我们在终端里看到的是一行文字,命令收到的却是一组已经分好边界的参数。中间那段工作由 Shell 完成:识别语法、执行扩展、决定一个词会变成零个、一个还是多个参数,然后才启动命令。
这也是许多“同一条命令有时能用、有时失控”的根源。变量里多了一个空格,未引用的 * 恰好匹配到文件,命令替换返回了多行内容,最终参数列表就可能完全不同。最稳的学习方法不是背下每种符号,而是始终追问两件事:当前字符由谁解释,命令最终收到几个参数?
下面会把整个过程拆开,并在每个容易混淆的节点用参数计数、目录实验和失败分支验证。示例以 Bash 为主;属于 Bash 扩展或需要显式开启的选项,会直接标出来。
Shell 不是把整行字符串直接交给程序。它先读取输入,把字符识别成词和操作符,再按语法组成命令结构。随后才对相应的词执行扩展与重定向,最后把命令名和参数向量交给内建命令或外部程序。

图:引号从解析阶段就影响边界,最后的“引号移除”只负责去掉语法字符。
先看一条看似普通的命令:
printf '<%s>\n' "$HOME" *.txtShell 首先知道 printf 是命令词,'<%s>\n'、"$HOME"、*.txt 是三个参数词。单引号和双引号在这个阶段已经发挥作用:单引号让格式串里的字符保持字面意义,双引号允许 $HOME 展开但保存它的参数边界。随后,*.txt 才进入文件名展开。
假设目录里有 a.txt 和 季度 报告.txt,命令最终收到的结构近似于:
argv[0] = "printf"
argv[1] = "<%s>\n"
argv[2] = "/home/learner"
argv[3] = "a.txt"
argv[4] = "季度 报告.txt"注意最后一个文件名。虽然它含有空格,但它是路径名展开产生的一个完整结果,不会再被拆成两个参数。Shell 的阶段顺序会影响结论,不能只看最终文字里有没有空格。
对普通命令词,可以用下面的顺序建立心智模型:
file-{A,B},它是 Bash 的文本生成扩展。<(...) 与 >(...)。*、?、[...] 模式替换为匹配路径。这里有一个常见误解:“引号移除最后发生,所以引号此前没有作用。”实际正相反。引号在分词与解析时已经标记了哪些字符受保护,决定哪些扩展可以发生、哪些结果不能继续分割;最后只是把不应传给命令的语法引号去掉。
通常只有大括号展开、词语分割和文件名展开会增加词的数量。"$@" 是重要例外:它处于双引号内,却能按原边界产生多个参数。启用 nullglob 后,无匹配模式还可能从一个词变成零个词。
引用(quoting)的目标是移除某些字符对 Shell 的特殊意义。它不是为了让命令“更安全地显示文本”,而是明确告诉 Shell:哪些字符仍参与扩展,哪些字符只按字面传递,以及哪些空格属于同一个参数。

图:四种引用方式不是强弱等级,而是针对不同需求的工具。
单引号最彻底。两个单引号之间的每个字符都保持字面值:变量不展开、命令不执行、通配符不匹配,空格也留在同一个参数中。
name='小虎'
printf '%s\n' '$name *.txt $(date)'
#输出:$name *.txt $(date)单引号内部不能直接出现单引号,反斜杠在里面也救不了它。需要输出 don't 时,可以结束单引号、在外部转义一个单引号,再重新开始:
printf '%s\n' 'don'\''t'双引号保存整体参数边界,但仍允许参数展开、命令替换和算术展开:
name='季度 报告'
printf '<%s>\n' "$name" "$(printf '已生成\n')" "$((6 * 7))"这里是三个参数:季度 报告、已生成、42。双引号内的 * 不做文件名展开。反斜杠则只保护紧随其后的一个字符,例如 \$name 产生字面量 $name。在双引号里,反斜杠只会在 $、反引号、"、\ 或换行等特定字符之前保留转义意义;它不是无条件删除。
Bash 支持 $'...' 形式的 ANSI-C 引用。它先解释常见反斜杠转义,再把结果当作一个受引用的词:
printf '%s' $'第一行\n第二行\t制表\n'这种写法适合表达换行、制表符、反斜杠或 Unicode 转义。它与普通单引号不同:'\n' 是两个字面字符,$'\n' 是一个换行字符。变量仍不会在 $'...' 内展开,因此下面输出的是 $name,后面跟一个换行:
printf '%s' $'$name\n'在以 Bash 为解释器的脚本里可以明确使用它。若脚本要运行在更旧或能力不确定的 sh 环境,应先核对目标 Shell;固定文本也可以直接交给 printf 的格式串处理。
“变量要加双引号”不是格式偏好,而是参数边界规则。变量存的是一段字符串,不存 Shell 参数列表。把它未引用地放回命令行,相当于允许 Shell 根据 IFS 再切一次,并把切出的每一段继续当作 glob 检查。

