健壮 Shell 脚本编写指南

原作者:刘家财。原文发布于 2024 年 4 月 5 日,更新于 2024 年 5 月 9 日。本稿于 2026 年 10 月 5 日对原有中文全文核查整理,保留全部实质主题、主要示例和参考线索,并明确校正参数、引用、errexit、管道及清理语义;所有示例均未实际执行。

作者为 asdf 编写插件时,遇到了为保证可移植性而设定的命令限制,进而重新梳理 Shell 编程。Shell 脚本通常由一种解释器,例如 Bash、Zsh,加上一组外部工具,例如 grep、awk,共同组成。写法相似,不代表不同解释器、不同工具版本的行为相同。本文以 POSIX Shell 的基本概念为起点,同时介绍常见的 Bash 扩展;使用数组、[[ ]]、(( ))、local 等内容时,要明确选择 Bash,不能直接宣称脚本适用于任意 /bin/sh。

Shell 脚本处理流程:解析命令和操作符,展开并保留参数边界,执行命令与管道,最后处理返回状态和清理;引用、pipefail 与 EXIT trap 各自解决不同问题。
未完纪绘制:引用、执行与错误处理的关系。基于刘家财原文并结合文中校注制作,非终端截图。

变量:空格与边界决定含义

赋值写成 var=value,等号两侧不能随意插入空格;否则 Shell 会把词语按命令调用解析。普通参数的值主要按文本处理,算术上下文会按整数解释。校注:原文“变量类型只有 string”是入门简化;Bash 还支持整数属性、索引数组和关联数组,后文也会介绍。

展开变量常用 $var,在命令参数中通常应写成 "${var}"。双引号保留参数边界,花括号消除变量名歧义。原文的例子:

user=john
touch "/tmp/$user_file"
# 希望拼接 user 与 _file 时,应改为:
touch "/tmp/${user}_file"

第一条 touch 引用的是变量 user_file,不是 user 加后缀。这里展示了错误写法与修正,不应在真实共享目录中照着试建文件。需要实验时使用自己拥有的隔离目录。

缺省值常用:

CC=${CC:-gcc}

这表示 CC 未设置或为空时采用 gcc。交互读取可以使用 read。原文的 read my_name 会处理反斜杠并受 IFS 影响;若要按原样读取一整行,整理版明确改为:

printf "%s\n" "What is your name?"
IFS= read -r my_name
printf "Hello %s\n" "$my_name"

差异说明:read 并非只有 Bash 才有;上例加入 IFS= 与 -r,并用格式明确的 printf 输出输入内容,不把用户输入当作格式字符串。

参数 准确含义
$0 Shell 或脚本的名称;普通 Shell 函数调用不会自动把它改成函数名。
$1 至 $9 前九个位置参数。参数不止九个;第十个写成 ${10}。
"$@" 把每个位置参数保留为独立参数,是转交参数的常用形式。
"$*" 在双引号内把位置参数连接成一个词,通常使用 IFS 的首字符。未引用的 $* 与 $@ 都可能再发生分词和文件名展开。
$$ Shell 的进程标识;Bash 子 Shell 中与实际子进程 PID 的关系有细节,不能一概当成当前子进程号。
$? 上一条命令或相应命令结构的退出状态,下一条命令会覆盖它。
IFS 字段分隔字符,默认包含空格、制表符和换行。

这张表修正了原文“最多九个参数”“函数的 $0 是函数名”以及对 $* 的简化描述。

引用:把数据留在一个参数里

foo="a b"
touch $foo        # 错误示例:通常变成两个文件参数
touch "$foo"      # 一个名为 a b 的文件参数
printf "%s\n" '$foo'  # 单引号保留字面量 $foo

未引用的变量展开可能进行字段分割和路径名展开,导致空格拆参、通配符匹配等行为。双引号抑制这些展开,同时仍允许参数展开、命令替换等;单引号内的字符通常保持原样。花括号本身不提供引用保护。

安全边界:引用可以保护参数边界,但不会使任意内容“变安全”。把输入交给 eval、sh -c 或拼成新命令,仍可能产生命令注入。原文没有使用这种不可信执行链,本稿也不新增它。对以 - 开头的文件名,还需按目标工具的约定使用 -- 或明确路径前缀;仅加引号不能阻止选项解析。

Bash 数组

