一文看懂 GDB 底層實現原理

在程序出現 bug 的時候,最好的解決辦法就是通過 GDB 調試程序,然後找到程序出現問題的地方。比如程序出現 段錯誤(內存地址不合法)時,就可以通過 GDB 找到程序哪裏訪問了不合法的內存地址而導致的。

本文不是介紹 GDB 的使用方式,而是大概介紹 GDB 的實現原理,當然 GDB 是一個龐大而複雜的項目,不可能只通過一篇文章就能解釋清楚,所以本文主要是介紹 GDB 使用的核心的技術 - ptrace

ptrace 系統調用

ptrace() 系統調用是 Linux 提供的一個調試進程的工具,ptrace() 系統調用非常強大,它提供非常多的調試方式讓我們去調試某一個進程,下面是 ptrace() 系統調用的定義:

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

下面解釋一下 ptrace() 各個參數的作用:

ptrace() 系統調用詳細的介紹可以參考以下鏈接:https://man7.org/linux/man-pages/man2/ptrace.2.html

ptrace 使用示例

下面通過一個簡單例子來說明 ptrace() 系統調用的使用,這個例子主要介紹怎麼使用 ptrace() 系統調用獲取當前被調試(追蹤)進程的各個寄存器的值,代碼如下(ptrace.c):

#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <stdio.h>

int main()
{   pid_t child;
    struct user_regs_struct regs;

    child = fork();  // 創建一個子進程
    if(child == 0) { // 子進程
        ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示當前進程進入被追蹤狀態
        execl("/bin/ls""ls", NULL);          // 執行 `/bin/ls` 程序
    } 
    else { // 父進程
        wait(NULL); // 等待子進程發送一個 SIGCHLD 信號
        ptrace(PTRACE_GETREGS, child, NULL, ®s); // 獲取子進程的各個寄存器的值
        printf("Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\n",
                regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 打印寄存器的值
        ptrace(PTRACE_CONT, child, NULL, NULL); // 繼續運行子進程
        sleep(1);
    }
    return 0;
}

通過命令 gcc ptrace.c -o ptrace 編譯並運行上面的程序會輸出如下結果:

Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59]
ptrace  ptrace.c

上面結果的第一行是由父進程輸出的,主要是打印了子進程執行 /bin/ls 程序後各個寄存器的值。而第二行是由子進程輸出的,主要是打印了執行 /bin/ls 程序後輸出的結果。

下面解釋一下上面程序的執行流程:

  1. 主進程調用 fork() 系統調用創建一個子進程。

  2. 子進程調用 ptrace(PTRACE_TRACEME,...) 把自己設置爲被追蹤狀態,並且調用 execl() 執行 /bin/ls 程序。

  3. 被設置爲追蹤(TRACE)狀態的子進程執行 execl() 的程序後,會向父進程發送 SIGCHLD 信號,並且暫停自身的執行。

  4. 父進程通過調用 wait() 接收子進程發送過來的信號,並且開始追蹤子進程。

  5. 父進程通過調用 ptrace(PTRACE_GETREGS, child, ...) 來獲取到子進程各個寄存器的值,並且打印寄存器的值。

  6. 父進程通過調用 ptrace(PTRACE_CONT, child, ...) 讓子進程繼續執行下去。

從上面的例子可以知道,通過向 ptrace() 函數的 request 參數傳入不同的值時,就有不同的效果。比如傳入 PTRACE_TRACEME 就可以讓進程進入被追蹤狀態,而傳入 PTRACE_GETREGS 時,就可以獲取被追蹤的子進程各個寄存器的值等。

本來我想使用 ptrace 實現一個簡單的調試工具,但在網上找到了一位 Google 的大神 Eli Bendersky 寫了類似的系列文章,所以我就不再重複工作了,在這裏貼一下文章的鏈接:

但由於 Eli Bendersky 大神的文章只是介紹使用 ptrace 實現一個簡單的進程調試工具,而沒有介紹 ptrace 的原理和實現,所以這裏爲了填補這個空缺,下面就詳細介紹一下 ptrace 的原理與實現。

ptrace 實現原理

本文使用的 Linux 2.4.16 版本的內核

看懂本文需要的基礎:進程調度,內存管理和信號處理相關知識。

調用 ptrace() 系統函數時會觸發調用內核的 sys_ptrace() 函數,由於不同的 CPU 架構有着不同的調試方式,所以 Linux 爲每種不同的 CPU 架構實現了不同的 sys_ptrace() 函數,而本文主要介紹的是 X86 CPU 的調試方式,所以 sys_ptrace() 函數所在文件是 linux-2.4.16/arch/i386/kernel/ptrace.c

