[C 的那些眉角]指標用完要歸零 — 懸空指標的恐怖故事

有一種 bug,我只要想到就頭皮發麻。

明明程式跑得好好的,突然在某個完全不相關的地方 crash,
或是資料莫名其妙被改掉,
或是在開發機上完全正常,到了產品上偶爾出問題。

很多時候,追到最後都是同一個兇手:

懸空指標(Dangling Pointer)。


懸空指標是什麼

簡單說,就是一個指標指向的記憶體已經不屬於你了,
但你還拿著那個指標,以為它還有效。

最常見的情況是 free 之後繼續使用:

uint8_t *buf = malloc(256);

// 用完了,釋放記憶體
free(buf);

// buf 現在指向哪裡?
// 指向剛才那塊記憶體,但那塊記憶體已經還給系統了
// 這塊記憶體可能被其他地方重新分配,也可能還沒被動過

// ❌ 繼續使用 buf,undefined behavior
buf[0] = 0xFF;       // 可能改到其他人的資料
printf("%d\n", buf[0]); // 可能讀到垃圾值,可能 crash

free 只是告訴系統「這塊記憶體我不用了」,
它不會把指標清空,buf 還是指向那個位址。


為什麼這麼難 debug

懸空指標最恐怖的地方,是它的行為不可預測

情況一:剛好還沒被覆蓋

uint8_t *buf = malloc(256);
memset(buf, 0xAB, 256);
free(buf);

// 如果這塊記憶體還沒被重新分配
// buf[0] 可能還是 0xAB
printf("%02X\n", buf[0]);  // 印出 AB,看起來「正常」

在開發階段,這種情況很常見,
因為程式邏輯簡單,記憶體分配不頻繁,
free 之後那塊記憶體通常還沒被動過。

所以測試都過了,出貨了。

到了客戶那邊,程式跑久了,記憶體被重複分配,
那塊記憶體被覆蓋了,bug 才出現。


情況二:剛好被其他人拿去用

uint8_t *buf = malloc(256);
free(buf);

// 假設這時候另一個地方也 malloc 了,剛好拿到同一塊記憶體
SomeStruct *obj = malloc(sizeof(SomeStruct));

// 現在 buf 和 obj 指向同一塊記憶體
buf[0] = 0xFF;  // 你以為在寫 buf,其實在改 obj 的內容

// obj 的資料被污染了,但 obj 的程式碼不知道
// 後續 obj 的行為會出錯,但 crash 的位置跟 buf 完全無關

這種 bug 最難追,因為 crash 的地方跟問題的根源差很遠。


情況三:嵌入式的 Warm Reset

在嵌入式,如果你用的是靜態分配的記憶體(不是 heap),
懸空指標的問題更微妙。

static uint8_t sensor_buf[64];
static uint8_t *active_buf = NULL;

void start_measurement(void) {
    active_buf = sensor_buf;
    // 開始使用 active_buf
}

void stop_measurement(void) {
    // 忘記清空 active_buf
    // active_buf 還是指向 sensor_buf
}

// Warm reset 之後,stop_measurement 沒有被呼叫
// active_buf 還是指向 sensor_buf
// 下次 start_measurement 之前就使用 active_buf,行為未定義

解法:用完就歸零

最簡單也最有效的防禦:

free(buf);
buf = NULL;  // 立刻清空

這樣做之後,如果不小心繼續使用 buf

free(buf);
buf = NULL;

buf[0] = 0xFF;  // 存取 NULL,直接 crash(Segmentation Fault)

「立刻 crash」比「偶爾出現奇怪行為」好太多了。

crash 的位置就是問題的根源,
不會讓錯誤蔓延到其他地方,
debug 容易很多。


寫一個安全的 free 巨集

每次 free 之後都要記得清空,
但人總是會忘記,可以用巨集強制做到:

#define SAFE_FREE(ptr)  \
    do {                \
        free(ptr);      \
        (ptr) = NULL;   \
    } while (0)

使用:

