vlambda博客
学习文章列表

[编程 | bash | 02] bash的执行流程

本文主要记录了bash的执行流程,大部分内容摘自bash手册,有些地方有省略,但基本都标注了关键词,如果想仔细了解,可以根据所提供的英文关键词去bash手册里搜索。

本文最初整理的时候已将近是10年前,新版的bash功能更强大,但由于时间有限,没有一一去对比新版手册,可能会遗失部分新增的功能。

2 bash的解析流程

我们在命令提示符后输入了一串字符,回车后提交,bash是怎么识别、解析并执行的呢?

linux中,除了1号进程,其他的进程都是由父进程fork出来的子进程。所以对bash来说,执行一个脚本,也就是父进程调用forkexec等函数产生的一个子进程。

内核中整个调用过程如下(linux4.4):

内核处理exec族函数的主要实现在fs/exec.c文件的do_execveat_common()方法中,其中调用exec_binprm()方法处理执行逻辑,这函数中使用search_binary_handler()对要加载的文件进行各种格式的判断,脚本(script)只是其中的一种。

确定是script格式后,就会调用script格式对应的load_binary方法load_script()进行处理,#!就是在这个函数中解析的。解析到了#!以后,内核会取其后面的可执行程序路径,再传递给search_binary_handler()重新解析。

这样最终找到真正的可执行二进制文件进行相关解析和执行操作。

摘自zorrozou的《shell的执行流程》。

2.1 相关定义

