自己動手寫一個GDB|基本功能

語言: CN / TW / HK

什麼是 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, &regs);

/* 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 多萬條指令。

總結

本文簡單介紹了偵錯程式的執行流程,當然這個偵錯程式暫時並沒有什麼作用。

下一篇文章將會介紹怎麼設定斷點和檢視程序當前的堆疊資訊,到時會更好玩,敬請期待。

本文的原始碼地址:https://github.com/liexusong/tdb/blob/main/tdb-1.c