uint8_t *buf = malloc(256);
// ... 使用 buf ...
SAFE_FREE(buf);  // free 並且清空,一步到位

這個巨集在很多嵌入式專案裡都看得到,
是個很實用的小工具。


除了 free,還有哪些情況會產生懸空指標

情況一:指向 stack 變數的指標被帶出去

// ❌ 回傳區域變數的位址
int *get_value(void) {
    int local = 42;
    return &local;  // local 在函式結束後就消失了
}

int *ptr = get_value();
printf("%d\n", *ptr);  // ptr 指向已經消失的 stack 空間

這個編譯器通常會警告,但不一定是錯誤。


情況二:callback 裡的指標比物件活得更久

typedef void (*Callback)(void *data);

void register_callback(Callback cb, void *user_data);

void setup(void) {
    MyObject *obj = malloc(sizeof(MyObject));
    register_callback(my_callback, obj);

    // 某個時機點釋放了 obj
    free(obj);
    obj = NULL;

    // 但 callback 系統還存著那個指標
    // 下次 callback 被觸發,user_data 就是懸空指標
}

這種情況在事件驅動的系統裡很常見,
要特別注意物件的生命週期和 callback 的生命週期是否一致。


情況三:多個指標指向同一塊記憶體

uint8_t *buf_a = malloc(256);
uint8_t *buf_b = buf_a;  // 兩個指標指向同一塊

free(buf_a);
buf_a = NULL;

// buf_b 還是指向那塊已經被 free 的記憶體
// buf_a = NULL 沒有幫到 buf_b
buf_b[0] = 0xFF;  // ❌ 懸空指標

這種情況 SAFE_FREE 也救不了,
因為它只能清空你傳進去的那個指標。

解法是設計上避免多個指標指向同一塊記憶體,
或是明確定義「誰是這塊記憶體的擁有者」,
只有擁有者可以 free


用工具幫你找

靠人工審查懸空指標很累,
有工具可以幫忙:

Valgrind(Linux)

valgrind --leak-check=full --track-origins=yes ./your_program

可以找到 use-after-free、double-free 等問題,
報告非常詳細,會告訴你哪行 free、哪行又用了。


AddressSanitizer(ASan)

gcc -fsanitize=address -g your_code.c -o your_program
./your_program

執行時期偵測,發現問題立刻報告,
比 Valgrind 快,適合在 CI 裡跑。

ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 1 at 0x602000000010 thread T0
    #0 0x401234 in main your_code.c:15

嵌入式環境

Valgrind 和 ASan 在嵌入式跑不了,
但可以:

  • 在 host 上跑單元測試,用 ASan 抓問題
  • 用靜態分析工具(PC-lint、Cppcheck)
  • 在 debug build 裡加 assert(ptr != NULL) 防禦

說實話

「用完就歸零」這個習慣,
我覺得比很多設計模式都實用。

不需要特別的工具,不需要改架構,
就是每次 free 之後多一行 = NULL

但我也承認,這個習慣需要刻意練習,
因為 free 完之後「感覺上」已經結束了,
很容易就直接往下寫,忘記清空。

我現在的做法是,把 SAFE_FREE 設成 snippet,
讓自己不用多想,直接用巨集,
強制把清空這件事綁在 free 裡面。

另外,每次看到 free 的時候,
我都會問自己:「有沒有其他指標也指向這塊記憶體?」

這個問題沒有工具可以自動回答,
只能靠設計的時候把記憶體所有權想清楚。


這篇的 Checklist

  • [ ] 每次 free 之後有立刻把指標設為 NULL
  • [ ] 使用 SAFE_FREE 巨集避免忘記
  • [ ] 沒有回傳區域變數的位址
  • [ ] Callback 的 user_data 生命週期有考慮清楚
  • [ ] 多個指標指向同一塊記憶體時,有明確定義擁有者
  • [ ] 在 host 端的測試有開 ASan 或用 Valgrind 跑過
  • [ ] 使用指標前有檢查是否為 NULL
分類: 程式相關,標籤: , , , , , , , , , , , , , 。這篇內容的永久連結

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *