Shell 脚本最容易给人一种错觉:把终端里能跑的命令逐行粘进文件,再加一个 #!/bin/bash,自动化就完成了。这个办法能应付一次演示,却经不起带空格的文件名、缺失参数、半途到来的信号、管道左侧失败,或一次需要回滚的发布。
我们换一种视角。脚本不是“命令收藏夹”,而是一个小程序:它有解释器和启动方式,有输入与输出契约,有成功与失败状态,也要管理临时资源、测试证据和版本。沿着生命周期学习,语法就不再是一堆特殊符号,而是每个边界上的明确选择。

本文的实操只在隔离容器的 /tmp/welearn-ch24 中创建脚本、夹具和临时文件。示例会刻意触发权限错误、引用错误、非零退出和信号中断,再核对预期状态与清理结果。这样做的原因是:失败路径只有真正运行过,才能证明脚本在异常条件下仍可控。
exec 完成进程替换直接运行有执行权限的脚本时,交互式 Shell 先解析命令名,通常创建子进程,再请求内核执行目标文件。内核看到首行的 #!,便把后面的路径当作解释器,并把脚本路径和原参数交给它:
#!/bin/bash
printf 'hello: %s\n' "${1:-world}"Linux 上的解释器脚本首行可概括为 #!解释器路径 [一个可选参数]。解释器必须真实存在且可执行。#!/bin/bash 明确要求 Bash;#!/bin/sh 则承诺只使用目标系统的 sh 语法,它可能指向 dash、Bash 的兼容模式或其他实现。若正文用了数组、[[ ... ]]、(( ... )) 或 BASH_SOURCE,却写成 /bin/sh,脚本的接口和实现就互相矛盾。
#!/usr/bin/env bash 会让 env 按 PATH 找 Bash,适合解释器位置不统一的开发环境,但也意味着实际执行的是搜索到的第一个同名程序。系统任务、特权边界或需要严格复现的部署更适合固定并核验解释器路径。
shebang 只在脚本被直接执行时由内核使用。运行 bash tool.sh 时,是你明确启动的 Bash 读取文件,文件内的 shebang 不负责选择解释器。执行链中的 exec 还有一个关键含义:成功时,新程序替换当前进程映像,不会返回到原程序。脚本里的 exec server "$@" 常用于让长期服务直接继承脚本的进程号与信号;若后面还有必须执行的动作,就不能把 exec 随意放在前面。
脚本保存后先查看权限,再按使用范围授权:
chmod 700 report.sh # 只有所有者可读、写、执行
chmod 755 report.sh # 所有人可读、执行,只有所有者可写直接执行 ./report.sh 需要目录可搜索、文件可读取且可执行,文件系统还不能以 noexec 方式挂载。隔离实测中,权限为 600 的脚本直接执行返回 126;同一个文件用 bash ./report.sh 可以运行,因为这时 Bash 只把它当作要读取的输入文件。加上执行位后,直接执行才成功。
两种启动方式不是等价的“绕过技巧”。显式运行 bash script 会忽略 shebang 指定的其他解释器,并可能让应该暴露的部署错误暂时消失。排查时可以用它区分“文件不可执行”和“脚本内容有问题”,交付时仍应按预定入口验收。
还要区分 126 和 127:前者通常表示命令找到了但无法执行,后者通常表示命令或 shebang 解释器找不到。它们都不是完整诊断,真正的判断要结合标准错误消息和目标环境。
PATH 搜索命令,当前目录必须明确表达输入 report 时,Shell 会按 PATH 中从左到右的目录搜索可执行文件。出于安全和可预测性考虑,当前目录通常不在 PATH 里,所以脚本就在眼前也可能得到 “command not found”。要运行当前目录的文件,应写 ./report。./ 是路径的一部分,明确告诉 Shell 不要做 PATH 搜索。
要把个人工具作为稳定命令使用,可以放进受控目录,例如用户自己的 ~/bin,再加入启动环境:
export PATH="$HOME/bin:$PATH"脚本内部调用依赖时也不能想当然。交互环境里的别名、函数和宽松 PATH 往往不会出现在定时任务、服务管理器或精简容器中。可靠做法是声明依赖,用 command -v awk >/dev/null 2>&1 做启动前检查,并为服务配置明确、最小的 PATH。不要把 . 放到系统任务的搜索路径前端,否则可写目录里的同名文件可能抢先被执行。

