包包baobaolin.com
date
entry
046
topic
tooling
rev
—

macOS 的 bash 3.2 裡,"$n個檔案" 印不出 3:緊貼變數的中文字被吃掉一個位元組

echo "上傳了 $n個檔案" 在 macOS 的 bash 裡印出「上傳了 ��檔案」,開了 set -u 就直接中止。bash 3.2 逐位元組判斷變數名在哪裡結束,而「個」的第一個位元組,在 macOS 的 UTF-8 語系裡算字母。

shell 腳本裡把變數直接接在文字後面,例如 echo "上傳了 $n個檔案",我原本以為沒問題:沒加大括號的變數名只會照 ASCII 的規則讀(字母或底線開頭,後面接字母、數字、底線),「個」不在裡面,所以 bash 會在 n 後面停下來。

但在這台 Mac 上,這行印出來的是「上傳了 ��檔案」,3 不見了。這篇要談的是 macOS 內建的 bash 3.2 為什麼會把中文字吃進變數名。依序是:怎麼重現、bash 原始碼裡的原因、這台機器的紀錄裡出現過幾次、為什麼不容易發現,最後是寫法。

重現

環境是 macOS 內建的 /bin/bash,GNU bash, version 3.2.57(1)-release (arm64-apple-darwin25),語系 LANG=zh_TW.UTF-8,沒有另外設 LC_ALL 或 LC_CTYPE。這台在我的 PATH 上只有這一個 bash,所以 #!/usr/bin/env bash 找到的也是它;/bin/sh 透過 /private/var/select/sh 選用的也是 /bin/bash。

$ cat b.sh
#!/usr/bin/env bash
n=3
echo "上傳了 $n個檔案"
echo "上傳了 ${n}個檔案"
echo "上傳了 $n 個檔案"
$ ./b.sh
上傳了 ��檔案
上傳了 3個檔案
上傳了 3 個檔案

第一行用 xxd 看,「了」和空白之後直接是 80 8b,接著是「檔案」。「個」的 UTF-8 是 e5 80 8b:第一個位元組 e5 和數字 3 都不見了,剩下兩個湊不成字的位元組,終端機顯示成兩個 �。

開了 set -u 的話,像這樣沒給預設值的直接展開,用到未設定的變數就是錯誤,非互動的腳本會直接結束。錯誤訊息會指出它把變數名讀成了什麼(�那個位置實際是 e5 這個位元組):

./a.sh: line 4: n�: unbound variable

這支 a.sh 結束碼 1,下一行沒有執行。我又換了幾個字元:)、,、。、、、「、的、秒、é 都一樣,每個都少了第一個位元組;語(e8 aa 9e)少了兩個;媺(e5 aa ba)三個全被吃掉,什麼都沒留下;ASCII 的 : 正常。同一支腳本用 LC_ALL=C 或 env -i(清空環境,語系退回 C)跑,三行都正確。

原因:逐位元組問 isalnum()

Apple 公開的 bash 原始碼(GitHub 上 apple-oss-distributions/bash 的 bash-3.2 目錄,patchlevel.h 是 57)裡,subst.c 的 param_expand() 這樣找變數名的結尾:

for (t_index = zindex; (c = string[zindex]) && legal_variable_char (c); zindex++)
  ;

c 宣告成 unsigned char,一次一個位元組。general.h 定義 legal_variable_char(c) 是 ISALNUM(c) || c == '_',include/chartypes.h 定義 ISALNUM(c) 是 IN_CTYPE_DOMAIN (c) && isalnum (c),在定義了 STDC_HEADERS 這類條件下 IN_CTYPE_DOMAIN 是 1。所以最後由 C 函式庫的 isalnum() 對每個位元組各判斷一次。

麻煩在 macOS 的 isalnum() 怎麼分類這些位元組。我寫了一支小程式,用 setlocale 切語系,對 0x80 到 0xFF 每個值呼叫一次:C 語系是 0 個為真;zh_TW.UTF-8 和 en_US.UTF-8 都是 65 個為真,也就是 0xC0 到 0xFF 除了 0xD7、0xF7 以外全部,加上 0xAA、0xB5、0xBA。這 65 個值剛好就是 Latin-1 編碼裡 À 到 ÿ 這些字母(扣掉 × 和 ÷),再加上 ª、µ、º。isalnum() 本來就不是拿來解 UTF-8 的,所以問題是 bash 逐位元組判斷、碰上這個分類結果,兩件事加在一起。

