Shell 脚本调试详解

Shell脚本调试简介

调试是Shell脚本开发中至关重要的环节。通过有效的调试技术,我们可以快速定位和修复脚本中的错误,提高代码质量和开发效率。

基本调试技巧

使用echo、set命令等基本方法进行调试。

高级调试方法

使用trap、调试模式等高级技术。

调试工具

使用ShellCheck、bashdb等专业工具。

错误处理

预防和处理各种常见错误。

问题诊断

诊断和解决复杂问题的方法。

最佳实践

调试和错误处理的最佳实践。

调试的重要性

有效的调试不仅可以快速定位问题,还能帮助理解代码执行流程,提高代码质量。掌握调试技巧是每个Shell脚本开发者的必备技能。

基本调试技巧

使用简单有效的方法进行脚本调试。

发现问题
添加调试输出
分析输出结果
定位问题
修复问题

使用echo进行调试

最简单直接的调试方法,通过输出变量值和执行状态来了解脚本运行情况。

echo_debug.sh
#!/bin/bash

# 使用echo进行调试的示例

echo "=== 脚本开始执行 ==="

# 调试变量赋值
name="Alice"
echo "DEBUG: name变量值为: $name"

# 调试函数调用
process_user() {
local username=$1
echo "DEBUG: 进入process_user函数,参数: $username"

# 模拟处理过程
sleep 1

echo "DEBUG: 用户 $username 处理完成"
}

# 调试循环
echo "DEBUG: 开始处理用户列表"
users=("Alice" "Bob" "Charlie")

for user in "${users[@]}"; do
echo "DEBUG: 正在处理用户: $user"
process_user "$user"
done

# 调试条件判断
age=25
echo "DEBUG: age变量值为: $age"

if [ $age -ge 18 ]; then
echo "DEBUG: 进入成年分支"
echo "已成年"
else
echo "DEBUG: 进入未成年分支"
echo "未成年"
fi

# 调试命令执行结果
echo "DEBUG: 执行ls命令"
result=$(ls -la)
echo "DEBUG: ls命令输出行数: $(echo "$result" | wc -l)"

# 带时间戳的调试信息
debug_echo() {
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$timestamp] DEBUG: $1"
}

debug_echo "这是带时间戳的调试信息"
debug_echo "脚本执行完成"

echo "=== 脚本执行结束 ==="
1=== 脚本开始执行 ===
2DEBUG: name变量值为: Alice
3DEBUG: 开始处理用户列表
4DEBUG: 正在处理用户: Alice
5DEBUG: 进入process_user函数,参数: Alice
6DEBUG: 用户 Alice 处理完成
7DEBUG: age变量值为: 25
8DEBUG: 进入成年分支
9[2023-11-16 14:30:25] DEBUG: 这是带时间戳的调试信息
10=== 脚本执行结束 ===
echo调试技巧:
  • 使用前缀如DEBUG:便于区分正常输出和调试信息
  • 添加时间戳帮助分析执行时序
  • 输出变量名和值,格式如:DEBUG: 变量名=值
  • 在关键分支、循环和函数调用处添加调试输出
  • 可以使用环境变量控制调试输出的开关

使用set命令进行调试

Bash的set命令提供了强大的调试选项,可以控制脚本的执行行为。

set_debug.sh
#!/bin/bash

# set命令调试示例

echo "=== set命令调试演示 ==="

# 启用调试模式
echo "1. 启用调试模式: set -x"
set -x

# 这行命令会被显示(前面有+号)
name="Debug User"
echo "用户名: $name"

# 关闭调试模式
echo "2. 关闭调试模式: set +x"
set +x

echo "调试模式已关闭,后续命令不会显示"

# 启用严格模式
echo "3. 启用严格模式"
set -euo pipefail

# -e: 遇到错误立即退出
# -u: 使用未定义变量时报错
# -o pipefail: 管道中任意命令失败则整个管道失败

echo "严格模式已启用"

# 演示错误处理
handle_error() {
echo "错误处理函数被调用"
echo "错误代码: $?"
echo "错误命令: $BASH_COMMAND"
}

