Linux基础系列文章大纲
Shell系列文章大纲


Bash命令行解析和eval★★★

当敲下命令行后,命令并非直接就执行起来,中间还会经历一些事情,比如Shell解析语法是否正确。

总之,命令行解析,是深入Shell和Shell脚本的必经之路,也是一个为未来写命令行、写脚本节省大量时间和精力的重要知识点。

特殊符号优先级

  1. 重定向属于各个命令
  2. 管道连接两个命令
  3. && || ;优先级相同
  4. 小括号、大括号可以将命令组合成一个整体,但它们有特殊意义:
  • 小括号使得命令在子Shell环境下执行
  • 大括号使得命令在当前Shell环境下执行

例如:

1
2
3
4
# 重定向属于第二个命令,不属于第一个命令或命令整体
echo haha | echo hhh >/tmp/a.log
lsdasd | echo hehe 2>/dev/null # 仍然会报错
lsdasd 2>/dev/null| echo hehe # 不会报错

命令生命周期概述

先从全局的角度了解一下命令的生命周期。即一个命令从『出生』到『消亡』中间经历了哪些事。

比如对于下面的echo命令行,shell做了哪些事情?

1
2
var='hello world'
echo -e $var $(((2+3)/5))
  1. 读取命令行
  2. 解析命令行:发现有变量引用$var,于是将其替换成变量的值hello world,发现有数学运算,于是将其替换成对应的值1,所以替换后得到echo -e hello world 1命令行
  3. 命令行解析完成后,调用命令:
    • 创建一个子shell进程,父shell进程被阻塞,它要等待子进程的退出,并且此时子进程获得终端控制权
    • 在子shell进程中通过exec加载磁盘中的echo命令
    • exec加载命令时,会搜索echo命令,然后调用它,于是替换子shell进程并得到echo进程
  4. echo进程开始执行,它要识别选项和参数,于是输出『hello world 1』到终端
  5. echo进程退出,并记录一个退出状态码
  6. echo退出后就回到了shell进程,shell进程会去读取子进程的退出状态码,shell进程读完echo进程记录的退出状态码后,echo进程完全消失,shell进程准备执行下一个命令

详细分析命令行解析的过程

整个命令行解析的过程如下图所示:

对于下面的echo命令:

1
2
3
4
name="junma"
a=20
touch ~/i{a,b}.sh
/bin/echo -e "some files:" ~/i* "\nThe date:$(date +%F)\n$name's age is $((a+4))" >/tmp/a.log

涉及到的过程:

1.读取命令行,并将读取的字符内容交给词法解析器
2.词法解析阶段

  • (1).解析引用(即识别双引号、单引号和反斜线),并根据空白符号和bash元字符,将读取的内容划分成token(在Shell语法中也成为word)
    • 划分token的元字符有:| & ; ( ) < > space tab
    • 解析引用是为了防止被引用的整体部分被分割成多个token
    • 比如echo "ls|cat"不会因为里面有竖线就将引号包围的部分划分成多个token
  • (2).根据控制元字符,将复杂命令结构划分成简单命令结构
    • 即将多个或复杂的命令行,划分成简单的一个一个命令
    • 控制元字符有:|| & && ; ;; ( ) | |& <newline>
  • (3).检查第一个token:
    • 如果第一个token是别名,则进行别名扩展
      • 别名替换本不该是词法解析阶段完成的,因为涉及了Bash自身的语法支持,但因为别名扩展会直接影响命令行结构,所以在词法解析阶段处理它才更合理
    • 如果第一个token是带有等号=且等号前的字符符合变量命名规范,则本条命令是一个变量赋值
    • 如果是shell函数、shell内置命令、shell保留关键字,则做相应处理

因为上面的命令行中,没有复杂命令结构,只是单个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
  • (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
2
3
4
5
touch "aa aaa.txt"
touch "bb bbb.txt"
for i in *.txt;do
echo $i
done

因为在单词分割时,*.txt还没有扩展,等到路径扩展时,aa aaa.txt自然会被作为一个元素整体。

而下面代码是有问题的,因为命令替换在单词分割之前:

1
2
3
4
5
touch "aa aaa.txt"
touch "bb bbb.txt"
for i in $(ls *.txt);do
echo $i
done

改进方式是修改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进程)

命令退出后回到父Shell,父Shell去获取命令退出状态码并赋值给变量$?,然后就可以执行下一条命令。

理解Shell中的单双引号

对于Shell中引号的使用,只有两个结论:

  1. 引号要配对,否则在词法分析的划分token阶段会一直等待用户提供更多引号来完成命令行
  2. 单引号和双引号以及反斜线的效果:
  • (1).反斜线:使反斜线后一个字符变为普通的字面字符
  • (1).双引号:双引号内所有字符变为字面符号,但\、$、`(反引号)除外,如果开启了!引用历史命令时,则感叹号也除外。所以:
    • 双引号内可以执行命令替换、变量替换、反斜线转义、算术运算等,但不能执行大括号扩展、波浪号扩展、通配符路径扩展等
    • 双引号内可用反斜线转义双引号本身,使得双引号变成普通字符,例如echo "hel\"lo"
  • (2).单引号:单引号内的所有字符全部变为字面符号符号。但注意:单引号内不能再使用单引号,即使使用了反斜线转义也不允许
    • 所以,单引号内所有Shell扩展和替换都不执行

例如:

1
2
# 输出单引号
echo "hello'world"

在Shell开始词法分析时,首先读取到双引号,于是会继续向后读取,直到遇到另一个配对的双引号作为引用结束,其中中间会读到单引号,它是在双引号范围内的,所以它不需要配对。

其它示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 输出双引号
echo 'hello"world'
echo "hello\"world"

# 单双引号混用:awk单引号隔开第一、第二字段
echo hello world | awk '{print $1"'"'"'"$2}'
echo hello world | awk "{print \$1\"'\"\$2}"
echo hello world | awk '{print $1"\047"$2}'

# sed或awk使用Shell中的变量
line=`cat /etc/fstab | wc -l`
sed -n $$((line-2))'p' /etc/fstab
sed -n "$((line-2))p" /etc/fstab

# sed中使用命令替换
sed -n 's/'$(hostname -I)'/0.0.0.0/' a.txt
sed -n "s/$(hostname -I)/0.0.0.0/" a.txt

eval命令

如果发现要执行的命令是eval命令,则会回到第一步从头开始解析(但移除eval这个token)。

所以,eval命令有二次解析的功能:第一轮解析已经将该扩展的扩展该替换的替换了,第二轮还可以再次扩展替换。

例如:

1
2
3
a=name
nama=junmajinlong
eval echo \$$a

第一轮解析后得到的命令行为eval echo $name,然后eval会让Shell再次解析命令行,于是得到echo junmajinlong