常用的中文字在 UTF-8 是三個位元組(補充平面的字是四個),第一個落在 0xE0 到 0xEF,後面的落在 0x80 到 0xBF。第一個位元組被 isalnum() 判成字母,bash 就把它併進變數名;第二個位元組多半不是,變數名在那裡結束。n 加上 e5 這個變數不存在,展開成空字串,剩下的 80 8b 原樣印出來。「語」的第二個位元組剛好是 0xAA,所以被吃掉兩個;「媺」的三個位元組都在那 65 個裡,整個字都不見了。

新版 bash 是不是還這樣,我手邊沒有可以測的版本,這篇只講 macOS 內建的這一個。

這台機器的紀錄裡出現過幾次

我用系統的 grep 搜這台機器上 Claude Code 的對話紀錄(~/.claude/projects 底下的 JSONL),找「英文變數名後面接非 ASCII 位元組、再接 : unbound variable」的錯誤。只算 10 月 15 日以前的工具輸出,找到 4 筆,分在 4 個紀錄檔裡,紀錄檔的修改日期在 9 月 23 日到 10 月 15 日之間。被誤讀的變數分別是 FILE、PATH、GH_ROLE、UP,錯誤訊息都長得像:

line 61: FILE�: unbound variable

今天又多了一個:寫 045 那支截圖包裝腳本時,錯誤訊息寫成 "沒有截圖(chrome 結束碼 $rc)",得到 rc�: unbound variable。這 5 筆都是以 unbound variable 的錯誤出現,也就是腳本開了 set -u。沒開的腳本,值會安靜地變成空字串,可能留下幾個亂碼,也可能什麼都不留;那種情況有沒有發生過,搜這個錯誤訊息找不到。

為什麼不容易發現

第一個原因是測試用的 shell 和執行腳本的 shell 不同,而兩者壞的方式也不同。我用的 Claude Code 工作階段裡,Bash 工具實際用 zsh 執行(見 叫 Bash 的工具跑的是 zsh)。在 zsh 5.9 裡打 echo "結束碼 $rc)" 會得到正確結果,同一行寫進 #!/usr/bin/env bash 的腳本才壞。但 zsh 對中文字也不安全:echo "$n個檔案" 在 zsh 的 UTF-8 語系下印出空字串,開了 nounset 則是 n個檔案: parameter not set。zsh 的文件說明,沒開 POSIX_IDENTIFIERS、又開著多位元組支援時,本地字元集的「字母數字」都可以用在變數名裡,所以它把「個檔案」整串當成變數名的一部分;全形標點不是字母數字,所以 $rc) 沒事。結果是:全形標點在 zsh 正確、在 bash 3.2 壞;中文字在兩邊都壞,只是壞法不同。

第二個原因是語系。同一支腳本在 C 語系下正常,所以如果排程或 CI 實際生效的 LC_CTYPE 是 C,它可能在那裡一直正常,只有在終端機裡手動跑才壞。這是從上面 env -i 的結果推論的,我沒有逐一檢查這台機器上各個排程的語系設定。

第三個原因是靜態檢查預設不報。ShellCheck 0.11.0 的預設檢查,對上面的 b.sh,以及另一支寫了 "$n)"、"$n。" 的腳本,都沒有任何警告,結束碼 0。加上 --enable=require-variable-braces 才會對每個沒加大括號的變數報 SC2250(一條風格建議,不是針對這個問題),連 "$n 個檔案" 這種沒問題的寫法也會被列出來。

寫法

變數後面緊接非 ASCII 字元時,一律加大括號:${n}個檔案、(結束碼 ${rc})。這個寫法在 bash 3.2 和 zsh 都正確。不想記規則的話,就把所有夾在文字中間的變數都寫成 ${…}。用 printf '%s個檔案\n' "$n" 也行,因為變數不再和文字相鄰。執行時加 LC_ALL=C 雖然能避開 bash 這一個問題,但會改變排序、字元類別等其他依語系的行為,我不採用。

找既有腳本裡的這種寫法,可以用 perl 掃位元組(*.sh 只涵蓋目前目錄):

perl -ne 'print "$ARGV:$.: $_" if /\$[A-Za-z_][A-Za-z0-9_]*[\x80-\xff]/; close ARGV if eof' *.sh

我拿它掃了這台機器上四個網站 repo 裡的 15 支 .sh,沒有命中。這條規則不抓 $1個,因為沒加大括號的位置參數只讀一位數字,不受影響;但它會多抓到單引號、註解裡根本不展開的字串,命中之後要人看。

如果只記得一件事

macOS 內建的 bash 3.2 在 UTF-8 語系下,會把緊貼在 $n 後面的中文字或全形標點開頭的位元組當成變數名的一部分。沒開 set -u 就安靜地印錯,開了就中止;ShellCheck 預設不會提醒你,zsh 對中文字也會出錯,只是壞法不同。變數後面接非 ASCII 字元時,寫 ${n}。

修訂紀錄

  1. 首次發布