# 设置错误处理
trap handle_error ERR

echo "4. 演示未定义变量错误"
# 这行会触发错误,因为undefined_var未定义
# echo "未定义变量: $undefined_var"

echo "5. 演示命令失败错误"
# 这行会触发错误,因为目录不存在
# ls /nonexistent_directory

echo "6. 演示管道错误"
# 这个管道会失败
# cat /nonexistent_file | grep "something"

echo "=== 脚本结束 ==="

# 恢复默认设置
set +euo pipefail
trap - ERR
1=== set命令调试演示 ===
21. 启用调试模式: set -x
3+ name='Debug User'
4+ echo '用户名: Debug User'
5用户名: Debug User
6+ set +x
7调试模式已关闭,后续命令不会显示
set选项 说明 使用场景
-x 显示执行的命令及其参数 跟踪脚本执行流程
-v 显示执行的命令(原始格式) 查看命令展开前的样子
-e 命令失败时立即退出 严格错误处理
-u 使用未定义变量时报错 避免变量拼写错误
-o pipefail 管道中任意命令失败则失败 严格的管道错误处理
-n 只检查语法,不执行 语法检查

高级调试方法

使用更高级的技术进行复杂脚本的调试。

使用trap命令进行调试

trap命令可以捕获信号和特殊事件,用于实现高级调试和错误处理。

trap_debug.sh
#!/bin/bash

# trap命令调试示例

# 调试函数
debug_trap() {
echo "=== 调试信息 ==="
echo "当前时间: $(date)"
echo "最后命令: $BASH_COMMAND"
echo "退出状态: $?"
echo "当前函数: ${FUNCNAME[1]}"
echo "当前行号: $LINENO"
echo "脚本名称: $0"
echo "进程ID: $$"
echo "=== 调试结束 ==="
echo
}

# 设置调试陷阱
set_debug_trap() {
# 在每个命令执行前调用调试函数
trap 'debug_trap' DEBUG
}

# 移除调试陷阱
remove_debug_trap() {
trap - DEBUG
}

# 错误处理函数
error_handler() {
local exit_code=$?
local command=$BASH_COMMAND

echo "❌ 错误发生!"
echo "   命令: $command"
echo "   退出码: $exit_code"
echo "   行号: $LINENO"
echo "   函数: ${FUNCNAME[1]}"

# 可以选择退出或继续执行
# exit $exit_code
}

# 退出处理函数
exit_handler() {
echo "=== 脚本退出 ==="
echo "退出状态: $?"
echo "执行时间: $SECONDS 秒"
echo "=== 感谢使用 ==="
}

# 信号处理函数
signal_handler() {
local signal=$1
echo "收到信号: $signal"
echo "正在清理资源..."
# 执行清理操作
exit 1
}

# 设置陷阱
set_traps() {
# 错误陷阱
trap error_handler ERR

# 退出陷阱
trap exit_handler EXIT

# 信号陷阱
trap 'signal_handler SIGINT' SIGINT
trap 'signal_handler SIGTERM' SIGTERM

echo "陷阱设置完成"
}

# 演示函数
demo_function() {
local input=$1
echo "处理输入: $input"

# 模拟处理
sleep 1

# 模拟错误(取消注释测试)
# ls /nonexistent_directory

echo "处理完成"
}

# 主函数
main() {
echo "=== 开始执行脚本 ==="

# 设置陷阱
set_traps

# 启用调试陷阱(取消注释启用详细调试)
# set_debug_trap

echo "1. 正常操作演示"
demo_function "测试数据1"

echo "2. 第二个操作"
demo_function "测试数据2"

# 模拟长时间运行
echo "3. 模拟工作..."
for i in {1..3}; do
    echo "  工作进度: $i/3"
    sleep 1
done

echo "4. 所有操作完成"

# 移除调试陷阱
remove_debug_trap
}

# 运行主函数
main
trap命令常用信号:
  • EXIT - 脚本退出时触发
  • ERR - 命令返回非零状态时触发
  • DEBUG - 每个命令执行前触发
  • SIGINT - Ctrl+C中断时触发
  • SIGTERM - 终止信号时触发
  • RETURN - 函数返回时触发

