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

核心傾印與事後除錯

你的程式已經掛掉了——在凌晨三點的伺服器上,沒掛除錯器,也沒辦法重現。一份核心傾印就是那一瞬間被凍結下來的快照照片。這篇要講核心如何替你拍下這張照片、怎麼確保它真的被存下來,以及如何把它載進 gdb、一路走回那行害死你的程式碼。

替屍體而非病人除錯

在上一篇導引裡,你是在程式還活著的時候駕馭 gdb 的:你設了中斷點、用單步執行一行一行走,當有東西看起來不對勁時,你印出一份回溯來看誰呼叫了誰。那是幸福的情況——你有一個活的行程可以戳。但傷得最重的那些臭蟲,從不給你這種奢侈。它們只發作一次,在一台你沒盯著的機器上,而等你趕到時,行程早就沒了。你無法把除錯器附掛到一個已經死亡的行程上。所以我們不再替活著的病人除錯,而是學著替一具屍體除錯。

一份核心傾印就是那份驗屍材料。當核心因為一個致命錯誤——比方說一次記憶體區段錯誤——而殺掉一個行程時,它可以在回收記憶體之前,寫出一個檔案,把行程在死亡瞬間的整個狀態都捕捉下來:記憶體的內容(堆積呼叫堆疊、全域變數),以及 CPU 暫存器(rax、rsp、rip,以及其餘的)。這名字是個古董:在 1960 年代,主記憶體是用一個個微小的磁性「磁核」織成的,所以把記憶體傾倒到磁碟,字面上就是一次 core dump(磁核傾印)。今天有幫助的比喻是一張照片:不是執行過程的一段影片,而是程式倒下那一微秒所拍下的、一個完美靜止的畫面。

為什麼那個檔案通常不見了

對新手殘酷的意外是:你當機了,你跑去找核心檔案,那裡卻什麼都沒有。這不是臭蟲,是政策。把一整份記憶體影像寫到磁碟,可能意味著好幾百 MiB,而一個正在當機的行程也許只是好幾千個之一,所以大多數系統出廠時,核心傾印是被掐住或預設關閉的。第一道閘門是一個針對每個行程的資源上限。shell 指令 `ulimit -c` 會顯示核心檔案大小的上限,而在一次全新登入後,它非常常是 0——意思是「寫零個位元組」,也就是根本永遠別產生核心檔案。用 `ulimit -c unlimited` 把它調高,就替當前的 shell 與它的子行程掀開了那道閘門。

通過那道閘門,第二道就出現了:就算核心被允許產生,它落在哪裡叫什麼名字,是由核心、而不是你的 shell 決定的。在 Linux 上,這由偽檔案 /proc/sys/kernel/core_pattern 來管轄。最簡單的設定是一個字面名稱像 `core`,它會在行程的工作目錄裡丟下一個叫 core(或 core.PID)的檔案。但在許多現代桌面上,core_pattern 反而以一個管線符號開頭,把傾印交給一個系統服務——systemd 的 coredumpd,或 Ubuntu 上的 apport——由它把核心收進一個系統目錄並加以壓縮。這就是為什麼目錄看起來空空如也:核心被拍下來了,只是不在你找的地方。你要用那個服務自己的工具去取回它,例如 `coredumpctl list` 與 `coredumpctl gdb`。

扣下扳機的那個訊號

真正決定一次死亡是否產生核心的,是結束該行程的那個訊號。當你的程式碼解參考一個壞指標時,硬體會陷入核心,而核心會遞送一個訊號給你的行程——通常是針對無效記憶體存取的 SIGSEGV,或當像失敗的 assert()、或偵測到的重複釋放之類呼叫了 abort() 時的 SIGABRT。有些訊號的預設動作是「終止並傾印核心」;有些則只是終止。SIGSEGV、SIGABRT、SIGFPE(一次壞的算術運算,像除以零)、SIGBUS 與 SIGILL,預設都會傾印。相對地,SIGTERM 和單純的 Ctrl-C(SIGINT),只會結束行程而不拍照——這正是為什麼一次乾淨的關閉不會留下核心。

你也可以刻意去拍這張照片。函式庫呼叫 abort() 會對你自己發出 SIGABRT,這是當程式偵測到一個不可能的狀態——一個被破壞的不變式、一個損毀的資料結構——時,讓它立刻停下來的有原則做法,好讓核心在現場還新鮮、損害尚未擴散之前就把它捕捉下來。這背後的機制,和一次失敗的斷言是同一個:一個觸發的 assert(p != NULL) 會 abort(),而如果核心傾印是開啟的,就會留給你一份直指那行罪魁禍首的傾印。手裡握著核心、早早大聲地當掉,勝過一拐一拐地撐下去、毀損掉你一小時後才會從一個沒用的症狀去除錯的狀態。

