一文看懂 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_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
寄存器的值。所以上面兩行代碼的意思就是:
-
獲取進程的
eflags
寄存器的值,並且設置Trap Flag
標誌。 -
把新的值設置到進程的
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