SSD Nodes Learn 🎉 VPS $4.99/月起
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-07

Bash命令替换:$()与反引号有什么区别

了解 Bash 命令替换为何在子 Shell 中运行,导致 cd 和变量修改消失;比较 $() 与反引号的嵌套和反斜杠陷阱,并掌握保持当前 Shell 状态的进程替换技巧。

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 4, 2026.

Bash 命令替换的作用

Bash 命令替换会将 $(command) 替换为该命令写入标准输出的文本。使用反引号的旧写法具有相同作用。所有令人意外的行为都源于两个事实:命令会在称为子 Shell 的独立进程中运行,并且其输出末尾的每个换行符都会被删除。

mkdir -p /tmp/subst-demo
cd /tmp/subst-demo
printf 'alpha\nbeta\ngamma\n' > three.txt
count=$(wc -l < three.txt)
echo "$count"
3

这就是该功能的全部行为。wc -l 输出了 3,后面跟着一个换行符;该换行符被删除,count 中就保存了您需要的两个字符。请注意,wc -l < three.txt 只输出一个数字,因为读取标准输入的 GNU wc 没有要输出的文件名。改用 wc -l three.txt,捕获到的就是 3 three.txt。这是另一个字符串,也是后续算术运算失败的常见原因。

本指南接下来介绍的是没人预料到的行为,因为子 Shell 是独立进程,而独立进程无法修改您正在输入命令的那个 Shell。

使用 $(),停止使用反引号

两种形式都有效。$() 属于 POSIX,因此 dashashbusybox sh 都支持它。现在已经没有可移植性理由继续使用反引号,而且有两个具体理由不应使用反引号。

反引号不支持嵌套

echo "$(echo "$(echo hi)")"
echo "`echo `echo hi``"
hi
echo hi

第二行输出了字面量 `echo hi。Shell 会向后扫描下一个未转义的反引号,因此你输入的第二个反引号关闭了第一个反引号。实际执行的命令是 echo,且没有参数;它输出了一个空行,随后该换行符被去除,结果变成空内容。echo hi` 作为普通文本被保留下来,最后一对反引号则执行了一个空命令。

要嵌套反引号,必须转义每个内部反引号:

echo "`echo \`echo hi\``"
hi

每增加一层嵌套,所需的转义数量都会再次翻倍。$() 不需要这些处理,因为解析器匹配的是括号,而不是扫描分隔符字符。

反引号会在命令运行前改变反斜杠

echo "$(echo 'a\\b')"
echo "`echo 'a\\b'`"
a\\b
a\b

相同的内部命令产生了不同的输出。在反引号中,Shell 会在解析内部文本之前先移除一层反斜杠转义,因此单引号没有起到保护作用。在 $() 中,括号之间的文本会作为普通脚本解析,因此单引号的行为符合预期。在 sedawk 这类单行命令中,这个问题尤其明显:丢失一个反斜杠,就可能让原本有效的模式悄然变成另一个模式。

$() 内部会重新开始处理引用,因此可以嵌套双引号而无需转义:

path=/etc/nginx/nginx.conf
echo "$(dirname "$path")"
/etc/nginx

使用反引号时,必须在 $path 外部加上 \"。每一次转义都可能导致错误。

为什么在 $() 中执行 cd 不会改变当前 shell

因为 $(...) 会创建一个新进程。子 shell 会获得变量副本和当前工作目录副本。它修改自己的副本,输出内容,然后退出。这个副本也会随之销毁。

cd /tmp/subst-demo
pwd
target=$(cd /etc && pwd)
echo "$target"
pwd
/tmp/subst-demo
/etc
/tmp/subst-demo

没有任何操作失败。cd 执行成功,子 shell 中的 pwd 也确实输出了 /etc。只是这个更改没有返回路径,因为子 shell 只能通过标准输出和退出状态向父 shell 传递信息。

变量赋值的行为也是如此:

count=0
msg=$(count=99; echo "inside: $count")
echo "$msg"
echo "outside: $count"
inside: 99
outside: 0

同一规则也解释了另一种更常见的此类问题:问题出现在管道中,而不是命令替换中:

