JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

gdb 入門:中斷點與回溯

在此之前,你能看進執行中程式的唯一窗口,就是它願意印出來的東西。除錯器把那扇窗整個撬開:你可以讓程式在半空中凍結、檢視任何變數,並問出能終結多數除錯戰役的那一個問題——我們究竟是怎麼走到這裡的?

從 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.
崩潰後的一段回溯:由上往下讀,框架 #0 中的引數 s=0x0 就點名了元兇。

看看這張草圖。程式停在 parse_token() 裡,是因為它被遞了一個空指標(s=0x0)卻去解參考它——一個教科書級的區段錯誤。但臭蟲幾乎從不在那個崩潰的函式裡;它在那個壞值誕生之處。所以你沿著堆疊往上走:`up` 到框架 #1、檢視 next_field 的區域變數、再 `up` 一次,每一層都問「這個值是從哪來的?」一個框架接一個框架,你從症狀往上爬,逼近那個真正造出錯誤指標的行。這趟攀爬就是全部的技藝,而回溯就是那張地圖。

留意這如何與你對程式如何執行的全部認識首尾相接。那份清單裡的每個框架,都是機器堆疊上實實在在的一塊區域,存著該次呼叫的返回位址與被保存的暫存器;gdb 不過是在走過這條框架鏈,並用 -g 那份地圖把原始位址翻譯回你的名稱與行號。下一篇會把同一套技巧用在一支已經死掉的程式上:在行程消失許久之後,從一份核心傾印讀它的回溯——但動作一模一樣:暫停、`bt`、從症狀爬向根因。

在一次真實崩潰上把它組起來

讓我們把一個臭蟲從頭走到尾,好讓整套節奏沉澱下來。假設 `./prog input.txt` 以 `Segmentation fault` 死掉。你只有一個症狀,別無其他。以下是從那一個沒用的字、走到一行修好的程式碼的有紀律路徑——這條路徑,你幾乎在往後遇到的每一次崩潰都會重複使用。

  1. 帶除錯資訊、不帶最佳化重新編譯:`gcc -g -O0 -Wall prog.c -o prog`。少了 -g,後面的步驟都半盲。
  2. 在除錯器底下執行:`gdb ./prog`,接著 `run input.txt`。讓它在 gdb 裡面崩潰,這樣行程會凍結在出錯處,而不是憑空消失。
  3. 當 gdb 停在出錯處,打 `bt`。由上往下讀回溯,找出它死掉的確切框架與行號。
  4. 在那個框架裡,用 `print` 印出出錯那行上的指標與變數。找出那個是 0x0 或一堆亂碼的——那就是壞了規矩的值。
  5. 一個框架接一個框架往上 `up`,邊走邊印,直到你抵達那行造出壞值的程式碼。那一行,而非崩潰現場,才是你的臭蟲。
  6. 修好根因、重新編譯,再在 gdb 底下重跑一次,確認崩潰消失、且一次乾淨的執行能跑到終點。

把這個迴圈跑上幾遍,它就不再像一個你操作的工具,而開始像你長出的一種感官。那個崩潰的字不再是死路,而成了一個起點座標。而本階段後面每一篇——核心傾印、Valgrind、各種消毒器、strace——都是同一份直覺對準不同類別的臭蟲:在現實破裂處讓程式停下、問什麼才是真的、再從症狀走回根因。gdb,就是這份直覺誕生之處。