[C 的那些眉角] undefined behavior — 那些「在我電腦會動」的陷阱

有一次 review 一個學弟寫的程式碼,他很得意地跟我說:「這段我測過了,跑得好好的。」

我看了一眼,裡面有一行:

int i = INT_MAX;
i = i + 1;

我問他:「這行你覺得會發生什麼事?」

他說:「變成負的啊,wrap around 嘛,這不是常識嗎?」

我沒有馬上反駁,只是說:「你在 x86 上測,用 GCC -O0 測的吧?」

他愣了一下。對,就是這樣。


Undefined behavior 不是「會出錯」,是「什麼都有可能」

寫 C 這麼多年,我發現最容易被誤解的一件事就是:大家以為 undefined behavior(UB)代表程式會 crash,或至少會有個「合理的錯誤結果」。

不是這樣的。C 標準對 UB 的定義,白話講就是「規格沒規定,編譯器愛怎麼處理都可以」。可以 crash,可以印出奇怪的數字,可以刪掉整段程式碼,也可以剛好長出你想要的結果——然後你就以為它是對的。

最麻煩的就是最後這種。因為它會通過你所有的測試,通過 code review,甚至在產品上跑好幾年都沒事,直到某天換了編譯器版本,或加了個 optimization flag,整個行為就變了。

這篇整理幾個我自己踩過、或是同事踩過的 UB 案例,都是那種「在我電腦上會動」的類型。


案例一:signed overflow,最經典也最陰

int i = INT_MAX;
i = i + 1;

很多人直覺覺得這會 wrap 成 INT_MIN。在某些平台、某些編譯器設定下,確實會這樣。但 C 標準講得很清楚:signed integer overflow 是 UB,unsigned overflow 才有明確定義的 wrap 行為。

問題不是「這次跑出來是什麼」,問題是編譯器可以假設「signed overflow 永遠不會發生」,然後拿這個假設去做優化。

我自己踩過的版本大概長這樣:

// 檢查是否會 overflow,這是很多人的直覺寫法
if (a + b < a) {
    // overflow 發生了,做點什麼
}

這段程式碼的意圖是:如果 a + b 加完比 a 還小,代表 overflow 了。邏輯上很合理,對吧?

但在 -O2 開優化的情況下,GCC 可能直接把這個 if 判斷式整個刪掉。因為編譯器的邏輯是:「signed overflow 是 UB,UB 不會發生,所以 a + b < a 這件事在 signed 情況下永遠不會成立,這行 if 是死碼,砍掉。」

我第一次遇到這個是在一個封包長度檢查的地方,debug 版正常,release 版(開了 -O2)直接漏掉了溢位檢查,導致後面 buffer 寫爆。追了快一天才發現是編譯器把我的防呆邏輯優化掉了。

正確做法

// 用 unsigned 做溢位檢查,這是定義明確的行為
if ((unsigned int)a + (unsigned int)b < (unsigned int)a) {
    // overflow
}

// 或者,用編譯器內建的 overflow 檢查函式
if (__builtin_add_overflow(a, b, &result)) {
    // overflow
}

__builtin_add_overflow 是 GCC/Clang 都支援的內建函式,會回傳是否 overflow,這是目前我自己專案裡的標準寫法。


案例二:strict aliasing,型別轉來轉去的隱藏地雷

這個是我吃過最大的虧。在做封包解析的時候,很常見的寫法是把一段 byte buffer 直接轉型成 struct 來讀:

uint8_t buf[8];
// ... 從 UART 收資料進 buf

float *fp = (float *)buf;
float value = *fp;

這段程式碼的問題叫 strict aliasing violation。C 標準規定,透過不相容的型別指標去存取同一塊記憶體,是 UB(有一些例外,像 char* 可以指向任何型別)。

編譯器會假設「不同型別的指標不會指向同一塊記憶體」,然後根據這個假設做優化,比如把讀取順序重排、把值 cache 在暫存器裡不重新讀取。

我踩到的情況是:在一個 sensor 資料處理的函式裡,先用 uint32_t* 把 raw data 讀出來做一些位元運算,接著又用 float* 去讀同一塊記憶體轉成浮點數。debug build 沒事,開了 -O2 之後數值開始跳來跳去,而且不是每次都錯,跟優化器怎麼排指令有關。

正確做法

memcpy 做型別之間的轉換,這是唯一標準保證安全的方式:

uint8_t buf[8];
float value;
memcpy(&value, buf, sizeof(value));

