一文看懂 | GDB 底层实现原理

回复 星标
更多

一文看懂 | GDB 底层实现原理

在程序出现bug的时候,最好的解决办法就是通过GDB调试程序,然后找到程序出现问题的地方。比如程序出现段错误(内存地址不合法)时,就可以通过GDB找到程序哪里访问了不合法的内存地址而导致的。

本文不是介绍 GDB 的使用方式,而是大概介绍 GDB 的实现原理,当然 GDB 是一个庞大而复杂的项目,不可能只通过一篇文章就能解释清楚,所以本文主要是介绍 GDB 使用的核心的技术 -ptrace

ptrace系统调用

ptrace()系统调用是 Linux 提供的一个调试进程的工具,ptrace()系统调用非常强大,它提供非常多的调试方式让我们去调试某一个进程,下面是ptrace()系统调用的定义:

longptrace(enum__ptrace_request request,pid_tpid,void*addr,void*data);

下面解释一下ptrace()各个参数的作用:

  • request:指定调试的指令,指令的类型很多,如:PTRACE_TRACEMEPTRACE_PEEKUSERPTRACE_CONTPTRACE_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>

intmain()

{pid_tchild;
structuser_regs_structregs;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, &regs);// 获取子进程的各个寄存器的值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);}return0;}

通过命令gcc ptrace.c -o ptrace编译并运行上面的程序会输出如下结果:

Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59]ptrace  ptrace.c

上面结果的第一行是由父进程输出的,主要是打印了子进程执行/bin/ls程序后各个寄存器的值。而第二行是由子进程输出的,主要是打印了执行/bin/ls程序后输出的结果。

下面解释一下上面程序的执行流程:

  1. 主进程调用fork()系统调用创建一个子进程。
  2. 子进程调用ptrace(PTRACE_TRACEME,...)把自己设置为被追踪状态,并且调用execl()执行/bin/ls程序。
  3. 被设置为追踪(TRACE)状态的子进程执行execl()的程序后,会向父进程发送SIGCHLD信号,并且暂停自身的执行。
  4. 父进程通过调用wait()接收子进程发送过来的信号,并且开始追踪子进程。
  5. 父进程通过调用ptrace(PTRACE_GETREGS, child, ...)来获取到子进程各个寄存器的值,并且打印寄存器的值。
  6. 父进程通过调用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参数不同进行不同的操作,如下:

asmlinkageintsys_ptrace(longrequest,longpid,longaddr,longdata)

{
structtask_struct*child;structuser*dummy=NULL;inti, 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)gotoout;if(request == PTRACE_ATTACH) {ret = ptrace_attach(child);gotoout_tsk;}...switch(request) {casePTRACE_PEEKTEXT:casePTRACE_PEEKDATA:...casePTRACE_PEEKUSR:...casePTRACE_POKETEXT:casePTRACE_POKEDATA:...casePTRACE_POKEUSR:...casePTRACE_SYSCALL:casePTRACE_CONT:...casePTRACE_KILL:...casePTRACE_SINGLESTEP:...casePTRACE_DETACH:...}out_tsk:free_task_struct(child);out:unlock_kernel();returnret;}

从上面的代码可以看出,sys_ptrace()函数首先根据进程的pid获取到进程的task_struct对象。然后根据传入不同的request参数在switch语句中进行不同的操作。

ptrace()支持的所有request操作定义在linux-2.4.16/include/linux/ptrace.h文件中,如下:

#definePTRACE_TRACEME         0#definePTRACE_PEEKTEXT        1#definePTRACE_PEEKDATA        2#definePTRACE_PEEKUSR         3#definePTRACE_POKETEXT        4#definePTRACE_POKEDATA        5#definePTRACE_POKEUSR         6#definePTRACE_CONT            7#definePTRACE_KILL            8#definePTRACE_SINGLESTEP      9#definePTRACE_ATTACH       0x10#definePTRACE_DETACH       0x11#definePTRACE_SYSCALL        24#definePTRACE_GETREGS        12#definePTRACE_SETREGS        13#definePTRACE_GETFPREGS      14#definePTRACE_SETFPREGS      15#definePTRACE_GETFPXREGS     18#definePTRACE_SETFPXREGS     19#definePTRACE_SETOPTIONS     21

由于ptrace()提供的操作比较多,所以本文只会挑选一些比较有代表性的操作进行解说,比如PTRACE_TRACEMEPTRACE_SINGLESTEPPTRACE_PEEKTEXTPTRACE_PEEKDATAPTRACE_CONT等,而其他的操作,有兴趣的朋友可以自己去分析其实现原理。

进入被追踪模式(PTRACE_TRACEME操作)

当要调试一个进程时,需要使进程进入被追踪模式,怎么使进程进入被追踪模式呢?有两个方法:

  • 被调试的进程调用ptrace(PTRACE_TRACEME, ...)来使自己进入被追踪模式。
  • 调试进程(如GDB)调用ptrace(PTRACE_ATTACH, pid, ...)来使指定的进程进入被追踪模式。

第一种方式是进程自己主动进入被追踪模式,而第二种是进程被动进入被追踪模式。

被调试的进程必须进入被追踪模式才能进行调试,因为 Linux 会对被追踪的进程进行一些特殊的处理。下面我们主要介绍第一种进入被追踪模式的实现,就是PTRACE_TRACEME的操作过程,代码如下:

