WR Blog 加载中...
返回文章
LinuxBashShell运维

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS封面

Linux 基础体系 · 第 43/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 脚本中大量“看起来像字符串”的内容,实际上会经历一套语法处理流程。变量可能被替换,命令输出可能被拼接,空白可能触发分词,通配符可能变成多个文件名;而引号会改变其中若干步骤是否发生。

许多危险并不是命令本身造成的,而是数据在展开过程中改变了形态。例如:

file='report 2025.txt'
rm $file

如果当前目录中存在 report2025.txt,实际执行的可能是:

rm report 2025.txt

正确写法通常是:

rm -- "$file"

这里的 "$file" 同时阻止了空白分词和通配符展开,-- 则防止以 - 开头的文件名被解释为选项。


一、先建立整体模型:Bash 如何把源码变成参数

以一条简单命令为例:

printf '<%s>\n' $value

Bash 并不是把 $value 替换成文本后直接执行。对普通命令参数而言,典型处理顺序可以概括为:

  1. 大括号展开{a,b}{1..3}
  2. 波浪号展开~~user
  3. 参数展开、命令替换、算术展开
    • $name
    • ${name:-default}
    • $(command)
    • $(( expression ))
  4. 单词分割:对未被引号保护的参数展开结果,依据 IFS 分割
  5. 路径名展开,也称通配展开*?[abc]
  6. 引号移除
  7. 将最终得到的一个或多个词作为命令参数执行

这是理解问题的核心模型,但它不是“所有 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.logerror.log 时,可能得到:

<app.log>
<error.log>

如果没有匹配项,默认情况下 Bash 通常保留字面量 *.log

<*.log>

nullglobfailglob 等选项可以改变这一行为,后文详述。

工程上,普通变量作为命令参数时应优先写成:

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}"

PORTabc 时,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 会:

  1. 启动命令或命令列表;
  2. 捕获其标准输出;
  3. 将输出中的尾随换行符删除;
  4. 把剩余内容作为展开结果放入当前位置;
  5. 继续进行后续上下文允许的分词和通配处理。

示例:

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 不能替代这种设计。errexitif 条件、while 条件、&&/|| 等上下文中有例外,复杂命令替换和管道还会受到 pipefail 等因素影响。

4.4 不要用命令替换模拟逐行读取

下面的写法经常被误用:

for line in $(cat input.txt); do
    echo "$line"
done

它的实际过程是:

  1. cat 输出文本;
  2. 尾随换行被删除;
  3. 未引用的命令替换结果按 IFS 分词;
  4. 可能进行通配展开;
  5. 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.txtdraft*.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 -print0read -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 指向的目标符合业务预期。


十一、端到端示例:安全处理文件名列表

下面的脚本演示三种不同数据流:

  1. 用通配生成文件数组;
  2. 用数组保持文件名边界;
  3. 用 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
  • globstarnullglobfailglob
  • 进程替换 < <(...)
  • Bash 特有参数展开。

如果脚本首行是:

#!/usr/bin/env bash

它应当由 Bash 执行。不要把 Bash 脚本交给:

sh script.sh

因为这会绕过脚本的 shebang,可能导致数组和 [[ ]] 等语法失败。发行版中 /bin/sh 可能链接到 dashbash 的兼容模式或其他实现,属于发行版差异,不能依赖其具体行为。


十三、把展开过程用于设计,而不是事后补救

处理一段数据时,可以先问三个问题:

数据应该是一个字符串,还是多个参数?

如果是多个参数,使用数组:

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 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
WR Blog 加载中...
返回文章
LinuxBashShell运维

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS封面

Linux 基础体系 · 第 43/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 脚本中大量“看起来像字符串”的内容,实际上会经历一套语法处理流程。变量可能被替换,命令输出可能被拼接,空白可能触发分词,通配符可能变成多个文件名;而引号会改变其中若干步骤是否发生。

许多危险并不是命令本身造成的,而是数据在展开过程中改变了形态。例如:

file='report 2025.txt'
rm $file

如果当前目录中存在 report2025.txt,实际执行的可能是:

rm report 2025.txt

正确写法通常是:

rm -- "$file"

这里的 "$file" 同时阻止了空白分词和通配符展开,-- 则防止以 - 开头的文件名被解释为选项。


一、先建立整体模型:Bash 如何把源码变成参数

以一条简单命令为例:

printf '<%s>\n' $value

Bash 并不是把 $value 替换成文本后直接执行。对普通命令参数而言,典型处理顺序可以概括为:

  1. 大括号展开{a,b}{1..3}
  2. 波浪号展开~~user
  3. 参数展开、命令替换、算术展开
    • $name
    • ${name:-default}
    • $(command)
    • $(( expression ))
  4. 单词分割:对未被引号保护的参数展开结果,依据 IFS 分割
  5. 路径名展开,也称通配展开*?[abc]
  6. 引号移除
  7. 将最终得到的一个或多个词作为命令参数执行

这是理解问题的核心模型,但它不是“所有 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.logerror.log 时,可能得到:

<app.log>
<error.log>

如果没有匹配项,默认情况下 Bash 通常保留字面量 *.log

<*.log>

nullglobfailglob 等选项可以改变这一行为,后文详述。

工程上,普通变量作为命令参数时应优先写成:

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}"

PORTabc 时,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 会:

  1. 启动命令或命令列表;
  2. 捕获其标准输出;
  3. 将输出中的尾随换行符删除;
  4. 把剩余内容作为展开结果放入当前位置;
  5. 继续进行后续上下文允许的分词和通配处理。

示例:

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 不能替代这种设计。errexitif 条件、while 条件、&&/|| 等上下文中有例外,复杂命令替换和管道还会受到 pipefail 等因素影响。

4.4 不要用命令替换模拟逐行读取

下面的写法经常被误用:

for line in $(cat input.txt); do
    echo "$line"
done

它的实际过程是:

  1. cat 输出文本;
  2. 尾随换行被删除;
  3. 未引用的命令替换结果按 IFS 分词;
  4. 可能进行通配展开;
  5. 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.txtdraft*.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 -print0read -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 指向的目标符合业务预期。


十一、端到端示例:安全处理文件名列表

下面的脚本演示三种不同数据流:

  1. 用通配生成文件数组;
  2. 用数组保持文件名边界;
  3. 用 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
  • globstarnullglobfailglob
  • 进程替换 < <(...)
  • Bash 特有参数展开。

