深入探索 GDB 調試技巧及其底層實現原理

本文分爲兩個大模塊,第一部分記錄下本人常用到的 GDB 的調試方法和技巧,第二部分則嘗試分析 GDB 調試的底層原理。

一、GDB 調試

要讓程序能被調試,首先得編譯成 debug 版本,當然 release 版本的也能通過導入符號表來實現調試,目前沒試過。

GDB 打斷點用 break 命令,一般簡寫 b,斷點有多種形式。

1.1 行斷點

可以在指定的文件的指定行裏打斷點,形式是:break 源文件名字 : 行號,比如:

b source.cpp:22

1.2 函數斷點

感覺更常用的是函數斷點,因爲我們在定位問題的時候,往往定位到某個關鍵函數,該函數可能被多次調用,被調用的位置也很多,那麼用行斷點就不太方便了,GDB 可以給一個函數打上斷點,打上斷點後,用 continue,簡寫 c,程序執行到函數被調用處就會阻塞,而不需要關注它在哪個文件哪一行被調用了。用法如下:

b func1

當進程阻塞在這之後,我們就可以用 step 命令,簡寫 s,來進入該函數內部,然後進一步用 next 或 step 來跟蹤函數里面的代碼

1.3 條件斷點

在調試一些循環語句中,我們有時候需要觀察某自增變量達到一個特定值的時候,代碼的行爲,這個時候就需要條件變量,比如 for 循環語句裏,我們只想在 i == 12 的時候觀察程序的運行,那麼就可以在斷點位置後面加上一個觸發條件,比如:

b source.cpp:22 if i == 12

那麼程序只會在 i==12 的時候阻塞,在 i 取其它值的時候,程序可以正常運行

1.4 多線程調試

當程序有多個線程的情況時,某個函數可能會被多個線程調用我們可以先用 info threads 查看線程編號,然後再限定下哪個線程指定到這裏需要阻塞,比如我們指定編號爲 3 的線程:

break source.cpp : 22 thread 3break func1 thread 3

或者我們指定僅運行當前線程,如下:

set scheduler-locking on

on 就是打開,off 關閉後就是運行所有線程。

注意:一般是用 step 進入到函數里面,只想跟蹤該函數內部的執行時才使用該命令,否則其餘情況線程不能切換,可能對調試會造成麻煩。

從語句就可以看出,它的意思就是設置(線程)調度關閉 / 開啓。

因爲在大型工程裏面,一個函數被多個線程調用,而那些線程調用我們這個目標函數具體做什麼事情我們並不關心,我們只需要在當前的線程裏,(該函數也可能在一個循環裏多次調用,服務器進程經常有這種情況),當前面幾次函數執行完還沒有達到我們想要的結果時,如果發生了線程切換,那將很麻煩,而限定程序不切換線程,那麼一直執行當前這個線程,那就更好定位問題了。

比如調試某基於 PG 內核的數據庫的 SQL 入口函數的時候,該函數會被十多個線程調用,而我的問題出現在主線程上,所以我需要設置線程不切換。

另外,在多進程情況下(有 fork() 時),GDB 默認模式下,只能調試這個父進程不會跟蹤子進程,不過可以設置,命令:set follow-fork-child,這樣就會跟蹤子進程了

1.5 刪除斷點和忽略斷點

用 info break 查看斷點信息,每個斷點都有個編號,當某些斷點不需要時,我們可以用 delete 刪除它,,比如刪除斷點 3:

delete 3

也可以將某行代碼上的所有斷點都清除,clear:

clear source.cpp : 22

如果只是暫時忽略某個斷點,還可以設置忽略次數,比如忽略斷點 3 一共 12 次,ignore:

ignore 3 12

  1. next 和 step

next 簡寫成 n,當執行到某一行我們想要繼續往下一行代碼走時就可以用該命令;

step 簡寫成 s,它也是單步執行,與 next 不同的是 1,如果當前代碼行是調用了某個函數,那麼 step 會進入該被調用的函數里面,一般比較接近我們的問題相關的代碼時,就可以用 step 進入函數內部,再單步調試。

  1. 查看棧幀