Bash 提供从 0 开始的索引数组,以及以字符串为键的关联数组。关联数组从 Bash 4 开始支持。下面保留原文示例结构,并为数组展开补上双引号:

declare -a indexed=(hello world)
# Bash 4+:
declare -A associative=([hello]=1 [world]=2)

printf "First element is %s\n" "${indexed[0]}"
for i in "${indexed[@]}"; do
    printf "%s\n" "$i"
done

对这里的两个简单词,预期输出依次为 First element is hello、hello、world。原文写作 ${indexed[@]};当元素含空格或通配符时,它可能破坏边界,整理版改为 "${indexed[@]}"。此处只是预期说明,不是本文测试输出。

条件控制与 test

if 检查的是命令退出状态:0 表示成功,非零表示失败。语法可写成:

if test-commands; then
    consequent-commands
elif more-test-commands; then
    more-consequents
else
    alternate-consequents
fi

上段是语法说明,连字符名称代表位置,不是可直接运行的命令。then 若与条件同一行,需要以分号隔开;否则可另起一行。fi 结束整个条件结构。

test 是条件检查命令,[ 是另一种常见形式,最后必须有独立的 ] 参数。Shell 中也可能同时存在同名的外部程序。中括号周围的空格是参数边界的一部分:

# 原文错误示例:缺少 [ 后的分隔空格
if ["${foo}" = "bar" ]; then
    echo "bar"
else
    echo "not bar"
fi

# 修正:同时保护未设置/空值和参数边界
if [ "${foo:-}" = "bar" ]; then
    echo "bar"
else
    echo "not bar"
fi

