從規則走到陷阱
在第二篇裡,你學到了關於未定義行為的硬道理:它不是「編譯器高興怎樣就怎樣」,也不等於「依平台而定」。C 標準對一個執行了 UB 運算的程式,根本就「不施加任何要求」,而最佳化器被允許假設 UB 永遠不會發生。那個假設,正是你遇過的那些詭異現象的源頭——在 `-O2` 下消失的程式碼、被刪掉的分支、以及那種時間旅行效應:UB 的後果出現在「觸發它的那一行之前」。這一篇把那條抽象的規則,化成一份野外圖鑑:產生大多數真實世界 UB 的三個陷阱、每一個在小巧的程式碼裡長什麼樣子、以及它為何咬人。
從這裡開始,請記住一個心智模型。UB 是編譯器和你簽下的一紙合約:「你」承諾絕不做那件被禁止的事,作為交換,編譯器承諾給你不必為它做檢查的快速程式碼。當你違背承諾時,你不會得到一個工整的錯誤——你得到的,是那些「信任了你」的最佳化所掉出來的任何東西。底下這三個陷阱,有號整數溢位、越界存取、和未初始化讀取,正是初學者最常違約的三個地方,所以它們值得我們一次一個、仔細而誠實地端詳。
陷阱一:有號整數溢位
這是個幾乎讓每個人都吃驚的陷阱,因為它的關鍵,繫於二進位那一階的一個區別:在 C 裡,有號溢位和無號溢位「不一樣」。無號運算會環繞(wrap)——它被定義為對 2^N 取模來計算,所以一個 `unsigned int` 衝到頂再加一,會依規則悄悄變成 0。但是有號溢位是未定義行為。當一個處於最大值 0x7fffffff 的 `int` 再多得一,標準「並沒有」說它會變成最小的負數——它說的是「不會發生任何你能依賴的事」。硬體也許會環繞成一個負數;最佳化器則可能做出更奇怪的事,因為它被允許假設那次溢位根本不會發生。
這除了「算出一個錯的數」之外,為何要緊?因為那個假設給了最佳化器真正的力量,去刪掉你的安全檢查。想想一個寫在加法「之後」的檢查:`if (x + 1 < x) { /* 溢位了! */ }`。你的本意是抓住環繞。但編譯器這樣推理:「`x + 1 < x` 只有在 `x + 1` 溢位時才可能為真;有號溢位是 UB;我可以假設 UB 永不發生;因此這個條件恆為假。」它就把整個分支刪掉了。你的溢位檢查在 `-O2` 下消失了,「正因為你用溢位來偵測溢位」——這正是第二篇的那種推理。誠實的教訓是:絕不要靠「讓它發生」來測試有號溢位。在你計算「之前」就先檢查。
// WRONG: tries to detect overflow by letting it happen.
// signed overflow is UB, so the optimizer may delete the branch.
int add_bad(int x) {
if (x + 1 < x) return -1; // may be compiled away at -O2
return x + 1;
}
// RIGHT: check BEFORE the operation, no UB is ever executed.
#include <limits.h>
int add_ok(int x) {
if (x > INT_MAX - 1) return -1; // would overflow? bail first
return x + 1;
}陷阱二:越界讀取或寫入
第二個陷阱最有名,而且它直接源自陣列與指標的運作方式。在 C 裡,陣列只是一串沒有附帶長度的位元組,而 `a[i]` 被定義為 `*(a + i)`——純粹的指標算術。語言「不給你任何自動的邊界檢查」:如果 `a` 有 4 個元素,索引 0、1、2、3 是有效的,而 `a[4]` 或 `a[-1]` 就是越界存取——未定義行為。標準允許你形成一個指向「最後一個元素再過去一格」的指標(你可以計算 `a + 4` 來標記結尾),但你絕不可以「解參考」它。一旦讀或寫過了邊界,你就完全脫離合約了。
這些在實務上從哪來?絕大多數來自那個不起眼的差一錯誤:一個迴圈寫成 `for (i = 0; i <= n; i++)` 而非 `i < n`,於是最後一輪碰到了 `a[n]`,多走了一格。或者一個字串緩衝區的大小只夠裝文字、卻沒留給空字元結尾字串那個收尾的 `'\0'`。它的奸詐之處在於:越界鮮少當場崩潰。`a[4]` 常常落在「下一個」存活變數上、或落在配置器隱藏的簿記資料上,於是程式拖著一個被悄悄汙染的值繼續跛行,而那個區段錯誤——如果它真的來的話——會在稍後、在某個無辜的地方才來。「它沒崩潰」不等於「它沒事」。
而越界不只是個正確性的錯誤,它更是「那個」經典的安全漏洞。當越界寫入溢進了由攻擊者輸入所挑選的相鄰記憶體——把一個堆疊陣列越界蓋過返回位址、或把一個堆積區塊越界蓋過下一個區塊——你就有了一個緩衝區溢位,這是 C 歷史上被利用得最多的一類記憶體錯誤。整個故事正是緊接著下一篇的主題,你會在那裡看清為何 `gets()` 這類函式被徹底禁用。眼下,先握住核心事實:在 C 裡,「你」就是那個邊界檢查。沒有任何東西替你做這件事。
陷阱三:未初始化的讀取
第三個陷阱最安靜,因為那個壞掉的值,看起來就像個普通的值。當你以自動儲存期寫下 `int x;`——一個位於呼叫堆疊上的區域變數——C「不會」替你把它歸零。它只是把先前某次呼叫遺留在那個堆疊框(stack frame)裡、碰巧坐在那兒的任何位元組交給你。在你指派之前就讀取 `x`,就是在使用一個未初始化變數,而那次讀取是未定義行為。來自 malloc() 的記憶體也一樣:它回傳的區塊,內容是不確定的。(只有靜態與全域變數、以及 calloc(),才從歸零開始。)
初學者以為這意味著「我會拿到一個隨機數字」,那頂多是個 bug。現實更糟、也更詭異。因為未初始化讀取是 UB,最佳化器可以假設它永遠不會產生一個有意義的值,而實務上,一個未初始化的變數,可能在「同一個函式裡的不同點讀出不同的值」、或讓一個分支在編譯器的推理中「兩條路都走」。一個常見的真實症狀:程式在 `-O0` 下能跑,因為那個堆疊槽碰巧裝著 0,卻在 `-O2` 下壞掉,因為最佳化器把變數留在一個裝著殘餘垃圾的暫存器裡。「在我的機器上沒問題」正是這個陷阱的口頭禪,因為那些殘餘的位元組,每次執行、每次建置都不同。
一套抓出全部三者的習慣
這三個陷阱共有一種殘忍的個性:它們很安靜、它們取決於最佳化等級、而且它們常在離出生地很遠的地方造成汙染。這使得「就仔細讀程式碼」單憑自己是個薄弱的防禦。好消息是,你剛爬過的那一階工具,給了你能精準抓住這些東西的機器。用 `-fsanitize=undefined` 編譯進去的 未定義行為消毒器,會在有號溢位與壞位移執行的那一瞬間攔下它們,並印出檔名與行號。位址消毒器(`-fsanitize=address`)會抓堆疊與堆積上的越界讀寫。而記憶體消毒器則專門獵捕未初始化的讀取。
- 永遠開著警告來建置:`gcc -O2 -Wall -Wextra` 在編譯期就免費抓出許多未初始化與溢位的錯誤。
- 做測試執行時,加上消毒器:`gcc -O1 -g -fsanitize=address,undefined main.c` 會把那些安靜的陷阱,變成一次大聲、且定位明確的崩潰。
- 在你計算「之前」就檢查溢位(拿去和 INT_MAX 比對),絕不靠檢查一個已經溢位的結果。
- 把每一次陣列存取都當成你的責任:自己追蹤長度,絕不讓索引碰到那個大小(size)。
- 在宣告時就初始化每一個變數——`int x = 0;`、`char *p = NULL;`——讓未初始化讀取根本無從發生。
請握住整個這一階的誠實框架。這些並不是什麼奇異、只有專家才會犯的錯誤;它們是連最資深的 C 程式設計師都還會犯的日常閃失,而這正是工具存在的原因。重點不是去覺得 C 很奸險、然後放棄,而是把「合約的邊界在哪裡」內化於心,並建立起那些反射動作——你追蹤的邊界、你初始化的值、你預先檢查的溢位——讓你穩穩待在邊界的正確一側。有了這些反射,下一篇就能帶你走進最鋒利的那個具體案例:一次越界寫入如何成為一個緩衝區溢位,以及為何一個老函式贏得了永久的禁令。