n=0
cat three.txt | while read -r line; do n=$((n+1)); done
echo "$n"
0

管道的每个阶段都在自己的子 shell 中运行,因此 while 循环只增加了 n 副本的值,然后退出。将管道替换为重定向后,循环会在当前 shell 中运行:

n=0
while read -r line; do n=$((n+1)); done < three.txt
echo "$n"
3

Bash 可以通过 shopt -s lastpipe 让管道的最后一个阶段在当前 shell 中运行,但仅限于关闭作业控制的情况;交互式 shell 永远不满足这一条件。请使用重定向。

命令替换中的交互式命令:提示符去哪了?

命令替换会将标准输出重定向到管道,但保持标准输入不变。某个程序如果将问题输出到标准输出,然后等待回答,消失的是问题,而不是等待过程。终端看起来像是卡住了。

ask() { printf 'Username: '; read -r u; printf '%s\n' "$u"; }
ask
Username: deploy
deploy

第一行中的 deploy 是您输入的内容。现在,在命令替换中运行同一个函数:

v=$(ask)
deploy

屏幕上只显示您自己输入的字符。这些字符由终端驱动程序回显,而不是由程序输出。提示符被发送到了其他位置:

echo "$v"
Username: deploy

现在,提示符位于变量中,并与回答连在一起,因为 $() 捕获了该函数写入标准输出的全部内容。您的输入仍然传递给 read,因为标准输入从未被修改。这正是“脚本卡住且不输出任何内容”这类问题报告的典型特征。

有些工具会将提示符写入标准错误,或直接写入 /dev/tty,因此提示符不会消失。但很多工具不会这样做。如果函数必须保留在命令替换中,请自行将提示符发送到标准错误:

ask() { printf 'Username: ' >&2; read -r u; printf '%s\n' "$u"; }
v=$(ask)
echo "$v"
Username: deploy
deploy

标准错误不会被捕获,因此提示符会显示在终端上,只有回答会进入 v

为什么在 $() 中 ls 的输出看起来不同?

因为 ls 会在文件描述符 1 上调用 isatty,并根据返回结果更改输出格式。在命令提示符中,该文件描述符连接到终端,因此 ls 会将名称按列排列在一行中。在命令替换中,该文件描述符连接到管道,因此 ls 会切换为每行输出一个名称。

mkdir -p /tmp/tty-demo
cd /tmp/tty-demo
touch alpha beta delta gamma
echo "$(ls)"
alpha
beta
delta
gamma

同一个检查也会关闭 grep --color=auto 中的颜色,并关闭 git 中的分页器。这是有意设计的功能。脚本无需显式请求,就能获得稳定、适合机器处理的输出。

这也解释了管道相关的一个常见问题。在命令提示符中,ls | sort$(ls | sort) 的输出相同,因为在这两种情况下,ls 的输出都连接到了管道。命令替换中发生变化的是管道的最后一个阶段。sort 从不检查输出是否连接到终端,因此其输出不会变化。在管道末尾放置一个会检测终端的命令,输出就会变化。因此,原本直接查看时表现正常的管道,一旦放入 $() 中,行为可能立即不同。

由此还要注意:即使 ls 的输出看起来便于处理,也不要在脚本中解析它。文件名可能包含空格和换行符。请使用 glob,或将 find -print0read -d '' 搭配使用。

会悄悄消失的末尾换行符

命令替换会删除输出末尾的所有换行符。不是最后一个,而是全部换行符。

cd /tmp/subst-demo
printf 'hello\n\n\n' > blanks.txt
wc -c < blanks.txt
v=$(cat blanks.txt)
printf '%s' "$v" | wc -c
8
5

文件包含 hello 和 3 个换行符,因此有 8 个字节。变量包含 hello,因此有 5 个字节。3 个字节在没有任何警告的情况下消失了。

这种删除行为是有意设计的,通常也很有用。正因为如此,stamp=$(date -u +%Y%m%dT%H%M%SZ) 生成的是可用的文件名片段,而不是包含换行符的文件名。因此,在 定时执行的 restic 备份脚本 中使用这种模式是安全的:

