SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor · 已更新 2026-08-13

Bash 命令替換:為什麼 $() 優於反引號

了解 Bash $() 為何在 subshell 執行,導致 cd 與變數變更消失;比較反引號的巢狀問題,並掌握保留 shell 狀態的 process substitution 技巧。

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

Bash 命令替換的作用

Bash 命令替換會將 $(command) 替換為該命令輸出至標準輸出的文字。使用反引號的舊寫法也具有相同作用。所有令人意外的行為,都源自兩個事實:命令會在名為 subshell 的獨立程序中執行,而且輸出結尾的每個換行字元都會被移除。

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;這是不同的字串,也是之後算術運算失敗的常見原因。

本指南接下來說明的是一般人意料之外的行為,因為 subshell 是獨立程序,而獨立程序無法變更你目前輸入指令的 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 會先移除一層反斜線逸出,再剖析內層文字,因此單引號沒有發揮保護作用。在 $() 內,括號之間的文字會以一般 script 方式剖析,因此單引號的行為符合預期。這在 sedawk 單行命令中尤其容易出問題,因為遺失一個反斜線,就可能讓原本可用的模式悄悄變成另一種模式。

$() 內的引號環境也會重新開始,因此可以巢狀使用雙引號,不必逸出:

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

反引號的等效寫法需要在 $path 周圍加入 \"。每一次逸出都增加一個出錯的機會。

為什麼在 $() 內執行 cd 不會改變目前的 shell

因為 $(...) 會 fork 出新的程序。子 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 只會將標準輸出與結束狀態傳給父 shell。

變數指派也遵循相同規則:

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

相同規則也能解釋另一種更常見的問題。這個版本涉及 pipeline,而不是 command substitution:

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

pipeline 的每個階段都會在自己的子 shell 中執行,因此 while 迴圈只遞增了 n 的副本,接著就結束了。將 pipe 改為 redirect 後,迴圈會在目前的 shell 中執行:

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

Bash 可以透過 shopt -s lastpipe 讓 pipeline 的最後一個階段在目前的 shell 中執行,但只有在 job control 關閉時才有效;互動式 shell 永遠不符合此條件。請改用 redirect。

提示字串去哪裡了?$() 內的互動式命令

命令替換會將標準輸出重新導向至管線,但不會處理標準輸入。某個程式將問題列印至標準輸出後等待回答時,消失的是問題,而不是等待動作。終端機看起來就像當機。

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,或搭配 read -d '' 使用 find -print0

會悄悄消失的結尾換行

命令替換會移除輸出結尾的所有換行。不是最後一個,而是全部換行。

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 bytes。變數包含 hello,因此共有 5 bytes。3 bytes 毫無警告地消失了。

移除換行是刻意設計的行為,通常也很實用。這能讓 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

你的時間戳記會不同。重點是檔名只有一行。

代價是,不能使用 $() 搬移檔案的確切 bytes。若需要保留結尾換行,請在替換內容中加入 sentinel 字元,之後再將它移除:

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

x 位於換行之後,因此要移除的內容中不再有結尾換行。接著,${v%x} 會移除 sentinel,留下原始 bytes。

另外有 2 個相關細節。讀取整個檔案時,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 服務與計時器 下的健康檢查中特別重要。被遮蔽的結束碼會讓該 unit 每次執行都回報成功,但原本應驗證的工作實際上從未完成。

實際需要的 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

輸入不會建立額外的 process,因此迴圈主體設定的內容會在迴圈結束後保留。

