C 給你效能與自由,代價是:寫錯時它不會保護你。未定義行為(Undefined Behavior, UB) 是 C 最危險的概念——程式可能 crash、可能默默算錯、也可能「看起來正常」直到換編譯器。 本單元建立兩件事:一份常見 UB 目錄(知道地雷在哪),與一套現代除錯工具鏈 (編譯警告、lldb/gdb、Sanitizers、assert、printf 除錯),把地雷變成可以系統化排除的問題。

學習目標

  • 認識最常見的 UB:陣列越界、signed overflow、未初始化、懸空指標、strict aliasing
  • 理解「UB 不保證 crash」為什麼比 crash 更可怕(最佳化下的行為改變)
  • 把編譯警告開好開滿:-Wall -Wextra -Wpedantic 與朋友們
  • 會用 lldb/gdb 的基本指令:break / run / next / step / print / bt
  • 會用 AddressSanitizer 與 UndefinedBehaviorSanitizer,並能解讀輸出
  • 知道 valgrind 的定位(Linux;macOS 上以 Sanitizers 為主)
  • 會用 assert 做防禦式程式設計、用 #ifdef DEBUG 管理除錯輸出

對照章節 / 參考來源

  • K&R 附錄 A/B 中散見的「行為未定義」條目(K&R 時代就存在!)
  • C11 標準附錄 J.2(Undefined behavior 完整清單,200+ 條)
  • clang 文件:AddressSanitizer、UndefinedBehaviorSanitizer
  • John Regehr, "A Guide to Undefined Behavior in C and C++"(部落格系列)

觀念講解

1. 什麼是 UB?為什麼不直接 crash?

C 標準對某些操作「不規定任何行為」,編譯器因此假設它們不會發生, 並以此為前提最佳化。後果是三種都可能:立即 crash、默默算錯、恰好正常。

int f(int x) { return x + 1 > x; }   /* 編譯器直接優化成 return 1; */
/* 因為 signed overflow 是 UB,「x+1 溢位」被假設不可能發生 */

危險之處:行為隨最佳化等級、編譯器版本、平台而變。 -O0 正常、-O2 出事的 bug,多半就是 UB。

2. 常見 UB 目錄

UB 例子 典型後果
陣列越界讀寫 a[10](大小 10 的陣列) 踩壞鄰居變數/heap 中繼資料
signed overflow INT_MAX + 1 最佳化後邏輯消失(如上例)
未初始化變數 int x; use(x); 值不確定、每次執行不同
懸空指標 free 後繼續用、回傳區域變數位址 heap corruption、詭異值
NULL 解參考 *(int *)NULL 通常 segfault(不保證!)
double free free(p); free(p); allocator 中繼資料毀損
位移過量 1 << 32(int 32 位) 各平台結果不同
strict aliasing 違規 *(float *)&int_var 最佳化後讀到舊值
修改字串常量 char *s = "hi"; s[0]='H'; segfault(唯讀段)

strict aliasing 簡介:編譯器假設「不同型別的指標不會指向同一塊記憶體」 (char* 例外),因此透過轉型指標讀寫會被重排/快取。 合法的型別雙關(type punning)方式:memcpy 或 union。

3. 第一道防線:把警告開滿

cc -std=c11 -Wall -Wextra -Wpedantic -o prog prog.c
  • -Wall:常見問題(未初始化、未使用、格式字串不符……)——不是全部!
  • -Wextra:更多(符號比較、遺漏的欄位初始化……)
  • -Wpedantic:嚴格遵循標準,抓非標準擴充
  • 進階:-Wconversion(隱式轉換)、-Wshadow(遮蔽)、 -Werror(警告當錯誤,CI 必備)

原則:零警告是底線。警告是編譯器免費送你的 code review。

4. 除錯器:lldb(macOS)/ gdb(Linux)

先用 -g 編譯(保留符號與行號資訊;除錯建議關最佳化 -O0):

cc -std=c11 -g -O0 -o prog prog.c
lldb ./prog
目的 lldb gdb
設中斷點 b main / b file.c:42 break main
執行 run(r) run
下一行(不進函式) next(n) next
下一行(進入函式) step(s) step
印值 p expr / v print expr
呼叫堆疊 bt bt
繼續 continue(c) continue
看原始碼 list list

最省時的用法:程式 crash 後直接 lldb ./prog + run, 停在出事點,bt 看呼叫鏈、v 看區域變數——多數 segfault 五分鐘內定位。

5. Sanitizers:執行期的 UB 雷達

編譯連結時加 -fsanitize=address,undefined -g:

  • ASan(AddressSanitizer):越界、use-after-free、double free、洩漏。 代價約 2 倍慢、2~3 倍記憶體——開發期永遠開著都划算。
  • UBSan(UndefinedBehaviorSanitizer):signed overflow、位移過量、 NULL 解參考、未對齊……逐條印出「哪一行、哪種 UB」。

輸出解讀(ASan 報告三大件): 1. 錯誤類型與位址:heap-buffer-overflow on address 0x... 2. 出事的呼叫堆疊(含檔名行號) 3. 該記憶體的來歷:在哪配置、在哪釋放(use-after-free 時)

valgrind:Linux 上的老牌記憶體檢查器(valgrind ./prog), 不需重新編譯即可偵測。macOS 新版系統支援有限,本教材以 Sanitizers 為主; 在 Linux 伺服器上它仍是標配工具。

