↓ 跳过正文

ShellCheck:在执行之前发现 Shell 脚本缺陷

·3902 字·8 分钟·
目录
modern-cli - 这篇文章属于一个选集。
§ 16: 本文

阅读信息

  • 内容类型:教程
  • 难度:入门
  • 适合谁:希望在 Linux、WSL2 与远程服务器上提升命令行效率的开发者。
  • 读完后:运行并解释 ShellCheck 诊断,修复一个具有真实缺陷的小脚本,并把检查纳入项目反馈入口。
  • 依据版本:ShellCheck 0.11.0
  • 验证日期:2026-09-11

bash -n script.sh 只能确认语法树是否合法,无法发现变量展开后的二次分词、空变量导致的命令截断或非 POSIX 语法调用。ShellCheck 在脚本执行前进行静态语义分析,输出带有稳定规则编号(SC####)的诊断:

代码位置 → 规则编号 (SC####) → 风险机理解释 → 针对性修复 → 静态与行为双重验证

静态分析能够拦截大部分高频语义缺陷,但无法获知运行时的外部环境状态、文件系统权限与网络响应。本文以 ShellCheck 0.11.0 为规范与验证基准。

按正确 Shell 方言运行 ShellCheck,从 SC2068 理解参数边界、完成最小修复,并用语法、静态和行为检查验收
图:把 SC 规则当作风险解释入口,做最小修复后用真实边界输入和三层质量检查验证行为。

1. 为什么需要静态语义分析
#

编写 Shell 脚本时,最常见的误区是把“语法能解析”当成“逻辑安全”。考虑以下常见操作:

rm -rf $TARGET_DIR/*

如果 TARGET_DIR 变量因拼写错误或环境变量缺失导致为空,在没有开启 set -u 的情况下,展开结果会退化为 rm -rf /*。bash -n 检查此脚本会直接返回 0,因为在语法层面这完全符合 Bash 语法规范;但 ShellCheck 会立刻报出 SC2115,警告未引用的变量可能展开为空并导致破坏性删除。

ShellCheck 的核心价值在于:

  1. 语义陷阱拦截:识别单词拆分(Word Splitting)、文件名通配展开(Globbing)、未初始化的变量和死循环。
  2. 可移植性保障:检查 #!/bin/sh 声明下混入的 Bash 独有语法(如 [[ ... ]] 或数组)。
  3. 标准化查证入口:每个诊断都附带官方 Wiki 编号与风险成因,便于团队建立明确的代码审查标准。

静态分析并不执行脚本,因此它不会验证外部命令是否存在、命令参数在特定发行版中是否有效,更无法探测网络和磁盘竞争。静态检查是第一道防御,而非替代运行时测试。

2. 安装与环境确认
#

通过系统包管理器、官方 Release 二进制或预编译包安装后,首先核对版本信息:

shellcheck --version

实测基准输出:

ShellCheck - shell script analysis tool
version: 0.11.0
license: GNU General Public License, version 3

直接对目标文件运行检查:

shellcheck script.sh

若需要检查动态生成的内容或在管道中过滤,可以通过标准输入读取,并用 --shell 显式指定 Shell 方言:

printf '%s\n' 'echo $HOME' | shellcheck --shell=bash -

当分析文件时,ShellCheck 默认依据文件首行的 shebang(如 #!/usr/bin/env bash)自动判断方言;从 stdin 读取或处理无 shebang 的公共函数库时,必须显式传递 --shell。

3. 端到端 Red / Green:修复参数引用缺陷
#

本节使用一个真实的循环遍历脚本演示 Red/Green 修复流程。可直接在本地创建测试脚本,也可进入随附的 fixture 目录复现:

cd research/modern-cli/fixture

Red:语法通过但存在参数拆分缺陷
#

查看待检查的脚本 src/check.sh:

#!/usr/bin/env bash

for file in $@; do
  echo "checking $file"
done

首先执行语法检查:

bash -n src/check.sh
printf 'exit=%s\n' "$?"

输出:

exit=0

语法完全合法。接下来运行 ShellCheck 0.11.0 进行静态分析:

shellcheck src/check.sh

诊断输出:

In src/check.sh line 3:
for file in $@; do
            ^-- SC2068 (error): Double quote array expansions to avoid re-splitting elements.

进程退出码为 1。

缺陷分析:未加双引号的 $@ 会让传入的位置参数再次经历单词拆分与通配符展开。如果传入包含空格的单项参数(例如 "report final.txt")或包含通配符的参数(如 '*.log'),该参数就会被拆解为两个循环项或在当前目录被展开,破坏调用方传递的数据边界。

Green:最小修复与行为验收
#

修复方案需要处理两处细节:

  1. 将 $@ 加上双引号变为 "$@",保持参数边界完整;
  2. 将 echo "checking $file" 改为 printf 'checking %s\n' "$file",避免当文件名形如 -n 或 -e 时被 echo 误解析为命令选项。

修改后的标准实现(对应 expected/check-quoted.sh):

#!/usr/bin/env bash

for file in "$@"; do
  printf 'checking %s\n' "$file"
done

再次执行语法与静态检查:

bash -n expected/check-quoted.sh
shellcheck expected/check-quoted.sh
printf 'exit=%s\n' "$?"

输出:

exit=0

静态诊断完全清零。随后进行真实输入测试,验证边界行为:

bash expected/check-quoted.sh 'report final.txt' '*.log'

预期输出恰好为两行:

checking report final.txt
checking *.log

传入的空格未被拆分,通配符也没有被错误展开。这一步验证了静态检查给出诊断、人工明确语义、最终由运行时行为测试完成闭环的过程。

4. 把 SC 编号当作可查证解释
#

ShellCheck 的诊断信息不是模糊的警告,而是指向官方 Wiki 的规范索引(如 SC2068)。

遇到未处理过的规则时,应按如下步骤排查:

  1. 核对严重级别:诊断包含 error、warning、info、style 四个级别。
  2. 查阅对应规则 Wiki:打开 https://www.shellcheck.net/wiki/SCxxxx,理解该规则出现的根本成因与经典错误场景。
  3. 辨析规则边界:
    • SC2068 专指数组与位置参数的展开缺陷:${array[@]} 或 $@ 必须加引号;
    • SC2086 针对普通标量变量展开:$var 缺少引号导致单词拆分与通配符展开;
    • 两者虽然都涉及双引号,但破坏场景与修复手段有别。
  4. 实施最小修改:仅修复引起语义风险的语句,不引入无关改动。
  5. 重跑检查与测试:确认修复未引入新的语法错误与行为变化。

禁止为了“让输出变干净”而无理由批量禁用规则。规则报出代表代码可能依赖了隐式行为,必须人工确认意图。

5. 方言决定哪些语法合法
#

Shell 存在多种演进标准,不同解释器对同一语法的支持天差地别:

shellcheck --shell=sh script.sh
shellcheck --shell=bash script.sh
shellcheck --shell=dash script.sh
声明方言常见解释器特性支持与限制
shPOSIX 标准 Shell不支持 [[ ... ]]、进程替换 <(...)、关联数组与高级参数截取
bashGNU Bash全面支持数组、正则匹配 [[ =~ ]]、进程替换与高级内置命令
dashDebian/Ubuntu 系统默认 /bin/sh紧凑型 POSIX 实现,启动极快,不支持 Bash 独有扩展
kshKornShell支持独立数组语法与特定函数定义风格

如果脚本声明了 #!/bin/sh,却在内部使用了 [[ $a == $b ]],在 Ubuntu/Debian 上执行时(/bin/sh 指向 dash)会直接抛出语法错误:

In script.sh line 3:
if [[ $a == $b ]]; then
   ^-- SC3010 (warning): In POSIX sh, [[ ]] is undefined.

此时不要使用 --shell=bash 绕过检查,而应根据部署环境做出明确选择:

  • 若运行依赖 Bash,将 shebang 修正为 #!/usr/bin/env bash;
  • 若必须保持 POSIX 兼容,将条件判断改写为 [ "$a" = "$b" ]。

检查方言必须严格对应部署时实际调用的解释器。

6. 跨文件依赖:source 路径与 SC1091
#

当脚本包含外部引入文件时:

source "${SCRIPT_DIR}/lib.sh"

ShellCheck 是静态分析工具,无法在未执行状态下求出 ${SCRIPT_DIR} 变量的运行时取值,默认会报出 SC1091:

In script.sh line 3:
source "${SCRIPT_DIR}/lib.sh"
^-- SC1091 (info): Not following: ./lib.sh was not specified as input (see -x to write follow).

默认情况下,出于安全和确定性考量,ShellCheck 不会主动遍历文件系统跟随 source。处理跨文件依赖的标准方法如下:

  1. 开启外部源跟踪:使用 -x(--external-sources)参数,授权 ShellCheck 跟随外部文件;
  2. 指定查找根目录:使用 -P(--source-path)参数,SCRIPTDIR 为保留关键字,指代当前被检查脚本所在的目录:
shellcheck -x -P SCRIPTDIR script.sh
  1. 提供静态定位指令:如果代码中路径包含复杂动态变量,可在 source 语句上方添加指令引导静态分析器:
# shellcheck source=./lib.sh
source "${SCRIPT_DIR}/lib.sh"

注意:-x 允许读取外部文件,在自动化审查中只应指向仓库受信路径,避免检查不可信目录下的未知脚本。

7. 规则抑制的工程规范
#

在极少数场景下,代码确实需要依赖 Shell 的单词拆分特性(例如将一个由受控空格分隔的命令参数字符串传递给可执行文件)。此时应遵循最小范围抑制规范:

# 此处变量内容已在上方白名单函数中经过严格校验,有意利用单词拆分展开为独立选项。
# shellcheck disable=SC2086
command $safe_options

工程抑制原则:

  1. 行级优先:在紧随生效语句的上一行进行抑制,绝不轻易在文件头部全量忽略;
  2. 必须附带审查理由:抑制指令前必须留下注释,陈述该场景为何安全、为何无法改用数组;
  3. 项目配置统一收敛:若团队统一对某个次要规则达成免检共识(例如在纯内部运维场景关闭关于 shebang 目录位置的提示),应在项目根目录维护 .shellcheckrc:
# .shellcheckrc
# 统一指定默认方言与基础级别
shell=bash
severity=warning
disable=SC1091

严禁在持续集成流水线中使用 shellcheck ... || true,该做法会彻底摧毁静态门禁的防线。

8. 集成参数、退出码与 CI 策略
#

在 CI/CD 流水线中集成 ShellCheck 时,必须清晰理解其退出码:

退出码含义流水线建议操作
0检查通过,无超过指定门槛的问题允许合并
1发现达到或超过 --severity 门限的诊断项阻断流程,打印具体报错
2命令行参数或选项错误配置失败,修复 CI 脚本参数
3无法处理输入文件(如文件不可读或 shebang 解析异常)环境排错
4工具内部异常收集环境并排查

常用集成命令组合:

# 只拦截 warning 及更高级别的严重缺陷(适合存量代码渐进接入)
shellcheck --severity=warning src/*.sh

# 输出供 IDE 或编译器日志解析器消费的格式
shellcheck --format=gcc src/*.sh

# 输出标准 JSON 格式供质量平台分析统计
shellcheck --format=json1 src/*.sh

固定 ShellCheck 版本后再解析机器格式输出,避免由于版本升级造成 CI 解析器不兼容。

9. 分析边界与质量闭环
#

静态分析无法替代运行测试。明确 ShellCheck 的盲区:

  • 外部依赖:无法验证 curl、jq、awk 等外部工具是否已安装,也无法验证特定工具参数是否与目标系统兼容;
  • 动态构造:无法深入推导 eval "$cmd_string" 中的内容;
  • 系统运行时状态:无法预测磁盘空间耗尽、权限不足、管道读端提前关闭导致的 SIGPIPE;
  • 子 Shell 变量丢失:在 cat file | while read line; do count=$((count+1)); done 中,循环在子 Shell 执行完毕后外部 $count 依然为 0。

完整的质量把控应由多层防御共同构成:

# 1. 语法正确性检查
bash -n src/*.sh

# 2. 静态语义与缺陷排查
shellcheck src/*.sh

# 3. 自动化行为测试(在临时目录或容器中)
bash tests/run.sh

配合开发阶段的 watchexec 与代码格式化工具 shfmt,可构建极速反馈循环:

watchexec -e sh -w src -- \
  bash -c 'shellcheck src/*.sh && shfmt -d -i 2 src/*.sh'

10. 可执行练习与验收
#

为检验掌握程度,请在空目录中完成以下实战练习:

  1. 构造实验脚本:创建 test_task.sh,内容如下:
    #!/bin/sh
    items="one two three"
    for x in $items; do
      echo "Item: $x"
    done
  2. 分析诊断结果:执行 shellcheck test_task.sh。观察 ShellCheck 输出的 SC2044 / SC2086 类提示,理解为何未引用的变量在 POSIX sh 下存在拆分风险。
  3. 修复与验证:
    • 将 shebang 更改为 #!/usr/bin/env bash;
    • 改写为数组结构 items=("one" "two" "three");
    • 循环改为 for x in "${items[@]}"; do 并使用 printf 打印;
    • 重新运行 shellcheck test_task.sh,确认退出码为 0。
  4. CI 门禁模拟:编写一行指令,同时执行 bash -n test_task.sh 和 shellcheck --severity=warning test_task.sh,确保非零退出能够正确阻断后序操作。

参考资料
#


本文采用 CC BY-NC-SA 4.0 许可。

modern-cli - 这篇文章属于一个选集。
§ 16: 本文

相关文章