如果脚本首行是:

#!/usr/bin/env bash

它应当由 Bash 执行。不要把 Bash 脚本交给:

sh script.sh

因为这会绕过脚本的 shebang,可能导致数组和 [[ ]] 等语法失败。发行版中 /bin/sh 可能链接到 dashbash 的兼容模式或其他实现,属于发行版差异,不能依赖其具体行为。


十三、把展开过程用于设计,而不是事后补救

处理一段数据时,可以先问三个问题:

数据应该是一个字符串,还是多个参数?

如果是多个参数,使用数组:

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 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:参数、命令、算术展开\n- `` ` ``:旧式命令替换\n- `\\`:在特定字符前仍可转义\n- `\"`:结束双引号\n- 在某些 Bash 场景中,`!` 可能与历史展开有关;脚本通常关闭历史展开,交互式 shell 则要注意环境差异\n\n例如:\n\n```bash\nvalue='a b'\nprintf '\u003c%s>\\n' \"$value\"\n```\n\n双引号保护的是**一个参数**,而不是“把内容变成某种安全格式”。如果将内容拼接到一个更大的词中:\n\n```bash\nprefix='a b'\nprintf '\u003c%s>\\n' \"x${prefix}y\"\n```\n\n仍然只有一个参数:\n\n```text\n\u003cxa by>\n```\n\n### 2.3 无引号:允许分词和通配,风险最大\n\n```bash\nvalue='a b'\nprintf '\u003c%s>\\n' $value\n```\n\n这里 `$value` 的结果会先被 `IFS` 分割;如果结果中还含有通配符,再进行路径名展开:\n\n```bash\nvalue='*.log'\nprintf '\u003c%s>\\n' $value\n```\n\n在当前目录有 `app.log`、`error.log` 时,可能得到:\n\n```text\n\u003capp.log>\n\u003cerror.log>\n```\n\n如果没有匹配项,默认情况下 Bash 通常保留字面量 `*.log`:\n\n```text\n\u003c*.log>\n```\n\n但 `nullglob`、`failglob` 等选项可以改变这一行为,后文详述。\n\n工程上,普通变量作为命令参数时应优先写成:\n\n```bash\ncommand -- \"$value\"\n```\n\n而不是:\n\n```bash\ncommand $value\n```\n\n但“永远给所有变量加双引号”仍不是完整规则,因为数组展开、`$@`、模式匹配和某些故意需要分词的场景有专门语义。\n\n---\n\n## 三、变量与参数展开\n\n### 3.1 基本变量展开\n\n变量赋值时,等号两侧不能有空格:\n\n```bash\nname='Alice'\ncount=3\n```\n\n读取变量时,推荐使用明确的 `${...}` 形式:\n\n```bash\necho \"$name\"\necho \"${name}42\"\n```\n\n如果写成:\n\n```bash\necho \"$name42\"\n```\n\nBash 会把 `name42` 当作变量名,而不是变量 `name` 后接字符串 `42`。\n\n`${...}` 还能表达默认值、替代值、删除前后缀和子串截取。例如:\n\n```bash\nunset value\n\nprintf '%s\\n' \"${value:-fallback}\" # value 未设置或为空时使用 fallback\nprintf '%s\\n' \"${value-fallback}\" # value 未设置时使用 fallback;已设置为空则仍为空\n```\n\n两者区别在于 `:-` 同时判断“未设置”和“空字符串”,而 `-` 只判断“未设置”。\n\n```bash\nvalue=''\n\nprintf '\u003c%s>\\n' \"${value:-fallback}\" # \u003cfallback>\nprintf '\u003c%s>\\n' \"${value-fallback}\" # \u003c>\n```\n\n这类展开适合处理配置默认值,但要注意它们只产生字符串,不会自动验证输入是否合法。比如:\n\n```bash\nport=\"${PORT:-8080}\"\n```\n\n当 `PORT` 为 `abc` 时,`port` 仍然是 `abc`,不会自动变成有效端口。\n\n### 3.2 失败型参数展开:在缺少配置时立即退出\n\nBash 提供 `:?` 和 `?`:\n\n```bash\n: \"${DATABASE_URL:?DATABASE_URL is required}\"\n```\n\n当 `DATABASE_URL` 未设置或为空时,Bash 输出错误并使当前脚本终止;`?` 只把“未设置”视为错误,`:?` 把“未设置或为空”都视为错误。\n\n冒号命令 `:` 本身什么也不做、通常返回成功,这里只是借助参数展开触发检查:\n\n```bash\n: \"${CONFIG_FILE:?missing CONFIG_FILE}\"\n```\n\n这比等到后续命令失败更容易定位配置问题。\n\n### 3.3 前缀和后缀删除:模式不是正则表达式\n\n```bash\npath='/var/log/app/error.log'\n\nprintf '%s\\n' \"${path##*/}\" # error.log\nprintf '%s\\n' \"${path%/*}\" # /var/log/app\n```\n\n`${parameter##pattern}` 从开头删除匹配 `pattern` 的最长部分;`${parameter%pattern}` 从结尾删除最长匹配部分。这里的 `*` 是 shell 模式,不是正则表达式。\n\n例如:\n\n```bash\nname='archive.tar.gz'\n\nprintf '%s\\n' \"${name%.*}\" # archive.tar\nprintf '%s\\n' \"${name##*.}\" # gz\n```\n\n`${name%.*}` 只删除最后一个扩展名;如果需求是处理复杂文件名,不能简单假设所有点号都表示扩展名。\n\n### 3.4 替换和子串\n\n```bash\ntext='one two two'\n\nprintf '%s\\n' \"${text/two/2}\" # one 2 two\nprintf '%s\\n' \"${text//two/2}\" # one 2 2\n```\n\n`${parameter/pattern/string}` 替换第一个匹配,`${parameter//pattern/string}` 替换所有匹配。匹配同样使用 shell 模式,不是正则表达式。\n\n子串截取使用 `${parameter:offset:length}`:\n\n```bash\nvalue='abcdef'\n\nprintf '%s\\n' \"${value:1:3}\" # bcd\nprintf '%s\\n' \"${value:3}\" # def\n```\n\n负数偏移需要与冒号之间留空格,避免被解析成 `:-`:\n\n```bash\nprintf '%s\\n' \"${value: -2}\" # ef\n```\n\n这些操作由 Bash 提供,不能直接假定 `/bin/sh` 支持。\n\n---\n\n## 四、命令替换:把命令输出变成数据\n\n### 4.1 `$(...)` 的生命周期和结果\n\n命令替换的形式是:\n\n```bash\nresult=$(command arg1 arg2)\n```\n\nBash 会:\n\n1. 启动命令或命令列表;\n2. 捕获其标准输出;\n3. 将输出中的尾随换行符删除;\n4. 把剩余内容作为展开结果放入当前位置;\n5. 继续进行后续上下文允许的分词和通配处理。\n\n示例:\n\n```bash\nnow=$(date '+%F %T')\nprintf 'now=%s\\n' \"$now\"\n```\n\n命令替换自身不会自动把输出拆成数组。下面的写法把整个输出保存为一个字符串:\n\n```bash\noutput=$(printf 'alpha\\nbeta\\n')\nprintf '\u003c%s>\\n' \"$output\"\n```\n\n输出为:\n\n```text\n\u003calpha\nbeta>\n```\n\n尾随换行被删除,所以无法仅凭命令替换结果区分“输出了一个换行”和“没有输出”。如果必须保留尾随换行,需要采用编码、长度信息或直接使用文件/管道等方式保存数据。\n\n命令替换中的内部换行不会自动删除:\n\n```bash\noutput=$(printf 'alpha\\nbeta\\n\\ngamma\\n')\nprintf '%s\\n' \"$output\"\n```\n\n结果仍会包含内部空行。\n\n### 4.2 双引号决定命令输出是否被拆开\n\n```bash\nvalue=$(printf 'alpha beta')\nprintf '参数数目=%d\\n' $value\nprintf '\u003c%s>\\n' \"$value\"\n```\n\n第一行中的 `$value` 未引用,会按空白分成两个参数;第二行中的 `\"$value\"` 是一个参数。\n\n命令替换通常应该这样写:\n\n```bash\nconfig=$(cat -- \"$file\")\n```\n\n而不是:\n\n```bash\nconfig=$(cat $file)\n```\n\n后者在 `$file` 含空格或通配符时可能读取错误文件,甚至读取多个文件。\n\n如果只是读取普通文件内容,Bash 中还可以使用:\n\n```bash\nconfig=$(\u003c\"$file\")\n```\n\n这是 Bash 的特殊语法,通常比启动 `cat` 更直接。但它仍然会删除尾随换行,且不适合把大文件全部载入变量。\n\n### 4.3 命令替换的退出状态\n\n赋值命令的退出状态通常来自命令替换中的最后一个命令:\n\n```bash\nif result=$(false); then\n echo \"success\"\nelse\n echo \"failed\"\nfi\n```\n\n会进入 `else`。\n\n多个命令替换时,应避免依靠模糊的整体状态推断:\n\n```bash\na=$(false)\nb=$(true)\nstatus=$?\n```\n\n此时 `status` 反映的是最后一次赋值命令,即 `b=$(true)`,不是 `a=$(false)`。\n\n应在每一步之后立即检查:\n\n```bash\nif ! a=$(some_command); then\n printf 'some_command failed\\n' >&2\n exit 1\nfi\n```\n\n`set -e` 不能替代这种设计。`errexit` 在 `if` 条件、`while` 条件、`&&`/`||` 等上下文中有例外,复杂命令替换和管道还会受到 `pipefail` 等因素影响。\n\n### 4.4 不要用命令替换模拟逐行读取\n\n下面的写法经常被误用:\n\n```bash\nfor line in $(cat input.txt); do\n echo \"$line\"\ndone\n```\n\n它的实际过程是:\n\n1. `cat` 输出文本;\n2. 尾随换行被删除;\n3. 未引用的命令替换结果按 `IFS` 分词;\n4. 可能进行通配展开;\n5. `for` 遍历分割后的词,而不是原始行。\n\n因此包含空格的行会被拆开,包含 `*` 的内容还可能被替换成文件名。\n\n逐行处理应使用:\n\n```bash\nwhile IFS= read -r line || [[ -n $line ]]; do\n printf '\u003c%s>\\n' \"$line\"\ndone \u003c input.txt\n```\n\n这里:\n\n- `IFS=` 防止 `read` 去除行首和行尾的 IFS 空白;\n- `-r` 禁止反斜杠作为转义符;\n- `|| [[ -n $line ]]` 处理没有换行符结尾的最后一行;\n- 重定向直接把文件接到循环的标准输入,不需要命令替换。\n\n---\n\n## 五、通配与路径名展开\n\n### 5.1 通配符匹配的是文件名,不是任意文本\n\nBash 中常见的路径名展开模式包括:\n\n- `*`:匹配任意长度的字符序列,通常不匹配路径分隔符 `/`;\n- `?`:匹配一个字符;\n- `[abc]`:匹配集合中的一个字符;\n- `[a-z]`:匹配指定范围中的一个字符;\n- `[!0-9]` 或 `[^0-9]`:匹配不在集合中的一个字符,具体写法应以 Bash 模式规则为准。\n\n例如当前目录有:\n\n```text\napp.log\ndb.log\nnotes.txt\n```\n\n则:\n\n```bash\nprintf '\u003c%s>\\n' *.log\n```\n\n可能输出:\n\n```text\n\u003capp.log>\n\u003cdb.log>\n```\n\n展开后的文件名按目录项排序后作为多个参数传给 `printf`。通配展开不是由 `printf` 完成的,而是在 `printf` 启动前由 Bash 完成的。\n\n因此:\n\n```bash\nrm -- *.log\n```\n\n可能一次删除多个文件。生产环境中必须确认匹配集合:\n\n```bash\nprintf '将处理:\\n'\nprintf ' %s\\n' -- *.log\n```\n\n但这个预览本身也受空目录行为影响,不能把它当成完整安全检查。\n\n### 5.2 隐藏文件和点号规则\n\n默认情况下,`*` 不匹配以 `.` 开头的文件名:\n\n```bash\nprintf '%s\\n' *\n```\n\n不会列出 `.env`、`.git` 等隐藏项。\n\n即使启用 `dotglob`,模式 `*` 也不会匹配特殊目录 `.` 和 `..`;但递归删除和目录清理仍有严重风险,不能仅凭“通配符不会匹配点目录”来推断命令安全。\n\n可以临时启用选项:\n\n```bash\nshopt -s nullglob\nfiles=( *.log )\nshopt -u nullglob\n```\n\n`nullglob` 的作用是:模式没有匹配项时,删除该模式,而不是保留字面量 `*.log`。\n\n对比:\n\n```bash\nshopt -u nullglob\nfiles=( *.does-not-exist )\ndeclare -p files\n```\n\n可能得到:\n\n```text\ndeclare -a files=([0]=\"*.does-not-exist\")\n```\n\n而启用后:\n\n```bash\nshopt -s nullglob\nfiles=( *.does-not-exist )\ndeclare -p files\n```\n\n数组为空:\n\n```text\ndeclare -a files=()\n```\n\n`failglob` 则在模式没有匹配时直接报告错误:\n\n```bash\nshopt -s failglob\nprintf '%s\\n' *.does-not-exist\n```\n\n这更适合希望“没有匹配就失败”的脚本,但必须清楚它是 Bash 选项,不是 POSIX shell 特性。\n\n### 5.3 `globstar` 和递归通配\n\n启用:\n\n```bash\nshopt -s globstar\nprintf '%s\\n' **/*.log\n```\n\n后,`**` 可以递归匹配目录层次。若要处理文件名中的空格,数组和双引号仍然必不可少:\n\n```bash\nshopt -s nullglob globstar\nfiles=( **/*.log )\n\nfor file in \"${files[@]}\"; do\n printf 'log: %s\\n' \"$file\"\ndone\n```\n\n`\"${files[@]}\"` 让每个数组元素保持一个参数;如果写成未引用的 `${files[@]}`,元素可能再次发生分词和通配。\n\n递归通配可能遍历非常大的目录树,也可能触碰不应访问的挂载点、符号链接目标或权限受限目录。生产脚本中通常要明确搜索根目录、文件类型和排除规则;需要复杂条件时,`find -print0` 往往比盲目使用 `**` 更可控。\n\n### 5.4 通配模式与正则表达式不是一回事\n\n```bash\n[[ $name == *.log ]]\n```\n\n这里使用的是 shell 模式,`*.log` 表示以 `.log` 结尾的字符串。\n\n而:\n\n```bash\n[[ $name =~ \\.log$ ]]\n```\n\n使用的是 Bash 的正则匹配运算符 `=~`。两者的语法、引用规则和捕获结果不同,不能混用。\n\n例如,shell 模式中的 `+` 通常只是普通字符;正则表达式中的 `+` 表示“一次或多次”。\n\n---\n\n## 六、数组:保留参数边界的数据结构\n\n### 6.1 数组解决什么问题\n\n字符串变量通常只保存一段文本,而命令参数需要的是“有边界的元素序列”。数组正是用来保存这种结构的:\n\n```bash\nfiles=(\n 'report 2025.txt'\n 'error.log'\n 'draft*.txt'\n)\n\nprintf '元素数量=%d\\n' \"${#files[@]}\"\n\nfor file in \"${files[@]}\"; do\n printf '\u003c%s>\\n' \"$file\"\ndone\n```\n\n输出中的 `report 2025.txt` 和 `draft*.txt` 都保持为单个元素。这里的 `*` 是数组数据,不会再次被通配展开,因为元素在 `\"${files[@]}\"` 中被引用。\n\n### 6.2 数组赋值与索引\n\n普通索引数组下标从 0 开始:\n\n```bash\nitems[0]='alpha'\nitems[2]='gamma'\n\nprintf '%s\\n' \"${items[0]}\"\nprintf '元素数量=%d\\n' \"${#items[@]}\"\n```\n\n索引 1 可以不存在。数组长度是存在的元素数量,不一定等于最大下标加一。\n\n追加元素:\n\n```bash\nitems+=('delta')\n```\n\n遍历时使用:\n\n```bash\nfor index in \"${!items[@]}\"; do\n printf 'index=%s value=%s\\n' \"$index\" \"${items[$index]}\"\ndone\n```\n\n`${!items[@]}` 展开为已存在的索引;这比假设索引连续更准确。\n\n关联数组需要 Bash:\n\n```bash\ndeclare -A user_id\nuser_id[alice]=1001\nuser_id[bob]=1002\n\nprintf '%s\\n' \"${user_id[alice]}\"\n```\n\n关联数组的键不是数字,遍历顺序不应被当作稳定排序。若需要确定顺序,应显式排序键或使用其他数据结构。\n\n### 6.3 `\"${array[@]}\"` 与 `\"${array[*]}\"`\n\n这是数组引用中最容易混淆的区别。\n\n假设:\n\n```bash\nargs=('alpha beta' 'gamma' '')\n```\n\n在双引号中:\n\n```bash\nprintf '\u003c%s>\\n' \"${args[@]}\"\n```\n\n展开为三个参数:\n\n```text\n\u003calpha beta>\n\u003cgamma>\n\u003c>\n```\n\n而:\n\n```bash\nprintf '\u003c%s>\\n' \"${args[*]}\"\n```\n\n会把所有元素连接成一个参数,连接符是 `IFS` 的第一个字符;默认通常是空格:\n\n```text\n\u003calpha beta gamma >\n```\n\n可以通过设置 `IFS` 观察连接方式:\n\n```bash\nIFS=,\nprintf '\u003c%s>\\n' \"${args[*]}\"\n```\n\n结果类似:\n\n```text\n\u003calpha beta,gamma,>\n```\n\n因此:\n\n- 传递数组元素给命令,通常用 `\"${array[@]}\"`;\n- 明确需要把数组连接成一段字符串时,才考虑 `\"${array[*]}\"`;\n- 未引用的 `${array[@]}` 和 `${array[*]}` 都可能触发分词和路径名展开,不应作为普通参数传递方式。\n\n### 6.4 `$@`、`$*` 与脚本参数\n\n脚本参数本身也是一种数组。假设脚本调用为:\n\n```bash\n./script.sh 'alpha beta' gamma\n```\n\n则:\n\n```bash\nprintf '数量=%d\\n' \"$#\"\n\nfor arg in \"$@\"; do\n printf '\u003c%s>\\n' \"$arg\"\ndone\n```\n\n输出:\n\n```text\n数量=2\n\u003calpha beta>\n\u003cgamma>\n```\n\n`\"$@\"` 会把每个位置参数作为独立参数传递,是包装其他命令时的标准形式:\n\n```bash\nrun_command() {\n command -- \"$@\"\n}\n```\n\n`\"$*\"` 则把所有位置参数合并成一个参数:\n\n```bash\nprintf '\u003c%s>\\n' \"$*\"\n```\n\n默认情况下结果类似:\n\n```text\n\u003calpha beta gamma>\n```\n\n如果函数需要接收并原样转发任意参数,应该使用:\n\n```bash\nsome_function() {\n printf '参数数量=%d\\n' \"$#\"\n command \"$@\"\n}\n```\n\n不要写成:\n\n```bash\ncommand $@\n```\n\n否则原本包含空格的参数会被重新拆分,参数中的通配符也可能重新展开。\n\n### 6.5 使用 `read -a` 解析输入\n\n`read -a array` 可以把一行按照 `IFS` 分割后写入数组:\n\n```bash\nline='alpha \"beta gamma\"'\nread -r -a fields \u003c\u003c\u003c \"$line\"\n\nprintf '数量=%d\\n' \"${#fields[@]}\"\nprintf '\u003c%s>\\n' \"${fields[@]}\"\n```\n\n这里不会像 shell 解析器那样完整理解引号;`read` 只是依据 `IFS` 进行分隔,结果通常不是用户期望的“解析 shell 命令行”。如果输入格式允许引号、转义、嵌套结构,不应把 `read -a` 当作通用解析器。\n\n---\n\n## 七、IFS:单词分割和 `read` 的分隔规则\n\n### 7.1 IFS 的定义\n\n`IFS` 是 Bash 用于**单词分割**和 `read` 分隔输入的变量,名称来自 Internal Field Separator。\n\n默认值通常是:\n\n```text\n空格、制表符、换行\n```\n\n可以检查当前值:\n\n```bash\nprintf '%q\\n' \"$IFS\"\n```\n\n但 `IFS` 是普通 shell 变量,可能被脚本、环境或函数修改;不要假定当前状态一定是默认值。\n\n### 7.2 未引用参数展开如何受 IFS 影响\n\n```bash\nvalue=' alpha beta '\nprintf '\u003c%s>\\n' $value\n```\n\n当 `IFS` 使用默认空白字符时,连续的 IFS 空白通常被视为分隔符,行首和行尾空白不会产生空字段。结果通常是:\n\n```text\n\u003calpha>\n\u003cbeta>\n```\n\n如果 `IFS` 含有非空白字符,例如逗号:\n\n```bash\nIFS=,\nvalue='alpha,,beta,'\nprintf '\u003c%s>\\n' $value\n```\n\n在未引用展开中,逗号可能产生空字段;但具体边界规则会受到 IFS 空白和非空白字符组合的影响。不能简单把 IFS 当成“一个正则分隔符”。\n\n更重要的是,修改全局 `IFS` 会影响后续所有未引用展开和 `read`:\n\n```bash\nIFS=,\nfor item in $value; do\n printf '\u003c%s>\\n' \"$item\"\ndone\n```\n\n因此解析局部数据时,应尽量缩小作用域:\n\n```bash\nwhile IFS=, read -r name id; do\n printf 'name=%s id=%s\\n' \"$name\" \"$id\"\ndone \u003c users.csv\n```\n\n这里 `IFS=,` 只作用于该次 `read` 命令,不会永久改变外层环境。\n\n### 7.3 `IFS` 不是安全的字符串解析器\n\n下面的代码经常被用来处理冒号分隔的值:\n\n```bash\nIFS=: read -r user uid gid \u003c\u003c\u003c \"$record\"\n```\n\n它适用于简单、无转义、字段中不允许冒号的格式。但如果字段本身可以包含分隔符、引号或转义,`IFS` 无法表达完整语法。\n\n例如 CSV 允许:\n\n```text\nAlice,\"Engineering, Platform\",active\n```\n\n直接用逗号作为 IFS 会错误地把 `Engineering, Platform` 拆成两个字段。此时应使用能够理解 CSV 语法的解析器,而不是继续堆叠 shell 展开技巧。\n\n### 7.4 `read` 的关键选项\n\n逐行读取推荐:\n\n```bash\nwhile IFS= read -r line; do\n printf '%s\\n' \"$line\"\ndone \u003c input.txt\n```\n\n选项含义:\n\n- `IFS=`:保留行首、行尾空白;\n- `-r`:保留反斜杠,不把 `\\n`、`\\t` 等当作转义;\n- `read` 默认读取到换行结束的一行;\n- 如果最后一行没有换行符,`read` 读取到内容后返回非零状态,因此完整循环常写为:\n\n```bash\nwhile IFS= read -r line || [[ -n $line ]]; do\n printf '\u003c%s>\\n' \"$line\"\ndone \u003c input.txt\n```\n\n如果确实需要按 NUL 字节读取:\n\n```bash\nwhile IFS= read -r -d '' file; do\n printf 'file=%s\\n' \"$file\"\ndone \u003c \u003c(find . -type f -print0)\n```\n\n文件名不能包含 NUL 字节,因此 `find -print0` 与 `read -d ''` 可以安全传递包含换行、空格和制表符的文件名。进程替换 `\u003c \u003c(...)` 是 Bash 语法,不是所有 `/bin/sh` 都支持。\n\n---\n\n## 八、赋值上下文、命令参数和测试上下文并不相同\n\n同一个展开表达式放在不同位置,行为可能不同。\n\n### 8.1 赋值通常不会进行普通分词\n\n```bash\nvalue=$var\n```\n\n即使 `$var` 含有空格,这也是一次赋值,不会因为空格生成多个命令参数。下面两种写法通常都能得到相同字符串:\n\n```bash\nvalue=$var\nvalue=\"$var\"\n```\n\n但加引号仍然更清晰,也避免读者误以为这里依赖特殊上下文:\n\n```bash\nvalue=\"$var\"\n```\n\n命令替换赋值同理:\n\n```bash\noutput=$(some_command)\n```\n\n不会因为输出中有空格而把赋值拆成多个词。\n\n### 8.2 算术上下文有自己的规则\n\n```bash\ncount=2\n(( count += 3 ))\nprintf '%d\\n' \"$count\"\n```\n\n算术表达式中的变量通常不需要 ` Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS - WR Blog
WR Blog 加载中...
返回文章
LinuxBashShell运维

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS封面

