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