title: Bash 不是程式語言,它是文字替換引擎
published: true
description: 以視覺化、適合初學者的方式了解 Bash 實際如何執行命令。了解為什麼空格會破壞你的腳本、展開流程如何運作、子程序失憶陷阱,以及如何撰寫絕不會默默失敗的腳本。
tags: bash, linux, devops, beginners
cover_image: https://i.imgur.com/qc345jS.png

現在就打開你的終端機,輸入這一行:

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 到底做了什麼。


1. 命令列的錯覺:為什麼空格會打碎一切

在 Python 或 JavaScript 中,解析器理解語法。當它看到 x = 10 時,會辨識出辨識字元(x)、指定運算子(=)以及數值常值(10)。空白大多只是裝飾。

Bash 不一樣。Bash 看世界只靠一個簡單規則:

你輸入的每一行,都會被視為:command argument1 argument2 argument3...

在 Bash 評估你真正想做什麼之前,它會先把你輸入的原始字串,依照空白切成單字。

為什麼空格會破壞你的 Bash 腳本

當你輸入:

x = 10

Bash 會把這段文字切成三個不同的字:

  1. x(命令名稱)
  2. =(第一個參數)
  3. 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 當成命令名稱去執行。


2. 按下 Enter 後發生什麼事:4 階段管線

要理解 Bash 的 bug,你需要先理解一個命令的生命週期。

當你按下 Enter 時,Bash 不會立刻執行你的命令。相反地,它會把命令送進一條多次掃描的文字轉換管線。

按下 Enter 時實際發生的事

確切順序如下:

第 1 步:詞法切分

Bash 讀取原始字元串流,並依照未加引號的空格、tab,以及控制運算子(例如 ;、&、|)把它切成 token。

第 2 步:展開流程

Bash 會掃過你的 token 並替換文字。這會以嚴格、可預測的順序進行:

  1. 大括號展開:echo file{1,2}.txt 變成 file1.txt file2.txt
  2. 波浪符展開:~/docs 變成 /home/user/docs
  3. 參數與變數展開:$USER 變成 smtahosin
  4. 命令替換:$(date +%Y) 會執行命令,並把輸出貼回那一行
  5. 算術展開:$((2 + 2)) 變成 4
  6. 字詞切分:任何未加引號而展開成空白的變數,會被切成多個參數
  7. 路徑名稱展開(萬用字元展開):*.png 會搜尋檔案系統並展開成符合的檔案

第 3 步:重新導向設定

Bash 會掃描重新導向運算子(>、<、>>、2>&1、|)。它會在任何程式碼開始執行前,調整行程的檔案描述元。

第 4 步:執行

最後,Bash 會檢查命令名稱。如果它是 Shell 內建命令(如 cd、echo、export),Bash 就會在內部執行。如果它是外部程式(如 git、python、curl),Bash 會呼叫作業系統系統呼叫 fork() 建立子行程,並用 execve() 載入二進位檔到記憶體中。

展開的黃金法則

最重要的重點是:

你執行的程式永遠看不到你原始的腳本語法。它只會看到 Bash 完成所有替換後的最終文字。

如果你執行:

grep $PATTERN $FILE

而 $PATTERN 內含空格,那麼在第 2.6 步時 Bash 就會把它切成多個字。等到 grep 啟動時,它收到的會是三個參數而不是兩個,並且完全誤解你的輸入。

這就是為什麼把變數加上 "$VAR" 的引號是必要的。雙引號會關閉字詞切分與萬用字元展開,讓你的字串在展開階段受到保護。


3. 子程序失憶陷阱:為什麼變數會消失

每個 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() 建立的子行程)。

作業系統實際上在做的是這樣:

  1. 主 Shell 腳本(PID 1001)在自己的記憶體空間中有 total=0。
  2. 當 Bash 遇到管線 | 時,它會建立一個子 Shell(PID 1002)來執行 while 迴圈。
  3. 子行程會取得父行程記憶體的「副本」。在子程序中,total 會遞增到 50。
  4. 當 sales.txt 到達檔尾時,while 迴圈結束。
  5. 子程序退出(exit 0)。作業系統回收 PID 1002,並銷毀它整個記憶體空間。
  6. 控制權回到父行程(PID 1001),而父行程自己的記憶體從未被動到。它的 total 仍然是 0。

子行程可以從父行程繼承環境變數,但子行程永遠無法修改父行程的記憶體。

如何修正子程序失憶

有兩個乾淨的方法可以修正。

修正 1:程序替換(建議)

