Shell 脚本中如何进行错误处理和调试?有哪些常用的技巧?
Shell 脚本没有编译器的类型检查和异常机制,错误一旦发生往往悄无声息地传播,导致脚本在错误的状态下继续运行,产出难以排查的脏数据。所以错误处理和调试能力是区分"能跑的脚本"和"可靠脚本"的分水岭,也是面试中的高频考点。
错误处理的核心手段
set 选项:让脚本在错误面前不再沉默
Shell 默认行为是"命令失败了就失败了,继续执行下一条",这在自动化场景中极其危险。set 选项可以从源头改变这个行为:
bashset -e # 任何命令返回非零退出码时,脚本立即终止 set -u # 引用未定义变量时报错退出,而非当作空字符串 set -o pipefail # 管道中任意命令失败,整个管道返回失败状态
面试中常问的是为什么要组合使用 set -euo pipefail:
- 只用
set -e不够:false | true这条管道,false失败了但管道整体返回true的退出码 0,set -e不会触发退出。加上pipefail才能捕获管道内的失败。 - 只用
set -e+pipefail仍不够:echo $UNDEFINED_VAR在set -u缺失时只输出空行,不会报错;如果这个变量是文件路径,后续操作会作用在错误的目标上。
需要注意 set -e 的一个坑:它在 if、while、&&、|| 等条件上下文中不会触发退出,因为 Shell 认为你已经在主动处理该命令的返回值了。
trap:在脚本退出时做最后一件事
trap 用于捕获信号或事件,最常见的用途是资源清理:
bashcleanup() { rm -f "$TEMP_FILE" echo "Cleaned up temporary resources" >&2 } trap cleanup EXIT
面试追问:trap 能捕获哪些信号?EXIT 是信号吗?
EXIT不是真正的信号,而是 Shell 内置的伪信号,在脚本以任何方式退出时都会触发(包括正常退出、set -e触发的退出、未被捕获的信号导致的退出)。INT(Ctrl+C)、TERM(kill 默认信号)是真正的信号,可以在脚本运行中被外部触发。ERR是另一个伪信号,仅在命令失败(返回非零)时触发,可以用来做错误日志记录:
basherror_handler() { local line_no=$1 echo "Error at line $line_no" >&2 } trap 'error_handler $LINENO' ERR
自定义错误处理函数
对于需要携带上下文信息的错误,封装一个错误处理函数比直接 exit 1 更实用:
bashdie() { echo "[FATAL] $1 (line: ${BASH_LINENO[0]})" >&2 exit "${2:-1}" } # 使用示例 [ -f "$CONFIG" ] || die "Config file not found: $CONFIG"
BASH_LINENO 是 Bash 的内置数组,BASH_LINENO[0] 给出调用者所在行号,比手动标注更可靠。
调试的核心手段
set -x:最常用的调试开关
bash# 对整个脚本开启 bash -x script.sh # 对脚本中某一段开启 set -x # 需要调试的代码 set +x
set -x 的输出以 + 开头,会显示变量展开后的实际值。set -v 则只显示原始输入行,不做变量展开。两者可以组合使用(set -xv),但实践中 set -x 单独使用频率远高于 -v。
ShellCheck:静态分析比手动调试更高效
面试中提到调试,很多人只想到 set -x,但静态分析工具 ShellCheck 才是效率最高的手段。它能捕获类型错误、未引用变量、不安全的 rm 操作、废弃语法等问题:
bashshellcheck myscript.sh
安装一次、受益全程。在 CI 流水线中加入 shellcheck 检查,可以在代码合并前拦截大量低级错误,比运行时调试成本低得多。
条件式调试输出
生产环境不能到处加 echo,一个受环境变量控制的调试函数更合适:
bashdebug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0 }
关键细节:用 ${DEBUG:-0} 提供默认值,避免 set -u 下未定义变量报错;输出重定向到 >&2(标准错误),不污染标准输出的正常数据流。
面试中容易忽略的点
管道中的错误处理:这是面试高频追问。set -e + set -o pipefail 是基本答案,但更完整的做法是用 PIPESTATUS 数组获取管道中每个命令的退出码:
bashgrep "error" app.log | sort | uniq -c echo "Exit codes: ${PIPESTATUS[@]}"
子 Shell 中的 set 选项不向上传播:管道中的每个命令运行在子 Shell 中,set -e 在子 Shell 中触发退出只退出子 Shell,不会导致父脚本退出。这是 set -o pipefail 存在的原因之一。
临时文件的安全创建:mktemp 比 /tmp/myfile.$$ 更安全,后者存在符号链接攻击风险:
bashTEMP_FILE=$(mktemp) || die "Failed to create temp file" trap 'rm -f "$TEMP_FILE"' EXIT
一个可靠脚本的骨架
把以上手段组合起来,得到一个可以直接复用的模板:
bash#!/usr/bin/env bash set -euo pipefail # === 错误处理 === die() { echo "[FATAL] $*" >&2 exit 1 } # === 资源清理 === TEMP_FILE="" cleanup() { [ -n "$TEMP_FILE" ] && rm -f "$TEMP_FILE" } trap cleanup EXIT INT TERM # === 调试支持 === debug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0 } # === 主逻辑 === main() { local input="${1:-}" [ -n "$input" ] || die "Usage: $0 <input>" [ -f "$input" ] || die "File not found: $input" TEMP_FILE=$(mktemp) || die "mktemp failed" debug "Created temp file: $TEMP_FILE" # 业务逻辑 ... debug "Done" } main "$@"
面试中给出这个骨架,再配合对每个 set 选项和 trap 信号的讲解,基本能覆盖错误处理和调试这两个考点的全部内容。