Shell脚本深入教程:Bash解析命令行和eval命令★★★
Bash命令行解析和eval★★★
当敲下命令行后,命令并非直接就执行起来,中间还会经历一些事情,比如Shell解析语法是否正确。
从敲下命令行到调用命令并开始执行,中间Shell进程对所敲下命令做的事,就是Shell对命令行的解析。
命令行解析,是深入Shell和Shell脚本的必经之路,也是一个为未来写命令行、写脚本节省大量时间和精力的重要知识点。
特殊符号优先级
- 重定向属于各个命令
- 管道连接两个命令
&& || ;
优先级相同- 小括号、大括号可以将命令组合成一个整体,但它们有特殊意义:
- 小括号使得命令在子Shell环境下执行
- 大括号使得命令在当前Shell环境下执行
例如:
1 | 重定向属于第二个命令,不属于第一个命令或命令整体 |
命令生命周期概述
先从全局的角度了解一下命令的生命周期。即一个命令从『出生』到『消亡』中间经历了哪些事。
比如对于下面的echo命令行,shell做了哪些事情?
1 | var='hello world' |
- 读取命令行
- 解析命令行:发现有变量引用
$var
,于是将其替换成变量的值hello world
,发现有数学运算,于是将其替换成对应的值1,所以替换后得到echo -e hello world 1
命令行 - 命令行解析完成后,调用命令:
- 创建一个子shell进程,父shell进程被阻塞,它要等待子进程的退出,并且此时子进程获得终端控制权
- 在子shell进程中通过exec加载磁盘中的echo命令
- exec加载命令时,会搜索echo命令,然后调用它,于是替换子shell进程并得到echo进程
- echo进程开始执行,它要识别选项和参数,于是输出『hello world 1』到终端
- echo进程退出,并记录一个退出状态码
- echo退出后就回到了shell进程,shell进程会去读取子进程的退出状态码,shell进程读完echo进程记录的退出状态码后,echo进程完全消失,shell进程准备执行下一个命令
详细分析命令行解析的过程
整个命令行解析的过程如下图所示:
对于下面的echo命令:
1 | name="junma" |
涉及到的过程:
1.读取命令行,并将读取的字符内容交给词法解析器
2.词法解析阶段:
- (1).解析引用(即识别双引号、单引号和反斜线),并根据空白符号和bash元字符,将读取的内容划分成token(在Shell语法中也成为word)
- 划分token的元字符有:
| & ; ( ) < > space tab
- 解析引用是为了防止被引用的整体部分被分割成多个token
- 比如
echo "ls|cat"
不会因为里面有竖线就将引号包围的部分划分成多个token
- 划分token的元字符有:
- (2).根据控制元字符,将复杂命令结构划分成简单命令结构
- 即将多个或复杂的命令行,划分成简单的一个一个命令
- 控制元字符有:
|| & && ; ;; ( ) | |& <newline>
- (3).检查第一个token:
- 如果第一个token是别名,则进行别名扩展
- 别名替换本不该是词法解析阶段完成的,因为涉及了Bash自身的语法支持,但因为别名扩展会直接影响命令行结构,所以在词法解析阶段处理它才更合理
- 如果第一个token是带有等号
=
且等号前的字符符合变量命名规范,则本条命令是一个变量赋值 - 如果是shell函数、shell内置命令、shell保留关键字,则做相应处理
- 如果第一个token是别名,则进行别名扩展
因为上面的命令行中,没有复杂命令结构,只是单个echo命令行,而且第一个token没有别名,所以,划分token后的结果如下:
3.word扩展阶段(各种Shell扩展和替换)
称为word扩展是因为下面这些操作都可能会改变word(即token)的数量。
所谓扩展或替换,指的是Shell会分析各个token中的某些特殊符号,并进行对应的值替换。
Shell按照下面列出来的先后顺序进行各种扩展行为:
- 大括号扩展
- 波浪号扩展
- 变量替换
- 算术替换
- 命令替换
- 单词拆分
- 引号移除
此外,对于支持命名管道的Shell,还支持进程替换。因为进程替换中的命令是异步执行的,而且它不会将执行结果替换到命令行中,而是以虚拟文件的方式作为命令的标准输入或标准输出,所以不要考虑进程替换在哪个阶段执行,这没有意义。尽管官方手册说,进程替换可能在波浪号扩展、变量替换、算术扩展、命令替换这四个阶段的任何一个阶段执行。
下面是各个扩展阶段的分析:
- (1).大括号扩展
- 例如
echo hey{1..3}
在这一阶段替换后变成echo hey1 hey2 hey3
- 例如
- (2).波浪号扩展,最常见的是
~
扩展成家目录,此外还有~+、~-
等也是波浪号扩展- 例如,对于root用户执行的命令
ls ~/.ssh ~/.bashrc
来说,在这一阶段替换后会得到ls /root/.ssh /root/.bashrc
- 例如,对于root用户执行的命令
- (3).变量替换,最常见的是将变量的值替换到变量引用位置处,此外还有各种变量操作也是变量扩展
- 例如,
ls /$USER
在这一阶段替换后变成ls /root
- 再例如,
echo ${#USER}
在这一阶段替换后变成echo 4
- 例如,
- (4).算术替换,即将算术运算的评估结果替换到算术表达式位置处
- (5).命令替换,即执行命令替换中的命令,并将命令的标准输出替换在命令替换位置处
- 例如,
echo $(hostname -I)
在这一阶段替换后会变成echo 192.168.100.11
- 如果命令替换的命令有多行,则默认会压缩成单个空格。可使用双引号保护命令替换的结果
- 例如
echo $(echo -e 'a\nb')
会替换成echo a b
echo "$(echo -e 'a\nb')"
会替换成echo $'a\nb'
- 例如,
- (6).单词拆分(word splitting)
- Shell重新扫描变量替换、算术扩展、命令替换后的结果,如果这三种替换是使用双引号包围的,则不会拆分开,如果它们没有使用双引号包围,则根据IFS变量的值再次对它们划分单词
- 例如
n="name age";test $n -eq "name age"
是错的,因为单词拆分后得到test name age -eq "name age"
,这会语法报错,但如果加上双引号包围"$n"
,则得到test "name age" -eq "name age"
- 如果没有变量替换、算术扩展、命令替换,则不会执行单词拆分
- (7).路径名扩展,也即通配符扩展
- 通配符包括
* [] ?
- 例如/root下有ia.sh和ib.sh文件,那么
ls /root/i*.sh
,路径扩展后命令变成ls /root/ia.sh /root/ib.sh
- 通配符包括
- (8).引号去除,即移除为了保护Shell解析的那一层引号
- 命令在开始执行之前,所有不需要的引号(即Shell层次的引号)都会被移除
- 例如
cat "/proc/self/cmdline"
查看到的结果是cat/proc/self/cmdline
整个扩展过程如下所示:
关于word splitting和路径扩展,有一个注意事项:
1 | touch "aa aaa.txt" |
因为在单词分割时,*.txt
还没有扩展,等到路径扩展时,aa aaa.txt
自然会被作为一个元素整体。
而下面代码是有问题的,因为命令替换在单词分割之前:
1 | touch "aa aaa.txt" |
改进方式是修改IFS的值:
1 | (IFS=$'\n';for i in $(ls *.txt);do echo $i;done) |
当Shell处理完各种Shell扩展之后,意味着Shell的解析完成了,接下来准备让命令运行起来。
4.搜索命令并执行
Shell首先判断第一个token(即命令):
- (1).如果命令中不含任何斜杠:
- 先判断是否有此名称的shell function存在,如果有则调用它,否则进行下一步搜索
- 判断该命令是否为bash内置命令,如果是则执行它,如果不是,则当作外部命令处理
- (2).如果命令中包含一个或多个斜杠,则当作外部命令处理
如果发现要执行的是外部命令:
- (1).Shell通过fork创建一个子shell进程,然后父Shell进程自身进入阻塞并等待子进程终止,同时会让出终端的控制权
- (2).子Shell进程通过exec去调用外部命令并替换当前子Shell进程
- exec调用外部命令时,会搜索命令,如果token中包含了斜线,则从相对路径或绝对路径中查找,否则从
$PATH
中搜索,如果找不到,则报错 - 替换子Shell进程后,就不再称为子Shell进程,而称之为对应命令的进程(比如echo进程)
- exec调用外部命令时,会搜索命令,如果token中包含了斜线,则从相对路径或绝对路径中查找,否则从
命令退出后回到父Shell,父Shell去获取命令退出状态码并赋值给变量$?
,然后就可以执行下一条命令。
eval命令
如果发现要执行的命令是eval命令,则会回到第一步从头开始解析(但移除eval这个token)。
所以,eval命令有二次解析的功能:第一轮解析已经将该扩展的扩展该替换的替换了,第二轮还可以再次扩展替换。
例如:
1 | a=name |
第一轮解析后得到的命令行为eval echo $name
,然后eval会让Shell再次解析命令行,于是得到echo junmajinlong
。