Shell 骨干流程0——流程总述

Shell 骨干流程

我们在进行shell变成或使用CLI shell进行交互时,shell背后进行了复杂的处理流程。借由理清这个处理流程,会给我们对shell有更加深刻的认知。

shell关键概念中英文

为了更清楚的描述,我们给出如下shell中常用的中英文定义:

  • blank:包含空格space和制表符tab
  • control operator, 控制符:包含newline, '||', '&&', '&', ';', ';;', ';&', ';;&', '|', '|&', '(', ')'
  • field, 字段:shell扩展之一带来的文本单位。扩展后,当执行命令时,生成的字段将用作命令名称和参数
  • job, 作业:在同一个进程组中的一系列进程,可由管道或衍生的进程组成
  • job control, 作业控制:一种机制,用户可以通过该机制选择性地停止(挂起)并重新开始(恢复)进程的执行。
  • metacharacter, 元字符:当不在引号中时,用于分割单词的字符,包括space, tab, newline, '|', '&', ';', '(', ')', '<', '>'
  • operator, 操作符:分为控制符或重定向符,操作符由至少一个元字符组成
  • process group, 进程组:一系列具有相同组进程ID的进程
  • reserved word, 保留字:对shell具有特殊含义的单词。,大多数保留字用于流程控制,如for, while, if
  • signal, 信号:一种机制,内核可以通过该机制将系统中发生的事件通知给进程
  • token, 标记:可以被shell认为是一个独立单元的一串字符,分为单词word或操作符operator
  • word, 单词:可以被shell认为是一个单元的一串字符,单词不能包括不带引号的元字符。

Shell处理流程图

我们首先给出shell的执行流程图,接下针对每一个步骤进行详细说明。

shell执行流程

大体流程

  1. 从文件、用户终端或其他唤起shell的方法中读取输入,通常shell会按行处理,如果有复合命令和多行命令符号则另加处理步骤。
  2. 根据元字符(space, tab, newline, '|', '&', ';', '(', ')', '<', '>')将输入的内容分割成各个标记(单词word或操作符operator),其中单词包括普通单词和保留字,操作符包括控制符或重定向符。
  3. 检查第一个标记(token)是否为引号(包括单引号,双引号,反斜杠),如果有引号则跳过部分流程。(引号处理)
  4. 检查第一个标记(token)是否为保留字(关键字),决定是否启用复合命令流程。(流程控制)。
  5. 检查第一个标记(token)是否为别名(alias),如果是则展开别名。
  6. 展开命令中的大括号。
  7. 展开波浪符号,即得到HOME_PATH。
  8. 参数展开,为${...}的展开,以及$Varname的替换。
  9. 命令替换"``"或者$(...)使用子shell执行。
  10. 计算算术表达式。
  11. 将之前展开的命令、替换的命令根据分隔符再一次切割,然后重组成真正可以执行的命令。
  12. 根据通配符(*,?)等展开路径名和文件名。
  13. 根据重定向符号执行任何必要的重定向,之后参数列表中删除重定向运算符及其操作数。
  14. 根据扩展后命令的首个单词在$PATH和内建命令中查找可执行命令或文件。
  15. 执行命令,其中首单词为命令用$0表示,后面为此命令的参数。如果遇到文件结束符号EOF则完成shell流程,否则读取下一条命令从第1步再开始执行。

形成初步命令

我们将第1-4步分为第一大步,其主要作用是形成初步命令。在流程图中为橙色部分,其主要处理流程为元字符分割为标记、引号处理、shell命令解析、别名展开。具体内容见《shell-骨干流程1——形成初步命令》。

复合命令与流程控制

这一步发生在第3步,即检查标记是否为保留字这步,如果为合法保留字就需要组成复合命令。如图中红色部分所示。由于复合命令与形成初步命令往往是交互进行的,因此我并没有将其标注成独立的步骤,详见《shell-骨干流程2——复合命令与流程控制》。

命令展开