sys_ptrace() 函數的主體是一個 switch 語句,會傳入的 request 參數不同進行不同的操作,如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    struct task_struct *child;
    struct user *dummy = NULL;
    int i, ret;

    ...

    read_lock(&tasklist_lock);
    child = find_task_by_pid(pid); // 獲取 pid 對應的進程 task_struct 對象
    if (child)
        get_task_struct(child);
    read_unlock(&tasklist_lock);
    if (!child)
        goto out;

    if (request == PTRACE_ATTACH) {
        ret = ptrace_attach(child);
        goto out_tsk;
    }

    ...

    switch (request) {
    case PTRACE_PEEKTEXT:
    case PTRACE_PEEKDATA:
        ...
    case PTRACE_PEEKUSR:
        ...
    case PTRACE_POKETEXT:
    case PTRACE_POKEDATA:
        ...
    case PTRACE_POKEUSR:
        ...
    case PTRACE_SYSCALL:
    case PTRACE_CONT:
        ...
    case PTRACE_KILL: 
        ...
    case PTRACE_SINGLESTEP:
        ...
    case PTRACE_DETACH:
        ...
    }
out_tsk:
    free_task_struct(child);
out:
    unlock_kernel();
    return ret;
}

從上面的代碼可以看出,sys_ptrace() 函數首先根據進程的 pid 獲取到進程的 task_struct 對象。然後根據傳入不同的 request 參數在 switch 語句中進行不同的操作。

ptrace() 支持的所有 request 操作定義在 linux-2.4.16/include/linux/ptrace.h 文件中,如下:

#define PTRACE_TRACEME         0
#define PTRACE_PEEKTEXT        1
#define PTRACE_PEEKDATA        2
#define PTRACE_PEEKUSR         3
#define PTRACE_POKETEXT        4
#define PTRACE_POKEDATA        5
#define PTRACE_POKEUSR         6
#define PTRACE_CONT            7
#define PTRACE_KILL            8
#define PTRACE_SINGLESTEP      9
#define PTRACE_ATTACH       0x10
#define PTRACE_DETACH       0x11
#define PTRACE_SYSCALL        24
#define PTRACE_GETREGS        12
#define PTRACE_SETREGS        13
#define PTRACE_GETFPREGS      14
#define PTRACE_SETFPREGS      15
#define PTRACE_GETFPXREGS     18
#define PTRACE_SETFPXREGS     19
#define PTRACE_SETOPTIONS     21

由於 ptrace() 提供的操作比較多,所以本文只會挑選一些比較有代表性的操作進行解說,比如 PTRACE_TRACEMEPTRACE_SINGLESTEPPTRACE_PEEKTEXTPTRACE_PEEKDATA 和 PTRACE_CONT 等,而其他的操作,有興趣的朋友可以自己去分析其實現原理。

進入被追蹤模式(PTRACE_TRACEME 操作)

當要調試一個進程時,需要使進程進入被追蹤模式,怎麼使進程進入被追蹤模式呢?有兩個方法:

第一種方式是進程自己主動進入被追蹤模式,而第二種是進程被動進入被追蹤模式。

被調試的進程必須進入被追蹤模式才能進行調試,因爲 Linux 會對被追蹤的進程進行一些特殊的處理。下面我們主要介紹第一種進入被追蹤模式的實現,就是 PTRACE_TRACEME 的操作過程,代碼如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    if (request == PTRACE_TRACEME) {
        if (current->ptrace & PT_PTRACED)
            goto out;
        current->ptrace |= PT_PTRACED; // 標誌 PTRACE 狀態
        ret = 0;
        goto out;
    }
    ...
}

從上面的代碼可以發現,ptrace() 對 PTRACE_TRACEME 的處理就是把當前進程標誌爲 PTRACE 狀態。

當然事情不會這麼簡單,因爲當一個進程被標記爲 PTRACE 狀態後,當調用 exec() 函數去執行一個外部程序時,將會暫停當前進程的運行,並且發送一個 SIGCHLD 給父進程。父進程接收到 SIGCHLD 信號後就可以對被調試的進程進行調試。

我們來看看 exec() 函數是怎樣實現上述功能的,exec() 函數的執行過程爲 sys_execve() -> do_execve() -> load_elf_binary()

static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
    ...
    if (current->ptrace & PT_PTRACED)
        send_sig(SIGTRAP, current, 0);
    ...
}

從上面代碼可以看出,當進程被標記爲 PTRACE 狀態時,執行 exec() 函數後便會發送一個 SIGTRAP 的信號給當前進程。

我們再來看看,進程是怎麼處理 SIGTRAP 信號的。信號是通過 do_signal() 函數進行處理的,而對 SIGTRAP 信號的處理邏輯如下:

int do_signal(struct pt_regs *regs, sigset_t *oldset) 
{
    for (;;) {
        unsigned long signr;

        spin_lock_irq(¤t->sigmask_lock);
        signr = dequeue_signal(¤t->blocked, &info);
        spin_unlock_irq(¤t->sigmask_lock);

        // 如果進程被標記爲 PTRACE 狀態
        if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {
            /* 讓調試器運行  */
            current->exit_code = signr;
            current->state = TASK_STOPPED;   // 讓自己進入停止運行狀態
            notify_parent(current, SIGCHLD); // 發送 SIGCHLD 信號給父進程
            schedule();                      // 讓出CPU的執行權限
            ...
        }
    }
}