当 foo 为空时,错误例可能展开成缺少左操作数的比较;当它非空时,甚至可能尝试运行 [值 这个命令名。原文通过 bash -vx if.sh 展示展开过程,但 -v、-x 会输出源码或展开后的参数,可能泄露令牌与密码,只应用于去除敏感数据的诊断环境。

常见判断 作用
[ -e path ] 路径存在
[ -f path ] / [ -d path ] 普通文件 / 目录
[ -S path ] / [ -L path ] 套接字 / 符号链接
[ -w path ] / [ -x path ] 可写 / 可执行检查
[ -s path ] 文件大小大于零
[ -n string ] / [ -z string ] 字符串非空 / 为空
[ string ] 字符串非空
[ s1 = s2 ] / [ s1 != s2 ] 字符串相等 / 不等
-eq / -ne 整数相等 / 不等
-gt / -ge / -lt / -le 整数大于 / 大于等于 / 小于 / 小于等于
[ ! expression ] 条件取反
[ expression1 -a expression2 ] / -o 原文列出的与 / 或组合形式;有参数歧义及可移植性问题,宜改用两个独立判断配合 && 或 ||。

实际代码应引用来自变量的路径和字符串,例如 [ -f "$path" ]。原文将表中条件统称为 POSIX test 条件,本稿保留功能但不作跨所有旧 POSIX 版本及 Shell 的保证;目标实现是否支持某个操作符需要核对。文件检查也不能消除检查与使用之间的竞态,涉及不可信目录时尤其如此。

Bash 的增强判断与算术

[[ ... ]] 是 Bash 条件结构,支持字符串排序、模式和正则匹配。== 或 != 右侧未引用时可作为通配模式,引用后对应内容按字面匹配:

if [[ "foo" == f* ]]; then
    echo "matched"
else
    echo "not matched"
fi

if [[ "foo" == "f*" ]]; then
    echo "matched"
else
    echo "not matched"
fi

if [[ "foo" =~ f.* ]]; then
    echo "matched"
else
    echo "not matched"
fi

按这些常量,前三个判断的预期结果依次是匹配、不匹配、匹配。<、> 可比较字符串顺序;=~ 使用扩展正则表达式,右侧的引用方式会影响哪些字符保留正则意义。不要把不可信正则当作普通字符串,也不要将 [[ ]] 代码用于并不支持它的 sh。

Bash 的 (( expression )) 进行整数算术,表达式值非零时命令状态为成功,值为零时状态为失败。这与“退出状态 0 表示成功”属于两个不同层次:

if (( 1 + 1 )); then
    echo "true"
else
    echo "false"
fi

if (( 1 - 1 )); then
    echo "true"
else
    echo "false"
fi

echo "0.1 + 0.1" | bc -l
if (( $(echo "0.1+0.1 == 0.2" | bc -l) )); then
    echo "0.1+0.1 = 0.2"
else
    echo "0.1+0.1 != 0.2"
fi

预期依次得到 true、false、.2 和相等判断成立的消息。浮点计算交给 bc;它是外部依赖,不保证所有环境已安装。原文这里输入的是常量;若改为外部数据,需要先验证其语法,不能把任意文本当成算术表达式。(( i++ )) 等表达式也可能因计算值为零返回非零,在 set -e 下必须单独考虑。

循环与字段分割

while 条件; do 命令; done 在条件成功时循环;for name in words; do 命令; done 逐个遍历词。原文的计数示例:

for i in 1 2 3; do
    echo "Looping ... number $i"
done

x=3
while [ "$x" -gt 0 ]; do
    echo "Looping ... number $x"
    x=$((x - 1))
done

前一循环依次输出 1、2、3,后一循环依次输出 3、2、1。整理版为 $x 补上引用;这里 x 是内部常量,若它来自外部,应先验证为整数。

原文通过修改 IFS 将冒号分隔字符串拆开:

foo="a:b:c:d"
old_ifs="$IFS"
IFS=":"
for x in ${foo}; do
    echo "$x"
done
IFS="${old_ifs}"

这个例子特意不引用 ${foo},预期产生 a、b、c、d 四个字段;改成 "${foo}" 则不会分割。校注:该写法也会允许后续路径名展开,而且中途异常可能使 IFS 未恢复。对一般不可信输入,Bash 可用局部读取数组的方式明确拆分:

IFS=: read -r -a parts <<< "$foo"
for x in "${parts[@]}"; do
    printf "%s\n" "$x"
done

此处 read -a 和 here-string <<< 是 Bash 用法,属于新增替代方案;输入中的换行、尾部空字段等是否需要保留,要按实际数据格式设计,不能把它当通用 CSV 解析器。

Bash 也支持 C 风格的计数循环:

for (( i=0; i<3; i++ )); do
    echo "Looping ... i is set to $i"
done

上述常量示例预期依次输出 0、1、2。

函数及其作用域

POSIX 风格函数写作 fname() compound-command,Bash 还支持 function fname 形式。函数体不一定是花括号,也可以是其他复合命令。原文用一个直接跟在函数名后的 if 展示这一点;下面引用参数但仍假定调用方传入整数:

my_function()
if [ "$1" -gt 10 ]; then
    echo "Large than 10"
else
    echo "Not large than 10"
fi

my_function 100
my_function 1

预期分别进入“大于 10”和“不大于 10”分支。真实函数应先验证参数个数、空值与整数格式,否则缺失参数、set -u 或非法数字都会改变行为。

函数的状态通常是最后一条命令的状态,也可用 return 提前返回;状态不是任意长度的业务数据,应把数据通过标准输出等渠道传递。普通赋值可能修改外部变量,Bash 中可用 local 声明局部变量:

x=1
my_func() {
    x=2
    echo "x is $x inside func"
}
my_func
echo "x is $x outside func"

x=1
my_local_func() {
    local x=2
    echo "x is $x inside func"
}
my_local_func
echo "x is $x outside func"

第一组内外都显示 2;第二组函数内显示 2,外部仍为 1。原文“函数没有作用域”应理解为未声明局部变量时的示例行为;Bash 的局部变量还有动态作用域细节,不能概括成所有 Shell 都完全没有作用域。

命令分组

( ... ) 在子 Shell 环境中执行,{ ...; } 在当前 Shell 环境中执行。花括号是保留字,需要与其他词分开,结尾命令后用分号或换行结束;圆括号本身具有语法分隔作用。

x=1
(
    echo "x is ${x} in group command 1"
    y=2
)
{
    echo "x is ${x} in group command 2"
    z=3
}
echo "x = ${x}, y = ${y:-}, z=${z}"

子 Shell 可以读取继承的变量,但在子 Shell 内设置的 y 不会回写父环境。当前 Shell 内设置的 z 则保留下来。这里为 y 增加 :-,避免在启用 nounset 时因演示变量不存在而直接退出;这是相对原例的显式修正。环境隔离是语义重点,不应将所有实现细节简单等同于某一种 fork 行为。

Here-document:多行文本是否展开

Here-document 的形式是 [n]<<word,随后写多行内容,并用同一个分隔词结束。可选的 n 是文件描述符,默认指标准输入。未引用的分隔词允许内容进行参数展开、命令替换和算术展开;引用分隔词则保留文本:

cat <<EOF
Home dir is $HOME
EOF

cat <<'EOF'
Home dir is $HOME
EOF

第一段输出实际的主目录值,第二段输出字面量 $HOME。原文用作者机器上的 /Users/jiacai 作为示例,不是本文环境的输出。普通结束分隔符必须独占一行且不能缩进;<<- 可移除前导制表符,但不会移除任意空格。将不可信文本直接嵌进未引用的 here-document,也要审查内容是否会触发展开。

内置命令与 exec

内置命令由 Shell 自身实现,常见的 cd 需要直接改变当前 Shell 的工作目录,普通外部子进程无法反向改变父进程目录。原文列举的其他内置功能如下:

命令 作用
: 通常返回成功,但参数展开和重定向仍会发生,不能说它完全没有副作用。
. filename 在当前 Shell 上下文读取并执行文件;文件必须可信。
exec 有命令时替换当前进程;无命令时可改变当前 Shell 的文件描述符。
export name=value 把变量标记为随后子进程可继承的环境变量。
set -o option / set +o option 打开 / 关闭相应 Shell 选项。

原文的 exec 例子说明替换进程后没有剩余 Shell 继续执行:

echo "Before exec"
exec ls -l
echo "After exec"  # exec 成功时不会执行

这样省去一个只负责等待的父 Shell,使进程树和信号关系更直接。若 exec 失败,行为还取决于 Shell 和相关选项,不应把注释理解成所有失败情形都一样。

没有命令参数时,可以只操作文件描述符:

exec 3< readfile   # 以只读方式打开文件,使用描述符 3
exec 4> writefile  # 打开写入描述符 4;会创建或截断文件
exec 5<&0         # 将描述符 0 复制到 5
exec 3<&-         # 关闭描述符 3

重定向影响当前 Shell 的后续命令。> 会截断已有文件,示例不是无副作用的演示;须先确定目标路径并防止误覆盖或符号链接指向意外位置。

常用展开与命令替换

下面保留原文的常见语法,路径改为相对实验目录,避免鼓励在共享 /tmp 中使用可预测名字:

touch ./t{1,2,3}.txt      # Bash 花括号展开
# echo *                 # 展开当前目录非隐藏匹配项,并不严格等价于 ls

foo="`whoami`"
foo="$(whoami)"          # 更易读,支持嵌套

echo "$(echo hello "$(echo word)")"
echo `echo hello \`echo word\``

foo="hello world"
echo "${#foo}"           # 11
echo "${foo:0:5}"        # hello(Bash)
echo "${foo/o/a}"        # hella world(Bash)
echo "${foo//o/a}"       # hella warld(Bash)

命令替换会移除末尾换行;$(...) 通常比嵌套反引号清晰。花括号展开、字符串切片和模式替换并非所有 POSIX Shell 都支持。上例中的 hello word 仅用于演示嵌套输出。原文把 echo * 注释为等价于 ls,整理版补充了隐藏文件、布局、选项和文件名特殊字符等差异。

严格选项不是自动正确性证明

原文引用了一个常见 Bash 模板:

#!/usr/bin/env bash
set -Eeuo pipefail
trap cleanup SIGINT SIGTERM ERR EXIT
cleanup() {
    trap - SIGINT SIGTERM ERR EXIT
    # script cleanup here
}

script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)

