最近在写一个 asdf 的插件,为了保证移植性,它对插件所能使用的命令有及其严格的限制,banned_commands.bats 里列举了被禁用的命令, 这进一步加深笔者的一个印象:写好 Shell 脚本真不是一件简单地事情。
可能每个程序员都会或多或少的被 Shell 折磨过,这篇文章就来系统地讲述 Shell 编程,重新认识一下这门即熟悉又陌生的语言, 当然,更重要的是介绍如何写出一份健壮的 Shell 脚本。

概述
Shell 编程是一个很大的范畴,通常是指用一门 Shell(比如:zsh、bash) 外加各种命令(比如:grep、awk)来编写。 POSIX shell 算是一个标准,但功能相对简陋,Bash 历史最为悠久,应用最广泛,因此很大程度上 Shell 编程就是写 Bash 脚本。不同 Shell 的对比可以参考:Unix Shells: Bash, Fish, Ksh, Tcsh, Zsh。
本文会以 POSIX 标准为基础,同时涵盖 Bash 中常用的功能。一门 Shell 通常会提供以下功能,后文也会就这几方面展开叙述:
- 变量
- 流程控制,比如:if、while、for 等
- 函数
- 内置命令,比如:
cd、[、:等
需要说明一点,Shell 编程对空格的处理十分敏感,代码相同,但空格不同,意义就不一样!后面在介绍时也会不断强调这一点。
变量
变量是任何一门语言中最基本、最实用的基本组件,很难想象一个没有变量的语言会是怎样,Shell 中定义变量的语法如下:
1var=value这里有两点需要注意:
- 等号左右两侧没有空格,否则就会被视为命令调用,这一点对初学者来说十分迷惑,但仔细想想其实是合理的,否则解释器怎么知道用户的意图呢?
- 变量类型只有 string,可以用后面提到的
((exp))这种语法对变量进行四则运算
使用一个变量的语法是 $var ,推荐的写法是 "${var}" 用双引号包起来可以保证变量里即使有空格,也不会为视为两个参数,加花括号是为了防止歧义,比如:
1user=john
2touch "/tmp/$user_file"上面例子意图是创建 /tmp/john_file 这个文件,但是实际上 user_file 整体会被识别成变量名,因此会出现不符合预期的行为,
改用 touch "/tmp/${user}_file" 就可以消除歧义了。
在实际使用时,经常会有给变量赋默认值的情况,比如当 CC 没有指定时,设置为 gcc,可用下面这种语法:
1CC=${CC:-gcc}此外,在交互式脚本中,可以使用 read 这个 Bash 内置函数来让用户输入变量的值:
1echo "What's your name?"
2read my_name
3echo "Hello ${my_name}"除了用户定义的变量,Shell 中有大量内置变量,下面是一些常见的:
| 内置变量 | 含义 |
|---|---|
$0 .. $9 | 脚本、函数执行时的参数,最多有 9 个,第一个为脚本、函数名本身 |
$@ | 所有的参数,相当于 $1 一直到最后 |
$* | 和 $@ 类型,只是不会保留空格,"File with spaces" 会变成 File with spaces 三个参数 |
$$ | 当前运行 shell 的 PID(进程标识符) |
$? | 上一条命令的返回值 |
IFS | Internal Field Separator,用来区分不同参数直接的分隔符,默认是 SPACE TAB NEWLINE |
Quote
在上面已经介绍过,在使用变量时最好用双引号包起来,否则可能会有问题:
1foo="a b"
2touch $foo在上面命令中,touch 命令会意外的创建两个文件,而不是一个名为 a b 的文件。
除了双引号,还有单引号,即 '$var' ,表示不会对 var 进行求值,而是原样输出。
数组
Bash 对变量类型进行了增强,提供了索引数组和关联数组两种类型:
- 索引数组(Indexed arrays)使用整数(包括算术表达式引用),索引从 0 开始
- 关联数组(Associative arrays)使用任意字符串,类似于其他语言的 HashMap 类型
1# 索引数组声明方式
2declare -a indexed=(hello world)
3# 关联数组声明方式,bash 4 开始支持
4# declare -A associative=([hello]=1 [world]=2)
5
6echo "First element is" ${indexed[0]}
7for i in ${indexed[@]}; do
8 echo "$i"
9done1First element is hello
2hello
3world条件控制
1if test-commands
2then
3 consequent-commands;
4[elif more-test-commands; then
5 more-consequents;]
6[else alternate-consequents;]
7fi如果 test-commands 的返回值是 0,表示 true,接着执行后面的 consequent-commands; ,否则进行后面的判断。
除了判断一个命令的返回值,POSIX shell 还提供了一个特殊的内置命令: test ,它后面可以跟一组条件表达式,用于常规逻辑的判断,
比如两个变量是否相等。 test 命令还有个等价的命令,即 [ 。只是用 [ 时,最后一个参数必须是 ] 表示判断语句的结束,
而且这种写法更为常见,但我们需要意识到 [ 是个内置命令。
上面 if 的语法虽然看起来简单,但还是想强调三点:
- if 语句最后有个
fi,表示结束 then必须在单独的一行,如果想和if放一起,可以用隔离符(比如:分号,换行是另一种常见的隔离符)将它们分开:if [ .. ]; then。后面可以看到,这条隔离规则适用于所有复杂的表达式。使用
[ condition ]进行判断时,两边必须要有空格。这里可以做个实验:1if ["${foo}" = "bar" ] 2then 3 echo "bar" 4else 5 echo "not bar" 6fi1bash: line 1: [: =: unary operator expected 2not bar为了弄清楚上面的错误,我们可以使用
bash -vx if.sh对脚本进行调试:1+ '[' = bar ']' 2if.sh: line 1: [: =: unary operator expected可以看到当
foo这个变量没有定义时,会展开成[ = bar ],很明显这是语法错误,因为=是个二元运算符, 这里报错可以解读为:在[后期望跟一个一元操作符。而且这里如果写成了[ ${foo} = "bar" ]也是错误, 因为它和上面展开是等价的!必须是[ "${foo}" = "bar" ]才正确。下面是 POSIX test 命令中支持的一些条件表达式,其中需要着重注意的是:
字符串比较用的是
=,数字比较用的是-eq
| 条件表达式 | 含义 |
|---|---|
[ -e pathname ] | pathname 所指的文件存在,不关心文件类型 |
[ -f pathname ] | pathname 所指的文件存在,并且是普通文件 |
[ -d pathname ] | pathname 所指的文件存在,并且是目录 |
[ -S pathname ] | pathname 所指的文件存在,并且是 Socket |
[ -L pathname ] | pathname 所指的文件存在,并且是软链 |
[ -w pathname ] | pathname 所指的文件存在,并且有可写的权限 |
[ -x pathname ] | pathname 所指的文件存在,并且有可执行权限 |
[ -s pathname ] | 文件大小是否大于 0 |
[ -n string ] | 字符串长度是否大于 0 |
[ -z string ] | 字符串长度是否等于 0 |
[ string ] | 字符串是否不是 null |
[ s1 = s2 ] | s1 是否等于 s2 |
[ s1 != s2 ] | s1 是否不等于 s2 |
[ n1 -eq n2 ] | 数字 n1 是否等于数字 n2 |
[ n1 -ne n2 ] | 数字 n1 是否不等于数字 n2 |
[ n1 -gt n2 ] | 数字 n1 是否大于(great than)数字 n2 |
[ n1 -ge n2 ] | 数字 n1 是否大于或等于数字 n2 |
[ n1 -lt n2 ] | 数字 n1 是否小于(less than)数字 n2 |
[ n1 -le n2 ] | 数字 n1 是否小于或等于数字 n2 |
[ express1 -a express2 ] | 两个表达式都是 true |
[ express1 -o express2 ] | 两个表达式有一个为 true |
[ ! express ] | 对表达式取反 |
Bash 加强版判断命令
除了 [ exp ] 表示判断,Bash 中增加了 [[ exp ]] 这种语法,它支持更高级的条件表达式:
s1 < s2,s1 > s2对字符串按照字典序进行大小判断s1 == s2,s1 != s2右边的 s2 在没有被 quote 时,会被视为模式,对 s1 进行通配符模式匹配。1if [[ "foo" == f* ]]; then 2 echo "matched" 3else 4 echo "not matched" 5fi 6 7if [[ "foo" == "f*" ]]; then 8 echo "matched" 9else 10 echo "not matched" 11fi1matched 2not matcheds1 =~ s2,在 s2 没有被 quote 时,被视为 POSIX 扩展的正则,进行正则匹配(内部用 regexec 实现)。比如:1if [[ "foo" =~ f.* ]]; then 2 echo "matched" 3else 4 echo "not matched" 5fi1matched更多使用细节,可以参考:3.2.5.2 Conditional Constructs。
(( expression ))会对 expression 进行四则运算,当结果不为 0 是,表示 true。支持的运算符在这里查看。需要说明一点,这个表达式只支持整数,如果是浮点数,需要利用其他命令,比如 bc 。1if (( 1 + 1 )); then 2 echo "true" 3else 4 echo "false" 5fi 6 7if (( 1 - 1 )); then 8 echo "true" 9else 10 echo "false" 11fi 12 13echo "0.1 + 0.1" | bc -l 14 15# 这里先用命令替换进行浮点数运算、比较,之后在用 (( .. )) 表达式判断真伪 16if (( $(echo "0.1+0.1 == 0.2" | bc -l) )); then 17 echo "0.1+0.1 = 0.2" 18else 19 echo "0.1+0.1 != 0.2" 20fi1true 2false 3.2 40.1+0.1 = 0.2
循环控制
1while test-commands; do consequent-commands; done
2
3for name [ [in [words …] ] ; ] do commands; done使用示例:
1for i in 1 2 3
2do
3 echo "Looping ... number $i"
4done1Looping ... number 1
2Looping ... number 2
3Looping ... number 31x=3
2while [ $x -gt 0 ]
3do
4 echo "Looping ... number $x"
5 x=$(($x-1))
6done1Looping ... number 3
2Looping ... number 2
3Looping ... number 1上面这个例子中用到了算数表达式 $((expression)) 。
1foo="a:b:c:d"
2old_ifs="$IFS"
3IFS=":"
4for x in ${foo}
5do
6 echo "$x"
7done
8IFS="${old_ifs}"1a
2b
3c
4d这个例子中,通过修改 IFS 来达到对字符串进行分割的效果。需要注意的是, for 语句不能写成 for x in "${foo}" ,
因为这时就不会对其进行分割了。
Bash 增强版 for
1for (( expr1 ; expr2 ; expr3 )) ; do commands ; done一个示例:
1for (( i=0;i<3;i++ ))
2do
3 echo "Looping ... i is set to $i"
4done1Looping ... i is set to 0
2Looping ... i is set to 1
3Looping ... i is set to 2函数
函数定义的语法如下:
1# POSIX 标准
2fname () compound-command [ redirections ]
3# 或 GNU Bash 扩展
4function fname [()] compound-command [ redirections ]compound-command 可以是上面提到的各种流程控制命令,比如 if、while、for 等,比如:
1my_function()
2if [ ${1} -gt 10 ]
3then
4 echo "Large than 10"
5else
6 echo "Not large than 10"
7fi
8
9my_function 100
10my_function 11Large than 10
2Not large than 10也可以是后面介绍的分组命令。函数最后一条命令的返回值为整体的返回值,返回值只能是数字,0 表示正常返回。
也可以用 return 来提前返回。和其他语言不同的是,shell 中的函数没有作用域的概念,如下例:
1x=1
2function my_func() {
3 x=2
4 echo "x is $x inside func"
5}
6
7my_func
8echo "x is $x outside func"1x is 2 inside func
2x is 2 outside func可以看到,函数里面可以直接修改外部的变量,如果要避免这种行为,可以使用 local 这个 Bash 内置函数来表示声明内部变量:
1x=1
2function my_func() {
3 local x=2
4 echo "x is $x inside func"
5}
6
7my_func
8echo "x is $x outside func"1x is 2 inside func
2x is 1 outside func分组命令
分组命令(grouping commands)是一次执行多个命令的语法,可以作为函数体,也可以单独执行。
1# 在一个新的子 shell 环境中执行这组命令
2( compound-list )
3
4# 在当前 shell 环境中执行这组命令
5{ compound-list ; }第二种方式结尾多了一个分号,这是由于花括号不是分隔符,如果没有分号来隔离, } 可能表示的是命令的一个参数,因此这里用分号表示后面的字符不再是命令的参数了,出来用分号外,换行符是更为常见的分隔符。而圆括号没有这个问题,因为他本身就是一个分隔符。
1x=1
2
3(
4 echo "x is ${x} in group command 1"
5 y=2
6)
7
8# 这里用换行符分割末尾的 }
9{
10 echo "x is ${x} in group command 2"
11 z=3
12}
13
14echo "x = ${x}, y = ${y}, z=${z}"1x is 1 in group command 1
2x is 1 in group command 2
3x = 1, y = , z=3通过上面的输出可以看到,在子 shell 环境执行的命令也可以读到父环境的变量,这是由于在进行 fork 时,子进程和父进程的内存是一样的,真正的区别在于,在子 shell 环境创建的变量,在父环境中是看不到的。
Here-Document
为了简化多行文本的书写,shell 支持一种称为 here-document 的写法,语法如下:
1[n]<<word
2 here-document
3delimitern 是可选的,默认是 stdin,后面的 word 到 delimiter 之间的内容会被写入到指定文件句柄中去。需要注意,里面的内容会进行变量名的替换,如果需要保证原样输出,可以在 word 中加上单引号。如下:
1cat <<EOF
2Home dir is $HOME
3EOF
4
5cat <<'EOF'
6Home dir is $HOME
7EOF1Home dir is /Users/jiacai
2Home dir is $HOME上面示例用了 EOF 作为分隔符,这是一般的约定,当然其他分隔符也是可以的。另一点需要注意,结尾的 EOF 前面不能有任何其他字符,可能有对齐的需要,可以用 <<- ,这时它会把前置的 TAB 键去掉。
内置命令
内置命令包含在 shell 本身中。当内置命令的名称作为简单命令(参见简单命令)的第一个单词时,shell 会直接执行该命令,而不会调用其他程序。内置命令可以实现外部命令无法实现的功能。最常见内置命令是 cd ,它会改变 shell 的当前工作目录(CWD),这个功能是无法通过外部命令来实现的。其他常见命令:
| 内置命令 | 含义 |
: [arguments] | 除了扩展参数和执行重定向外,不做任何操作。返回状态为零 |
. filename [arguments] | 在当前 shell 上下文中读取并执行文件名参数中的命令 |
exec [-cl] [-a name] [command] | 如果提供 command,它将替换当前 shell 而不创建新进程,否则进行 fd 重定向 |
export name=value | 标记环境中要传递给子进程的每个变量 |
set [-/+o option] | 当带 option 时,可以改变 shell 的属性, -o 表示开启, + 表示关闭 |
exec
exec 主要有两种执行方式,后面携带一个命令时,这个命令会取代当前脚本的执行。这样做的原因主要有:
- 节省系统资源,毕竟省去了新建一个进程的资源
- 更清晰的进程树。这个也很好理解,否则当前的 bash 进程就会是 command 的父进程,但这个 bash 有没什么用
1echo "Before exec"
2exec ls -l
3echo "After exec" # 这行不会被执行另一种方式是后面不带命令,只是做文件句柄的重定向:
1# 用只读的方式打开 readfile,并且句柄为 3
2exec 3< readfile
3
4# 用可写的方式打开 writefile,并且句柄为 4
5exec 4> writefile
6
7# 复制句柄 0 到句柄 5
8exec 5<&0
9
10# 关闭句柄 3
11exec 3<&-重定向的生效作用域是当前 bash 环境,因此一般在脚本一开始进行重定向,便于后面命令使用。
常用命令
1# 同时创建三个文件
2touch /tmp/t{1,2,3}.txt
3
4# 输出当前目录下所有文件,等价于 ls
5# echo *
6
7# 命令替换,将命令的输出赋值给变量,会删除尾部换行符。下面两种方式等价,一般推荐用后者,它是 POSIX 标准
8foo="`whoami`"
9foo="$(whoami)"
10
11# 命令替换支持嵌套,很明显,第一种写法更易读
12echo $(echo hello $(echo word))
13echo `echo hello \`echo word\``
14
15# 获取变量长度
16foo="hello world"
17echo ${#foo}
18
19# 字符串截断 ${parameter:offset:length}
20echo ${foo:0:5}
21
22# 字符串替换 ${parameter/pattern/string} 只会替换第一个
23echo ${foo/o/a}
24# 两个斜线表示替换全部
25echo ${foo//o/a}1hello word
2hello word
311
4hello
5hella world
6hella warld更多 Shell 参数替换命令可以参考:Shell Parameter Expansion
模板
鉴于写一个健壮的 Shell 脚本十分困难,因此网络上不断有人分享 Shell 编写的心得,下面这个模板就是一个不错的经验之谈:
1#!/usr/bin/env bash
2set -Eeuo pipefail
3trap cleanup SIGINT SIGTERM ERR EXIT
4cleanup() {
5 trap - SIGINT SIGTERM ERR EXIT
6 # script cleanup here
7}
8
9script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)首先这里用 set 这个内置命令设置了 shell 的属性,以
-开头表示开启以下属性:e,POSIX 标准。任何命令失败时,立刻退出,就像执行了exit一样,失败时会产生一个 ERR 信号供 trap 捕捉。o pipefail,Bash 提供。在执行 pipeline 时,e只会保证第一条命令失败后,后面的 pipeline 不会执行,但 shell 脚本会继续执行,这个选项就可以解决这个问题:1set -e 2foo | echo "a" 3echo "bar"1line 2: foo: command not found 2a 3bar可以看到 bar 还是输出了。加上
-o pipefail后就不会了:1set -eo pipefail 2foo | echo "a" 3echo "bar"1bash: line 2: foo: command not found 2au,POSIX 标准。对未定义变量展开时,立刻退出。-E,Bash 提供。可以保证在某些复杂命令出现错误时,trap 可以捕捉到 ERR 信号。1set -euo pipefail 2 3trap "echo ERR trap fired!" ERR 4 5myfunc() 6{ 7 # 'foo' is a non-existing command 8 foo 9} 10 11myfunc 12echo "bar"1bash: line 8: foo: command not found这里可以看到,
-e已经阻止 shell 继续执行,但是 trap 并没有捕捉到错误信号,加上-E就可以避免这个问题:1set -Eeuo pipefail 2 3trap "echo ERR trap fired!" ERR 4 5myfunc() 6{ 7 # 'foo' is a non-existing command 8 foo 9} 10 11myfunc 12echo "bar"1bash: line 8: foo: command not found 2ERR trap fired!
cleanup里面的trap - SIGINT SIGTERM ERR EXIT表示后面指定信号的行为重置为 shell 启动时的值,这样可以防止 cleanup 因接受到多个信号,而被重复调用多次。- 最后的
script_dir表示脚本所在位置,这样不论用什么方式调用它,都可以获得正确的绝对路径。
此外,还可以利用 ShellCheck 这个静态检查工具来帮助我们发现代码中的潜在问题,每个错误都对应一个专门的 WIKI 页面来解释。Google 的 Shell Style Guide 也是个不错的参考资料。
Emacs 用户的话,可以利用 auto-insert 来自动插入这个模板。
总结
通过整理这篇文章,笔者自己对 shell 这门古老语言有了不少新的认识,之前经常看到写文章说推荐 XX 用法,不要用 YY 用法, 也没去深究,导致每次用都忘记了,遇到问题再去 Google,浪费了不少时间,想要真正的偷懒,还是要做到『知其然、知其所以然』, 不然肯定会重复的犯同样的错误!
最后,GitHub 上不乏一些用 Bash 实现的“大型”项目,看完本文的读者可以进一步学习,毕竟实战出真知!
- gh2o/bash_tls: A minimal TLS 1.2 client implementation in a pure Bash script
- p8952/bocker: Docker implemented in around 100 lines of bash
在 V2EX 上讨论 💬
参考
- The Shell Scripting Tutorial
- Bash command groups: Why do curly braces require a semicolon?
- Why semicolon NOT required in these cases?
- Command Grouping (Bash Reference Manual)
- Do parentheses really put the command in a subshell?
- Command Substitution (Bash Reference Manual)
- ShellCheck: A static analysis tool for shell scripts | Hacker News
- Minimal safe Bash script template
- Safer bash scripts with 'set -euxo pipefail'