一个小脚本也值得有稳定骨架:
#!/usr/bin/env bash
set -u
readonly PROGRAM=${0##*/}
readonly VERSION='1.0.0'
usage() {
printf '用法: %s 文件...\n' "$PROGRAM" >&2
}
main() {
# 参数检查、核心动作和返回状态放在这里。
:
}
main "$@"首行确定运行时,空行划分模块,常量说明公共配置,函数把解析、校验和动作隔开,最后一行只有一个清晰入口。注释应解释“为什么这样做”或“哪个约束不明显”,不用逐字复述命令。缩进风格选两格或四格都可以,关键是 if、case、循环和函数体始终一致。
长命令可以在运算符或参数边界换行:
find "$root" \
-type f \
-name '*.log' \
-mtime +7 \
-print续行反斜杠后面不能再有空格。与其依赖一个肉眼难查的长续行,不如把参数放进 Bash 数组,或把复杂步骤拆成有名称的函数。长选项通常更易读,但可移植脚本还要确认目标工具是否支持。
printf 输出,用 read -r 接收文本echo 适合快速交互,但不同实现对 -n、-e 和反斜杠的处理并不完全一致。脚本接口更适合 printf:
printf '处理完成:%s\n' "$file"
printf '%s\n' "$value"格式串应由脚本控制,外部数据放在后续参数中。不要写 printf "$user_input",因为输入里的 %s、%b 会被当成格式指令。
读取一整行时常用:
IFS= read -r lineIFS= 防止首尾空白被字段分隔规则吃掉,-r 防止反斜杠被解释。读取交互输入前还要判断是否连接终端。定时任务里盲目等待 read 会永久挂住;批处理工具应优先用参数、配置或标准输入,并给交互确认提供显式开关。
多行文本或标准输入可以用 here document。未引用的结束标记会展开变量和命令替换,引用标记则保持正文原样:
cat <<EOF
项目:$project
EOF
cat <<'LITERAL'
这里的 $project 不会展开。
LITERAL结束标记必须单独成行且不能多出空格。<<-EOF 只会剥去前导 Tab,不会剥普通空格;为了视觉缩进而混用二者,很容易让终止标记失效。
变量赋值的等号两边不能留空格:
project='we-learn'
output_dir="$HOME/reports"
report_file="${output_dir}/${project}.txt"${name} 明确变量名边界。普通 Shell 变量只在当前 Shell 上下文中可见;子进程需要的变量要 export。不要无差别导出全部配置,尤其不要把秘密塞进环境后又把调试环境完整打印到日志。
命令替换把标准输出捕获成字符串:
line_count=$(wc -l < "$file")替换会去掉末尾连续换行,Shell 变量也不能保存 NUL 字节,所以它不适合无损搬运任意二进制或文件名列表。把 $(find ...)、$(ls ...) 当数组来源还会遇到分词和换行文件名;应改用 NUL 分隔、readarray -d '',或直接让 find -exec 处理。
Bash 算术可写 next=$((count + 1))。((expression)) 既计算又返回状态:结果非零时状态为 0,结果为 0 时状态为 1。隔离实测里,set -e 下的 ((counter -= 1)) 在计数降到 0 时让脚本意外退出;赋值形式 counter=$((counter - 1)) 不会把算术真假误当成未处理故障。
$#、$0、位置参数与 shift 描述调用现场脚本收到的参数不是一段文本,而是一组有边界的字符串:
printf '程序名:%s\n' "$0"
printf '参数数量:%d\n' "$#"
printf '第一个参数:%s\n' "${1-}"
printf '第十个参数:%s\n' "${10-}"$0 是调用时使用的名称,不保证是规范化绝对路径。只想显示命令名时可以用 ${0##*/}。$# 是参数个数;${10} 必须加花括号,否则 $10 会被解析成 $1 后接字符 0。
shift 删除 $1,其余参数依次前移,适合手工解析简单子命令:
while (($# > 0)); do
case $1 in
--) shift; break ;;
--verbose) verbose=1; shift ;;
*) break ;;
esac
done每次 shift 前都要保证参数仍存在。复杂短选项、组合选项和选项参数更适合 getopts,不要让一个不断扩大的 case 变成第二套解析器。
"$@" 保留边界,"$*" 合并参数把参数转交给另一个命令时,默认写法是:
run_task "$@"在双引号内,"$@" 展开为多个独立参数,每个原参数保持原边界,空参数也不会丢。"$*" 则展开为一个参数,成员由 IFS 的第一个字符连接。未引用的 $@ 和 $* 还会再次字段分割和路径名展开。
隔离实测把三个参数 alpha beta、* 和空字符串转交给参数观察器:
"$@" → 3 个参数:<alpha beta> <*> <>
"$*" → 1 个参数:<alpha beta * >
未引用 $@ → 空参数丢失,空格拆词,glob-* 展开成多个文件名这就是为什么“文件名平时没有空格”不是省略引号的理由。脚本接口应按允许的输入范围正确工作,而不是按一组碰巧简单的样例工作。

getopts 让失败尽早发生参数展开可以明确未设置和空值策略:
mode=${MODE:-safe}
config=${CONFIG_PATH:?必须设置配置路径}
optional=${OPTIONAL-}冒号版本把“未设置或为空”都视为缺失;不带冒号只区分是否设置。业务接口应明确空字符串是否有效,而不是机械套用一种形式。
短选项可用 Shell 内建的 getopts:
verbose=0
output=''
while getopts ':vo:' option; do
case $option in
v) verbose=1 ;;
o) output=$OPTARG ;;
:) printf '选项 -%s 缺少参数\n' "$OPTARG" >&2; exit 64 ;;
\?) printf '未知选项:-%s\n'
开头的冒号启用静默错误模式,让脚本统一生成诊断。循环结束后按 OPTIND 移走已解析选项,"$@" 就只剩位置参数。若同一 Shell 上下文多次调用解析函数,要自行重置 OPTIND=1。getopts 主要处理短选项;需要长选项时,可以写边界清楚的 case,或选用成熟解析器,而不是用 eval 拼接命令。
Shell 在执行命令前会完成参数展开、命令替换、算术展开、字段分割和路径名展开。引用决定哪些步骤可以发生。
IFS 分成多个词,再把 *、?、[...] 当作 glob。price='$100'
printf '%s\n' "$price"
grep 'r.*t' "$file"
printf '%s\n' "$HOME"正则表达式和 glob 都使用类似符号,却由不同程序解释。grep 'r.*t' 的引号是防止 Shell 抢先展开;引号移除后,grep 仍会看到 r.*t 并按正则处理。双引号内可以展开变量,但不会把变量里的空格重新切开,所以 printf '<%s>\n' "$name" 始终给 printf 一个数据参数。
下面的写法看似方便,实际上无法可靠表达参数边界:
options='--output "report file.txt"'
tool $options变量中的引号不会在展开后重新获得语法作用,它们只会变成普通字符。继续用 eval "tool $options" 会把数据再次当代码解析,输入一旦可控就可能执行意外命令。
Bash 可以用数组保存 argv:
args=(--output 'report file.txt')
((verbose)) && args+=(--verbose)
tool "${args[@]}" "$input"POSIX sh 没有数组,可以用函数和 set -- 逐个构造位置参数:
set -- --output "$output"
[ "$verbose" = 1 ] && set -- "$@" --verbose
tool "$@" "$input"核心原则是始终保存“参数列表”,不要保存“希望以后重新解析的一行命令”。如果动态参数来源是配置文件,也要先定义配置语法,而不是把整行配置直接交给 Shell。
常见安全写法是:
kernel=$(uname -r)
printf 'kernel=%s\n' "$kernel"赋值右侧不会做普通字段分割,但变量以后参与命令参数时仍应引用。命令替换会删除末尾换行;内部失败的状态还可能被外层命令覆盖。例如:
value=$(produce_value)
status=$?这里赋值命令的状态通常来自 produce_value,必须立刻保存。若写成 local value=$(produce_value),某些 Shell 中 local 自身的成功状态会遮住替换失败;更清楚的写法是先 local value,再单独赋值并检查。
需要逐行读列表时,用循环输入而不是 for item in $(command):
while IFS= read -r line; do
process "$line"
done < input.txt任意文件名应使用 NUL 分隔。文本行与文件名是不同数据模型,不能因为测试数据没有换行字符就把二者混用。
Shell 的长处是编排专用工具。${path##*/} 或 basename -- "$path" 取末段名称,sed 做流式替换,awk 按记录和字段计算。对文件集合,优先使用 find ... -exec command {} +;若目标工具链支持,也可用 find ... -print0 | xargs -0 command 保留包含空白和换行的名称。普通换行分隔的 find | xargs 会把文件名当文本切开,不能用于不受约束的路径。
if、&& 和 || 判断的是命令状态Unix 风格命令通常用 0 表示成功,用非零表示其他结果。Shell 的 if 不要求布尔表达式,它直接执行条件位置的命令:
if grep -q -- "$pattern" "$file"; then
printf '找到匹配\n'
else
status=$?
case $status in
1) printf '没有匹配\n' ;;
*) printf '读取或正则错误\n' >&2; exit "$status" ;;
esac
figrep 的 1 通常表示没有匹配,不等于程序故障;2 才表示错误。任何依赖状态的脚本都应查看目标命令的退出约定,不要把所有非零都翻译成同一句“执行失败”。
command1 && command2 只在左侧成功时运行右侧;command1 || fallback 只在左侧非零时运行右侧。短路列表适合短小动作,涉及日志、恢复和多种状态时,完整 if 更容易审查。需要保存 $? 时必须紧跟目标命令,因为任何后续命令都会覆盖它。
[ ... ]、[[ ... ]] 和 (( ... )) 解决不同问题单中括号是 test 的另一种写法,参数之间必须有空格,展开值通常要引用:
if [ -f "$file" ] && [ -s "$file" ]; then
printf '普通且非空\n'
fi常用文件判断包括 -e 存在、-f 普通文件、-d 目录、-r/-w/-x 权限、-s 非空。字符串用 =、!=、-z、-n;整数比较用 -eq/-ne/-lt/-le/-gt/-ge。不要用字符串的 > 代替数值比较。
[[ ... ]] 是 Bash 条件关键字,内部变量展开不会做普通字段分割和 glob,支持模式与 =~ 正则:
if [[ $name == report-*.txt ]]; then
:
fi右侧模式是否引用会改变含义:引用后通常按字面字符串比较。算术条件用 ((count >= 10))。选定 Bash 后可以使用这些能力;承诺 /bin/sh 可移植性时,就回到 POSIX 的 [ ]、case 和算术展开。
case 把字符串分派写成一张可读的表多分支字符串匹配用 case 往往比长串 elif 清楚:
case ${1-} in
start|run) start_service ;;
stop) stop_service ;;
--help|-h) usage ;;
'') printf '缺少子命令\n' >&2; exit 64 ;;
*) printf '未知子命令:%s\n' "$1" >&2; exit 64 ;;
esac模式由 case 解释,不是正则;* 表示任意字符串,? 表示单个字符,a|b 合并分支。顺序从上到下,宽泛模式应放后面。每个分支用 ;; 结束,避免意外落入后续分支。
case 也适合规范化有限状态,比如把 yes|y|Y 映射为确认。不要用它假装解析无限复杂的结构化数据;JSON、复杂日期或嵌套配置应交给了解其语法的工具。
for 遍历参数或已知集合,不解析 ls遍历调用者传入的文件:
for file in "$@"; do
process_file "$file"
done遍历 glob 时,模式本身不能整体引用:
for file in "$root"/*.log; do
[[ -e $file ]] || continue
process_file "$file"
done如果没有匹配,默认 Bash 可能保留字面模式,所以要检查 -e,或在受控函数范围内启用并恢复 nullglob。不要写 for file in $(ls):ls 的输出是给人看的文本,空格、换行、控制字符和显示选项都会破坏文件名边界。
批量动作还应先决定部分失败策略。是遇到第一个错误立即结束,还是处理全部输入并汇总失败?后者可以维护 failed=0,每个失败设为 1,循环结束后 exit "$failed"。这个状态是接口的一部分,要写进用法和测试。
while 与 until 适合状态驱动,但要防止子 Shell 丢变量按行读取文件:
count=0
while IFS= read -r line; do
count=$((count + 1))
process_line "$line"
done < "$input"
printf '共处理 %d 行\n' "$count"把重定向放到循环末尾,循环通常在当前 Shell 环境运行,count 在结束后仍可见。若写成 producer | while ...; do ...; done,Bash 默认让管道各段运行在子 Shell 中,循环里修改的变量可能在外层消失。需要流式生产者时,Bash 可用进程替换 done < <(producer);POSIX 脚本可以把状态写到文件、调整管道结构,或避免依赖循环外变量。
圆括号会显式创建子 Shell,适合把目录或环境变化限制在局部:(cd "$workdir" && run_task)。子 Shell 退出后,外层工作目录和变量不变;反过来,它也不能用普通赋值把结果传回父 Shell,只能通过输出、状态、文件或进程间通信返回。
until command; do ...; done 在命令成功前重复,适合带上限的就绪探测:
attempt=0
until service_ready; do
attempt=$((attempt + 1))
((attempt < 5)) || exit 75
sleep 1
done所有重试都应有次数或截止时间,并区分“尚未就绪”和“永久错误”。无限重试会把真实故障变成长期占用资源的静默任务。
return 是状态函数可把重复动作和边界检查封装起来:
make_label() {
local source=$1
printf 'label:%s\n' "$source"
}
validate_even() {
local number=$1
((number % 2 == 0))
}
label=$(make_label demo)
if validate_even 42; then
printf '%s\n' "$label
return 传的是 0–255 范围内的退出状态,不是任意字符串或大整数。要返回数据,用标准输出并让调用者捕获;诊断必须写标准错误,否则会污染捕获结果。函数最后一条命令的状态会成为函数状态,关键分支最好显式 return 0 或 return 1,避免后来新增调试 printf 意外把失败改成成功。
Bash 的 local 不是 POSIX sh 必备能力,而且有动态作用域特征:被调用函数可能看到调用者的局部变量。公共函数应通过参数传值,少依赖这种隐式共享。
pipefail 才暴露上游失败对于 produce | transform | save,Bash 默认用最后一个命令的状态作为整条管道状态。若 produce 失败而 save 正常读到空输入并退出 0,管道表面仍可能成功。启用:
set -o pipefail后,管道状态会变成最右侧非零命令的状态;全部成功才为 0。隔离实测中的 false | true 默认状态为 0,打开 pipefail 后为 1,PIPESTATUS 立即读到两个分量 1 0。
PIPESTATUS 是 Bash 数组,任何后续命令都会刷新它,必须紧跟管道保存。pipefail 也不说明哪一段的标准错误应该如何处理,更不替代输出验收。像 grep pattern file | head -n 1 还可能因下游提前关闭触发 SIGPIPE;是否视为故障要结合业务意图。
set -e 有条件上下文例外,不是自动异常系统set -e 或 set -o errexit 的规则取决于语法位置。Bash 不会因为以下位置的非零状态立即退出:
if、while、until 用来判断的命令;&& 或 || 列表中除最后一项外的命令;pipefail 影响;! 反转的命令。实测结果很直观:
set -e; if false; then ...; fi; echo survived
set -e; false && echo ...; echo survived
set -e; false | true; echo survived三段都会继续;第三段加 set -o pipefail 才在管道后停止。函数、子 Shell、命令替换和调用上下文还会让规则更难凭直觉预测。
因此不要宣传一行 set -euo pipefail 能自动让所有脚本安全。可以在理解边界后选择启用,但对预期会失败的命令,仍用 if command; then ... else ... fi 显式处理;对关键结果,再验证输出文件、行数、哈希或业务条件。

set -u 能发现拼写,也需要为可选值留出口set -u 在展开未设置变量时退出,能捕获许多变量名拼写错误:
set -u
printf '%s\n' "$required"可选值和位置参数要先检查或提供默认值:
optional=${OPTIONAL_VALUE:-}
first=${1-}
if (($# == 0)); then
usage
exit 64
fi空字符串和未设置不是总是等价,${name-} 与 ${name:-} 的差别应按接口决定。数组、间接引用和不同 Bash 版本在 nounset 下还有更多边界,不能只在一组完整参数上试跑。
一个务实起点是 set -u -o pipefail,再对每个关键命令显式检查;是否加 -e 由团队风格和测试覆盖决定。无论选择什么开关,脚本都要有失败用例,因为开关只改变控制流,不会验证业务结果。
mktemp 原子创建,不要用 $$ 猜名字这类旧写法有竞态:
tmp="/tmp/report.$$"
printf '%s\n' "$data" > "$tmp"进程号可预测,检查“文件不存在”和随后创建之间还有时间窗口,其他进程可能抢先建立文件或符号链接。应让 mktemp 完成唯一命名和创建:
old_umask=$(umask)
umask 077
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/report.XXXXXX") || exit 1
umask "$old_umask"模板末尾需要足够的 X。目录形式便于把多个中间文件放进同一个受控边界。所有路径都要引用,删除时加 --:rm -rf -- "$tmpdir"。
还要先保证变量非空、确实由脚本创建且位于允许根目录;不要把未经检查的用户输入交给递归删除。umask 077 让新文件默认只对所有者开放。实测创建的秘密文件权限为 600,运行结束后整个练习目录不存在。
trap 要延迟求值、幂等,并覆盖正常与信号路径一个可复用的清理骨架:
tmpdir=''
cleaned=0
cleanup() {
if ((cleaned == 0)); then
[[ -n $tmpdir ]] && rm -rf -- "$tmpdir"
cleaned=1
fi
}
on_signal() {
local status=$1
cleanup
exit "$status"
}
trap 'cleanup' EXIT 使用单引号,让动作在触发时解析;若在注册时就用双引号展开尚未赋值的临时路径,清理可能锁定错误值。清理函数要幂等,因为信号处理函数调用一次后,exit 还会触发 EXIT。隔离实测向工作进程发送 TERM,它以 143 退出,临时目录消失,清理动作只产生一次有效效果。
EXIT 是 Shell 的伪信号;INT 通常来自 Ctrl-C,TERM 是常见的优雅停止请求。KILL 和 STOP 不能捕获,所以磁盘临时区还要能在下次启动时识别并安全回收。脚本若启动子进程,还要考虑转发信号和等待子进程,不能只删父脚本自己的文件。
临时目录解决唯一命名与清理,不会自动保证业务原子性。生成最终报告时,可以先在目标文件同一文件系统的临时路径写完整内容,校验后再重命名:
target_dir=${output%/*}
[[ $target_dir == "$output" ]] && target_dir='.'
stage=$(mktemp "$target_dir/.report.XXXXXX") || exit 1
generate_report > "$stage" || {
rm -f -- "$stage"
exit 1
}
chmod 600
同一文件系统内的重命名通常提供原子名称切换,读者不会看到半个文件。它仍不能同时提交数据库、远程对象和多个文件;跨资源一致性需要更高层事务或可恢复协议。覆盖现有目标前还要决定备份、权限、所有者和符号链接策略。

一个可组合脚本应让标准输出只包含约定数据,让日志和诊断进入标准错误:
log() {
local level=$1
shift
printf '%s level=%s message=%s\n' \
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
"$level" \
"$*" >&2
}
printf '%s\n' "$result"
log INFO '报告生成完成'这样 result=$(tool) 不会把日志混进数据,tool > report.txt 2> task.log 也能独立保存两条通道。日志至少要有时间、级别、动作和稳定的对象标识;不要为了方便把完整参数、环境或文件内容全部打印出来。
错误消息应包含脚本名和下一步。返回状态再区分用法错误、暂时不可用和内部故障。状态编号可以采用团队约定,但不要假设所有命令共享同一张表。
命令行参数可能被进程查看工具、审计记录或错误日志看到;环境变量也可能被子进程继承并在诊断中泄露。秘密更适合通过受限文件、匿名文件描述符或专用秘密服务传递。创建落盘秘密前设置:
umask 077
secret_file=$(mktemp "${TMPDIR:-/tmp}/credential.XXXXXX") || exit 1使用后由 trap 清理,但删除普通文件并不保证底层介质立即擦除。敏感程度更高时,应避免落盘或使用受控内存接口。
set -x 会把展开后的命令写到标准错误,调试包含令牌的命令前必须关闭。更好的做法是让敏感函数从一开始就不依赖参数回显。日志脱敏要在数据进入格式串之前完成,不能等泄露后再删日志。
bash -n 查语法,ShellCheck 查常见语义风险提交前先做快速门禁:
bash -n script.sh
shellcheck script.shbash -n 读取并解析脚本但不执行,能发现未闭合引号、缺失 fi、错误括号等语法问题。它不会证明命令存在、路径正确或业务结果符合预期。
ShellCheck 会结合 shebang 分析引用、未使用变量、可移植性和常见控制流错误。隔离检查中,正确样例无诊断;反例:
for argument in $@; do
echo $argument
done返回 1,并报告 SC2068 与 SC2086,指出参数数组和变量需要引用。静态分析是高价值审查器,不是运行时证明;确实有意的例外可以在最小范围解释并抑制,不能把整类规则全局关闭。
一个脚本测试至少要记录输入参数、标准输入和环境;标准输出与标准错误;退出状态;创建、修改或删除的文件;以及中断、空输入、重复运行和并发等失败路径。
set +e
./tool.sh --output "$tmp/report file.txt" fixture.txt \
>"$tmp/stdout" 2>"$tmp/stderr"
status=$?
set -e
[[ $status -eq 0 ]]
cmp -s "$tmp/report file.txt" expected/report.txt
[[ ! -s $tmp/stderr ]]预期失败同样要断言准确状态与消息,不能只写“命令失败所以测试通过”。信号测试应后台启动脚本、等待就绪、发送 TERM、wait 取得状态,再确认临时目录消失。每个测试用新的 mktemp -d,退出时清理,避免上一用例污染下一用例。
本章实操覆盖了权限 126、坏解释器 127、"$@" 边界、getopts 的 64、nounset、pipefail、TERM 143、Bash-only 脚本被 dash 拒绝,以及版本切换与回滚。这样做是为了让每条结论都有可复现输出,而不是只验证一条顺利路径。

sh 和 Bash 要做明确选择可移植脚本的核心不是把 shebang 改成 /bin/sh,而是从语法、工具选项到运行环境都遵守目标集合。POSIX 风格可用:
#!/bin/sh
value=${1:-default}
if [ -n "$value" ]; then
printf '%s\n' "$value"
fiBash 版本可以合理使用数组、[[ ]]、进程替换、mapfile、PIPESTATUS 和 local,前提是明确依赖。隔离环境中的 /bin/sh 指向 dash,执行 Bash 数组脚本立即以状态 2 报语法错误,而 POSIX 样例正常输出。
测试可移植性时,应在实际支持矩阵中的多个 Shell 上运行,例如 dash、Bash POSIX 模式和目标系统的 sh,再让 ShellCheck 按 shebang 检查。外部工具同样有方言。GNU sed、find、date 和 mv 的某些选项在其他系统上不同。若脚本强依赖 GNU 行为,应明确声明并检测版本;若追求广泛可移植,就使用标准能力或提供兼容分支。
个人工具可以安装到用户控制的 ~/bin;面向系统用户的本地工具常放 /usr/local/bin,管理命令可放 /usr/local/sbin。安装动作需要相应授权,但运行脚本不应因为方便就长期使用高权限。脚本本身通常由管理员可写、使用者只读执行,避免可写脚本被高权限任务调用。
交付清单至少包括:
PATH;定时任务和服务应以干净环境验收:
env -i \
HOME="$HOME" \
PATH=/usr/local/bin:/usr/bin:/bin \
/usr/local/bin/report-tool --version脚本不要依赖交互式启动文件、当前工作目录、别名或未声明环境变量。相对业务路径应基于明确配置;若要定位脚本自身目录,还要理解符号链接和被 source 时的差异。被 source 的文件在当前 Shell 执行,可修改调用者变量和工作目录;普通执行只通过 stdout、stderr 和状态与父进程交流。共享库式脚本应只定义函数和常量,避免加载时立刻执行主任务。
POSIX 形式 . ./config.sh 与 Bash 的 source ./config.sh 都不是“读取数据文件”,而是执行其中的 Shell 代码。只能加载来源、所有者和权限已经核对的文件。若配置只需要键值数据,更稳妥的做法是定义受限格式并用专门解析逻辑读取,避免把配置权限等同于代码执行权限。
不要直接覆盖唯一脚本后再期待“出问题时找旧文件”。一种简单布局是:
/opt/report-tool/
├── releases/
│ ├── 1.4.2/report-tool
│ └── 1.5.0/report-tool
└── current -> releases/1.5.0先把新版本完整放进独立目录,核验哈希、权限、语法、ShellCheck 和冒烟测试,再切换 current。失败时把链接切回上一版本。隔离实测依次得到 version=1、切换后的 version=2 和回滚后的 version=1,证明入口变化可观察。

代码回滚不等于状态回滚。若脚本修改数据库、远程资源或不可逆文件,新旧代码之间还需要兼容窗口、备份和恢复方案。发布前也要锁定并发:两个实例同时切换、清理或写同一目标时,单进程正确仍不足以保证系统结果。
部署完成后保留版本、时间、操作者、哈希和测试结果。若需要长期演进、复杂数据结构、并发、网络协议或大型测试框架,及时迁移到更合适的语言,而不是让 Shell 脚本无限增长。
目标工具接收一个模式和多个文本文件,输出每个文件的匹配行数。它使用 Bash,支持 -v 和 -o 文件,错误写 stderr,部分文件失败时继续处理,最终返回 1;用法错误返回 64。

#!/usr/bin/env bash
set -u -o pipefail
readonly PROGRAM=${0##*/}
readonly VERSION='1.0.0'
verbose=0
output='-'
tmpdir=''
failed=0
usage() {
printf '用法: %s [-v] [-o 输出文件] 模式 文件...\n' "$PROGRAM" >&2
}
log() {
这里没有启用 set -e,每个预期非零都按协议处理。grep 的 1 被转换为计数 0,真正错误才记失败;"$@" 保留文件名边界;临时目录权限受限并由正常、INT、TERM 路径清理。install 适合当前 GNU/Linux 目标,若支持其他系统,要把它列进依赖或改成可移植的复制与权限步骤。
为工具建立独立夹具:
fixtures/
├── two-matches.txt
├── zero-match.txt
├── name with spaces.txt
└── missing.txt测试顺序可以这样安排:
bash -n 与 ShellCheck 必须通过,因为它们能在运行前拦截语法、引用和方言问题。-o 写文件后核对权限 600;发送 TERM 后等待状态 143,并确认临时目录消失,因为正常返回无法覆盖中断路径。完成这些测试后,再删除夹具、临时目录和容器。清理不是一句“测试完毕”的口号,而是最后一条断言:运行目录不存在、临时容器已自动移除、没有后台进程继续占用资源。
这也是判断 Shell 是否仍是合适工具的时刻。若接口已经需要嵌套结构、复杂并发、长期状态和大量字符串运算,迁移不是失败,而是把任务交给更匹配的数据模型和测试生态。Shell 最擅长的仍是把现有命令、文件和流连接成短小、透明、可审查的自动化。