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 |
常見錯誤與陷阱
- 「在我機器上會動」=沒有 bug:UB 可能恰好無症狀; 換編譯器、開 -O2、換平台就爆。用 Sanitizers 驗證而不是用運氣。
- 用 -O2 除錯:變數被最佳化掉、行號亂跳、函式被內聯。
除錯組態固定
-g -O0(或-Og)。 - assert 有副作用:
assert(read_next(&x) == 0)在 NDEBUG 下整行消失, 程式行為直接改變。副作用放外面,assert 只檢查結果。 - printf 沒換行就 crash:stdout 行緩衝,最後的訊息還在緩衝區裡; 除錯輸出用 stderr 或記得換行/fflush。
- 只修「症狀行」:ASan 報的是「偵測點」,根因可能在更早的越界寫入。 讀完整份報告(尤其 allocated/freed by 的堆疊)再動手。
練習題
- 【易】把
01_ub_catalog.c的每個ENABLE_BUG_*開關逐一改為 1, 搭配-fsanitize=address,undefined重編執行,記錄每種 UB 的報告長相。 - 【易】寫一個含未初始化變數的函式,分別用
-O0與-O2編譯執行, 觀察輸出差異;再用-Wall讓編譯器直接抓出來。 - 【中】用 lldb 對
03_assert_debug.c設中斷點:在push進入時停下, 印出參數、單步走到 assert,觀察它如何中止程式。 - 【中】故意寫一個洩漏 3 塊記憶體的程式,用
ASAN_OPTIONS=detect_leaks=1(macOS 需要)跑 ASan 的 LeakSanitizer, 對照報告把洩漏一一修掉。 - 【難】研究 strict aliasing:寫
*(float*)&i的版本與 memcpy 版本, 用-O2 -fstrict-aliasing -Wstrict-aliasing比較行為與警告, 再用 union 寫第三版並說明 C11 對 union 雙關的規定。 - 【難】為你自己過去的一個專案加上「除錯建置組態」:
-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 如何影響產出的組語