從 printf 走向真正的除錯器
你一路爬上這座梯子,寫的都是只在你開口時才回話的程式。你學會了灑下一堆列印敘述、重新編譯、再讀那串輸出——這做法誠實、好用,也是後面一篇談有原則的 printf 除錯的主題。但 printf 有個硬天花板:你想問的每個問題,都得在編譯之前就決定好。萬一程式在你沒插探針的地方崩潰,你就瞎了。除錯器(debugger)抬高了這道天花板。它是一支獨立的程式,替你執行你的程式,並能在任何瞬間讓它停下、檢視其記憶體的任何一個位元組、再讓它繼續——而這一切都不必改動你原始碼的任何一行。
在 Linux 上,經典工具是 gdb,即 GNU 除錯器;在 macOS 上對應的是 lldb,底下的觀念幾乎相同,只是指令名稱略有差異。我們會用 gdb 的詞彙來談。這個心智模型很簡單,值得牢牢記住:gdb 掛接(attach)到你的行程上,成為它的監督者,從此之後你的程式只有在 gdb 允許時才會前進。就是這一個轉變——從一支衝向終點的程式,變成一支你能暫停並盤問的程式——撐起了整個階段,也是任何有紀律的除錯流程的核心。
首要之務:編譯時讓除錯器看得見
這一步幾乎人人第一次都會忘。你可執行檔裡的機器碼,只是一串編了號的指令;預設情況下,它不帶有你變數名稱、行號、或哪段 C 敘述產生了哪條指令的任何記憶。編譯器可以把這份地圖附上去,但前提是你得開口要。你用 -g 旗標來要。少了它,gdb 還是能執行你的程式,但只能給你看原始的位址與暫存器——它沒辦法說「變數 n 是 7」或「你在第 42 行」。
所以要養成的習慣是:當你準備除錯,就帶著除錯資訊編譯,並把最佳化器關掉。用 `gcc -g -O0 -Wall main.c -o prog`。-g 嵌入原始碼地圖;-O0 關閉最佳化。為什麼要關掉最佳化器?因為在 -O2 下,編譯器獲准重排、合併、刪除程式碼,於是某一行原始碼可能對應不到二進位檔中任何單一位置——單步執行會令人錯亂地跳來跳去,變數也讀成 `<optimized out>`。先在 -O0 下除錯;只有當你專門在追一個「只有最佳化後才出現」的臭蟲時,才動用更高的最佳化等級。
中斷點:在關鍵處凍結程式
中斷點(breakpoint)是給 gdb 的一道命令:當程式抵達這個地點時停下,把控制權交給我。你在執行前設好一個,接著啟動程式;它會以全速原生速度狂奔,直到碰到被標記的那一行,就地凍結、一切原封不動、等你發問。在底層,gdb 玩了一個討喜的小把戲:它把目標指令的第一個位元組改寫成一個特殊陷阱,於是當 CPU 走到那裡,一個 陷阱就把控制權踢回 gdb;然後 gdb 在讓你繼續之前,悄悄把真正的位元組換回去。你從不會看見這次調包——你只看見程式恰好停在你要求的地方。
日常流程一隻手就數得完。用 `gdb ./prog` 啟動。用 `break main`(依函式)或 `break main.c:42`(依檔案與行號)設一個中斷點。用 `run`(縮寫 `r`)執行。當它停住,就四下張望:`print n` 顯示某個變數,`info locals` 傾印視野中每一個區域變數,`list` 列出周圍的原始碼。用 `continue`(`c`)讓它前進到下一個中斷點。這個迴圈——設點、執行、查看、繼續——就佔了所有除錯器用法的九成。
有兩種中斷點很早就能回本。條件中斷點只在某個判斷為真時觸發:`break process.c:88 if i == 500` 會略過前面 499 次無害的迭代,恰好停在出狀況的那一圈——當臭蟲藏在上萬次迴圈深處時,這簡直無價。監看點是它的手足,用 `watch total` 設定:它盯的不是程式碼中的某個位置,而是在某個值改變的那一瞬間停下程式,無論是哪一行幹的。這就是你逮住某變數被你從沒懷疑過的程式碼蓋掉的方法。
單步執行:一行一行走過程式
一旦在中斷點凍結,你很少會想再把整支程式放出去跑。你想要一次小心地啜一口、看看有什麼改變。這就是 單步執行,而你絕不能搞混的兩個指令是 `next` 與 `step`。兩者都會執行目前這一行原始碼、並停在下一行——差別在於當目前這行呼叫了一個函式時它們各自怎麼做。`next`(`n`)把函式呼叫當成單一黑盒子:它把整個呼叫跑完,等它回傳後才停。`step`(`s`)會鑽進去,停在被呼叫函式內部的第一行。
拿捏的訣竅誠實又簡單:對你信得過的程式碼用 `next` 跨過去,對你懷疑的程式碼用 `step` 鑽進去。如果你 `step` 進像 printf() 這樣的函式庫呼叫,你會跌進你並不想看的機器層內部——所以對信得過的呼叫用 `next` 跨過去,把 `step` 留給你自己那些可疑的函式。第三個指令 `finish` 的意思是一直執行到目前函式回傳、然後停在呼叫者那裡;當你不小心鑽深了一層,它就是逃生艙。光靠 `next`、`step`、`finish` 三個,你就能以恰好符合你問題的粒度,走過任何一段執行過程。
回溯:我們究竟是怎麼走到這裡的?
這是 gdb 給你最有價值的一樣東西,也兌現了你先前學過的呼叫堆疊。當程式暫停時——在中斷點,或在崩潰那一刻被凍結——打 `backtrace`(`bt`),gdb 就會印出引你走到此刻的那條函式呼叫鏈,最內層的在最上面。每一行是一個堆疊框架:函式、它的引數、以及它正停在的那一行原始碼。由上往下讀,你就清清楚楚看見控制流是怎麼從 main() 一路往下流到你所在之處。
(gdb) bt #0 parse_token (s=0x0) at parser.c:73 #1 next_field (line=0x55...e0) at parser.c:140 #2 read_record (f=...) at record.c:58 #3 main (argc=2, argv=...) at main.c:22 -> frame #0 is where it stopped; s=0x0 is a NULL pointer. -> 'up' moves to #1, 'down' moves back toward #0.
看看這張草圖。程式停在 parse_token() 裡,是因為它被遞了一個空指標(s=0x0)卻去解參考它——一個教科書級的區段錯誤。但臭蟲幾乎從不在那個崩潰的函式裡;它在那個壞值誕生之處。所以你沿著堆疊往上走:`up` 到框架 #1、檢視 next_field 的區域變數、再 `up` 一次,每一層都問「這個值是從哪來的?」一個框架接一個框架,你從症狀往上爬,逼近那個真正造出錯誤指標的行。這趟攀爬就是全部的技藝,而回溯就是那張地圖。
留意這如何與你對程式如何執行的全部認識首尾相接。那份清單裡的每個框架,都是機器堆疊上實實在在的一塊區域,存著該次呼叫的返回位址與被保存的暫存器;gdb 不過是在走過這條框架鏈,並用 -g 那份地圖把原始位址翻譯回你的名稱與行號。下一篇會把同一套技巧用在一支已經死掉的程式上:在行程消失許久之後,從一份核心傾印讀它的回溯——但動作一模一樣:暫停、`bt`、從症狀爬向根因。
在一次真實崩潰上把它組起來
讓我們把一個臭蟲從頭走到尾,好讓整套節奏沉澱下來。假設 `./prog input.txt` 以 `Segmentation fault` 死掉。你只有一個症狀,別無其他。以下是從那一個沒用的字、走到一行修好的程式碼的有紀律路徑——這條路徑,你幾乎在往後遇到的每一次崩潰都會重複使用。
- 帶除錯資訊、不帶最佳化重新編譯:`gcc -g -O0 -Wall prog.c -o prog`。少了 -g,後面的步驟都半盲。
- 在除錯器底下執行:`gdb ./prog`,接著 `run input.txt`。讓它在 gdb 裡面崩潰,這樣行程會凍結在出錯處,而不是憑空消失。
- 當 gdb 停在出錯處,打 `bt`。由上往下讀回溯,找出它死掉的確切框架與行號。
- 在那個框架裡,用 `print` 印出出錯那行上的指標與變數。找出那個是 0x0 或一堆亂碼的——那就是壞了規矩的值。
- 一個框架接一個框架往上 `up`,邊走邊印,直到你抵達那行造出壞值的程式碼。那一行,而非崩潰現場,才是你的臭蟲。
- 修好根因、重新編譯,再在 gdb 底下重跑一次,確認崩潰消失、且一次乾淨的執行能跑到終點。
把這個迴圈跑上幾遍,它就不再像一個你操作的工具,而開始像你長出的一種感官。那個崩潰的字不再是死路,而成了一個起點座標。而本階段後面每一篇——核心傾印、Valgrind、各種消毒器、strace——都是同一份直覺對準不同類別的臭蟲:在現實破裂處讓程式停下、問什麼才是真的、再從症狀走回根因。gdb,就是這份直覺誕生之處。