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 实现。shdashkshzsh 和 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 命令中,可以用以下顺序建立直觉:

  1. 花括号展开;
  2. 波浪号展开;
  3. 参数展开、命令替换、算术展开;
  4. 词分割;
  5. 路径名展开;
  6. 引号移除。

实际行为还受上下文影响,例如赋值语句、重定向、[[ ... ]] 和数组上下文有特殊规则。因此这是一种工程上有用的主顺序,而不是可以替代 Bash 语法规则的完整形式化定义。

看一个完整例子:

prefix='log'
suffix='*.txt'

printf '<%s>\n' ${prefix}_${suffix}

展开过程大致是:

  1. 参数展开:
    log_*.txt
    
  2. 词分割:如果结果中没有空白,仍是一个词;
  3. 路径名展开:如果当前目录存在 log_a.txtlog_b.txt,则变成两个参数;
  4. 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 -einherit_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 都有自己的语法。用 grepcut 或 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 从左到右处理重定向:

  1. >all.log:让标准输出指向 all.log
  2. 2>&1:让标准错误复制当前标准输出的目标。

下面写法含义不同:

command 2>&1 >all.log

处理顺序是:

  1. 标准错误先复制当前标准输出,通常仍指向终端;
  2. 标准输出再指向文件。

结果可能是标准输出进入文件,但标准错误仍显示在终端。

现代 Bash 还支持:

command &>all.log

这是 Bash 语法,不是所有 POSIX Shell 都支持。

2. 管道建立进程间数据流

管道:

producer | consumer

Shell 通常会:

  1. 创建管道;
  2. 启动 producer,把其标准输出连接到管道写端;
  3. 启动 consumer,把其标准输入连接到管道读端;
  4. 等待管道中的命令;
  5. 根据规则计算管道退出状态。

数据流是:

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 最容易被误解。它不是“任何子命令返回非零,脚本都立即退出”。在以下上下文中,失败通常不会触发立即退出:

  • ifwhile 的条件命令;
  • 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. 临时文件的正确生命周期

临时文件至少要考虑:

  1. 创建位置和权限;
  2. 文件名冲突;
  3. 中断后的清理;
  4. 清理失败;
  5. 是否需要跨进程共享。

不应手工拼接:

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 "$@"

这个例子包含几个重要设计点:

  1. main "$@" 保留原始参数边界;
  2. 参数数量错误返回 64,表示命令用法错误;
  3. 输入目录不存在时返回明确的非零状态;
  4. 临时目录由 mktemp -d 创建;
  5. 先写临时文件,再用 install 写入目标文件,避免直接截断已有输出;
  6. find 的路径参数被引用,避免目录名中的空格造成错误;
  7. 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

如果脚本需要 jqflock 或特定包,应在部署阶段通过 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

第二种代码更长,但明确说明了:

  • 哪个命令失败;
  • 失败时记录什么;
  • 返回什么状态;
  • 是否还有清理动作。

脚本的可靠性不仅取决于“正常路径能运行”,还取决于异常路径是否可观察、可恢复和可测试。


十六、交稿前自查清单

本文涉及的核心机制可以用以下问题复核:

  1. 是否知道 Bash 展开的主要顺序,以及引用如何改变词分割和路径名展开?
  2. 是否能解释未加引号变量为何会在空格、通配符和空值处失败?
  3. 是否区分了 $@$*、数组的 "${array[@]}""${array[*]}"
  4. 是否知道命令替换会删除末尾换行,且在子 Shell 中执行?
  5. 是否理解重定向从左到右处理,以及 2>&1 的顺序含义?
  6. 是否知道管道通常建立多个进程,变量不会自然回写?
  7. 是否理解默认管道状态、pipefailPIPESTATUS 的区别?
  8. 是否把 set -e 当作有限的退出策略,而不是完整异常系统?
  9. 是否能在 set -e 下正确捕获命令失败和原始退出码?
  10. 是否处理了 set -uERR 陷阱、信号、临时目录和清理失败?
  11. 是否测试了语法、静态问题、正常输入、异常输入、依赖失败和并发?
  12. 是否考虑了 cron、systemd timer 的环境、时区、错过执行和防重入?
  13. 是否明确了 Bash 专有能力、GNU 扩展和发行版差异?
  14. 是否通过可信包管理渠道安装并验证脚本依赖?

掌握这些机制后,Shell 脚本就不再只是命令列表,而可以被当作一种有明确参数边界、进程模型、数据流和故障语义的工程程序来设计。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。