最近在写一个 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 中定义变量的语法如下:
|
|
这里有两点需要注意:
- 等号左右两侧没有空格,否则就会被视为命令调用,这一点对初学者来说十分迷惑,但仔细想想其实是合理的,否则解释器怎么知道用户的意图呢?
- 变量类型只有 string,可以用后面提到的
((exp))
这种语法对变量进行四则运算
使用一个变量的语法是 $var
,推荐的写法是 "${var}"
用双引号包起来可以保证变量里即使有空格,也不会为视为两个参数,加花括号是为了防止歧义,比如:
|
|
上面例子意图是创建 /tmp/john_file
这个文件,但是实际上 user_file
整体会被识别成变量名,因此会出现不符合预期的行为,
改用 touch "/tmp/${user}_file"
就可以消除歧义了。
在实际使用时,经常会有给变量赋默认值的情况,比如当 CC 没有指定时,设置为 gcc,可用下面这种语法:
|
|
此外,在交互式脚本中,可以使用 read
这个 Bash 内置函数来让用户输入变量的值:
|
|
除了用户定义的变量,Shell 中有大量内置变量,下面是一些常见的:
内置变量 | 含义 |
---|---|
$0 .. $9 | 脚本、函数执行时的参数,最多有 9 个,第一个为脚本、函数名本身 |
$@ | 所有的参数,相当于 $1 一直到最后 |
$* | 和 $@ 类型,只是不会保留空格,"File with spaces" 会变成 File with spaces 三个参数 |
$$ | 当前运行 shell 的 PID(进程标识符) |
$? | 上一条命令的返回值 |
IFS | Internal Field Separator,用来区分不同参数直接的分隔符,默认是 SPACE TAB NEWLINE |
Quote
在上面已经介绍过,在使用变量时最好用双引号包起来,否则可能会有问题:
|
|
在上面命令中,touch 命令会意外的创建两个文件,而不是一个名为 a b
的文件。
除了双引号,还有单引号,即 '$var'
,表示不会对 var 进行求值,而是原样输出。
数组
Bash 对变量类型进行了增强,提供了索引数组和关联数组两种类型:
- 索引数组(Indexed arrays)使用整数(包括算术表达式引用),索引从 0 开始
- 关联数组(Associative arrays)使用任意字符串,类似于其他语言的 HashMap 类型
|
|
|
|
条件控制
|
|
如果 test-commands
的返回值是 0,表示 true,接着执行后面的 consequent-commands;
,否则进行后面的判断。
除了判断一个命令的返回值,POSIX shell 还提供了一个特殊的内置命令: test
,它后面可以跟一组条件表达式,用于常规逻辑的判断,
比如两个变量是否相等。 test
命令还有个等价的命令,即 [
。只是用 [
时,最后一个参数必须是 ]
表示判断语句的结束,
而且这种写法更为常见,但我们需要意识到 [
是个内置命令。
上面 if 的语法虽然看起来简单,但还是想强调三点:
- if 语句最后有个
fi
,表示结束 then
必须在单独的一行,如果想和if
放一起,可以用隔离符(比如:分号,换行是另一种常见的隔离符)将它们分开:if [ .. ]; then
。后面可以看到,这条隔离规则适用于所有复杂的表达式。使用
[ condition ]
进行判断时,两边必须要有空格。这里可以做个实验:1 2 3 4 5 6
if ["${foo}" = "bar" ] then echo "bar" else echo "not bar" fi
1 2
bash: line 1: [: =: unary operator expected not bar
为了弄清楚上面的错误,我们可以使用
bash -vx if.sh
对脚本进行调试:1 2
+ '[' = bar ']' if.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 进行通配符模式匹配。1 2 3 4 5 6 7 8 9 10 11
if [[ "foo" == f* ]]; then echo "matched" else echo "not matched" fi if [[ "foo" == "f*" ]]; then echo "matched" else echo "not matched" fi
1 2
matched not matched
s1 =~ s2
,在 s2 没有被 quote 时,被视为 POSIX 扩展的正则,进行正则匹配(内部用 regexec 实现)。比如:1 2 3 4 5
if [[ "foo" =~ f.* ]]; then echo "matched" else echo "not matched" fi
1
matched
更多使用细节,可以参考:3.2.5.2 Conditional Constructs。
(( expression ))
会对 expression 进行四则运算,当结果不为 0 是,表示 true。支持的运算符在这里查看。需要说明一点,这个表达式只支持整数,如果是浮点数,需要利用其他命令,比如 bc 。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
if (( 1 + 1 )); then echo "true" else echo "false" fi if (( 1 - 1 )); then echo "true" else echo "false" fi echo "0.1 + 0.1" | bc -l # 这里先用命令替换进行浮点数运算、比较,之后在用 (( .. )) 表达式判断真伪 if (( $(echo "0.1+0.1 == 0.2" | bc -l) )); then echo "0.1+0.1 = 0.2" else echo "0.1+0.1 != 0.2" fi
1 2 3 4
true false .2 0.1+0.1 = 0.2
循环控制
|
|
使用示例:
|
|
|
|
|
|
|
|
上面这个例子中用到了算数表达式 $((expression))
。
|
|
|
|
这个例子中,通过修改 IFS 来达到对字符串进行分割的效果。需要注意的是, for 语句不能写成 for x in "${foo}"
,
因为这时就不会对其进行分割了。
Bash 增强版 for
|
|
一个示例:
|
|
|
|
函数
函数定义的语法如下:
|
|
compound-command 可以是上面提到的各种流程控制命令,比如 if、while、for 等,比如:
|
|
|
|
也可以是后面介绍的分组命令。函数最后一条命令的返回值为整体的返回值,返回值只能是数字,0 表示正常返回。
也可以用 return
来提前返回。和其他语言不同的是,shell 中的函数没有作用域的概念,如下例:
|
|
|
|
可以看到,函数里面可以直接修改外部的变量,如果要避免这种行为,可以使用 local 这个 Bash 内置函数来表示声明内部变量:
|
|
|
|
分组命令
分组命令(grouping commands)是一次执行多个命令的语法,可以作为函数体,也可以单独执行。
|
|
第二种方式结尾多了一个分号,这是由于花括号不是分隔符,如果没有分号来隔离, }
可能表示的是命令的一个参数,因此这里用分号表示后面的字符不再是命令的参数了,出来用分号外,换行符是更为常见的分隔符。而圆括号没有这个问题,因为他本身就是一个分隔符。
|
|
|
|
通过上面的输出可以看到,在子 shell 环境执行的命令也可以读到父环境的变量,这是由于在进行 fork 时,子进程和父进程的内存是一样的,真正的区别在于,在子 shell 环境创建的变量,在父环境中是看不到的。
Here-Document
为了简化多行文本的书写,shell 支持一种称为 here-document 的写法,语法如下:
|
|
n
是可选的,默认是 stdin,后面的 word 到 delimiter 之间的内容会被写入到指定文件句柄中去。需要注意,里面的内容会进行变量名的替换,如果需要保证原样输出,可以在 word 中加上单引号。如下:
|
|
|
|
上面示例用了 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 有没什么用
|
|
另一种方式是后面不带命令,只是做文件句柄的重定向:
|
|
重定向的生效作用域是当前 bash 环境,因此一般在脚本一开始进行重定向,便于后面命令使用。
常用命令
|
|
|
|
更多 Shell 参数替换命令可以参考:Shell Parameter Expansion
模板
鉴于写一个健壮的 Shell 脚本十分困难,因此网络上不断有人分享 Shell 编写的心得,下面这个模板就是一个不错的经验之谈:
|
|
首先这里用 set 这个内置命令设置了 shell 的属性,以
-
开头表示开启以下属性:e
,POSIX 标准。任何命令失败时,立刻退出,就像执行了exit
一样,失败时会产生一个 ERR 信号供 trap 捕捉。o pipefail
,Bash 提供。在执行 pipeline 时,e
只会保证第一条命令失败后,后面的 pipeline 不会执行,但 shell 脚本会继续执行,这个选项就可以解决这个问题:1 2 3
set -e foo | echo "a" echo "bar"
1 2 3
line 2: foo: command not found a bar
可以看到 bar 还是输出了。加上
-o pipefail
后就不会了:1 2 3
set -eo pipefail foo | echo "a" echo "bar"
1 2
bash: line 2: foo: command not found a
u
,POSIX 标准。对未定义变量展开时,立刻退出。-E
,Bash 提供。可以保证在某些复杂命令出现错误时,trap 可以捕捉到 ERR 信号。1 2 3 4 5 6 7 8 9 10 11 12
set -euo pipefail trap "echo ERR trap fired!" ERR myfunc() { # 'foo' is a non-existing command foo } myfunc echo "bar"
1
bash: line 8: foo: command not found
这里可以看到,
-e
已经阻止 shell 继续执行,但是 trap 并没有捕捉到错误信号,加上-E
就可以避免这个问题:1 2 3 4 5 6 7 8 9 10 11 12
set -Eeuo pipefail trap "echo ERR trap fired!" ERR myfunc() { # 'foo' is a non-existing command foo } myfunc echo "bar"
1 2
bash: line 8: foo: command not found ERR 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'