stamp=$(date -u +%Y%m%dT%H%M%SZ)
printf 'backup-%s.tar.gz\n' "$stamp"
backup-20260804T031500Z.tar.gz

您的时间戳会有所不同。关键是文件名只有一行。

代价是,不能使用 $() 移动文件的精确字节内容。如果需要保留末尾的换行符,请在替换内容中追加一个哨兵字符,然后再将其删除:

v=$(cat blanks.txt; printf x)
v=${v%x}
printf '%s' "$v" | wc -c
8

x 位于换行符之后,因此末尾没有需要删除的换行符。随后,${v%x} 会删除哨兵字符,并保留原始字节。

还有两个相关细节。读取整个文件时,v=$(<blanks.txt) 无需运行 cat 即可完成相同的工作,因为 bash 会自行打开文件。它同样会删除末尾的换行符。相反,here-string 会添加一个您没有写入的换行符:

wc -c <<< 'abc'
4

引用命令替换结果,否则 bash 会进行分词和路径名展开

未加引号的替换结果会经过分词和路径名展开。加引号的替换结果不会经过这两步。

printf 'a b\tc\nd\n' > words.txt
echo $(cat words.txt)
echo "$(cat words.txt)"
a b c d
a b	c
d

未加引号时,bash 按 IFS 中的字符拆分输出。默认情况下,这些字符是空格、制表符和换行符。随后,echo 使用单个空格连接这四个片段。加引号时,文本作为一个词传递,制表符和内部换行符均保持不变。

路径名展开的风险更高:

mkdir -p /tmp/glob-demo
cd /tmp/glob-demo
touch one.txt two.txt
printf '*\n' > pattern.txt
p=$(cat pattern.txt)
echo $p
echo "$p"
one.txt pattern.txt two.txt
*

赋值本身是安全的,因为赋值不会进行分词或路径名展开。问题发生在 echo $p,其中 * 会针对当前目录进行展开。脚本从配置文件读取模式却忘记加引号时,就会对它能看到的每个文件执行操作。为每个替换结果加引号,即可避免整类此类错误。只有在确实需要分词时才省略引号,而这种情况很少见。

为什么 local x=$(cmd) 始终返回 0?

因为 local 本身就是一个命令,而 $? 报告的是 local 的状态,而不是其中包含的命令替换的状态。

check_bad() { local out=$(false); echo "status: $?"; }
check_bad
status: 0

false 以 1 退出,local 成功声明了变量,因此 1 被丢弃了。declareexporttypesetreadonly 的行为也相同。set -e 也无法捕获这个错误,因为从 shell 的角度看,没有任何命令失败。

将声明与赋值分开:

check_good() { local out; out=$(false); echo "status: $?"; }
check_good
status: 1

顶层的普通赋值会报告其最后一个命令替换的状态:

out=$(exit 3)
echo $?
3

这在运行于 systemd 服务和计时器 下的运行状况检查中尤其重要。被屏蔽的退出码会导致该单元每次运行都报告成功,而它本应验证的工作实际上从未执行。

实际需要使用的 Shell 内替代方案

大多数人使用 $(...),是因为他们想把数据放入变量。很多时候,他们真正需要的是输入,而不是捕获输出。下面这 4 种形式会在当前 Shell 中保留状态。

在循环上使用重定向

cd /tmp/subst-demo
while read -r line; do printf 'got: %s\n' "$line"; done < three.txt
got: alpha
got: beta
got: gamma

输入不会创建单独的进程,因此循环体设置的内容会在循环结束后保留。

进程替换

while read -r line; do printf 'got: %s\n' "$line"; done < <(sort -r three.txt)
got: gamma
got: beta
got: alpha