Linux 基础体系 · 第 43/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Bash 展开与引用深入:变量、命令替换、通配、数组和 IFS

Bash 脚本中大量“看起来像字符串”的内容,实际上会经历一套语法处理流程。变量可能被替换,命令输出可能被拼接,空白可能触发分词,通配符可能变成多个文件名;而引号会改变其中若干步骤是否发生。

许多危险并不是命令本身造成的,而是数据在展开过程中改变了形态。例如:

file='report 2025.txt'
rm $file

如果当前目录中存在 report2025.txt,实际执行的可能是:

rm report 2025.txt

正确写法通常是:

rm -- "$file"

这里的 "$file" 同时阻止了空白分词和通配符展开,-- 则防止以 - 开头的文件名被解释为选项。


一、先建立整体模型:Bash 如何把源码变成参数

以一条简单命令为例:

printf '<%s>\n' $value

Bash 并不是把 $value 替换成文本后直接执行。对普通命令参数而言,典型处理顺序可以概括为:

  1. 大括号展开{a,b}{1..3}
  2. 波浪号展开~~user
  3. 参数展开、命令替换、算术展开
    • $name
    • ${name:-default}
    • $(command)
    • $(( expression ))
  4. 单词分割:对未被引号保护的参数展开结果,依据 IFS 分割
  5. 路径名展开,也称通配展开*?[abc]
  6. 引号移除
  7. 将最终得到的一个或多个词作为命令参数执行

