自己動手寫一個GDB|基本功能
什麼是 GDB
GDB
全稱 the GNU Project debugger
,主要用來除錯使用者態應用程式。
根據官方文件介紹,GDB 支援除錯以下語言編寫的應用程式:
-
Ada
-
Assembly
-
C
-
C++
-
D
-
Fortran
-
Go
-
Objective-C
-
OpenCL
-
Modula-2
-
Pascal
-
Rust
當然,最常用的還是用於除錯 C/C++ 編寫的應用程式。
本文並不是 GDB 的使用教程,所以不會對 GDB 的使用進行詳細的介紹。本文的目的是,教會大家自己動手擼一個簡易的 GDB。所以閱讀本文前,最好先了解下 GDB 的使用。
在程式設計圈中流傳一句話: 不要重複造輪子
。但是本人覺得,重複造輪子才能真正理解輪子的實現原理。
ptrace 系統呼叫
GDB
實現的核心技術是 ptrace()
系統呼叫。
如果你對 ptrace 的實現原理有興趣,可以閱讀這篇文章進行了解:《ptrace實現原理》
ptrace()
是一個複雜的系統呼叫,主要用於編寫除錯程式。你可以通過以下命令來檢視 ptrace()
的介紹:
$ man ptrace
ptrace()
系統呼叫的功能很強大,但我們並不會用到所有的功能。所以,本文的約定是:在編寫程式的過程中,使用到的功能才會進行詳細介紹。
簡易的 GDB
我們要實現一個有如下功能的 GDB:
-
可以對一個可執行程式進行除錯。
-
可以在除錯程式時,設定斷點。
-
可以在除錯程式時,列印程式的資訊。
下面主要圍繞這三個功能進行闡述。
1. 除錯可執行檔案
我們使用 GDB 除錯程式時,一般使用 GDB 直接載入程式的可執行檔案,如下命令:
$ gdb ./example
上面命令的執行過程如下:
-
首先,GDB 呼叫
fork()
系統呼叫建立一個新的子程序。 -
然後,子程序會呼叫
exec()
系統呼叫載入程式的可執行檔案到記憶體。 -
接著,子程序便進入停止狀態(停止執行),並且等待 GDB 主程序傳送除錯命令。
流程如下圖所示:
我們可以按照上面的流程來編寫程式碼:
第一步:建立被除錯子程序
除錯程式一般分為 被除錯程序
與 除錯程序
。
-
被除錯程序
:就是需要被除錯的程序。 -
除錯程序
:主要用於向 被除錯程序 傳送除錯命令。
實現程式碼如下:
int main(int argc, char** argv)
{
pid_t child_pid;
if (argc < 2) {
fprintf(stderr, "Expected a program name as argument\n");
return -1;
}
child_pid = fork();
if (child_pid == 0) { // 1) 子程序:被除錯程序
load_executable_file(argv[1]); // 載入可執行檔案
} else if (child_pid > 0) { // 2) 父程序:除錯程序
send_debug_command(child_pid); // 傳送除錯命令
} else {
perror("fork");
return -1;
}
return 0;
}
上面的程式碼執行流程如下:
-
主程序首先呼叫
fork()
系統呼叫建立一個子程序。 -
然後子程序會呼叫
load_executable_file()
函式載入要進行除錯的程式,並且等待主程序傳送除錯命令。 -
最後主程序會呼叫
send_debug_command()
向被除錯程序(子程序)傳送除錯命令。
所以,接下來我們主要介紹 load_executable_file()
和 send_debug_command()
這兩個函式的實現過程。
第二步:載入被除錯程式
前面我們說過,子程序主要用於載入被除錯的程式,並且等待除錯程序(主程序)傳送除錯命令,現在我們來分析下 load_executable_file()
函式的實現:
void load_executable_file(const char *target_file)
{
/* 1) 執行跟蹤(debug)當前程序 */
ptrace(PTRACE_TRACEME, 0, 0, 0);
/* 2) 載入並且執行被除錯的程式可執行檔案 */
execl(target_file, target_file, 0);
}
load_executable_file()
函式的實現很簡單,主要執行流程如下:
-
呼叫
ptrace(PTRACE_TRACEME...)
系統呼叫告知核心,當前程序可以被進行跟蹤,也就是可以被除錯。 -
呼叫
execl()
系統呼叫載入並且執行被除錯的程式可執行檔案。
首先,我們來看看 ptrace()
系統呼叫的原型定義:
long ptrace(long request, pid_t pid, void *addr, void *data);
下面我們對其各個引數進行說明:
-
request
: 向程序傳送的除錯命令,可以傳送的命令很多。 比如上面程式碼的PTRACE_TRACEME
命令定義為 0,表示能 夠 對程序進行除錯。 -
pid
:指定要對哪個程序傳送除錯命令的程序ID。 -
addr
:如果要讀取或者修改程序某個記憶體地址的內容,就可以通過這個引數指定。 -
data
:如果要修改程序某個地址的內容,要修改的值可以通過這個引數指定,配合addr
引數使用。
所以,程式碼:
ptrace(PTRACE_TRACEME, 0, 0, 0);
的作用就是告知核心,當前程序能夠 被跟蹤(除錯)。
接著,當呼叫 execl()
系統呼叫載入並且執行被除錯的程式時,核心會把當前被除錯的程序掛起(把執行狀態設定為停止狀態),等待主程序傳送除錯命令。
當程序的執行狀態被設定為停止狀態時,核心會停止對此程序進行排程,除非有其他程序把此程序的執行狀態改為可執行狀態。
第三步:向被除錯程序傳送除錯命令
我們來到最重要的一步了,就是要向被除錯的程序傳送除錯命令。
用過 GDB
除錯程式的同學都非常熟悉,我們可以向被除錯的程序傳送 單步除錯
、 列印當前堆疊資訊
、 檢視某個變數的值
和 設定斷點
等操作。
這些命令都可以通過 ptrace()
系統呼叫傳送,下面我們介紹一下怎麼使用 ptrace()
系統呼叫來對被除錯程序進行除錯操作。
void send_debug_command(pid_t debug_pid)
{
int status;
int counter = 0;
struct user_regs_struct regs;
unsigned long long instr;
printf("Tiny debugger started...\n");
/* 1) 等待被除錯程序(子程序)傳送訊號 */
wait(&status);
while (WIFSTOPPED(status)) {
counter++;
/* 2) 獲取當前暫存器資訊 */
ptrace(PTRACE_GETREGS, debug_pid, 0, ®s);
/* 3) 獲取 EIP 暫存器指向的記憶體地址的值 */
instr = ptrace(PTRACE_PEEKTEXT, debug_pid, regs.rip, 0);
/* 列印當前執行中的指令資訊 */
printf("[%u. EIP = 0x%08llx. instr = 0x%08llx\n",
counter, regs.rip, instr);
/* 4) 將被除錯程序設定為單步除錯,並且喚醒被除錯程序 */
ptrace(PTRACE_SINGLESTEP, debug_pid, 0, 0);
/* 5) 等待被除錯程序(子程序)傳送訊號 */
wait(&status);
}
printf("Tiny debugger exited...\n");
}
send_debug_command()
函式的實現有點小複雜,我們來分析下這個函式的主要執行流程吧。
-
1. 當被除錯程序被核心掛起時,核心會向其父程序傳送一個
SIGCHLD
訊號,父程序可以通過呼叫wait()
系統呼叫來捕獲這個資訊。 -
2. 然後我們在一個迴圈內,跟蹤程序執行指令的過程。
-
3. 通過呼叫
ptrace(PTRACE_GETREGS...)
來獲取當前程序所有暫存器的值。 -
4. 通過呼叫
ptrace(PTRACE_PEEKTEXT...)
來獲取某個記憶體地址的值。 -
5. 通過呼叫
ptrace(PTRACE_SINGLESTEP...)
將被除錯程序設定為單步除錯模式,這樣當被除錯程序每執行一條指令,都會進入停止狀態。
整個除錯流程可以歸納為以下的圖片:
測試程式
最後,我們來測試一下這個簡單的除錯工具的效果。我們使用以下命令編譯程式:
$ gcc tdb.c -o. tdb
編譯之後,我們會獲得一個名為 tdb
的可執行檔案。然後,我們可以使用以下命令來除錯程式:
$ ./tdb 要除錯的程式可執行檔案
例如我們要除錯 ls
命令這個程式,可以輸入以下命令:
$ ./tdb /bin/ls
Tiny debugger started...
[1. EIP = 0x7f47efd6a0d0. instr = 0xda8e8e78948
[2. EIP = 0x7f47efd6a0d3. instr = 0xc4894900000da8e8
[3. EIP = 0x7f47efd6ae80. instr = 0xe5894855fa1e0ff3
[4. EIP = 0x7f47efd6ae84. instr = 0x89495741e5894855
[5. EIP = 0x7f47efd6ae85. instr = 0xff89495741e58948
[6. EIP = 0x7f47efd6ae88. instr = 0x415641ff89495741
[7. EIP = 0x7f47efd6ae8a. instr = 0x4155415641ff8949
[8. EIP = 0x7f47efd6ae8d. instr = 0x4853544155415641
[9. EIP = 0x7f47efd6ae8f. instr = 0xec83485354415541
[10. EIP = 0x7f47efd6ae91. instr = 0xf38ec8348535441
[11. EIP = 0x7f47efd6ae93. instr = 0x48310f38ec834853
[12. EIP = 0x7f47efd6ae94. instr = 0xc148310f38ec8348
[13. EIP = 0x7f47efd6ae98. instr = 0x94820e2c148310f
[14. EIP = 0x7f47efd6ae9a. instr = 0x48d0094820e2c148
[15. EIP = 0x7f47efd6ae9e. instr = 0xcfe0158d48d00948
[16. EIP = 0x7f47efd6aea1. instr = 0x480002cfe0158d48
[17. EIP = 0x7f47efd6aea8. instr = 0x480002c5d1058948
[18. EIP = 0x7f47efd6aeaf. instr = 0x490002cfd2058b48
[19. EIP = 0x7f47efd6aeb6. instr = 0xd140252b4cd48949
...
[427299. EIP = 0x7fec65592b30. instr = 0x6616eb0000003cba
[427300. EIP = 0x7fec65592b35. instr = 0x841f0f6616eb
[427301. EIP = 0x7fec65592b4d. instr = 0xf0003d48050ff089
[427302. EIP = 0x7fec65592b4f. instr = 0xfffff0003d48050f
Tiny debugger exited...
可見,執行 ls
這個命令需要執行 40 多萬條指令。
總結
本文簡單介紹了偵錯程式的執行流程,當然這個偵錯程式暫時並沒有什麼作用。
下一篇文章將會介紹怎麼設定斷點和檢視程序當前的堆疊資訊,到時會更好玩,敬請期待。
本文的原始碼地址:http://github.com/liexusong/tdb/blob/main/tdb-1.c
- Linux核心除錯利器|kprobe 原理與實現
- 自己動手寫一個GDB|基本功能
- 怎樣學好計算機底層技術?
- 一文讀懂eBPF|即時編譯(JIT)實現原理
- 一文看懂eBPF|eBPF實現原理
- 一文看懂eBPF|eBPF的簡單使用
- 學習計算機底層原理,我推薦幾個大佬!
- 搞懂程序組、會話、控制終端關係,才能明白守護程序幹嘛的?
- 跟大佬們一起起飛!
- 一文讀懂|Linux 程序管理之CFS負載均衡
- Linux 多核 SMP 系統的引導
- 深入理解Linux核心之記憶體定址
- 圖解|Linux 組排程
- 網際網路圈,年末小聚
- 手把手教你|攔截系統呼叫
- KSM機制剖析 — Linux 核心中的記憶體去耦合
- 使用 GDB Qemu 除錯 Linux 核心
- eBPF 概述:第 1 部分:介紹
- 圖解 | Linux記憶體效能優化核心思想
- 一文看懂 | fork 系統呼叫