2.1.1 空白字符(blank

主要包括空格和制表符。

2.1.2 单词(word)

被当作一个整体看待的字符序列,也是狭义上的token

2.1.3 name

仅由字母、数字和下划线组成的word,开头第一个字符不能是数字,也被称为标识符。

2.1.4 元字符(metacharacter

用来分隔word的字符,引号内的不能算元字符。

主要有如下几个:

 |  & ; ( ) < > space tab NEWLINE

2.1.5 控制操作符(control operator

主要是用来执行控制功能的token

主要有如下几类:

|| & && ; ;; ( ) | |& <newline>

2.1.6 保留字(Reserved words

保留字对bash有特殊的意义,以下未在引号内的字符序列被当作bash的保留字:

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

2.1.7 内置命令(builtin command

bash内建的命令,由bash直接执行,不需要fork新的子进程。本文里有时候也称其为内建命令。

如果想确定一个命令是内置的还是外部操作系统的,可以用type指令来识别:

[root@etcd1 ~]# type -all cd
cd 是 shell 内嵌
cd 是 /usr/bin/cd
[root@etcd1 ~]# type echo
echo 是 shell 内嵌
[root@etcd1 ~]# type chmod
chmod 是 /usr/bin/chmod

2.1.8 引用(Quoting

Bash中有三种引用机制:

  • 单引号:不允许嵌套,所见即所得。
  • 双引号:仅允许变量置换( $)、命令置换、转义( \)历史扩展( !)等有限几种。
  • 转义字符:禁止后续特殊字符的特殊含义。

2.1.9 命令(Command

主要包括三种:

  • 简单命令( Simple Commands):只包含单个的控制符并由其结尾。
  • 复杂命令:由多种控制符( | & || <> && ;等)分隔的多个简单命令序列。
  • 复合命令( Compound Commands):系列简单命令和复杂命令组成的组合,也可包含各类循环控制结构,譬如 ifforwhile{}、 ()[][[]]等。

2.2 Tokens分解

我们在命令提示符下提交的一串字符,在bash中被称为管道行,该管道行可能包含0个或多个用管道字符|分隔的管道行,这些字符串首先要被分解成各种各样的token

什么意思呢?简单理解就相当于分词,分隔符由元字符组成(meta character)。注意,重定向操作是在这个阶段被识别的,并存储好后供后续使用。

分词的时候要注意,如果是单引号开始的,直接跳到2.14小节的命令查找,单引号内部的字符串序列被当作命令去搜索并执行。如果是双引号开始,和单引号类似,但会额外对其做2.7小节的参数扩展、2.8小节的命令置换和2.9小节的算术扩展,然后将双引号内经过扩展的字符序列当作命令去搜索并执行。

token主要有如下类型:

  • word
  • 关键字
  • 重定向符
  • 分号

2.3 Token检查

分词完成后,bash会检查第一个关键字。

  • 如果为开放的关键字,譬如是 ifforwhile等控制结构,或者是 {(等复合命令,则shell在内部对这些做进一步的处理,并重复这一过程。
  • 如果为控制结构内部的关键字,譬如 thencasebreakdo等关键字,则抛错。
  • 如果前面两者都不是,则继续下一步的处理。

2.4 别名检查

检查第一个token是否是别名。

  • 如果是别名,则替换其别名定义,跳到2.2重新处理。

  • 如果不是别名,继续下一步。

2.5 大括号扩展(Brace expansion

大括号扩展的解释可以参看bash手册里的原文:

Brace  expansion  is  a mechanism by which arbitrary strings may be generated.  This mechanism is similar to pathname expansion, but the filenames generated need not exist.  Patterns to be brace expanded take the form of an optional  preamble,  followed  by either  a  series  of  comma-separated  strings or a sequence expression between a pair of braces, followed by an optional post‐script.The reamble is prefixed to each string contained within the braces, and  the  postscript is then appended to each resulting string, expanding left to right.

大括号扩展类似于路径扩展,只是扩展后生成的字符串不必想目录和文件那样需要存在。大括号扩展可以嵌套,我们一般用其来减少重复字符的输入。

[root@etcd1 etcd]# for i in {a..b}{1..3};do echo $i;done
a1
a2
a3
b1
b2
b3

[root@etcd1 etcd]# touch /var/log/a.log
[root@etcd1 etcd]# mv /var/log/a.{log,bak}
[root@etcd1 etcd]# ll /var//log/a.bak
-rw-r--r-- 1 root root 0 3月 14 21:37 /var//log/a.bak

[root@etcd1 etcd]# echo test{a{a..b},b{1..3}}
testaa testab testb1 testb2 testb3

在高版本的bash中,还支持类似于ptyhon里的list类似的功能:

[root@etcd1 etcd]# echo {1..10..2}
1 3 5 7 9
[root@etcd1 etcd]# echo {001..10..2}
001 003 005 007 009

甚至还支持逆序输出。

2.6 波浪号扩展(Tilde expansion)

波浪号就是~,在linux里一般用来代表用户主目录,波浪号扩展的结果就是得到目录名。

If a word begins with an unquoted tilde character (`~'), all of the characters preceding the first unquoted slash  (or  all  characters,  if there  is no unquoted slash) are considered a tilde-prefix.

如果token以未被引用的~开始,直到未被引用的/,则会进行波浪号扩展。

波浪号扩展主要有如下三种用法。

2.6.1 用户宿主目录

我们一般用~来代替$HOME变量(据说以前的键盘,Home键和~是设置在一起的),譬如:

[root@etcd1 ~]# pwd
/root
[root@etcd1 ~]# cd /tmp;cd ~;pwd
/root
[root@etcd1 ~]# echo ~etcd
/home/etcd
[root@etcd1 ~]# echo ~root
/root
[root@etcd1 ~]# echo ~None #None用户不存在,则扩展失败,原样输出
~None
[root@etcd1 ~]# echo ~/a.sh
/root/a.sh

2.6.2 当前目录

一般用~+来代替变量$PWD,用~-来代表变量$OLDPWD

[root@etcd1 ~]# echo ~+ ~-
/root /tmp
[root@etcd1 ~]# pwd
/root

在上面的示例中,我当前的目录是/root,上一次的目录是/tmp

如果变量$PWD$OLDPWD的值为空,则扩展失败,按原样输出。

2.6.3 目录栈

在切换工作目录的时候,我们最常用的命令是:

[root@etcd1 ~]# cd -
/tmp

该命令可以快速地帮我们切换到上一次的工作目录$OLDPWD~-

但如果涉及到不止两个工作目录,如何在多个目录之间快速切换呢?

bash提供了pushdpopd的两个内建命令来操作目录栈,目录栈就是一个用来存放多个目录的"堆栈",遵循“后进先出”的原则。

[root@etcd1 tmp]# dirs -v
0 /tmp
[root@etcd1 tmp]# pushd /root
~ /tmp
[root@etcd1 ~]# pushd /home/etcd
/home/etcd ~ /tmp
[root@etcd1 etcd]# pushd /usr/local
/usr/local /home/etcd ~ /tmp
[root@etcd1 local]# pushd /var
/var /usr/local /home/etcd ~ /tmp
[root@etcd1 var]# popd /tmp
/var /usr/local /home/etcd ~
[root@etcd1 var]# dirs -v
0 /var
1 /usr/local
2 /home/etcd
3 ~

在上面的操作中,我们建立了一个目录栈,我们可以用dirs -v来查看目录栈里的目录及其序号,可以用popd来删除目录栈里指定的目录,如果popd不带参数,则删除最近存入目录栈内的目录名。

这样,我们就可以通过目录栈里的目录序号来快速切换工作目录。

[root@etcd1 var]# dirs -v
0 /var
1 /usr/local
2 /home/etcd
3 ~
[root@etcd1 var]# cd ~2; pwd
/home/etcd
[root@etcd1 etcd]# cd ~3; pwd
/root
[root@etcd1 ~]# cd ~1; pwd
/usr/local

默认是正序,当然,我们也可以逆序来切换:

[root@etcd1 local]# cd ~-1;pwd
/home/etcd

2.7 参数扩展(Parameter expansion

下面是bash手册里的原文:

The  $ character introduces parameter expansion, command substitution, or arithmetic expansion.  The parameter name or symbol to be expanded may be enclosed in braces, which are optional but serve to protect the variable to be  expanded  from characters immediately following it which could be interpreted as part of the name.

bash将对 $及其后续的字符进行参数扩展,参数扩展主要包括参数扩展、命令置换、算术扩展等几类。

2.7.1 参数扩展

这里的参数扩展主要是用来做字符串处理。

${parameter:offset[:length]}

针对变量parameter,从指定偏移offset处获取指定长度length的子字符串。

${parameter#word}

针对变量parameter,从其头部(#$的前面)开始,将字符串word替换为空值。如果使用两个字符#,则使用贪婪模式。

${parameter%word}

针对变量parameter,从其尾部(%$的后面)开始,将字符串word替换为空值。如果使用两个字符%,则使用贪婪模式。

${#parameter}

获取变量parameter值的长度。

${parameter/pattern/string}

针对变量parameter,将匹配pattern的部分替换成字符串string。如果将第一个斜杠双写,则表示全局替换。注意,这里和sed里的替换不一样,只能用"/",不能用"%"等其他字符来替代"/"。

下面看看示例:

[root@etcd1 ~]# a="hello word"
[root@etcd1 ~]# echo ${a:3:4}
lo w
[root@etcd1 ~]# echo ${a:3}
lo word
[root@etcd1 ~]# echo ${a: -3}
ord
[root@etcd1 ~]# echo ${a:3:-3}
lo w
[root@etcd1 ~]# echo ${a:-3:3}
hello word
[root@etcd1 ~]# echo ${a: -3:3}
ord
[root@etcd1 ~]# echo ${a: -3:1}
o
[root@etcd1 ~]# echo ${a: -3:2}
or
[root@etcd1 ~]# echo ${a#he*o}
word
[root@etcd1 ~]# echo ${a##he*o}
rd
[root@etcd1 ~]# echo ${a%%o*d}
hell
[root@etcd1 ~]# echo ${a%o*d}
hello w
[root@etcd1 ~]# echo ${#a}
10
[root@etcd1 ~]# echo ${a/o/9}
hell9 word
[root@etcd1 ~]# echo ${a//o/9}
hell9 w9rd

2.7.2 变量置换

最常见的就是变量置换。

${parameter}

常见的变量写法,如果变量名parameter中不包括数字、连字符等字符,以及不会引起歧义的情况下,大括号可以省略。

a=${parameter:-word}

使用默认值。如果变量parameter无值,则将word赋值给变量a,如果变量parameter有值,则将变量parameter的值赋值给a。操作后变量parameter的值保持不变。

${parameter:=word}

指定默认值。如果变量parameter的值为空,则将word赋值给parameter

a=${parameter:+word}

使用备用值。如果变量parameter为空,则a也为空,否则,将word赋值给a。操作后,变量parameter的值保持不变。

${parameter:?word}

如果变量parameter为空,则输出word错误信息,否则,输出变量parameter的值。

[root@etcd1 ~]# echo $b

[root@etcd1 ~]# a=${b:-val};echo $a
val
[root@etcd1 ~]# b="hello"
[root@etcd1 ~]# a=${b:-val}; echo $a
hello
[root@etcd1 ~]# unset a b
[root@etcd1 ~]# echo ${b:=val}
val
[root@etcd1 ~]# unset a b
[root@etcd1 ~]# echo ${b:+val}

[root@etcd1 ~]# b="hello"
[root@etcd1 ~]# echo ${b:+val}
val
[root@etcd1 ~]# unset a b; echo ${b:?error}
-bash: b: error
[root@etcd1 ~]# b="hello"; echo ${b:?error}
hello

可以通过shopt -s nocasematch这个指令去开启大小写不敏感。

2.7.3 数组

数组不能算是参数扩展,但形式类似,所以也归集到一起了。

{#array[@]}

数组长度。

{array[@]}

数组值列表。两者的区别在于前者两端有双引号,大部分情况下两者等价。

${array[index]}

数组中第index个成员的值。

下面来看看示例:

[root@etcd1 ~]# a=(1 2 3 4)
[root@etcd1 ~]# echo ${a[*]}
1 2 3 4
[root@etcd1 ~]# echo ${a[@]}
1 2 3 4
[root@etcd1 ~]# echo ${#a[@]}
4
[root@etcd1 ~]# echo ${#a[*]}
4
[root@etcd1 ~]# echo ${a[2]}
3
[root@etcd1 ~]# for i in ${a[*]};do echo "$i";done
1
2
3
4
[root@etcd1 ~]# for i in ${a[@]};do echo "$i";done
1
2
3
4
[root@etcd1 ~]# for i in "${a[*]}";do echo "$i";done
1 2 3 4
[root@etcd1 ~]# for i in "${a[@]}";do echo "$i";done
1
2
3
4

2.7.4 间接扩展

可以看作其他程序里的指针。

{!prefix@}

扩展以prefix开头的变量的名称。

{!array[@]}

扩展数组array的key值列表。

下面来看看示例:

[root@etcd1 ~]# a1="1";a2="2";
[root@etcd1 ~]# for v in ${!a*} ${!a@};do echo $v;done
a
a1
a2
a
a1
a2
[root@etcd1 ~]# a=(x y);
[root@etcd1 ~]# for v in "${!a[*]}" "${!a[@]}";do echo $v;done
0 1
0
1

2.7.5 大小写转换

培训文档大概写于10年前,当时bash里没有这个功能,但整理的时候发现新版的bash手册里多了这个,所以加了进来。

${parameter^pattern}

将变量parameter中匹配pattern的第一个字符匹配转换成大写。

${parameter^^pattern}

将变量parameter中匹配pattern的所有字符转换成大写。

${parameter,pattern}

将变量parameter中匹配pattern的第一个字符转换成小写。

${parameter,pattern}

将变量parameter中匹配pattern的所有字符转换成小写。

下面来看示例:

[root@etcd1 ~]# a="hello word"
[root@etcd1 ~]# echo ${a^}
Hello word
[root@etcd1 ~]# echo ${a^^}
HELLO WORD
[root@etcd1 ~]# echo ${a^[aeh]}
Hello word
[root@etcd1 ~]# echo ${a^^[aehld]}
HELLo worD

2.7.6 特殊变量

变量名 含义
$0 当前命令名
$n 命令的第n个参数,可以有9个
$# 所有参数的个数
$* 所有参数
$@ 所有参数,被双引号包含。
$# 所有参数的个数
$? 上个命令的退出状态
$$ 当前脚本所在的进程ID

2.8 命令置换(Command Substitution

命令置换比较简单易懂,就是用命令的执行结果来替换。

一般我们用下面这两种等价形式来达到命令置换的效果。

$(command)和`command`

一般来说,我们推荐使用前者,因为前者比较清晰明了,并且可以嵌套使用。但后者兼容性好些,是各类平台下shell语言的通用写法。

譬如,我们可以将命令的执行结果赋值给变量result

[root@etcd1 ~]# result=$(cat /etc/passwd)
[root@etcd1 ~]# result=$(< /etc/passwd) #速度稍快些

2.9 算术扩展(Arithmetic Expansion

说白了,就是在bash里进行数学运算。

[root@etcd1 ~]# echo $((3*5+5%3+8>>2))
6
[root@etcd1 ~]# echo $((3*5+5%3+(8>>2)))
19
[root@etcd1 ~]# echo $((2|4)) + $((2^4)) + $((2&4)) + $((~4))
6 + 6 + 0 + -5

[root@etcd1 ~]# echo $[3**3]
27

2.10 进程置换(Process Substitution

以前也翻译成过程置换。

在支持命名管道(FIFO)或者以/dev/fd等方式来命名打开的文件的系统上支持进程置换。可以通过>(list)<(list)的方式把一个进程的输入输出提供给另一个进程,可以减少临时文件的使用,也隐藏了文件描述符的相关操作。

譬如:

[root@etcd1 ~]# ls /root
anaconda-ks.cfg a.sh b.sh
[root@etcd1 ~]# ls /tmp/*.sh
/tmp/a.sh /tmp/b.sh
[root@etcd1 ~]# sort <(ls /root) <(ls /tmp/*.sh)
anaconda-ks.cfg
a.sh
b.sh
/tmp/a.sh
/tmp/b.sh

初学者学习while循环的时候可能会经常碰到一个坑。

[root@etcd1 ~]# cat > /tmp/c.sh <<- 'EOF'
#!/bin/sh
c=0
awk -F: '{print $1,$4}' /etc/passwd|while read user gid;do
if [ $gid -ge 100 ]; then
echo $user
c=$((c+1))
fi
done
echo "count:$c"
EOF

[root@etcd1 ~]# bash /tmp/c.sh
games
systemd-network
polkitd
chrony
etcd
count:0

脚本的本意是累加变量c的值,但输出结果却是0,和预期不符。

这主要是管道subshell的关系,进程间无法共享变量。

我们可以通过进程置换来解决:

#!/bin/sh
c=0
while read user gid;do
if [ $gid -ge 100 ];then
echo $user
c=$((c+1))
fi
done< <(awk -F: '{print $1,$4}' /etc/passwd)

注意,两个小于号之间有个空格。

2.11 单词分割(Word Splitting

前面的扩展完成后,还要进行一次单词分割。只是这次进行$IFS中的字符做分割符而不是前面的元字符。

2.12 路径名扩展(Pathname Expansion

也称为通配符扩展,对路径中的*?[]等符号进行扩展。

*

匹配任意字符,包括null字符。如果使用shopt -s globstar开启了golbstar,则可以使用连续的两个\*\*来匹配所有的文件和目录,如果其后面跟了一个/,则只匹配目录。

只匹配单个字符

[:class:]和[...]

匹配任一字符或者class里的任一字符。

[root@etcd1 ~]# ls /tmp/[abc]*
/tmp/a.sh /tmp/b /tmp/b.sh /tmp/c.sh
[root@etcd1 ~]# ls /tmp/[ac]*
/tmp/a.sh /tmp/c.sh
[root@etcd1 ~]# ls /tmp/[a-c]*
/tmp/a.sh /tmp/b /tmp/b.sh /tmp/c.sh

class符合posix标准里的定义:

alnum alpha ascii blank cntrl digit graph lower print punct space upper word xdigit

如果通过shopt -s extglob开启了extgolb,还支持如下的通配符模式:

?(pattern-list)

针对给定的模式pattern-list,匹配零次或一次。pattern-list是由"|"分隔的多个pattern

*(pattern-list)

针对给定的模式pattern-list,匹配零次或多次。

+(pattern-list)

针对给定的模式pattern-list,匹配一次或多次。

@(pattern-list)

针对给定的模式pattern-list,匹配其中之一。

!(pattern-list)

针对给定的模式pattern-list,取反。

2.13 引用移除(Quote Removal

经过前面的流程后,然后将未被引用的反斜杠\、单引号'、双引号"等符号移除掉。

2.14 命令查找

根据命令优先级进行命令查找,前面已经针对别名进行了处理,所以这里不包括别名。

优先查找函数,然后是复合命令,然后是内置命令,最后在$PATH下找脚本或可执行程序。

命令查找的时候,有时候不希望按默认的优先级去执行,只希望执行命令而不是内建命令,bash也提供了几个特别的命令来实现此类要求。

2.14.1 command

运行带有参数的命令以跳过正常的shell函数查找,只执行内建命令或在$PATH中找到的命令。

command: 用法:command [-pVv] 命令 [参数 ...]
  • p:在 $PATH内查找。
  • -v:待执行命令的描述信息
  • -V:待执行命令的详细描述。

下面看示例:

[root@etcd1 ~]# command ls
anaconda-ks.cfg a.sh b.sh
[root@etcd1 ~]# command -v ls
alias ls='ls --color=auto'
[root@etcd1 ~]# command -V ls
ls 是 `ls --color=auto' 的别名
[root@etcd1 ~]# command -p ls
anaconda-ks.cfg a.sh b.sh
[root@etcd1 ~]# type -all ls
ls 是 `ls --color=auto'
的别名
ls 是 /usr/bin/ls

因为ls既是内建命令,也是系统命令,所以没有很好地说明-p选项 作用。

如果给出-V 或者-v 选项,找到了待查命令,其退出状态就是0,否则就是1。如果没有使用这些选项,并且产生了错误或者没有找到待查命令,其退出状态就是127。否则,待找命令的退出状态就是command的退出状态。

2.14.2 builtin

执行指定的shell-builtin内置函数,传递参数并返回其退出状态。这在定义一个名称与shell内置函数相同的函数时非常有用,它将内置函数的功能保留在函数中。cd内置通常是这样重新定义的。如果shell-builtin不是shell内置命令,则返回状态为false

builtin shell-builtin [arguments]

下面看示例:

[root@etcd1 ~]# builtin find /root "*.sh"
-bash: builtin: find: 不是shell内建
[root@etcd1 ~]# builtin pwd
/root

2.14.3 enable

启用或禁用内置的shell命令,禁用后允许在不指定完整路径的情况下执行与shell内置同名的磁盘命令,即使内置命令的优先级高于磁盘 命令。

 enable [-a] [-dnps] [-f filename] [name ...]
  • n:禁用每个 name,否则启用每个 name
  • f:从共享对象 filename中加载新的内置命令,实现动态加载。
  • d:删除 f选项加载的内建命令。
  • p:打印内建命令列表,如果没有提供任何参数,也会做同样输出。
  • n:只打印被禁用的内置命令列表。
  • a:打印所有的内置命令列表,不管其是否被禁用。
  • s:仅输出 posix规范的特殊内置命令列表。

2.15 命令执行(Command execution

处理重定向操作。

如果是eval,则针对eval后面的输入从头做一遍。

在将命令拆分为单词后,如果生成一个简单的命令或一个命令序列,将执行如下操作。

如果命令名不包含任何/shell将尝试查找它。如果存在同名函数,则执行该函数。如果不存在同名函数,则在内置列表里去搜索。如果找到匹配项,则执行。

如果既不是shell函数,也不是内置命令,并且不包含/,那么bash会在$PATH里搜索。bash使用哈希表来记录命令的完整路径,只有在哈希表里找不到该命令时,才会在$PATH里执行完全搜索。如果搜索失败,shell会去搜索一个名为command_not_found_handle的函数,如果该函数存在,则将原始命令及其参数作为该函数的参数传递过去并执行,该函数的退出状态就将成为shell的退出状态。如果未定义该函数,shell将打印错误信息并返回127。如果搜索成功,或者命令名包含有/,shell将在单独的执行环境里执行。命令名被赋值给$0,如果有其他参数,则依次赋值给$1$2、...、$9

如果文件执行失败是因为其不是可执行的文件格式,并且该文件不是目录,则假定其是一个脚本,则会派生出一个subshell来执行。

如果程序是以#!开头的文件,除第一行外,其余部分交给第一行指定的程序去解释执行。