分类课程智能体AI
文章
订阅
分类课程AI导师
文章
价格
课程进度
24 / 25
上一节Linux 编译:从一行 C 到可验证的构建产物下一节终端工具箱:把好玩的命令组合成真正有用的工作流
自在学

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号 | 湘ICP备2025148919号-1

关于我们隐私政策使用条款

© 2025 - 2026 株洲市自在学教育科技有限公司 版权所有

公网安备湘公网安备43020302000292号湘ICP备2025148919号-1

编程LinuxShell 脚本:从一段可执行文本到可靠的自动化工具

Shell 脚本:从一段可执行文本到可靠的自动化工具

Shell 脚本最容易给人一种错觉:把终端里能跑的命令逐行粘进文件,再加一个 #!/bin/bash,自动化就完成了。这个办法能应付一次演示,却经不起带空格的文件名、缺失参数、半途到来的信号、管道左侧失败,或一次需要回滚的发布。

我们换一种视角。脚本不是“命令收藏夹”,而是一个小程序:它有解释器和启动方式,有输入与输出契约,有成功与失败状态,也要管理临时资源、测试证据和版本。沿着生命周期学习,语法就不再是一堆特殊符号,而是每个边界上的明确选择。

Shell 脚本从文本、解释器、参数、执行到测试和部署的完整生命周期

本文的实操只在隔离容器的 /tmp/welearn-ch24 中创建脚本、夹具和临时文件。示例会刻意触发权限错误、引用错误、非零退出和信号中断,再核对预期状态与清理结果。这样做的原因是:失败路径只有真正运行过,才能证明脚本在异常条件下仍可控。


先看清脚本怎样被执行

shebang 选择解释器,exec 完成进程替换

直接运行有执行权限的脚本时,交互式 Shell 先解析命令名,通常创建子进程,再请求内核执行目标文件。内核看到首行的 #!,便把后面的路径当作解释器,并把脚本路径和原参数交给它:

bash
#!/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 随意放在前面。

1
一个脚本使用 Bash 数组和双中括号条件,哪条 shebang 与实现契约一致?

执行权限、读取权限与挂载选项共同决定能否启动

脚本保存后先查看权限,再按使用范围授权:

bash
chmod 700 report.sh   # 只有所有者可读、写、执行
chmod 755 report.sh   # 所有人可读、执行,只有所有者可写

直接执行 ./report.sh 需要目录可搜索、文件可读取且可执行,文件系统还不能以 noexec 方式挂载。隔离实测中,权限为 600 的脚本直接执行返回 126;同一个文件用 bash ./report.sh 可以运行,因为这时 Bash 只把它当作要读取的输入文件。加上执行位后,直接执行才成功。

两种启动方式不是等价的“绕过技巧”。显式运行 bash script 会忽略 shebang 指定的其他解释器,并可能让应该暴露的部署错误暂时消失。排查时可以用它区分“文件不可执行”和“脚本内容有问题”,交付时仍应按预定入口验收。

还要区分 126 和 127:前者通常表示命令找到了但无法执行,后者通常表示命令或 shebang 解释器找不到。它们都不是完整诊断,真正的判断要结合标准错误消息和目标环境。

2
权限为 600 的 Bash 脚本能被 bash script.sh 读取,却不能用 ./script.sh 启动,最直接缺少什么?

PATH 搜索命令,当前目录必须明确表达

输入 report 时,Shell 会按 PATH 中从左到右的目录搜索可执行文件。出于安全和可预测性考虑,当前目录通常不在 PATH 里,所以脚本就在眼前也可能得到 “command not found”。要运行当前目录的文件,应写 ./report。./ 是路径的一部分,明确告诉 Shell 不要做 PATH 搜索。

要把个人工具作为稳定命令使用,可以放进受控目录,例如用户自己的 ~/bin,再加入启动环境:

bash
export PATH="$HOME/bin:$PATH"

脚本内部调用依赖时也不能想当然。交互环境里的别名、函数和宽松 PATH 往往不会出现在定时任务、服务管理器或精简容器中。可靠做法是声明依赖,用 command -v awk >/dev/null 2>&1 做启动前检查,并为服务配置明确、最小的 PATH。不要把 . 放到系统任务的搜索路径前端,否则可写目录里的同名文件可能抢先被执行。

内核、shebang、权限、PATH 与解释器共同组成的脚本启动链

3
命令名在当前目录中,但当前目录不在 PATH。哪些调用方式明确且可预测?

把第一份脚本写成可维护的程序

结构、注释和缩进要服务于阅读路径

一个小脚本也值得有稳定骨架:

bash
#!/usr/bin/env bash
 
set -u
 
