在別處才崩潰的那種錯誤
在這一階的前兩篇裡,你學會了用中斷點讓程式停下、讀回溯(backtrace)、並在崩潰後拆解一個核心傾印(core dump)。當崩潰直接指向元兇時,這些工具棒極了。但有一整族的記憶體錯誤有個殘忍的習性:「造成傷害」的那一行,「不是」崩潰的那一行。你在一個堆積區塊的尾端多寫了一個位元組,當下什麼事也沒發生,程式還愉快地跑下去,然後十次函式呼叫之後,`malloc()` 回傳垃圾、或 `free()` 中止——因為你悄悄地踩爛了配置器放在你區塊旁邊的簿記資料。那個區段錯誤(segfault),如果你還有幸看到的話,是症狀;真正的傷口在上游某處。
這就是症狀與根本原因之間的鴻溝,也正是普通除錯器吃力的地方。等程式死掉時,證據早已冷掉:那次汙染的寫入老早就結束了,在崩潰現場沒留下任何痕跡。你可以拿除錯器一步步走到天荒地老也看不到它,因為在撞擊的那一刻,一切看起來都沒問題。你需要的,是一個在罪行「正被犯下」的當下就盯著它的工具——一個把你「每一次」記憶體存取,都拿去對照你的程式「實際被允許碰觸的範圍」來檢查的工具。
Valgrind 實際上在做什麼
Valgrind 不是一個你去操控的除錯器;它是一顆「虛擬的 CPU」。當你執行 `valgrind ./a.out` 時,你真正的機器碼並不會被直接執行。Valgrind 反而會即時地把你的每一段指令翻譯成「插了檢查」的指令,由它自己來跑——在每一次載入、儲存、配置、釋放周圍都加上一點檢查。它最常用的工具 Memcheck 就是用來抓記憶體錯誤的那一個,也是預設工具。你不必為它重新編譯程式;你只是把你平常的可執行檔放到模擬器底下跑。
它怎麼知道某次讀取是非法的?Memcheck 為你程式的記憶體保留了一份「影子(shadow)」。對你程式擁有的每一個位元組,它額外追蹤兩件事:這個位元組是否可定址(你的程式到底有沒有權利碰它?)、以及它是否已定義(是否寫進了真正的值,還是仍是未初始化的垃圾?)。它隨著程式進行更新這份影子:`malloc()` 把新鮮的位元組標成「可定址但未定義」,寫入它們會標成已定義,而 `free()` 又把它們標回「不可定址」。然後在每一次存取時,它都先問影子。從一個不可定址的位元組讀取,就是一次無效讀取;一個依賴於未定義位元組的分支,就是一次對未初始化記憶體的使用。正是這套簿記,讓 Memcheck 能在罪行進行的當下抓到它,而不是去驗屍。
讀懂一份 Memcheck 報告
最重要的一個習慣,是先帶著除錯資訊編譯:`gcc -g -O0 main.c -o a.out`。`-g` 給了 Memcheck 它需要的行號與變數名稱(就是第一篇裡 gdb 想要的那份除錯資訊),而把最佳化保持在低檔的 `-O0`,意味著它回報的那一行就是你真正寫下的那一行。接著執行 `valgrind ./a.out`,並且由下往上讀報告——最後幾行總結洩漏,報告的主體則用回溯描述每一次非法存取。
// bug.c : read one past the end of a 4-int block
int *a = malloc(4 * sizeof(int)); // a[0..3] are valid
if (a == NULL) return 1; // always check malloc
for (int i = 0; i <= 4; i++) // BUG: i == 4 is out of bounds
a[i] = i; // a[4] writes past the block
free(a);
$ gcc -g -O0 bug.c -o bug
$ valgrind ./bug
==12345== Invalid write of size 4
==12345== at 0x4005AA: main (bug.c:5)
==12345== Address 0x5204050 is 0 bytes after a block of size 16 alloc'd
==12345== at 0x4838DEF: malloc (vg_replace_malloc.c:...)
==12345== by 0x40059E: main (bug.c:2)把那份報告當成一句話來讀。「Invalid write of size 4」告訴你罪行的「種類」與牽涉幾個位元組;那行 `at ... main (bug.c:5)` 就是冒煙的槍——做這件事的確切原始碼行。「0 bytes after a block of size 16」這句話,是 Memcheck 在告訴你地理位置:你寫在一個 16 位元組配置(我們那四個 `int`)的緊接之後,這是一個教科書式的堆積緩衝區溢位,肇因於把 `i < 4` 寫成 `i <= 4` 這個經典的差一錯誤。它甚至還顯示了第二段回溯,那幾行 `alloc'd ... by main (bug.c:2)`,指向「這個區塊在哪裡誕生」。你同時拿到了犯罪現場、和區塊的出生地。
洩漏、釋放後使用、與重複釋放
Memcheck 的另一大強項,是抓那些「根本不會崩潰」的生命週期錯誤。記憶體洩漏是其中最安靜的:你 `malloc()` 了一個區塊,弄丟了指向它的最後一個指標,卻從不 `free()` 它。程式照常運作,只是慢慢地膨脹。當你的程式結束時,Memcheck 會走過堆積,回報任何「已沒有任何存活指標能到達」的區塊。用 `valgrind --leak-check=full ./a.out` 來跑,它會把洩漏分組,並給每一個配置回溯——於是你學到的不只是「洩漏了 16 位元組」,而是製造出那個被遺棄區塊的確切 `malloc()` 呼叫。
另外兩個更危險的生命週期錯誤,都和「碰觸你已經交還的記憶體」有關。釋放後使用(use-after-free)是指你 `free(p)` 之後,仍然去讀或寫 `*p`——此時 `p` 是個懸空指標,瞄準的是配置器可能已經交給別人的記憶體。重複釋放(double-free)則是對同一個 `p` 呼叫了兩次 `free()`,這會弄壞配置器內部的閒置串列(free list),且傾向於在很久之後才爆炸。兩者在 C 裡都是未定義行為,也都幾乎對普通除錯器隱形。Memcheck 能精確地抓到它們,因為當你 `free()` 一個區塊的那一刻,它就把那些位元組標成不可定址,於是緊接著的那一次 `*p` 就會在出問題的那一行帶著回溯觸發警報。
一套工作流程,以及它誠實的極限
- 用你能找到的最小輸入重現錯誤——慢吞吞的模擬器需要一個小案例。
- 用 `gcc -g -O0` 重新編譯,好讓回溯帶著你真正的行號與變數名稱。
- 執行 `valgrind --leak-check=full ./a.out`,把你程式自己的引數接在後面傳入。
- 先修它回報的「第一個」錯誤,然後重跑——早期的汙染常會連鎖出一大堆後續、看起來像假的錯誤。
- 重複到 Memcheck 印出「All heap blocks were freed -- no leaks are possible」且零錯誤為止。
現在談誠實的極限,因為一個你盲目信任的工具是危險的。Memcheck 對堆積極為出色,但對「堆疊」與「全域」的越界大致是盲的——它沒辦法像看堆積區塊那樣,看見你越界寫了一個區域陣列 `char buf[8]`,因為那些位元組不是由 `malloc()` 發出來的。它也只回報「你的測試實際走過的路徑」上的錯誤;一個藏在你從未跑到的分支裡的釋放後使用,就會繼續隱形。而它找到的是「第一次非法存取」,那只有在那次存取真的是根源時,才會是症狀的真正成因——有時是好幾步之前,一個值被「正確地、卻從壞資料」算了出來。Valgrind 把搜尋範圍縮得極小;它不替你思考。
這也正是下一篇要轉向消毒器(sanitizers)的原因。AddressSanitizer 是用 `-fsanitize=address` 編譯「進」你的程式裡的,所以它確實能抓到 Memcheck 漏掉的堆疊與全域越界,而且只慢幾倍、而非幾十倍。代價是你必須為它重新建置,而 Valgrind 能原封不動地跑任何二進位檔。它們是互補、而非對手:當你無法重新編譯、或想要它深入的洩漏分析時,找 Valgrind;當你能重新建置、又想要速度加上堆疊涵蓋率時,找消毒器。無論哪一條路,你都已經從「猜記憶體在哪裡壞掉」跨越到「被告知,還附上一個行號」了。