本檔收錄 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;
}

解題思路

  1. 先讓工具說話。-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 */

解題思路

  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,讀垃圾 */
}

解題思路

  1. 差一錯誤(off-by-one):sp <= CAP 讓 sp == CAP 時 仍寫入——stack[CAP] 是陣列後面那格,踩到誰要看 連結器把什麼放在旁邊(本例是 sp 自己!越界寫可能 直接改掉 sp,症狀離奇)。
  2. 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
  1. 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);              /* 用完就忘 */
}

解題思路

  1. 工具選擇的平台現實:Linux 上 ASan 內建 LeakSanitizer;macOS arm64 執行時直接告訴你 detect_leaks is not supported on this platform。 替代品是系統內建的 leaks(MallocStackLogging=1 讓它記下每塊的配置堆疊)。
  2. 真實報告(節錄,三塊全被點名,含檔名行號):
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 上救不了你。

解題思路

  1. strict aliasing 是什麼:標準允許編譯器假設 「不同型別的指標不會指向同一塊記憶體」(char * 例外)。 *(uint32_t *)&f 用 uint32_t 的身分讀 float 的記憶體—— 編譯器有權把這個讀取重排到 f 的寫入之前、或直接用 暫存器裡的舊值。簡單案例常常「碰巧對」, 函式一變大、內聯一發生就翻車。
  2. 合法寫法一: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 */

解題思路

  1. Sanitizer 的工作方式決定修蟲節奏:ASan 遇到第一個 錯誤就中止——所以是「引爆 → 修 → 重跑 → 下一顆」的 迭代,不是一次看全部。
  2. 第一顆: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)