asmlinkageintsys_ptrace(longrequest,longpid,longaddr,longdata)

{
...if(request == PTRACE_TRACEME) {if(current->ptrace & PT_PTRACED)gotoout;current->ptrace |= PT_PTRACED;// 标志 PTRACE 状态ret =0;gotoout;}...}

从上面的代码可以发现,ptrace()PTRACE_TRACEME的处理就是把当前进程标志为PTRACE状态。

当然事情不会这么简单,因为当一个进程被标记为PTRACE状态后,当调用exec()函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个SIGCHLD给父进程。父进程接收到SIGCHLD信号后就可以对被调试的进程进行调试。

我们来看看exec()函数是怎样实现上述功能的,exec()函数的执行过程为sys_execve() -> do_execve() -> load_elf_binary()

staticintload_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信号的处理逻辑如下:

intdo_signal(struct pt_regs *regs,sigset_t*oldset)

{
for(;;) {unsignedlongsignr;spin_lock_irq(&current->sigmask_lock);signr = dequeue_signal(&current->blocked, &info);spin_unlock_irq(&current->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件事:

  1. 如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行状态。
  2. 发送 SIGCHLD 信号给父进程。
  3. 让出 CPU 的执行权限,使 CPU 执行其他进程。

执行以上过程后,被追踪进程便进入了调试模式,过程如下图:

513416

traceme

当父进程(调试进程)接收到SIGCHLD信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。

获取被调试进程的内存数据(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)

调试进程(如GDB)可以通过调用ptrace(PTRACE_PEEKDATA, pid, addr, data)来获取被调试进程addr处虚拟内存地址的数据,但每次只能读取一个大小为 4字节的数据。

我们来看看ptrace()PTRACE_PEEKDATA操作的处理过程,代码如下:

asmlinkageintsys_ptrace(longrequest,longpid,longaddr,longdata)

{
...switch(request) {casePTRACE_PEEKTEXT:casePTRACE_PEEKDATA: {unsignedlongtmp;intcopied;copied = access_process_vm(child, addr, &tmp,sizeof(tmp),0);ret = -EIO;if(copied !=sizeof(tmp))break;ret = put_user(tmp, (unsignedlong*)data);break;}...}

从上面代码可以看出,对PTRACE_PEEKTEXTPTRACE_PEEKDATA的处理是相同的,主要是通过调用access_process_vm()函数来读取被调试进程addr处的虚拟内存地址的数据。

access_process_vm()函数的实现主要涉及到内存管理相关的知识,可以参考我以前对内存管理分析的文章,这里主要大概说明一下access_process_vm()的原理。

我们知道每个进程都有个mm_struct的内存管理对象,而mm_struct对象有个表示虚拟内存与物理内存映射关系的页目录的指针pgd。如下:

structmm_struct{...pgd_t*pgd;/* 页目录指针 */...}

access_process_vm()函数就是通过进程的页目录来找到addr虚拟内存地址映射的物理内存地址,然后把此物理内存地址处的数据复制到data变量中。如下图所示:

513416

memory_map

access_process_vm()函数的实现这里就不分析了,有兴趣的读者可以参考我之前对内存管理分析的文章自行进行分析。

单步调试模式(PTRACE_SINGLESTEP)

单步调试是一个比较有趣的功能,当把被调试进程设置为单步调试模式后,被调试进程没执行一条CPU指令都会停止执行,并且向父进程(调试进程)发送一个 SIGCHLD 信号。

我们来看看ptrace()函数对PTRACE_SINGLESTEP操作的处理过程,代码如下:

asmlinkageintsys_ptrace(longrequest,longpid,longaddr,longdata)

{
...switch(request) {casePTRACE_SINGLESTEP: {/* set the trap flag. */longtmp;...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寄存器的各个标志如下图:

513416

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寄存器的值。所以上面两行代码的意思就是:

  1. 获取进程的eflags寄存器的值,并且设置Trap Flag标志。
  2. 把新的值设置到进程的eflags寄存器中。

设置完eflags寄存器的值后,就调用wake_up_process()函数把被调试的进程唤醒,让其进入运行状态。单步调试过程如下图:

513416

single-trace

处于单步调试模式时,被调试进程每执行一条指令都会触发一次SIGTRAP信号,而被调试进程处理SIGTRAP信号时会发送一个SIGCHLD信号给父进程(调试进程),并且让自己停止执行。

而父进程(调试进程)接收到SIGCHLD后,就可以对被调试的进程进行各种操作,比如读取被调试进程内存的数据和寄存器的数据,或者通过调用ptrace(PTRACE_CONT, child,...)来让被调试进程进行运行等。

小结

由于ptrace()的功能十分强大,所以本文只能抛砖引玉,没能对其所有功能进行分析。另外断点功能并不是通过ptrace()函数实现的,而是通过int3指令来实现的,在Eli Bendersky大神的文章有介绍。而对于ptrace()的所有功能,只能读者自己慢慢看代码来体会了。

- EOF -

推荐阅读点击标题可跳转

1、new[] 和 delete[] 一定要配对使用吗?

2、分布式基石|最难 paxos 和最易 raft ?

3、图解网络|收到 RST,就一定会断开 TCP 连接吗?

关注『CPP开发者』

看精选C++技术文章 . 加C++开发者专属圈子

点赞和在看就是最大的支持❤️

此帖已被锁定,无法回复
新窗口打开 关闭