在多線程環境下,因爲每個線程都有一個棧,所以首先得切換線程,info threads 查看線程編號,加入要切換到的線程是 3 號,那麼 thread 3 即可切換到 3 號線程。如果前面設置了關閉線程切換,那就不用管。

查看棧幀的命令是 backtrace,簡寫 bt。它會依次從棧頂往棧底列出當前線程的棧幀,如下所示,#0 即是棧頂,也就是說,當前線程正在執行 exec_simple_query() 函數,而且我們可以看到該函數被傳入的參數的值

3.1 回退棧幀

使用 up n 和 down n 可以對棧幀進行回退和前進,想改變當前調試的函數時很好用。如當前在棧幀 0 處,那麼 up 5 就會切換到棧幀 #5 處(up 叫 up 但卻是往棧底走的,爲了不記憶錯亂,記成它會走到棧幀序號更大的棧幀),再 down 4,那麼就到了棧幀 #1 的地方

  1. attach 和 detach

我們經常需要調試一個已經在運行的進程,一般先用 top 命令查看其進程號,或者 ps -ef | grep 進程名字查看,其中 - ef 可以把前臺、後臺的進程都展示出來。

查詢到 PID 之後,就用 gdb attach PID 調試該進程;注意,調試完該進程後,用 detach 命令分離被調試進程和 gdb,這樣該程序將不再受 gdb 的控制,而 gdb 也可以繼續去 attach 其它進程。

如果沒有 detach,那麼當我們殺死 gdb 進程的時候,被調試的進程也會被殺死。

看看 GDB 的官方文檔對 detach 的描述:

detach When you have finished debugging the attached process, you can use the detach command to release it from GDB control. Detaching the process continues its execution. After the detach command, that process and GDB become completely independent once more, and you are ready to attach another process or start one with run. detach does not repeat if you press RET again after executing the command. If you exit GDB or use the run command while you have an attached process, you kill that process. By default, GDB asks for confirmation if you try to do either of these things; you can control whether or not you need to confirm by using the set confirm command (see section Optional warnings and messages).

  1. handle 信號處理

GDB 在調試進程的時候,可能會受到來自進程的各種信號,這個時候我們需要定義下 GDB 遇到某種信號時,做某種處理,其語法格式爲:

handle 信號類型 處理方式

比如我調試 PG 內核的時候,就會收到 SIGUSR2,這是用戶自定義信號,某個進程收到該信號時,默認的處理方式是進程終止,因此當沒有在 gdb 調試前設置針對該信號的處理方式時,輸入 c 後,調試並沒有正常進行,而是停了下來,並且打印了一些信息,這個時候就需要使用 handle 來處理 SIGUSR2 信號,如下:

handle SIGUSR2 nostop noprint

然後再輸入 c 去 continue,就能正常進行調試了。

  1. 查看代碼

gdb attach 進程之後,執行 layout src 會出現兩個窗口,上方窗口用於看代碼,開了兩個窗口不能上下切換查看歷史命令。

可以切換兩個窗口間焦點,用 fs next,這樣就可以使用上下鍵查看歷史命令了。

  1. 查看函數彙編代碼
disassemble funcName

  1. 內存泄漏

像數據庫內核這種代碼量龐大的項目,可以用靜態代碼檢測工具去檢測內存泄漏。

如果要在中小型項目中用 GDB 調試的時候去幫助判斷是否發生內存泄漏的話,可以給 malloc/free 或者自己封裝的內存申請 / 釋放函數打上斷點,並且打印對應的指針的值,可以設置跟蹤變量,比如 malloc 返回的指針 p 進行跟蹤:watch p,因爲它如果被釋放並且被置空的話,最後是可以看到該變量爲 0x0 的。

還可以在 GDB 中 call 一下 glibc 庫函數:malloc_stats() 函數可以統計本進程具體的內存使用情況,精確到字節,觀察 in use bytes 的數值變化。

二、GDB 調試原理

GDB 能夠對程序進行調試,源自於一個系統調用:ptrace

第一個參數 request 參數指定了我們要使用 ptrace 的什麼功能。

2.1 調試一個可執行程序 test

用 GDB 去運行一個程序,比如 gdb ./test,或者是先進入 gdb,再執行./test 運行程序 test,第一個參數就是 PTRACE_TRACEME,顧名思義,就是 “跟蹤我”。

參數 pid 表示的是要跟蹤進程的 pid, addr 表示要監控的被跟蹤子進程的地址。

這個時候,原理就是開啓一個 GDB 進程,然後 GDB 進程 fork 出一個子進程,讓子進程執行 PTRACE_TRACEME, 然後子進程再調用 execve(),如下圖

此時,GDB 進程及其子進程就可以讀寫 test 進程的指令空間、數據空間、堆棧和寄存器的值。而且 gdb 進程接管了 test 進程的所有信號,也就是說系統向 test 進程發送的所有信號,都被 gdb 進程接收到。

(其實應該是內核給 gdb 的子進程發信號,然後該進程給其父進程即 GDB 進程發信號,父子進程間通信很容易)

2.2 GDB 調試一個已經存在的程序即 gdb attach 原理

我們用 gdb attach PID 的時候,ptrace 第一個參數傳入的就是 PTRACE_ATTACH,這是父進程調用 attach 到已經運行的子進程中; 這個命令會有權限的檢查, 普通用戶進程不能 attach 到 root 進程中,但一般調試的都是普通用戶進程,所以也沒遇到過問題。

這個過程就是:運行一個 GDB 進程,他調用 ptrace() 嘗試去 attach 附着目標進程 test,此時 GDB 需要給 test 進程發送一個信號 SIGSTOP,要求 test 停止,這個信號是不能忽略的,然後 test 進程就進入 TASK_STOPED 狀態,(用 top -u 用戶名可以看到被 gdb attach 的進程如果沒有 continue 的話,其進程狀態是 t,這個就是暫停或被跟蹤),然後之後狀態是被跟蹤狀態 TASK_TRACED,這個不重要,反正狀態都是 t,而不是 Run。

這個過程的示意圖如下

2.3 GDB 斷點原理

在某行代碼處打一個斷點,其實就是將該行代碼的彙編 (是指令級別!!!)用 INT 3 中斷指令代替,原來的代碼被保存到 “斷點鏈表” 中。

這個是軟中斷,硬中斷是外設給 CPU 中斷,讓 CPU 停下,這個是內核在 CPU 待執行指令中插入的中斷指令 (勘誤,CPU 執行到 int 3 中斷指令纔不會停下,CPU 只是個執行指令的機器它不會自己停下,只不過此時執行中斷指令,然後 CPU 被操作系統內核代碼佔據,也就是進入所謂的 CPU 的內核態,然後內核會進行補不同進程的調度),所以是軟中斷。(都是讓 CPU 收到中斷指令,只是看是硬件發的還是軟件發的)

INT n 這種中斷指令,CPU 執行到這裏時,內核調用相應的中斷處理程序,對於 INT 3,那就是當前進程 test 停止運行,將 CPU 交給 GDB 進程用。

INT 3 是 x86 系列處理器提供的專門用來支持調試的指令。簡單地說,這條指令的目的就是使 CPU 中斷(break)到調試器,以供調試者對執行現場進行各種分析

這裏還有個細節,就是運行到中斷指令的話,這句指令不是執行完了嗎,那我們到斷點處,是怎麼繼續運行該斷點處的代碼的?

實際上,CPU 輪到 GDB 進程後,GDB 會去斷點鏈表裏找到原先的彙編指令(源代碼也一樣),將斷點那一行的 INT 3 又替換回原先的代碼,而且讓 PC 指針回退回該行。

所以我們想執行斷點處的代碼的話,輸入指令 n,就行了,而不是直接執行斷點的下一行。

PC:Program Counter,是通用寄存器,但是有特殊用途,用來指向當前運行指令的下一條指令

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pbt13ROzcTkGQign2j8mSQ