它提出了“出错时停止、统一清理、定位脚本目录”的设计方向,但原文对几个选项的解释需要修正:

  • -e(errexit)不是“任何命令失败立即退出”。处于 if、while、until 条件、部分 &&/|| 列表、非最后管道成员、! 取反等语境中的失败有例外。函数如果作为条件调用,函数体内的错误传播也可能与直觉不同。
  • pipefail 改变的是整条管道的退出状态。默认取最后一个命令的状态;启用后取最右侧的非零状态,全部成功时为零。它不会把管道改为串行,也不保证左边失败时右边尚未启动。
  • -u(nounset)让多数未设置参数的展开报错;对可选参数应明确使用缺省值,并了解 $@、$* 等特殊处理。
  • -E 让 ERR trap 在函数、命令替换和子 Shell 环境中继承。ERR 是 Bash 特殊 trap 条件,不是操作系统错误信号;它也受与 -e 相关的语境例外影响。

原文用不存在的 foo 演示管道状态:

set -e
foo | echo "a"
echo "bar"

# 对照脚本:
set -eo pipefail
foo | echo "a"
echo "bar"

在这两个独立脚本的普通非条件语境中,前者的管道最后一条 echo 成功,所以可能继续输出 bar;后者的管道整体变为失败,触发退出。两者中的 echo "a" 仍可能执行。原文描述“-e 保证第一条失败后后续管道不执行”不准确,本稿据 Bash 手册改正。

