Linux 基础体系 · 第 11/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Bash 与 Shell 工程实践:展开、引用、管道、错误处理和脚本测试
本文面向现代主流 Linux 发行版,示例以 Bash 5.x 和常见 GNU/Linux 用户空间为基准。需要兼容 POSIX sh、Dash、Zsh 或旧版 Bash 时,应按目标解释器重新验证语法和错误传播行为。
Shell 脚本经常被误认为是“把命令按顺序写进文件”。实际上,Shell 同时承担了命令解释器、文本展开器、进程启动器和退出状态传播器的职责。很多生产事故并非来自某个命令本身,而是来自以下机制之间的组合:
- 未加引号的变量触发了分词和通配符展开;
- 管道中的命令运行在不同进程,修改的变量没有回到父 Shell;
- 上游命令失败,但管道整体仍返回成功;
set -e在条件、管道和命令替换中的行为与直觉不同;- 临时文件、信号和并发执行没有生命周期管理;
- 测试只覆盖了“正常输入”,没有验证空值、空格、通配符、失败和中断。
要写出可维护的 Bash 脚本,必须先理解 Shell 如何把源代码变成进程和参数。
一、Shell、Bash 与命令执行模型
1. Shell 不只是一个命令行程序
Shell 是一种命令解释器。它读取输入,进行语法分析和展开,然后启动外部程序或执行内建命令。
Bash 是 Bourne Again Shell,是一种具体的 Shell 实现。sh、dash、ksh、zsh 和 Bash 的语法和行为并不完全相同。
例如:
#!/usr/bin/env bash
表示脚本希望由 Bash 执行。下面这些 Bash 特性不是 POSIX sh 的通用能力:
- 数组;
[[ ... ]]条件表达式;(( ... ))算术命令;mapfile/readarray;- 进程替换
<(...); pipefail;coproc;- 部分
shopt选项。
如果脚本声明使用 Bash,就应当明确调用 Bash,而不是用:
sh script.sh
因为这会绕过脚本首行的 shebang,直接由 sh 解释。正确方式是:
chmod +x script.sh
./script.sh
或者:
bash script.sh
shebang 只在直接执行脚本时由内核处理。它不是 Shell 内部语法,也不能影响已经显式指定的解释器。
2. 一条命令的大致生命周期
对一条简单命令,可以用下面的模型理解:
源代码
│
├─ 词法与语法分析
│
├─ 别名、保留字、函数、内建命令识别
│
├─ 展开:花括号、参数、命令替换、算术、分词、路径名
│
├─ 重定向处理
│
├─ 查找并执行命令
│
└─ 产生退出状态
这不是所有内部细节的完整描述,但足以解释大多数工程问题。
例如:
name='report 2025.txt'
printf '%s\n' $name
Shell 不会把 $name 自动当成一个整体参数。变量值先参与词分割,结果可能是两个参数:
report
2025.txt
而:
printf '%s\n' "$name"
会把它作为一个参数:
report 2025.txt
命令 printf 是否支持空格不是关键;关键在于 Shell 在启动 printf 之前已经决定了参数边界。
二、展开:Shell 如何把文本变成参数
1. 展开的基本顺序
在常见的 Bash 命令中,可以用以下顺序建立直觉:
- 花括号展开;
- 波浪号展开;
- 参数展开、命令替换、算术展开;
- 词分割;
- 路径名展开;
- 引号移除。
实际行为还受上下文影响,例如赋值语句、重定向、[[ ... ]] 和数组上下文有特殊规则。因此这是一种工程上有用的主顺序,而不是可以替代 Bash 语法规则的完整形式化定义。
看一个完整例子:
prefix='log'
suffix='*.txt'
printf '<%s>\n' ${prefix}_${suffix}
展开过程大致是:
- 参数展开:
log_*.txt - 词分割:如果结果中没有空白,仍是一个词;
- 路径名展开:如果当前目录存在
log_a.txt和log_b.txt,则变成两个参数; printf实际收到:log_a.txt log_b.txt
如果写成:
printf '<%s>\n' "${prefix}_${suffix}"
引号会阻止词分割和路径名展开,printf 收到的只有一个参数:
log_*.txt
2. 参数展开不等于字符串替换
最常见的参数展开是:
name='archive.tar.gz'
printf '%s\n' "$name"
printf '%s\n' "${name%.gz}"
printf '%s\n' "${name%%.*}"
printf '%s\n' "${name#*.}"
printf '%s\n' "${name##*.}"
输出:
archive.tar.gz
archive.tar
archive
tar.gz
gz
其中:
${var%pattern}:从末尾删除能匹配pattern的最短部分;${var%%pattern}:从末尾删除最长部分;${var#pattern}:从开头删除最短部分;${var##pattern}:从开头删除最长部分。
这里的 pattern 使用 Shell 模式匹配,不是正则表达式。* 表示任意长度字符序列,但不等价于正则表达式中的全部语义。
还可以设置默认值和检查必需变量:
: "${CONFIG_FILE:=/etc/myapp/config.conf}"
: "${API_TOKEN:?API_TOKEN must be set}"
含义分别是:
- 如果
CONFIG_FILE未设置或为空,则赋值为默认路径; - 如果
API_TOKEN未设置或为空,则输出错误并让当前命令失败。
注意 :- 和 := 的区别:
value="${x:-default}" # x 为空或未设置时使用 default,但不修改 x
value="${x:=default}" # x 为空或未设置时赋值为 default
如果只想判断“未设置”,而允许空字符串存在,应使用 - 或 = 的不带冒号形式:
"${x-default}"
"${x=default}"
3. 命令替换会丢失末尾换行
命令替换使用 $(...):
today="$(date +%F)"
Shell 会执行括号内的命令,把标准输出捕获为字符串。命令替换会删除结果末尾连续的换行符:
value="$(printf 'a\n\n')"
printf '<%s>\n' "$value"
输出是:
<a>
因此命令替换不适合无损传输任意字节流。Shell 变量本身也不能可靠保存 NUL 字节。
命令替换中的命令在子 Shell 环境中运行:
cwd_before=$PWD
changed="$(cd /tmp && pwd)"
printf '父 Shell: %s\n' "$PWD"
printf '输出值: %s\n' "$changed"
cd /tmp 只影响命令替换内部的子 Shell,父 Shell 的工作目录不变。
如果命令替换内部发生失败,外层命令未必因此失败:
result="$(false)"
printf 'status=%s\n' "$?"
这里 printf 通常仍会执行;是否提前退出还会受到 set -e、inherit_errexit 和上下文的影响。不要把“某个子命令失败”与“包含命令替换的整条命令失败”混为一谈。
4. 算术展开和算术命令
算术展开:
a=7
b=3
printf '%s\n' "$((a * b + 1))"
输出:
22
算术命令:
if (( a > b )); then
printf 'a is larger\n'
fi
(( ... )) 的退出状态不是“语法是否正确”,而是算术结果是否为零:
- 结果非零:退出状态为
0; - 结果为零:退出状态为
1。
因此下面的代码可能触发 set -e:
set -e
(( count++ ))
printf '这行可能不会执行\n'
当 count 初始为 0 时,后缀自增表达式的值是旧值 0,所以算术命令返回失败状态。更明确的写法是:
(( ++count ))
或者:
count=$((count + 1))
5. 数组展开必须明确目标
Bash 数组:
files=(
"report 1.txt"
"report 2.txt"
)
printf '<%s>\n' "${files[@]}"
输出两个参数:
<report 1.txt>
<report 2.txt>
这是遍历数组元素的常用写法。相反:
printf '<%s>\n' ${files[@]}
会让数组元素再次经历词分割和路径名展开,文件名中的空格、通配符都会造成错误。
${files[*]} 和 ${files[@]} 的差异主要在双引号中:
"${files[@]}"
展开为多个独立参数,每个元素一个参数。
"${files[*]}"
展开为一个参数,元素之间用 IFS 的第一个字符连接。
因此,除非确实需要把数组拼接成一个字符串,遍历参数列表时应使用:
for file in "${files[@]}"; do
printf '%s\n' "$file"
done
三、引用:保留参数边界,而不是“让字符串更安全”
1. 双引号的核心作用
双引号通常允许:
- 参数展开;
- 命令替换;
- 算术展开。
同时抑制:
- 词分割;
- 路径名展开。
例如:
filename='a b[1].txt'
if [[ -f "$filename" ]]; then
printf '文件存在\n'
fi
如果写成:
if [[ -f $filename ]]; then
在 [[ ... ]] 内部通常仍然安全,因为 [[ 的操作数不会进行普通的词分割和路径名展开。但这是一条上下文特例,不应推广到普通命令:
rm -- $filename
在普通命令中,未加引号的变量可能被拆成多个参数并触发通配符展开。正确写法是:
rm -- "$filename"
-- 用于告诉支持该约定的命令:后续参数即使命名以 - 开头,也不要解释成选项。它不能修复未加引号造成的分词问题,二者解决的是不同风险。
2. 单引号和双引号的区别
单引号中的内容按字面保留:
name='Alice'
printf '%s\n' '$name'
输出:
$name
双引号允许展开:
printf '%s\n' "$name"
输出:
Alice
单引号不能直接包含单引号。要生成字符串 It's valid,可以使用相邻字符串拼接:
printf '%s\n' 'It'\''s valid'
或者在适合的地方使用双引号:
printf '%s\n' "It's valid"
3. $@、$* 和位置参数
脚本参数:
#!/usr/bin/env bash
for arg in "$@"; do
printf '<%s>\n' "$arg"
done
执行:
./show-args.sh 'a b' '*.txt'
输出:
<a b>
<*.txt>
"$@" 保留每个原始参数的边界,是把参数转交给另一个命令的正确形式:
some_command "$@"
"$*" 则把所有参数合并成一个字符串,参数之间用 IFS 的第一个字符连接。它适合少数确实需要“整体字符串”的场景,不适合一般的参数转发。
4. 用 printf 替代 echo
echo 在不同实现中对 -n、反斜杠和转义序列的处理存在差异。脚本中应优先使用:
printf '%s\n' "$message"
如果要输出可能以 - 开头的用户输入,printf '%s\n' "$value" 不会把它误解为 printf 选项,因为格式字符串已经固定。
四、路径名、文件名和文本流
1. 不要用 for file in $(find ...) 处理文件列表
下面的写法有多个问题:
for file in $(find . -type f); do
printf '%s\n' "$file"
done
命令替换会删除末尾换行,随后结果还会进行词分割。包含空格、制表符、换行符的文件名会被拆开;通配符还可能再次触发路径名展开。
处理普通文件名时,优先使用 Shell 的路径名展开并检查是否匹配:
shopt -s nullglob
files=(./*.log)
for file in "${files[@]}"; do
printf '%s\n' "$file"
done
nullglob 使没有匹配项时模式展开为空数组,而不是保留字面量 ./*.log。它只影响当前 Bash Shell,不影响外部命令。
对于递归遍历,使用 find 的安全接口:
find . -type f -name '*.log' -print0 |
while IFS= read -r -d '' file; do
printf '%s\n' "$file"
done
这里:
-print0以 NUL 分隔路径;read -d ''读取到 NUL;IFS=防止去除前后空白;-r防止反斜杠被当作转义符。
这种方式可以处理包含空格、制表符和换行的文件名,但 Shell 变量仍不能表示 NUL,因此不能把整个 NUL 分隔流一次性存入变量。
2. IFS 不是通用分隔符配置
IFS 是 Shell 在词分割和 read 中使用的内部字段分隔符。下面的代码会改变当前 Shell 后续行为:
IFS=,
read -r first second <<< 'a,b'
修改全局 IFS 容易产生远处的副作用。更局部的写法是:
while IFS=, read -r first second; do
printf '%s / %s\n' "$first" "$second"
done < input.csv
但 CSV 并不是简单的逗号分隔文本;如果字段允许引号、嵌入逗号或换行,应使用专门的 CSV 解析器,而不是用 IFS=, 冒充完整 CSV 解析。
3. Shell 不适合解析任意结构化数据
JSON、YAML、复杂 CSV、XML 都有自己的语法。用 grep、cut 或 Bash 字符串操作拼装解析器,通常会在转义、嵌套和换行处失败。
例如处理 JSON 时,可以使用发行版提供的 jq:
jq -r '.items[] | .name' input.json
但这引入了外部依赖。生产脚本应明确依赖来源,例如通过 APT 或 DNF 安装,并在部署验证阶段检查版本:
command -v jq >/dev/null 2>&1 || {
printf 'jq is required\n' >&2
exit 127
}
软件包仓库的签名验证、依赖解析和版本锁定属于包管理系统的职责,脚本不应通过直接下载未知 URL 并执行来绕过这些机制。
五、重定向和管道:数据流与进程边界
1. 文件描述符是数据流的入口
常见文件描述符:
0:标准输入;1:标准输出;2:标准错误。
重定向:
command >output.log 2>error.log
把标准输出和标准错误分别写入文件。
合并输出:
command >all.log 2>&1
这里顺序很重要。Shell 从左到右处理重定向:
>all.log:让标准输出指向all.log;2>&1:让标准错误复制当前标准输出的目标。
下面写法含义不同:
command 2>&1 >all.log
处理顺序是:
- 标准错误先复制当前标准输出,通常仍指向终端;
- 标准输出再指向文件。
结果可能是标准输出进入文件,但标准错误仍显示在终端。
现代 Bash 还支持:
command &>all.log
这是 Bash 语法,不是所有 POSIX Shell 都支持。
2. 管道建立进程间数据流
管道:
producer | consumer
Shell 通常会:
- 创建管道;
- 启动
producer,把其标准输出连接到管道写端; - 启动
consumer,把其标准输入连接到管道读端; - 等待管道中的命令;
- 根据规则计算管道退出状态。
数据流是:
producer 的 stdout ── pipe ──> consumer 的 stdin
管道不是“把两个函数串起来”。每个命令通常运行在独立进程中,因此变量修改不会跨越管道自动返回:
count=0
printf '%s\n' a b c |
while IFS= read -r item; do
(( ++count ))
done
printf 'count=%s\n' "$count"
在常见 Bash 配置下,while 循环运行在子 Shell 中,输出通常是:
count=0
一种避免方式是让循环从进程替换读取:
count=0
while IFS= read -r item; do
(( ++count ))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"
这里循环在当前 Shell 中执行,printf 在进程替换产生的进程中执行。
Bash 的 lastpipe 选项可以在满足条件时让管道最后一个命令在当前 Shell 执行,但它受非交互模式和作业控制状态影响,不应作为跨环境脚本的唯一依赖:
shopt -s lastpipe
3. 管道的退出状态默认只看最后一条命令
默认情况下:
false | true
printf 'status=%s\n' "$?"
通常输出:
status=0
因为管道整体的退出状态默认是最后一条命令 true 的状态。上游失败被隐藏了。
Bash 的 pipefail 改变这一规则:
set -o pipefail
false | true
printf 'status=%s\n' "$?"
启用 pipefail 后,只要管道中有命令失败,管道整体通常返回最右侧失败命令的退出状态;如果所有命令成功,整体才成功。
完整的错误传播链是:
上游命令失败
│
├─ 无 pipefail:最后一个命令成功 → 管道成功
│
└─ 有 pipefail:保留最右侧失败状态 → 管道失败
但 pipefail 也有边界。消费者提前退出时,生产者可能收到 SIGPIPE,导致管道状态非零,即使消费者已经获得了所需结果:
set -o pipefail
seq 100000 | head -n 1
printf 'status=%s\n' "$?"
在某些实现和运行条件下,seq 可能因为写端关闭而得到 SIGPIPE。因此不能把所有非零管道状态都简单归类为“业务失败”,应结合命令语义和日志诊断。
如果需要查看管道中每个命令的状态,可以使用 Bash 数组:
set +e
false | true | false
status=("${PIPESTATUS[@]}")
set -e
printf '%s\n' "${status[@]}"
必须立即读取 PIPESTATUS,因为执行下一条命令后它会被更新。
六、退出状态:Shell 中的错误传播基础
1. 退出状态的含义
Unix 进程退出时会返回一个整数状态。惯例是:
0:成功;- 非零:失败、异常或特殊结果。
非零值并不自动说明错误类型。不同命令有自己的约定。例如:
grep找到匹配通常返回0;- 没有匹配通常返回
1; - 参数或执行错误可能返回
2; test条件为假通常返回1。
因此这段代码未必表示异常:
if grep -q 'enabled' config.txt; then
printf '已启用\n'
else
printf '未启用\n'
fi
grep 返回 1 可能只是“没有匹配”。
2. &&、|| 和条件上下文
Shell 使用退出状态控制逻辑:
if command; then
success_path
else
failure_path
fi
短路表达式:
mkdir -p "$dir" && printf 'created\n'
只有 mkdir 成功才执行 printf。
command || {
rc=$?
printf 'failed: rc=%s\n' "$rc" >&2
exit "$rc"
}
花括号中的命令在当前 Shell 执行;如果使用小括号,则会创建子 Shell:
( cd /tmp; pwd )
&& 和 || 组合时不要依赖模糊的阅读直觉:
test -f "$file" && echo yes || echo no
它并不严格等价于“如果文件存在则输出 yes,否则输出 no”,因为 echo yes 自己失败时还会触发 echo no。需要明确分支时使用 if。
七、错误处理:set -e 不是异常系统
1. set -e 的意图与限制
常见脚本开头是:
set -Eeuo pipefail
各选项大致含义:
-e:某些未被特殊语法位置豁免的简单命令失败时退出;-u:展开未设置变量时报告错误;-E:让ERR陷阱在函数、命令替换和子 Shell 中继承;pipefail:让管道传播内部失败。
其中 -e 最容易被误解。它不是“任何子命令返回非零,脚本都立即退出”。在以下上下文中,失败通常不会触发立即退出:
if或while的条件命令;until的条件命令;&&链中除最后一个命令外的命令;||链中除最后一个命令外的命令;- 管道中除最后一个命令外的命令,除非启用
pipefail; - 使用
!取反的命令。
例如:
set -e
if grep -q 'ready' status.txt; then
printf 'ready\n'
fi
printf '脚本继续\n'
没有匹配不是异常,脚本会继续。
再看函数:
set -e
check() {
false
printf '函数内部继续\n'
}
check
printf '外部继续\n'
如果函数调用处处于特殊条件上下文,函数体内的命令也可能受到该上下文的影响:
if check; then
printf 'success\n'
fi
这正是 set -e 难以作为完整错误模型的原因:某条命令是否触发退出,不仅由命令本身决定,还由它所在的语法上下文决定。
2. 正确捕获退出状态
错误:
if ! output="$(some_command)"; then
printf 'command failed\n' >&2
exit 1
fi
这种写法可以在 set -e 下安全地把命令放入条件上下文,同时保留失败分支。
如果需要保留原始退出码:
if output="$(some_command)"; then
printf '%s\n' "$output"
else
rc=$?
printf 'some_command failed, rc=%s\n' "$rc" >&2
exit "$rc"
fi
不要这样写:
output="$(some_command)"
rc=$?
在没有 set -e 时它可以工作,但如果启用了 set -e,命令失败可能在执行 rc=$? 之前就终止脚本。
3. set -u 的边界
set -u 能发现许多拼写错误:
set -u
printf '%s\n' "$not_defined"
但数组和可选参数需要显式处理:
printf 'first=%s\n' "${1-}"
表示参数不存在时使用空字符串。
判断数组是否有元素:
if ((${#items[@]} > 0)); then
printf '%s\n' "${items[@]}"
fi
在脚本中,最好区分“未设置”“设置为空”和“有值”,不要让 set -u 替代输入校验。
4. ERR 陷阱和退出日志
可以使用 trap 记录失败位置:
#!/usr/bin/env bash
set -Eeuo pipefail
trap 'rc=$?; printf "ERROR rc=%s line=%s cmd=%q\n" "$rc" "$LINENO" "$BASH_COMMAND" >&2' ERR
false
ERR 陷阱的触发规则与 set -e 有相似的条件豁免,并不是所有非零状态都会触发。BASH_COMMAND 表示当时正在执行的命令,但在复杂函数、命令替换和管道中,日志仍需结合上下文解释。
不要在陷阱中无条件执行会改变退出状态的命令后再直接使用 $?。应先保存:
trap 'rc=$?; cleanup; exit "$rc"' EXIT
八、信号、清理和临时文件生命周期
1. 临时文件的正确生命周期
临时文件至少要考虑:
- 创建位置和权限;
- 文件名冲突;
- 中断后的清理;
- 清理失败;
- 是否需要跨进程共享。
不应手工拼接:
tmp="/tmp/report.$$"
进程 ID 并不能提供充分的随机性,也不能可靠防止预测和冲突。使用 mktemp:
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf -- "$tmpdir"
}
trap cleanup EXIT INT TERM HUP
output="$tmpdir/output.txt"
mktemp -d 创建一个权限受控的临时目录,后续文件都放在目录中。清理时使用 -- 防止路径以连字符开头,并始终引用变量。
注意 trap ... EXIT 是 Bash 特性;EXIT 不等同于操作系统信号。脚本收到 SIGKILL 时无法运行清理函数,文件系统故障也可能导致清理失败。
2. 脚本可能被中断而不是“正常失败”
常见信号包括:
SIGTERM:服务管理器请求终止;SIGINT:通常来自终端的 Ctrl-C;SIGHUP:终端断开等场景;SIGKILL:无法捕获,进程立即终止。
使用 trap 时应避免在信号处理函数中做复杂业务操作。清理临时文件、关闭子进程或记录状态通常足够。
如果脚本会被 cron 或 systemd timer 定期启动,还需要考虑上一次执行仍在运行的情况。最简单的防重入方式是使用 flock:
exec 9>/run/lock/my-job.lock
if ! flock -n 9; then
printf 'another instance is running\n' >&2
exit 0
fi
# 进入临界区
/run/lock 的可写权限取决于用户和发行版配置。普通用户可以使用自己的运行目录或状态目录。生产环境中必须验证锁文件目录权限,不能假设任意用户都能写入 /run/lock。
flock 锁绑定打开的文件描述符;脚本进程结束后锁会释放。若工作需要跨机器互斥,本地文件锁并不能解决问题。
九、函数、子 Shell 和作用域
1. 函数返回的是状态,不是任意对象
Shell 函数可以通过标准输出返回文本,通过退出状态返回成功或失败:
get_version() {
printf '%s\n' '1.2.3'
}
if version="$(get_version)"; then
printf 'version=%s\n' "$version"
fi
函数中的普通变量默认影响当前 Shell:
set_value() {
value='changed'
}
value='old'
set_value
printf '%s\n' "$value"
输出:
changed
如果使用 local,变量只在函数及其调用范围内可见:
set_value() {
local value='changed'
printf '%s\n' "$value"
}
不要用全局变量隐式传递复杂状态。更明确的方式是:
- 标准输出传递结果;
- 返回码传递成功/失败;
- 文件或目录传递较大数据;
- 全局变量只用于明确的脚本级状态。
2. 子 Shell 会复制环境,但不回写状态
小括号会创建子 Shell:
value='parent'
(
value='child'
printf '%s\n' "$value"
)
printf '%s\n' "$value"
输出:
child
parent
子 Shell 初始时继承父 Shell 的变量、当前目录和打开的文件描述符,但后续修改不会回到父 Shell。
这也解释了为什么下面的 cd 不影响外部:
(
cd /var/log
printf '%s\n' "$PWD"
)
printf '%s\n' "$PWD"
花括号组:
{
cd /var/log
printf '%s\n' "$PWD"
}
通常在当前 Shell 执行,因此会改变当前目录。语法上,花括号需要命令分隔符:
{ command1; command2; }
十、一个可维护的 Bash 脚本骨架
下面脚本统计指定目录下的 .log 文件数量,并把结果写入输出文件:
#!/usr/bin/env bash
set -Eeuo pipefail
usage() {
printf 'Usage: %s DIRECTORY OUTPUT_FILE\n' "${0##*/}" >&2
}
log() {
printf '[%s] %s\n' "$(date --iso-8601=seconds)" "$*" >&2
}
main() {
if (($# != 2)); then
usage
return 64
fi
local directory=$1
local output_file=$2
local tmpdir
local count
if [[ ! -d "$directory" ]]; then
log "directory does not exist: $directory"
return 66
fi
tmpdir="$(mktemp -d)"
cleanup() {
rm -rf -- "$tmpdir"
}
trap cleanup RETURN
count="$(
find "$directory" -type f -name '*.log' -printf '%p\n' |
wc -l
)"
printf 'log_files=%s\n' "$count" >"$tmpdir/result"
install -m 0644 "$tmpdir/result" "$output_file"
log "wrote result to $output_file"
}
main "$@"
这个例子包含几个重要设计点:
main "$@"保留原始参数边界;- 参数数量错误返回
64,表示命令用法错误; - 输入目录不存在时返回明确的非零状态;
- 临时目录由
mktemp -d创建; - 先写临时文件,再用
install写入目标文件,避免直接截断已有输出; find的路径参数被引用,避免目录名中的空格造成错误;trap cleanup RETURN绑定到main函数返回,清理函数只负责清理本函数创建的临时目录。
这里的 find -printf 是 GNU find 常见扩展,在 Debian、Ubuntu、Fedora 等主流 GNU/Linux 发行版中通常存在,但不是 POSIX find 的保证能力。如果需要更强的跨 Unix 可移植性,应改用:
find "$directory" -type f -name '*.log' -print
再根据实际数据处理输出。
这个统计示例仍有边界:wc -l 统计的是输出中的换行数,而 GNU find -printf '%p\n' 每个匹配项输出一个换行,因此对普通路径列表成立。它不是通用的任意文件名序列化方案;若文件名包含换行,文本输出会产生歧义。只统计数量时,更严谨的 GNU 工具链可以使用:
find "$directory" -type f -name '*.log' -printf x | wc -c
每个文件输出一个 x,不受文件名内容影响。
十一、脚本测试:从语法检查到故障注入
1. 静态检查、语法检查和格式化解决不同问题
语法检查:
bash -n script.sh
它只解析语法,不执行命令。可以发现括号、引号和关键字错误,但发现不了路径不存在、权限不足或逻辑错误。
ShellCheck:
shellcheck script.sh
ShellCheck 可以发现许多常见问题,例如:
- 未加引号的变量;
for f in $(...);- 无效的条件写法;
$?被后续命令覆盖;- 数组展开方式错误;
- 可能被
set -u触发的未定义变量。
ShellCheck 不是 Bash 解释器,也不能证明业务逻辑正确。它给出的建议仍需结合脚本目标判断。
格式化工具如 shfmt 可以统一缩进和换行,但格式一致不等于语义正确。
2. 单元测试应运行脚本的真实边界
假设脚本名为 count-logs.sh,测试至少应覆盖:
- 正常目录;
- 空目录;
- 不存在的目录;
- 输出路径不可写;
- 目录名包含空格;
- 无匹配文件;
- 文件名包含通配符;
- 被中断时临时目录是否清理;
- 并发启动时是否允许重复执行。
手工测试可以这样建立隔离目录:
set -euo pipefail
testdir="$(mktemp -d)"
trap 'rm -rf -- "$testdir"' EXIT
mkdir -p "$testdir/input"
touch "$testdir/input/a.log"
touch "$testdir/input/b.txt"
touch "$testdir/input/report 2025.log"
./count-logs.sh "$testdir/input" "$testdir/result"
cat "$testdir/result"
预期输出:
log_files=2
这里验证了:
.log后缀匹配;.txt不计数;- 含空格的文件名不会被错误拆分。
3. 用临时 PATH 模拟外部命令失败
脚本测试不应依赖系统命令永远成功。可以创建假的命令覆盖真实命令:
fakebin="$(mktemp -d)"
trap 'rm -rf -- "$fakebin"' EXIT
cat >"$fakebin/install" <<'EOF'
#!/usr/bin/env bash
printf 'simulated install failure\n' >&2
exit 73
EOF
chmod +x "$fakebin/install"
PATH="$fakebin:$PATH" ./count-logs.sh "$testdir/input" "$testdir/result"
rc=$?
printf 'rc=%s\n' "$rc"
测试重点不是“伪造得像不像真实 install”,而是验证脚本在依赖失败时是否:
- 返回非零;
- 不报告成功;
- 保留或清理临时文件符合预期;
- 日志包含可诊断信息。
若脚本使用绝对路径调用 /usr/bin/install,这种 PATH 注入无法替换它。绝对路径可以减少 PATH 劫持风险,但会降低测试替换能力;生产脚本应根据安全边界取舍,而不是机械使用一种形式。
4. Bats 适合组织 Bash 测试,但不是 Bash 标准组件
Bats 是第三方 Bash 测试框架。安装方式和软件包名称随发行版而异,应使用发行版仓库或项目提供的可信发布渠道,并锁定测试环境版本。
一个 Bats 测试示例:
#!/usr/bin/env bats
setup() {
TESTDIR="$(mktemp -d)"
mkdir -p "$TESTDIR/input"
touch "$TESTDIR/input/a.log"
touch "$TESTDIR/input/b.txt"
}
teardown() {
rm -rf -- "$TESTDIR"
}
@test "counts log files" {
run ./count-logs.sh "$TESTDIR/input" "$TESTDIR/result"
[ "$status" -eq 0 ]
[ "$output" = "*wrote result*" ]
run cat "$TESTDIR/result"
[ "$status" -eq 0 ]
[ "$output" = "log_files=1" ]
}
具体断言语法依赖 Bats 版本和扩展,不应把 Bats 当作系统自带命令。测试环境应显式安装并验证:
command -v bats
bats --version
5. 测试退出状态,而不仅是标准输出
标准输出可能看起来正确,但脚本仍以错误状态退出;反过来也可能返回非零但输出了可接受的“未找到”结果。
测试应同时检查:
./script.sh input output >stdout.log 2>stderr.log
rc=$?
if ((rc != 0)); then
printf 'unexpected failure: rc=%s\n' "$rc" >&2
cat stderr.log >&2
exit 1
fi
对于预期失败,也要断言失败类型。例如参数错误应非零,且不能产生半成品输出:
if ./count-logs.sh /does/not/exist result; then
printf 'expected failure, got success\n' >&2
exit 1
fi
[[ ! -e result ]]
十二、cron 与 systemd timer 中的 Shell 风险
定时任务把脚本放到了更严格的运行环境中。交互式终端中成立的假设,在 cron 或 systemd timer 中可能不成立。
1. cron 的环境通常更少
cron 常见问题包括:
PATH比交互式 Shell 短;- 当前工作目录不一定是项目目录;
- 没有交互式终端;
- 邮件或日志处理方式取决于实现和配置;
- 时区由 cron 服务、系统和配置共同影响;
- 重启期间错过的任务通常不会自动补偿。
cron 中不要依赖当前目录:
0 2 * * * /opt/myapp/bin/job.sh >>/var/log/myapp/job.log 2>&1
脚本内部应使用绝对路径,或显式设置:
PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
export PATH
如果脚本需要 jq、flock 或特定包,应在部署阶段通过 APT、DNF 等包管理系统安装并验证,而不是假设管理员环境中已经存在。
2. systemd timer 能表达更多运行约束
systemd timer 通常配合 service 单元运行脚本:
# /etc/systemd/system/my-job.service
[Unit]
Description=Run my job
[Service]
Type=oneshot
ExecStart=/opt/myapp/bin/job.sh
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
# /etc/systemd/system/my-job.timer
[Unit]
Description=Run my job periodically
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target
启用和验证:
sudo systemctl daemon-reload
sudo systemctl enable --now my-job.timer
systemctl status my-job.timer
systemctl list-timers my-job.timer
journalctl -u my-job.service
Persistent=true 表示如果计时器在应触发时未运行,恢复后可以补执行一次。它不是无限补偿机制,也不等于“每个错过的周期都执行一次”。
systemd 服务还可以表达:
- 用户和组;
- 工作目录;
- 环境变量;
- 超时;
- 资源限制;
- 重启策略;
- 日志去向;
- 依赖关系。
但 Type=oneshot 的服务执行完毕后会进入完成状态;是否允许并发、失败后如何重试,仍需结合单元配置和脚本自身的锁设计验证。
3. 定时任务的时区和防重入
OnCalendar 依赖系统时区和 systemd 的时间解释。cron 的时区行为也受具体实现、系统时区和配置影响。涉及账务、备份、证书或跨地区业务时,应明确:
- 使用哪个时区;
- 夏令时切换时是否会重复或跳过时间点;
- 系统时间校准时如何处理;
- 错过执行后是否补偿;
- 上一次执行未结束时是否跳过、排队或并行。
定时任务不是可靠性保证。脚本必须把重复执行设计为安全操作,或者显式使用锁和幂等检查。
十三、包管理和可重复安装对脚本的影响
脚本依赖外部命令,就依赖相应的软件包。现代 Linux 发行版通常使用:
- Debian、Ubuntu 等系统的 APT;
- Fedora、RHEL、Rocky、Alma 等系统的 DNF。
安装软件时,包管理器负责:
- 选择仓库;
- 验证仓库元数据和软件包签名;
- 解析依赖;
- 记录安装状态;
- 处理升级和卸载。
例如:
sudo apt-get update
sudo apt-get install --yes jq shellcheck
或:
sudo dnf install -y jq ShellCheck
包名、版本和可用仓库可能因发行版而不同,不能在脚本中无条件假设两个命令都存在。生产部署应考虑:
- 仓库来源是否可信;
- 是否固定到经过验证的版本;
- 是否需要内部镜像;
- 依赖升级是否经过测试;
- 脚本运行用户是否有权限;
- 包管理操作是否与业务执行解耦。
脚本中可以检查命令:
require_commands() {
local command_name
for command_name in "$@"; do
if ! command -v "$command_name" >/dev/null 2>&1; then
printf 'missing command: %s\n' "$command_name" >&2
return 127
fi
done
}
require_commands find wc install mktemp
但“检查存在”不等于“版本兼容”。对关键工具还应验证必要选项:
if ! find --help 2>&1 | grep -q -- '-printf'; then
printf 'GNU find with -printf is required\n' >&2
exit 127
fi
这种能力探测也可能受本地化输出影响。更可靠的方式是明确声明平台范围,或者在构建和测试环境中锁定发行版。
十四、常见错误与诊断路径
1. 错误:变量未加引号
失败表现:
rm -f $file
可能出现:
- 文件名含空格时变成多个参数;
- 文件名含
*时被展开为多个路径; - 空变量导致命令参数数量改变;
- 参数以
-开头时被当作选项。
诊断方法:
printf 'file=<%s>\n' "$file"
declare -p file
修复通常是:
rm -f -- "$file"
2. 错误:管道隐藏上游失败
失败表现:
generate_report | gzip >report.gz
generate_report 失败,但 gzip 正常结束,脚本认为成功。
诊断方法:
set -o pipefail
generate_report | gzip >report.gz
printf 'pipeline=%s\n' "$?"
printf 'stages=%s\n' "${PIPESTATUS[*]}"
修复方式取决于业务:启用 pipefail,或者分别执行并验证每个阶段。
3. 错误:用命令替换读取文件
失败表现:
content="$(cat input.txt)"
可能丢失末尾换行,并把大文件完整读入内存。
如果目标是逐行处理:
while IFS= read -r line || [[ -n "$line" ]]; do
printf '%s\n' "$line"
done < input.txt
|| [[ -n "$line" ]] 用于处理没有末尾换行的最后一行。
如果目标只是把文件内容传给命令,直接重定向通常更好:
some_command <input.txt
4. 错误:认为 set -e 会自动完成错误处理
失败表现是脚本在某些失败场景退出,在另一些类似场景继续执行,导致行为不一致。
诊断方法:
bash -x script.sh
也可以临时设置更易读的跟踪格式:
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
生产环境不应无条件开启 set -x,因为命令参数可能包含密码、令牌或个人数据。若必须调试,应通过受控开关启用,并避免把敏感值放进命令行参数。
十五、工程取舍与边界
1. 何时使用 Bash,何时换语言
Bash 适合:
- 启动和编排系统命令;
- 处理文件描述符、管道和退出状态;
- 编写部署、构建、备份和运维胶水脚本;
- 逻辑规模较小、外部命令是主要工作对象的程序。
当需求包含以下特征时,应认真考虑 Python、Go 或其他更适合的语言:
- 复杂数据结构;
- 大规模文本处理;
- 并发控制;
- 网络协议;
- 精确的错误类型;
- 长时间运行的服务;
- 需要严格处理任意字节数据;
- 需要可移植的单元测试和库复用。
Shell 的变量、数组和退出码模型很轻量,但也因此不适合承载复杂应用程序状态。
2. Bash 模式不是安全边界
即使使用:
set -Eeuo pipefail
也不能自动防止:
- 命令注入;
- 路径遍历;
- 权限错误;
- TOCTOU 竞态;
- 不可信输入导致的选项注入;
- 临时文件竞争;
- 供应链风险;
- 外部命令返回语义被误判。
例如:
grep "$pattern" "$file"
如果 $pattern 以 - 开头,某些命令可能把它解释成选项。应根据命令语法使用 -- 或固定选项位置:
grep -- "$pattern" "$file"
但并非每个命令都以完全相同方式支持 --,必须查看对应命令的手册页。
3. 可读性本身是可靠性工具
下面两种写法功能可能相同:
cmd "$file" || exit 1
if ! cmd "$file"; then
printf 'failed to process file: %s\n' "$file" >&2
exit 1
fi
第二种代码更长,但明确说明了:
- 哪个命令失败;
- 失败时记录什么;
- 返回什么状态;
- 是否还有清理动作。
脚本的可靠性不仅取决于“正常路径能运行”,还取决于异常路径是否可观察、可恢复和可测试。
十六、交稿前自查清单
本文涉及的核心机制可以用以下问题复核:
- 是否知道 Bash 展开的主要顺序,以及引用如何改变词分割和路径名展开?
- 是否能解释未加引号变量为何会在空格、通配符和空值处失败?
- 是否区分了
$@、$*、数组的"${array[@]}"和"${array[*]}"? - 是否知道命令替换会删除末尾换行,且在子 Shell 中执行?
- 是否理解重定向从左到右处理,以及
2>&1的顺序含义? - 是否知道管道通常建立多个进程,变量不会自然回写?
- 是否理解默认管道状态、
pipefail和PIPESTATUS的区别? - 是否把
set -e当作有限的退出策略,而不是完整异常系统? - 是否能在
set -e下正确捕获命令失败和原始退出码? - 是否处理了
set -u、ERR陷阱、信号、临时目录和清理失败? - 是否测试了语法、静态问题、正常输入、异常输入、依赖失败和并发?
- 是否考虑了 cron、systemd timer 的环境、时区、错过执行和防重入?
- 是否明确了 Bash 专有能力、GNU 扩展和发行版差异?
- 是否通过可信包管理渠道安装并验证脚本依赖?
掌握这些机制后,Shell 脚本就不再只是命令列表,而可以被当作一种有明确参数边界、进程模型、数据流和故障语义的工程程序来设计。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:systemd 服务管理:Unit、依赖、重启、资源限制和安全沙箱
- 下一篇:Linux 软件包管理:APT、DNF、仓库、签名、依赖与可重复安装
- 延伸:Linux 定时任务:cron、systemd timer、时区、防重入和错过执行
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论