实战能力|一文看懂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()?各個參數的作用:
request:指定調試的指令,指令的類型很多,如:PTRACE_TRACEME、PTRACE_PEEKUSER、PTRACE_CONT、PTRACE_GETREGS等等,下面會介紹不同指令的作用。
pid:進程的ID(這個不用解釋了)。
addr:進程的某個地址空間,可以通過這個參數對進程的某個地址進行讀或寫操作。
data:根據不同的指令,有不同的用途,下面會介紹。
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?程序后輸出的結果。
下面解釋一下上面程序的執行流程:
主進程調用?fork()?系統調用創建一個子進程。
子進程調用?ptrace(PTRACE_TRACEME,...)?把自己設置為被追蹤狀態,并且調用?execl()?執行?/bin/ls?程序。
被設置為追蹤(TRACE)狀態的子進程執行?execl()?的程序后,會向父進程發送?SIGCHLD?信號,并且暫停自身的執行。
父進程通過調用?wait()?接收子進程發送過來的信號,并且開始追蹤子進程。
父進程通過調用?ptrace(PTRACE_GETREGS, child, ...)?來獲取到子進程各個寄存器的值,并且打印寄存器的值。
父進程通過調用?ptrace(PTRACE_CONT, child, ...)?讓子進程繼續執行下去。
從上面的例子可以知道,通過向?ptrace()?函數的?request?參數傳入不同的值時,就有不同的效果。比如傳入?PTRACE_TRACEME?就可以讓進程進入被追蹤狀態,而傳入?PTRACE_GETREGS?時,就可以獲取被追蹤的子進程各個寄存器的值等。
本來我想使用?ptrace?實現一個簡單的調試工具,但在網上找到了一位 Google 的大神?Eli Bendersky?寫了類似的系列文章,所以我就不再重復工作了,在這里貼一下文章的鏈接:
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?實現一個簡單的進程調試工具,而沒有介紹?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_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA?和?PTRACE_CONT?等,而其他的操作,有興趣的朋友可以自己去分析其實現原理。
進入被追蹤模式(PTRACE_TRACEME操作)
當要調試一個進程時,需要使進程進入被追蹤模式,怎么使進程進入被追蹤模式呢?有兩個方法:
被調試的進程調用?ptrace(PTRACE_TRACEME, ...)?來使自己進入被追蹤模式。
調試進程(如GDB)調用?ptrace(PTRACE_ATTACH, pid, ...)?來使指定的進程進入被追蹤模式。
第一種方式是進程自己主動進入被追蹤模式,而第二種是進程被動進入被追蹤模式。
被調試的進程必須進入被追蹤模式才能進行調試,因為 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件事:
如果當前進程被標記為 PTRACE 狀態,那么就使自己進入停止運行狀態。
發送 SIGCHLD 信號給父進程。
讓出 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_mapaccess_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?寄存器的值。所以上面兩行代碼的意思就是:
獲取進程的?eflags?寄存器的值,并且設置?Trap Flag?標志。
把新的值設置到進程的?eflags?寄存器中。
設置完?eflags?寄存器的值后,就調用?wake_up_process()?函數把被調試的進程喚醒,讓其進入運行狀態。單步調試過程如下圖:
single-trace處于單步調試模式時,被調試進程每執行一條指令都會觸發一次?SIGTRAP?信號,而被調試進程處理?SIGTRAP?信號時會發送一個?SIGCHLD?信號給父進程(調試進程),并且讓自己停止執行。
而父進程(調試進程)接收到?SIGCHLD?后,就可以對被調試的進程進行各種操作,比如讀取被調試進程內存的數據和寄存器的數據,或者通過調用?ptrace(PTRACE_CONT, child,...)?來讓被調試進程進行運行等。
小結
由于?ptrace()?的功能十分強大,所以本文只能拋磚引玉,沒能對其所有功能進行分析。另外斷點功能并不是通過?ptrace()?函數實現的,而是通過?int3?指令來實現的,在?Eli Bendersky?大神的文章有介紹。而對于?ptrace()?的所有功能,只能讀者自己慢慢看代碼來體會了。
- END -
看完一鍵三連在看,轉發,點贊
是對文章最大的贊賞,極客重生感謝你
推薦閱讀
劫起|再談Linux epoll驚群問題的原因和解決方案
網絡排障全景指南手冊v1.0精簡版pdf
一個奇葩的網絡問題
總結
以上是生活随笔為你收集整理的实战能力|一文看懂GDB调试底层实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 实战能力|一文看懂GDB调试上层实现
- 下一篇: 深入理解消息队列(场景,对比,原理和设计