11 - 错误处理
11 - 错误处理
11.1 退出码(Exit Code)
每条命令执行后都会返回一个退出码(0-255),其中 0 表示成功,非 0 表示失败。
| 退出码 | 含义 | 常见场景 |
|---|
| 0 | 成功 | 命令正常完成 |
| 1 | 一般错误 | 通用失败 |
| 2 | 误用 Shell 命令 | 参数错误、语法错误 |
| 126 | 权限不足 | 文件不可执行 |
| 127 | 命令未找到 | PATH 中找不到命令 |
| 128+N | 被信号 N 杀死 | 130=Ctrl+C (SIGINT), 137=SIGKILL, 143=SIGTERM |
| 128 | 无效退出码参数 | exit 参数无效 |
| 255 | 退出码超出范围 | exit 256 |
# 检查退出码
ls /etc/hosts
echo "退出码: $?" # 0
ls /nonexistent
echo "退出码: $?" # 2 或 1
# 自定义退出码
readonly EXIT_SUCCESS=0
readonly EXIT_ERROR=1
readonly EXIT_USAGE=2
readonly EXIT_CONFIG=3
readonly EXIT_DEPENDENCY=4
# 使用自定义退出码
check_prerequisites() {
command -v docker &>/dev/null || { echo "需要安装 Docker" >&2; return $EXIT_DEPENDENCY; }
command -v git &>/dev/null || { echo "需要安装 Git" >&2; return $EXIT_DEPENDENCY; }
return $EXIT_SUCCESS
}
11.2 严格模式
# 推荐的严格模式开头
set -euo pipefail
# 等价于:
# set -e → 命令失败立即退出
# set -u → 使用未定义变量报错
# set -o pipefail → 管道中任一命令失败则整体失败
set -e 的注意事项
set -e
# ✅ 失败的命令会导致脚本退出
ls /nonexistent # 脚本在这里终止
# ⚠️ 以下场景 set -e 不会触发退出:
# 1. if 条件中的命令
if ls /nonexistent; then # 不会退出
echo "不会执行"
fi
# 2. || 或 && 后的命令
ls /nonexistent || echo "失败了" # 不会退出
# 3. 子 Shell 中的失败
(result=$(ls /nonexistent)) # 子 Shell 失败,但父 Shell 可能不退出
# 4. 管道中除最后一个命令外的失败
false | echo "hello" # echo 成功,$?=0
# 解决:set -o pipefail
# 5. 函数返回值被检查时
my_func() {
ls /nonexistent
return 0
}
if my_func; then # 函数返回 0,不会退出
echo "不会触发 -e"
fi
# 6. 变量赋值右侧的命令失败
var=$(false) # 这里会触发 -e 退出
set -u 的注意事项
set -u
# ❌ 使用未定义变量会报错
echo "$undefined_var" # unbound variable
# ✅ 提供默认值
echo "${undefined_var:-默认值}"
# ✅ 检查变量是否设置
if [[ -v my_var ]]; then
echo "$my_var"
fi
# ⚠️ 特殊变量不会触发 -u
echo "$@" # 即使没有参数也不会报错
echo "$*" # 同上
echo "$1" # ⚠️ 如果没有第一个参数会报错
11.3 trap 信号处理
trap 是 Bash 的事件处理机制,可以在接收到信号或退出时执行清理代码。
常用信号
| 信号 | 编号 | 说明 | 触发方式 |
|---|
SIGHUP | 1 | 挂起 | 终端关闭 |
SIGINT | 2 | 中断 | Ctrl+C |
SIGQUIT | 3 | 退出 | Ctrl+\ |
SIGTERM | 15 | 终止 | kill 命令 |
SIGKILL | 9 | 强制终止 | kill -9(无法捕获) |
EXIT | 0 | 脚本退出 | 脚本结束时 |
ERR | - | 命令出错 | 命令返回非 0 |
DEBUG | - | 调试 | 每条命令执行前 |
RETURN | - | 函数返回 | 函数/脚本返回时 |
# 基本用法
trap 'echo "收到 Ctrl+C"' INT
trap 'echo "脚本退出"' EXIT
# 清理临时文件
cleanup() {
local exit_code=$?
echo "清理临时文件..."
rm -rf "$TEMP_DIR" 2>/dev/null
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# 创建临时目录
TEMP_DIR=$(mktemp -d)
# 捕获 ERR 信号(命令失败时)
trap 'echo "错误发生在第 $LINENO 行,命令: $BASH_COMMAND"' ERR
# 禁用 trap
trap - INT # 移除 INT 处理
trap '' INT # 忽略 INT 信号(Ctrl+C 无效)
trap 常用模式
# 模式一:创建临时文件/目录,自动清理
create_temp() {
local tmpdir
tmpdir=$(mktemp -d)
trap "rm -rf '$tmpdir'" EXIT INT TERM
echo "$tmpdir"
}
# 模式二:后台进程清理
PID_FILE="/tmp/myapp.pid"
cleanup() {
[[ -f "$PID_FILE" ]] && {
kill "$(cat "$PID_FILE")" 2>/dev/null
rm -f "$PID_FILE"
}
}
trap cleanup EXIT
# 写入 PID
echo $$ > "$PID_FILE"
# 模式三:锁文件清理
LOCK_FILE="/tmp/myapp.lock"
acquire_lock() {
if [[ -f "$LOCK_FILE" ]]; then
local old_pid
old_pid=$(cat "$LOCK_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
echo "另一个实例正在运行 (PID: $old_pid)" >&2
exit 1
fi
echo "清除过期锁文件" >&2
fi
echo $$ > "$LOCK_FILE"
trap "rm -f '$LOCK_FILE'" EXIT
}
acquire_lock
11.4 业务场景:健壮的脚本框架
#!/bin/bash
# robust_script.sh —— 带完整错误处理的脚本框架
set -euo pipefail
# ---- 全局配置 ----
readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
readonly LOCK_FILE="/tmp/${SCRIPT_NAME}.lock"
# ---- 日志函数 ----
log() {
local level="$1"
shift
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*"
echo "$msg" | tee -a "$LOG_FILE" >&2
}
log_info() { log "INFO" "$@"; }
log_warn() { log "WARN" "$@"; }
log_error() { log "ERROR" "$@"; }
log_debug() { log "DEBUG" "$@"; }
# ---- 错误处理 ----
error_handler() {
local line=$1
local code=$2
log_error "脚本在第 $line 行失败,退出码: $code"
log_error "失败命令: ${BASH_COMMAND}"
exit "$code"
}
trap 'error_handler ${LINENO} $?' ERR
# ---- 清理函数 ----
TEMP_FILES=()
TEMP_DIRS=()
register_temp_file() { TEMP_FILES+=("$1"); }
register_temp_dir() { TEMP_DIRS+=("$1"); }
cleanup() {
local exit_code=$?
log_info "正在清理..."
for file in "${TEMP_FILES[@]}"; do
[[ -f "$file" ]] && rm -f "$file" && log_debug "删除文件: $file"
done
for dir in "${TEMP_DIRS[@]}"; do
[[ -d "$dir" ]] && rm -rf "$dir" && log_debug "删除目录: $dir"
done
[[ -f "$LOCK_FILE" ]] && rm -f "$LOCK_FILE"
log_info "清理完成,退出码: $exit_code"
exit "$exit_code"
}
trap cleanup EXIT INT TERM
# ---- 锁机制 ----
acquire_lock() {
if [[ -f "$LOCK_FILE" ]]; then
local old_pid
old_pid=$(<"$LOCK_FILE")
if kill -0 "$old_pid" 2>/dev/null; then
log_error "另一个实例正在运行 (PID: $old_pid)"
exit 1
fi
log_warn "清除过期锁文件 (PID: $old_pid)"
fi
echo $$ > "$LOCK_FILE"
}
# ---- 重试机制 ----
retry() {
local max_attempts="$1"
local delay="$2"
local attempt=1
shift 2
while ((attempt <= max_attempts)); do
if "$@"; then
return 0
fi
log_warn "第 $attempt/$max_attempts 次尝试失败"
if ((attempt < max_attempts)); then
log_info "等待 ${delay}s 后重试..."
sleep "$delay"
fi
((attempt++))
done
log_error "所有 $max_attempts 次尝试均失败"
return 1
}
# ---- 主逻辑 ----
main() {
acquire_lock
log_info "脚本启动: $SCRIPT_NAME (PID: $$)"
# 创建临时目录
local tmpdir
tmpdir=$(mktemp -d)
register_temp_dir "$tmpdir"
# 业务逻辑
log_info "开始处理..."
# 使用重试机制
retry 3 5 curl -sf "https://api.example.com/health" -o "$tmpdir/response.json"
# 创建临时文件
local tmpfile="$tmpdir/processed.txt"
touch "$tmpfile"
register_temp_file "$tmpfile"
echo "处理完成" > "$tmpfile"
log_info "处理完成"
}
main "$@"
11.5 错误处理最佳实践
命令级别的错误处理
# 方式一:短路处理
cd /tmp || { echo "无法切换目录" >&2; exit 1; }
# 方式二:if 语句
if ! cd /tmp; then
echo "无法切换目录" >&2
exit 1
fi
# 方式三:retry 重试
retry 3 5 some_command
# 方式四:|| 提供备选方案
result=$(command_may_fail) || result="默认值"
函数级别的错误处理
# 函数返回错误码
validate_input() {
local input="$1"
[[ -z "$input" ]] && { echo "输入为空" >&2; return 1; }
[[ ! -f "$input" ]] && { echo "文件不存在: $input" >&2; return 2; }
[[ ! -r "$input" ]] && { echo "文件不可读: $input" >&2; return 3; }
return 0
}
# 调用者检查错误
if ! validate_input "$config_file"; then
echo "输入验证失败" >&2
exit 1
fi
11.6 注意事项
| 陷阱 | 说明 | 解决方案 |
|---|
set -e 在子 Shell | 子 Shell 中的失败可能不传播 | 使用 set -o pipefail |
trap ERR 在函数中 | 默认不传播到函数调用者 | set -E(errtrace) |
| 清理函数中的错误 | cleanup 本身可能失败 | ` |
kill -9 无法捕获 | SIGKILL 不可捕获 | 确保使用 SIGTERM |
| 退出码范围 | 只有 0-255 | 取模处理 |
11.7 扩展阅读