自定义调试函数和工具

创建可重用的调试函数和工具,提高调试效率。

custom_debug.sh
#!/bin/bash

# 自定义调试函数示例

# 调试级别
DEBUG_LEVEL=${DEBUG_LEVEL:-0}  # 0=关闭, 1=基础, 2=详细, 3=全部

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color

# 调试日志函数
debug() {
local level=$1
local message=$2

if [ $DEBUG_LEVEL -ge $level ]; then
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    local level_name=""
    local color=$NC

    case $level in
        1) level_name="INFO"; color=$BLUE ;;
        2) level_name="DEBUG"; color=$CYAN ;;
        3) level_name="TRACE"; color=$PURPLE ;;
    esac

    echo -e "${color}[$timestamp] [$level_name] $message${NC}" >&2
fi
}

# 变量检查函数
check_variable() {
local var_name=$1
local var_value=$2

if [ -z "$var_value" ]; then
    debug 1 "变量 $var_name 为空或未设置"
    return 1
else
    debug 3 "变量 $var_name = $var_value"
    return 0
fi
}

# 函数跟踪
function_trace() {
local function_name=$1
local action=$2  # enter or exit

if [ $DEBUG_LEVEL -ge 2 ]; then
    local timestamp=$(date '+%Y-%m-%d %H:%M:%S')

    if [ "$action" = "enter" ]; then
        echo -e "${PURPLE}[$timestamp] [TRACE] → 进入函数: $function_name${NC}" >&2
    else
        echo -e "${PURPLE}[$timestamp] [TRACE] ← 退出函数: $function_name (返回值: $?)${NC}" >&2
    fi
fi
}

# 性能测量
start_timer() {
local timer_name=$1
TIMER_START_$timer_name=$(date +%s%N)
debug 2 "计时器 $timer_name 启动"
}

end_timer() {
local timer_name=$1
local start_var=TIMER_START_$timer_name
local start_time=${!start_var}

if [ -n "$start_time" ]; then
    local end_time=$(date +%s%N)
    local duration=$(( (end_time - start_time) / 1000000 ))  # 毫秒

    debug 1 "计时器 $timer_name: ${duration}ms"
    unset $start_var
else
    debug 1 "错误: 计时器 $timer_name 未启动"
fi
}

# 内存检查(简单版本)
check_memory() {
if command -v free >/dev/null 2>&1; then
    local memory_info=$(free -h | grep Mem | awk '{print $3 "/" $2}')
    debug 2 "内存使用: $memory_info"
fi
}

# 演示函数
demo_processing() {
function_trace "${FUNCNAME[0]}" "enter"

local input=$1
debug 1 "开始处理: $input"

start_timer "processing"
check_memory

# 模拟处理
debug 3 "步骤1: 验证输入"
check_variable "input" "$input"

debug 3 "步骤2: 处理数据"
sleep 0.5

debug 3 "步骤3: 生成结果"
local result="处理结果: ${input^^}"  # 转换为大写

end_timer "processing"
debug 1 "处理完成: $result"

function_trace "${FUNCNAME[0]}" "exit"
echo "$result"
}

# 主函数
main() {
echo "=== 自定义调试工具演示 ==="

# 设置调试级别
if [ -n "$1" ]; then
    DEBUG_LEVEL=$1
fi

debug 1 "调试级别: $DEBUG_LEVEL"
debug 2 "脚本启动: $0"
debug 3 "所有参数: $*"

start_timer "total_execution"

# 处理多个输入
inputs=("测试数据1" "测试数据2" "另一个测试")

for input in "${inputs[@]}"; do
    debug 1 "=== 处理输入: $input ==="
    result=$(demo_processing "$input")
    echo "结果: $result"
    echo
done

end_timer "total_execution"
debug 1 "所有处理完成"

echo "=== 演示结束 ==="
}

