这个机制本身就很有趣,也很实用,接下来探索一下GDB核心功能的详细实现。
GDB基本的调试功能都是通过一个系统调用ptrace来实现的。
ps: 限于本人能力有限,对底层CPU 执行的正确逻辑没法做到万无一失,欢迎大家批评指正,相互学习讨论。
ptrace 主要被用做进程追踪,追踪进程的什么内容呢?这里有很多可选的配置,比如进程内存的值、进程寄存器的值,进程接收到的信号,指定进程以何种方式运行等等;
接口声明如下:
#include <sys/ptrace.h> long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);调用ptrace 追踪进程时(gdb attach -p $pid),被追踪进程会发生如下事情:
追踪进程会变为被追踪进程 的父进程
Baron+ 215677 154756 0 11:57 pts/1 S 0:00 | \_ gdb attach -p 215063 Baron+ 218064 215677 7 11:59 pts/1 S+ 0:00 | \_ /home/Baron/write_test进程状态会进入 TASK_TRACED ,表示当前进程正在被追踪,此时进程会暂停下来,等待追踪进程的操作。这个状态有点像TASK_STOPPED,都是让进程暂停下来等待被唤醒或者操作。只是TASK_TRACED状体的进程 不接受SIGCONT信号,只接受ptrace指定的PTRACE_DETACH和PTRACE_CONT 请求从而唤醒进程执行操作。
发送给被追踪进程的信号会被转发给父进程,除了SIGKILL,子进程则会被阻塞。
父进程收到信号之后可以对子进程进行修改,来让子进程继续运行。
接下来描述一下ptrace接口的参数含义:
request 作为ptrace的核心配置,提供非常多的进程追踪能力
PTRACE_TRACEME 和 PTRACE_ATTACH 都是和进程建立追踪关系
PTRACE_TRACEME表示被追踪进程调用,让父进程来追踪自己。通常是gdb调试新进程时使用。PTRACE_ATTACH父进程attach到正在运行的子进程上,这种追踪方式会检查权限,普通用户无法追踪root用户下的进程PTRACE_PEEKTEXT 、PTRACE_PEEKDATA、PTRACE_PEEKUSER、PTRACE_GETREGS等表示读取子进程内存,寄存器等内容
PTRACE_POKETEXT,PTRACE_POKEDATA,PTRACE_POKEUSR等表示修改子进程的内存,寄存器的内容
PTRACE_CONT,PTRACE_SYSCALL, PTRACE_SINGLESTEP表示被控制进程以何种方式追踪
PTRACE_CONT表示重新启动被追踪进程PTRACE_SYSCALL每次进入或者退出系统调用时都会触发一次SIGTRAP(Trace/breakpoint trap),strace的追踪系统调用就是通过该配置进行追踪的,进入时获取参数,退出时获取系统调用返回值。PTRACE_SINGLESTEP 每执行完一次指令之后会触发一次sigtrap,支持获取当前进程的内存/寄存器状态。gdb的next指令通过该选项实现。PTRACE_DETACH, PTRACE_KILL解除父子进程之间的追踪关系
如果父进程在在子进程前结束,则会自动解除追踪关系。
pid表示 要跟踪的进程pid
addr表示进程的内存地址
data 根据前面设置的requet选项而变化,比如要开始追踪时则设置request= PTRACE_CONT,同时将data设置为对应signal数字(SIGTRAP – 5)。
gdb调试的基本架构如下
本地调试 通过本地gdb 命令行或者mi图形接口进行调试远端调试 就是在当前设备通过远端的gdb server对远端设备的目标程序进行调试两者共同点是 底层都通过ptrace系统调用进行调试。
ptrace的基本使用我们已经看了一遍,如果想要了解更加详细的信息,可以通过man 2 ptrace进一步了解。
接下来通过ptrace来简单看一下gdb的实现原理:
当我们使用gdb设置断点的时候,gdb会将断点处的指令修改为INT 3(x86开始支持的专门用作调试的CPU指令,使得cpu终端到调试器),同时将断点信息以及修改前的指令保存起来。当被调试的子进程执行到断点处时 触发INT 3中断,从而产生SIGTRAP信号。因为此时父进程已经和调试进程建立追踪关系,ptrace会将子进程的SIGTRAP信号发送给父进程,此时父进程先和已有的断点信息进行对比,比如确认INT 3指令的位置,来确认当前信号是否因为断点产生。如果是,则会等待用户输入指令,进行下一步处理,如果不是,则不予理会,继续执行后续代码。通过以上原理可以看出,gdb会修改子进程的代码(将设置断点处的子进程指令修改为INT 3),那就涉及到修改子进程内存的情况了。这里是通过ptrace的PTRACE_POKEDATA选项进行修改。
通过ptrace 修改 被追踪进程的内存数据
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/ptrace.h> void check(long ret, char *str) { if (ret == -1) { printf("execute %s failed with %ld !!!\n", str, ret); } printf("Execute %s success! \n", str); } char str[] = "Ptrace is testing"; int main() { pid_t pid = fork(); union{ char cdata[8]; u_int64_t data; }u = {"CHANGE T"}; switch (pid) { case 0: // 子进程先休眠2秒 sleep(2); printf("Child's data is %s\n", str); break; case -1: printf("Fork failed "); exit(1) ; break; default: // 父进程先修改子进程内存中的值,但是父进程内存中的数据不变 check(ptrace(PTRACE_ATTACH, pid ,0 ,0),"PT_ATTACH"); // 链接到子进程 check(ptrace(PTRACE_POKEDATA, pid ,str ,u.data),"PT_WRITE_D"); // 修改子进程内存中str的内容 check(ptrace(PTRACE_CONT, pid ,0 ,0),"PT_CONTINUE"); // 子进程继续运行 printf("Parent's data is %s\n", str); wait(NULL); break; } return 0; }执行结果如下,可以看到父进程已经将子进程内存中的str数据前8个字节做了更改,但是父进程内存中的数据还是没有变化。
$ ./ptrace_change Execute PT_ATTACH success! Execute PT_WRITE_D success! Execute PT_CONTINUE success! Parent's data is Ptrace is testing Child's data is CHANGE Ts testing通过ptrace 对被追踪进程进行单步调试,以下代码是在32位系统上调试的,所以寄存器的表示还是eip,而x86_64的系统下寄存器都已经变更为rip了。
总体的逻辑如下:
追踪给定的进程pid, 通过PTRACE_ATTACH作为父进程与 给定进程建立追踪关系获取被追踪进程的 CPU存放的下一个指令的存放地址 — EIP,CPU 存放当前主线程的栈顶指针偏移地址 — ESP通过ptrace的PTRACE_SINGLESTEP选项不断得将EIP和ESP指针向下移动,每执行一条指令,寄存器指针移动一次,直到两个寄存器指针到达栈尾,结束调试当然打印并不只打印寄存器的地址,像GDB每一次单步追踪会等待用户的输入,这个时候可以查看或者修改esp和eip当前状态下的进程内存中的数据。
看ptrace测试 代码之前先简单描述一下ESP和EIP寄存器的关系:
进程开始运行的时候,左侧CPU的ESP寄存器指向主线程的函数栈顶(函数的执行是不断得压栈和弹栈的) 右侧的EIP寄存器则保存CPU执行的下一条汇编指令(后文有一个简单的测试程序的全指令截图,可以看看) 当开始运行的时候,一个函数语句可能需要多条汇编指令来完成,所以EIP改变多次,ESP才会发生一次改变。 通过n次的指令执行程序主体代码, 运行完成的标记就是ESP指向函数栈底,EIP指令指针也指向函数栈底。
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <wait.h> #include <sys/ptrace.h> #include <sys/reg.h> #include <sys/user.h> #include <sys/signal.h> #define M_OFFSETOF(STRUCT, ELEMENT) \ (unsigned int) &((STRUCT *)NULL)->ELEMENT; #define D_LINUXNONUSRCONTEXT 0x40000000 // 32位系统下内核态部分的结束地址 //(32位系统虚拟进程空间内核地址占用1个G) int main (int argc, char *argv[]) { int Tpid, stat, res; int signo; int ip, sp; int ipoffs, spoffs; int initialSP = -1; int initialIP = -1; struct user u_area; struct user_regs_struct regs; /* ** 传入指定进程的PID */ if (argv[1] == NULL) { printf("Need pid of traced process\n"); printf("Usage: pt pid \n"); exit(1); } Tpid = strtoul(argv[1], NULL, 10); printf("Tracing pid %d \n",Tpid ); /* ** 获取EIP 偏移地址 -- 保存CPU 下一个指令的寄存器地址 ** 获取ESP 偏移地址 -- 保存CPU 函数栈顶指针的偏移地址 */ ipoffs = M_OFFSETOF(struct user, regs.eip); spoffs = M_OFFSETOF(struct user, regs.esp); /* ** 通过Ptrace 将输入PID所代表的进程作为当前进程的子进程,并建立追踪关系。 ** 此时会目标子进程发送一个SIGSTOP的信号,调用waitpid来感知子进程的状态变化。 */ printf("Attaching to process %d\n",Tpid); if ((ptrace(PTRACE_ATTACH, Tpid, 0, 0)) != 0) {; printf("Attach result %d\n",res); } res = waitpid(Tpid, &stat, WUNTRACED); if ((res != Tpid) || !(WIFSTOPPED(stat)) ) { printf("Unexpected wait result res %d stat %x\n",res,stat); exit(1); } printf("Wait result stat %x pid %d\n",stat, res); stat = 0; signo = 0; /* ** 完成子进程(输入的PID 进程)的状态切换,并且与当前追踪进程建立了父子关系 */ while (1) { /* ** 通过ptrace的PTRACE_SINGLESTEP进行单步调试,调试过程会向子进程发送SIGTRAP信号 ** 通过wait系统调用进行捕获 */ if ((res = ptrace(PTRACE_SINGLESTEP, Tpid, 0, signo)) < 0) { perror("Ptrace singlestep error"); exit(1); } res = wait(&stat); /* ** 捕获到SIGTRAP信号之后,将信号置0,准备开启下一个单步调试。 ** 如果发现子进程接受到的信号是SIGHUP和SIGINT(子进程接受到了暂停信号 ** 那么就停止单步调试,父进程退出。 */ if ((signo = WSTOPSIG(stat)) == SIGTRAP) { signo = 0; } if ((signo == SIGHUP) || (signo == SIGINT)) { ptrace(PTRACE_CONT, Tpid, 0, signo); printf("Child took a SIGHUP or SIGINT. We are done\n"); break; } /* ** 单步调试之后,两个寄存器的地址会发生变化,所以需要重新获取以下 */ ip = ptrace(PTRACE_PEEKUSER, Tpid, ipoffs, 0); sp = ptrace(PTRACE_PEEKUSER, Tpid, spoffs, 0); /* ** 通过 ldd 查看输入的PID进程的内存分布如下 ** libc.so.6 => /lib/i686/libc.so.6 (0x40030000) ** /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x40000000) ** 这里跳过内核态的地址 */ if (ip & D_LINUXNONUSRCONTEXT) { continue; } if (initialIP == -1) { initialIP = ip; initialSP = sp; printf("---- Starting LOOP IP %x SP %x ---- \n", initialIP, initialSP); } else { // 直到运行到ESP指针和EIP指针的结尾,完成单步追踪 if ((ip == initialIP) && (sp == initialSP)) { ptrace(PTRACE_CONT, Tpid, 0, signo); printf("----- LOOP COMPLETE -----\n"); break; } } printf("Stat %x IP %x SP %x Last signal %d\n",stat, ip, sp, signo); } printf("Debugging complete\n"); sleep(5); return(0); }测试代码如下:
#include <stdio.h> #include <unistd.h> #include <stdlib.h> int main() { int *a[10] = {0}; int i = 0; int j = 0; while(i < 1000) { a[i] = (int *)malloc(sizeof(int)*10); if(a[i] == NULL){ printf("malloc failed\n"); exit(1); }else { printf("malloc address is %x\n",(unsigned int)a[i]); } for(;j < 10; ++j){ a[i][j] = j; } i++; sleep(1); } for(i =0;i < 1000 ;++i) { free (a[i]); } return 0; }测试代码对应的CPU指令如下 perf top -p pid
先运行测试代码,再编译运行ptrace追踪代码./test_ptrace $pid,可以看到ptrace追踪代码如下输出: 其中IP和SP指向的地址可看到 SP指针不会每次追踪都发生变化,而指令寄存器地址IP每次都发生变化,因为每次执行的指令都不一样,这和我们描述ptrace单步调试代码逻辑时的ESP和EIP寄存器关系图逻辑一样的。
因为还不是linux手艺人,还没法深入浅出linux系统,所以这里只能通过自己的猜测和工具来 弥补体系结构这块知识的缺失了。
Tracing pid 314201 Attaching to process 314201 Wait result stat 137f pid 314201 ---- Starting LOOP IP a88e0840 SP b60b6418 ---- Stat 57f IP a88e0840 SP b60b6418 Last signal 0 Stat 57f IP a88e0846 SP b60b6418 Last signal 0 Stat 57f IP a88e0848 SP b60b6418 Last signal 0 Stat 57f IP a88e06f4 SP b60b6420 Last signal 0 Stat 57f IP a88e06f6 SP b60b6420 Last signal 0 Stat 57f IP a88e06f8 SP b60b6420 Last signal 0 Stat 57f IP a88e0720 SP b60b6420 Last signal 0 Stat 57f IP a88e0727 SP b60b65d8 Last signal 0 Stat 57f IP a88e0729 SP b60b65d8 Last signal 0 Stat 57f IP a88e072a SP b60b65e0 Last signal 0 ...... Stat 57f IP a88e06ef SP b60b6420 Last signal 0 Stat 57f IP a88e0830 SP b60b6418 Last signal 0 Stat 57f IP a88e0837 SP b60b6418 Last signal 0 Stat 57f IP a88e0839 SP b60b6418 Last signal 0 Stat 57f IP a88e083e SP b60b6418 Last signal 0 ----- LOOP COMPLETE ----- Debugging complete这里不可能将每一个ptrace的选项的实现都讲明白,只能在主线的调试流程上看看当 attach,获取被追踪进程内存数据,单步调试 这一些功能的背后内核做了什么。
使用frtrace 抓取SyS_ptrace函数的执行逻辑,关于ftrace的使用可以参考关于 Rocksdb 性能分析 需要知道的一些“小技巧“ – perf_context的“内功” ,systemtap、perf、 ftrace的颜值
这个抓取主要是通过执行gdb的一些调试命令来让ptrace的不同选项得到运行,抓取attach,breadpoint,r,n等基本gdb指令的结果如下(主体的处理逻辑还是比较长的,这里仅仅贴一部分逻辑):
# tracer: function_graph # # CPU TASK/PID DURATION FUNCTION CALLS # | | | | | | | | | 3) <...>-46083 | | SyS_ptrace() { # 系统调用入口 3) <...>-46083 | | ptrace_get_task_struct() { # 获取进程的task_struc 3) <...>-46083 | | find_task_by_vpid() { 3) <...>-46083 | | find_task_by_pid_ns() { 3) <...>-46083 | 0.523 us | find_pid_ns(); 3) <...>-46083 | 1.178 us | } 3) <...>-46083 | 1.858 us | } 3) <...>-46083 | 2.387 us | } 3) <...>-46083 | | ptrace_attach() { # attach 入口 3) <...>-46083 | | mutex_lock_interruptible() { 3) <...>-46083 | 0.037 us | _cond_resched(); 3) <...>-46083 | 0.707 us | } 3) <...>-46083 | 0.087 us | _raw_spin_lock(); 3) <...>-46083 | | __ptrace_may_access() { 3) <...>-46083 | 0.105 us | get_dumpable(); 3) <...>-46083 | | security_ptrace_access_check() { 3) <...>-46083 | | yama_ptrace_access_check() { 3) <...>-46083 | 0.068 us | cap_ptrace_access_check(); 3) <...>-46083 | 0.584 us | } 3) <...>-46083 | 0.043 us | cap_ptrace_access_check(); 3) <...>-46083 | 1.404 us | } 3) <...>-46083 | 2.947 us | } ......ps: 后文涉及到的ptrace源码是 linux-3.10.1.0.1版本
通过gdb 调试一个新的进程会进入PTRACE_TRACEME选项,gdb ./new_process
ptrace系统调用入口如下: 确认能够建立连接之后通过_ptrace_link将当前进程new_process和gdb追踪进程建立父子关系
通过gdb attach到一个正在运行的进程上时会进入这个逻辑,gdb attach -p pid
在后续会通过signal_wake_up_state函数唤醒处于stopped状态的进程
使得因正在被调试而暂停,或者断掉的进程恢复运行,gdb的n,r,c等命令让进程重新运行都是通过该选项实现的
进入到arch_ptrace之后,通过ptrace_reuqest --> ptrace_resume对该选项进行处理
将进程的标志寄存器设置为单步模式,让被调试进程继续运行。当执行完一条指令之后,会触发INT中断,并发信号给控制进程,等待下一次的执行。
读取虚拟进程内存中的数据,像gdb的p 打印变量 就是该选项的功能,与选项PTRACE_PEEKTEXT一样,只不过读取的是不同的地址空间的数据。TEXT是代码段的数据,程序执行代码中的一段数据,DATA段存储已经初始化的静态数据和全局变量数据。
修改被追踪进程指定内存地址中的数据,通过设置access_process_vm函数最后一个参数来表示是写入内存中的数据还是从内存中读数据。
获取被追踪进程 指定寄存器中的数据
而对应的PTRACE_SETREG即修改用户进程寄存器内容,通过__get_user函数将data中的数据写入到regs数组之中。