一文看懂 | GDB底層實現(xiàn)原理
掃描二維碼
隨時隨地手機看文章
在程序出現(xiàn)bug的時候,最好的解決辦法就是通過GDB調(diào)試程序,然后找到程序出現(xiàn)問題的地方。比如程序出現(xiàn)段錯誤(內(nèi)存地址不合法)時,就可以通過GDB找到程序哪里訪問了不合法的內(nèi)存地址而導(dǎo)致的。
本文不是介紹 GDB 的使用方式,而是大概介紹 GDB 的實現(xiàn)原理,當然 GDB 是一個龐大而復(fù)雜的項目,不可能只通過一篇文章就能解釋清楚,所以本文主要是介紹 GDB 使用的核心的技術(shù) -ptrace。
ptrace系統(tǒng)調(diào)用
ptrace()系統(tǒng)調(diào)用是 Linux 提供的一個調(diào)試進程的工具,ptrace()系統(tǒng)調(diào)用非常強大,它提供非常多的調(diào)試方式讓我們?nèi)フ{(diào)試某一個進程,下面是ptrace()系統(tǒng)調(diào)用的定義:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
下面解釋一下ptrace()各個參數(shù)的作用:
- request:指定調(diào)試的指令,指令的類型很多,如:PTRACE_TRACEME、PTRACE_PEEKUSER、PTRACE_CONT、PTRACE_GETREGS等等,下面會介紹不同指令的作用。
- pid:進程的ID(這個不用解釋了)。
- addr:進程的某個地址空間,可以通過這個參數(shù)對進程的某個地址進行讀或?qū)懖僮鳌?
- data:根據(jù)不同的指令,有不同的用途,下面會介紹。
ptrace()系統(tǒng)調(diào)用詳細的介紹可以參考以下鏈接:https://man7.org/linux/man-pages/man2/ptrace.2.html
ptrace使用示例
下面通過一個簡單例子來說明ptrace()系統(tǒng)調(diào)用的使用,這個例子主要介紹怎么使用ptrace()系統(tǒng)調(diào)用獲取當前被調(diào)試(追蹤)進程的各個寄存器的值,代碼如下(ptrace.c):
#include#include #include #include #include #include int main() { pid_t child; struct user_regs_struct regs; child = fork(); // 創(chuàng)建一個子進程 if(child == 0) { // 子進程 ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示當前進程進入被追蹤狀態(tài) execl("/bin/ls", "ls", NULL); // 執(zhí)行 `/bin/ls` 程序 } else { // 父進程 wait(NULL); // 等待子進程發(fā)送一個 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); // 繼續(xù)運行子進程 sleep(1); } return 0; }
通過命令gcc ptrace.c -o ptrace編譯并運行上面的程序會輸出如下結(jié)果:
Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59] ptrace ptrace.c
上面結(jié)果的第一行是由父進程輸出的,主要是打印了子進程執(zhí)行/bin/ls程序后各個寄存器的值。而第二行是由子進程輸出的,主要是打印了執(zhí)行/bin/ls程序后輸出的結(jié)果。
下面解釋一下上面程序的執(zhí)行流程:
- 主進程調(diào)用fork()系統(tǒng)調(diào)用創(chuàng)建一個子進程。
- 子進程調(diào)用ptrace(PTRACE_TRACEME,...)把自己設(shè)置為被追蹤狀態(tài),并且調(diào)用execl()執(zhí)行/bin/ls程序。
- 被設(shè)置為追蹤(TRACE)狀態(tài)的子進程執(zhí)行execl()的程序后,會向父進程發(fā)送SIGCHLD信號,并且暫停自身的執(zhí)行。
- 父進程通過調(diào)用wait()接收子進程發(fā)送過來的信號,并且開始追蹤子進程。
- 父進程通過調(diào)用ptrace(PTRACE_GETREGS, child, ...)來獲取到子進程各個寄存器的值,并且打印寄存器的值。
- 父進程通過調(diào)用ptrace(PTRACE_CONT, child, ...)讓子進程繼續(xù)執(zhí)行下去。
從上面的例子可以知道,通過向ptrace()函數(shù)的request參數(shù)傳入不同的值時,就有不同的效果。比如傳入PTRACE_TRACEME就可以讓進程進入被追蹤狀態(tài),而傳入PTRACE_GETREGS時,就可以獲取被追蹤的子進程各個寄存器的值等。
本來我想使用ptrace實現(xiàn)一個簡單的調(diào)試工具,但在網(wǎng)上找到了一位 Google 的大神Eli Bendersky寫了類似的系列文章,所以我就不再重復(fù)工作了,在這里貼一下文章的鏈接:
- https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1/
- https://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints
- https://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information
但由于Eli Bendersky大神的文章只是介紹使用ptrace實現(xiàn)一個簡單的進程調(diào)試工具,而沒有介紹ptrace的原理和實現(xiàn),所以這里為了填補這個空缺,下面就詳細介紹一下ptrace的原理與實現(xiàn)。
ptrace實現(xiàn)原理
本文使用的 Linux 2.4.16 版本的內(nèi)核
看懂本文需要的基礎(chǔ):進程調(diào)度,內(nèi)存管理和信號處理相關(guān)知識。
調(diào)用ptrace()系統(tǒng)函數(shù)時會觸發(fā)調(diào)用內(nèi)核的sys_ptrace()函數(shù),由于不同的 CPU 架構(gòu)有著不同的調(diào)試方式,所以 Linux 為每種不同的 CPU 架構(gòu)實現(xiàn)了不同的sys_ptrace()函數(shù),而本文主要介紹的是X86 CPU的調(diào)試方式,所以sys_ptrace()函數(shù)所在文件是linux-2.4.16/arch/i386/kernel/ptrace.c。
sys_ptrace()函數(shù)的主體是一個switch語句,會傳入的request參數(shù)不同進行不同的操作,如下:
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 對應(yīng)的進程 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()函數(shù)首先根據(jù)進程的pid獲取到進程的task_struct對象。然后根據(jù)傳入不同的request參數(shù)在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_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA和PTRACE_CONT等,而其他的操作,有興趣的朋友可以自己去分析其實現(xiàn)原理。
進入被追蹤模式(PTRACE_TRACEME操作)
當要調(diào)試一個進程時,需要使進程進入被追蹤模式,怎么使進程進入被追蹤模式呢?有兩個方法:
- 被調(diào)試的進程調(diào)用ptrace(PTRACE_TRACEME, ...)來使自己進入被追蹤模式。
- 調(diào)試進程(如GDB)調(diào)用ptrace(PTRACE_ATTACH, pid, ...)來使指定的進程進入被追蹤模式。
第一種方式是進程自己主動進入被追蹤模式,而第二種是進程被動進入被追蹤模式。
被調(diào)試的進程必須進入被追蹤模式才能進行調(diào)試,因為 Linux 會對被追蹤的進程進行一些特殊的處理。下面我們主要介紹第一種進入被追蹤模式的實現(xiàn),就是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 狀態(tài) ret = 0; goto out; } ... }
從上面的代碼可以發(fā)現(xiàn),ptrace()對PTRACE_TRACEME的處理就是把當前進程標志為PTRACE狀態(tài)。
當然事情不會這么簡單,因為當一個進程被標記為PTRACE狀態(tài)后,當調(diào)用exec()函數(shù)去執(zhí)行一個外部程序時,將會暫停當前進程的運行,并且發(fā)送一個SIGCHLD給父進程。父進程接收到SIGCHLD信號后就可以對被調(diào)試的進程進行調(diào)試。
我們來看看exec()函數(shù)是怎樣實現(xiàn)上述功能的,exec()函數(shù)的執(zhí)行過程為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狀態(tài)時,執(zhí)行exec()函數(shù)后便會發(fā)送一個SIGTRAP的信號給當前進程。
我們再來看看,進程是怎么處理SIGTRAP信號的。信號是通過do_signal()函數(shù)進行處理的,而對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 狀態(tài) if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) { /* 讓調(diào)試器運行 */ current->exit_code = signr; current->state = TASK_STOPPED; // 讓自己進入停止運行狀態(tài) notify_parent(current, SIGCHLD); // 發(fā)送 SIGCHLD 信號給父進程 schedule(); // 讓出CPU的執(zhí)行權(quán)限 ... } } }
上面的代碼主要做了3件事:
- 如果當前進程被標記為 PTRACE 狀態(tài),那么就使自己進入停止運行狀態(tài)。
- 發(fā)送 SIGCHLD 信號給父進程。
- 讓出 CPU 的執(zhí)行權(quán)限,使 CPU 執(zhí)行其他進程。
執(zhí)行以上過程后,被追蹤進程便進入了調(diào)試模式,過程如下圖:
traceme
當父進程(調(diào)試進程)接收到SIGCHLD信號后,表示被調(diào)試進程已經(jīng)標記為被追蹤狀態(tài)并且停止運行,那么調(diào)試進程就可以開始進行調(diào)試了。
獲取被調(diào)試進程的內(nèi)存數(shù)據(jù)(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)
調(diào)試進程(如GDB)可以通過調(diào)用ptrace(PTRACE_PEEKDATA, pid, addr, data)來獲取被調(diào)試進程addr處虛擬內(nèi)存地址的數(shù)據(jù),但每次只能讀取一個大小為 4字節(jié)的數(shù)據(jù)。
我們來看看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的處理是相同的,主要是通過調(diào)用access_process_vm()函數(shù)來讀取被調(diào)試進程addr處的虛擬內(nèi)存地址的數(shù)據(jù)。
access_process_vm()函數(shù)的實現(xiàn)主要涉及到內(nèi)存管理相關(guān)的知識,可以參考我以前對內(nèi)存管理分析的文章,這里主要大概說明一下access_process_vm()的原理。
我們知道每個進程都有個mm_struct的內(nèi)存管理對象,而mm_struct對象有個表示虛擬內(nèi)存與物理內(nèi)存映射關(guān)系的頁目錄的指針pgd。如下:
struct mm_struct { ... pgd_t *pgd; /* 頁目錄指針 */ ... }
而access_process_vm()函數(shù)就是通過進程的頁目錄來找到addr虛擬內(nèi)存地址映射的物理內(nèi)存地址,然后把此物理內(nèi)存地址處的數(shù)據(jù)復(fù)制到data變量中。如下圖所示:
memory_map
access_process_vm()函數(shù)的實現(xiàn)這里就不分析了,有興趣的讀者可以參考我之前對內(nèi)存管理分析的文章自行進行分析。
單步調(diào)試模式(PTRACE_SINGLESTEP)
單步調(diào)試是一個比較有趣的功能,當把被調(diào)試進程設(shè)置為單步調(diào)試模式后,被調(diào)試進程沒執(zhí)行一條CPU指令都會停止執(zhí)行,并且向父進程(調(diào)試進程)發(fā)送一個 SIGCHLD 信號。
我們來看看ptrace()函數(shù)對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; } ... }
要把被調(diào)試的進程設(shè)置為單步調(diào)試模式,英特爾的 X86 CPU 提供了一個硬件的機制,就是通過把eflags寄存器的Trap Flag設(shè)置為1即可。
當把eflags寄存器的Trap Flag設(shè)置為1后,CPU 每執(zhí)行一條指令便會產(chǎn)生一個異常,然后會觸發(fā) Linux 的異常處理,Linux 便會發(fā)送一個SIGTRAP信號給被調(diào)試的進程。eflags寄存器的各個標志如下圖:
eflags-register
從上圖可知,eflags寄存器的第8位就是單步調(diào)試模式的標志。
所以ptrace()函數(shù)的以下2行代碼就是設(shè)置eflags進程的單步調(diào)試標志:
tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; put_stack_long(child, EFL_OFFSET, tmp);
而get_stack_long(proccess, offset)函數(shù)用于獲取進程棧offset處的值,而EFL_OFFSET偏移量就是eflags寄存器的值。所以上面兩行代碼的意思就是:
- 獲取進程的eflags寄存器的值,并且設(shè)置Trap Flag標志。
- 把新的值設(shè)置到進程的eflags寄存器中。
設(shè)置完eflags寄存器的值后,就調(diào)用wake_up_process()函數(shù)把被調(diào)試的進程喚醒,讓其進入運行狀態(tài)。單步調(diào)試過程如下圖:
single-trace
處于單步調(diào)試模式時,被調(diào)試進程每執(zhí)行一條指令都會觸發(fā)一次SIGTRAP信號,而被調(diào)試進程處理SIGTRAP信號時會發(fā)送一個SIGCHLD信號給父進程(調(diào)試進程),并且讓自己停止執(zhí)行。
而父進程(調(diào)試進程)接收到SIGCHLD后,就可以對被調(diào)試的進程進行各種操作,比如讀取被調(diào)試進程內(nèi)存的數(shù)據(jù)和寄存器的數(shù)據(jù),或者通過調(diào)用ptrace(PTRACE_CONT, child,...)來讓被調(diào)試進程進行運行等。
小結(jié)
由于ptrace()的功能十分強大,所以本文只能拋磚引玉,沒能對其所有功能進行分析。另外斷點功能并不是通過ptrace()函數(shù)實現(xiàn)的,而是通過int3指令來實現(xiàn)的,在Eli Bendersky大神的文章有介紹。而對于ptrace()的所有功能,只能讀者自己慢慢看代碼來體會了。





