Linux 基础体系 · 第 43/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS
Bash 脚本中大量“看起来像字符串”的内容,实际上会经历一套语法处理流程。变量可能被替换,命令输出可能被拼接,空白可能触发分词,通配符可能变成多个文件名;而引号会改变其中若干步骤是否发生。
许多危险并不是命令本身造成的,而是数据在展开过程中改变了形态。例如:
file='report 2025.txt'
rm $file
如果当前目录中存在 report 和 2025.txt,实际执行的可能是:
rm report 2025.txt
正确写法通常是:
rm -- "$file"
这里的 "$file" 同时阻止了空白分词和通配符展开,-- 则防止以 - 开头的文件名被解释为选项。
一、先建立整体模型:Bash 如何把源码变成参数
以一条简单命令为例:
printf '<%s>\n' $value
Bash 并不是把 $value 替换成文本后直接执行。对普通命令参数而言,典型处理顺序可以概括为:
- 大括号展开:
{a,b}、{1..3} - 波浪号展开:
~、~user - 参数展开、命令替换、算术展开:
$name${name:-default}$(command)$(( expression ))
- 单词分割:对未被引号保护的参数展开结果,依据
IFS分割 - 路径名展开,也称通配展开:
*、?、[abc] - 引号移除
- 将最终得到的一个或多个词作为命令参数执行
这是理解问题的核心模型,但它不是“所有 Bash 语法都严格走同一条流水线”。例如:
- 赋值语句右侧通常不会进行普通的单词分割和路径名展开;
[[ ... ]]内部有特殊规则,通常不会进行普通单词分割和路径名展开;case模式、重定向目标、数组赋值等位置也有上下文相关规则;- 命令替换和进程替换可能在执行阶段启动子进程,但它们与普通参数展开在语法上并不完全等价。
因此,下面的规则首先针对“命令参数位置”,再说明特殊上下文。
可以用一个实验观察未引用展开如何增加参数数量:
#!/usr/bin/env bash
value='alpha beta'
printf '参数数量: %d\n' $#
printf '参数内容:\n'
printf '<%s>\n' "$@"
如果以如下方式调用:
./demo.sh "$value"
输出是一个参数:
参数数量: 1
参数内容:
<alpha beta>
但如果写成:
./demo.sh $value
输出通常是两个参数:
参数数量: 2
参数内容:
<alpha>
<beta>
$value 展开为文本 alpha beta 后,未引用的空格触发了单词分割。
二、引用不是装饰:单引号、双引号和无引号
2.1 单引号:几乎完全按字面保留
单引号中的所有字符都按字面处理:
echo '$HOME $(date) *.log'
输出类似:
$HOME $(date) *.log
变量不会展开,命令不会执行,通配符不会生效。
单引号不能直接包含单引号。下面的写法是语法错误:
echo 'Bob's file'
如果需要在单引号字符串中放置一个单引号,常见写法是结束单引号、插入转义后的单引号、再重新开始单引号:
printf '%s\n' 'Bob'\''s file'
这实际上由三段组成:
'Bob' \' 's file'
2.2 双引号:允许部分展开,但阻止分词和通配
双引号允许参数展开、命令替换和算术展开:
name='Alice'
printf 'name=%s\n' "$name"
printf 'time=%s\n' "$(date +%H:%M)"
printf 'sum=%s\n' "$((2 + 3))"
但双引号会阻止展开结果进一步进行普通的单词分割和路径名展开。因此:
value='alpha beta *.log'
printf '<%s>\n' "$value"
输出只有一个参数:
<alpha beta *.log>
双引号并不让所有字符都失去特殊意义。以下字符在双引号中仍有特殊作用:
$:参数、命令、算术展开`:旧式命令替换\:在特定字符前仍可转义":结束双引号- 在某些 Bash 场景中,
!可能与历史展开有关;脚本通常关闭历史展开,交互式 shell 则要注意环境差异
例如:
value='a b'
printf '<%s>\n' "$value"
双引号保护的是一个参数,而不是“把内容变成某种安全格式”。如果将内容拼接到一个更大的词中:
prefix='a b'
printf '<%s>\n' "x${prefix}y"
仍然只有一个参数:
<xa by>
2.3 无引号:允许分词和通配,风险最大
value='a b'
printf '<%s>\n' $value
这里 $value 的结果会先被 IFS 分割;如果结果中还含有通配符,再进行路径名展开:
value='*.log'
printf '<%s>\n' $value
在当前目录有 app.log、error.log 时,可能得到:
<app.log>
<error.log>
如果没有匹配项,默认情况下 Bash 通常保留字面量 *.log:
<*.log>
但 nullglob、failglob 等选项可以改变这一行为,后文详述。
工程上,普通变量作为命令参数时应优先写成:
command -- "$value"
而不是:
command $value
但“永远给所有变量加双引号”仍不是完整规则,因为数组展开、$@、模式匹配和某些故意需要分词的场景有专门语义。
三、变量与参数展开
3.1 基本变量展开
变量赋值时,等号两侧不能有空格:
name='Alice'
count=3
读取变量时,推荐使用明确的 ${...} 形式:
echo "$name"
echo "${name}42"
如果写成:
echo "$name42"
Bash 会把 name42 当作变量名,而不是变量 name 后接字符串 42。
${...} 还能表达默认值、替代值、删除前后缀和子串截取。例如:
unset value
printf '%s\n' "${value:-fallback}" # value 未设置或为空时使用 fallback
printf '%s\n' "${value-fallback}" # value 未设置时使用 fallback;已设置为空则仍为空
两者区别在于 :- 同时判断“未设置”和“空字符串”,而 - 只判断“未设置”。
value=''
printf '<%s>\n' "${value:-fallback}" # <fallback>
printf '<%s>\n' "${value-fallback}" # <>
这类展开适合处理配置默认值,但要注意它们只产生字符串,不会自动验证输入是否合法。比如:
port="${PORT:-8080}"
当 PORT 为 abc 时,port 仍然是 abc,不会自动变成有效端口。
3.2 失败型参数展开:在缺少配置时立即退出
Bash 提供 :? 和 ?:
: "${DATABASE_URL:?DATABASE_URL is required}"
当 DATABASE_URL 未设置或为空时,Bash 输出错误并使当前脚本终止;? 只把“未设置”视为错误,:? 把“未设置或为空”都视为错误。
冒号命令 : 本身什么也不做、通常返回成功,这里只是借助参数展开触发检查:
: "${CONFIG_FILE:?missing CONFIG_FILE}"
这比等到后续命令失败更容易定位配置问题。
3.3 前缀和后缀删除:模式不是正则表达式
path='/var/log/app/error.log'
printf '%s\n' "${path##*/}" # error.log
printf '%s\n' "${path%/*}" # /var/log/app
${parameter##pattern} 从开头删除匹配 pattern 的最长部分;${parameter%pattern} 从结尾删除最长匹配部分。这里的 * 是 shell 模式,不是正则表达式。
例如:
name='archive.tar.gz'
printf '%s\n' "${name%.*}" # archive.tar
printf '%s\n' "${name##*.}" # gz
${name%.*} 只删除最后一个扩展名;如果需求是处理复杂文件名,不能简单假设所有点号都表示扩展名。
3.4 替换和子串
text='one two two'
printf '%s\n' "${text/two/2}" # one 2 two
printf '%s\n' "${text//two/2}" # one 2 2
${parameter/pattern/string} 替换第一个匹配,${parameter//pattern/string} 替换所有匹配。匹配同样使用 shell 模式,不是正则表达式。
子串截取使用 ${parameter:offset:length}:
value='abcdef'
printf '%s\n' "${value:1:3}" # bcd
printf '%s\n' "${value:3}" # def
负数偏移需要与冒号之间留空格,避免被解析成 :-:
printf '%s\n' "${value: -2}" # ef
这些操作由 Bash 提供,不能直接假定 /bin/sh 支持。
四、命令替换:把命令输出变成数据
4.1 $(...) 的生命周期和结果
命令替换的形式是:
result=$(command arg1 arg2)
Bash 会:
- 启动命令或命令列表;
- 捕获其标准输出;
- 将输出中的尾随换行符删除;
- 把剩余内容作为展开结果放入当前位置;
- 继续进行后续上下文允许的分词和通配处理。
示例:
now=$(date '+%F %T')
printf 'now=%s\n' "$now"
命令替换自身不会自动把输出拆成数组。下面的写法把整个输出保存为一个字符串:
output=$(printf 'alpha\nbeta\n')
printf '<%s>\n' "$output"
输出为:
<alpha
beta>
尾随换行被删除,所以无法仅凭命令替换结果区分“输出了一个换行”和“没有输出”。如果必须保留尾随换行,需要采用编码、长度信息或直接使用文件/管道等方式保存数据。
命令替换中的内部换行不会自动删除:
output=$(printf 'alpha\nbeta\n\ngamma\n')
printf '%s\n' "$output"
结果仍会包含内部空行。
4.2 双引号决定命令输出是否被拆开
value=$(printf 'alpha beta')
printf '参数数目=%d\n' $value
printf '<%s>\n' "$value"
第一行中的 $value 未引用,会按空白分成两个参数;第二行中的 "$value" 是一个参数。
命令替换通常应该这样写:
config=$(cat -- "$file")
而不是:
config=$(cat $file)
后者在 $file 含空格或通配符时可能读取错误文件,甚至读取多个文件。
如果只是读取普通文件内容,Bash 中还可以使用:
config=$(<"$file")
这是 Bash 的特殊语法,通常比启动 cat 更直接。但它仍然会删除尾随换行,且不适合把大文件全部载入变量。
4.3 命令替换的退出状态
赋值命令的退出状态通常来自命令替换中的最后一个命令:
if result=$(false); then
echo "success"
else
echo "failed"
fi
会进入 else。
多个命令替换时,应避免依靠模糊的整体状态推断:
a=$(false)
b=$(true)
status=$?
此时 status 反映的是最后一次赋值命令,即 b=$(true),不是 a=$(false)。
应在每一步之后立即检查:
if ! a=$(some_command); then
printf 'some_command failed\n' >&2
exit 1
fi
set -e 不能替代这种设计。errexit 在 if 条件、while 条件、&&/|| 等上下文中有例外,复杂命令替换和管道还会受到 pipefail 等因素影响。
4.4 不要用命令替换模拟逐行读取
下面的写法经常被误用:
for line in $(cat input.txt); do
echo "$line"
done
它的实际过程是:
cat输出文本;- 尾随换行被删除;
- 未引用的命令替换结果按
IFS分词; - 可能进行通配展开;
for遍历分割后的词,而不是原始行。
因此包含空格的行会被拆开,包含 * 的内容还可能被替换成文件名。
逐行处理应使用:
while IFS= read -r line || [[ -n $line ]]; do
printf '<%s>\n' "$line"
done < input.txt
这里:
IFS=防止read去除行首和行尾的 IFS 空白;-r禁止反斜杠作为转义符;|| [[ -n $line ]]处理没有换行符结尾的最后一行;- 重定向直接把文件接到循环的标准输入,不需要命令替换。
五、通配与路径名展开
5.1 通配符匹配的是文件名,不是任意文本
Bash 中常见的路径名展开模式包括:
*:匹配任意长度的字符序列,通常不匹配路径分隔符/;?:匹配一个字符;[abc]:匹配集合中的一个字符;[a-z]:匹配指定范围中的一个字符;[!0-9]或[^0-9]:匹配不在集合中的一个字符,具体写法应以 Bash 模式规则为准。
例如当前目录有:
app.log
db.log
notes.txt
则:
printf '<%s>\n' *.log
可能输出:
<app.log>
<db.log>
展开后的文件名按目录项排序后作为多个参数传给 printf。通配展开不是由 printf 完成的,而是在 printf 启动前由 Bash 完成的。
因此:
rm -- *.log
可能一次删除多个文件。生产环境中必须确认匹配集合:
printf '将处理:\n'
printf ' %s\n' -- *.log
但这个预览本身也受空目录行为影响,不能把它当成完整安全检查。
5.2 隐藏文件和点号规则
默认情况下,* 不匹配以 . 开头的文件名:
printf '%s\n' *
不会列出 .env、.git 等隐藏项。
即使启用 dotglob,模式 * 也不会匹配特殊目录 . 和 ..;但递归删除和目录清理仍有严重风险,不能仅凭“通配符不会匹配点目录”来推断命令安全。
可以临时启用选项:
shopt -s nullglob
files=( *.log )
shopt -u nullglob
nullglob 的作用是:模式没有匹配项时,删除该模式,而不是保留字面量 *.log。
对比:
shopt -u nullglob
files=( *.does-not-exist )
declare -p files
可能得到:
declare -a files=([0]="*.does-not-exist")
而启用后:
shopt -s nullglob
files=( *.does-not-exist )
declare -p files
数组为空:
declare -a files=()
failglob 则在模式没有匹配时直接报告错误:
shopt -s failglob
printf '%s\n' *.does-not-exist
这更适合希望“没有匹配就失败”的脚本,但必须清楚它是 Bash 选项,不是 POSIX shell 特性。
5.3 globstar 和递归通配
启用:
shopt -s globstar
printf '%s\n' **/*.log
后,** 可以递归匹配目录层次。若要处理文件名中的空格,数组和双引号仍然必不可少:
shopt -s nullglob globstar
files=( **/*.log )
for file in "${files[@]}"; do
printf 'log: %s\n' "$file"
done
"${files[@]}" 让每个数组元素保持一个参数;如果写成未引用的 ${files[@]},元素可能再次发生分词和通配。
递归通配可能遍历非常大的目录树,也可能触碰不应访问的挂载点、符号链接目标或权限受限目录。生产脚本中通常要明确搜索根目录、文件类型和排除规则;需要复杂条件时,find -print0 往往比盲目使用 ** 更可控。
5.4 通配模式与正则表达式不是一回事
[[ $name == *.log ]]
这里使用的是 shell 模式,*.log 表示以 .log 结尾的字符串。
而:
[[ $name =~ \.log$ ]]
使用的是 Bash 的正则匹配运算符 =~。两者的语法、引用规则和捕获结果不同,不能混用。
例如,shell 模式中的 + 通常只是普通字符;正则表达式中的 + 表示“一次或多次”。
六、数组:保留参数边界的数据结构
6.1 数组解决什么问题
字符串变量通常只保存一段文本,而命令参数需要的是“有边界的元素序列”。数组正是用来保存这种结构的:
files=(
'report 2025.txt'
'error.log'
'draft*.txt'
)
printf '元素数量=%d\n' "${#files[@]}"
for file in "${files[@]}"; do
printf '<%s>\n' "$file"
done
输出中的 report 2025.txt 和 draft*.txt 都保持为单个元素。这里的 * 是数组数据,不会再次被通配展开,因为元素在 "${files[@]}" 中被引用。
6.2 数组赋值与索引
普通索引数组下标从 0 开始:
items[0]='alpha'
items[2]='gamma'
printf '%s\n' "${items[0]}"
printf '元素数量=%d\n' "${#items[@]}"
索引 1 可以不存在。数组长度是存在的元素数量,不一定等于最大下标加一。
追加元素:
items+=('delta')
遍历时使用:
for index in "${!items[@]}"; do
printf 'index=%s value=%s\n' "$index" "${items[$index]}"
done
${!items[@]} 展开为已存在的索引;这比假设索引连续更准确。
关联数组需要 Bash:
declare -A user_id
user_id[alice]=1001
user_id[bob]=1002
printf '%s\n' "${user_id[alice]}"
关联数组的键不是数字,遍历顺序不应被当作稳定排序。若需要确定顺序,应显式排序键或使用其他数据结构。
6.3 "${array[@]}" 与 "${array[*]}"
这是数组引用中最容易混淆的区别。
假设:
args=('alpha beta' 'gamma' '')
在双引号中:
printf '<%s>\n' "${args[@]}"
展开为三个参数:
<alpha beta>
<gamma>
<>
而:
printf '<%s>\n' "${args[*]}"
会把所有元素连接成一个参数,连接符是 IFS 的第一个字符;默认通常是空格:
<alpha beta gamma >
可以通过设置 IFS 观察连接方式:
IFS=,
printf '<%s>\n' "${args[*]}"
结果类似:
<alpha beta,gamma,>
因此:
- 传递数组元素给命令,通常用
"${array[@]}"; - 明确需要把数组连接成一段字符串时,才考虑
"${array[*]}"; - 未引用的
${array[@]}和${array[*]}都可能触发分词和路径名展开,不应作为普通参数传递方式。
6.4 $@、$* 与脚本参数
脚本参数本身也是一种数组。假设脚本调用为:
./script.sh 'alpha beta' gamma
则:
printf '数量=%d\n' "$#"
for arg in "$@"; do
printf '<%s>\n' "$arg"
done
输出:
数量=2
<alpha beta>
<gamma>
"$@" 会把每个位置参数作为独立参数传递,是包装其他命令时的标准形式:
run_command() {
command -- "$@"
}
"$*" 则把所有位置参数合并成一个参数:
printf '<%s>\n' "$*"
默认情况下结果类似:
<alpha beta gamma>
如果函数需要接收并原样转发任意参数,应该使用:
some_function() {
printf '参数数量=%d\n' "$#"
command "$@"
}
不要写成:
command $@
否则原本包含空格的参数会被重新拆分,参数中的通配符也可能重新展开。
6.5 使用 read -a 解析输入
read -a array 可以把一行按照 IFS 分割后写入数组:
line='alpha "beta gamma"'
read -r -a fields <<< "$line"
printf '数量=%d\n' "${#fields[@]}"
printf '<%s>\n' "${fields[@]}"
这里不会像 shell 解析器那样完整理解引号;read 只是依据 IFS 进行分隔,结果通常不是用户期望的“解析 shell 命令行”。如果输入格式允许引号、转义、嵌套结构,不应把 read -a 当作通用解析器。
七、IFS:单词分割和 read 的分隔规则
7.1 IFS 的定义
IFS 是 Bash 用于单词分割和 read 分隔输入的变量,名称来自 Internal Field Separator。
默认值通常是:
空格、制表符、换行
可以检查当前值:
printf '%q\n' "$IFS"
但 IFS 是普通 shell 变量,可能被脚本、环境或函数修改;不要假定当前状态一定是默认值。
7.2 未引用参数展开如何受 IFS 影响
value=' alpha beta '
printf '<%s>\n' $value
当 IFS 使用默认空白字符时,连续的 IFS 空白通常被视为分隔符,行首和行尾空白不会产生空字段。结果通常是:
<alpha>
<beta>
如果 IFS 含有非空白字符,例如逗号:
IFS=,
value='alpha,,beta,'
printf '<%s>\n' $value
在未引用展开中,逗号可能产生空字段;但具体边界规则会受到 IFS 空白和非空白字符组合的影响。不能简单把 IFS 当成“一个正则分隔符”。
更重要的是,修改全局 IFS 会影响后续所有未引用展开和 read:
IFS=,
for item in $value; do
printf '<%s>\n' "$item"
done
因此解析局部数据时,应尽量缩小作用域:
while IFS=, read -r name id; do
printf 'name=%s id=%s\n' "$name" "$id"
done < users.csv
这里 IFS=, 只作用于该次 read 命令,不会永久改变外层环境。
7.3 IFS 不是安全的字符串解析器
下面的代码经常被用来处理冒号分隔的值:
IFS=: read -r user uid gid <<< "$record"
它适用于简单、无转义、字段中不允许冒号的格式。但如果字段本身可以包含分隔符、引号或转义,IFS 无法表达完整语法。
例如 CSV 允许:
Alice,"Engineering, Platform",active
直接用逗号作为 IFS 会错误地把 Engineering, Platform 拆成两个字段。此时应使用能够理解 CSV 语法的解析器,而不是继续堆叠 shell 展开技巧。
7.4 read 的关键选项
逐行读取推荐:
while IFS= read -r line; do
printf '%s\n' "$line"
done < input.txt
选项含义:
IFS=:保留行首、行尾空白;-r:保留反斜杠,不把\n、\t等当作转义;read默认读取到换行结束的一行;- 如果最后一行没有换行符,
read读取到内容后返回非零状态,因此完整循环常写为:
while IFS= read -r line || [[ -n $line ]]; do
printf '<%s>\n' "$line"
done < input.txt
如果确实需要按 NUL 字节读取:
while IFS= read -r -d '' file; do
printf 'file=%s\n' "$file"
done < <(find . -type f -print0)
文件名不能包含 NUL 字节,因此 find -print0 与 read -d '' 可以安全传递包含换行、空格和制表符的文件名。进程替换 < <(...) 是 Bash 语法,不是所有 /bin/sh 都支持。
八、赋值上下文、命令参数和测试上下文并不相同
同一个展开表达式放在不同位置,行为可能不同。
8.1 赋值通常不会进行普通分词
value=$var
即使 $var 含有空格,这也是一次赋值,不会因为空格生成多个命令参数。下面两种写法通常都能得到相同字符串:
value=$var
value="$var"
但加引号仍然更清晰,也避免读者误以为这里依赖特殊上下文:
value="$var"
命令替换赋值同理:
output=$(some_command)
不会因为输出中有空格而把赋值拆成多个词。
8.2 算术上下文有自己的规则
count=2
(( count += 3 ))
printf '%d\n' "$count"
算术表达式中的变量通常不需要 $:
(( count < 10 ))
算术命令的退出状态有一个容易忽略的规则:表达式值为非零时返回 0,表达式值为零时返回 1。
if (( count )); then
echo "non-zero"
fi
这在 set -e 下尤其需要注意:
(( count-- ))
如果递减前 count 为 1,表达式返回值可能是 0,命令状态为 1;脚本可能因 errexit 退出。若只是想执行递减而不依赖表达式状态,可以明确写:
: $((count--))
或者使用赋值并检查业务条件。
8.3 [[ ... ]] 不是普通命令参数解析
Bash 的条件命令:
if [[ $file == *.log ]]; then
echo "log file"
fi
在 [[ ]] 中,右侧的 *.log 是模式,不会按当前目录展开成文件名;这是它与:
printf '%s\n' *.log
的根本区别。
在 [[ ]] 中,变量通常可以不加双引号:
[[ $value == "$expected" ]]
因为该上下文不会按普通命令参数那样进行单词分割和路径名展开。但为了表达清楚,模式位置和普通字符串位置仍应区分:
[[ $value == *.log ]] # 右侧是模式
[[ $value == "$pattern" ]] # 变量内容通常按模式解释;具体需求需明确
如果希望右侧变量按字面字符串比较,可以使用:
[[ $value == "$expected" ]]
[[ ]] 是 Bash/Ksh 风格能力,不能直接移植到纯 POSIX sh。
九、空值、未设置值和参数边界
空字符串不是“没有参数”。例如:
args=('' 'x')
printf '数量=%d\n' "${#args[@]}"
printf '<%s>\n' "${args[@]}"
输出包含一个空参数:
数量=2
<>
<x>
这与数组为空不同:
empty=()
printf '数量=%d\n' "${#empty[@]}"
变量未设置、设置为空字符串、设置为包含空格的字符串,必须分开考虑:
unset value
printf 'unset: <%s>\n' "${value-unset}"
value=''
printf 'empty: <%s>\n' "${value-unset}"
输出:
unset: <unset>
empty: <>
如果代码需要保留“空参数”,必须使用引用和数组;把参数序列压缩成空格分隔字符串再恢复,通常会丢失边界信息。
错误示例:
args=('alpha beta' '' 'gamma')
joined="${args[*]}"
set -- $joined
恢复后无法可靠区分:
- 原来的
alpha beta是一个元素还是两个元素; - 原来的空元素是否存在;
- 元素中是否包含通配符;
- 元素中是否包含 IFS 字符。
这说明数组不是“字符串的另一种写法”,而是不同的数据结构。
十、常见失败表现与诊断方法
10.1 用 printf 显示参数边界
调试展开问题时,不要只写:
echo $value
echo 可能解释选项和反斜杠,且无法清楚显示参数数量。推荐:
printf 'argc=%d\n' "$#"
printf 'arg=<%s>\n' "$1"
调试脚本参数:
for arg in "$@"; do
printf 'arg=%q\n' "$arg"
done
%q 输出适合 Bash 重新读取的转义表示,能够显示空格、换行和特殊字符。
例如:
value=$'alpha\nbeta'
printf 'quoted=%q\n' "$value"
可能输出:
quoted=$'alpha\nbeta'
10.2 使用 declare -p 检查变量类型和内容
value='a b'
items=('a b' 'c')
declare -p value items
输出会明确显示普通变量和数组结构,例如:
declare -- value="a b"
declare -a items=([0]="a b" [1]="c")
这比肉眼观察 echo 输出更可靠。
10.3 用 set -x 观察执行轨迹,但不要泄露秘密
set -x
printf '<%s>\n' "$value"
set +x
xtrace 可以帮助观察 Bash 经过部分展开后准备执行的命令,但它不是完整的解析器调试器,而且可能把密码、令牌和密钥写入日志。
可以设置专用描述符和提示:
export BASH_XTRACEFD=9
exec 9>trace.log
PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '
set -x
生产环境开启前应确认日志权限和敏感数据脱敏策略。
10.4 用 ShellCheck 发现明显的引用错误
ShellCheck 能识别很多常见问题,例如:
rm $file
for x in $(cat file); do
...
done
但静态检查工具不能替代对输入格式、命令语义和权限边界的理解。比如 rm -- "$file" 解决了参数边界,却不能证明 $file 指向的目标符合业务预期。
十一、端到端示例:安全处理文件名列表
下面的脚本演示三种不同数据流:
- 用通配生成文件数组;
- 用数组保持文件名边界;
- 用 NUL 分隔从
find读取任意合法文件名。
11.1 当前目录下处理 .log 文件
#!/usr/bin/env bash
set -u
shopt -s nullglob
files=( ./*.log )
shopt -u nullglob
if ((${#files[@]} == 0)); then
printf '没有匹配的 .log 文件\n'
exit 0
fi
for file in "${files[@]}"; do
printf '文件: %q\n' "$file"
done
每一步成立的原因:
./*.log明确限制搜索当前目录,并让结果带有./前缀;nullglob使无匹配时数组为空;files=( ... )将通配结果保存为数组元素;"${files[@]}"逐元素展开;printf '%q'只用于展示,不改变实际变量内容。
如果后续要删除:
for file in "${files[@]}"; do
rm -- "$file"
done
仍然应在删除前确认目录、权限、符号链接行为和匹配集合。脚本运行用户拥有的文件可能包含重要数据,rm 没有通用撤销机制。
11.2 递归读取任意文件名
#!/usr/bin/env bash
set -u
while IFS= read -r -d '' file; do
printf '找到: %q\n' "$file"
done < <(find /var/tmp/my-app -type f -name '*.log' -print0)
数据流如下:
find 标准输出
└── NUL 分隔的路径名
└── 进程替换提供文件描述符
└── read -d '' 每次读取一个路径
└── "$file" 作为单个参数使用
这里不会因路径中的空格、制表符或换行而拆分。文件名不能包含 NUL,所以 NUL 是 Unix 文件名处理中常用的安全记录分隔符。
但这不意味着所有故障都消失了:
find可能因权限不足而跳过目录;- 目录内容可能在遍历期间变化;
- 符号链接是否跟随取决于
find选项; - 进程替换中的
find失败不会天然等同于while循环失败; - 如果要求严格检测
find的退出状态,应采用更明确的临时文件、管道状态设计或在 Bash 中单独记录状态。
十二、生产边界:Shell 展开能做什么,不能做什么
12.1 Shell 展开适合参数组织,不适合复杂数据序列化
数组适合在 Bash 进程内部传递参数:
cmd=(curl --fail --silent --show-error --url "$url")
"${cmd[@]}"
这比把命令拼接成字符串再执行安全:
cmd="curl --fail $url"
$cmd
后者会重新经历分词和通配展开,且如果进一步使用 eval "$cmd",还会引入第二次 shell 解析,可能执行变量内容中的命令替换、重定向或命令分隔符。
除非明确知道需要二次解析,否则不要使用 eval 拼接命令。
12.2 外部输入不能直接作为选项和代码
即使已经引用:
grep "$pattern" "$file"
如果 pattern='--include=*',它是否被解释为选项取决于命令参数位置和命令自身规则。对文件名这类可能以短横线开头的数据,使用:
command -- "$file"
对选项和值,应根据具体命令采用显式选项形式:
grep -- "$pattern" "$file"
但不是所有命令都支持 --,应查看对应命令的手册页。引用只控制 Bash 的展开,不会改变外部程序自己的选项解析规则。
12.3 Shell 变量不能表示 NUL
Bash 变量和命令替换结果不能保存 NUL 字节。需要传输任意文件名时,使用文件描述符、NUL 分隔流或直接的系统调用接口,而不要试图把 NUL 塞进变量。
这也是为什么:
find ... -print0
通常与:
read -r -d ''
配套,而不是先放入一个普通变量。
12.4 Bash 与 /bin/sh 的边界
以下能力不是 POSIX sh 的通用能力:
- 数组;
- 关联数组;
[[ ... ]];mapfile/readarray;shopt;globstar、nullglob、failglob;- 进程替换
< <(...); - Bash 特有参数展开。
如果脚本首行是:
#!/usr/bin/env bash
它应当由 Bash 执行。不要把 Bash 脚本交给:
sh script.sh
因为这会绕过脚本的 shebang,可能导致数组和 [[ ]] 等语法失败。发行版中 /bin/sh 可能链接到 dash、bash 的兼容模式或其他实现,属于发行版差异,不能依赖其具体行为。
十三、把展开过程用于设计,而不是事后补救
处理一段数据时,可以先问三个问题:
数据应该是一个字符串,还是多个参数?
如果是多个参数,使用数组:
options=(--color=auto '--exclude=*.tmp')
command "${options[@]}" -- "$file"
如果使用字符串拼接:
options="--color=auto --exclude=*.tmp"
command $options "$file"
就把参数边界交给 IFS 和通配规则处理,极易产生歧义。
数据应该按行、按 NUL,还是按某种格式解析?
- 普通逐行文本:
while IFS= read -r line - 任意文件名列表:NUL 分隔
- CSV、JSON、带引用规则的配置:使用对应格式解析器
- 简单的无转义分隔字段:可以局部使用
IFS=... read
不要因为 IFS 能拆字符串,就把它当成通用语法解析器。
数据是在 Bash 中匹配模式,还是交给外部工具匹配?
- 文件名集合:路径名展开;
- 字符串后缀判断:
[[ $x == *.log ]]; - 正则需求:
[[ $x =~ regex ]]; - 复杂递归搜索:
find; - 大规模文本处理:专用工具。
明确匹配层次,可以避免把“shell 模式”“正则表达式”“文件系统遍历”混为一谈。
十四、核心规则总结
Bash 展开的关键不在于记住某一条“变量必须加引号”的口号,而在于掌握数据边界何时会丢失:
"$value" # 一个参数,保留空格和通配符
$value # 可能分词,之后可能通配
"${array[@]}" # 每个元素一个参数
"${array[*]}" # 所有元素合成一个参数
"$@" # 每个位置参数一个参数
"$*" # 所有位置参数合成一个参数
$(command) # 捕获输出并删除尾随换行
IFS= read -r line # 保留一行的原始空白和反斜杠
可以把安全的参数传递抽象为:
结构化数据
→ 保持元素边界
→ 在最终命令调用处逐元素引用
→ 对可能被解释为选项的路径使用 --
反过来,以下链条会不断丢失边界:
数组
→ 拼成字符串
→ 命令替换
→ 未引用展开
→ IFS 分词
→ 通配展开
→ 重新传给命令
当脚本涉及用户输入、文件名、临时目录、批量删除、权限敏感路径或外部命令时,必须逐步确认每一次展开后的实际参数。Bash 不会替工程师推断“这个空格属于数据”还是“这个空格是参数分隔符”;引号、数组、明确的读取协议和及时的状态检查,才是表达这种意图的工具。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则
- 下一篇:Bash 函数与 Trap:退出码、错误传播、临时资源和信号清理
- 延伸:Bash 与 Shell 工程实践:展开、引用、管道、错误处理和脚本测试
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论