bash命令的执行分为四大步骤:输入
、解析
、扩展
和执行
。
本文将详述bash命令的一般处理过程:
如图所示
输入
交互模式
在交互模式下,输入来自终端。bash使用GNU Readline库处理用户命令输入,Readline提供类似于vi或emacs的行编辑功能(如Ctrl+a
、Ctrl+e
等等)。
当敲击键盘时,字符会存入Readline的编辑缓冲区,Readline会处理输入的变化并及时地将结果显示到终端上。
Readline还要保持命令提示符(prompt
)的稳定(比如提示符的颜色)。
在将编辑缓冲区的内容交给bash之前,Readline会执行历史扩展
(见这里),之后由bash负责将本条命令存储到历史列表并进入下一步骤。
非交互模式
在非交互模式下,输入一般来自文件。此时,bash使用C语言标准库的stdio来获得输入。
不像Readline那样需要实现各种功能,stdio的工作较为简单:缓冲文件内容并逐行提供输入给bash处理。
解析
解析阶段的主要工作为:词法分析
和语法解析
词法分析
指分析器从Readline或其他输入获取字符行,根据元字符
将它们分割成word
,并根据上下文环境标记这些word
(确定单词的类型)。元字符
包括:
| & ; ( ) < > space tab
语法解析
指解析器和分析器合作,根据各个单词的类型以及它们的位置,判断命令是否合法以及确定命令类型。
单词(word
)有很多种,bash从左到右依次分析它们的类型。下面对一些情况做一下简介:
1、重定向
分析器分析每个单词,如果单词表示一个重定向,则保持至执行阶段再处理。
2、赋值语句
对于非重定向的首个单词进行分析,如果该单词是一个赋值语句,则保持至扩展阶段处理。
然后继续分析下一个单词,对于连续的赋值语句
或重定向
都做如上处理。
3、关键字
对于非重定向或赋值语句的第一个单词进行判定,如果是保留关键字
,则根据语法定义判定该种命令类型的语法和结尾(结尾一般为某种控制操作符)。
4、别名
如果非重定向或赋值语句的第一个单词是一个普通单词,bash会根据别名记录判定该单词是不是一个命令别名,如果是,则使用对应的文本替换该别名(注意此文本可以是shell能够接受的任意字符)。
然后继续分割并判定替换后的文本,重复上述同样过程,如果替换后仍有别名
(不同于前面曾扩展过的别名),则递归地展开并判定。
另外,默认时只有在交互式shell环境下才允许别名扩展。如果需要在脚本中使用命令别名,则需开启选项shopt -s expand_aliases
。由于别名的功能都可以用函数实现,建议在脚本中使用函数来代替命令别名。
5、其他
如果非重定向或赋值语句的第一个单词不是别名或复合命令的起始单词,解析器将标记它为命令名,并赋值给位置变量0
,其余单词(控制操作符之前的)为此命令的参数($1、$2...$n)。
然后分析器继续分析下一条命令(控制操作符之后的),直到整行都分析完毕。
注意,在同一命令内,赋值语句
后面必须是一个简单命令
。如果是复合命令,将会报错。
还要注意,引用
(见这里)会使元字符
失去其特殊意义,其内部的多个单词可能会被bash看做是一个word
。
最终解析器返回一个C结构体来表达一个命令(对于复合命令,这个结构体中可能还包含有其他命令),然后将其传递给shell的下一阶段:单词展开。
扩展
扩展阶段对应于单词的各种变换,最终得到可用于执行的命令。
以如下脚本为例解释此阶段依次进行的扩展(各种扩展的方法请看之前的文章):
#!/bin/bash
TMP='temp/tmp' num=2
cat ~/"${TMP:0:$((num+2))}"/test_{[0-9],[a-z]}.txt
脚本第三行是一条简单命令(只为举例说明)。
大括号扩展
首先进行的是大括号扩展
,此扩展会导致单词数量的变化。
扩展后的命令形如:
cat ~/"${TMP:0:$((num+2))}"/test_[0-9].txt ~/"${TMP:0:$((num+2))}"/test_[a-z].txt
波浪号扩展
然后进行的是波浪号扩展,~
被$HOME
的值所代替。
扩展后的命令形如:
cat /root/"${TMP:0:$((num+2))}"/test_[0-9].txt /root/"${TMP:0:$((num+2))}"/test_[a-z].txt
变量、命令、进程、数学扩展
在波浪号扩展后进行变量扩展
、命令替换
、进程替换
和数学扩展
,它们按其出现的位置依次扩展。对于嵌套的情况,先进行内部扩展。
扩展后的命令形如:
cat /root/"temp"/test_[0-9].txt /root/"temp"/test_[a-z].txt
单词分割
单词分割
只作用于前一种扩展(变量、命令、进程、数学扩展)的结果,如果扩展处于双引号中,则不会分割(变量或数组使用@
的情况例外)。
bash利用环境变量IFS
的值进行单词分割,如果扩展的结果单词中包含IFS中的任意字符,则被分割为多个单词。如果扩展的结果为空,则此单词被移除(引号中的空值会被保留)。
我们的例子中扩展的结果单词temp
不包含IFS中字符,所以没有进行单词分割
。
注意如果没有上述扩展发生,也不会进行本阶段的单词分割。
路径扩展
单词分割结束后,bash扫描每个单词中的字符*
、?
和[
,如果包含这些字符,此单词就作为一个模式对文件名进行通配符匹配
。
匹配到的所有结果将成为命令的新单词。
我们的例子中,路径扩展后的命令形如:
cat /root/"temp"/test_1.txt /root/"temp"/test_4.txt /root/"temp"/test_x.txt
移除引用
路径扩展完毕后,将移除所有的非扩展结果的引用字符(包括'' "" \
)。
我们的例子中,作用于单词temp的双引号,并不是扩展后的结果,所以会被移除:
cat /root/temp/test_1.txt /root/temp/test_4.txt /root/temp/test_x.txt
脚本执行:
[root@centos7 temp]# ./test.sh
我是文件 test_1.txt
我是文件 test_4.txt
我是文件 test_x.txt
[root@centos7 temp]#
抛开我们的例子,如果一条简单命令有前置的赋值语句,等号右边的单词会经过:波浪号括展
、变量|命令|进程|数学扩展
和移除引用
。大括号扩展、单词分割和路径扩展不会发生。
执行
不同类型的命令,bash的执行方式有所差异。
复合命令
bash中每种复合命令
都使用一个C函数来实现,功能包括执行恰当的展开(如for循环中关键词in后面的单词),执行特定的命令,根据命令的返回值来变更执行流程等等。
管道命令
对于管道命令
,管道两侧的命令会在不同的两个子进程中执行。
此时命令要先后经历
1、fork()系统调用
创建子进程。
2、连接管道
然后命令的执行步骤如下述简单命令
的执行。
简单命令
无论是什么类型的命令,最终都将归结到简单命令的执行。
一条简单命令的执行过程如下:
命令搜索
1、如果命令名中包含字符/
(目录分隔符),则直接执行该路径指定的文件。
2、如果命令名中无斜线,则搜索当前环境中定义的函数
,如果找到,则执行该函数。
3、如果未找到函数,则搜索内置命令
,如果找到,则执行该内置命令(注意内置命令eval
会使其后的所有单词再次经过解析、扩展和执行)。
4、如果没有对应的内置命令,则搜索hash
缓存中记录的对象,如果有该命令的缓存,则直接执行该绝对路径对应的文件。
5、如果hash表中无缓存记录,则搜索环境变量PATH
值中所有目录内的文件,如果找到该名称的文件,则执行(并缓存至hash表);如果未找到,则返回错误信息,设置返回值为127并exit。
命令执行
对于命令的执行,我们介绍更一般的情况(命令位于磁盘文件系统之上的情况):
1、bash执行fork()系统调用
创建子进程(如果命令已经处于子shell内,则不会再次fork(),例如上述管道命令)
2、执行重定向
3、执行execve()系统调用
,控制权移交给操作系统。
4、内核判断该文件是否是操作系统能够处理的可执行格式(如ELF格式的可执行二进制文件或开头顶格写#!
的可执行文本文件)
5、如果操作系统能够处理该文件,则调用相应的函数(二进制文件)或解释器(脚本文件)进行执行。
6、如果文件不具备操作系统的可执行格式(如文本文件但没有顶格写的#!
),execve()
失败,此时,bash会判断该文件,如果该文件有可执行权限并且不是一个目录,则认为该文件是一个脚本,于是调用默认解释器解释执行该文件的内容。
7、执行完毕后,bash收集命令的返回值。
这些,就是bash执行命令的整个流程。