Shell 脚本出 bug,往往不是逻辑错,而是一个变量为空、一条命令静默失败,脚本带着错误结果继续往下跑。set -eux 和 shellcheck 是两味药:前者让脚本一出错就停、把每步执行打出来;后者静态扫一遍常见写法错误。这一篇把它们讲清楚,再配一个能直接抄进脚本头部的"保险套装"。
一、set 的几个开关
| 开关 | 作用 |
|---|---|
-e |
命令失败(非零退出)立即退出 |
-u |
引用未定义变量时报错退出 |
-x |
执行前打印命令展开结果(调试用) |
-o pipefail |
管道里任一命令失败,整条管道算失败 |
-E |
ERR 陷阱被子 shell / 函数继承 |
-n |
只读不执行,做语法检查 |
生产脚本开头一行组合用:
bash
set -euo pipefail
这一行的含义:命令失败就停、空变量立刻报、管道任何一环失败都算失败。
set -e:出错就停
bash
set -e
cp /etc/does-not-exist /tmp/x
echo "这行不会执行"
预期输出:
text
cp: cannot stat '/etc/does-not-exist': No such file or directory
cp 失败,脚本直接退出,后面的 echo 不会跑。没有 -e 的话,echo 照常执行,你以为一切正常。
但 -e 有例外 :命令在 if、while 条件里,或者在 && / || 链中、或者命令以 ! 开头时,失败不触发退出。这是故意的,因为你本来就是在判断失败。
set -u:空变量立刻报错
bash
set -u
fname=""
rm "$fname"
没有 -u 时变成 rm "",bash 报"缺少操作数",或者更糟------某些写法下误删当前目录内容。有 -u 直接:
text
rm: missing operand
可选变量用 ${var:-default},既满足 -u 又给默认值,这是脚本里最常见的写法。
set -x:每步打出来
bash
set -x
name=Ben
echo "hi $name"
预期输出:
text
+ name=Ben
+ echo 'hi Ben'
hi Ben
+ 之后就是命令真正执行时的样子。看变量到底传了什么、文件名是不是被展开,靠它。调试完 set +x 关掉。
pipefail
bash
set -o pipefail
make | tee build.log
没有 pipefail 时,管道退出码是最后一个命令(tee)的。make 编译失败,tee 成功,脚本以为一切正常。加了 pipefail,make 失败整条管道就失败。这条单独记不住,就和 -euo 写在一起。
二、手动调试:bash -x 和 PS4
不改脚本文件,直接:
bash
bash -x ./deploy.sh
每步 trace 打到标准错误,不影响正常输出。想在 trace 里看到行号和文件名,改 PS4:
bash
export PS4='+ [${BASH_SOURCE}:${LINENO}] '
bash -x ./deploy.sh
输出变成:
text
+ [deploy.sh:12] cp /etc/nginx.conf /tmp/
+ [deploy.sh:13] echo 完成
定位快很多。只想检查语法不跑:
bash
bash -n ./deploy.sh
有错报行号,没错静默退出。
三、shellcheck:静态扫错
安装
Ubuntu / Debian:
bash
sudo apt install shellcheck
CentOS / Rocky:
bash
sudo dnf install ShellCheck
也有在线版 shellcheck.net,把脚本贴上去就能出结果,不用装。
跑一下
bash
shellcheck deploy.sh
常见检查项:
| 编号 | 含义 | 怎么改 |
|---|---|---|
| SC2086 | 变量未加引号,会分词和展开通配 | 改成 "$var" |
| SC2034 | 变量赋值后从未使用 | 删掉或用掉 |
| SC2164 | cd 失败后继续执行 | `cd ... |
| SC2006 | 用了反引号 |
改成 $(...) |
| SC2015 | `A && B | |
| SC2166 | [ -n "$var" -a ... ] |
改成两个 [ ] 用 && |
| SC2086 | $@ 在某些上下文要写成 "$@" |
保留引号 |
示例:一段有问题的脚本
bash
#!/bin/bash
dir=/var/log
cd $dir
files=`ls *.log`
echo $files
shellcheck 输出(节选):
text
In deploy.sh line 3:
cd $dir
^-- SC2164: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
In deploy.sh line 4:
files=`ls *.log`
^-- SC2006: Use $(...) instead of `...`.
In deploy.sh line 5:
echo $files
^-- SC2086: Double quote to prevent globbing and word splitting.
改完:
bash
#!/bin/bash
set -euo pipefail
dir=/var/log
cd "$dir" || exit 1
files=$(ls *.log)
echo "$files"
四、⚠️ set -e 容易被坑的地方
-
函数里
local var=$(cmd):局部赋值的退出码是local内建自己的,不是 cmd 的,-e不会触发。拆开写:bashvar=$(cmd) local var或者
local var; var=$(cmd)。 -
cmd | grep something,grep 没匹配返回 1,带pipefail会直接退出。只想知道有没有,包到if里:bashif echo "$output" | grep -q something; then echo "命中" fi -
管道里想忽略某个命令失败,用
|| true:bashrm -f /tmp/*.tmp || true -
set -e在子 shell 和函数里的继承不完整,配合-E(errtrace)和-T(functrace)才能让trap ERR跟着进函数。 -
别把
-e当安全网,关键步骤后面显式判断退出码才是稳的。
五、知识扩展:shellcheck 在检查什么
shellcheck 不执行脚本,它把 bash 解析成语法树,再套用一组模式规则。每条 SC 编号就是一条规则,规则背后往往是一个真实踩坑案例:SC2086 对应"文件名带空格导致 ls 出来的列表被拆开",SC2164 对应"cd 到不存在的目录,后续操作全在错误路径上"。所以它报出来的不是风格建议,是 bug 候选------能改就改,拿不准就去官网查这条编号的解释。
set -e 历史上一直有争议。POSIX 规定它在很多情况下不生效(if 条件、&& 链、函数调用),不同 shell 实现还不一样。bash 4.4 加了 set -o errtrace(即 -E)和 set -o functrace 改善子 shell 行为。写跨发行版脚本时,别完全依赖 -e,关键步骤后面显式 || { echo 失败; exit 1; } 才是稳的。
调试脚本有个朴素原则:先 bash -n 过语法,再 shellcheck 过写法,再 bash -x 跑一遍看变量。三步下来,九成问题在这一关就暴露了,不用等到服务器上才发现。
六、常见调试场景速查
| 现象 | 怎么办 |
|---|---|
| 脚本一跑就报"未定义变量" | 注释掉 set -u,或者给变量加 ${var:-} |
| 不知道变量传了什么 | 临时 set -x,或在关键行后 echo "dbg: $var" >&2 |
| 语法错误但不知道哪行 | bash -n script.sh,报错会给行号 |
| 某些命令失败脚本还继续跑 | 检查是不是在 if 条件或 && 链里,那是正常的 |
函数里 local var=$(cmd) 失败没退出 |
拆开 local 和赋值,别写一行 |
临时插一行调试,最常用的是把变量打到标准错误(重定向不混进正常输出):
bash
echo "dbg: host=$host, port=$port" >&2
调试完删掉这行就行,比在文件里到处 set -x / set +x 干净。
-x 和 -v 的区别顺带说一下:-v 是把脚本原文打印一遍,你能看到每行长什么样;-x 是把变量展开后的实际命令打印一遍。排查"变量到底变成什么了"用 -x;排查"这行为什么没执行"用 -v。