現在就打開你的終端機,輸入這一行:
x = 10
按下 Enter。會發生什麼事?
bash: x: command not found...
如果你來自 Python、JavaScript、Go 或 Java,這個錯誤會讓人感覺像是在被冒犯。你並不是要 Bash 執行一個叫做 x 的可執行檔。你只是想把數字 10 存進一個變數裡。
為什麼在等號兩邊多加兩個小空格,就會讓你的 Shell 直接當機?
多年來,開發者都像在做迷信儀式一樣記住各種 Shell 的怪癖。我們記得 x=10 不能有空格、每個變數都要加雙引號,以及 2>&1 是某種神奇咒語,只要想讓 cron job 安靜一點,就從 Stack Overflow 直接複製貼上。
當你把 Bash 當成是壞掉版的 Python 或 JavaScript 時,你寫的每個腳本都會顯得很脆弱。只要檔名裡出現空格,或某個子程序默默吞掉退出碼,你的自動化部署就會在凌晨 2 點炸掉。
有一個秘密,徹底改變了我看待命令列的方式:
Bash 並不是你以為的那種程式語言。它是一個把 Unix 行程黏在一起的文字替換巨集引擎。
只要你掌握這個單一的心智模型,Bash 裡所有看起來古怪的語法就不再神祕,反而完全說得通。
在你的程式開始執行之前,讓我們先揭開 Shell 到底做了什麼。
在 Python 或 JavaScript 中,解析器理解語法。當它看到 x = 10 時,會辨識出辨識字元(x)、指定運算子(=)以及數值常值(10)。空白大多只是裝飾。
Bash 不一樣。Bash 看世界只靠一個簡單規則:
你輸入的每一行,都會被視為:command argument1 argument2 argument3...
在 Bash 評估你真正想做什麼之前,它會先把你輸入的原始字串,依照空白切成單字。