不要把資料透過管線送進迴圈,而是使用程序替換,從下方把資料送入迴圈:

#!/usr/bin/env bash

total=0

while read -r line; do
    ((total++))
done < <(cat sales.txt)

echo "Final Total: $total"  # 會印出 50!

把迴圈放在主程序中,再把資料重新導向進去,這樣迴圈就會在父 Shell 中執行,變數也會被保留下來。

修正 2:啟用 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,但在腳本裡它可以直接運作。


4. 標準串流與檔案描述元:拆解 2>&1

在每個開發者的職涯中,都會有一天看到這個命令:

my_script.sh > output.log 2>&1

多數教學會這樣解釋:「它會把一般輸出和錯誤訊息都重新導向到 output.log。」

這告訴你它做了什麼,但沒有告訴你為什麼語法要這樣寫。更別說如果你把順序反過來輸入:

my_script.sh 2>&1 > output.log  # 危險:這不是你以為的效果!

你的錯誤訊息還是會灑滿整個終端機畫面。為什麼?

拆解 2>&1:順序很重要

檔案描述元表格

每個 Unix 行程一開始都有三個預設通訊通道,以數字檔案描述元(FD)表示:

檔案描述元 名稱 預設目標
0 stdin 鍵盤輸入
1 stdout 終端機顯示
2 stderr 終端機顯示

你可以把檔案描述元想成 Linux 核心內部一個小型指標表。

當 Bash 評估重新導向時,它會嚴格依照由左到右的順序來處理:

情況 A:> output.log 2>&1(正確寫法)

  1. > output.log(等同於 1> output.log):Bash 把 FD 1(stdout)重新指向 output.log。
  2. 2>&1:& 符號表示「檔案描述元」,不是一個名叫 1 的檔案。這會告訴 Bash:「把 FD 1 的指標複製到 FD 2。」
  3. 因為 FD 1 已經指向 output.log,所以 FD 2 也會被更新為指向 output.log。
  4. 結果:stdout 和 stderr 都會寫入檔案。

情況 B:2>&1 > output.log(安靜的 bug)

  1. 2>&1:Bash 把 FD 1 的指標複製到 FD 2。此時 FD 1 仍然指向你的終端機顯示。所以 FD 2 被設定為指向終端機。
  2. > output.log:Bash 再把 FD 1 重新指向 output.log。
  3. 結果:FD 1 寫入檔案,但 FD 2 仍然寫到你的終端機。錯誤訊息就這樣漏出去了。

現代語法

如果你使用的是 Bash 4 或更新版本,你不需要再寫 > file 2>&1。你可以用更簡潔的寫法:

my_script.sh &> output.log

&> 運算子會自動把 stdout 和 stderr 都重新導向到目標,而不需要玩指標搬移的把戲。


5. 「嚴格模式」安全護盾:停止默默發生的生產事故

預設情況下,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

嚴格模式安全護盾

讓我們逐一拆解每個旗標,看看你到底得到了什麼保護:

1. set -e(errexit)

如果任何命令以非零退出碼結束(發生錯誤),腳本會立刻終止。不會再有連鎖失敗,例如資料庫遷移失敗後,還繼續嘗試啟動壞掉的應用程式。

2. set -u(nounset)

把未設定的變數視為致命錯誤。如果你把 $TEM_DIR 拼錯,Bash 會立即停止執行:

bash: TEM_DIR: unbound variable

破壞性的命令就不會執行。

3. set -o pipefail

在像這樣的標準管線中:

dump_database | gzip > backup.sql.gz

如果 dump_database 因為記憶體不足而崩潰,但 gzip 成功壓縮了空輸入,Bash 仍然會回傳退出碼 0。你的 CI/CD 管線會以為備份成功了,但其實資料庫備份只是一個空檔案。

set -o pipefail 會改變這點:只要管線中的任何命令失敗,整條管線就會失敗,並保留最後一個失敗程式的退出碼。

4. set -E(errtrace)

確保你設定的任何 ERR trap 會被 Shell 函式、命令替換,以及子程序繼承。沒有 -E 的話,函式可能會悄悄吞掉致命錯誤,而不會觸發你的清理處理器。


6. 專業級 Bash 清理模式

業餘腳本和生產等級自動化之間最大差異之一,就是出事時如何處理清理。

如果你的腳本在 /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 區塊。


7. Bash 心智模型速查表

這裡有一份可以放在桌邊的快速參考表:

習慣 / 陷阱 實際發生的事 經得起考驗的修正方式
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


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝49   💬2  
692
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登