readonly PROGRAM=${0##*/}
readonly VERSION='1.0.0'
 
usage() {
  printf '用法: %s 文件...\n' "$PROGRAM" >&2
}
 
main() {
  # 参数检查、核心动作和返回状态放在这里。
  :
}
 
main "$@"

首行确定运行时,空行划分模块,常量说明公共配置,函数把解析、校验和动作隔开,最后一行只有一个清晰入口。注释应解释“为什么这样做”或“哪个约束不明显”,不用逐字复述命令。缩进风格选两格或四格都可以,关键是 if、case、循环和函数体始终一致。

长命令可以在运算符或参数边界换行:

bash
find "$root" \
  -type f \
  -name '*.log' \
  -mtime +7 \
  -print

续行反斜杠后面不能再有空格。与其依赖一个肉眼难查的长续行,不如把参数放进 Bash 数组,或把复杂步骤拆成有名称的函数。长选项通常更易读,但可移植脚本还要确认目标工具是否支持。

4
注释越多,脚本就一定越容易维护。

用 printf 输出,用 read -r 接收文本

echo 适合快速交互,但不同实现对 -n、-e 和反斜杠的处理并不完全一致。脚本接口更适合 printf:

bash
printf '处理完成:%s\n' "$file"
printf '%s\n' "$value"

格式串应由脚本控制,外部数据放在后续参数中。不要写 printf "$user_input",因为输入里的 %s、%b 会被当成格式指令。

读取一整行时常用:

bash
IFS= read -r line

IFS= 防止首尾空白被字段分隔规则吃掉,-r 防止反斜杠被解释。读取交互输入前还要判断是否连接终端。定时任务里盲目等待 read 会永久挂住;批处理工具应优先用参数、配置或标准输入,并给交互确认提供显式开关。

多行文本或标准输入可以用 here document。未引用的结束标记会展开变量和命令替换,引用标记则保持正文原样:

bash
cat <<EOF
项目:$project
EOF
 
cat <<'LITERAL'
这里的 $project 不会展开。
LITERAL

结束标记必须单独成行且不能多出空格。<<-EOF 只会剥去前导 Tab,不会剥普通空格;为了视觉缩进而混用二者,很容易让终止标记失效。

5
要原样读取可能含首尾空格和反斜杠的一行,哪些设置有直接作用?

变量、命令替换和算术各有数据边界

变量赋值的等号两边不能留空格:

bash
project='we-learn'
output_dir="$HOME/reports"
report_file="${output_dir}/${project}.txt"

${name} 明确变量名边界。普通 Shell 变量只在当前 Shell 上下文中可见;子进程需要的变量要 export。不要无差别导出全部配置,尤其不要把秘密塞进环境后又把调试环境完整打印到日志。

命令替换把标准输出捕获成字符串:

bash
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)) 不会把算术真假误当成未处理故障。

6
在启用 set -e 的脚本中,为什么 ((counter -= 1)) 可能在计数变成 0 时终止脚本?

把位置参数设计成稳定接口

$#、$0、位置参数与 shift 描述调用现场

脚本收到的参数不是一段文本,而是一组有边界的字符串:

bash
printf '程序名:%s\n' "$0"
printf '参数数量:%d\n' "$#"
printf '第一个参数:%s\n' "${1-}"
printf '第十个参数:%s\n' "${10-}"

$0 是调用时使用的名称,不保证是规范化绝对路径。只想显示命令名时可以用 ${0##*/}。$# 是参数个数;${10} 必须加花括号,否则 $10 会被解析成 $1 后接字符 0。

shift 删除 $1,其余参数依次前移,适合手工解析简单子命令:

bash
while (($# > 0)); do
  case $1 in
    --) shift; break ;;
    --verbose) verbose=1; shift ;;
    *) break ;;
  esac
done

每次 shift 前都要保证参数仍存在。复杂短选项、组合选项和选项参数更适合 getopts,不要让一个不断扩大的 case 变成第二套解析器。

7
要访问脚本的第十个位置参数,哪种写法正确?

"$@" 保留边界,"$*" 合并参数

把参数转交给另一个命令时,默认写法是:

bash
run_task "$@"

在双引号内,"$@" 展开为多个独立参数,每个原参数保持原边界,空参数也不会丢。"$*" 则展开为一个参数,成员由 IFS 的第一个字符连接。未引用的 $@ 和 $* 还会再次字段分割和路径名展开。

隔离实测把三个参数 alpha beta、* 和空字符串转交给参数观察器:

text
"$@"  → 3 个参数:<alpha beta> <*> <>
"$*"  → 1 个参数:<alpha beta * >
未引用 $@ → 空参数丢失,空格拆词,glob-* 展开成多个文件名

这就是为什么“文件名平时没有空格”不是省略引号的理由。脚本接口应按允许的输入范围正确工作,而不是按一组碰巧简单的样例工作。

位置参数经过双引号、"@"、"*" 和未引用展开后的 argv 边界变化

8
包装脚本要把所有参数原样传给内部命令,应优先使用哪种展开?

默认值、必填检查与 getopts 让失败尽早发生