上面的代碼主要做了 3 件事:

  1. 如果當前進程被標記爲 PTRACE 狀態,那麼就使自己進入停止運行狀態。

  2. 發送 SIGCHLD 信號給父進程。

  3. 讓出 CPU 的執行權限,使 CPU 執行其他進程。

執行以上過程後,被追蹤進程便進入了調試模式,過程如下圖:

traceme

當父進程(調試進程)接收到 SIGCHLD 信號後,表示被調試進程已經標記爲被追蹤狀態並且停止運行,那麼調試進程就可以開始進行調試了。

獲取被調試進程的內存數據(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)

調試進程(如 GDB)可以通過調用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 來獲取被調試進程 addr 處虛擬內存地址的數據,但每次只能讀取一個大小爲 4 字節的數據。

我們來看看 ptrace() 對 PTRACE_PEEKDATA 操作的處理過程,代碼如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    switch (request) {
    case PTRACE_PEEKTEXT:
    case PTRACE_PEEKDATA: {
        unsigned long tmp;
        int copied;

        copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
        ret = -EIO;
        if (copied != sizeof(tmp))
            break;
        ret = put_user(tmp, (unsigned long *)data);
        break;
    }
    ...
}

從上面代碼可以看出,對 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 的處理是相同的,主要是通過調用 access_process_vm() 函數來讀取被調試進程 addr 處的虛擬內存地址的數據。

access_process_vm() 函數的實現主要涉及到 內存管理 相關的知識,可以參考我以前對內存管理分析的文章,這裏主要大概說明一下 access_process_vm() 的原理。

我們知道每個進程都有個 mm_struct 的內存管理對象,而 mm_struct 對象有個表示虛擬內存與物理內存映射關係的頁目錄的指針 pgd。如下:

struct mm_struct {
    ...
    pgd_t *pgd; /* 頁目錄指針 */
    ...
}

而 access_process_vm() 函數就是通過進程的頁目錄來找到 addr 虛擬內存地址映射的物理內存地址,然後把此物理內存地址處的數據複製到 data 變量中。如下圖所示:

memory_map

access_process_vm() 函數的實現這裏就不分析了,有興趣的讀者可以參考我之前對內存管理分析的文章自行進行分析。

單步調試模式(PTRACE_SINGLESTEP)

單步調試是一個比較有趣的功能,當把被調試進程設置爲單步調試模式後,被調試進程沒執行一條 CPU 指令都會停止執行,並且向父進程(調試進程)發送一個 SIGCHLD 信號。

我們來看看 ptrace() 函數對 PTRACE_SINGLESTEP 操作的處理過程,代碼如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
    ...
    switch (request) {
    case PTRACE_SINGLESTEP: {  /* set the trap flag. */
        long tmp;
        ...
        tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
        put_stack_long(child, EFL_OFFSET, tmp);
        child->exit_code = data;
        /* give it a chance to run. */
        wake_up_process(child);
        ret = 0;
        break;
    }
    ...
}

要把被調試的進程設置爲單步調試模式,英特爾的 X86 CPU 提供了一個硬件的機制,就是通過把 eflags 寄存器的 Trap Flag 設置爲 1 即可。

當把 eflags 寄存器的 Trap Flag 設置爲 1 後,CPU 每執行一條指令便會產生一個異常,然後會觸發 Linux 的異常處理,Linux 便會發送一個 SIGTRAP 信號給被調試的進程。eflags 寄存器的各個標誌如下圖:

eflags-register

從上圖可知,eflags 寄存器的第 8 位就是單步調試模式的標誌。

所以 ptrace() 函數的以下 2 行代碼就是設置 eflags 進程的單步調試標誌:

tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
put_stack_long(child, EFL_OFFSET, tmp);

而 get_stack_long(proccess, offset) 函數用於獲取進程棧 offset 處的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。所以上面兩行代碼的意思就是:

  1. 獲取進程的 eflags 寄存器的值,並且設置 Trap Flag 標誌。

  2. 把新的值設置到進程的 eflags 寄存器中。

設置完 eflags 寄存器的值後,就調用 wake_up_process() 函數把被調試的進程喚醒,讓其進入運行狀態。單步調試過程如下圖:

single-trace

處於單步調試模式時,被調試進程每執行一條指令都會觸發一次 SIGTRAP 信號,而被調試進程處理 SIGTRAP 信號時會發送一個 SIGCHLD 信號給父進程(調試進程),並且讓自己停止執行。

而父進程(調試進程)接收到 SIGCHLD 後,就可以對被調試的進程進行各種操作,比如讀取被調試進程內存的數據和寄存器的數據,或者通過調用 ptrace(PTRACE_CONT, child,...) 來讓被調試進程進行運行等。

小結

由於 ptrace() 的功能十分強大,所以本文只能拋磚引玉,沒能對其所有功能進行分析。另外斷點功能並不是通過 ptrace() 函數實現的,而是通過 int3 指令來實現的,在 Eli Bendersky 大神的文章有介紹。而對於 ptrace() 的所有功能,只能讀者自己慢慢看代碼來體會了。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/y3c07Hk7g3P-rd0oDzszlA