Shell是如何执行命令的

命令的执行过程梳理

Posted by Haiming on September 30, 2020

从第一行的#!开始,Shell是如何执行命令的?

参考:https://linuxtoy.org/archives/shell-programming-execute.html#id3

#!开始

开头的声明是讲文本文件的内容是交给哪个解释器来解释的。

比如:#!/bin/bash,就是将文本文件的内容交给bash去自行。

但是不同的系统之中这个文件的位置可能不同,这种情况下该怎么去写解释器的路径呢?

 #!/usr/bin/env 脚本解释器名称

这就利用了 env 命令可以得到可执行程序执行路径的功能,让脚本自行找到在当前系统上到底解释器在什么路径。让脚本更具通用性。

上面这段怎么解释呢?

env 是Linux内部的一个命令,其会根据当前的环境变量设置(通常是PATH),查找对应的”脚本解释器名称“的位置,然后返回。这样就能保证找到对应的目录了。

程序如何解析开头的 #

一般而言,程序开头的这个 # 都是注释,那么按理讲,后面的内容不应该被识别出来啊?为什么还可以生效?

讲这个之前,我们要先讲一下fork,execsource的区别:

参考:https://zhuanlan.zhihu.com/p/51034728

http://xstarcd.github.io/wiki/shell/exec_redirect.html

  1. fork,意为分叉,其作用是新建一个child process, 这个新建的child process是parent process的一个副本,其从父进程那边获得一定的资源分配,并且继承父进程的环境,唯一的一个不同点在于pid(process id)。子进程和父进程相当于面向对象中一个对象的两个实例
  2. exec,其不产生新的process, 而是将老的process之中的内容清除换上新的部分的内容,相当于除了pid一样之外,其他的内容都不一样了,比如原来的进程的上下文,原来进程的代码段,数据段,堆栈等等,都被新的进程所代替。
  3. source,其也不产生新的进程,但是和exec之间的不同在于其在执行完被调用脚本之中的内容之后会继续执行调用脚本之中的内容。

那么实际上在脚本相互调用的过程之中,我们最主要还是使用fork和source,因为一般来说被调用的程序结束之后还是有代码需要执行的。

下面是各种方式之间值传递的不同:

  1. fork之中,调用脚本的全局变量(parent process)可以直接传入被调用脚本(children process),但是被调用脚本之中的变量是无法回传的。

  2. exec之中,调用脚本的全局变量(parent process)可以直接传入被调用脚本(children process),被调用脚本之中的值无所谓要不要回传,因为调用完成就直接结束了

  3. source之中,调用脚本和被调用脚本之中的变量可以相互传递。

下面这两张图我认为很形象。

img

img

讲完了进程之间三种函数的关系,下面从整个文件解析的步骤来分析。

#!的作用是什么?这个功能是由系统的程序载入器做的,Linux上除了一号进程之外,其他进程都可以看做是父进程fork出来的,然后再用exec()将对应的内容替换掉而已。#!就是在内核处理exec的时候进行解析的。

内核的调用过程如下:其处理exec函数的主要实现是在fs/exec.c之中,而其先对要加载的文件进行格式的判断,我们这里面写到的脚本(script)只是其中一种。而exec在确认了其格式之后,就会调用相应的script格式的load_binary方法:load_script进行处理。而#!就是在这个函数之中被解析的。解析到#!之后,内核会提取后面的程序路径,再传递给search_binary_handler()进行重新解析,这样最后就能找到可执行的二进制文件进行相关的执行操作。

总而言之,是先判断文件类型,再根据脚本类型提取#!之后的内容拿到对应的解释器,再最后将完整的程序交给解释器来解析。

所以#!必须写在第一行的前两个字符,因为内核之中已经写死了检查前两个字符,当内核选择好解释器之中会对其进行重新解释,但是在解释器之中,#开头的全是注释,当然第一行就会直接忽略了。

bash如何执行Shell命令

首先还是分割整个脚本:

  1. Bash 会以一些特殊字符来作为分隔符,将文本进行分段解析。主要的分隔符还是回车或者分号这种可以标识”一行”的符号,用来将大的命令进行分段
  2. 将大的命令进行分段之后,就要区分要执行的命令了。这一层主要解析的是管道|&&,||这种可以起到连接命令作用的特殊字符。在这一层解析之后,bash就能拿到最基本的一个个要执行的命令了。
  3. 拿到一个个要执行的命令之后,再进行解析就能拿到要执行的命令和参数,主要解析的是空格和tab等等。

绝大部分解析完的字符串,bash都是在fork之后将其传递给exec来执行,然后wait其执行完毕之后再解析下一行。

bash解释功能的顺序

  1. 别名:alias,自己设置的缩写,比如alias cat='cat -n'
  2. 关键字:keyword,主要是bash提供的语法,比如if,while等等
  3. 函数:function
  4. 内建命令:built in:依赖bash自身就能实现的命令,比如cd。
  5. 哈希索引:hash:针对外部命令,用于缩短在$PATH之中进行查找的时间而产生的,会直接从内置的hash表之中拿到对应的程序位置
  6. 外部命令:command:大部分我们在bash之中执行的都是外键命令,比如ls,find等等,其是作为一个可执行文件放在$PATH下面的,bash在执行的时候,都会进行fork(),exec()wait()直到最后结束。

bash脚本的执行退出

返回码逻辑上有两类,0 为真,非零为假。就是说,返回为 0 表示命令执行成功,非零表示执行失败。

如果是因为被打断而发生退出,那么会在对应的基础上加上128:

这一般是因为进程接收到了一个需要程序退出的信号。比如我们日常使用的 Ctrl+c 操作,就是给进程发送了一个 2 号 SIGINT 信号。考虑到程序退出可能性的各种可能,系统将错误返回码设计成 1-255,这其中还分成两类:

  1. 程序退出的返回码:1-127。这部分返回码一般用来作为给程序员自行设定错误退出用的返回码,比如:如果一个文件不存在,ls 将返回 2。如果要执行的命令不存在,则 bash 统一返回 127。返回码 125 和 126 有特殊用处,一个是程序命令不存在的返回码,另一个是命令的文件在,但是不可执行的返回码。
  2. 程序被信号打断的返回码:128-255。这部分系统习惯上是用来表示进程被信号打断的退出返回码的。一个进程如果被信号打断了,其退出返回码一般是 128+信号编号的数字。

比如说,如果一个进程被 2 号信号打断的话,其返回码一般是 128+2=130。如:

[zorro@zorrozou-pc0 bash]$ sleep 1000
^C
[zorro@zorrozou-pc0 bash]$ echo $?
130