调试与脚本规范
# 第 11 章 调试与脚本规范
# 目录介绍
# 11.1 调试排错
# 11.1.1 set -x——逐行跟踪执行
set -x 让 Shell 在执行每条命令前先打印它——是排查"脚本跑到哪一行了"最直接的工具:
#!/bin/bash
# ===== 基础用法 =====
set -x # 开启跟踪
name="alice"
echo "Hello $name"
set +x # 关闭跟踪
# -x 输出格式(以 + 开头):
# + name=alice
# + echo 'Hello alice'
# ===== 局部调试 =====
# 不需要全局开 -x,用 {} 包裹关键区域
build_app() {
echo "Building..."
set -x # 只跟踪这部分
gcc -o app main.c util.c -lpthread
set +x
echo "Build done"
}
# ===== 执行时临时开启 =====
# bash -x script.sh # 整个脚本跟踪
# bash -x script.sh 2>trace.log # 跟踪日志输出到文件(stdout 分开)
# ===== 管道中调试 =====
# 只看脚本的跟踪输出(不含正常输出)
bash -x script.sh 2>&1 | grep '^+'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 11.1.2 set -e / -u / -o pipefail——故障快速暴露
这三个选项是生产级脚本的安全带,组合使用能 90% 避免"静默失败":
#!/bin/bash
# ===== 标准开头:三大件缺一不可 =====
set -euo pipefail
# -e: 任何命令返回非 0 立即退出(快速失败)
# -u: 使用未定义变量时报错退出
# -o pipefail: 管道中任何一个命令失败,整个管道都算失败
# ===== set -e 详解 =====
# ❌ 不加 -e:错误被忽略——后续代码在错误状态运行
rm /nonexistent/file
echo "继续执行..." # 这条也会执行!
# 输出:rm: cannot remove ... \n 继续执行...
# ✅ 加 -e
set -e
rm /nonexistent/file # 脚本在这里退出
echo "永远不会执行"
# ===== -e 的例外情况 =====
# 以下场景不会触发 -e:
grep "pattern" file.txt || true # || 连接的命令
if grep "pattern" file.txt; then ... # if/while 条件中
while ! ping -c 1 host; do sleep 1; done # while 条件中
# ===== set -u 详解 =====
# ❌ 不加 -u
echo "Hello $UNDEFINED_VAR" # 输出:Hello (无报错)
# ✅ 加 -u
set -u
echo "Hello $UNDEFINED_VAR" # 报错:UNDEFINED_VAR: unbound variable
# 安全引用可能不存在的变量:${VAR:-default}
echo "Hello ${UNDEFINED_VAR:-world}" # 输出:Hello world
# ===== set -o pipefail 详解 =====
# ❌ 不加 pipefail:只看管道最后一个命令的退出码
false | true
echo "退出码: $?" # 输出:0(true 成功了,false 的失败被忽略)
# ✅ 加 pipefail
set -o pipefail
false | true
echo "退出码: $?" # 输出:1(false 的失败被传递)
# ===== 实战:有选择的退出控制 =====
# 某些命令返回非 0 是正常的(如 grep 没找到)
if ! command -v jq &>/dev/null; then
echo "jq 未安装,跳过 JSON 处理"
# 不会触发 -e 因为用了 if 条件
fi
# 明确允许失败:|| true
set -e
cleanup_temp || true # cleanup 失败不影响主流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# 11.1.3 trap——信号捕获与清理
trap 让脚本在退出、中断、错误时自动执行清理——防止留下垃圾文件:
#!/bin/bash
# ===== trap 基础语法 =====
# trap '要执行的命令' 信号
# ===== EXIT —— 无论正常/异常退出都执行 =====
cleanup() {
echo "清理中..."
rm -rf "$TEMP_DIR"
rm -f /tmp/mylock
echo "清理完成"
}
trap cleanup EXIT
TEMP_DIR=$(mktemp -d)
# ... 脚本主体 ...
# 无论脚本正常结束还是中途报错,cleanup 都会执行
# ===== ERR —— 捕获命令失败 =====
trap 'echo "[$(date)] 错误发生在 行$LINENO 退出码$?" >> error.log' ERR
# ===== INT / TERM —— 捕获 Ctrl+C 和 kill =====
trap 'echo "收到中断信号,正在安全退出..."; exit 1' INT TERM
# ===== DEBUG —— 每条命令执行前触发(调试用)=====
trap 'echo "DEBUG: 行 $LINENO: $BASH_COMMAND"' DEBUG
# 比 set -x 更灵活,可自定义输出格式
# ===== RETURN —— 函数或 source 返回时触发 =====
my_func() {
trap 'echo "my_func 返回"' RETURN
echo "函数体"
}
my_func
# 输出:函数体\nmy_func 返回
# ===== trap 组合使用模板 =====
cat > /tmp/trap_template.sh << 'SCRIPT'
#!/bin/bash
set -euo pipefail
TEMP_DIR=""
LOCK_FILE="/var/run/script.lock"
cleanup() {
local exit_code=$?
echo "[$(date)] 脚本退出,退出码: $exit_code"
# 清理临时文件
[[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]] && rm -rf "$TEMP_DIR"
# 释放锁
[[ -f "$LOCK_FILE" ]] && rm -f "$LOCK_FILE"
exit $exit_code
}
trap cleanup EXIT INT TERM
# 错误时记录
trap 'echo "[$(date)] 错误: 行 $LINENO 命令 $BASH_COMMAND (退出码 $?)" >&2' ERR
main() {
TEMP_DIR=$(mktemp -d)
# ... 主逻辑 ...
}
main "$@"
SCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 11.1.4 PS4——自定义跟踪输出
PS4 是 set -x 输出的前缀,自定义它可以让跟踪信息包含行号、函数名和时间:
#!/bin/bash
# ===== 默认 PS4 =====
# PS4='+ ' # 默认值
set -x
echo "test"
# 输出:+ echo 'test'
# ===== 加上行号和函数名 =====
export PS4='+(${BASH_SOURCE}:${LINENO}): ${FUNCNAME[0]:+${FUNCNAME[0]}(): }'
set -x
my_func() {
local x=42
echo "x=$x"
}
my_func
# 输出:
# +(script.sh:10): my_func(): local x=42
# +(script.sh:11): my_func(): echo 'x=42'
# ===== 加上时间戳 =====
export PS4='+[$(date "+%H:%M:%S")] ${BASH_SOURCE}:${LINENO}: '
# 输出:+[14:30:05] script.sh:10: local x=42
# ===== 实战:调试友好的 PS4 =====
export PS4='+${BASH_SOURCE}:${LINENO}(${FUNCNAME[0]:+${FUNCNAME[0]}}) '
# ===== 仅调试特定函数时生效 =====
debug_function() {
local old_ps4="$PS4"
export PS4='+[DEBUG] ${FUNCNAME[0]}:${LINENO}: '
set -x
# ... 待调试代码 ...
set +x
export PS4="$old_ps4"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 11.1.5 常见错误 Top 10 与排查
#!/bin/bash
# ===== 1. 变量赋值等号两边有空格 =====
# ❌
name = "alice" # Shell 把 name 当命令,= 当参数
# ✅
name="alice"
# ===== 2. [ ] 条件测试忘加空格 =====
# ❌
if [$a -eq 1]; then # [ 和 ] 是命令,必须和参数有空格
# ✅
if [ "$a" -eq 1 ]; then
# ===== 3. 变量未加引号——单词拆分 =====
# ❌
filename="my file.txt"
rm $filename # 等于 rm my file.txt → 删两个文件!
# ✅
rm "$filename"
# ===== 4. 管道中的变量作用域 =====
# ❌ 管道右侧在子 shell 中运行,变量修改不会传回
count=0
cat file.txt | while read line; do
((count++))
done
echo "$count" # 仍然是 0!
# ✅ 用 here-string/process substitution 避免子 shell
while read line; do
((count++))
done < file.txt
# ===== 5. set -e 被忽略的场景 =====
# ❌ 函数在条件中被调用——即使内部报错也不会退出 set -e
check_status() {
false
echo "这行还会执行"
}
if check_status; then :; fi
# ===== 6. 数组索引和 ${} 混淆 =====
# ❌ arr[0] 不需要大括号
echo ${arr[0]} # 可以
echo $arr[0] # ❌ 只输出 ${arr}[0] 的空值
# ===== 7. = vs == =====
# [ ] 里用 = 做字符串比较,== 是 bash 扩展
[ "$a" = "$b" ] # POSIX
[ "$a" == "$b" ] # bash only(但也能用)
[[ "$a" == "$b" ]] # [[ ]] 里推荐 ==
# ===== 8. 只重定向 stderr 忘重定向 stdout =====
# ❌ command 2>&1 > file.log # stderr 去了终端(重定向顺序错误)
# ✅ command > file.log 2>&1 # 先把 stdout 到文件,再把 stderr 跟过去
# ===== 9. crontab 时间表达式 =====
# ❌ 每天凌晨 3 点(容易写错)
# 0 3 * * * ← 分 时 日 月 周
# * * * * 3 ← 这不是凌晨 3 点,是每周三的每分钟
# ===== 10. EOF 标识符前有空格 =====
# ❌
cat << EOF # heredoc
EOF ← 这里不能有空格或 tab(除非用 <<-)
# ✅
cat << 'EOF'
EOF
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# 11.1.6 shellcheck——静态分析救星
#!/bin/bash
# ===== 安装 =====
# macOS: brew install shellcheck
# Linux: apt install shellcheck / yum install ShellCheck
# CI: docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable *.sh
# ===== 使用 =====
shellcheck script.sh # 检查单个脚本
shellcheck --severity=error script.sh # 只看错误级别
shellcheck -x script.sh # 跟踪 source 的文件一起检查
shellcheck -f json script.sh # JSON 格式(CI 集成)
# ===== 常见 shellcheck 警告与修复 =====
# SC2086: 变量未加引号
# ❌ rm $file
# ✅ rm "$file"
# SC2164: cd 没有检查返回值
# ❌ cd /some/dir
# ✅ cd /some/dir || exit 1
# SC2068: 数组未加引号
# ❌ for f in ${files[@]}
# ✅ for f in "${files[@]}"
# SC2046: 命令替换未加引号
# ❌ for f in $(ls *.txt)
# ✅ for f in *.txt
# SC2155: declare/export 掩盖返回值
# ❌ export MYVAR=$(some_command)
# ✅ MYVAR=$(some_command); export MYVAR
# ===== 在每个脚本中消掉 shellcheck 警告 =====
# 特定行忽略:行尾加注释
# shellcheck disable=SC2034 # 忽略"变量未使用"警告
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 11.2 脚本规范
# 11.2.1 文件头注释模板
#!/bin/bash
# ============================================
# 脚本名称: backup_database.sh
# 功能描述: 备份 MySQL 数据库并压缩归档
# 使用方法: ./backup_database.sh [数据库名]
# ./backup_database.sh --all 备份所有数据库
# 参数说明:
# -a, --all 备份所有数据库
# -d, --db NAME 指定数据库名
# -o, --output DIR 指定输出目录(默认 /backups)
# -h, --help 显示帮助
# 环境变量:
# MYSQL_PWD MySQL 密码 (推荐用 ~/.my.cnf)
# BACKUP_DIR 备份目录 (覆盖 -o 参数)
# 退出码:
# 0 成功
# 1 参数错误
# 2 备份失败
# 3 磁盘空间不足
# 作者: alice
# 创建日期: 2025-06-10
# 更新日志:
# 2025-06-10 v1.0 初始版本
# 2025-07-01 v1.1 增加压缩和清理逻辑
# ============================================
set -euo pipefail
IFS=$'\n\t' # 防止空格/制表符导致意外分词
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 11.2.2 函数命名与组织
#!/bin/bash
# ===== 函数命名规范 =====
# 使用 snake_case(小写+下划线)
# 动词开头:get_ / set_ / check_ / do_ / parse_ / build_ / deploy_
get_config() { ... } # 获取值
set_defaults() { ... } # 设置默认值
check_port() { ... } # 检查/验证
do_backup() { ... } # 执行动作
parse_args() { ... } # 解析输入
build_image() { ... } # 构建
deploy_app() { ... } # 部署
# ===== 函数注释 =====
# 每个函数加一行描述
# Usage: function_name <arg1> <arg2>
# 或更详细:
# @param $1 输入文件路径
# @param $2 输出目录(可选,默认 /tmp)
# @return 0 成功, 1 失败
# @stdout 处理结果
parse_config() {
local file="$1"
local output="${2:-/tmp}"
# ...
}
# ===== 变量命名 =====
# 全局变量:大写
readonly CONFIG_FILE="/etc/myapp/config"
readonly MAX_RETRIES=5
# 局部变量:小写 + local 声明
function my_func() {
local input_file="$1"
local retries=0
# ...
}
# ===== 函数组织顺序 =====
# 1. 全局常量
# 2. 工具/辅助函数
# 3. 核心业务函数
# 4. main() 入口
# 5. main "$@" 调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 11.2.3 缩进/换行/行宽
#!/bin/bash
# ===== 缩进:2 空格(Google Style)或 Tab(各有拥趸)=====
# 推荐 2 空格——在不同的编辑器中显示一致
if [[ -f "$file" ]]; then
while IFS= read -r line; do
case "$line" in
ERROR*)
log_error "$line"
;;
WARN*)
log_warn "$line"
;;
esac
done < "$file"
fi
# ===== 长命令换行:用 \ 续行 =====
# ✅
curl -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"key": "value"}' \
"https://api.example.com/v1/data"
# 对于管道,换行符在 | 之后
find /var/log \
-name "*.log" \
-mtime +7 \
-type f \
| xargs rm -f
# ===== if 语句风格 =====
# ✅ then 和 if 同行(分号分隔)
if [[ "$x" -eq 1 ]]; then
echo "one"
fi
# ✅ then 另起一行也可以
if [[ "$x" -eq 1 ]]
then
echo "one"
fi
# ===== case 风格 =====
case "$1" in
start)
do_start
;;
stop|kill)
do_stop
;;
*)
echo "Usage: $0 {start|stop}"
exit 1
;;
esac
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# 11.2.4 引号使用原则
#!/bin/bash
# ===== 原则:除非明确需要单词拆分,否则变量一律加双引号 =====
# ✅ 变量引用加引号
name="Alice Smith"
echo "Hello, $name" # ✅
echo "Hello, ${name}" # ✅ 更清晰的边界
# ❌ 不加引号的后果
echo Hello, $name # ❌ Hello, 是参数1, Alice是参数2, Smith是参数3
# ===== 命令替换也一样 =====
# ✅
files=$(find . -name "*.txt")
echo "$files"
# ===== 需要不加引号的场景:分词是目的 =====
# 这种时候才故意不加引号:
options="-a -l -h"
ls $options # 故意让 Shell 把 "-a -l -h" 拆成三个参数
# ===== 单引号:所有内容原样输出 =====
echo 'Hello $name' # 输出:Hello $name(不展开变量)
# 双引号里 $ 和 ` 仍然会展开
echo "Hello $name" # 输出:Hello Alice Smith
# ===== heredoc 引号:决定是否展开变量 =====
cat << 'EOF' # 'EOF' = 不展开
Hello $name
EOF
# 输出:Hello $name
cat << EOF # EOF 不加引号 = 展开变量
Hello $name
EOF
# 输出:Hello Alice Smith
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# 11.2.5 主入口与参数解析
#!/bin/bash
set -euo pipefail
# ---- 常量 ----
readonly SCRIPT_NAME=$(basename "$0")
readonly SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
# ---- 默认配置 ----
LOG_FILE="/var/log/${SCRIPT_NAME%.*}.log"
DRY_RUN=false
VERBOSE=false
# ---- 工具函数 ----
log() { echo "[$(date '+%H:%M:%S')] $*"; }
die() { log "ERROR: $*" >&2; exit 1; }
usage() {
cat << EOF
用法: $SCRIPT_NAME [选项] <参数>
选项:
-d, --dry-run 预览模式,不实际执行
-v, --verbose 详细输出
-o, --output DIR 指定输出目录
-h, --help 显示此帮助
示例:
$SCRIPT_NAME -v /path/to/input
$SCRIPT_NAME --dry-run --output /tmp /path/to/input
EOF
exit 0
}
# ---- 参数解析 ----
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
-d|--dry-run) DRY_RUN=true; shift ;;
-v|--verbose) VERBOSE=true; shift ;;
-o|--output) OUTPUT_DIR="$2"; shift 2 ;;
-h|--help) usage ;;
-*) die "未知选项: $1" ;;
*) POS_ARGS+=("$1"); shift ;;
esac
done
}
# ---- 主逻辑 ----
main() {
parse_args "$@"
# 参数校验
[[ ${#POS_ARGS[@]} -eq 0 ]] && die "缺少必要参数,使用 -h 查看帮助"
log "开始执行..."
# 核心逻辑
# ...
log "执行完成"
}
main "$@"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
# 11.3 思考题
set -e 陷阱:以下代码在
set -e下是否能正确退出?如果不能,为什么?如何修复?set -e backup() { false; echo "backup done"; } if backup; then echo "success"; fi1
2
3trap 清理链:写一个脚本,创建 3 层临时目录(A/B/C),保证脚本无论以什么方式退出(正常/Ctrl+C/kill),三层目录都被安全清理。
管道调试:有一个管道链
cmd1 | cmd2 | cmd3 | cmd4,如何快速找到是哪个环节出了问题导致的最终输出异常?shellcheck 清零:找系统中一个现有脚本,运行
shellcheck后逐条修复所有 warning,对比修复前后的差异。规范改造:将以下"野脚本"改造成符合本章规范的版本:
#!/bin/sh a=$1 cd /tmp/dir for f in $(ls); do rm $f done echo done1
2
3
4
5
6
7
上次更新: 2026/06/17, 12:47:39