C 與 C++ 中未定義行為的普遍性
數十年來,C 與 C++ 一直是系統程式設計的基石,因其效能和與硬體的接近程度而備受推崇。然而,隨著我們進入 21 世紀,一個令人清醒的現實已經浮現:編寫真正「正確」的 C 或 C++ 幾乎是不可能的。語言規範中充斥著未定義行為 (Undefined Behavior, UB),以至於即使是擁有數十年經驗的專家級程式設計師,也可能寫出違反標準的程式碼。
這不僅僅是學術上的吹毛求疵。在現代編譯環境中,UB 是個無聲的殺手,可能導致災難性的失敗、安全性漏洞以及幾乎無法除錯的邏輯錯誤。
超越顯而易見的問題:UB 究竟意味著什麼
大多數開發者都熟悉那些「重大」的 UB 陷阱:double-frees、use-after-free 以及陣列越界存取。因為 C/C++ 不是記憶體安全語言,這些被視為預期的風險。然而,一種常見的誤解仍然存在,認為 UB 只有在開啟優化時才會產生影響。有些人認為,如果沒有高層級的優化,編譯器就不會「利用」UB 來進行惡意行為。
這是一個根本性的誤解。UB 並不代表編譯器正在主動嘗試破壞你的程式碼;它意味著編譯器被允許假設你的程式碼是有效的。當程式設計師引入 UB 時,他們本質上是在告訴編譯器:「這種狀態永遠不會發生。」因此,編譯器可能會省略必要的檢查,或者生成忽略程式設計師實際意圖的程式碼,因為該意圖從未在語言的規則內合法地表達出來。
正如一位社群成員所指出的:
編譯器預期 UB 程式碼「不會」發生,因此如果你執意寫出 UB 程式碼,編譯器(特別是優化器)被允許將其轉換為任何對其「快樂路徑」(happy path) 有利的結果。
不可見的地雷區:常見 UB 的範例
UB 的普遍程度遠遠超過記憶體損壞。它隱藏在最平凡的操作中。
對齊與指標轉換
一個簡單的解引用整數指標的函式,如果指標沒有正確對齊(例如,不是 sizeof(int) 的倍數),就可能觸發 UB。雖然 x86 架構對未對齊讀取非常寬容,但其他架構如 SPARC 或 Alpha 可能會觸發 SIGBUS 或需要核心模擬,從而降低效能或導致程式崩潰。
至關重要的是,UB 往往發生在解引用之前。將位元組緩衝區(例如網路封包)直接轉換為整數指標,通常被認為是 UB,因為編譯器可能會為了安全性標記或垃圾回收而為指標的低位元分配特定含義。
標準函式庫陷阱:isxdigit()
即使是標準函式庫函式也能成為陷阱。isxdigit() 函式預期接收一個可以表示為 unsigned char 或 EOF 的 int。如果程式設計師在 char 為有符號 (signed) 的系統上傳入一個 char,任何超出 0-127 範圍的值都會變成負整數。如果 isxdigit() 的內部實作中使用該輸入作為陣列索引而未檢查負值,則可能導致越界記憶體讀取——在嵌入式系統中,這可能會觸發 I/O 映射記憶體。
浮點數轉換
將 float 轉換為 int 是 UB 的常見來源。根據 C23 標準,如果浮點數值的整數部分無法由目標整數型別來表示,行為就是未定義的。這包括非有限值(NaN 或 Infinity)。
為了安全地將 float 轉換為整數,開發者必須實作廣泛的有限性與範圍邊界的檢查——對於一個看似僅僅是單次轉換的任務來說,這需要大量的樣板程式碼。
空指標悖論
雖然大多數開發者假設 NULL 是位址 0,但 C 標準僅保證 NULL 與 0 相等。解引用空指標是 UB 的典型範例。此外,使用 memset 將結構體清零並不技術上保證指標成員會被設定為該平台的 NULL 值,儘管這在大多數現代系統上都能正常運作。
未來的路徑:人類專家 vs. AI
鑑於 C23 標準中包含數百個「未定義」一詞的實例,手動審核大型程式碼庫以尋找 UB 的任務是極其繁重的。這正是大語言模型 (LLMs) 展現出驚人實用性的地方。LLMs 通常能夠發現細微的 UB——例如錯誤的 printf 格式說明符或對齊問題——這些是人類審核員可能錯過的。
然而,這也引入了新的緊張關係。雖然 AI 可以找到這些錯誤,但確認修復方案仍需要專家級的人類。隨著產業轉趨向安全性,存在一種風險,即我們依賴 AI 來「修復」UB,卻沒有完全理解底層層次架構的架構影響,從而可能引入新的、更細微的錯誤。
結論
在現代時代,若沒有嚴格的工具與監督,編寫 C 或 C++ 程式碼是越來越不負責任的行為。人類閱讀程式碼的方式與現代編譯器解釋其方式之間的差距已經變得太大了。無論是透過採用記憶體安全語言,或是整合 LLM 驅動的審核,產業必須找到一種方法來解決 C 抽象機中固有的系統性不穩定性。