原文另比较 -E 对函数中 ERR trap 的继承:

# 分别作为独立脚本比较;第二次在选项中增加 -E
set -euo pipefail
trap 'echo "ERR trap fired!"' ERR
myfunc() {
    foo  # 故意不存在的命令,仅用于错误路径示意
}
myfunc
echo "bar"

# 对照版本把首行选项改为:
# set -Eeuo pipefail

原文展示前者退出但没有函数内的 trap 消息,增加 -E 后会出现 ERR trap fired!。这是原文给定语境的说明,本文未重现;不能推广到所有条件调用或命令替换中。Bash 默认在一些命令替换环境中清除 -e,inherit_errexit 或 POSIX 模式又会影响这一点。

清理必须保留状态并明确处理信号

原模板清理时重置多个 trap,可减少重复调用,但有一个实际边界:捕获 SIGINT 或 SIGTERM 后,如果处理函数只返回而不退出,脚本可能继续运行。原模板也没有明确保留进入清理前的退出状态。清理失败、重复进入、资源创建到一半等情形都需要单独设计。

以下是编辑新增的最小结构,用于说明退出状态与信号的关系,并未实现任何项目资源删除:

#!/usr/bin/env bash
set -Eeuo pipefail

cleanup() {
    local status=$?
    trap - EXIT
    # 只在这里清理本脚本已确认创建的具体资源。
    # 清理失败要单独记录,不覆盖原始 status。
    exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

if ! printf "%s\n" "example operation"; then
    printf "%s\n" "operation failed" >&2
    exit 1
fi

相对原模板,这里把最终清理交给 EXIT,明确约定 INT/TERM 的退出状态,并保存原状态;没有添加 rm -rf 一类泛化清理。仍应根据实际资源和信号需求设计幂等性,这不是可替代项目错误处理的万能模板。SIGKILL、断电等情况不能靠 trap 完成清理。

原模板的 script_dir 在子 Shell 内切换到 BASH_SOURCE[0] 所在目录,再用 pwd -P 获取物理目录;它依赖 Bash,且符号链接入口、被 source 的脚本、路径不可访问等情形需要另行验证。不能把这行概括为“无论怎样调用都保证正确”。

静态工具与进一步阅读

作者推荐 ShellCheck 检查潜在问题,每条诊断都有 对应说明;也可参考 Google Shell Style Guide。Emacs 用户可用 auto-insert 插入经过自己审查的模板。静态检查只能发现部分问题,仍需针对退出状态、条件调用、空值、特殊文件名和信号设计用例。

原文以“理解为什么,而不只是记住推荐写法”收束,并推荐阅读 gh2o/bash_tls 和 p8952/bocker 等 Bash 项目作为学习材料。这里保留学习线索,不将它们作为可直接部署的安全实现推荐。

原文参考还包括 The Shell Scripting Tutorial、Bash Reference Manual 的 Command Grouping、Command Substitution、Shell Parameter Expansion 和 set 章节,以及“花括号为何需要分号”“圆括号是否进入子 Shell”的讨论、ShellCheck 的 Hacker News 讨论、Minimal safe Bash script template 与 Safer bash scripts with set -euxo pipefail。全部原始引用入口保留在 刘家财原文参考区。本次语义校核另参考 Debian 发布的 GNU Bash 5.2 手册。

本文只进行了全文核对与静态审查,没有运行 Shell 示例、ShellCheck、外部项目或清理命令,也没有用伪造终端截图证明效果。未发现原文片段硬编码秘密或直接执行不可信文本的代码,不等于脚本无漏洞;引用遗漏、日志泄密、文件覆盖、状态与信号处理是本文指出的实际审查点。

来源及授权:刘家财《健壮 Shell 脚本编写指南》,原文版权与署名保留。源页未给出本次能够核验的单独转载许可证,因此不自行套用开源许可。中文校注和原创流程图:未完纪。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容