参数展开可以明确未设置和空值策略:

bash
mode=${MODE:-safe}
config=${CONFIG_PATH:?必须设置配置路径}
optional=${OPTIONAL-}

冒号版本把“未设置或为空”都视为缺失;不带冒号只区分是否设置。业务接口应明确空字符串是否有效,而不是机械套用一种形式。

短选项可用 Shell 内建的 getopts:

bash
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' "$OPTARG" >&2; exit 64 ;;
  esac
done
shift "$((OPTIND - 1))"

开头的冒号启用静默错误模式,让脚本统一生成诊断。循环结束后按 OPTIND 移走已解析选项,"$@" 就只剩位置参数。若同一 Shell 上下文多次调用解析函数,要自行重置 OPTIND=1。getopts 主要处理短选项;需要长选项时,可以写边界清楚的 case,或选用成熟解析器,而不是用 eval 拼接命令。

9
getopts 解析完成后,哪些动作通常必要?

引用决定一个字符串会变成几个参数

单引号、双引号和不引用不是三种装饰

Shell 在执行命令前会完成参数展开、命令替换、算术展开、字段分割和路径名展开。引用决定哪些步骤可以发生。

  • 单引号中的字符按字面保留,变量和命令替换都不执行。单引号内部不能用反斜杠转义单引号,需要结束引用、插入转义字符再重新开始。
  • 双引号允许变量、命令替换和部分反斜杠处理,但抑制字段分割和路径名展开。
  • 未引用的展开结果可能先按 IFS 分成多个词,再把 *、?、[...] 当作 glob。
bash
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 一个数据参数。

10
希望 grep 收到字面正则 r.*t,而不是让 Shell 展开当前目录文件,最合适的写法是什么?

需要动态命令时,用数组保存参数,不要保存命令字符串

下面的写法看似方便,实际上无法可靠表达参数边界:

bash
options='--output "report file.txt"'
tool $options

变量中的引号不会在展开后重新获得语法作用,它们只会变成普通字符。继续用 eval "tool $options" 会把数据再次当代码解析,输入一旦可控就可能执行意外命令。

Bash 可以用数组保存 argv:

bash
args=(--output 'report file.txt')
((verbose)) && args+=(--verbose)
tool "${args[@]}" "$input"

POSIX sh 没有数组,可以用函数和 set -- 逐个构造位置参数:

sh
set -- --output "$output"
[ "$verbose" = 1 ] && set -- "$@" --verbose
tool "$@" "$input"

核心原则是始终保存“参数列表”,不要保存“希望以后重新解析的一行命令”。如果动态参数来源是配置文件,也要先定义配置语法,而不是把整行配置直接交给 Shell。

11
Bash 脚本需要动态追加选项且保留带空格参数,哪种数据结构最合适?

命令替换要立即引用,也要理解它不是二进制容器

常见安全写法是:

bash
kernel=$(uname -r)
printf 'kernel=%s\n' "$kernel"

赋值右侧不会做普通字段分割,但变量以后参与命令参数时仍应引用。命令替换会删除末尾换行;内部失败的状态还可能被外层命令覆盖。例如:

bash
value=$(produce_value)
status=$?

这里赋值命令的状态通常来自 produce_value,必须立刻保存。若写成 local value=$(produce_value),某些 Shell 中 local 自身的成功状态会遮住替换失败;更清楚的写法是先 local value,再单独赋值并检查。

需要逐行读列表时,用循环输入而不是 for item in $(command):

bash
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 会把文件名当文本切开,不能用于不受约束的路径。

12
只要给命令替换外面加双引号,就可以无损保存任意二进制和所有文件名。

用退出状态组织控制流

if、&& 和 || 判断的是命令状态

Unix 风格命令通常用 0 表示成功,用非零表示其他结果。Shell 的 if 不要求布尔表达式,它直接执行条件位置的命令:

bash
if grep -q -- "$pattern" "$file"; then
  printf '找到匹配\n'
else
  status=$?
  case $status in
    1) printf '没有匹配\n' ;;
    *) printf '读取或正则错误\n' >&2; exit "$status" ;;
  esac
fi

grep 的 1 通常表示没有匹配,不等于程序故障;2 才表示错误。任何依赖状态的脚本都应查看目标命令的退出约定,不要把所有非零都翻译成同一句“执行失败”。

command1 && command2 只在左侧成功时运行右侧;command1 || fallback 只在左侧非零时运行右侧。短路列表适合短小动作,涉及日志、恢复和多种状态时,完整 if 更容易审查。需要保存 $? 时必须紧跟目标命令,因为任何后续命令都会覆盖它。

13
grep 返回 1 时,最准确的通用解释是什么?

[ ... ]、[[ ... ]] 和 (( ... )) 解决不同问题

单中括号是 test 的另一种写法,参数之间必须有空格,展开值通常要引用:

bash
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,支持模式与 =~ 正则:

bash
if [[ $name == report-*.txt ]]; then
  :
fi

右侧模式是否引用会改变含义:引用后通常按字面字符串比较。算术条件用 ((count >= 10))。选定 Bash 后可以使用这些能力;承诺 /bin/sh 可移植性时,就回到 POSIX 的 [ ]、case 和算术展开。

14
下列哪些判断方式与数据类型匹配?

case 把字符串分派写成一张可读的表

多分支字符串匹配用 case 往往比长串 elif 清楚:

bash
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、复杂日期或嵌套配置应交给了解其语法的工具。

15
case 中的星号模式应该通常放在哪里?

循环和函数要守住输入边界

for 遍历参数或已知集合,不解析 ls

遍历调用者传入的文件:

bash
for file in "$@"; do
  process_file "$file"
done

遍历 glob 时,模式本身不能整体引用:

bash
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"。这个状态是接口的一部分,要写进用法和测试。

16
遍历脚本收到的所有文件参数并保留文件名边界,首选哪种循环?

while 与 until 适合状态驱动,但要防止子 Shell 丢变量

按行读取文件:

bash
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 在命令成功前重复,适合带上限的就绪探测:

bash
attempt=0
until service_ready; do
  attempt=$((attempt + 1))
  ((attempt < 5)) || exit 75
  sleep 1
done

所有重试都应有次数或截止时间,并区分“尚未就绪”和“永久错误”。无限重试会把真实故障变成长期占用资源的静默任务。

17
在 Bash 中通过 producer | while read ... 修改 count,循环结束后外层一定能看到新值。

函数的标准输出是数据,return 是状态

函数可把重复动作和边界检查封装起来:

bash
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"
fi

return 传的是 0–255 范围内的退出状态,不是任意字符串或大整数。要返回数据,用标准输出并让调用者捕获;诊断必须写标准错误,否则会污染捕获结果。函数最后一条命令的状态会成为函数状态,关键分支最好显式 return 0 或 return 1,避免后来新增调试 printf 意外把失败改成成功。

Bash 的 local 不是 POSIX sh 必备能力,而且有动态作用域特征:被调用函数可能看到调用者的局部变量。公共函数应通过参数传值,少依赖这种隐式共享。

18
函数既要产生字符串结果又要报告成功或失败,推荐怎样分工?

管道和失败策略不能靠一句 strict mode

管道默认看最后一段,pipefail 才暴露上游失败

对于 produce | transform | save,Bash 默认用最后一个命令的状态作为整条管道状态。若 produce 失败而 save 正常读到空输入并退出 0,管道表面仍可能成功。启用:

bash
set -o pipefail

后,管道状态会变成最右侧非零命令的状态;全部成功才为 0。隔离实测中的 false | true 默认状态为 0,打开 pipefail 后为 1,PIPESTATUS 立即读到两个分量 1 0。

PIPESTATUS 是 Bash 数组,任何后续命令都会刷新它,必须紧跟管道保存。pipefail 也不说明哪一段的标准错误应该如何处理,更不替代输出验收。像 grep pattern file | head -n 1 还可能因下游提前关闭触发 SIGPIPE;是否视为故障要结合业务意图。

19
未启用 pipefail 时,false | true 的整条管道状态通常是什么?

set -e 有条件上下文例外,不是自动异常系统

set -e 或 set -o errexit 的规则取决于语法位置。Bash 不会因为以下位置的非零状态立即退出:

  • if、while、until 用来判断的命令;
  • && 或 || 列表中除最后一项外的命令;
  • 管道中除最后一段外的命令,是否暴露还受 pipefail 影响;
  • 被 ! 反转的命令。

实测结果很直观:

text
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 显式处理;对关键结果,再验证输出文件、行数、哈希或业务条件。

退出状态经过 if、短路列表、管道、pipefail 和 set -e 条件上下文的决策图

20
启用 set -e 后,哪些位置的非零状态通常不会立刻退出?

set -u 能发现拼写,也需要为可选值留出口

set -u 在展开未设置变量时退出,能捕获许多变量名拼写错误:

bash
set -u
printf '%s\n' "$required"

可选值和位置参数要先检查或提供默认值:

bash
optional=${OPTIONAL_VALUE:-}
first=${1-}
 
