Shell 骨干流程1——形成初步命令

总体流程图镇楼:

shell执行流程

Shell 骨干流程1——形成初步命令

我们将第1-4步分为第一大步,其主要作用是形成初步命令。在流程图中为橙色部分,其主要处理流程为元字符分割为标记、shell命令解析、shell命令解析、别名展开。元字符将命令分割为标记,做为后续处理的准备工作。引号处理分为单引号、双引号和反斜杠的处理。shell命令解析这步比较复杂,涉及保留字,命令组合方式,条件,循环等内容,我们放到单独的文章里讨论。最后别名展开算是正式处理命令的第一步。

元字符分割输入内容为标记

首先,对于元字符有哪些这个问题,我在查询资料时,发现资料的说法并不统一。于是,我查看了Bash的源码,如下所示:

1#define shell_meta_chars   "()<>;&|"
2#define shell_break_chars  "()<>;&| \t\n"

按照这个定义元字符应该是()<>;&|这几个,但是bash源码中又在下一行定义了“shell_break_chars”,添加了space( ), tab(\t), newline(\n)这三个元素。在源码实操分割的语法分析器(y.tab.c文件)中,使用的是“shell_break_chars”,因此第1步应该是通过"()<>;&| \t\n"这几个元素来分割读取的内容。需要特别指出:引号包裹的部分会作为一个整体来处理,在1-4步中不需要额外处理,后面会有内容展开的步骤。分割完成后的标记分为单词word或操作符operator两种类型。

单词word,就是按照字面意思的一串字符,一般的单词之间是没有blank(包含空格space和制表符tab)和其他元字符的,但是在如果存在引号,则可以有blank和元字符,且引号中内容作为一个单词来处理。单词word还包括等号,数字,算术表达式。操作符operator则包含了控制字符和重定位符,操作符至少由一个元字符组成。我在Bash Reference Manual第二节看到有关操作符的内容,总觉得实际上应用的操作符比书中第二节列出的内容要多,于是我要翻了翻源码,找到了bash中操作符的内容,如下:

 1/* other tokens that can be returned by read_token() */
 2STRING_INT_ALIST other_token_alist[] = {
 3  /* Multiple-character tokens with special values */
 4  { "--", TIMEIGN },
 5  { "-p", TIMEOPT },
 6  { "&&", AND_AND },
 7  { "||", OR_OR },
 8  { ">>", GREATER_GREATER },
 9  { "<<", LESS_LESS },
10  { "<&", LESS_AND },
11  { ">&", GREATER_AND },
12  { ";;", SEMI_SEMI },
13  { ";&", SEMI_AND },
14  { ";;&", SEMI_SEMI_AND },
15  { "<<-", LESS_LESS_MINUS },
16  { "<<<", LESS_LESS_LESS },
17  { "&>", AND_GREATER },
18  { "&>>", AND_GREATER_GREATER },
19  { "<>", LESS_GREATER },
20  { ">|", GREATER_BAR },
21  { "|&", BAR_AND },
22  { "EOF", yacc_EOF },
23  /* Tokens whose value is the character itself */
24  { ">", '>' },
25  { "<", '<' },
26  { "-", '-' },
27  { "{", '{' },
28  { "}", '}' },
29  { ";", ';' },
30  { "(", '(' },
31  { ")", ')' },
32  { "|", '|' },
33  { "&", '&' },
34  { "newline", '\n' },
35  { (char *)NULL, 0}
36};

果然,比Bash Reference Manual所述内容多了不少,还是实际代码中最全啊。从操作符的“other_token_alist”列表可以看出,元字符组成的操作符会在此列表中查找,将符合列表内容的多个元字符组成符号会被当成一个操作符。例如,ll $(type -path cc) ~/.*$(($$%1000)) >> /tmp/test.txt 2>&1会被分割为:ll, $, (, type, -path, cc, ), ~/.*, $, (, (, $$%1000, ), ), >>, /tmp/test.txt, 2, >&, 1。单词和操作符都被认为是一个独立单元,称为标记(token)。每一个标记都会被单独的处理。

引号处理

