
16 组用例,一个 set -euo pipefail,两个方向相反的答案:该拦住的地方它放行,跟错误无关的地方它把整段脚本带走,退出码 141。整篇的骨架就是下面那张表,表之后的每一节都在解读表里的几行。
环境是 macOS 自带的那份:
bash 版本:GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25)
3.2.57 是 2011 年的版本,这份结论只在它身上成立,bash 5 本机没装,没验证。脚本存档在 tools/verify-bash-strict.sh,表里每个数字都是它在这台机器上打印出来的原值。
一张表:16 组用例各自的退出码
| # | 用例 | 退出码 | 结果 |
|---|---|---|---|
| 1 | set -u 读一个未定义变量 | 1 | 停在那行,报 unbound variable |
| 2 | set -u 下 "$@" 没传参数 | 0 | 平安无事,函数照常执行 |
| 3 | set -u 展开一个空数组 | 1 | 停在那行,报 arr[@]: unbound variable |
| 4 | local 后面跟命令替换 | 0 | 放行;把 local 去掉就变成 1 |
| 5 | ((0)) 和 ((1)) 自己的退出码 | 1 / 0 | set -e 不管那个 1,脚本走到尾 |
| 6 | 被 if 调用的函数体 | 0 | 两个 false 全放行,走 then 分支 |
| 7 | false && echo | 0 | AND 列表豁免 |
| 8 | ! false | 0 | 取反前缀豁免 |
| 9 | false | cat,没有 pipefail | 0 | 非最后一个命令被吃掉;加上 pipefail 变 1 |
| 10 | seq | head 配着 pipefail | 141 | SIGPIPE 当成失败上报;去掉 pipefail 变 0 |
| 11 | trap ERR 配 if 和最后一句 false | 1 | 只有最后那句独立的 false 触发 |
| 12 | 命令替换里的两条语句 | 0 / 1 | 只看替换里最后一条命令 |
| 13 | 子 shell 里的 false | 1 | 子 shell 先退,父 shell 跟着退 |
| 14 | 函数体被 || 接住 | 0 | 函数体里 set -e 全程关闭 |
| 15 | 手工补上三处防御 | 0 | 三条正常信息 |
| 16 | 三种修法 | 0 / 0 / 141 | 换成 awk 的偏方照样 141 |
读表别按直觉来:退出码那一列里的 0 不等于这一行没事,非 0 也不等于前面那行报了错。第 10 行和第 16 行的 141 跟真正的错误毫无关系,第 4、6、7、8、9、12、14 行的 0 底下压着失败。
表里第 4 行:local 后面跟命令替换
set -e
f() {
local out=$(false)
echo "函数继续执行了,out 是空字符串:[${out}]"
}
f
echo "脚本正常走到底"
这段的输出是:
函数继续执行了,out 是空字符串:[]
脚本正常走到底
退出码 0,就是表里第 4 行那一格。原因是 local 是内建命令,set -e 看的是 local 自己的退出码,那次命令替换失败了它不报。把 local 去掉,同样的命令替换就会让脚本死掉,退出码 1。
这个坑在部署脚本里最要命:写成 local dump=$(pg_dump ...) 的话,备份失败也不会有人吭声,脚本接着做后面的事。
表里第 6 行:失败的命令出现在条件位置
只要命令出现在 if、&&、||、! 里,set -e 就默认结果得自己处理,整个位置都不再触发退出。函数被这样调用时,函数体内的 set -e 也是全程关闭的:
set -e
f() {
false
echo " 函数里 false 之后还在跑"
false
echo " 函数里第二个 false 之后也还在跑"
return 0
}
if f; then
echo "if 走的是 then 分支"
fi
输出是两行「还在跑」加上 then 分支,退出码 0。函数里两个 false 全被放过去了。f || echo "接住" 这种写法同理,也就是表里第 14 行;false && echo 这种 AND 列表和 ! false 一样躺在这个豁免里,分别是第 7 行和第 8 行。
trap ... ERR 跟着受同样的限制。加上 trap "echo [trap ERR 触发]" ERR 之后,if false; then :; fi 和 false || true 都不触发,只有最后一句独立的 false 才触发,对应表里第 11 行的退出码 1。
表里第 9 行:管道里非最后一个命令
set -e
false | cat
echo "前面那个 false 被管道吃掉了,脚本继续"
没有 pipefail 时,管道的退出码取最后那个命令,cat 成功了,脚本继续走,退出码 0。加上 pipefail 之后这段立刻变成退出码 1,表里第 9 行两个数字的来历就在这里。
第 10 行的 141:pipefail 撞上 head
set -euo pipefail
seq 1 200000 | head -1
echo "seq 那一段收到 SIGPIPE,pipefail 把它当成失败上报"
实际的输出只有一行 1,退出码是 141,表里那一行就是这么来的。head 读完第一行就走了,seq 还在往管道里写,被 SIGPIPE 打死,pipefail 把这个 141 当成整条管道的失败,set -e 顺势退掉整个脚本。去掉 pipefail 就恢复正常,脚本继续跑完,退出码 0。
三种修法都试了:在管道外面接住(seq 1 200000 | head -1 || true)管用;set +o pipefail 局部关掉再打开也管用。网上有个偏方说把 head 换成 awk 'NR==1 { print; exit }' 就不会中招,实测还是退出码 141,也就是表里第 16 行最后那个数字。一样的道理,awk 提前退出同样让写端吃到 SIGPIPE。
这条最坑的地方是报错位置:日志里最后一条正常输出离出事的地方很远,看不出是管道干的。
第 13 行的 1:子 shell 把父 shell 一起带走
set -e
(echo " 子 shell 开始"; false; echo " 子 shell 里 false 之后这行不会跑")
echo "这行也不会跑"
输出只有「子 shell 开始」一行,退出码 1,表里第 13 行那一格就是它。子 shell 继承了 set -e,里面的 false 把子 shell 结束掉,父 shell 又因为子 shell 返回非 0 而退出。想隔离就显式关掉,或者把子 shell 的失败接住:
set -e
(set +e; echo " 子 shell 开始"; false; echo " 子 shell 里 false 之后还在跑")
( false ) || echo " 子 shell 失败了,被 || 接住"
echo "父 shell 走到底"
这段的退出码是 0,四行输出全打出来了。
表里 1 到 3 行:set -u 那边的三个数字
未定义变量会让脚本停在那一行,报错是 HOME_UNSET_VAR: unbound variable,退出码 1。空数组展开的待遇不一样:
set -u
arr=()
printf "数组元素=[%s]\n" "${arr[@]}"
报错 arr[@]: unbound variable,退出码 1。但同一个脚本里 "$@" 没传参数是平安无事的,函数照常执行,退出码 0,也就是表里第 2 行那一格。网上说 bash 4.4 之前的 "$@" 也会炸,3.2.57 上没这回事,只能说这份输出只适用于这个版本。
数组的修法是先判长度再展开,或者干脆别在 set -u 的脚本里用空数组做累积。
表里第 5 行:那个复现不出来的 ((i++))
有个流传很广的说法是 ((i++)) 会让 set -e 的脚本静默退出,我在这台机器上复现不出来:
((0)) 的退出码 = 1
((1)) 的退出码 = 0
((0)) 确实返回 1,但 set -e 不管它,脚本继续往下走,退出码 0,表里第 5 行的两格就是这么一对。所以在 3.2 上看到别人博客里这段「坑」,不用急着改代码。
表里第 15 行:要读的退出码都写出来
把需要读退出码的地方全部显式写出来,不指望 set -e 记着:
#!/bin/bash
set -euo pipefail
greet() {
local who
who=$(printf "金铭")
echo " 局部变量 who=[${who}]"
}
greet
if ! out=$(false); then
echo " 命令替换失败被 if 接住了"
fi
arr=()
if [ ${#arr[@]} -gt 0 ]; then
printf " %s\n" "${arr[@]}"
else
echo " 数组是空的,这一支什么都不做"
fi
这段退出码 0,输出三条正常信息。三个习惯对应上面的三个坑:local 先声明后赋值,会失败的命令替换包在 if ! 里,数组先看 #arr[@]。代价是代码啰嗦,换来的是失败一定有人管。
这份表该贴到哪一类脚本上
新写的脚本、跑在 CI 里没人盯着的脚本,加上严格模式,再把上面那几个位置手工过一遍。它的价值是拦住没意识到的分支,不是替代错误处理。
在别人维护的、历史很长的老脚本上直接加 set -euo pipefail,风险大过收益。里面多半有几处依赖「命令失败但流程继续」的逻辑,加完之后脚本会在某个随机位置死掉,而那个位置和真正的错误没有任何关系。想加的话,先把 set -e 和 set -o pipefail 分开试,一次加一个。
下次再看到脚本莫名其妙停在 141 上,先去数那条管道右边有几个会提前退出的命令,比翻日志快。