if (($# == 0)); then
  usage
  exit 64
fi

空字符串和未设置不是总是等价,${name-} 与 ${name:-} 的差别应按接口决定。数组、间接引用和不同 Bash 版本在 nounset 下还有更多边界,不能只在一组完整参数上试跑。

一个务实起点是 set -u -o pipefail,再对每个关键命令显式检查;是否加 -e 由团队风格和测试覆盖决定。无论选择什么开关,脚本都要有失败用例,因为开关只改变控制流,不会验证业务结果。

21
启用 set -u 后,要安全读取可缺省的第一个参数,哪种写法合适?

临时资源和信号处理决定失败后留下什么

用 mktemp 原子创建,不要用 $$ 猜名字

这类旧写法有竞态:

bash
tmp="/tmp/report.$$"
printf '%s\n' "$data" > "$tmp"

进程号可预测,检查“文件不存在”和随后创建之间还有时间窗口,其他进程可能抢先建立文件或符号链接。应让 mktemp 完成唯一命名和创建:

bash
old_umask=$(umask)
umask 077
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/report.XXXXXX") || exit 1
umask "$old_umask"

模板末尾需要足够的 X。目录形式便于把多个中间文件放进同一个受控边界。所有路径都要引用,删除时加 --:rm -rf -- "$tmpdir"。

还要先保证变量非空、确实由脚本创建且位于允许根目录;不要把未经检查的用户输入交给递归删除。umask 077 让新文件默认只对所有者开放。实测创建的秘密文件权限为 600,运行结束后整个练习目录不存在。

22
为什么 /tmp/tool.$$ 不能替代 mktemp?

trap 要延迟求值、幂等,并覆盖正常与信号路径

一个可复用的清理骨架:

bash
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
trap 'on_signal 130' INT
trap 'on_signal 143' TERM

trap 'cleanup' EXIT 使用单引号,让动作在触发时解析;若在注册时就用双引号展开尚未赋值的临时路径,清理可能锁定错误值。清理函数要幂等,因为信号处理函数调用一次后,exit 还会触发 EXIT。隔离实测向工作进程发送 TERM,它以 143 退出,临时目录消失,清理动作只产生一次有效效果。

EXIT 是 Shell 的伪信号;INT 通常来自 Ctrl-C,TERM 是常见的优雅停止请求。KILL 和 STOP 不能捕获,所以磁盘临时区还要能在下次启动时识别并安全回收。脚本若启动子进程,还要考虑转发信号和等待子进程,不能只删父脚本自己的文件。

23
可靠的 cleanup 函数应满足哪些条件?

临时文件不是事务,关键输出要分阶段提交

临时目录解决唯一命名与清理,不会自动保证业务原子性。生成最终报告时,可以先在目标文件同一文件系统的临时路径写完整内容,校验后再重命名:

bash
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 "$stage"
mv -f -- "$stage" "$output"

同一文件系统内的重命名通常提供原子名称切换,读者不会看到半个文件。它仍不能同时提交数据库、远程对象和多个文件;跨资源一致性需要更高层事务或可恢复协议。覆盖现有目标前还要决定备份、权限、所有者和符号链接策略。

mktemp 创建、写入、校验、信号处理、提交和幂等清理的资源生命周期

24
使用 mktemp 后,多文件更新和数据库写入就自动成为一个事务。

标准输出、标准错误和秘密要分开管理

stdout 给调用者,stderr 给操作者

一个可组合脚本应让标准输出只包含约定数据,让日志和诊断进入标准错误:

bash
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 也能独立保存两条通道。日志至少要有时间、级别、动作和稳定的对象标识;不要为了方便把完整参数、环境或文件内容全部打印出来。

错误消息应包含脚本名和下一步。返回状态再区分用法错误、暂时不可用和内部故障。状态编号可以采用团队约定,但不要假设所有命令共享同一张表。

25
脚本输出会被另一个程序通过命令替换捕获,运行日志应写到哪里?

秘密不应出现在参数、调试跟踪和宽权限文件中

命令行参数可能被进程查看工具、审计记录或错误日志看到;环境变量也可能被子进程继承并在诊断中泄露。秘密更适合通过受限文件、匿名文件描述符或专用秘密服务传递。创建落盘秘密前设置:

bash
umask 077
secret_file=$(mktemp "${TMPDIR:-/tmp}/credential.XXXXXX") || exit 1

使用后由 trap 清理,但删除普通文件并不保证底层介质立即擦除。敏感程度更高时,应避免落盘或使用受控内存接口。

set -x 会把展开后的命令写到标准错误,调试包含令牌的命令前必须关闭。更好的做法是让敏感函数从一开始就不依赖参数回显。日志脱敏要在数据进入格式串之前完成,不能等泄露后再删日志。

26
处理秘密时,哪些做法更稳妥?

语法检查、静态分析和行为测试缺一不可

bash -n 查语法,ShellCheck 查常见语义风险

提交前先做快速门禁:

bash
bash -n script.sh
shellcheck script.sh

bash -n 读取并解析脚本但不执行,能发现未闭合引号、缺失 fi、错误括号等语法问题。它不会证明命令存在、路径正确或业务结果符合预期。

ShellCheck 会结合 shebang 分析引用、未使用变量、可移植性和常见控制流错误。隔离检查中,正确样例无诊断;反例:

bash
for argument in $@; do
  echo $argument
done

返回 1,并报告 SC2068 与 SC2086,指出参数数组和变量需要引用。静态分析是高价值审查器,不是运行时证明;确实有意的例外可以在最小范围解释并抑制,不能把整类规则全局关闭。

27
bash -n 通过后,可以得出什么结论?

测试夹具要同时断言输出、状态和副作用

一个脚本测试至少要记录输入参数、标准输入和环境;标准输出与标准错误;退出状态;创建、修改或删除的文件;以及中断、空输入、重复运行和并发等失败路径。

bash
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 拒绝,以及版本切换与回滚。这样做是为了让每条结论都有可复现输出,而不是只验证一条顺利路径。

从语法解析、静态分析到输出状态、副作用、信号和清理的脚本测试证据塔

28
验证一个生成报告的脚本,哪些断言应同时考虑?

POSIX sh 和 Bash 要做明确选择

可移植脚本的核心不是把 shebang 改成 /bin/sh,而是从语法、工具选项到运行环境都遵守目标集合。POSIX 风格可用:

sh
#!/bin/sh
value=${1:-default}
if [ -n "$value" ]; then
  printf '%s\n' "$value"
fi

Bash 版本可以合理使用数组、[[ ]]、进程替换、mapfile、PIPESTATUS 和 local,前提是明确依赖。隔离环境中的 /bin/sh 指向 dash,执行 Bash 数组脚本立即以状态 2 报语法错误,而 POSIX 样例正常输出。

测试可移植性时,应在实际支持矩阵中的多个 Shell 上运行,例如 dash、Bash POSIX 模式和目标系统的 sh,再让 ShellCheck 按 shebang 检查。外部工具同样有方言。GNU sed、find、date 和 mv 的某些选项在其他系统上不同。若脚本强依赖 GNU 行为,应明确声明并检测版本;若追求广泛可移植,就使用标准能力或提供兼容分支。

29
把使用 Bash 数组的脚本 shebang 改成 /bin/sh,是否就完成了可移植化?

部署脚本时也要准备回滚

安装位置、最小环境和依赖版本都要可核对

个人工具可以安装到用户控制的 ~/bin;面向系统用户的本地工具常放 /usr/local/bin,管理命令可放 /usr/local/sbin。安装动作需要相应授权,但运行脚本不应因为方便就长期使用高权限。脚本本身通常由管理员可写、使用者只读执行,避免可写脚本被高权限任务调用。

交付清单至少包括:

  • 脚本版本与哈希;
  • 解释器路径和最低版本;
  • 外部命令、配置路径与最小 PATH;
  • 输入输出、退出状态和日志位置;
  • 文件权限、运行用户与资源限制;
  • 冒烟测试、升级步骤和回滚入口。

定时任务和服务应以干净环境验收:

bash
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 代码。只能加载来源、所有者和权限已经核对的文件。若配置只需要键值数据,更稳妥的做法是定义受限格式并用专门解析逻辑读取,避免把配置权限等同于代码执行权限。

30
脚本在交互终端能运行,部署为定时任务前还应核对哪些条件?

版本目录加原子切换,让代码回滚有明确动作

不要直接覆盖唯一脚本后再期待“出问题时找旧文件”。一种简单布局是:

text
/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 脚本无限增长。

31
把 current 从新版本切回旧版本,能自动回滚哪些内容?

综合实操:完成一个可测试的批量统计工具

先写清契约,再组合参数、状态、日志和清理

目标工具接收一个模式和多个文本文件,输出每个文件的匹配行数。它使用 Bash,支持 -v 和 -o 文件,错误写 stderr,部分文件失败时继续处理,最终返回 1;用法错误返回 64。

批量统计脚本的参数解析、文件校验、grep 状态分流、报告输出和清理总览

bash
#!/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() {
  ((verbose)) || [[ $1 == ERROR ]] || return 0
  local level=$1
  shift
  printf '%s: %s: %s\n' "$PROGRAM" "$level" "$*" >&2
}
 
cleanup() {
  [[ -n $tmpdir ]] && rm -rf -- "$tmpdir"
  tmpdir=''
}
 
on_signal() {
  local status=$1
  cleanup
  exit "$status"
}
 
trap cleanup EXIT
trap 'on_signal 130' INT
trap 'on_signal 143' TERM
 
while getopts ':vo:' option; do
  case $option in
    v) verbose=1 ;;
    o) output=$OPTARG ;;
    :) printf '%s: -%s 缺少参数\n' "$PROGRAM" "$OPTARG" >&2; exit 64 ;;
    \?) printf '%s: 未知选项 -%s\n' "$PROGRAM" "$OPTARG" >&2; exit 64 ;;
  esac