shell的引号处理作用是去除特殊字符或单词的特殊作用。比如,在shell中,'&'通常会被用来表示后台处理命令或在重定向时表示后面的数字为打开的文件描述符,为了能直接按字面意思显示'&'符号,需要我们使用“引用”的方式。shell中存在三种引用机制:转义符(\),单引号(')和双引号(")。还有一种特殊的引用,注释(#),这个大多数人应该都了解,就不特别说明了。

转义符

转义符,我们通常用反斜杠''表示,它的作用是将紧跟后面的一个字符保留原来的字面的意思。在shell中,元字符和通配符通常代表特殊的含义,如果我们想让这些字符按照普通字符显示显示出来,就需要用到转义符了。比如,我们想在shell中输出$' " * ? \ ~ ` ! # $ & |$,这类特殊字符,直接用echoprintf输出肯定是不行的,必须加上转义符号:

1$ echo \' \" \* \? \\ \~ \` \! \# \$ \&  \|
2' " * ? \ ~ ` ! # $ & |
3$ printf \'\"\*\?\\\~\`\!\#\$\&\|
4'"*?\~`!#$&|
5# 如果想让printf也空一格输出,需要在每个字符后面加上空格的转义,即\+SPACE
6$ printf \'\ \"\ \*\ \?\ \\\ \~\ \`\ \!\ \#\ \$\ \&\ \|
7' " * ? \ ~ ` ! # $ & |

实际上,转义符不仅仅对特殊字符有效,对一般的字符也是一样的作用,只不过一般字符的字面意思就是字符本身,所以转义符加了和没加一样。

1$ echo \a\b\c\d\e\f\g\h\i\j\k\l\m\n\o\p\q\r\s\t\u\v\w\x\y\z
2abcdefghijklmnopqrstuvwxyz
3$ echo \1\2\3\4\5\6\7\8\9\0
41234567890
5$ echo \,\;\[\]\{\}\-\@\%
6,;[]{}-@%

虽说,转义符会保留字符原来的字面的意思,但是在键盘上有个按键不同,就是newline字符(即键盘上的回车/换行)。shell会将转义符\+newline的组合忽略,这种机制方便了我们在输入时进行换行,而在执行时仍旧是一行,多用于很长的命令,如下:

1$ find /etc -exec grep\
2>'[0-9][0-9]*[.][0-9][0-9] \
3> *[.][0-9][0-9]*[0-9][0-9]*' {} \;

由于find命令很长且可以明显的分割为多个部分,因此,我们使用转义符\+newline让输入的时候命令更易读,实际执行起来,等同于find /etc -exec grep '[0-9][0-9]*[.][0-9][0-9] *[.][0-9][0-9]*[0-9][0-9]*' {} \;

ANSI-C引用

转移符还有另一种用法,源自于C语言的字符串输出格式,叫做ANSI-C引用。对于有C语言经验的人来说,这种用法很容易理解。在这种用法下,转义符“\”和后面的一些字母组合起来会有特殊的含义。比如“\n”会被替换成换行,“\t”会被替换成tab,“\b”会被替换成退格(删除前一个字符)等等。如果想输出“\n,\t”这些字符,要先将转义符转义,再将字母转义,即写成“\\n,\\t”的形式。

在ANSI-C模式下,转义符特殊使用方法如下表:

字符 含义
\a 响铃。
\b 退格。
\e、\E 看作一个转义字符(这不符合 ANSI C 的标准)。
\f 换页。
\n 新一行。
\r 回车。
\t 水平制表符。
\v 垂直制表符。
\\ 反斜线。
' 单引号。
" 双引号。
? 问号。
\nnn 值是八进制 nnn 的八比特字符。
\xHH 值是十六进制 HH 的八比特字符。
\uHHHH 值是十六进制 HHHH 的 Unicode(ISO/IEC 10646)字符。
\UHHHHHHHH 值是十六进制的 HHHHHHHH 的 Unicode(ISO/IEC 10646)字符。
\cx 表示 control-x 字符。

shell中常见的有三个命令会使用ANSI-C模式。

  1. echo -e 字符串
  2. printf FORMAT [ARGUMENT],注意只有用单/双引号引起来的FORMAT,才会使用ANSI-C模式;
  3. $'字符串',注意这里$后面一定是单引号。

举例子如下:

 1$ echo -e 'a\tb\n' # echo命令默认有一个换行,因此会多一个空行
 2a       b
 3
 4$ printf '%s\t%s\n' a b # 必须用引号
 5a       b
 6$ printf %s\t%s\n a b # 没有引号不行,下面由于没有换行,所以输出内容和$符号挤到了一行。
 7atbn$ printf 'a%sb\n' \n # 在参数[ARGUMENT]里面不行,必须在[FORMAT]里面。
 8anb
 9$ echo $'a\tb'
10a       b
11$ echo $"a\tb\n" # 双引号不行
12a\tb\n

单引号

我们可以使用单引号引用字符串,单引号内的任何字符都会保持其字面意思。单引号不能出现在单引号引用中,即使前面加了反斜线也不行。注:$'字符串'属于命令扩展,并非单纯的单引号引用。

1$ echo  'a\tb\n'
2a\tb\n
3$ echo '\\\\ $ ! * ? "'
4\\\\ $ ! * ? "
5$ echo '\' ' # 不能出现单引号
6> '
7\

双引号

我们可以使用双引号引用字符串,在双引号(' " ')内的大多数字符保留字符的字面含义,但'$','`','\',以及在启用历史记录扩展后,'!'这四个除外。在双引号中可以使用单引号,也可以在转义符后面加双引号或其他特殊字符表示其字面意思(\",\\,\$,\`,\!)。由于双引号可以将变量、子shell命令、历史记录扩展以及其他特殊字符带入其中,因此双引号比单引号使用起来更加灵活。

1$ VAR="a variable"
2$ echo "This is $VAR" # 变量扩展
3This is a variable
4$ echo "list path `pwd`" # 子命令扩展
5list path /home/lelouch
6$ echo "' \" \` \$ \\" #特殊字符使用,单引号不用转义
7' " ` $ \
8$ echo "!$" # 输出上一个执行的命令内容
9echo ""' \" \` \$ \\""

shell命令解析

shell中最常见的是简单命令,我们直接在shell中输入的一般是简单命令,有shell单步执行,返回结果,如ls、echo "hello world"、cat /etc/hosts。简单命令是组成shell的基石,其他更复杂的命令也是由简单命令组成。

简单命令是由一系列有blanks分割的一组单词(word),并以某个shell操作符(operator)结束,首个单词通常表示需要执行的命令名称/别名,后面的单词都是该命令/别名的参数。

  • blank:包含空格space和制表符tab
  • control operator, 控制符:包含newline, '||', '&&', '&', ';', ';;', ';&', ';;&', '|', '|&', '(', ')'

简单命令组成复杂命令的方式一般有管道、命令列表、复合命令等。

管道

管道(Pipeline)操作符为“|”,是一系列将标准输入输出链接起来的进程,其中每一个进程的输出被直接作为下一个进程的输入。管道中的组成元素也被称作过滤程序。这个概念是由道格拉斯·麦克罗伊为Unix 命令行发明的,因与物理上的管道相似而得名。

这是来自Wikipedia的定义。定义中指出,默认情况下,管道只会将上一个程序的标准输出(stdout),传递给下一个命令,作为标准的输入(stdin),对标准错误(stderr)信息没有直接处理能力。最后的命令将会把标准输出和标准错误都输出到屏幕上。画个简图来描述他们的关系:

管道命令示意图

注意:

  1. 管道命令只处理前一个命令正确输出,不处理错误输出。
  2. 管道命令右边命令,必须能够接收标准输入流命令才行。
  3. 管道触发两个子进程分别执行"|"两边的程序;而重定向是在一个进程内执行。
  4. 如果使用|&,则表示命令1的标准错误和标准输出都作为命令2的标准输入,这是2>&1 |的简写。

总结来说,管道是将命令串成一串,依次执行力,上一个命令的输出作为下一个命令的输入,更多关于管道的内容可以查看《linux-管道pipe与xargs》。

命令列表

命令列表是由简单命令和||,&&,&,;四种符号组成的命令,简单命令之间通过上述四种符号进行分割。有时候,在直接写shell脚本时,也可以用newline代替;从而让内容更加工整。

  • && 用法command 1 && command 2,表示当command 1执行成功后,再执行command 2,如果不成功,则不执行command 2
  • || 用法command 1 || command 2,表示若command 1执行失败,则执行command 2,如果执行成功,则不执行command 2
  • ; 表示一条命令输入结束,命令按顺序执行。
  • 表示后台执行&之前的命令,此时如果有多个命令,后台执行的命令和其他命令是异步的。

从运算优先级来看,&&,||处于同一优先级,&,;处于次一优先级。命令列表的使用例子如下:

 1$ cd /tmp; pwd; cd /dev/ && pwd
 2/tmp
 3/dev
 4$ cd /root && pwd # 无法cd到/root目录,因此&&后面的pwd没有执行
 5-bash: cd: /root: Permission denied
 6$ cd /root || pwd # 无法cd到/root目录,发生错误后||后面的pwd得以执行
 7-bash: cd: /root: Permission denied
 8/dev
 9$ echo 'I am 1'& echo 'I am 2' & echo 'I am 3' # 前两个命令后台异步执行,最后一个命令前台执行。
10[1] 23766
11[2] 23767
12I am 3
13I am 2
14I am 1
15[1]-  Done                    echo 'I am 1'
16[2]+  Done                    echo 'I am 2'
17

此外,除了&符号,GNU还提供了parallel命令来并行执行命令,但这部分超出了shell本身的内容,有兴趣的读者可以阅读parallel的相关文档

复合命令

复合命令是通过shell保留字和简单命令组合形成的组合命令,算是shell脚本语言的基本结构。每一个复合命令结构都由每一个保留字或控制符开始,并以对应的保留字或控制符结束,这算是shell编程语言的一个特色,例如以if开头,fi结尾;do开头,done结尾等等。对shell复合命令结构的输入、输出重定向将被应用到该结构的每一个简单命令中,除非其中有某个简单命令用显式的重定向覆盖该结构的重定向。写shell语言时,为了保证代码可读性,在复合命令结构之间通常用newline来分割,而不是用;来分割,虽然从语法上来讲,二者作用是一样的,但是过长的代码会给后续的维护造成困难。The Bourne Again SHell(bash)提供循环、条件、组命令、协同四种流程控制复合命令,以及函数复合命令,并用相应的保留字指示bash是哪一种复合命令。bash的保留字如以下表格所示:

if then elif else fi time
for in until while do done
case esac coproc select function
{ } [[ ]] !

总体处理流程上,shell会先检查第一个标记,如果是可前置保留字(if, for, while, until, case, select, time, function, coproc, {, [[, !),则开启复合命令流程;如果是非可前置保留字(then, elif, fi, in, do, done, esac, }, ]]),且之前没有与之对应的可前置保留字,则报语法错误;如果不是保留字则当作简单命令,执行下一步骤。

我们将在《shell-骨干流程2——复合命令与控制流程》中详细的讨论复合命令。

别名(alias)展开

经过命令解析步骤,可以将任意命令都分解成简单命令,剩下的步骤都是针对简单命令,一条条处理。首先要做的是别名展开,主要做的是检查简单命令的第一个标记(token)是否为别名(alias),如果是则展开别名(alias)。shell维护了一个“alias”列表,我们可以通过shell的内置命令alias查看现有的别名列表:

1$ alias
2alias l='ls -CF'
3alias la='ls -A'
4alias ll='ls -l'
5alias ls='ls --color=auto'

我们可以通过alias 别名名称='别名内容'的方式在当前shell环境中临时添加别名(该shell关闭后,临时添加的别名失效),也可以在任意shell环境文件(如/etc/environment, /etc/profile, ~/.bash_profile, ~/.bash_login, ~/.profile, ~/.bashrc, /etc/bashrc, /etc/bash.bashrc)中永久添加别名。如果别名名称重复,则会覆盖原有别名。也可以通过unalias 别名名称取消某个别名。

1$ alias hello='cd /etc/ && pwd'
2$ hello
3/etc
4$ alias hello='whoami'
5$ hello
6lelouch
7$ unalias hello
8$ hello
9-bash: hello: command not found

展开别名本质上是一个文本替换的过程。比如,我们输入ll /etc/,shell就会拿着第一个单词"ll",查询alias列表,发现有一项alias ll='ls -l',就会将ll替换成ls -l,命令变成ls -l /etc/。然后,回到第1步,经过元字符分割为标记、shell命令解析、shell命令解析,再到展开别名这步,拿着第一个单词"ls",查询alias列表,又发现有一项alias ls='ls --color=auto',替换ls -l /etc/ls --color=auto -l /etc/。再回到第1步,经过2,3两步,到展开别名这步,这次首单词还是“ls”。但是shell的alias有一个规定,之前展开过的别名不能二次展开,这是为了防止出现循环展开的情形,因此shell不会再对ls进行处理,完成这一步工作。

有人会有一个疑惑,为什么展开别名后,还有回到第1步,重新执行1-3步?

这是因为,我们对别名的名称有限制(后面再说),但是对别名的内容基本没有限制。因此别名的内容可以包含多条简单命令、复合命令、元字符、引号等等,所以别名展开后,还需要对这些内容进行再次处理。

此外,从shell处理流程图中,我们能够发现别名替换是在命令执行之前完成的,如果我们在一个复合命令中使用alias定义了某个别名,并立即在该复合命令中使用,那么实际上是不生效的,因为复合命令没有执行完,别名未被写入系统环境。

1alias dog='echo "Here is a dog"' ; dog # 复合命令中定义别名,后面的命令无法使用别名
2-bash: dog: command not found
3$ dog # 当上一个复合命令执行完,别名才被写入系统
4Here is a dog

这个问题在函数中尤其明显,因为shell在函数定义阶段并不执行,只有当实际使用的时候才会执行函数内容,而shell的函数执行流程在不同版本软件(bash,csh,zsh,sh)中多少有些不同,因此为了安全起见,不要在复合命令中使用alias

关于shell的别名还有两个细节问题。一是之前提到的别名的名称有限制,任何元字符、引号字符(‘'’,‘"’,‘\’)以及‘/’, ‘$’, ‘`’, ‘=’都不可以出现在别名名称中。二是在shell脚本中使用别名。别名默认只在交互式shell中启用,shell官方不赞成在脚本中使用别名,而希望使用Shell函数。

参考内容

https://www.gnu.org/software/bash/manual/bash.html