有一種 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