前言:在程序出現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使用示例
- 下面通過一個簡單例子來說明 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 程序后面輸出的結果。
更多linux內核視頻教程文檔資料免費領取后臺私信【內核】自行獲取.
Linux內核源碼/內存調優/文件系統/進程管理/設備驅動/網絡協議棧-學習視頻教程-騰訊課堂
下面解釋一下上面程序的執行流程:
- 主進程調用 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實現原理
本文使用的 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 執行其他進程。
- 執行以上過程后,追蹤進程便進入了調試模式,過程如下圖:
?
- 當父進程(調試進程)接收到 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 變量中。如下圖所示:
- 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 寄存器的第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() 函數把被調試的進程喚醒,讓其進入運行狀態。 單步調試過程如下圖:
?
- 處于單步調試模式時,被調試進程每執行一條指令都會觸發一次 SIGTRAP 信號,而被調試進程處理 SIGTRAP 信號時會發送一個 SIGCHLD 信號給父進程(調試進程),并且讓自己停止執行。
- 而父進程(調試進程)接收到 SIGCHLD 后面,就可以對被調試的進程進行各種操作,比如讀取被調試進程內存的數據和寄存器的數據,或者通過調用 ptrace(PTRACE_CONT, child,...) 來讓被調試進程進行運行等。
四,小結
- 由于 ptrace() 的功能十分強大,所以本文只能拋磚引玉,沒能對其所有功能進行分析。另外斷點功能并不是通過 ptrace() 函數實現的,而是通過 int3 指令來實現的,在 Eli Bendersky 大神的文章有所介紹。而對于 ptrace() 的所有功能,只能讀者自己慢慢看代碼來體會了。