<(command) 会提供一个路径,例如 /dev/fd/63,通过该路径可以读取命令的输出。命令本身仍在独立进程中运行。while 循环不会创建独立进程,这正是这种写法的用途。< <( 中的空格是必需的:<<( 会被解析为 here-document 的开头,因此语法解析会失败。进程替换是 bash 的功能,因此在 Ubuntu 或 Debian 上使用 #!/bin/sh 的脚本会由 dash 执行,并在其中失败。请使用 #!/bin/bash

Here-string

read -r first rest <<< 'alpha beta gamma'
echo "$first"
echo "$rest"
alpha
beta gamma

<<< 会将一个字符串写入命令的标准输入。read 在当前 Shell 中运行,因此两个变量都会在当前环境中设置,可以直接使用。read -r a b <<< "$(some-command)" 是从一行输出中提取两个字段的常用方法。

使用 mapfile 读取整个文件

mapfile -t lines < three.txt
echo "${#lines[@]}"
echo "${lines[1]}"
3
beta

mapfile(也写作 readarray)会在当前 Shell 中将文件读入数组。-t 会移除每个元素末尾的换行符。它需要 bash 4 或更高版本。Ubuntu 24.04 随附 bash 5.2,因此所有当前服务器镜像都可以使用它。

提交脚本前的检查清单

  • 编写 $(command);除非明确需要拆分,否则将其引用为 "$(command)"
  • 假设末尾换行符已被删除。如果需要保留换行符,请添加哨兵字符。
  • 不要在替换操作中使用交互式命令,或将其提示发送到标准错误。
  • out=$(command) 的退出状态很重要时,请将 local out 单独放在一行。
  • 要从输入设置变量,请使用重定向或进程替换,而不是管道。

人们在设置新 VPS时编写的第一个小脚本中经常会出现这些写法,而相关故障通常不会明显显示出来。备份脚本可能会将提示内容捕获到文件名中,健康检查也可能屏蔽退出码,却仍然持续报告成功。等到在多台服务器上运行同一个脚本时,代价会进一步增加,因为原本无人读取的输出现在会出现在二十台机器上,而你仍然不会读取它。

FAQ

为什么 $() 中的 cd 不会改变我的当前目录?

$(...) 会在子 shell 中执行命令。子 shell 是一个独立进程,其中保存着工作目录和变量的副本。cd 只会改变这份副本,随后该进程退出,副本也会被丢弃。子 shell 只能返回标准输出和退出状态,因此目录变更无法传递到父 shell。如果需要获取目录本身,请使用 target=$(cd /etc && pwd) 捕获目录,并通过 "$target" 使用它。如果希望当前 shell 切换目录,请直接运行 cd,不要将其放在命令替换中。

bash 中 $() 和反引号有什么区别?

对于简单命令,两者会产生相同结果,但有两个重要区别。$() 可以直接嵌套,因为解析器会匹配括号;而反引号每嵌套一层,都需要转义反引号。反引号还会在解析内部命令前去除一层反斜杠转义,因此 ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`,所以没有理由为了可移植性使用反引号。

为什么命令需要输入时,我的脚本会在没有提示符的情况下挂起?

命令替换会将标准输出重定向到管道,但标准输入仍连接到终端。将提示符输出到标准输出的程序会把该提示符捕获到变量中,而后面的 read 仍会等待输入。终端只会显示终端驱动程序回显的输入字符。将问题移到命令替换外部,或者使用 printf 'Username: ' >&2 将提示符写入标准错误,这样提示符就不会被捕获。

为什么变量末尾的空行消失了?

命令替换会删除所有末尾换行符,而不只是最后一个。printf 'hello\n\n\n' > f; v=$(cat f) 会让 v 保存五个字节,而文件包含八个字节。若要保留这些换行符,请在命令替换中追加一个哨兵值,然后依次使用 v=$(cat f; printf x)v=${v%x} 删除它。哨兵值位于换行符之后,因此 bash 不会再删除末尾内容。

为什么 local out=$(cmd) 总是报告成功?

local 本身就是一条命令,该行之后的 $? 报告的是 local 是否成功声明变量。命令替换的退出状态会被消耗并丢弃,这也意味着 set -e 不会停止脚本。declareexporttypesetreadonly 的行为相同。将 local out 写在一行,将 out=$(cmd) 写在下一行,此时 $? 才会报告真实状态。

#bash#shell-scripting#linux#subshell#coreutils