6. assert 與防禦式程式設計

#include <assert.h>
assert(index >= 0 && index < len);   /* 不變量:違反即中止並印出位置 */
  • assert 檢查的是「程式邏輯上不可能為假」的條件(前置/後置條件、不變量)。
  • 使用者輸入錯誤不該用 assert——那要用正常的錯誤處理。
  • 定義 NDEBUG 後 assert 全部消失(cc -DNDEBUG), 所以 assert 的運算式不可以有副作用。

7. printf 除錯與 #ifdef DEBUG

printf 仍是最常用的除錯工具,但要用得專業:

#ifdef DEBUG
#define DBG(fmt, ...) fprintf(stderr, "[%s:%d %s] " fmt "\n", \
                              __FILE__, __LINE__, __func__, ##__VA_ARGS__)
#else
#define DBG(fmt, ...) ((void)0)      /* release 版零成本 */
#endif
  • 輸出到 stderr(不進管線、不受 stdout 緩衝延遲影響)
  • 帶上 __FILE__/__LINE__/__func__ 自動標位置
  • 用 -DDEBUG 開關,發布版完全移除
  • 除錯 crash 時記得 fflush(stderr) 或逐行輸出——緩衝區可能吞掉最後線索

範例程式

範例採「含 bug 版本 + 修正版」對照設計:bug 以註解標明、 危險路徑一律用 #if ENABLE_BUG(預設 0)保護,直接執行絕不 crash; 想親眼看 Sanitizer 抓蟲,把個別開關改為 1 並用 -fsanitize=address,undefined 重編。

檔案 內容 編譯 / 執行
01_ub_catalog.c UB 目錄:越界/溢位/未初始化/懸空指標/strict aliasing,每項「bug 版(#if 保護)+ 修正版」對照 cc -std=c11 -Wall -Wextra -o out 01_ub_catalog.c && ./out
02_sanitizers_demo.c 為 ASan/UBSan 設計的展示:預設走安全路徑;開啟開關重編即可看報告;含輸出解讀指南 cc -std=c11 -Wall -Wextra -fsanitize=address,undefined -g -o out 02_sanitizers_demo.c && ./out
03_assert_debug.c assert 防禦式程式設計、NDEBUG 行為、DBG 巨集與 #ifdef DEBUG 分級除錯輸出 cc -std=c11 -Wall -Wextra -DDEBUG -o out 03_assert_debug.c && ./out

常見錯誤與陷阱

  1. 「在我機器上會動」=沒有 bug:UB 可能恰好無症狀; 換編譯器、開 -O2、換平台就爆。用 Sanitizers 驗證而不是用運氣。
  2. 用 -O2 除錯:變數被最佳化掉、行號亂跳、函式被內聯。 除錯組態固定 -g -O0(或 -Og)。
  3. assert 有副作用:assert(read_next(&x) == 0) 在 NDEBUG 下整行消失, 程式行為直接改變。副作用放外面,assert 只檢查結果。
  4. printf 沒換行就 crash:stdout 行緩衝,最後的訊息還在緩衝區裡; 除錯輸出用 stderr 或記得換行/fflush。
  5. 只修「症狀行」:ASan 報的是「偵測點」,根因可能在更早的越界寫入。 讀完整份報告(尤其 allocated/freed by 的堆疊)再動手。

練習題

  1. 【易】把 01_ub_catalog.c 的每個 ENABLE_BUG_* 開關逐一改為 1, 搭配 -fsanitize=address,undefined 重編執行,記錄每種 UB 的報告長相。
  2. 【易】寫一個含未初始化變數的函式,分別用 -O0 與 -O2 編譯執行, 觀察輸出差異;再用 -Wall 讓編譯器直接抓出來。
  3. 【中】用 lldb 對 03_assert_debug.c 設中斷點:在 push 進入時停下, 印出參數、單步走到 assert,觀察它如何中止程式。
  4. 【中】故意寫一個洩漏 3 塊記憶體的程式,用 ASAN_OPTIONS=detect_leaks=1(macOS 需要)跑 ASan 的 LeakSanitizer, 對照報告把洩漏一一修掉。
  5. 【難】研究 strict aliasing:寫 *(float*)&i 的版本與 memcpy 版本, 用 -O2 -fstrict-aliasing -Wstrict-aliasing 比較行為與警告, 再用 union 寫第三版並說明 C11 對 union 雙關的規定。
  6. 【難】為你自己過去的一個專案加上「除錯建置組態」: -g -O0 -DDEBUG -fsanitize=address,undefined -Wall -Wextra -Werror, 修完所有浮出來的問題。

延伸閱讀

  • C11 標準附錄 J.2(UB 清單)——掃一遍會謙虛很多
  • John Regehr 部落格 "A Guide to Undefined Behavior in C and C++"(三部曲)
  • clang docs:AddressSanitizer / UndefinedBehaviorSanitizer / ThreadSanitizer
  • 《The Art of Debugging with GDB, DDD, and Eclipse》
  • CERT C Coding Standard(安全編碼規則,逐條對應 UB)
  • Matt Godbolt 的 Compiler Explorer(godbolt.org):親眼看 UB 如何影響產出的組語