# 运行主函数
main "$@"
1=== 自定义调试工具演示 ===
2[14:30:25] [INFO] 调试级别: 2
3[14:30:25] [DEBUG] 脚本启动: custom_debug.sh
4[14:30:25] [DEBUG] 计时器 total_execution 启动
5[14:30:25] [INFO] === 处理输入: 测试数据1 ===
6[14:30:25] [TRACE] → 进入函数: demo_processing
7[14:30:25] [INFO] 开始处理: 测试数据1
8[14:30:25] [DEBUG] 计时器 processing 启动
9[14:30:25] [INFO] 处理完成: 处理结果: 测试数据1
10[14:30:25] [DEBUG] 计时器 total_execution: 1520ms

调试工具

使用专业工具提高调试效率和质量。

ShellCheck

Shell脚本静态分析工具,可以检测语法错误和常见问题。

shellcheck演示
#!/bin/bash

# 有问题的脚本示例(ShellCheck会检测到问题)

# 问题1: 未引用的变量
name=John
echo Hello $name

# 问题2: 文件不存在检查方式不对
if [ -f /tmp/*.log ]; then
echo "找到日志文件"
fi

# 问题3: 循环中的命令替换
for file in $(ls *.txt); do
echo "处理文件: $file"
done

# 问题4: 测试表达式中的常见错误
[ $var = "value" ]

# 问题5: 函数中未使用local变量
func() {
counter=0
counter=$((counter + 1))
}
1Line 6: echo Hello $name
2^-- SC2086: Double quote to prevent globbing and word splitting.
3Line 9: if [ -f /tmp/*.log ]; then
4^-- SC2144: -f doesn't work with globs. Use a for loop.
5Line 14: for file in $(ls *.txt); do
6^-- SC2045: Iterating over ls output is fragile. Use globs.
ShellCheck使用:
shellcheck script.sh - 检查脚本
shellcheck -s bash script.sh - 指定shell类型
shellcheck -x script.sh - 检查源命令
shellcheck -e SC2034 script.sh - 忽略特定警告

Bashdb

Bash调试器,提供类似GDB的调试体验。

bashdb演示
#!/bin/bash

# 安装bashdb
# Ubuntu/Debian: sudo apt-get install bashdb
# CentOS/RHEL: sudo yum install bashdb
# macOS: brew install bashdb

# 使用bashdb调试
# bashdb --debug script.sh

# 常用命令:
# break [行号]    - 设置断点
# run            - 运行脚本
# next           - 执行下一行
# step           - 进入函数
# print 变量     - 打印变量值
# watch 变量     - 监视变量变化
# backtrace      - 显示调用栈
# quit           - 退出调试器

# 示例调试会话:
# $ bashdb --debug example.sh
# bashdb(0)> break 10
# bashdb(0)> run
# bashdb(1)> print $variable
# bashdb(1)> next
# bashdb(1)> quit
Bashdb优势:
  • 支持断点调试
  • 可以单步执行
  • 查看变量值和调用栈
  • 支持条件断点
  • 提供完整的调试环境

综合示例

通过实际脚本示例展示调试技术的综合应用。

完整的调试框架

创建一个可重用的调试框架,应用于各种脚本项目。

debug_framework.sh
#!/bin/bash

# 完整的调试框架

# 配置
DEBUG_LEVEL=${DEBUG_LEVEL:-0}
LOG_FILE=${LOG_FILE:-"/var/tmp/debug_$$.log"}
ENABLE_COLOR=${ENABLE_COLOR:-true}
ENABLE_TIMESTAMP=${ENABLE_TIMESTAMP:-true}

# 颜色支持
if [ "$ENABLE_COLOR" = "true" ] && [ -t 1 ]; then
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
PURPLE='\033[0;35m'
CYAN='\033[0;36m'
NC='\033[0m'
else
RED=''; GREEN=''; YELLOW=''; BLUE=''; PURPLE=''; CYAN=''; NC=''
fi

# 日志级别
declare -A LOG_LEVELS=(
[0]="FATAL"
[1]="ERROR"
[2]="WARN"
[3]="INFO"
[4]="DEBUG"
[5]="TRACE"
)

# 初始化日志系统
init_logging() {
local log_dir=$(dirname "$LOG_FILE")
mkdir -p "$log_dir"
> "$LOG_FILE"
log "INFO" "日志系统初始化完成" "日志文件: $LOG_FILE"
}

# 日志函数
log() {
local level=$1
local message=$2
local context=$3

# 检查日志级别
local level_num=0
case $level in
    "FATAL") level_num=0 ;;
    "ERROR") level_num=1 ;;
    "WARN") level_num=2 ;;
    "INFO") level_num=3 ;;
    "DEBUG") level_num=4 ;;
    "TRACE") level_num=5 ;;
    *) level_num=3 ;;
esac

if [ $level_num -le $DEBUG_LEVEL ]; then
    local timestamp=""
    if [ "$ENABLE_TIMESTAMP" = "true" ]; then
        timestamp=$(date '+%Y-%m-%d %H:%M:%S')
    fi

    local color=$NC
    case $level in
        "FATAL") color=$RED ;;
        "ERROR") color=$RED ;;
        "WARN") color=$YELLOW ;;
        "INFO") color=$BLUE ;;
        "DEBUG") color=$CYAN ;;
        "TRACE") color=$PURPLE ;;
    esac

    local log_entry=""
    if [ -n "$timestamp" ]; then
        log_entry="[$timestamp]"
    fi

    log_entry="$log_entry [$level] $message"
    if [ -n "$context" ]; then
        log_entry="$log_entry ($context)"
    fi

    # 输出到控制台
    echo -e "${color}${log_entry}${NC}" >&2

    # 写入日志文件(无颜色)
    echo "${log_entry}" >> "$LOG_FILE"
fi
}

# 错误处理
set_error_handling() {
set -o errtrace
set -o nounset
set -o pipefail

trap 'error_handler $LINENO' ERR
trap 'exit_handler' EXIT
trap 'signal_handler SIGINT' SIGINT
}

# 错误处理函数
error_handler() {
local line_num=$1
local exit_code=$?
local command=$BASH_COMMAND

log "ERROR" "脚本执行错误" "行号: $line_num, 命令: $command, 退出码: $exit_code"

# 显示调用栈
if [ $DEBUG_LEVEL -ge 4 ]; then
    log "DEBUG" "调用栈:" "$(print_stack_trace)"
fi

exit $exit_code
}

# 退出处理函数
exit_handler() {
local exit_code=$?
log "INFO" "脚本退出" "退出码: $exit_code, 运行时间: ${SECONDS}秒"
}

# 信号处理函数
signal_handler() {
local signal=$1
log "WARN" "收到信号" "信号: $signal"
exit 1
}

# 打印调用栈
print_stack_trace() {
local frame=0
local stack_trace=""

while caller $frame; do
    ((frame++))
done | awk '{
    if (NR == 1) {
        stack_trace = $2 ":" $1 " in " $3
    } else {
        stack_trace = stack_trace "\n" $2 ":" $1 " in " $3
    }
} END {
    print stack_trace
}'
}

# 性能监控
declare -A TIMERS=()

start_timer() {
local timer_name=$1
TIMERS[$timer_name]=$(date +%s%N)
log "DEBUG" "启动计时器" "名称: $timer_name"
}

end_timer() {
local timer_name=$1
local start_time=${TIMERS[$timer_name]}

if [ -n "$start_time" ]; then
    local end_time=$(date +%s%N)
    local duration_ns=$((end_time - start_time))
    local duration_ms=$((duration_ns / 1000000))

    log "INFO" "计时器结束" "名称: $timer_name, 耗时: ${duration_ms}ms"
    unset TIMERS[$timer_name]
else
    log "WARN" "计时器未找到" "名称: $timer_name"
fi
}

# 资源监控
check_resources() {
if command -v free >/dev/null 2>&1; then
    local memory=$(free -h | awk '/^Mem:/ {print $3 "/" $2}')
    log "DEBUG" "内存使用" "$memory"
fi

if command -v df >/dev/null 2>&1; then
    local disk=$(df -h / | awk 'NR==2 {print $5}')
    log "DEBUG" "磁盘使用" "$disk"
fi
}

# 变量检查
validate_variable() {
local var_name=$1
local var_value=$2
local required=${3:-false}

if [ -z "$var_value" ]; then
    if [ "$required" = "true" ]; then
        log "ERROR" "必需变量为空" "变量名: $var_name"
        return 1
    else
        log "DEBUG" "变量为空" "变量名: $var_name"
    fi
else
    log "TRACE" "变量检查" "$var_name=$var_value"
fi

return 0
}

# 函数跟踪
trace_function() {
local function_name=$1
local action=$2

if [ $DEBUG_LEVEL -ge 5 ]; then
    if [ "$action" = "enter" ]; then
        log "TRACE" "进入函数" "$function_name"
    else
        log "TRACE" "退出函数" "$function_name (返回值: $?)"
    fi
fi
}

# 演示使用
demo_function() {
trace_function "${FUNCNAME[0]}" "enter"

local input=$1
log "INFO" "开始处理" "输入: $input"

start_timer "demo_processing"
check_resources

# 验证输入
validate_variable "input" "$input" true

# 模拟处理
log "DEBUG" "处理步骤1" "转换大小写"
local processed=$(echo "$input" | tr '[:lower:]' '[:upper:]')

log "DEBUG" "处理步骤2" "添加前缀"
processed="PROCESSED: $processed"

end_timer "demo_processing"
log "INFO" "处理完成" "结果: $processed"

trace_function "${FUNCNAME[0]}" "exit"
echo "$processed"
}

# 主函数
main() {
local args=("$@")

# 初始化
init_logging
set_error_handling

log "INFO" "脚本启动" "参数: ${args[*]}, PID: $$"
start_timer "script_execution"

# 处理参数
if [ ${#args[@]} -eq 0 ]; then
    log "WARN" "没有提供参数" "使用默认值"
    args=("默认数据1" "默认数据2")
fi

# 处理每个参数
local results=()
for arg in "${args[@]}"; do
    log "INFO" "处理参数" "值: $arg"
    result=$(demo_function "$arg")
    results+=("$result")
    log "INFO" "得到结果" "$result"
done

# 显示最终结果
log "INFO" "所有处理完成" "结果数量: ${#results[@]}"
for result in "${results[@]}"; do
    echo "最终结果: $result"
done

end_timer "script_execution"
log "INFO" "脚本执行完成" "总耗时"
}

# 运行主函数(如果直接执行)
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
main "$@"
fi
调试框架特性:
  • 可配置的日志级别
  • 文件和控制台双重输出
  • 颜色支持(可选)
  • 性能监控和计时
  • 资源使用监控
  • 完整的错误处理
  • 函数调用跟踪
  • 可重用的组件

调试最佳实践

推荐做法
  • 在开发初期就加入调试代码
  • 使用有意义的变量和函数名
  • 添加详细的注释
  • 使用版本控制记录调试过程
  • 编写可测试的代码
  • 使用静态分析工具
避免的做法
  • 不要在生产环境保留调试输出
  • 避免过度复杂的调试代码
  • 不要忽略警告信息
  • 避免硬编码调试开关
  • 不要忘记清理临时文件
  • 避免在循环中输出大量调试信息
❌ 不好的做法
# 调试代码混乱
x=5
y=10
# 这里有问题
result=$x+$y
echo $result  # 输出 5+10 而不是 15
✅ 好的做法
# 清晰的调试代码
x=5
y=10
debug "变量值" "x=$x, y=$y"

# 使用正确的算术运算
result=$((x + y))
debug "计算结果" "x+y=$result"

echo "$result  # 输出 15
常见调试陷阱:
  • 变量作用域问题 - 在函数中修改全局变量
  • 命令替换问题 - 使用反引号而不是$()
  • 引号使用错误 - 变量扩展中的引号问题
  • 路径问题 - 相对路径和绝对路径混淆
  • 权限问题 - 文件权限和用户权限
  • 环境差异 - 开发环境和生产环境差异