Process substitution

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,讀取該 command 的輸出。該 command 仍會在自己的 process 中執行。但 while 迴圈不會,這正是此方法的重點。< <( 中的空格是必要的:<<( 會被解讀為 here-document 的開頭,因而無法通過語法解析。Process substitution 是 bash 功能,因此在 Ubuntu 或 Debian 上,含有 #!/bin/sh 的 script 會使用 dash 執行,並在該環境中失敗。請使用 #!/bin/bash

Here-strings

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

<<< 會將一個字串送入 command 的 standard input。read 會在你的 shell 中執行,因此兩個變數都會在可供使用的環境中設定完成。read -r a b <<< "$(some-command)" 是從一行輸出中取出 2 個欄位的標準方式。

使用 mapfile 處理完整檔案

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

mapfile,也寫作 readarray,會在目前的 shell 中將檔案讀入 array。-t 會移除每個元素結尾的 newline。它需要 bash 4 或更新版本;Ubuntu 24.04 隨附 bash 5.2,因此目前的任何 server image 都能使用。

提交腳本前的檢查清單

  • 撰寫 $(command);除非確實需要分割,否則將其引用為 "$(command)"
  • 假設結尾的換行字元已消失。如需保留,請加入哨兵字元。
  • 不要在替換中使用互動式命令,或將其提示訊息傳送至標準錯誤。
  • out=$(command) 的結束狀態很重要時,請將 local out 單獨寫在一行。
  • 若要根據輸入設定變數,請使用重新導向或程序替換,不要使用管線。

這些寫法常見於人們開始設定新的 VPS時撰寫的第一批小型腳本,而錯誤通常不會顯示出來。備份腳本若將提示訊息擷取到檔名中,或健康檢查若遮蔽了結束碼,就會持續回報成功。當你開始在多台伺服器上執行相同腳本後,問題的代價會增加,因為原本未讀取的輸出,現在變成 20 台機器上都未讀取的輸出。

FAQ

為什麼在 $() 內執行 cd 不會變更目前的目錄?

$(...) 會在 subshell 中執行命令。subshell 是持有工作目錄與變數副本的獨立程序。cd 只會變更該副本,程序結束後副本也會被捨棄。subshell 只能傳回標準輸出與結束狀態,因此沒有機制將目錄變更傳回父 shell。若只需要取得目錄本身,請使用 target=$(cd /etc && pwd) 擷取,再搭配 "$target"。若要讓 shell 移動目錄,請直接執行 cd,不要在外層包覆 substitution。

bash 中的 $() 與反引號有何不同?

對簡單命令而言,兩者會產生相同結果,但有兩項重要差異。$() 可以直接巢狀使用,因為 parser 會比對括號;反引號則必須在每一層巢狀中使用跳脫的反引號。反引號也會在解析內部命令前,先移除一層反斜線跳脫,因此 ` echo 'a\\b' prints a\b while $(echo 'a\\b') prints a\\b. $() is in POSIX and works in dash and busybox sh`,所以沒有理由為了可攜性而使用反引號。

為什麼命令提出問題時,我的 script 會在沒有提示字元的情況下停住?

Command substitution 會將標準輸出重新導向至 pipe,但標準輸入仍連接至終端機。將提示字元輸出至標準輸出的程式,會讓該提示字元被擷取至變數中;而背後的 read 仍會等待輸入。終端機只會顯示由 terminal driver 回顯的輸入字元。請將問題移到 substitution 外部,或使用 printf 'Username: ' >&2 將提示字元寫入標準錯誤,避免被擷取。

為什麼變數結尾的空白行會消失?

Command substitution 會移除所有結尾的換行,不只是最後一個換行。printf 'hello\n\n\n' > f; v=$(cat f) 會讓 v 只包含五個位元組,而檔案包含八個位元組。若要保留這些換行,請在 substitution 內附加 sentinel,之後使用 v=$(cat f; printf x) 搭配 v=${v%x} 將其移除。sentinel 位於換行之後,因此 bash 沒有結尾內容可移除。

為什麼 local out=$(cmd) 總是回報成功?

local 本身就是一個命令,而該行後的 $? 回報的是 local 宣告變數是否成功。substitution 的結束狀態已被取用並捨棄,因此 set -e 也不會停止 script。declareexporttypesetreadonly 的行為相同。請在一行撰寫 local out,下一行再撰寫 out=$(cmd),此時 $? 才會回報實際狀態。

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