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

什麼是 GDB

GDB 全稱 the GNU Project debugger,主要用來調試用戶態應用程序。

根據官方文檔介紹,GDB 支持調試以下語言編寫的應用程序:

當然,最常用的還是用於調試 C/C++ 編寫的應用程序。

本文並不是 GDB 的使用教程,所以不會對 GDB 的使用進行詳細的介紹。本文的目的是,教會大家自己動手擼一個簡易的 GDB。所以閱讀本文前,最好先了解下 GDB 的使用。

在編程圈中流傳一句話:不要重複造輪子。但是本人覺得,重複造輪子才能真正理解輪子的實現原理。

ptrace 系統調用

GDB 實現的核心技術是 ptrace() 系統調用。

如果你對 ptrace 的實現原理有興趣,可以閱讀這篇文章進行了解:《ptrace 實現原理

ptrace() 是一個複雜的系統調用,主要用於編寫調試程序。你可以通過以下命令來查看 ptrace() 的介紹:

$ man ptrace

ptrace() 系統調用的功能很強大,但我們並不會用到所有的功能。所以,本文的約定是:在編寫程序的過程中,使用到的功能纔會進行詳細介紹。

簡易的 GDB

我們要實現一個有如下功能的 GDB:

下面主要圍繞這三個功能進行闡述。

1. 調試可執行文件

我們使用 GDB 調試程序時,一般使用 GDB 直接加載程序的可執行文件,如下命令:

$ gdb ./example

上面命令的執行過程如下:

流程如下圖所示:

我們可以按照上面的流程來編寫代碼:

第一步:創建被調試子進程

調試程序一般分爲 被調試進程 與 調試進程

實現代碼如下:

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;
}

上面的代碼執行流程如下:

所以,接下來我們主要介紹 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() 系統調用的原型定義:

long ptrace(long request,  pid_t pid, void *addr,  void *data);

下面我們對其各個參數進行說明:

所以,代碼:

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() 函數的實現有點小複雜,我們來分析下這個函數的主要執行流程吧。

整個調試流程可以歸納爲以下的圖片:

測試程序


最後,我們來測試一下這個簡單的調試工具的效果。我們使用以下命令編譯程序:

$ 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