自己動手寫一個 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()
函數的實現有點小複雜,我們來分析下這個函數的主要執行流程吧。
-
- 當被調試進程被內核掛起時,內核會向其父進程發送一個
SIGCHLD
信號,父進程可以通過調用wait()
系統調用來捕獲這個信息。
- 當被調試進程被內核掛起時,內核會向其父進程發送一個
-
- 然後我們在一個循環內,跟蹤進程執行指令的過程。
-
- 通過調用
ptrace(PTRACE_GETREGS...)
來獲取當前進程所有寄存器的值。
- 通過調用
-
- 通過調用
ptrace(PTRACE_PEEKTEXT...)
來獲取某個內存地址的值。
- 通過調用
-
- 通過調用
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 多萬條指令。
總結
本文簡單介紹了調試器的執行流程,當然這個調試器暫時並沒有什麼作用。
下一篇文章將會介紹怎麼設置斷點和查看進程當前的堆棧信息,到時會更好玩,敬請期待。
本文的源代碼地址:https://github.com/liexusong/tdb/blob/main/tdb-1.c
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/d9X_ZWNMtgfYkb3k5TsNHg