手把手教你使用 GNU 調試器
GNU 調試器是一個發現程序缺陷的強大工具。
如果你是一個程序員,想在你的軟體增加某些功能,你首先考慮實現它的方法:例如寫一個方法、定義一個類,或者創建新的數據類型。然後你用編譯器或解釋器可以理解的編程語言來實現這個功能。但是,如果你覺得你所有代碼都正確,但是編譯器或解釋器依然無法理解你的指令怎麼辦?如果軟體大多數情況下都運行良好,但是在某些環境下出現缺陷怎麼辦?這種情況下,你得知道如何正確使用調試器找到問題的根源。
GNU 調試器 (GDB)是一個發現項目缺陷的強大工具。它通過追蹤程序運行過程中發生了什麼來幫助你發現程序錯誤或崩潰的原因。(LCTT 校註:GDB 全程是「GNU Project Debugger」,即 「GNU 項目調試器」,但是通常我們簡稱為「GNU 調試器」)
本文是 GDB 基本用法的實踐教程。請跟隨示例,打開命令行並克隆此倉庫:
git clone https://github.com/hANSIc99/core_dump_example.git
快捷方式
GDB 的每條命令都可以縮短。例如:顯示設定的斷點的 info break
命令可以被縮短為 i break
。你可能在其他地方看到過這種縮寫,但在本文中,為了清晰展現使用的函數,我將所寫出整個命令。
命令行參數
你可以將 GDB 附加到每個可執行文件。進入你克隆的倉庫(core_dump_example
),運行 make
進行編譯。你現在能看到一個名為 coredump
的可執行文件。(更多信息,請參考我的文章《創建和調試 Linux 的轉儲文件》。)
要將 GDB 附加到這個可執行文件,請輸入: gdb coredump
。
你的輸出應如下所示:
返回結果顯示沒有找到調試符號。
調試信息是 目標文件 (可執行文件)的組成部分,調試信息包括數據類型、函數簽名、源代碼和操作碼之間的關係。此時,你有兩種選擇:
- 繼續調試彙編代碼(參見下文「無符號調試」)
- 使用調試信息進行編譯,參見下一節內容
使用調試信息進行編譯
為了在二進位文件中包含調試信息,你必須重新編譯。打開 Makefile
,刪除第 9 行的注釋標籤(#
)後重新編譯:
CFLAGS =-Wall -Werror -std=c++11 -g
-g
告訴編譯器包含調試信息。運行 make clean
,接著運行 make
,然後再次調用 GDB。你得到如下輸出後就可以調試代碼了:
新增的調試信息會增加可執行文件的大小。在這種情況下,執行文件增加了 2.5 倍(從 26,088 位元組 增加到 65,480 位元組)。
輸入 run -c1
,使用 -c1
開關啟動程序。當程序運行到達 State_4
時將崩潰:
你可以檢索有關程序的其他信息,info source
命令提供了當前文件的信息:
- 101 行代碼
- 語言: C++
- 編譯器(版本、調優、架構、調試標誌、語言標準)
- 調試格式:DWARF 2
- 沒有預處理器宏指令(使用 GCC 編譯時,宏僅在 使用 -g3 標誌編譯 時可用)。
info shared
命令列印了動態庫列表機器在虛擬地址空間的地址,它們在啟動時被載入到該地址,以便程序運行:
如果你想了解 Linux 中的庫處理方式,請參見我的文章 在 Linux 中如何處理動態庫和靜態庫。
調試程序
你可能已經注意到,你可以在 GDB 中使用 run
命令啟動程序。run
命令接受命令行參數,就像從控制台啟動程序一樣。-c1
開關會導致程序在第 4 階段崩潰。要從頭開始運行程序,你不用退出 GDB,只需再次運行 run
命令。如果沒有 -c1
開關,程序將陷入死循環,你必須使用 Ctrl+C
來結束死循環。
你也可以一步一步運行程序。在 C/C++ 中,入口是 main
函數。使用 list main
命令打開顯示 main
函數的部分源代碼:
main
函數在第 33 行,因此可以輸入 break 33
在 33 行添加斷點:
輸入 run
運行程序。正如預期的那樣,程序在 main
函數處停止。輸入 layout src
並排查看源代碼:
你現在處於 GDB 的文本用戶界面(TUI)模式。可以使用鍵盤向上和向下箭頭鍵滾動查看源代碼。
GDB 高亮顯示當前執行行。你可以輸入 next
(n
)命令逐行執行命令。如果你沒有指定新的命令,GBD 會執行上一條命令。要逐行運行代碼,只需按回車鍵。
有時,你會發現文本的輸出有點顯示不正常:
如果發生這種情況,請按 Ctrl+L
重置屏幕。
使用 Ctrl+X+A
可以隨時進入和退出 TUI 模式。你可以在手冊中找到 其他的鍵綁定 。
要退出 GDB,只需輸入 quit
。
設置監察點
這個示常式序的核心是一個在無限循環中運行的狀態機。n_state
變數枚舉了當前所有狀態:
while(true){
switch(n_state){
case State_1:
std::cout << "State_1 reached" << std::flush;
n_state = State_2;
break;
case State_2:
std::cout << "State_2 reached" << std::flush;
n_state = State_3;
break;
(.....)
}
}
如果你希望當 n_state
的值為 State_5
時停止程序。為此,請在 main
函數處停止程序並為 n_state
設置監察點:
watch n_state == State_5
只有當所需的變數在當前上下文中可用時,使用變數名設置監察點才有效。
當你輸入 continue
繼續運行程序時,你會得到如下輸出:
如果你繼續運行程序,當監察點表達式評估為 false
時 GDB 將停止:
你可以為一般的值變化、特定的值、讀取或寫入時來設置監察點。
更改斷點和監察點
輸入 info watchpoints
列印先前設置的監察點列表:
刪除斷點和監察點
如你所見,監察點就是數字。要刪除特定的監察點,請先輸入 delete
後輸入監察點的編號。例如,我的監察點編號為 2;要刪除此監察點,輸入 delete 2
。
注意: 如果你使用 delete
而沒有指定數字,所有 監察點和斷點將被刪除。
這同樣適用於斷點。在下面的截屏中,我添加了幾個斷點,輸入 info breakpoint
列印斷點列表:
要刪除單個斷點,請先輸入 delete
後輸入斷點的編號。另外一種方式:你可以通過指定斷點的行號來刪除斷點。例如,clear 78
命令將刪除第 78 行設置的斷點號 7。
禁用或啟用斷點和監察點
除了刪除斷點或監察點之外,你可以通過輸入 disable
,後輸入編號禁用斷點或監察點。在下文中,斷點 3 和 4 被禁用,並在代碼窗口中用減號標記:
也可以通過輸入類似 disable 2 - 4
修改某個範圍內的斷點或監察點。如果要重新激活這些點,請輸入 enable
,然後輸入它們的編號。
條件斷點
首先,輸入 delete
刪除所有斷點和監察點。你仍然想使程序停在 main
函數處,如果你不想指定行號,可以通過直接指明該函數來添加斷點。輸入 break main
從而在 main
函數處添加斷點。
輸入 run
從頭開始運行程序,程序將在 main
函數處停止。
main
函數包括變數 n_state_3_count
,當狀態機達到狀態 3 時,該變數會遞增。
基於 n_state_3_count
的值添加一個條件斷點,請輸入:
break 54 if n_state_3_count == 3
繼續運行程序。程序將在第 54 行停止之前運行狀態機 3 次。要查看 n_state_3_count
的值,請輸入:
print n_state_3_count
使斷點成為條件斷點
你也可以使現有斷點成為條件斷點。用 clear 54
命令刪除最近添加的斷點,並通過輸入 break 54
命令添加一個簡單的斷點。你可以輸入以下內容使此斷點成為條件斷點:
condition 3 n_state_3_count == 9
3
指的是斷點編號。
在其他源文件中設置斷點
如果你的程序由多個源文件組成,你可以在行號前指定文件名來設置斷點,例如,break main. cpp:54
。
捕捉點
除了斷點和監察點之外,你還可以設置捕獲點。捕獲點適用於執行系統調用、載入共享庫或引發異常等事件。
要捕獲用於寫入 STDOUT 的 write
系統調用,請輸入:
catch syscall write
每當程序寫入控制台輸出時,GDB 將中斷執行。
在手冊中,你可以找到一整章關於 斷點、監察點和捕捉點 的內容。
評估和操作符號
用 print
命令可以列印變數的值。一般語法是 print <表達式> <值>
。修改變數的值,請輸入:
set variable <variable-name> <new-value>.
在下面的截屏中,我將變數 n_state_3_count
的值設為 123
。
/x
表達式以十六進位列印值;使用 &
運算符,你可以列印虛擬地址空間內的地址。
如果你不確定某個符號的數據類型,可以使用 whatis
來查明。
如果你要列出 main
函數範圍內可用的所有變數,請輸入 info scope main
:
DW_OP_fbreg
值是指基於當前子程序的堆棧偏移量。
或者,如果你已經在一個函數中並且想要列出當前堆棧幀上的所有變數,你可以使用 info locals
:
查看手冊以了解更多 檢查符號 的內容。
附加調試到一個正在運行的進程
gdb attach <進程 ID>
命令允許你通過指定進程 ID(PID)附加到一個已經在運行的進程進行調試。幸運的是,coredump
程序將其當前 PID 列印到屏幕上,因此你不必使用 ps 或 top 手動查找 PID。
啟動 coredump
應用程序的一個實例:
./coredump
操作系統顯示 PID 為 2849
。打開一個單獨的控制台窗口,移動到 coredump
應用程序的根目錄,然後用 GDB 附加到該進程進行調試:
gdb attach 2849
當你用 GDB 附加到進程時,GDB 會立即停止進程運行。輸入 layout src
和 backtrace
來檢查調用堆棧:
輸出顯示在 main.cpp
第 92 行調用 std::this_thread::sleep_for<...>(. ..)
函數時進程中斷。
只要你退出 GDB,該進程將繼續運行。
你可以在 GDB 手冊中找到有關 附加調試正在運行的進程 的更多信息。
在堆棧中移動
在命令窗口,輸入 up
兩次可以在堆棧中向上移動到 main.cpp
:
通常,編譯器將為每個函數或方法創建一個子程序。每個子程序都有自己的棧幀,所以在棧幀中向上移動意味著在調用棧中向上移動。
你可以在手冊中找到有關 堆棧計算 的更多信息。
指定源文件
當調試一個已經在運行的進程時,GDB 將在當前工作目錄中尋找源文件。你也可以使用 目錄命令 手動指定源目錄。
評估轉儲文件
閱讀 創建和調試 Linux 的轉儲文件 了解有關此主題的信息。
參考文章太長,簡單來說就是:
- 假設你使用的是最新版本的 Fedora
- 使用
-c1
開關調用 coredump:coredump -c1
- 使用 GDB 載入最新的轉儲文件:
coredumpctl debug
- 打開 TUI 模式並輸入
layout src
backtrace
的輸出顯示崩潰發生在距離 main.cpp
五個棧幀之外。回車直接跳轉到 main.cpp
中的錯誤代碼行:
看源碼發現程序試圖釋放一個內存管理函數沒有返回的指針。這會導致未定義的行為並引起 SIGABRT
。
無符號調試
如果沒有源代碼,調試就會變得非常困難。當我在嘗試解決逆向工程的挑戰時,我第一次體驗到了這一點。了解一些 彙編語言 的知識會很有用。
我們用例子看看它是如何運行的。
找到根目錄,打開 Makefile
,然後像下面一樣編輯第 9 行:
CFLAGS =-Wall -Werror -std=c++11 #-g
要重新編譯程序,先運行 make clean
,再運行 make
,最後啟動 GDB。該程序不再有任何調試符號來引導源代碼的走向。
info file
命令顯示二進位文件的內存區域和入口點:
.text
區段始終從入口點開始,其中包含實際的操作碼。要在入口點添加斷點,輸入 break *0x401110
然後輸入 run
開始運行程序:
要在某個地址設置斷點,使用取消引用運算符 *
來指定地址。
選擇反彙編程序風格
在深入研究彙編之前,你可以選擇要使用的 彙編風格。 GDB 默認是 AT&T,但我更喜歡 Intel 語法。變更風格如下:
set disassembly-flavor intel
現在輸入 layout asm
調出彙編代碼窗口,輸入 layout reg
調出寄存器窗口。你現在應該看到如下輸出:
保存配置文件
儘管你已經輸入了許多命令,但實際上還沒有開始調試。如果你正在大量調試應用程序或嘗試解決逆向工程的難題,則將 GDB 特定設置保存在文件中會很有用。
該項目的 GitHub 存儲庫中的 gdbinit 配置文件包含最近使用的命令:
set disassembly-flavor intel
set write on
break *0x401110
run -c2
layout asm
layout reg
set write on
命令使你能夠在程序運行期間修改二進位文件。
退出 GDB 並使用配置文件重新啟動 GDB : gdb -x gdbinit coredump
。
閱讀指令
應用 c2
開關後,程序將崩潰。程序在入口函數處停止,因此你必須寫入 continue
才能繼續運行:
idiv
指令進行整數除法運算:RAX
寄存器中為被除數,指定參數為除數。商被載入到 RAX
寄存器中,餘數被載入到 RDX
中。
從寄存器角度,你可以看到 RAX
包含 5
,因此你必須找出存儲堆棧中位置為 rbp-0x4
的值。
讀取內存
要讀取原始內存內容,你必須指定比讀取符號更多的參數。在彙編輸出中向上滾動一點,可以看到堆棧的劃分:
你最感興趣的應該是 rbp-0x4
的值,因為它是 idiv
的存儲參數。你可以從截圖中看到rbp-0x8
位置的下一個變數,所以 rbp-0x4
位置的變數是 4 位元組寬。
在 GDB 中,你可以使用 x
命令查看任何內存內容:
x/
< 可選參數n
、f
、u
> < 內存地址addr
>
可選參數:
n
:單元大小的重複計數(默認值:1)f
:格式說明符,如 printfu
:單元大小b
:位元組h
:半字(2 個位元組)- w: 字(4 個位元組)(默認)
- g: 雙字(8 個位元組)
要列印 rbp-0x4
的值,請輸入 x/u $rbp-4
:
如果你能記住這種模式,則可以直接查看內存。參見手冊中的 查看內存 部分。
操作彙編
子程序 zeroDivide()
發生運算異常。當你用向上箭頭鍵向上滾動一點時,你會找到下面信息:
0x401211 <_Z10zeroDividev> push rbp
0x401212 <_Z10zeroDividev+1> mov rbp,rsp
這被稱為 函數前言:
- 調用函數的基指針(
rbp
)存放在棧上 - 棧指針(
rsp
)的值被載入到基指針(rbp
)
完全跳過這個子程序。你可以使用 backtrace
查看調用堆棧。在 main
函數之前只有一個堆棧幀,所以你可以用一次 up
回到 main
:
在你的 main
函數中,你會找到下面信息:
0x401431 <main+497> cmp BYTE PTR [rbp-0x12],0x0
0x401435 <main+501> je 0x40145f <main+543>
0x401437 <main+503> call 0x401211<_Z10zeroDividev>
子程序 zeroDivide()
僅在 jump equal (je)
為 true
時進入。你可以輕鬆地將其替換為 jump-not-equal (jne)
指令,該指令的操作碼為 0x75
(假設你使用的是 x86/64 架構;其他架構上的操作碼不同)。輸入 run
重新啟動程序。當程序在入口函數處停止時,設置操作碼:
set *(unsigned char*)0x401435 = 0x75
最後,輸入 continue
。該程序將跳過子程序 zeroDivide()
並且不會再崩潰。
總結
你會在許多集成開發環境(IDE)中發現 GDB 運行在後台,包括 Qt Creator 和 VSCodium 的 本地調試 擴展。
了解如何充分利用 GDB 的功能很有用。一般情況下,並非所有 GDB 的功能都可以在 IDE 中使用,因此你可以從命令行使用 GDB 的經驗中受益。
via: https://opensource.com/article/21/1/gnu-project-debugger
作者:Stephan Avenwedde 選題:lkxed 譯者:Maisie-x 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive