本檔收錄 6 題由易到難的練習,全部採「找出並修正 bug」形式:
題目給含 bug 的程式碼,你要用本單元的工具(警告、lldb、Sanitizers、
leaks、assert)把蟲抓出來修好。exercises/ 內是修正版:
cc -std=c11 -Wall -Wextra -o /tmp/out exercises/ex1_warnings_fix.c && /tmp/out
文中所有警告訊息、Sanitizer 報告、lldb 過程都是在 macOS (Apple clang 21,Apple Silicon)上真實執行的記錄。 修正版解答檔直接執行絕不 crash。
練習 1:讓警告替你 code review(難度:基礎)
題目
下面的程式「能編譯」,但藏著五個 bug。先不要用眼睛找——
用 cc -std=c11 -Wall -Wextra 編譯它,讓編譯器把五個問題
全部指出來,然後逐一修正到零警告、且答案正確(平均應為 87)。
#include <stdio.h>
static int average(const int *a, int n)
{
int sum;
for (unsigned i = 0; i < n; i++)
sum += a[i];
}
int main(void)
{
int scores[5] = { 80, 92, 75, 88, 100 };
char *msg;
printf("count = %d\n", sizeof scores / sizeof scores[0]);
printf("avg = %d\n", average(scores, 5));
return 0;
}
解題思路
- 先讓工具說話。
-Wall -Wextra的真實輸出(節錄):
warning: variable 'sum' set but not used [-Wunused-but-set-variable]
warning: comparison of integers of different signs:
'unsigned int' and 'int' [-Wsign-compare]
warning: non-void function does not return a value [-Wreturn-type]
warning: variable 'sum' is uninitialized when used here [-Wuninitialized]
warning: format specifies type 'int' but the argument has type
'unsigned long' [-Wformat]
warning: unused variable 'msg' [-Wunused-variable]
6 warnings generated.
六條警告、五個 bug(sum 被抓了兩次)——每一條都是真的。
2. 逐條翻譯成修法:
- -Wuninitialized:int sum; 從垃圾值起跳 → int sum = 0;
- -Wsign-compare:unsigned i < int n → 兩邊型別統一
(int i);若 n 為負,unsigned 比較會把它變成天文數字;
- -Wreturn-type:函式承諾回傳 int 卻掉出結尾——呼叫端
拿到的是垃圾(UB)→ 補 return sum / n;
- -Wformat:sizeof 的結果是 size_t,%d 對它是 UB
→ %zu;
- -Wunused-variable:沒用的 msg 刪掉——雜訊會掩護
真正的問題。
3. 為什麼「能編譯」:警告不是錯誤,編譯器仍會產出
執行檔——執行檔裡的 UB 就交給運氣。零警告是底線,
CI 上加 -Werror 把底線變硬規則。
4. 修正版順手補了 n <= 0 的邊界(除以零也是 UB)。
參考解答
/*
* 練習 1(修正版):讓警告全部閉嘴——而且是用「修好」的方式
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex1_warnings_fix.c
* 執行:/tmp/out
*
* 對應 EXERCISES.md 練習 1 的含 bug 版本:那一版藏著五個
* -Wall -Wextra 能全數抓到的錯誤(未初始化、有號/無號比較、
* 少 return、格式字串不符、未使用變數)。本檔是逐一修正後的
* 版本——零警告、答案正確。
*/
#include <stdio.h>
/*
* 修正 1:sum 必須初始化為 0(原版是垃圾值起跳)。
* 修正 2:迴圈變數與 n 同為 int(原版 unsigned i < int n 引發
* -Wsign-compare;n 若為負還會變成天文數字迴圈)。
* 修正 3:函式承諾回傳 int 就每條路徑都要 return(原版少了)。
*/
static int average(const int *a, int n)
{
if (n <= 0)
return 0; /* 邊界:空陣列 */
int sum = 0; /* 修正 1 */
for (int i = 0; i < n; i++) /* 修正 2 */
sum += a[i];
return sum / n; /* 修正 3 */
}
int main(void)
{
int scores[5] = { 80, 92, 75, 88, 100 };
/* 修正 4:原版宣告了沒用到的 char *msg(-Wunused-variable) */
/* 修正 5:sizeof 的結果是 size_t,格式要用 %zu(原版用 %d,
-Wformat 抓到;在 64 位平台上 %d 對 size_t 是 UB) */
printf("count = %zu\n", sizeof scores / sizeof scores[0]);
printf("avg = %d\n", average(scores, 5));
printf("empty = %d\n", average(scores, 0));
return 0;
}
程式碼講解
- 五個修正各對應一條警告,註解編號一一對上—— 修 bug 時「一條警告一個 commit」的習慣讓回溯容易。
average現在對空陣列回 0 而不是除以零——警告修完後 「編譯器看不到的邏輯邊界」仍要自己想(工具是下限 不是上限)。-Wall這名字是歷史遺留——它不是全部警告;-Wextra補一批;更嚴格還有-Wconversion、-Wshadow、-Wpedantic。本教材基準是-Wall -Wextra零警告。- 易錯處:用 cast 讓警告閉嘴(如
(int)(sizeof...)消 -Wformat)——警告是症狀,cast 是止痛藥;先確認語意 正確再考慮 cast(此處%zu才是正解)。
範例輸出
count = 5
avg = 87
empty = 0
練習 2:未初始化變數 — -O0 與 -O2 的雙面人(難度:基礎)
題目
下面的 pick_max 忘了初始化 best。分別用 -O0 與 -O2
編譯執行,記錄輸出差異;找出能在編譯期抓到它的工具;
最後修正它(順手處理空陣列)。
static int pick_max(const int *a, int n)
{
int best; /* bug: 忘了初始化 */
for (int i = 0; i < n; i++)
if (a[i] > best)
best = a[i];
return best;
}
/* 測資:{ -7, -3, -42, -1, -19 },正確答案 -1 */
解題思路
- 實驗記錄(真實輸出):
--- -O0 --- --- -O2 ---
max = -1 max = 2147483647
max = -1 max = 2147483647
-O0 每次都對(堆疊上的殘值碰巧 ≤ -42,於是迴圈
蓋掉了它);-O2 每次都錯(最佳化器直接假設
「讀未初始化的值」不會發生,推導出 best 可以是任何值
——它選了對它方便的答案)。「在我機器上會動」的
完美反例:動的那台是 -O0。
2. 哪些工具抓得到(全部實測):
- -Wall -Wextra:沒抓到!迴圈讓資料流分析
不敢斷言(「n 可能 ≥ 1,第一輪就會賦值」……並不會);
- -Wconditional-uninitialized:抓到——
variable 'best' may be uninitialized when used here;
- cc --analyze(clang 靜態分析器):抓到——
The right operand of '>' is a garbage value;
- UBSan:沒抓到(未初始化讀取歸 MemorySanitizer 管,
macOS 上沒有 MSan)。
教訓:工具各有守備範圍,警告全開+靜態分析
雙管齊下。
3. 修法的選擇:best = 0 錯(全負數陣列答案變 0);
best = INT_MIN 可但空陣列回 INT_MIN 像合法值;
best = a[0] 起跳+空陣列獨立回報最誠實——
修 bug 時順手把介面修對。
參考解答
/*
* 練習 2(修正版):未初始化變數——初始化它,並讓警告替你站崗
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex2_uninit_fix.c
* 執行:/tmp/out
*
* 對應 EXERCISES.md 練習 2 的含 bug 版本:pick_max 在「陣列全是
* 負數以外」的假設下忘了初始化 best,-O0 與 -O2 的輸出不同
* (UB 的招牌症狀)。本檔為修正版;含 bug 的實驗記錄見 EXERCISES.md。
*/
#include <stdio.h>
#include <limits.h>
/*
* 修正:best 從「第一個元素」起跳(比 INT_MIN 起跳更好——
* 空陣列直接報錯,而不是回傳一個看似合法的 INT_MIN)。
*/
static int pick_max(const int *a, int n, int *ok)
{
if (n <= 0) { /* 邊界獨立處理,不混進主邏輯 */
*ok = 0;
return 0;
}
int best = a[0]; /* ★ 修正:明確的初始值 */
for (int i = 1; i < n; i++)
if (a[i] > best)
best = a[i];
*ok = 1;
return best;
}
int main(void)
{
int data[] = { -7, -3, -42, -1, -19 };
int n = (int)(sizeof data / sizeof data[0]);
int ok;
int m = pick_max(data, n, &ok);
printf("max of all-negative array = %d (ok=%d, expect -1)\n", m, ok);
m = pick_max(data, 0, &ok);
printf("empty array = %d (ok=%d, expect ok=0)\n", m, ok);
printf("\nfixed version: same answer at -O0 and -O2, every run.\n");
printf("the buggy version answered -1 at -O0 (by luck) and\n"
"2147483647 at -O2 -- see EXERCISES.md for the real log.\n");
return 0;
}
程式碼講解
pick_max改成回傳值+ok旗標的雙通道:空陣列時*ok = 0——「沒有最大值」與「最大值是 0」分得清清楚楚。- 迴圈從
i = 1開始:a[0]已是起始值,少比一次。 - 修正版在 -O0 與 -O2 輸出完全相同——行為不再依賴 最佳化等級,這就是「沒有 UB」的可觀察定義。
- 易錯處:看到 -O2 出錯就怪編譯器「太激進」—— 編譯器完全守約:UB 的前提下任何輸出都「正確」。 合約是你先違反的。
範例輸出
max of all-negative array = -1 (ok=1, expect -1)
empty array = 0 (ok=0, expect ok=0)
fixed version: same answer at -O0 and -O2, every run.
the buggy version answered -1 at -O0 (by luck) and
2147483647 at -O2 -- see EXERCISES.md for the real log.
練習 3:assert 防線與 lldb 單步觀察(難度:進階)
題目
含 bug 的有界堆疊:push 的邊界寫成 sp <= CAP(差一,
會寫進 stack[CAP])、pop 沒檢查空堆疊。修正它,
並用 assert 把「不可能為假」的前置條件釘死;
再用 lldb 在 push 設中斷點,印參數、單步、看 sp 的變化。
static void push(int v)
{
if (sp <= CAP) /* bug: 應為 sp < CAP,等號多了 */
stack[sp++] = v;
}
static int pop(void)
{
return stack[--sp]; /* bug: 空堆疊時 sp 變 -1,讀垃圾 */
}
解題思路
- 差一錯誤(off-by-one):
sp <= CAP讓sp == CAP時 仍寫入——stack[CAP]是陣列後面那格,踩到誰要看 連結器把什麼放在旁邊(本例是sp自己!越界寫可能 直接改掉 sp,症狀離奇)。 - assert 的角色分工——本題的核心觀念:
- 呼叫者的錯誤(把堆疊塞爆)→ 正常錯誤處理:
呼叫端先
is_full()再 push,滿了就拒收; - 邏輯上不可能的狀態(檢查過不滿卻越界)→ assert:assert(sp >= 0 && sp < CAP)——它炸開表示程式有 bug, 當場停在案發現場比繼續跑到記憶體毀損好一萬倍。 assert 失敗的真實輸出:
Assertion failed: (sp >= 0 && sp < CAP), function push,
file assert_demo.c, line 7.
Abort trap: 6
- lldb 實錄(
cc -g -O0編譯後,指令與輸出皆為真實記錄):
(lldb) b push
Breakpoint 1: where = ex3_dbg`push + 16 at ex3_assert_stack.c:40:5
(lldb) run
Process stopped, stop reason = breakpoint 1.1
frame #0: push(v=10) at ex3_assert_stack.c:40:5
-> 40 assert(sp >= 0 && sp < CAP);
(lldb) p v
(int) 10
(lldb) p sp
(int) 0
(lldb) bt
* frame #0: push(v=10) at ex3_assert_stack.c:40:5
frame #1: main at ex3_assert_stack.c:65:9
(lldb) n # 單步跨過 assert(成立,無事發生)
-> 42 stack[sp++] = v;
(lldb) n
(lldb) p sp
(int) 1 # push 完成,sp 前進
(lldb) c # 放行到下一次 push
五個指令(b / run / p / bt / n)就是 80% 的除錯日常。
4. NDEBUG 的紀律:-DNDEBUG 讓 assert 整行消失——
所以 assert 運算式絕不能有副作用
(assert(pop() == 42) 在 release 版會把 pop 吃掉)。
參考解答
/*
* 練習 3(修正版):assert 防線 + lldb 觀察 — 有界堆疊
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex3_assert_stack.c
* 執行:/tmp/out
* 除錯建置:cc -std=c11 -Wall -Wextra -g -O0 -DDEBUG -o /tmp/out ...
*
* 對應 EXERCISES.md 練習 3 的含 bug 版本:push 的邊界寫成
* sp <= CAP(差一越界)、pop 沒檢查空堆疊。本檔為修正版:
* 邊界改對,並用 assert 把「不可能為假」的前置條件釘死;
* DBG 巨集在 -DDEBUG 時輸出足跡。lldb 逐步觀察的操作記錄
* 見 EXERCISES.md。
*/
#include <stdio.h>
#include <assert.h>
/* -DDEBUG 時 DBG 輸出到 stderr 並自帶位置;release 版零成本 */
#ifdef DEBUG
#define DBG(fmt, ...) fprintf(stderr, "[%s:%d %s] " fmt "\n", \
__FILE__, __LINE__, __func__, ##__VA_ARGS__)
#else
#define DBG(fmt, ...) ((void)0)
#endif
#define CAP 4
static int stack[CAP];
static int sp = 0; /* 下一個空格;不變量:0 <= sp <= CAP */
static int is_full(void) { return sp == CAP; }
static int is_empty(void) { return sp == 0; }
/*
* assert 檢查的是「呼叫者的契約」:呼叫 push 前必須先確認不滿。
* 使用者輸入的錯誤不歸 assert 管——那要用正常錯誤處理;
* 但「程式邏輯保證不會滿卻滿了」是 bug,就該當場炸開。
*/
static void push(int v)
{
assert(sp >= 0 && sp < CAP); /* 前置條件(原版是 <=,越界) */
DBG("push %d at sp=%d", v, sp);
stack[sp++] = v;
}
static int pop(void)
{
assert(sp > 0); /* 原版沒檢查:空堆疊 pop 讀垃圾 */
int v = stack[--sp];
DBG("pop %d, sp now %d", v, sp);
return v;
}
int main(void)
{
printf("bounded stack, CAP=%d, with assert guards:\n\n", CAP);
/* 正常流程:呼叫端永遠先檢查再操作 */
int inputs[] = { 10, 20, 30, 40, 50, 60 };
int accepted = 0;
for (int i = 0; i < 6; i++) {
if (is_full()) {
printf(" stack full, rejecting %d\n", inputs[i]);
continue; /* 正常錯誤處理,不是 assert */
}
push(inputs[i]);
printf(" pushed %d\n", inputs[i]);
accepted++;
}
printf("\npopping everything:\n");
while (!is_empty())
printf(" popped %d\n", pop());
printf("\naccepted %d of 6 (CAP=%d), all asserts held.\n",
accepted, CAP);
printf("\nnotes:\n"
" - compile with -DNDEBUG and every assert vanishes --\n"
" which is why asserts must never have side effects\n"
" - the buggy version (sp <= CAP) writes stack[4]: one\n"
" element past the array. the assert version aborts\n"
" at the exact line instead of corrupting memory\n");
return 0;
}
程式碼講解
push開頭的assert(sp >= 0 && sp < CAP):上界防差一、 下界防「sp 被越界寫踩壞」——不變量整句寫進去, 炸開時訊息自帶全部線索。- 呼叫端的
if (is_full()) ... continue;與 assert 並存: 正常錯誤處理擋合法的意外,assert 擋不可能的狀態, 兩層各司其職。 DBG巨集:-DDEBUG時輸出到 stderr 並自帶__FILE__:__LINE__ __func__;release 版((void)0)零成本。 printf 除錯的專業化版本。- 輸出顯示 4 收 2 拒、pop 全數吐回(LIFO 順序 40 30 20 10) ——正確性可對帳。
- 易錯處:把使用者輸入驗證寫成 assert——release 版 (NDEBUG)驗證直接消失,等於沒驗。
範例輸出
bounded stack, CAP=4, with assert guards:
pushed 10
pushed 20
pushed 30
pushed 40
stack full, rejecting 50
stack full, rejecting 60
popping everything:
popped 40
popped 30
popped 20
popped 10
accepted 4 of 6 (CAP=4), all asserts held.
notes:
- compile with -DNDEBUG and every assert vanishes --
which is why asserts must never have side effects
- the buggy version (sp <= CAP) writes stack[4]: one
element past the array. the assert version aborts
at the exact line instead of corrupting memory
練習 4:三塊洩漏,一塊一個教訓(難度:進階)
題目
下面的程式洩漏三塊記憶體,各屬一種經典模式。用工具找出
三塊洩漏的配置位置,逐一修正到零洩漏。
(macOS Apple Silicon 的 ASan 不支援 detect_leaks=1,
改用系統內建的 leaks 工具:
MallocStackLogging=1 leaks --atExit -- ./prog。)
static int count_vowels(const char *text) /* 洩漏 1 */
{
char *copy = dup_str(text);
int n = 0;
for (char *p = copy; *p; p++)
if (strchr("aeiou", *p)) n++;
return n; /* 忘了 free(copy) */
}
static char *make_label(int version) /* 洩漏 2 */
{
char *label = dup_str("draft");
if (version >= 2)
label = dup_str("final"); /* 覆寫,舊塊漏了 */
return label;
}
static void init_config(void) /* 洩漏 3 */
{
char *cfg = dup_str("mode=fast;color=on");
printf("config = %s\n", cfg); /* 用完就忘 */
}
解題思路
- 工具選擇的平台現實:Linux 上 ASan 內建
LeakSanitizer;macOS arm64 執行時直接告訴你
detect_leaks is not supported on this platform。 替代品是系統內建的leaks(MallocStackLogging=1讓它記下每塊的配置堆疊)。 - 真實報告(節錄,三塊全被點名,含檔名行號):
Process 69119: 3 leaks for 96 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in dup_str>':
3 main + 36 ex4_buggy.c:46
2 count_vowels + 24 ex4_buggy.c:14
1 dup_str + 32 ex4_buggy.c:7
1 (48 bytes) ROOT LEAK: <malloc in dup_str 0x967008000> [48]
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in dup_str>':
2 init_config + 24 ex4_buggy.c:31
1 (32 bytes) ROOT LEAK: ... [32]
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in dup_str>':
2 make_label + 28 ex4_buggy.c:23
1 (16 bytes) ROOT LEAK: ... [16]
讀法:每塊洩漏附配置時的呼叫堆疊——修洩漏
看的是「它在哪出生」,然後追問「它該在哪死」。
3. 三種模式三種修法:
- 暫存副本忘了收(count_vowels):函式內配的
函式內收,return 前必經 free——「這個函式配的、
這個函式收」;
- 指標覆寫(make_label):第二次賦值蓋掉第一塊的
唯一把手——重新賦值前先 free 舊的;
- 用完就忘(init_config):malloc 的指標沒存
也沒 free——用完當場釋放。
4. 所有權思維:每塊 malloc 在寫下的那一刻就要回答
「誰負責 free、什麼時候」。make_label 的答案是
「呼叫者」——這種所有權轉移要寫進函式註解。
5. 修正後驗收:leaks 回報 0 leaks for 0 total leaked bytes
(實測)。
參考解答
/*
* 練習 4(修正版):三處記憶體洩漏,一處一個教訓
*
* 編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex4_leak_fix.c
* 執行:/tmp/out
* 驗漏(macOS 內建 leaks 工具;arm64 的 ASan 不支援 detect_leaks):
* cc -std=c11 -Wall -Wextra -g -o /tmp/out ex4_leak_fix.c
* MallocStackLogging=1 leaks --atExit -- /tmp/out
*
* 對應 EXERCISES.md 練習 4 的含 bug 版本:leaks 抓到三塊洩漏
* (真實報告見 EXERCISES.md)。本檔為修正版,三個洩漏模式
* 各對應一種修法。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char *dup_str(const char *s)
{
size_t len = strlen(s);
char *p = malloc(len + 1);
if (p != NULL)
memcpy(p, s, len + 1);
return p;
}
/*
* 洩漏 1(原版):函式內 malloc 的暫存副本,return 前忘了 free。
* 修法:出口統一釋放——「這個函式配的、這個函式收」。
*/
static int count_vowels_fixed(const char *text)
{
char *copy = dup_str(text);
if (copy == NULL)
return -1;
int n = 0;
for (char *p = copy; *p; p++)
if (strchr("aeiou", *p) != NULL)
n++;
free(copy); /* ★ 修正:唯一出口,必經之路 */
return n;
}
/*
* 洩漏 2(原版):指標被覆寫——第二次賦值蓋掉第一塊的唯一把手,
* 舊塊從此無人能 free。修法:重新賦值前先釋放舊的。
*/
static char *make_label_fixed(int version)
{
char *label = dup_str("draft");
if (version >= 2) {
free(label); /* ★ 修正:換新之前放掉舊的 */
label = dup_str("final");
}
return label; /* 所有權交給呼叫者(要 free) */
}
/*
* 洩漏 3(原版):「用完就忘」——malloc 的指標印完就丟,
* 沒存起來也沒釋放。修法:用完當場 free(或存起來供之後釋放)。
*/
static void init_config_fixed(void)
{
char *cfg = dup_str("mode=fast;color=on");
if (cfg == NULL)
return;
printf("config = %s\n", cfg);
free(cfg); /* ★ 修正:不留孤兒 */
}
int main(void)
{
int nv = count_vowels_fixed("memory leaks are silent bugs");
printf("vowels = %d\n", nv);
char *l1 = make_label_fixed(1);
char *l2 = make_label_fixed(2);
printf("labels = \"%s\", \"%s\"\n", l1, l2);
free(l1); /* 呼叫者履行所有權契約 */
free(l2);
init_config_fixed();
printf("\nverify with the macOS leaks tool:\n"
" MallocStackLogging=1 leaks --atExit -- /tmp/out\n"
"this fixed build reports '0 leaks for 0 total leaked bytes'.\n"
"the buggy version leaked 3 blocks (96 bytes) -- see\n"
"EXERCISES.md for the actual report with stack traces.\n");
return 0;
}
程式碼講解
- 三個修正函式的
★註解標出補上的 free 各在哪—— 對照含 bug 版一目瞭然。 make_label_fixed的註解寫明「所有權交給呼叫者」, main 履行契約(free(l1); free(l2);)—— 洩漏常發生在「兩邊都以為對方會收」。dup_str對 malloc 失敗回 NULL,呼叫端都有檢查—— 錯誤路徑的洩漏(配了 A、配 B 失敗、A 忘了收) 是進階版的同型問題(本單元練習 6 的 mycp 也處理過)。- 易錯處:用「程式結束前 OS 會回收」替洩漏開脫—— 一次性小程式無妨,長駐程式(伺服器、daemon)的洩漏 是記憶體耗盡的倒數計時。
範例輸出
vowels = 9
labels = "draft", "final"
config = mode=fast;color=on
verify with the macOS leaks tool:
MallocStackLogging=1 leaks --atExit -- /tmp/out
this fixed build reports '0 leaks for 0 total leaked bytes'.
the buggy version leaked 3 blocks (96 bytes) -- see
EXERCISES.md for the actual report with stack traces.
練習 5:型別雙關三寫法 — cast/memcpy/union(難度:挑戰)
題目
要讀 float 的 IEEE 754 位元模式。*(uint32_t *)&f 的寫法
違反 strict aliasing(UB)。寫出 memcpy 版與 union 版
(皆合法),驗證 1.0f == 0x3F800000 等已知值,
並說明 C11 對 union 雙關的規定、以及為什麼
-Wstrict-aliasing 在 clang 上救不了你。
解題思路
- strict aliasing 是什麼:標準允許編譯器假設
「不同型別的指標不會指向同一塊記憶體」(
char *例外)。*(uint32_t *)&f用 uint32_t 的身分讀 float 的記憶體—— 編譯器有權把這個讀取重排到 f 的寫入之前、或直接用 暫存器裡的舊值。簡單案例常常「碰巧對」, 函式一變大、內聯一發生就翻車。 - 合法寫法一:memcpy。
uint32_t u;
memcpy(&u, &f, sizeof u);
位元組層級的搬運不觸犯型別規則。效能迷思實測:
-O2 下 clang 把整個函式編成一條暫存器搬移指令
(fmov w0, s0)——和 cast 版產出的機器碼相同,
合法性是免費的。
3. 合法寫法二:union(C 特有!)。
union { float f; uint32_t u; } pun;
pun.f = f; return pun.u;
C11 §6.5.2.3 註 95 明文允許「寫一個成員、讀另一個成員」
(值為位元重新詮釋)。注意 C++ 不允許——
跨語言的標頭要用 memcpy。
4. 工具的空窗:實測
cc -O2 -Wall -Wextra -Wstrict-aliasing 對 cast 版
零警告(clang 基本沒實作這個警告;它是 GCC 的旗標)。
UBSan 也不查 aliasing。這是少數「工具鏈抓不到、
只能靠紀律」的 UB——所以要背下來:
型別雙關只用 memcpy 或 union。
5. 驗證設計:已知值(1.0f、-2.0f)對帳、兩種合法寫法
互相對照(agree)、反方向(位元模式 0x40490FDB → π)。
參考解答
/*
* 練習 5(合法版):型別雙關三寫法 — cast(UB)/ memcpy / union
*
* 編譯:cc -std=c11 -Wall -Wextra -O2 -o /tmp/out ex5_aliasing.c
* 執行:/tmp/out
*
* 讀 float 的位元模式(IEEE 754 檢查)是型別雙關(type punning)
* 的經典場景。三種寫法:
* 1. *(unsigned *)&f —— 違反 strict aliasing,UB(本檔以
* ENABLE_BUG=0 隔離,僅供對照,不編入預設建置)
* 2. memcpy —— 永遠合法,現代編譯器優化成單條指令
* 3. union —— C11(含 C99 TC3)明文允許
* 本檔實作 2 與 3,驗證 1.0f == 0x3F800000 等已知值。
*/
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#define ENABLE_BUG 0 /* 改 1 可看 UB 版(-Wstrict-aliasing 會抱怨) */
/* ---- 寫法 2:memcpy —— 標準保證合法的萬用解 ---- */
static uint32_t float_bits_memcpy(float f)
{
uint32_t u;
memcpy(&u, &f, sizeof u); /* 位元組搬運:不經過型別化的讀取 */
return u;
/* -O2 下 clang 把整個函式編成一條暫存器搬移——
「memcpy 很慢」是過時的迷信 */
}
/* ---- 寫法 3:union —— C11 §6.5.2.3 註 95 明文允許 ---- */
static uint32_t float_bits_union(float f)
{
union {
float f;
uint32_t u;
} pun;
pun.f = f; /* 寫進一個成員…… */
return pun.u; /* ……讀另一個成員:C 允許(C++ 不允許!) */
}
#if ENABLE_BUG
/* ---- 寫法 1:指標轉型 —— 違反 strict aliasing,UB ---- */
static uint32_t float_bits_cast(float f)
{
return *(uint32_t *)&f; /* 編譯器可假設 uint32_t* 與 float*
不指向同塊記憶體 -> 重排/讀舊值 */
}
#endif
static void show(float f)
{
uint32_t m = float_bits_memcpy(f);
uint32_t u = float_bits_union(f);
printf(" %13g memcpy=0x%08X union=0x%08X %s\n",
(double)f, m, u, m == u ? "agree" : "DISAGREE?!");
}
int main(void)
{
printf("float bit patterns (IEEE 754 single):\n");
show(1.0f); /* 0x3F800000:符號0 指數127 尾數0 */
show(-2.0f); /* 0xC0000000 */
show(0.5f); /* 0x3F000000 */
show(0.1f); /* 0x3DCCCCCD:0.1 不能精確表示 */
show(0.0f); /* 0x00000000 */
/* 已知值驗證 */
printf("\nknown-value checks:\n");
printf(" 1.0f -> 0x%08X (expect 0x3F800000) %s\n",
float_bits_memcpy(1.0f),
float_bits_memcpy(1.0f) == 0x3F800000u ? "OK" : "BAD");
printf(" -2.0f -> 0x%08X (expect 0xC0000000) %s\n",
float_bits_memcpy(-2.0f),
float_bits_memcpy(-2.0f) == 0xC0000000u ? "OK" : "BAD");
/* 反方向:位元模式 -> float(一樣用 memcpy) */
uint32_t bits = 0x40490FDBu; /* pi 的 IEEE 754 表示 */
float pi;
memcpy(&pi, &bits, sizeof pi);
printf("\nreverse: 0x40490FDB -> %.7f (pi)\n", (double)pi);
printf("\nwhy not *(uint32_t *)&f ?\n"
" strict aliasing: the optimizer assumes differently-typed\n"
" pointers never alias, so it may reorder or cache across\n"
" the cast. it often 'works' -- until -O2, a new compiler,\n"
" or a bigger function proves otherwise. memcpy and union\n"
" express the same intent with defined behavior (and the\n"
" same generated code).\n");
return 0;
}
程式碼講解
- cast 版被
#if ENABLE_BUG(預設 0)隔離——與本單元 範例程式的慣例一致:UB 程式碼不進預設建置。 show同時跑 memcpy 與 union 版並比對——兩條合法路徑 互為驗證。0.1f -> 0x3DCCCCCD:順便看見「0.1 不能精確表示」 的位元證據(尾數 CCCC... 循環)。- 反方向轉換同樣用 memcpy:位元模式 → float, 0x40490FDB 還原出 3.1415927。
- 易錯處:以為
char *例外反過來也成立—— 「用 char 讀任何東西」合法, 「用任何型別讀 char 陣列」不合法*; 例外是單向的。
範例輸出
float bit patterns (IEEE 754 single):
1 memcpy=0x3F800000 union=0x3F800000 agree
-2 memcpy=0xC0000000 union=0xC0000000 agree
0.5 memcpy=0x3F000000 union=0x3F000000 agree
0.1 memcpy=0x3DCCCCCD union=0x3DCCCCCD agree
0 memcpy=0x00000000 union=0x00000000 agree
known-value checks:
1.0f -> 0x3F800000 (expect 0x3F800000) OK
-2.0f -> 0xC0000000 (expect 0xC0000000) OK
reverse: 0x40490FDB -> 3.1415927 (pi)
why not *(uint32_t *)&f ?
strict aliasing: the optimizer assumes differently-typed
pointers never alias, so it may reorder or cache across
the cast. it often 'works' -- until -O2, a new compiler,
or a bigger function proves otherwise. memcpy and union
express the same intent with defined behavior (and the
same generated code).
練習 6:除錯建置組態 — 修到雷達全綠(難度:挑戰)
題目
下面的「單字統計」小工具藏著三顆雷。用完整的除錯組態
cc -std=c11 -g -O0 -DDEBUG -fsanitize=address,undefined \
-Wall -Wextra -Werror -o prog prog.c
編譯執行,讓 Sanitizers 逐顆引爆並報告;逐一修正, 直到同一組態下零警告、零報告、正常結束。
static char *dup_upper(const char *s)
{
size_t len = strlen(s);
char *p = malloc(len); /* bug 1: 少了 +1 */
for (size_t i = 0; i < len; i++)
p[i] = (char)toupper((unsigned char)s[i]);
p[len] = '\0'; /* 越界寫入! */
return p;
}
static long square_chars(int nchars)
{
return nchars * nchars; /* bug 2: int 相乘先溢位 */
}
/* main 內: */
free(copy);
printf("first char was %c\n", copy[0]); /* bug 3: use-after-free */
解題思路
- Sanitizer 的工作方式決定修蟲節奏:ASan 遇到第一個 錯誤就中止——所以是「引爆 → 修 → 重跑 → 下一顆」的 迭代,不是一次看全部。
- 第一顆:heap-buffer-overflow(真實報告節錄):
==ERROR: AddressSanitizer: heap-buffer-overflow ...
WRITE of size 1 at 0x60400000087d thread T0
#0 dup_upper ...
#1 main ...
0x60400000087d is located 0 bytes after 45-byte region
allocated by thread T0 here:
#0 malloc
#1 dup_upper ...
讀法三件套:錯誤類型(越界寫 1 位元組)、
出事堆疊(dup_upper)、區塊來歷(45 位元組,
在 dup_upper 配置)。「0 bytes after a 45-byte region」
=差一——strlen 不含 '\0',malloc 要 len + 1。
3. 第二顆:UBSan 的 signed overflow(修完第一顆才跑得到):
ex6_stage2.c:18:19: runtime error: signed integer overflow:
50000 * 50000 cannot be represented in type 'int'
直接給檔名行號。陷阱在「回傳型別是 long 救不了
運算過程」——int * int 先以 int 相乘(溢位=UB),
才轉成 long。修法:(int64_t)nchars * nchars,
cast 放在乘法之前。
4. 第三顆:heap-use-after-free:
==ERROR: AddressSanitizer: heap-use-after-free ...
READ of size 1 ...
freed by thread T0 here: #1 main ...
previously allocated by ...: #1 dup_upper ...
報告同時給「在哪 free」與「在哪配置」——
把「最後一次使用」排到 free 之前即修復。
5. 為什麼整套組態要一起上:-g -O0 讓報告有行號、
變數不被最佳化掉;-DDEBUG 打開 DBG 足跡;
ASan+UBSan 各管一半的 UB;-Werror 把警告變硬門檻。
這套就是「除錯建置」的標準配方,CI 裡開一個
這樣的 job,蟲活不過第一次 push。
參考解答
/*
* 練習 6(修正版):除錯建置組態驗收 — 修到雷達全綠
*
* 一般編譯:cc -std=c11 -Wall -Wextra -o /tmp/out ex6_debug_build.c
* 除錯組態(本題的重點——必須也零警告、零報告通過):
* cc -std=c11 -g -O0 -DDEBUG -fsanitize=address,undefined \
* -Wall -Wextra -Werror -o /tmp/out ex6_debug_build.c && /tmp/out
*
* 對應 EXERCISES.md 練習 6 的含 bug 版本:一支「單字統計」小工具
* 藏了三顆雷——配置少 +1(heap overflow)、int 相乘溢位(UB)、
* 用了已 free 的指標。ASan/UBSan 的真實報告見 EXERCISES.md。
* 本檔為全部修正後的版本。
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdint.h>
#ifdef DEBUG
#define DBG(fmt, ...) fprintf(stderr, "[%s:%d] " fmt "\n", \
__FILE__, __LINE__, ##__VA_ARGS__)
#else
#define DBG(fmt, ...) ((void)0)
#endif
/* 修正 1(原版洩 heap overflow):strlen 不含 '\0',
配置必須 +1——差一個位元組就是 ASan 的 heap-buffer-overflow */
static char *dup_upper(const char *s)
{
size_t len = strlen(s);
char *p = malloc(len + 1); /* ★ +1 放 '\0' */
if (p == NULL)
return NULL;
for (size_t i = 0; i < len; i++)
p[i] = (char)toupper((unsigned char)s[i]);
p[len] = '\0';
return p;
}
/* 修正 2(原版 int * int 溢位=UB):先把運算元升到 64 位。
50000 * 50000 = 25 億 > INT_MAX,int 相乘先溢位才賦值給 long
——「目的型別夠大」救不了「運算過程溢位」 */
static int64_t square_chars(int nchars)
{
return (int64_t)nchars * nchars; /* ★ cast 在乘法之前 */
}
/* 修正 3(原版 use-after-free):free 之後還讀 words[0]。
修法:先取用、後釋放——把「最後一次使用」和 free 的順序排對 */
int main(void)
{
const char *text = "sanitizers turn silent bugs into loud reports";
/* 斷詞並轉大寫 */
char *copy = dup_upper(text);
if (copy == NULL)
return 1;
DBG("upper copy = %s", copy);
char *words[16];
int nw = 0;
for (char *p = strtok(copy, " "); p != NULL && nw < 16;
p = strtok(NULL, " "))
words[nw++] = p; /* 指進 copy,不另配置 */
printf("words (%d):", nw);
for (int i = 0; i < nw; i++)
printf(" %s", words[i]);
printf("\n");
int nchars = (int)strlen(text);
printf("chars = %d\n", nchars);
printf("chars^2 = %lld (64-bit, no overflow)\n",
(long long)square_chars(nchars));
/* 展示大數字也安全:原版在這裡 int 溢位 */
printf("50000^2 = %lld (buggy version printed garbage here)\n",
(long long)square_chars(50000));
/* ★ 修正 3:最後一次使用 words/copy 在 free 之前 */
printf("first = %s\n", words[0]);
free(copy); /* words[] 全部隨之失效 */
printf("\nthis file passes the full debug config:\n"
" -g -O0 -DDEBUG -fsanitize=address,undefined "
"-Wall -Wextra -Werror\n");
return 0;
}
程式碼講解
- 三個修正各有
★註解對應含 bug 版的三顆雷:malloc(len + 1)、(int64_t)nchars * nchars(cast 在 乘法前)、printf移到free(copy)之前。 strtok回傳的指標指進 copy 本體——所以 words[] 的 壽命跟著 copy 走,free(copy) 之後全部失效; 修正版把最後一次使用排在 free 之前,並加註解說明 這個壽命綁定。50000^2 = 2500000000正確印出(64 位運算)—— 含 bug 版在這裡印出溢位垃圾(UB,值不可預測)。- 本檔在完整除錯組態(含 -Werror)下實測通過、 一般組態也零警告——「雷達全綠」的驗收兩種組態都要跑。
- 易錯處:修完 bug 後把 Sanitizer 關掉「因為變慢」—— 開發期永遠開著(2 倍慢換「蟲一出生就被抓」, 是整個除錯工具鏈裡最划算的交易)。
範例輸出
words (7): SANITIZERS TURN SILENT BUGS INTO LOUD REPORTS
chars = 45
chars^2 = 2025 (64-bit, no overflow)
50000^2 = 2500000000 (buggy version printed garbage here)
first = SANITIZERS
this file passes the full debug config:
-g -O0 -DDEBUG -fsanitize=address,undefined -Wall -Wextra -Werror
(除錯組態下開頭多一行 [ex6_debug_build.c:59] upper copy = ...
的 DBG 足跡。)
常見錯誤解法對照
| 錯誤寫法 | 症狀 | 正解 |
|---|---|---|
| 帶著警告繼續開發 | UB 上線後隨機發作 | 零警告底線 + CI -Werror(練習 1) |
| 用 -O0 的正確輸出證明沒 bug | -O2 換編譯器就翻車 | 兩種等級都跑 + 靜態分析(練習 2) |
| 使用者輸入錯誤用 assert 擋 | NDEBUG 下驗證消失 | assert 只管不變量(練習 3) |
| assert 運算式有副作用 | release 版行為改變 | 副作用放外面(練習 3) |
| 指標重新賦值不先 free | 舊塊永久洩漏 | 先 free 再覆寫(練習 4) |
*(uint32_t *)&f 型別雙關 |
碰巧對,直到不對;工具抓不到 | memcpy 或 union(練習 5) |
long r = int * int |
相乘先溢位才轉型 | cast 放乘法之前(練習 6) |
| 修完蟲關掉 Sanitizer | 新蟲重新隱形 | 開發建置常駐開啟(練習 6) |