done
shift "$((OPTIND - 1))"
 
if (($# < 2)); then
  usage
  exit 64
fi
 
pattern=$1
shift
 
old_umask=$(umask)
umask 077
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/${PROGRAM}.XXXXXX") || {
  printf '%s: 无法创建临时目录\n' "$PROGRAM" >&2
  exit 1
}
umask "$old_umask"
 
report=$tmpdir/report.tsv
printf '文件\t匹配行数\n' > "$report"
 
for file in "$@"; do
  if [[ ! -f $file || ! -r $file ]]; then
    log ERROR "不可读的普通文件:$file"
    failed=1
    continue
  fi
 
  if count=$(grep -c -- "$pattern" "$file"); then
    :
  else
    status=$?
    if ((status == 1)); then
      count=0
    else
      log ERROR "grep 无法处理:$file"
      failed=1
      continue
    fi
  fi
 
  printf '%s\t%s\n' "$file" "$count" >> "$report"
done
 
if [[ $output == '-' ]]; then
  cat -- "$report"
else
  install -m 600 -- "$report" "$output" || {
    log ERROR "无法写入输出:$output"
    exit 1
  }
  log INFO "报告已写入:$output"
fi
 
exit "$failed"

这里没有启用 set -e,每个预期非零都按协议处理。grep 的 1 被转换为计数 0,真正错误才记失败;"$@" 保留文件名边界;临时目录权限受限并由正常、INT、TERM 路径清理。install 适合当前 GNU/Linux 目标,若支持其他系统,要把它列进依赖或改成可移植的复制与权限步骤。

32
综合脚本为什么不能把 grep 返回 1 直接当作整批失败?

用成功、边界、失败、信号和回滚五组测试收尾

为工具建立独立夹具:

text
fixtures/
├── two-matches.txt
├── zero-match.txt
├── name with spaces.txt
└── missing.txt

测试顺序可以这样安排:

  1. bash -n 与 ShellCheck 必须通过,因为它们能在运行前拦截语法、引用和方言问题。
  2. 成功路径传入两个文件,断言表头、计数、状态 0 和空 stderr,因为“能产生文件”不等于内容正确。
  3. 文件名含空格与零匹配,断言仍只有一个 argv 元素且计数为 0,因为这两处最能暴露引用和 grep 状态误判。
  4. 混入缺失文件,断言其他文件仍输出、stderr 有对象名、最终状态为 1,因为脚本选择的是“继续并汇总失败”策略。
  5. -o 写文件后核对权限 600;发送 TERM 后等待状态 143,并确认临时目录消失,因为正常返回无法覆盖中断路径。
  6. 把脚本放进版本目录,冒烟测试后切换入口,再切回旧版本,因为部署动作本身也应可验证。

完成这些测试后,再删除夹具、临时目录和容器。清理不是一句“测试完毕”的口号,而是最后一条断言:运行目录不存在、临时容器已自动移除、没有后台进程继续占用资源。

这也是判断 Shell 是否仍是合适工具的时刻。若接口已经需要嵌套结构、复杂并发、长期状态和大量字符串运算,迁移不是失败,而是把任务交给更匹配的数据模型和测试生态。Shell 最擅长的仍是把现有命令、文件和流连接成短小、透明、可审查的自动化。

33
一套完整的脚本验收为什么要包含信号和回滚测试?
34
脚本直接执行失败而 bash script.sh 成功,排查顺序应优先覆盖哪些环节?
35
要输出一段包含用户数据的固定格式文本,最稳妥的基本写法是什么?
36
getopts 完成 -v 和 -o 参数解析后,怎样得到尚未解析的位置参数?
37
把字面引号存进变量后再普通展开,Shell 会把这些引号重新当作语法并恢复参数边界。
38
一个文件条件同时要求路径是普通文件、可读且非空,哪些测试可以组合使用?
39
为什么建议把 while read 的文件重定向写在 done 后,而不是总用 producer | while read?
40
false | true 在启用 set -e 但未启用 pipefail 时仍继续,最直接的原因是什么?
41
临时目录清理要同时抵御竞态和误删,哪些设计应组合使用?
42
只要错误日志写入 stderr,就可以安全记录所有参数、环境变量和秘密。
43
ShellCheck 和 bash -n 都通过后,下一项最有价值的验证是什么?
44
版本入口已经成功回滚到旧脚本,还应单独核对什么?
45
综合工具选择继续处理其余文件并最终汇总失败,测试应验证哪些结果?
  • 先看清脚本怎样被执行
    • shebang 选择解释器,`exec` 完成进程替换
    • 执行权限、读取权限与挂载选项共同决定能否启动
    • `PATH` 搜索命令,当前目录必须明确表达
  • 把第一份脚本写成可维护的程序
    • 结构、注释和缩进要服务于阅读路径
    • 用 `printf` 输出,用 `read -r` 接收文本
    • 变量、命令替换和算术各有数据边界
  • 把位置参数设计成稳定接口
    • `$#`、`$0`、位置参数与 `shift` 描述调用现场
    • `"$@"` 保留边界,`"$*"` 合并参数
    • 默认值、必填检查与 `getopts` 让失败尽早发生
  • 引用决定一个字符串会变成几个参数
    • 单引号、双引号和不引用不是三种装饰
    • 需要动态命令时,用数组保存参数,不要保存命令字符串
    • 命令替换要立即引用,也要理解它不是二进制容器
  • 用退出状态组织控制流
    • `if`、`&&` 和 `||` 判断的是命令状态
    • `[ ... ]`、`[[ ... ]]` 和 `(( ... ))` 解决不同问题
    • `case` 把字符串分派写成一张可读的表
  • 循环和函数要守住输入边界
    • `for` 遍历参数或已知集合,不解析 `ls`
    • `while` 与 `until` 适合状态驱动,但要防止子 Shell 丢变量
    • 函数的标准输出是数据,`return` 是状态
  • 管道和失败策略不能靠一句 strict mode
    • 管道默认看最后一段,`pipefail` 才暴露上游失败
    • `set -e` 有条件上下文例外,不是自动异常系统
    • `set -u` 能发现拼写,也需要为可选值留出口
  • 临时资源和信号处理决定失败后留下什么
    • 用 `mktemp` 原子创建,不要用 `$$` 猜名字
    • `trap` 要延迟求值、幂等,并覆盖正常与信号路径
    • 临时文件不是事务,关键输出要分阶段提交
  • 标准输出、标准错误和秘密要分开管理
    • stdout 给调用者,stderr 给操作者
    • 秘密不应出现在参数、调试跟踪和宽权限文件中
  • 语法检查、静态分析和行为测试缺一不可
    • `bash -n` 查语法,ShellCheck 查常见语义风险
    • 测试夹具要同时断言输出、状态和副作用
    • POSIX `sh` 和 Bash 要做明确选择
  • 部署脚本时也要准备回滚
    • 安装位置、最小环境和依赖版本都要可核对
    • 版本目录加原子切换,让代码回滚有明确动作
  • 综合实操:完成一个可测试的批量统计工具
    • 先写清契约,再组合参数、状态、日志和清理
    • 用成功、边界、失败、信号和回滚五组测试收尾

目录

  • 先看清脚本怎样被执行
    • shebang 选择解释器,`exec` 完成进程替换
    • 执行权限、读取权限与挂载选项共同决定能否启动
    • `PATH` 搜索命令,当前目录必须明确表达
  • 把第一份脚本写成可维护的程序
    • 结构、注释和缩进要服务于阅读路径
    • 用 `printf` 输出,用 `read -r` 接收文本
    • 变量、命令替换和算术各有数据边界
  • 把位置参数设计成稳定接口
    • `$#`、`$0`、位置参数与 `shift` 描述调用现场
    • `"$@"` 保留边界,`"$*"` 合并参数
    • 默认值、必填检查与 `getopts` 让失败尽早发生
  • 引用决定一个字符串会变成几个参数
    • 单引号、双引号和不引用不是三种装饰
    • 需要动态命令时,用数组保存参数,不要保存命令字符串
    • 命令替换要立即引用,也要理解它不是二进制容器
  • 用退出状态组织控制流
    • `if`、`&&` 和 `||` 判断的是命令状态
    • `[ ... ]`、`[[ ... ]]` 和 `(( ... ))` 解决不同问题
    • `case` 把字符串分派写成一张可读的表
  • 循环和函数要守住输入边界
    • `for` 遍历参数或已知集合,不解析 `ls`
    • `while` 与 `until` 适合状态驱动,但要防止子 Shell 丢变量
    • 函数的标准输出是数据,`return` 是状态
  • 管道和失败策略不能靠一句 strict mode
    • 管道默认看最后一段,`pipefail` 才暴露上游失败
    • `set -e` 有条件上下文例外,不是自动异常系统
    • `set -u` 能发现拼写,也需要为可选值留出口
  • 临时资源和信号处理决定失败后留下什么
    • 用 `mktemp` 原子创建,不要用 `$$` 猜名字
    • `trap` 要延迟求值、幂等,并覆盖正常与信号路径
    • 临时文件不是事务,关键输出要分阶段提交
  • 标准输出、标准错误和秘密要分开管理
    • stdout 给调用者,stderr 给操作者
    • 秘密不应出现在参数、调试跟踪和宽权限文件中
  • 语法检查、静态分析和行为测试缺一不可
    • `bash -n` 查语法,ShellCheck 查常见语义风险
    • 测试夹具要同时断言输出、状态和副作用
    • POSIX `sh` 和 Bash 要做明确选择
  • 部署脚本时也要准备回滚
    • 安装位置、最小环境和依赖版本都要可核对
    • 版本目录加原子切换,让代码回滚有明确动作
  • 综合实操:完成一个可测试的批量统计工具
    • 先写清契约,再组合参数、状态、日志和清理
    • 用成功、边界、失败、信号和回滚五组测试收尾