图:看参数数量比看一行合并输出更可靠。
用一个专门打印参数数量的函数观察边界:
inspect() {
printf 'argc=%d\n' "$#"
local arg
for arg in "$@"; do
printf '<%s>\n' "$arg"
done
}
value='alpha beta gamma'
inspect $value
inspect "$value"输出是:
argc=3
<alpha>
<beta>
<gamma>
argc=1
<alpha beta gamma>第一条的参数展开结果未被双引号保护,于是默认 IFS 空白触发分割,连续空白边界也被折叠。第二条保留原字符串,包括两个连续空格。
一般规则很实用:当变量表示“一个文件名、一个用户输入、一个消息、一个 URL”时,使用 "$var"。如果你真正需要一组参数,不要把它们塞进一个空格分隔字符串再依赖未引用展开;使用 Bash 数组,并以 "${args[@]}" 展开。
"$@" 与 "$*" 不是同义写法脚本接收三个参数后,"$@" 会展开为三个独立的受引用参数,效果等价于 "$1" "$2" "$3"。空参数也能保留;如果没有位置参数,"$@" 产生零个参数。
"$*" 则只产生一个参数,把全部位置参数用 IFS 的第一个字符连接。默认情况下看起来像用空格拼接:
set -- '甲 乙' '' '*.txt'
inspect "$@" # argc=3
inspect "$*" # argc=1未引用的 $@ 和 $* 还会经历词语分割与文件名展开。实验目录里若有 .txt 文件,第三个位置参数 *.txt 甚至会变成真实路径;空参数也会消失。这种行为依赖目录状态,不适合做参数透传。
一个包装脚本转交参数时,稳定写法是:
target_command --fixed-option "$@"这三种扩展解决的问题不同:大括号批量生成文本,波浪号缩写目录位置,算术展开计算整数。把它们都叫“展开”不代表它们遵循同一种触发规则。

图:大括号不看文件,波浪号只认未引用词首,算术只处理整数。
大括号展开是 Bash 扩展,不是可移植 sh 的共同能力。它可以列举选项或生成序列:
printf '<%s>\n' report-{A,B}{01..03}.txt它按从左到右的顺序产生六段文本:report-A01.txt 到 report-B03.txt。这些文件不需要存在。结果随后还能进入其他扩展,例如:
printf '<%s>\n' log-{app,api}-*.txtShell 先得到 log-app-*.txt 与 log-api-*.txt,再对它们做文件名展开。一个正确的大括号表达式需要未引用的左右大括号,并包含未引用逗号或有效序列。把整个表达式放进双引号会阻止大括号展开。
大括号适合批量创建规则清楚的目录,但执行具有副作用的命令前,先用 printf '<%s>\n' ... 预览生成结果,能及时发现范围或前缀写错。
未引用的 ~ 位于词首时,Bash 把它当作波浪号前缀。常用形式包括:
引用会阻止它:"~/work" 不是家目录路径,而是以字面 ~ 开头的相对路径。波浪号也不能随意放在词中间,/tmp/~/file 不会展开。
一个稳定习惯是:需要在家目录后拼接变量时,让 ~ 自己处于可展开位置,并引用变量部分;或直接使用 "$HOME/path"。$HOME 是参数展开,所以要靠双引号保护包含空格等字符的值。
$(( expression )) 计算算术表达式,并用结果替换原结构:
count=7
printf '下一批编号:%d\n' "$((count + 1))"
printf '整数除法:%d\n' "$((5 / 2))"输出为 8 和 2。Bash Shell 算术以整数为基础,不提供小数结果。表达式里可以直接使用变量名,也支持常见加减乘除、余数、比较、位运算和括号;无效表达式会报告展开错误,相关命令不会正常执行。
算术展开适合计数器、索引和简单整数判断。涉及金额精度、大数或浮点计算时,应选择明确支持所需数值模型的工具,不要假设 Bash 会自动保留小数。
花括号形式 ${parameter} 不只是为了好看。它能明确变量名边界,也提供默认值、赋值、前置条件、长度、前后缀删除与替换。许多短小的数据整理可以直接完成,不必额外启动进程。