把照片載進 gdb

現在來收成。你交給 gdb 兩樣東西:當機的那個執行檔,以及它所產生的核心檔案——`gdb ./a.out core`。gdb 不會執行任何東西;程式已經死了。它反而從傾印重建出那個世界,把你丟在一個提示符前,彷彿你一直就站在當機現場的中斷點旁。第一個要打的指令是 `bt`(backtrace,回溯)。它印出死亡那一刻被凍結的呼叫堆疊——從 main() 一路向下到觸發錯誤那行的函式呼叫鏈——讓你由下往上讀,看清程式是怎麼走到它的劫數的。這就是你上一篇學到的同一個回溯,只不過現在是從一具屍體、而非一個暫停的活行程裡重建出來的。

但這份回溯只有在你帶著除錯資訊編譯時才讀得懂。這是整篇導引裡最重要的單一習慣。少了它,gdb 仍能給你看原始位址和暫存器值,但那些框架讀起來就是一面十六進位的牆——`0x000055e3a1b4 in ?? ()`——沒有函式名、沒有行號、沒有變數。解法是加上 `-g` 旗標、把除錯資訊打開來編譯:`gcc -g -O0 main.c`。-g 旗標告訴編譯器去嵌入一張從機器位址映射回你原始碼行與變數名的對照表,這樣 gdb 才能把屍體的原始位元組翻譯成你實際寫下的那些字。

  1. 帶著除錯資訊、不做最佳化來編譯:`gcc -g -O0 main.c -o a.out`。這裡保持 -O0,好讓傾印中的程式碼與你的原始碼一對一對應。
  2. 在啟動的 shell 裡開啟核心傾印:`ulimit -c unlimited`,並用 `cat /proc/sys/kernel/core_pattern` 確認它們會去哪裡。
  3. 執行程式讓它當掉:`./a.out`。你應該會看到一行像「Segmentation fault (core dumped)」的訊息。
  4. 在除錯器裡打開那具屍體:`gdb ./a.out core`(若是某個服務捕捉到的,就用 `coredumpctl gdb`)。
  5. 打 `bt` 取得回溯、找出當機的那個框架,再用 `frame N` 進到你在意的那個框架裡。
  6. 用 `print p` 或 `info locals` 檢視那些已死的變數,看清楚那個害死你的確切值——通常是一個空指標或野指標。

讀懂現場,以及它誠實的限制

一旦你站在當機的那個框架,這具屍體就完全可以檢視了。`print p` 顯示一個指標的值;`print *p` 本來就會是當初害你當機的那件事,但 `print p` 是安全的,因為你只是在讀傾印裡存著的位元組,並沒有執行程式碼。`info locals` 把當前框架裡每一個區域變數都倒出來,而 `info registers` 把 rax、rsp、rip 等等,原封不動地照死亡那一刻的樣子給你看——尤其 rip,正指著那條出錯的指令。一次經典的判讀是這樣:bt 顯示當機發生在一個叫 process() 的函式裡;`frame 1` 移過去;`print buf` 揭露 buf 是 0x0,一個空指標;於是你現在知道 process() 是被一個某呼叫端從未配置過的緩衝區呼叫的。驗屍完成。

三個誠實的但書,讓你不至於過度信任這張照片。第一,執行檔與核心必須相符——如果你在當機後哪怕只是稍微重新編譯過,位址到行號的對照表就對不上了,gdb 會誤導你;只用產生出該核心的那個一模一樣的二進位檔去除錯它。第二,最佳化會把現場攪渾:在 -O2 下編譯器會把程式碼重排與內聯,所以一個框架可能被「最佳化掉了」,而一個變數即便有 -g 在場也可能讀作 `<optimized out>`。這就是為什麼 -O0 對除錯最和善,儘管正式版的當機你必須照它原本的樣子去讀。第三,當機地點不總是根本原因:一個在這裡為空的指標,也許是被一百萬條指令之前、完全不同的程式碼裡的一次緩衝區溢位給破壞掉的。

最後這個但書,正是通往這個階級其餘部分的橋。一份核心傾印告訴你屍體倒在哪裡,而那有時離傷口是在哪裡造成的很遠。當症狀與成因相隔甚遠——這裡一個失效的指標、那裡一次脫靶的寫入——這張靜態的照片就走到了路的盡頭,而你會去拿那些在記憶體被使用的當下、而非事後才去觀察它的工具。這正是 Valgrind消毒器所做的事,它們是接下來兩篇導引的主題。核心傾印仍是你不可或缺的第一線急救員:拍攝起來便宜、當一個臭蟲只在一台你永遠不會見到的機器上發作一次時無比珍貴,也是把「它當機了」變成「它在第 42 行、buf 等於 0x0 時當機了」最快的方法。