这是理解问题的核心模型,但它不是“所有 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.logerror.log 时,可能得到:

<app.log>
<error.log>

如果没有匹配项,默认情况下 Bash 通常保留字面量 *.log

<*.log>

nullglobfailglob 等选项可以改变这一行为,后文详述。

工程上,普通变量作为命令参数时应优先写成:

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}"

PORTabc 时,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 会:

  1. 启动命令或命令列表;
  2. 捕获其标准输出;
  3. 将输出中的尾随换行符删除;
  4. 把剩余内容作为展开结果放入当前位置;
  5. 继续进行后续上下文允许的分词和通配处理。

示例:

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 不能替代这种设计。errexitif 条件、while 条件、&&/|| 等上下文中有例外,复杂命令替换和管道还会受到 pipefail 等因素影响。

4.4 不要用命令替换模拟逐行读取

下面的写法经常被误用:

for line in $(cat input.txt); do
    echo "$line"
done

它的实际过程是:

  1. cat 输出文本;
  2. 尾随换行被删除;
  3. 未引用的命令替换结果按 IFS 分词;
  4. 可能进行通配展开;
  5. 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.txtdraft*.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 -print0read -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 指向的目标符合业务预期。


十一、端到端示例:安全处理文件名列表

下面的脚本演示三种不同数据流:

  1. 用通配生成文件数组;
  2. 用数组保持文件名边界;
  3. 用 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
  • globstarnullglobfailglob
  • 进程替换 < <(...)
  • Bash 特有参数展开。