图:先判断“未设置、空、非空”,再选择是否使用 word。
:-、:=、:?、:+ 的分工先区分三种状态:变量未设置、变量已设置但值为空、变量已设置且非空。操作符里的冒号决定是否把“空”也当作缺值:
去掉冒号后,例如 ${var-word},只检查“未设置”;已经设置为空的变量不会采用 word。这个差异适合区分“用户明确给了空值”和“用户根本没提供”。
实际脚本中常见三种意图:
#给显示用的值一个回退,但不修改原变量
label=${LABEL:-未命名}
#初始化一次,后续继续使用
: "${CACHE_DIR:=/tmp/cache}"
#缺少关键配置就立刻停止当前命令
: "${API_ENDPOINT:?API_ENDPOINT 不能为空}": 是一个什么也不做但仍会展开参数的内建命令,因此适合承载赋值或校验。word 只有在需要采用时才展开,这也意味着其中若包含命令替换,不一定每次都会执行。
${#var} 返回值的字符长度。${var#pattern} 和 ${var##pattern} 分别删除最短、最长匹配前缀;% 与 %% 对后缀做同样的事。这里的 pattern 使用 Shell 模式,不是正则表达式:
path='/srv/archive/report.tar.gz'
printf '长度:%s\n' "${#path}"
printf '文件名:%s\n' "${path##*/}"
printf '去一层后缀:%s\n' "${path%.*}"
printf '去全部后缀:%s\n' "${path%%.*}"结果依次为 26、report.tar.gz、/srv/archive/report.tar 和 /srv/archive/report。单个 #/% 选择最短匹配,双写选择最长匹配。
Bash 还支持模式替换:${var/pattern/replacement} 替换第一个匹配,${var//pattern/replacement} 替换全部匹配。例如:
route='/api/v1/users'
printf '%s\n' "${route//\//_}"
#输出:_api_v1_users替换形式应标为 Bash 语境能力。所有这些展开的结果作为一个数据值使用时仍建议放在双引号里。参数展开能省去简单的外部文本处理,但复杂解析、结构化数据或不受信任的语法不应硬塞进一行 ${...}。
命令替换要的是一段标准输出文本,进程替换要的是一个可以当作路径使用的流端点。二者语法相似,却不能互换。

图:$(...) 把输出放进当前词,<(...) 把异步进程的输出暴露成可读路径。
$(...) 捕获标准输出并删除尾随换行命令替换的标准形式是 $(command):
kernel=$(uname -r)
printf '内核版本:%s\n' "$kernel"Bash 在子 Shell 环境中执行括号内命令,用它的标准输出替换整个结构。所有尾随换行都会删除,内部换行保留。 例如:
value=$(printf 'A\nB\n\n')
printf '%q\n' "$value"
#输出:$'A\nB'如果写成未引用的 $value 或 $(...),保留下来的内部换行和空格还可能进入词语分割,随后文本中的通配符也可能 glob。把命令输出当作一个值时,应写 "$(command)" 或在赋值后使用 "$value"。
$(...) 可以自然嵌套:
printf '%s\n' "目录:$(dirname "$(command -v bash)")"传统反引号形式 `command` 仍可能在旧脚本中出现,但嵌套需要额外转义,括号边界也不醒目。新代码优先 $(...),可读性和组合性更好。
命令替换不适合承载任意文件名列表。文件名可以包含空格和换行,而一段字符串无法可靠保存参数边界。需要遍历 glob 结果时使用数组;处理 find 结果时使用空字符分隔、-exec 或能理解对应协议的工具。
<(...) 和 >(...) 提供流的路径进程替换是 Bash 扩展,并依赖系统提供命名管道或 /dev/fd。<(list) 让另一个命令可以通过路径读取 list 的输出:
diff <(sort before.txt) <(sort after.txt)diff 得到两个类似 /dev/fd/63 的路径参数,不是两段直接塞进参数的文本。>(list) 方向相反:向这个路径写入的数据会成为 list 的输入。
左尖括号与左括号之间不能有空格;< (...) 会被解析成别的语法。进程列表异步运行,错误状态、生命周期和消费者何时读完都需要考虑。它在 Bash 中很方便,但用 dash 等不支持的 Shell 解析会直接报语法错误,因此脚本的 shebang 与语法必须一致。
参数展开把变量值放进命令行后,工作可能还没结束。若结果不在双引号内,Bash 会先按 IFS 分割,再把每个字段检查为路径名模式。两步串联,才是未引用变量最难预测的部分。

图:变量值本身不是参数数组;分割与 glob 会根据环境重建参数。
默认 IFS 包含空格、制表符和换行。Bash 会扫描未处于双引号内的参数展开、命令替换和算术展开结果,并按 IFS 形成字段。源码中普通的未引用空格早在词法分析时就已经分开命令词,不属于这一阶段。
data=' one two three '
inspect $data
#输出:one / two / three,共 3 个参数
inspect "$data"
#输出:保留首尾和连续空格,共 1 个参数改变 IFS 可以解析非常简单、规则明确的分隔文本,但它不是通用 CSV 解析器,处理不了引号嵌套、转义和字段内分隔符。更不能通过 IFS 把一段不可信文本变成“安全命令”。如果数据本来就是多个值,数组比拼接字符串更能保留边界:
files=('alpha.txt' '季度 报告.txt' '*.literal')
inspect "${files[@]}"*、?、[...] 匹配路径名词语分割之后,Bash 检查未引用的 *、? 和 [:
Shell 负责把模式替换成排序后的路径列表,命令通常看不到原始 *.txt。若希望模式由下游命令解释,例如 find . -name '*.txt',就要引用它,避免外层 Shell 抢先展开。
以点开头的名称默认有额外规则:模式开头没有显式 . 时,* 不匹配 .env 这类点文件。斜杠也必须由模式里的斜杠显式匹配。不要用 *.* 表示“全部文件”;它只匹配名称中含点的项目,还会漏掉许多没有扩展名的文件。
Bash 默认在模式没有匹配时保留字面模式:
printf '<%s>\n' no-such-*.txt
#输出:<no-such-*.txt>这不是“找到一个同名文件”,而是没有发生替换。Bash 提供几项必须显式开启的 shopt 选项:
这些选项改变的是当前 Shell 状态。脚本若依赖它们,应在明确作用域中设置,并在复用当前 Shell 时恢复原状态。failglob 适合“必须匹配到文件,否则停止”;nullglob 适合确实允许空集合的循环,但要检查命令无参数时是否会改变含义。
方括号范围还受 locale 的排序规则影响。[a-z] 在不同 locale 下可能纳入不同排序位置的字符。需要可复现的 ASCII 范围时,可以只对相关命令设置 LC_ALL=C,或使用合适的 POSIX 字符类表达意图。
printfecho 很适合交互时快速看一眼普通文本,但不适合稳定打印未知变量。不同实现对首参数 -n、-e 和反斜杠转义有历史差异,Shell 内建、外部程序或别名还可能不是同一个实现。

图:固定格式与数据参数分离,才能让内容不被误认成选项或格式。
下面这段在 Bash 中不会输出变量里的 -n,连换行也会被当作选项抑制:
value='-n'
echo "$value"更稳定的写法是固定格式串,把数据放在后续参数:
printf '%s\n' "$value"
printf 'name=%s count=%d\n' "$name" "$count"不要反过来写 printf "$value"。若变量里含 %s、%n 或反斜杠,它会被当作格式控制。稳定模式是 printf '固定格式' "数据"。
printf 还适合观察参数边界:
printf '<%s>\n' "$@"有位置参数时,格式串会按需重复使用,因此每个参数单独一行。相比 echo "$@" 把所有内容用空格显示,前者更容易看出空格属于哪个值。不过,零个位置参数时,printf 仍会执行一次格式,并把缺失的 %s 参数当作空字符串;需要精确区分零参数时,应同时打印 $# 或使用 for arg in "$@" 循环。调试不可见字符时,Bash 内建 printf '%q\n' "$value" 可生成可复用的引用形式;它是 Bash 能力,不应当作通用 sh 格式。
下面的实验只在一次性 Debian 容器的 /tmp/welearn-ch07 中创建文件。它用 inspect 打印真实参数数量,依次验证引用、$@/$*、glob 选项和命令替换,最后显式删除目录;容器使用 --rm,退出后也不会保留容器层。
docker run --rm -i --name welearn-linux-ch07-lab \
debian:bookworm-slim bash -s <<'LAB'
set -u
LAB=/tmp/welearn-ch07
rm -rf "$LAB"
mkdir -p "$LAB/files/nested/deep"
cd "$LAB/files"
touch alpha.txt 'two words.txt' beta.log .secret.txt nested/deep/inside.txt
inspect() {
printf 'argc=%d\n' "$#"
local i=1 arg
for arg in "$@"; do
printf 'arg%d=<%s>\n' "$i" "$arg"
i=$((i + 1))
done
}
value='alpha beta gamma'
printf '%s\n' '--- $value ---'
inspect $value
printf '%s\n' '--- "$value" ---'
inspect "$value"
关键输出如下:
--- $value ---
argc=3
arg1=<alpha>
arg2=<beta>
arg3=<gamma>
--- "$value" ---
argc=1
arg1=<alpha beta gamma>
--- "$@" ---
argc=3
arg1=<甲 乙>
arg2=<>
arg3=<*.txt>
--- "$*" ---
argc=1
arg1=<甲 乙 *.txt>这里故意把空参数放在第二位。"$@" 的输出证明它没有消失,通配符也保持为第三个参数的字面内容;"$*" 则已经把三段边界压成一个字符串。
把下面内容接在同一个 here-document 里:
printf '%s\n' '--- glob 默认行为 ---'
printf '<%s>\n' *.txt .*.txt no-match-*.txt
shopt -s nullglob
none=(no-match-*.txt)
printf 'nullglob_count=%d\n' "${#none[@]}"
shopt -u nullglob
( shopt -s failglob; printf '%s\n' no-match-
实测结果中的关键行是:
<alpha.txt>
<two words.txt>
<.secret.txt>
<no-match-*.txt>
nullglob_count=0
bash: no match: no-match-*.txt
failglob_status=1
captured=$'A\nB'
cleanup=oktwo words.txt 整体出现在一对尖括号中,说明 glob 产生的含空格路径仍是一个参数。默认无匹配模式保持字面量;nullglob 变成零项;failglob 报错且状态为非零。命令替换删掉两个尾随换行,却保留 A 与 B 之间的内部换行。
失败边界也要看清:failglob 在命令执行前失败,因此不能指望命令本身处理这个模式;开启 nullglob 后,某些命令会从“处理指定文件”变成“无参数运行”,必须先检查数组长度。实验末尾先离开工作目录,再删除整个 /tmp/welearn-ch07,cleanup=ok 是显式复查证据。
扩展问题最容易被 echo 的合并显示掩盖。遇到“文件名有空格就失败”“模式有时变成路径”“参数数量不对”,按下面顺序检查:
sh script.sh 中期待 Bash 的大括号、数组、进程替换或 shopt。$var、$(...)、$@ 判断它是否位于双引号内;对 * ? [ 判断模式应该由外层 Shell 还是下游命令解释。inspect、printf '<%s>\n' "$@" 或 Bash 的 printf '%q\n',不要只看一行空格拼接结果。shopt -p nullglob failglob dotglob globstar 查看选项,用 printf '<%q>\n' "$IFS" 检查分隔符,并记录 locale。eval 让变量内容重新变成 Shell 语法;参数列表用数组,文件名列表用 glob、空字符协议或 -exec,普通文本始终作为受引用数据传递。最后把常用默认规则压缩成四句:一个值用 "$var",一组位置参数用 "$@",一个 Bash 数组用 "${array[@]}",稳定输出用固定格式的 printf。 只有在明确需要词语分割或路径名展开时,才有意识地放开引用,并同时处理零匹配与多匹配。