當你輸入:
x = 10
Bash 會把這段文字切成三個不同的字:
x(命令名稱)=(第一個參數)10(第二個參數)接著 Bash 會在系統的 $PATH 裡尋找一個名為 x 的可執行檔,並把 = 和 10 傳給它。由於你的機器上沒有安裝叫做 x 的程式,所以 Shell 會顯示 command not found: x。
再看看把空格拿掉後會怎樣:
x=10
因為沒有空格,Bash 會把這當成一個連續的 token。在初始解析階段,Bash 會檢查這個 token 是否符合 NAME=VALUE 的模式。
當它看到這種模式時,Bash 會說:「啊哈!這不是外部命令,這是內部變數指定。」 它會把字串 "10" 存到內部符號表中對應 x 的位置,而不會啟動任何行程。
這個規則也解釋了另一個經典惡夢:
if [$x == 10]; then # 當掉!
為什麼會失敗?因為 [ 不是語法。[ 是一個真正的命令(歷史上位於 Unix 系統的 /bin/[,在現代 Shell 中也常內建以提升速度)。
因為 [ 是命令,所以它需要空格,讓 Bash 能解析它的參數:
if [ "$x" == 10 ]; then # 可以!
這裡,[ 是程式,"$x" 是參數 1,== 是參數 2,10 是參數 3,而 ] 是必要的結尾參數。少一個空格,Bash 就會把 [10 當成命令名稱去執行。
要理解 Bash 的 bug,你需要先理解一個命令的生命週期。
當你按下 Enter 時,Bash 不會立刻執行你的命令。相反地,它會把命令送進一條多次掃描的文字轉換管線。

確切順序如下:
Bash 讀取原始字元串流,並依照未加引號的空格、tab,以及控制運算子(例如 ;、&、|)把它切成 token。
Bash 會掃過你的 token 並替換文字。這會以嚴格、可預測的順序進行:
echo file{1,2}.txt 變成 file1.txt file2.txt~/docs 變成 /home/user/docs$USER 變成 smtahosin$(date +%Y) 會執行命令,並把輸出貼回那一行$((2 + 2)) 變成 4*.png 會搜尋檔案系統並展開成符合的檔案Bash 會掃描重新導向運算子(>、<、>>、2>&1、|)。它會在任何程式碼開始執行前,調整行程的檔案描述元。
最後,Bash 會檢查命令名稱。如果它是 Shell 內建命令(如 cd、echo、export),Bash 就會在內部執行。如果它是外部程式(如 git、python、curl),Bash 會呼叫作業系統系統呼叫 fork() 建立子行程,並用 execve() 載入二進位檔到記憶體中。
最重要的重點是:
你執行的程式永遠看不到你原始的腳本語法。它只會看到 Bash 完成所有替換後的最終文字。
如果你執行:
grep $PATTERN $FILE
而 $PATTERN 內含空格,那麼在第 2.6 步時 Bash 就會把它切成多個字。等到 grep 啟動時,它收到的會是三個參數而不是兩個,並且完全誤解你的輸入。
這就是為什麼把變數加上 "$VAR" 的引號是必要的。雙引號會關閉字詞切分與萬用字元展開,讓你的字串在展開階段受到保護。
每個 Shell 開發者至少都遇過一次這個 bug。
你寫了一個很漂亮的小迴圈,想要計算檔案中的專案數,或累加總和:
#!/usr/bin/env bash
total=0
cat sales.txt | while read line; do
((total++))
done
echo "Final Total: $total"
你拿一個有 50 行的檔案來跑。你原本預期它會印出 Final Total: 50。
結果它印出:
Final Total: 0
你在迴圈裡加上一個 echo $total,它確實會一路數上去:1、2、3……一直到 50。但只要迴圈結束,變數就又回到 0。
是 Bash 把你的變數清掉了嗎?還是記憶體外洩了?

這個 bug 的根源在於行程隔離。
在 Unix 中,管線(|)會把一個行程的輸出連接到另一個行程的輸入。因為兩個命令必須同時執行才能串流資料,Bash 會把管線的每一側都放在獨立的子程序中執行(透過 fork() 建立的子行程)。
作業系統實際上在做的是這樣:
total=0。| 時,它會建立一個子 Shell(PID 1002)來執行 while 迴圈。total 會遞增到 50。sales.txt 到達檔尾時,while 迴圈結束。exit 0)。作業系統回收 PID 1002,並銷毀它整個記憶體空間。total 仍然是 0。子行程可以從父行程繼承環境變數,但子行程永遠無法修改父行程的記憶體。
有兩個乾淨的方法可以修正。
不要把資料透過管線送進迴圈,而是使用程序替換,從下方把資料送入迴圈:
#!/usr/bin/env bash
total=0
while read -r line; do
((total++))
done < <(cat sales.txt)
echo "Final Total: $total" # 會印出 50!
把迴圈放在主程序中,再把資料重新導向進去,這樣迴圈就會在父 Shell 中執行,變數也會被保留下來。
lastpipe在現代 Bash(4.2+)中,你可以告訴 Shell 在前景程序中執行管線的最後一個命令,而不是放進子程序:
#!/usr/bin/env bash
shopt -s lastpipe
total=0
cat sales.txt | while read -r line; do
((total++))
done
echo "Final Total: $total" # 會印出 50!
注意:在互動式 Shell 中,工作控制會讓 lastpipe 無法生效,除非你同時執行 set +m,但在腳本裡它可以直接運作。
2>&1在每個開發者的職涯中,都會有一天看到這個命令:
my_script.sh > output.log 2>&1
多數教學會這樣解釋:「它會把一般輸出和錯誤訊息都重新導向到 output.log。」
這告訴你它做了什麼,但沒有告訴你為什麼語法要這樣寫。更別說如果你把順序反過來輸入:
my_script.sh 2>&1 > output.log # 危險:這不是你以為的效果!
你的錯誤訊息還是會灑滿整個終端機畫面。為什麼?

每個 Unix 行程一開始都有三個預設通訊通道,以數字檔案描述元(FD)表示:
| 檔案描述元 | 名稱 | 預設目標 |
|---|---|---|
0 |
stdin |
鍵盤輸入 |
1 |
stdout |
終端機顯示 |
2 |
stderr |
終端機顯示 |
你可以把檔案描述元想成 Linux 核心內部一個小型指標表。
當 Bash 評估重新導向時,它會嚴格依照由左到右的順序來處理:
> output.log 2>&1(正確寫法)> output.log(等同於 1> output.log):Bash 把 FD 1(stdout)重新指向 output.log。2>&1:& 符號表示「檔案描述元」,不是一個名叫 1 的檔案。這會告訴 Bash:「把 FD 1 的指標複製到 FD 2。」output.log,所以 FD 2 也會被更新為指向 output.log。2>&1 > output.log(安靜的 bug)2>&1:Bash 把 FD 1 的指標複製到 FD 2。此時 FD 1 仍然指向你的終端機顯示。所以 FD 2 被設定為指向終端機。> output.log:Bash 再把 FD 1 重新指向 output.log。如果你使用的是 Bash 4 或更新版本,你不需要再寫 > file 2>&1。你可以用更簡潔的寫法:
my_script.sh &> output.log
&> 運算子會自動把 stdout 和 stderr 都重新導向到目標,而不需要玩指標搬移的把戲。
預設情況下,Bash 是為 1989 年的互動式命令列便利性設計的,不是為 2026 年的關鍵任務自動化而設計的。
想像一下這種惡夢場景:
#!/bin/bash
# 清理暫存部署資料夾
TEMP_DIR="/tmp/deploy_cache"
# 假設下面的變數名稱打錯了:
rm -rf "$TEM_DIR/*"
因為 $TEM_DIR 拼錯了,預設的 Bash shell 會怎麼做?
它不會丟出 ReferenceError。它不會當掉。
相反地,Bash 會把未定義的變數安靜地展開成空字串。命令就會變成:
rm -rf "/*"
就這樣,一個自動化腳本把正式環境伺服器的根目錄刪掉了。
為了避免你的腳本默默自殺,你應該在每一個腳本最上方放上這一行:
set -Eeuo pipefail

讓我們逐一拆解每個旗標,看看你到底得到了什麼保護:
set -e(errexit)如果任何命令以非零退出碼結束(發生錯誤),腳本會立刻終止。不會再有連鎖失敗,例如資料庫遷移失敗後,還繼續嘗試啟動壞掉的應用程式。
set -u(nounset)把未設定的變數視為致命錯誤。如果你把 $TEM_DIR 拼錯,Bash 會立即停止執行:
bash: TEM_DIR: unbound variable
破壞性的命令就不會執行。
set -o pipefail在像這樣的標準管線中:
dump_database | gzip > backup.sql.gz
如果 dump_database 因為記憶體不足而崩潰,但 gzip 成功壓縮了空輸入,Bash 仍然會回傳退出碼 0。你的 CI/CD 管線會以為備份成功了,但其實資料庫備份只是一個空檔案。
set -o pipefail 會改變這點:只要管線中的任何命令失敗,整條管線就會失敗,並保留最後一個失敗程式的退出碼。
set -E(errtrace)確保你設定的任何 ERR trap 會被 Shell 函式、命令替換,以及子程序繼承。沒有 -E 的話,函式可能會悄悄吞掉致命錯誤,而不會觸發你的清理處理器。
業餘腳本和生產等級自動化之間最大差異之一,就是出事時如何處理清理。
如果你的腳本在 /tmp 建立暫存檔,或開啟網路埠,當使用者按下 Ctrl+C,或網路呼叫逾時時,會怎麼辦?通常,殘留檔案會永遠留在磁碟上。
你可以使用 Bash 內建的 trap 機制來處理:
#!/usr/bin/env bash
set -Eeuo pipefail
# 建立安全的暫存目錄
SCRATCH_DIR=$(mktemp -d)
# 定義清理處理器
cleanup() {
local exit_code=$?
echo "Cleaning up scratch directory: $SCRATCH_DIR"
rm -rf "$SCRATCH_DIR"
exit "$exit_code"
}
# 監聽 EXIT、INT(Ctrl+C)與 TERM(kill)
trap cleanup EXIT INT TERM
echo "Doing heavy work in $SCRATCH_DIR..."
# 在這裡執行你的任務...
# 即使這個命令崩潰,cleanup 函式也會執行!
不管你的腳本如何結束,無論是成功完成、發生致命錯誤,或是收到終止訊號被殺掉,EXIT trap 都會可靠地執行。它就是 Bash 版的 try...finally 區塊。
這裡有一份可以放在桌邊的快速參考表:
| 習慣 / 陷阱 | 實際發生的事 | 經得起考驗的修正方式 |
|---|---|---|
x = 10 |
執行二進位檔 x,並帶入 = 與 10 參數 |
x=10(= 兩邊不要有空格) |
[ $val == 1 ] |
如果 $val 含空格或為空就會失敗 |
[[ "$val" == 1 ]](使用雙中括號並加上引號) |
cmd \| while read line |
迴圈在子程序中執行,變數修改會消失 | while read line; do ... done < <(cmd) |
rm -rf $DIR |
如果 $DIR 有空格會發生字詞切分;如果未設定,可能刪掉 / |
rm -rf "${DIR:?Variable DIR is required}" |
cmd 2>&1 > file.log |
在重新導向前就先複製 FD 1;錯誤仍會留在畫面上 | cmd > file.log 2>&1 或 cmd &> file.log |
| 預設腳本執行方式 | 遇到錯誤和拼字錯誤仍會默默繼續 | 在第 2 行加入 set -Eeuo pipefail |
| 手動刪除檔案 | 如果腳本被中斷,暫存檔會殘留 | 使用 trap cleanup EXIT INT TERM |
Bash 常常被當成一種古老又惱人的苦差事,開發者通常只在 Dockerfile 或 CI 流程失敗時才碰它。
但當你不再把它當成物件導向語言,而是開始理解它真正的本質——一個為了協調系統行程而設計的高速字串替換引擎——那些摩擦感就會消失。
你不再猜空格該放哪裡。你理解為什麼引號很重要。你會寫出在出錯時能優雅失敗、能乾淨復原,而不是在正式環境中悄悄壞掉的腳本。
下次打開終端機時,請用不同的眼光看待提示字元。想想展開流程、檔案描述元表格,以及底層運作的行程邊界。
你遇過最奇怪的 Bash bug 是什麼,曾經害你熬到很晚?你有沒有遇過變數在 while 迴圈裡消失,或是未加引號的變數把整個目錄搞砸?歡迎在下方留言分享你最精彩的 Shell 災難故事。
原文出處:https://dev.to/smtahosin/bash-isnt-a-programming-language-its-a-text-substitution-engine-5eml