有人會覺得 memcpy 很慢,多一次複製感覺很浪費。但現代編譯器在型別大小固定、對齊沒問題的情況下,通常會把這個 memcpy 直接優化成一個 load 指令,跟你手動轉型的效能是一樣的,只是行為變成標準定義的、不會被激進優化搞爛。

如果你用的是 C11 以後的編譯器,也可以用 union(這其實是編譯器擴充行為,嚴格來說也不是標準保證的,但 GCC/Clang 都明確支援),不過我自己還是偏好 memcpy,可攜性比較沒有疑慮。


案例三:未初始化變數,看運氣的隨機數產生器

int status;

if (some_condition) {
    status = 1;
}

if (status == 1) {
    do_something();
}

如果 some_condition 是 false,status 從來沒被賦值就被拿去比較,這是讀取未初始化變數,UB。

實務上最恐怖的地方是:這種 bug 在 debug build、在某個特定的 stack layout 下,status 剛好殘留的值就是符合你預期的,於是你以為程式邏輯是對的。

我遇過一次是在一個狀態機的初始化流程裡,某個 enum 變數在特定分支沒有被賦值,結果在開發板 A 上跑起來完全正常,量產換了另一批 MCU(同型號但 stack 使用習慣不同的 bootloader)之後開始間歇性地跑錯狀態。查了兩天,最後開 -Wall -Wextra 才看到編譯器早就在警告了,只是我們一直沒認真看警告訊息。

正確做法

老實說這個沒什麼高深的解法,就是紀律問題:

  • 宣告時就給初始值,即使是 0 或 -1 這種「不可能的值」也好,至少行為是確定的
  • 打開 -Wall -Wextra -Wuninitialized,這些警告不是裝飾用的
  • 靜態分析工具(cppcheck、clang-tidy)可以抓到大部分這類問題,別懶得跑

案例四:函式呼叫順序,你以為的「由左到右」

printf("%d %d\n", i++, i++);

這行在很多教學裡會被拿出來當範例,但正確答案不是「兩個 i++ 的結果」,而是:這是 UB,因為函式引數的求值順序沒有定義

不只是這種明顯的例子,稍微隱晦一點的:

int arr[3] = {0, 0, 0};
int i = 0;
arr[i++] = i;

這行也是 UB。左邊的 arr[i++] 跟右邊的 i 誰先被求值,標準沒規定。有些編譯器會先算左邊索引再遞增,有些會先遞增再算,結果可能是 arr[0] = 1 也可能是別的組合。

我自己是把這類寫法當成禁區,寧可多寫一行也不要圖方便:

int idx = i;
i++;
arr[idx] = i;

醜一點,但每個人看得懂、每個編譯器算出來的結果都一樣,這才是我要的。


為什麼「在我電腦上會動」這句話特別可怕

寫韌體這行做久了,我對這句話有種生理性的警覺。因為嵌入式開發常常要跨平台、跨編譯器、跨優化等級:

  • 開發板換了 MCU 型號
  • 編譯器從 GCC 8 升到 GCC 12
  • Debug 版本切到 release 版本,優化等級從 -O0 變 -O2
  • 甚至只是換了一個 linker script,記憶體佈局不一樣

任何一個變因都可能讓潛藏的 UB 從「沒事」變成「炸裂」。而且炸裂的方式往往跟你的直覺完全不搭邊,你會去懷疑硬體、懷疑通訊協定、懷疑任何地方,就是不會想到「喔,原來是我三年前寫的那行程式碼本來就是未定義行為,只是運氣好一直沒爆」。

工具上我自己現在的做法:

  • 開發階段全程掛 -Wall -Wextra -fsanitize=undefined,UBSan 抓 UB 的能力真的比人眼可靠
  • CI 裡至少跑一次 release 優化等級的測試,不要只測 debug build
  • 遇到「這段邏輯感覺怪怪的但測試都過」的程式碼,回頭去查標準,不要只相信「反正它現在能動」

小結

這篇整理的四個案例都不算冷門,甚至可以說是 C 語言裡最常被踩的幾個坑。但每次跟人聊起來,還是會發現不少人對「UB 到底有多不可預期」這件事沒有足夠的警覺。

規格沒定義的東西,不代表編譯器不會拿去做激進優化。你以為的「合理猜測」,在編譯器眼裡完全不成立。這中間的落差,就是那些「在我電腦上會動」的程式碼,未來在某個你想不到的時間點爆掉的原因。

你也有 UB 踩坑經驗嗎?歡迎留言分享,互相取暖一下。

發佈留言