命令展开如图中绿色部分所示,包含5-11步。第5-7步都是各式各样的命令展开。基本的命令展开包括3种,分别为:大括号展开、波浪符号展开、参数与变量展开。这三种命令展开本质上是shell语法糖的展开。第8,9两步实际上是子命令执行,并非语法糖,原始命令将启动子进程(子shell)来执行子命令,执行的结果作为标记,嵌入到原命令中。5-7步和8-9步的区别在于,5-7步本质是查找语法糖对应的内容进行展开,无需使用子进程;8-9步本质是采纳子进程执行的结果,而非简单的查找替换。经过5-9步的处理,原始命令已经能够被shell直接执行,因此我们需要第10步将这些替换过后的内容重组起来,根据系统分隔符(Internal Field Seperator,IFS)再次分割(因为命令展开过程中会带来新的内容)。最后我们还需要第11步,展开路径和文件名,这一步和之前5-7步展开又是不同的,之前是语法糖替换,而这次是使用shell模式匹配方式(通配符)替换。第11步的shell模式匹配是正则表达式简化版,主要是利用通配符,并非完整的正则表达式规则。详见《shell-骨干流程3——命令展开》。

I/O与重定向

第12部分(紫色方框)是执行任何必要的重定向,并从参数列表中删除重定向运算符及其操作数。这部分涉及到进程标准I/O和/dev下的各种设备文件描述符,在文章《linux-从设备文件看重定向》中有详细介绍。

命令执行与job控制

第13,14步是真正的命令执行阶段。如图中蓝色部分所示。第13步是保证命令的存在及可执行性,在非复合命令中,首个单词(word)通常指的是需要执行的命令,后面的部分都是该命令的参数。最终在第14步执行命令+参数,并返回结果。如果存在未执行命令则读取下一条命令从头在开始处理,若遇到文件结束符(EOF)则完成shell流程。

作业控制(job control)是针对即将执行和正在执行命令的一套控制机制,也是shell流程中不可缺少的一部分。命令执行与job控制部分详见《shell-骨干流程4——命令执行与job控制》。

具体例子

为了更好的理解整体流程,我们使用https://se.ifmo.ru/~ad/Documentation/Bash_Shell/bash3-CHP-7-SECT-3.html中的例子对应上图中的步骤进一步讲解。

  1. 读取命令ll $(type -path cc) ~/.*$(($$%1000))
  2. ll $(type -path cc) ~/.*$(($$%1000))分割成不同的标记,此处分割为:ll, $, (, type, -path, cc, ), ~/.*, $, (, (, $$%1000, ), )
  3. 命令中不含有引号,无操作;
  4. ll 不是保留字,无操作;
  5. 检测到ll为别名,替换为ls -lls -l $(type -path cc) ~/.*$(($$%1000)),然后,从流程开始再执行一遍步骤1-3,在步骤1中将ls -l再分割为ls, -l两部分;
  6. 不含有大括号,无操作;
  7. 发现波浪符号,将~展开为/home/usernamels -l $(type -path cc) /home/username/.*$(($$%1000))
  8. 发现$$符号,将$$参数展开为当前进程号2537(根据实际情况,进程号都不相同),且ls -l $(type -path cc) /home/username/.*$((2537%1000))
  9. 发现$()符号,执行命令替换,开启子shell执行type -path cc,结果为/usr/bin/ccls -l /usr/bin/cc /home/username/.*$((2537%1000))
  10. 发现$(( ))算数运算符号,进行算术运算2537%1000=537,代入原命令:ls -l /usr/bin/cc /home/username/.*537
  11. 未发现新的分隔符(IFS),无需进行再分割,无操作;
  12. 发现通配符"*",进行展开得到.hist537文件,ls -l /usr/bin/cc /home/username/.hist537
  13. 未发现重定向操作符,无操作;
  14. 首个单词为“ls”,在$PATH,/usr/bin中检索到ls命令;
  15. 执行命令/usr/bin/ls,后面的-l /usr/bin/cc /home/username/.hist537为命令的参数,其作用为查看/usr/bin/cc /home/username/.hist537这两个文件的详细属性。此命令后不再有其他命令,结束此shell流程。

参考内容