如果脚本首行是:

#!/usr/bin/env bash

它应当由 Bash 执行。不要把 Bash 脚本交给:

sh script.sh

因为这会绕过脚本的 shebang,可能导致数组和 [[ ]] 等语法失败。发行版中 /bin/sh 可能链接到 dashbash 的兼容模式或其他实现,属于发行版差异,不能依赖其具体行为。


十三、把展开过程用于设计,而不是事后补救

处理一段数据时,可以先问三个问题:

数据应该是一个字符串,还是多个参数?

如果是多个参数,使用数组:

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 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论

0 条讨论
0/1000
还没有评论,来聊聊你的看法
:\n\n```bash\n(( count \u003c 10 ))\n```\n\n算术命令的退出状态有一个容易忽略的规则:表达式值为非零时返回 0,表达式值为零时返回 1。\n\n```bash\nif (( count )); then\n echo \"non-zero\"\nfi\n```\n\n这在 `set -e` 下尤其需要注意:\n\n```bash\n(( count-- ))\n```\n\n如果递减前 `count` 为 1,表达式返回值可能是 0,命令状态为 1;脚本可能因 `errexit` 退出。若只是想执行递减而不依赖表达式状态,可以明确写:\n\n```bash\n: $((count--))\n```\n\n或者使用赋值并检查业务条件。\n\n### 8.3 `[[ ... ]]` 不是普通命令参数解析\n\nBash 的条件命令:\n\n```bash\nif [[ $file == *.log ]]; then\n echo \"log file\"\nfi\n```\n\n在 `[[ ]]` 中,右侧的 `*.log` 是模式,不会按当前目录展开成文件名;这是它与:\n\n```bash\nprintf '%s\\n' *.log\n```\n\n的根本区别。\n\n在 `[[ ]]` 中,变量通常可以不加双引号:\n\n```bash\n[[ $value == \"$expected\" ]]\n```\n\n因为该上下文不会按普通命令参数那样进行单词分割和路径名展开。但为了表达清楚,模式位置和普通字符串位置仍应区分:\n\n```bash\n[[ $value == *.log ]] # 右侧是模式\n[[ $value == \"$pattern\" ]] # 变量内容通常按模式解释;具体需求需明确\n```\n\n如果希望右侧变量按字面字符串比较,可以使用:\n\n```bash\n[[ $value == \"$expected\" ]]\n```\n\n`[[ ]]` 是 Bash/Ksh 风格能力,不能直接移植到纯 POSIX `sh`。\n\n---\n\n## 九、空值、未设置值和参数边界\n\n空字符串不是“没有参数”。例如:\n\n```bash\nargs=('' 'x')\nprintf '数量=%d\\n' \"${#args[@]}\"\nprintf '\u003c%s>\\n' \"${args[@]}\"\n```\n\n输出包含一个空参数:\n\n```text\n数量=2\n\u003c>\n\u003cx>\n```\n\n这与数组为空不同:\n\n```bash\nempty=()\nprintf '数量=%d\\n' \"${#empty[@]}\"\n```\n\n变量未设置、设置为空字符串、设置为包含空格的字符串,必须分开考虑:\n\n```bash\nunset value\nprintf 'unset: \u003c%s>\\n' \"${value-unset}\"\n\nvalue=''\nprintf 'empty: \u003c%s>\\n' \"${value-unset}\"\n```\n\n输出:\n\n```text\nunset: \u003cunset>\nempty: \u003c>\n```\n\n如果代码需要保留“空参数”,必须使用引用和数组;把参数序列压缩成空格分隔字符串再恢复,通常会丢失边界信息。\n\n错误示例:\n\n```bash\nargs=('alpha beta' '' 'gamma')\njoined=\"${args[*]}\"\nset -- $joined\n```\n\n恢复后无法可靠区分:\n\n- 原来的 `alpha beta` 是一个元素还是两个元素;\n- 原来的空元素是否存在;\n- 元素中是否包含通配符;\n- 元素中是否包含 IFS 字符。\n\n这说明数组不是“字符串的另一种写法”,而是不同的数据结构。\n\n---\n\n## 十、常见失败表现与诊断方法\n\n### 10.1 用 `printf` 显示参数边界\n\n调试展开问题时,不要只写:\n\n```bash\necho $value\n```\n\n`echo` 可能解释选项和反斜杠,且无法清楚显示参数数量。推荐:\n\n```bash\nprintf 'argc=%d\\n' \"$#\"\nprintf 'arg=\u003c%s>\\n' \"$1\"\n```\n\n调试脚本参数:\n\n```bash\nfor arg in \"$@\"; do\n printf 'arg=%q\\n' \"$arg\"\ndone\n```\n\n`%q` 输出适合 Bash 重新读取的转义表示,能够显示空格、换行和特殊字符。\n\n例如:\n\n```bash\nvalue= alpha\\nbeta'\nprintf 'quoted=%q\\n' \"$value\"\n```\n\n可能输出:\n\n```text\nquoted= alpha\\nbeta'\n```\n\n### 10.2 使用 `declare -p` 检查变量类型和内容\n\n```bash\nvalue='a b'\nitems=('a b' 'c')\n\ndeclare -p value items\n```\n\n输出会明确显示普通变量和数组结构,例如:\n\n```text\ndeclare -- value=\"a b\"\ndeclare -a items=([0]=\"a b\" [1]=\"c\")\n```\n\n这比肉眼观察 `echo` 输出更可靠。\n\n### 10.3 用 `set -x` 观察执行轨迹,但不要泄露秘密\n\n```bash\nset -x\nprintf '\u003c%s>\\n' \"$value\"\nset +x\n```\n\n`xtrace` 可以帮助观察 Bash 经过部分展开后准备执行的命令,但它不是完整的解析器调试器,而且可能把密码、令牌和密钥写入日志。\n\n可以设置专用描述符和提示:\n\n```bash\nexport BASH_XTRACEFD=9\nexec 9>trace.log\nPS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]}: '\nset -x\n```\n\n生产环境开启前应确认日志权限和敏感数据脱敏策略。\n\n### 10.4 用 ShellCheck 发现明显的引用错误\n\nShellCheck 能识别很多常见问题,例如:\n\n```bash\nrm $file\nfor x in $(cat file); do\n ...\ndone\n```\n\n但静态检查工具不能替代对输入格式、命令语义和权限边界的理解。比如 `rm -- \"$file\"` 解决了参数边界,却不能证明 `$file` 指向的目标符合业务预期。\n\n---\n\n## 十一、端到端示例:安全处理文件名列表\n\n下面的脚本演示三种不同数据流:\n\n1. 用通配生成文件数组;\n2. 用数组保持文件名边界;\n3. 用 NUL 分隔从 `find` 读取任意合法文件名。\n\n### 11.1 当前目录下处理 `.log` 文件\n\n```bash\n#!/usr/bin/env bash\n\nset -u\n\nshopt -s nullglob\nfiles=( ./*.log )\nshopt -u nullglob\n\nif ((${#files[@]} == 0)); then\n printf '没有匹配的 .log 文件\\n'\n exit 0\nfi\n\nfor file in \"${files[@]}\"; do\n printf '文件: %q\\n' \"$file\"\ndone\n```\n\n每一步成立的原因:\n\n- `./*.log` 明确限制搜索当前目录,并让结果带有 `./` 前缀;\n- `nullglob` 使无匹配时数组为空;\n- `files=( ... )` 将通配结果保存为数组元素;\n- `\"${files[@]}\"` 逐元素展开;\n- `printf '%q'` 只用于展示,不改变实际变量内容。\n\n如果后续要删除:\n\n```bash\nfor file in \"${files[@]}\"; do\n rm -- \"$file\"\ndone\n```\n\n仍然应在删除前确认目录、权限、符号链接行为和匹配集合。脚本运行用户拥有的文件可能包含重要数据,`rm` 没有通用撤销机制。\n\n### 11.2 递归读取任意文件名\n\n```bash\n#!/usr/bin/env bash\n\nset -u\n\nwhile IFS= read -r -d '' file; do\n printf '找到: %q\\n' \"$file\"\ndone \u003c \u003c(find /var/tmp/my-app -type f -name '*.log' -print0)\n```\n\n数据流如下:\n\n```text\nfind 标准输出\n └── NUL 分隔的路径名\n └── 进程替换提供文件描述符\n └── read -d '' 每次读取一个路径\n └── \"$file\" 作为单个参数使用\n```\n\n这里不会因路径中的空格、制表符或换行而拆分。文件名不能包含 NUL,所以 NUL 是 Unix 文件名处理中常用的安全记录分隔符。\n\n但这不意味着所有故障都消失了:\n\n- `find` 可能因权限不足而跳过目录;\n- 目录内容可能在遍历期间变化;\n- 符号链接是否跟随取决于 `find` 选项;\n- 进程替换中的 `find` 失败不会天然等同于 `while` 循环失败;\n- 如果要求严格检测 `find` 的退出状态,应采用更明确的临时文件、管道状态设计或在 Bash 中单独记录状态。\n\n---\n\n## 十二、生产边界:Shell 展开能做什么,不能做什么\n\n### 12.1 Shell 展开适合参数组织,不适合复杂数据序列化\n\n数组适合在 Bash 进程内部传递参数:\n\n```bash\ncmd=(curl --fail --silent --show-error --url \"$url\")\n\"${cmd[@]}\"\n```\n\n这比把命令拼接成字符串再执行安全:\n\n```bash\ncmd=\"curl --fail $url\"\n$cmd\n```\n\n后者会重新经历分词和通配展开,且如果进一步使用 `eval \"$cmd\"`,还会引入第二次 shell 解析,可能执行变量内容中的命令替换、重定向或命令分隔符。\n\n除非明确知道需要二次解析,否则不要使用 `eval` 拼接命令。\n\n### 12.2 外部输入不能直接作为选项和代码\n\n即使已经引用:\n\n```bash\ngrep \"$pattern\" \"$file\"\n```\n\n如果 `pattern='--include=*'`,它是否被解释为选项取决于命令参数位置和命令自身规则。对文件名这类可能以短横线开头的数据,使用:\n\n```bash\ncommand -- \"$file\"\n```\n\n对选项和值,应根据具体命令采用显式选项形式:\n\n```bash\ngrep -- \"$pattern\" \"$file\"\n```\n\n但不是所有命令都支持 `--`,应查看对应命令的手册页。引用只控制 Bash 的展开,不会改变外部程序自己的选项解析规则。\n\n### 12.3 Shell 变量不能表示 NUL\n\nBash 变量和命令替换结果不能保存 NUL 字节。需要传输任意文件名时,使用文件描述符、NUL 分隔流或直接的系统调用接口,而不要试图把 NUL 塞进变量。\n\n这也是为什么:\n\n```bash\nfind ... -print0\n```\n\n通常与:\n\n```bash\nread -r -d ''\n```\n\n配套,而不是先放入一个普通变量。\n\n### 12.4 Bash 与 `/bin/sh` 的边界\n\n以下能力不是 POSIX `sh` 的通用能力:\n\n- 数组;\n- 关联数组;\n- `[[ ... ]]`;\n- `mapfile`/`readarray`;\n- `shopt`;\n- `globstar`、`nullglob`、`failglob`;\n- 进程替换 `\u003c \u003c(...)`;\n- Bash 特有参数展开。\n\n如果脚本首行是:\n\n```bash\n#!/usr/bin/env bash\n```\n\n它应当由 Bash 执行。不要把 Bash 脚本交给:\n\n```bash\nsh script.sh\n```\n\n因为这会绕过脚本的 shebang,可能导致数组和 `[[ ]]` 等语法失败。发行版中 `/bin/sh` 可能链接到 `dash`、`bash` 的兼容模式或其他实现,属于发行版差异,不能依赖其具体行为。\n\n---\n\n## 十三、把展开过程用于设计,而不是事后补救\n\n处理一段数据时,可以先问三个问题:\n\n### 数据应该是一个字符串,还是多个参数?\n\n如果是多个参数,使用数组:\n\n```bash\noptions=(--color=auto '--exclude=*.tmp')\ncommand \"${options[@]}\" -- \"$file\"\n```\n\n如果使用字符串拼接:\n\n```bash\noptions=\"--color=auto --exclude=*.tmp\"\ncommand $options \"$file\"\n```\n\n就把参数边界交给 IFS 和通配规则处理,极易产生歧义。\n\n### 数据应该按行、按 NUL,还是按某种格式解析?\n\n- 普通逐行文本:`while IFS= read -r line`\n- 任意文件名列表:NUL 分隔\n- CSV、JSON、带引用规则的配置:使用对应格式解析器\n- 简单的无转义分隔字段:可以局部使用 `IFS=... read`\n\n不要因为 `IFS` 能拆字符串,就把它当成通用语法解析器。\n\n### 数据是在 Bash 中匹配模式,还是交给外部工具匹配?\n\n- 文件名集合:路径名展开;\n- 字符串后缀判断:`[[ $x == *.log ]]`;\n- 正则需求:`[[ $x =~ regex ]]`;\n- 复杂递归搜索:`find`;\n- 大规模文本处理:专用工具。\n\n明确匹配层次,可以避免把“shell 模式”“正则表达式”“文件系统遍历”混为一谈。\n\n---\n\n## 十四、核心规则总结\n\nBash 展开的关键不在于记住某一条“变量必须加引号”的口号,而在于掌握数据边界何时会丢失:\n\n```bash\n\"$value\" # 一个参数,保留空格和通配符\n$value # 可能分词,之后可能通配\n\"${array[@]}\" # 每个元素一个参数\n\"${array[*]}\" # 所有元素合成一个参数\n\"$@\" # 每个位置参数一个参数\n\"$*\" # 所有位置参数合成一个参数\n$(command) # 捕获输出并删除尾随换行\nIFS= read -r line # 保留一行的原始空白和反斜杠\n```\n\n可以把安全的参数传递抽象为:\n\n```text\n结构化数据\n → 保持元素边界\n → 在最终命令调用处逐元素引用\n → 对可能被解释为选项的路径使用 --\n```\n\n反过来,以下链条会不断丢失边界:\n\n```text\n数组\n → 拼成字符串\n → 命令替换\n → 未引用展开\n → IFS 分词\n → 通配展开\n → 重新传给命令\n```\n\n当脚本涉及用户输入、文件名、临时目录、批量删除、权限敏感路径或外部命令时,必须逐步确认每一次展开后的实际参数。Bash 不会替工程师推断“这个空格属于数据”还是“这个空格是参数分隔符”;引号、数组、明确的读取协议和及时的状态检查,才是表达这种意图的工具。\n\n---\n\n## 系列导航与关联阅读\n\n- 系列入口:[Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维](https://wrblog.cn/articles/783307a4-e8a6-5d5a-9d9a-87ed9f345130)\n- 上一篇:[Linux 文本处理:grep、sed、awk、cut、sort、uniq 和正则](https://wrblog.cn/articles/2ec2d0d4-67b4-5b6a-a46d-1f29c4b66b50)\n- 下一篇:[Bash 函数与 Trap:退出码、错误传播、临时资源和信号清理](https://wrblog.cn/articles/8a3a0c44-fc07-5a18-9c96-f5bf07d3b441)\n- 延伸:[Bash 与 Shell 工程实践:展开、引用、管道、错误处理和脚本测试](https://wrblog.cn/articles/77aa8821-9ea8-5082-ba9c-b3df7987cbce)\n\n## 官方资料\n\n- [Linux man-pages](https://www.kernel.org/doc/man-pages/)\n- [Debian Administrator's Handbook](https://www.debian.org/doc/manuals/debian-handbook/)\n\n> 本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。\n","tags":["Linux","Bash","Shell","运维"],"likeCount":0,"commentCount":0,"createdByUserId":"10000000000","createdByDisplayName":"小郝","createdByAvatar":"/public/profile/10000000000/avatar/2026/08/04/db02b81c-42f2-441b-8a80-61370cdbb581.webp","publishTime":"2026-09-01 13:56:19","updateTime":"2026-09-01 13:56:19"}},"status":200,"locale":"zh-CN","theme":"light"}