Shell脚本中的trap命令详解及调试技巧
一、trap命令基础
1.1 trap的作用
trap命令用于在脚本执行过程中捕获和处理信号,当脚本收到指定信号时执行特定的命令或函数
1.2 基本语法
trap '命令' 信号列表
trap 信号列表 # 恢复默认信号处理
trap - # 查看当前设置的trap
trap -p [信号] # 查看指定信号的trap设置
二、常见的信号
2.1 常用信号列表
# 查看所有信号
kill -l
# 常用信号及其编号
SIGHUP 1 # 终端挂起或控制进程终止
SIGINT 2 # 中断进程 (Ctrl+C)
SIGQUIT 3 # 退出进程 (Ctrl+\)
SIGTERM 15 # 终止信号(默认kill命令)
SIGKILL 9 # 强制终止(无法捕获)
SIGSTOP 19 # 暂停进程(无法捕获)
SIGTSTP 20 # 终端挂起信号 (Ctrl+Z)
2.2 在脚本中常用的信号
EXIT 0 # 伪信号,脚本退出时触发
ERR # 伪信号,命令返回非零状态时触发
DEBUG # 伪信号,每个命令执行前触发
RETURN # 伪信号,函数返回时触发
三、trap的常见用法
3.0 脚本调试
set -euo pipefail
trap 'echo "脚本被用户中断"; exit 130' INT TERM
trap 'echo "[DEBUG] 错误位置: 文件: $0, 行号: $LINENO" >&2' ERR
trap 'echo "[DEBUG] 失败命令: $BASH_COMMAND, 退出码: $?" >&2' ERR
3.1 捕获Ctrl+C(中断信号)
#!/bin/bash
# 设置中断处理
trap 'echo "脚本被中断,正在清理..."; exit 1' INT
echo "开始执行任务..."
sleep 10
echo "任务完成"
3.2 脚本退出时的清理工作
#!/bin/bash
# 创建临时文件
TEMP_FILE=$(mktemp)
echo "创建临时文件: $TEMP_FILE"
# 设置退出时的清理函数
cleanup() {
echo "正在清理..."
rm -f "$TEMP_FILE"
echo "清理完成"
}
# 捕获退出信号
trap cleanup EXIT
# 脚本主要逻辑
echo "处理数据..."
echo "测试数据" > "$TEMP_FILE"
cat "$TEMP_FILE"
3.3 捕获错误
#!/bin/bash
# 错误处理函数
error_handler() {
local line=$1
local cmd=$2
echo "错误发生在第 $line 行: $cmd"
echo "错误代码: $?"
}
trap 'error_handler ${LINENO} "$BASH_COMMAND"' ERR
# 以下命令会触发ERR
ls /不存在的目录
echo "这行不会执行到上面命令已退出"
# 注意:某些情况下需要配合set -e使用
3.4 组合多个信号
#!/bin/bash
cleanup() {
echo "执行清理操作..."
# 清理代码
}
# 捕获多个信号
trap cleanup INT TERM EXIT
echo "脚本运行中..."
# 模拟长时间运行
sleep 100
四、高级用法
4.1 临时禁用trap
#!/bin/bash
handler() {
echo "收到信号"
}
trap handler INT
echo "按Ctrl+C测试,等待5秒..."
sleep 5
# 临时禁用信号处理
trap '' INT
echo "信号处理已禁用,按Ctrl+C无效"
sleep 5
# 恢复信号处理
trap handler INT
echo "信号处理已恢复"
sleep 5
4.2 不同阶段的trap设置
#!/bin/bash
echo "阶段1: 基本处理"
trap 'echo "收到INT信号"' INT
sleep 2
echo "阶段2: 更复杂的处理"
trap 'echo "正在退出..."; exit 1' INT
sleep 2
4.3 使用DEBUG信号进行调试
#!/bin/bash
# 设置调试信息
debug_handler() {
echo "DEBUG: 即将执行命令: $BASH_COMMAND"
}
trap debug_handler DEBUG
# 脚本命令
echo "第一行"
x=10
echo "x的值为: $x"
echo "最后一行"
# 禁用DEBUG
trap '' DEBUG
echo "调试模式已关闭"
五、调试技巧
5.1 基本调试方法
#!/bin/bash
# 方法1: 使用 -x 参数执行
# bash -x script.sh
# 方法2: 在脚本中开启调试
set -x # 开启调试模式
echo "调试信息"
set +x # 关闭调试模式
# 方法3: 只调试脚本的一部分
echo "正常输出"
(
set -x
echo "这部分会显示调试信息"
ls -l
)
echo "返回正常输出"
5.2 调试选项组合
#!/bin/bash
# 常用调试选项
set -e # 遇到错误立即退出
set -u # 使用未定义变量时报错
set -o pipefail # 管道中任意命令失败则整个管道失败
# 推荐组合使用
set -euo pipefail
# 可以放在脚本开头
echo "使用严格模式"
5.3 自定义调试函数
#!/bin/bash
# 调试函数
debug() {
if [[ "${DEBUG:-0}" -eq 1 ]]; then
echo "DEBUG [$(date '+%H:%M:%S')]: $*" >&2
fi
}
# 设置调试级别
VERBOSE="${VERBOSE:-0}"
DEBUG="${DEBUG:-0}"
# 使用调试函数
debug "脚本开始执行"
if [[ "$VERBOSE" -eq 1 ]]; then
echo "详细信息..."
fi
debug "脚本结束"
5.4 使用trap进行调试跟踪
#!/bin/bash
# 调试跟踪函数
trace() {
local func="${FUNCNAME[1]}"
local line="${BASH_LINENO[0]}"
echo "TRACE: $func:$line" >&2
}
# 在每个命令前执行trace
trap trace DEBUG
# 测试函数
test_function() {
echo "函数内部"
local x=5
echo "x=$x"
}
# 主程序
echo "主程序开始"
test_function
echo "主程序结束"
# 禁用跟踪
trap - DEBUG
六、综合示例
6.1 完整的脚本示例
#!/bin/bash
# 严格模式
set -euo pipefail
# 配置
readonly SCRIPT_NAME=$(basename "$0")
readonly TEMP_DIR=$(mktemp -d)
# 日志函数
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >&2
}
# 错误处理函数
error_exit() {
local msg="$1"
local code="${2:-1}"
log "错误: $msg"
exit "$code"
}
# 清理函数
cleanup() {
local exit_code=$?
log "开始清理..."
if [[ -d "$TEMP_DIR" ]]; then
rm -rf "$TEMP_DIR" && log "已删除临时目录: $TEMP_DIR"
fi
log "脚本 $SCRIPT_NAME 退出,状态码: $exit_code"
trap - EXIT INT TERM # 清理后移除trap
exit $exit_code
}
# 设置trap
trap cleanup EXIT INT TERM
# 主函数
main() {
log "脚本 $SCRIPT_NAME 开始执行"
# 检查参数
if [[ $# -eq 0 ]]; then
error_exit "用法: $0 <参数>"
fi
# 创建临时文件
local temp_file="$TEMP_DIR/data.txt"
echo "处理数据..." > "$temp_file"
# 模拟处理过程
for i in {1..5}; do
log "处理第 $i 个任务"
echo "结果 $i" >> "$temp_file"
sleep 1
done
# 显示结果
log "处理完成,结果:"
cat "$temp_file"
log "脚本执行成功"
}
# 执行主函数
main "$@"
6.2 调试脚本模板
#!/bin/bash
# 调试配置
DEBUG="${DEBUG:-0}"
VERBOSE="${VERBOSE:-0}"
# 调试输出函数
debug() {
[[ "$DEBUG" -eq 1 ]] && echo "DEBUG: $*" >&2
}
verbose() {
[[ "$VERBOSE" -eq 1 ]] && echo "INFO: $*" >&2
}
# 信号处理
trap 'echo "中断信号收到,正在退出..."; exit 1' INT TERM
trap 'debug "脚本退出,退出码: $?"' EXIT
# 主逻辑
debug "脚本开始,参数: $*"
verbose "详细模式已启用"
# 使用示例
debug "当前目录: $(pwd)"
# 条件调试
if [[ "$DEBUG" -eq 1 ]]; then
set -x # 开启命令跟踪
verbose "调试模式详细输出"
set +x # 关闭命令跟踪
fi
echo "正常输出"
七、最佳实践建议
始终设置清理trap:特别是在创建临时文件时
使用EXIT信号:确保脚本无论以何种方式退出都能执行清理
避免在trap中执行复杂操作:保持处理函数简单
测试信号处理:确保脚本能正确处理中断
使用严格模式:set -euo pipefail 可以减少错误
添加日志记录:便于调试和问题追踪
避免SIGKILL陷阱:SIGKILL(9)和SIGSTOP(19)无法被捕获
八、调试命令总结
# 常用调试执行方式
bash -x script.sh # 跟踪执行
bash -v script.sh # 显示所有行
bash -xv script.sh # 组合使用
# 在脚本内部
set -x # 开启调试
set -v # 显示输入行
set -e # 错误退出
set -u # 未定义变量报错
# 调试特定部分
(
set -x
# 需要调试的代码
command1
command2
set +x
)
## 基础版本
#!/bin/bash
# 设置错误处理
error_handler() {
echo "错误发生在第 $1 行"
echo "退出状态: $2"
}
trap 'error_handler ${LINENO} $?' ERR
## 加强版本
#!/bin/bash
error_handler() {
local line_no=$1
local exit_code=$2
local failed_command="${BASH_COMMAND}"
echo "错误发生在第 $line_no 行"
echo "退出状态: $exit_code"
echo "失败命令: $failed_command"
# 根据退出码分析失败原因
case $exit_code in
1) echo "失败原因: 一般性错误" ;;
2) echo "失败原因: shell内置命令用法错误" ;;
126) echo "失败原因: 命令不可执行" ;;
127) echo "失败原因: 命令未找到" ;;
128) echo "失败原因: 无效的退出参数" ;;
130) echo "失败原因: 被Ctrl+C终止" ;;
137) echo "失败原因: 被SIGKILL强制终止" ;;
255) echo "失败原因: 退出状态超出范围" ;;
*) echo "失败原因: 未知错误" ;;
esac
}
trap 'error_handler ${LINENO} $?' ERR
详细展示失败过程
error_handler() {
local line_no=$1
local exit_code=$2
local failed_command="${BASH_COMMAND}"
# 获取更多上下文信息
local script_name="${BASH_SOURCE[0]##*/}"
local function_name="${FUNCNAME[1]:-main}"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "======= 错误报告 ======="
echo "时间 : $timestamp"
echo "脚本 : $script_name"
echo "函数 : $function_name"
echo "行号 : $line_no"
echo "退出码 : $exit_code"
echo "失败命令 : $failed_command"
echo ""
# 分析失败原因
analyze_failure_reason "$exit_code" "$failed_command"
# 显示上下文代码
show_code_context "$line_no"
echo "========================"
}
analyze_failure_reason() {
local exit_code=$1
local command=$2
echo "失败原因分析:"
case $exit_code in
# 常见系统退出码
0) echo " ✓ 命令执行成功 (这不应该出现在错误处理中)" ;;
1) echo " ✗ 一般性错误 - 通常是命令自身的错误" ;;
2) echo " ✗ Shell内置命令用法错误" ;;
126) echo " ✗ 命令不可执行 - 权限问题或二进制文件损坏"
check_command_permissions "$command" ;;
127) echo " ✗ 命令未找到 - 请检查命令拼写或是否已安装"
suggest_alternative "$command" ;;
128) echo " ✗ 无效的退出参数" ;;
# 信号相关退出码 (128 + 信号编号)
130) echo " ✗ 被Ctrl+C终止 (SIGINT)" ;;
137) echo " ✗ 被SIGKILL强制终止" ;;
143) echo " ✗ 被SIGTERM优雅终止" ;;
# 特定命令的退出码
3) [[ "$command" =~ ^grep ]] && echo " ✗ grep: 未找到匹配内容" ;;
5) [[ "$command" =~ ^diff ]] && echo " ✗ diff: 文件有差异" ;;
64) echo " ✗ 命令行用法错误" ;;
65) echo " ✗ 输入数据格式错误" ;;
66) echo " ✗ 无法打开输入文件" ;;
67) echo " ✗ 地址未知" ;;
68) echo " ✗ 主机名未知" ;;
69) echo " ✗ 服务不可用" ;;
70) echo " ✗ 内部软件错误" ;;
71) echo " ✗ 系统错误 (如无法fork)" ;;
72) echo " ✗ 关键系统文件丢失" ;;
73) echo " ✗ 无法创建输出文件" ;;
74) echo " ✗ 输入/输出错误" ;;
75) echo " ✗ 临时故障,请重试" ;;
124) [[ "$command" =~ ^timeout ]] && echo " ✗ timeout: 命令执行超时" ;;
125) [[ "$command" =~ ^timeout ]] && echo " ✗ timeout: timeout命令自身失败" ;;
# 自定义退出码范围
100-199)echo " ⚠ 可能是脚本自定义的错误码" ;;
*) echo " ? 未知退出码: $exit_code"
echo " 请参考: man 3 exit 或命令的手册页" ;;
esac
# 如果是文件/目录操作,提供额外检查
if [[ "$command" =~ ^(ls|cd|cp|mv|rm|mkdir|touch|cat) ]]; then
check_file_issues "$command"
fi
}
# 辅助函数:检查命令权限
check_command_permissions() {
local cmd_path=$(which "${1%% *}" 2>/dev/null)
if [[ -n "$cmd_path" ]]; then
if [[ ! -x "$cmd_path" ]]; then
echo " 文件存在但不可执行: $cmd_path"
echo " 权限: $(ls -la "$cmd_path" | cut -d' ' -f1)"
fi
else
echo " 无法找到命令路径"
fi
}
# 辅助函数:建议替代命令
suggest_alternative() {
local cmd_name="${1%% *}"
# 常见命令的替代建议
case "$cmd_name" in
icc|icpc)
echo " 建议: 使用 gcc 或 clang" ;;
ipconfig)
echo " 建议: 使用 ifconfig (Linux) 或 ip addr" ;;
dir)
echo " 建议: 使用 ls" ;;
*)
# 尝试在PATH中查找相似命令
echo " 已安装的类似命令:"
compgen -c | grep -i "${cmd_name:0:3}" | head -5 | sed 's/^/ /' ;;
esac
}
# 辅助函数:检查文件相关问题
check_file_issues() {
local command=$1
# 提取文件路径(简化版)
local file_path=$(echo "$command" | grep -o "[^ ]*\/[^ ]*" | head -1)
if [[ -n "$file_path" ]]; then
echo ""
echo "文件检查:"
if [[ ! -e "$file_path" ]]; then
echo " ✗ 文件不存在: $file_path"
# 检查父目录
local parent_dir=$(dirname "$file_path")
if [[ ! -d "$parent_dir" ]]; then
echo " ✗ 父目录也不存在: $parent_dir"
fi
elif [[ -d "$file_path" && ! "$command" =~ ^(cd|ls|find) ]]; then
echo " ⚠ 这是一个目录,不是文件: $file_path"
elif [[ ! -r "$file_path" && "$command" =~ ^(cat|less|more|head|tail) ]]; then
echo " ✗ 文件不可读: $file_path"
echo " 权限: $(ls -la "$file_path" | cut -d' ' -f1)"
elif [[ ! -w "$file_path" && "$command" =~ ^(cp|mv|rm|echo.*\>) ]]; then
echo " ✗ 文件不可写: $file_path"
echo " 权限: $(ls -la "$file_path" | cut -d' ' -f1)"
elif [[ ! -x "$file_path" && "$command" =~ \.\/ ]]; then
echo " ✗ 脚本不可执行: $file_path"
echo " 使用: chmod +x \"$file_path\""
fi
fi
}
# 辅助函数:显示代码上下文
show_code_context() {
local line_no=$1
local script_file="${BASH_SOURCE[0]}"
if [[ -f "$script_file" ]]; then
echo ""
echo "代码上下文:"
# 显示前3行到后3行
local start_line=$((line_no - 3))
local end_line=$((line_no + 3))
[[ $start_line -lt 1 ]] && start_line=1
sed -n "${start_line},${end_line}p" "$script_file" | \
awk -v err_line="$line_no" -v start="$start_line" '
{
line_num = NR + start - 1
if (line_num == err_line) {
printf " %3d >>> %s\n", line_num, $0
} else {
printf " %3d %s\n", line_num, $0
}
}'
fi
}
trap 'error_handler ${LINENO} $?' ERR