去年我写出的最糟的一个脚本,0.4 秒就跑完,还打印了 backup looks done。它其实什么都没拷:源路径是错的,cp 失败了,而脚本里没有任何东西让它停下来。这一句话就说清了 bash 严格模式 的意义——默认情况下,shell 脚本会忽略几乎每一次失败,并返回它碰巧执行的最后一条命令的退出码。
这篇文章会走一遍 set -euo pipefail,讲清楚 set -e 仍然 会失效的那些地方,以及那些造成大多数「在我机器上是好的」bug 的引号规则。下面每一个失败,都是我在临时目录里真正跑过的脚本,输出是原样贴过来的。
bash 严格模式到底买到了什么
严格模式就是 shebang 之后的一行:
#!/usr/bin/env bash
set -euo pipefail
它不会把一个脚本变正确。它消除的是三类特定的沉默——这是 bash 最接近「编译器警告」的东西。它不会替你给变量加引号、校验输入,也不会阻止 rm 跟着一个错误的路径走;严格模式是安全带,不是司机。方向盘还得你自己握。
-e(errexit):命令失败就停下
没有它,失败只是一行打印了错误、然后继续往下走的命令:
#!/usr/bin/env bash
rm -f priceless.txt
cp missing-source.txt priceless.txt # fails
echo "backup looks done"
$ bash noe.sh
cp: cannot stat 'missing-source.txt': No such file or directory
backup looks done
ls: cannot access 'priceless.txt': No such file or directory
退出码取的是最后一条命令的状态,于是这个脚本「成功地撒了个谎」。加上 set -e,cp 的失败就会在它撒谎之前结束脚本。
-u(nounset):变量未设置就停下
未设置的变量会展开成空串,这正是 rm -rf "$DIR/" 变成 rm -rf / 的方式:
set -u
echo "start"
echo "DEBUG=${DEBUG_MODE}" # never set anywhere
echo "end"
$ bash u.sh
start
u.sh: line 4: DEBUG_MODE: unbound variable
end 再也不会打印。这就是重点。
-o pipefail:让管道报告最糟的那一段
这是最被低估的一个开关。没有它,管道的状态就是最后一条命令的状态,于是上游的失败会凭空消失:
set -eu
echo "importing..."
cat /does/not/exist.csv | wc -l
echo "import finished OK (exit $?)"
$ bash pipe.sh
importing...
cat: /does/not/exist.csv: No such file or directory
0
import finished OK (exit 0)
wc -l 从一个根本不存在的文件里数出了零行,还报告成功。有了 pipefail,cat 的失败会成为整条管道的状态,-e 随后把脚本停掉。
set -e 会悄悄失效的地方(bash 陷阱)
set -e 不是力场。bash 明确列出了一批会禁用它的上下文;下面这些,是会让人丢数据的。
条件里的命令被豁免
任何出现在 if、while、until、&&/|| 列表里、或者 ! 之后的命令,都是预期会失败的,因此 -e 对它网开一面:
set -e
if ! grep -q "deprecated" notes.txt; then
echo "notes.txt has no 'deprecated' marker"
fi
echo "reached the end"
$ bash ifgotcha.sh
grep: notes.txt: No such file or directory
notes.txt has no 'deprecated' marker
reached the end
grep 失败是因为文件不存在,而不是因为没有那个标记,脚本却继续往下走了。要把「文件在不在」单独测掉——[[ -f notes.txt ]] || die "notes.txt is missing"——而不是把一个它扛不住的语义塞进退出码里。
子 shell 和 $(...) 里的失败
裸子 shell 是遵守 -e 的:跑一句 ( false; echo unreachable ),那个 echo 永远不会打印。但把同样的子 shell 放进一个条件里,它的整个函数体就都被豁免了:
if ( false; echo "inside a subshell that is a condition" ); then
$ bash sub_c.sh
inside a subshell that is a condition
then-branch
after
false 被忽略,if 走进了 then 分支。命令替换有同样的盲区:它的状态是最里面最后一条命令的状态,所以 x=$(false; echo "hi") 会把 x 设成 hi 并返回 0——那个 false 就这么没了。永远不要用「我在 $(...) 里检查过了」来代替「这个替换失败了」。
函数,以及 local x=$(cmd) 这个坑
当一个函数被用来做判断时——放进 if,或者接在 || 后面——-e 会在它的整个函数体里被关掉,而它只返回最后一条命令的状态:
set -e
check() {
grep -q "ok" missing.txt # fails
echo "check() continued after its grep failed"
}
check || echo "check returned non-zero"
$ bash fngotcha.sh
grep: missing.txt: No such file or directory
check() continued after its grep failed
script finished
$ echo $?
0
它的孪生陷阱是 local:它的退出状态是 local 自己的,而不是那个命令替换的:
local payload=$(cat definitely-not-here.json) # fails
echo "status right after local = $?"
$ bash localrc.sh
cat: definitely-not-here.json: No such file or directory
payload=[]
status right after local = 0
fetch returned; script kept going
set -e 根本不会触发,$? 读出来的还是 0。把声明和赋值拆开写:local payload; payload=$(cat ...) || die "no payload"。
算术里还藏着另一个沉默杀手。当 i=0 时,表达式 (( i++ )) 求的是旧值 0,而算术命令把这个值当作自己的状态返回——于是它返回 1。在 set -e 下,循环会在第一次迭代就死掉,什么也不打印。用 (( ++i ))(前置自增,返回新的非零值),或者写成 (( i++ )) || true。
引号与单词拆分:头号「在我机器上是好的」
单词拆分 是 bash 对一个未加引号的展开按 IFS(默认是空白)重新切分,然后再做通配展开。只要文件名里有一个空格,它就会现形:
names="my report.txt a b.txt plain.txt"
for f in $names; do echo "[$f]"; done
$ bash split.sh
--- unquoted loop ---
1: [my]
2: [report.txt]
3: [a]
4: [b.txt]
5: [plain.txt]
三个文件名,迭代了五次。括号测试运算符同样地字面——[ ] 碰到空展开还会直接报错:
$ bash brackets.sh
--- [ ] with a glob ---
literal test: FALSE
--- [[ ]] with a glob ---
pattern test: TRUE
--- [ ] with an empty variable ---
brackets.sh: line 10: [: =: unary operator expected
not equal
从这次运行里可以抽出三条规则:
- 给每一个你不打算拆分的展开加引号:写
"$name",不是$name。 - 优先用
[[ ]]而不是[ ]。 在[[ ]]里,展开既不会被拆分也不会被通配,[[ $name = *.txt ]]做的是模式匹配,空串也是安全的。 - 永远不要把列表存进一个字符串。 这就是下一节。
凡是列表,用数组而不是字符串
只要一个值可能包含空白或者通配字符,它就该放进数组,而不是字符串。上面那个循环的修法:
files=("my report.txt" "a b.txt" "plain.txt")
for f in "${files[@]}"; do echo "[$f]"; done
"${files[@]}" 是唯一正确的展开方式:每个元素各自加引号,一个都不拆分。数个数用 "${#files[@]}",取切片用 "${files[@]:1}",除非你确实想要一个拼起来的字符串,否则别用 ${files[*]}。
trap EXIT 与 mktemp:自己收拾干净
一个会创建临时文件或锁的脚本,必须在它死掉时也把它们清掉。mktemp 给你一个唯一路径;trap ... EXIT 保证清场在正常退出、-e 触发的报错、或者收到信号时都会跑:
workdir=$(mktemp -d)
trap 'rm -rf "$workdir"' EXIT
这套接线很容易接错一点点。如果你在函数里把临时目录创建成 local,那么 EXIT trap 会在函数返回之后才触发,此时那个 local 早已出了作用域:
$ bash localtrap.sh
work done in /tmp/tmp.rDa7Aj3KKb
localtrap.sh: line 1: workdir: unbound variable
$ echo $?
1
在 set -u 下,trap 自己会死在这个未绑定变量上,目录随之泄漏。把路径声明在脚本顶层、任何函数之外,这样 trap 还能看见它;优先用 mktemp -d,别手搓 /tmp/myapp.$$ 这种名字;trap 里的路径也永远加引号。
退出码、$? 与 PIPESTATUS
$? 是最近一条命令的状态——而且它会被紧接着的下一条命令覆盖,哪怕那条命令只是你的 echo:
$ bash pstat.sh
PIPESTATUS = 0 1 0
$? of the pipeline = 0 because cat succeeded
整条管道的 $? 是 0,因为最后一段成功了;但 PIPESTATUS 是 0 1 0:中间那个 grep 失败了。它会被下一条命令冲掉,所以要在打印任何东西之前立刻捕获(statuses=("${PIPESTATUS[@]}"))。如果你只想知道「有没有任何一段失败」,pipefail 更省事——这也正是 bash pstat_pipefail.sh 会以 1 退出、永远走不到下一行的原因。
shellcheck 才是真正的安全网
严格模式在运行时抓失败。shellcheck 在你运行之前就抓:未加引号的展开(SC2086)、local/export 掩盖返回值(SC2155)、不可达代码,以及上面那些 set -e 豁免。它以静态方式读你的脚本,并精确指到出错的列:
$ shellcheck import.sh
In import.sh line 9:
cat $source | wc -l
^-----^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
cat "$source" | wc -l
In import.sh line 14:
local out=$(convert "$1")
^--^ SC2155 (warning): Declare and assign separately to avoid masking return values.
For more information:
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing and word splitting.
https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to avoid masking return values.
坦白说:我跑上面那些 demo 的沙箱里没装 shellcheck,也没有网络把它装上,所以这一段是它标准的诊断措辞,而不是一次运行的实录——但结论不受影响。极少数情况下你确实想拆分时,# shellcheck disable=SC2086 注释就是逃生口;加上 -x 让它跟随 source 进来的文件;并把它接进 CI(shellcheck -x deploy/*.sh)和你的编辑器。当你读一个不是自己写的脚本时,用的是同一块肌肉,正如阅读一份代码库;你留给下一个人的注释,也是写一份 README 的同一份纪律。
一份可以直接抄的加固模板
这就是我开每一个非平凡脚本时的骨架:严格模式、把 IFS 收窄到只按换行和制表符拆分、一个 die 助手、把临时目录清场提到函数之外、处处加引号,以及一个真实的退出状态。
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
log() { printf '[%s] %s\n' "$(date +%T)" "$*" >&2; }
die() { log "FATAL: $*"; exit 1; }
readonly WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
main() {
local src="${1:-}"
[[ -n $src ]] || die "usage: ${0##*/} <file>"
[[ -f $src ]] || die "not a file: $src"
local lines
lines=$(wc -l <"$src") || die "cannot count lines in $src"
log "read $lines lines from $src"
printf '%s\n' "$lines"
}
main "$@"
$ bash good.sh sample.txt
[18:46:06] read 3 lines from sample.txt
3
$ echo $?
0
有两个细节让它稳固:lines=$(...) 和 local lines 是分开的两条语句,所以 wc 失败会触发 -e;而工作目录创建在顶层、不在 main 里,所以 EXIT trap 还能看见它。我用「前后各数一次临时目录」的方式验证了清场——差值为零。
一句话总结
set -euo pipefail 让脚本在 bash 平时会忽略的失败上停下来,但 -e 会在这些地方被挂起:条件里、&&/|| 列表里、用作条件的子 shell 里、命令替换里,以及被判断的函数的函数体内——所以永远不要让退出码承载它扛不住的语义。给每个展开加引号,列表用数组,在任何其它命令之前先捕获 PIPESTATUS,用 mktemp 加顶层 trap ... EXIT 清场,剩下的交给 shellcheck 在生产环境之前发现。