@MicroCai
2021-03-19T02:11:15.000000Z
字数 5513
阅读 1542
Archives 翻译
这是debuggers工作原理系列的第一篇,本文将从基础讲起。
这部分主要内容会讲解 Linux 上 debuggers 的核心实现 -- ptrace 函数。这篇文章涉及到的所有代码基于 32 位 Ubuntu 机器。因为这块的代码依赖于特定平台和机器环境,迁移到其他平台也不难。
想要了解这块的思路,首先想象下 debugger 它自身是怎么工作的。一个 debugger 可以启动一些进程并进行调试,或者把它放到已有进程进行调试。可以单步调试、设置断点,并且运行起来,检测变量和栈帧信息。很多 debugger 还有各种高级功能,比如在 debugger 进程的地址空间执行表达式、调用方法,甚至实时修改进程代码生效。
虽然现代 debugger 非常复杂,但是它的底层实现机制并不难。基于操作系统、编译器、链接器提供的一些基础能力,再加上一些简单代码就可以完成一个 debugger。
debugger 的核心就是 ptrace 函数,这是一个功能丰富但并不复杂的工具,可以让一个进程控制另一个进程的执行,并且深入进程内查看各种上下文信息。 ptrace 函数可以讲非常多内容,这边仅关注一些实用的使用例子
下面以一个 DEMO 例子,在 debugger trace 模式下,演示单步调试,跟踪 DEMO 进程里面 CPU 执行汇编代码情况。本文会对涉及代码进行讲解,完成代码放在文章末尾,有兴趣的同学可以去下载尝试。
首先写一个能执行用户命令的子进程,一个能跟踪调试子进程的父进程。代码如下
int main(int argc, char** argv){pid_t child_pid;if (argc < 2) {fprintf(stderr, "Expected a program name as argument\n");return -1;}child_pid = fork();if (child_pid == 0)run_target(argv[1]);else if (child_pid > 0)run_debugger(child_pid);else {perror("fork");return -1;}return 0;}
如上条件分支,if 语句里面执行一个子进程(target 进程),else 语句执行父进程(即 debugger 进程)
下面是 target 进程代码
void run_target(const char* programname){procmsg("target started. will run '%s'\n", programname);/* Allow tracing of this process */if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {perror("ptrace");return;}/* Replace this process's image with the given program */execl(programname, programname, 0);}
这里调用一个 ptrace 函数,该方法在 sys/ptrace.h 里面声明
long ptrace(enum __ptrace_request request, pid_t pid,void *addr, void *data);
第一个 request 参数,表示是它是众多预定义的 PTRACE_ * 类型常量之一。第二个参数给一些 request 指定了进程 ID。后俩个参数是指向相应内存的 address 和 data 指针。上方代码在调用 ptrace 时,传入了 PTRACE_TRACEME 的 request,意味着子进程在请求系统内核,让父进程跟踪调试它。帮助文档对 request 清晰描述如下
使用此参数表明当前进程可以被其父进程跟踪调试。任何发给当前进程的信号(如 SIGKILL)都会中断自己的执行,并且父进程会通过 wait() 函数收到相应通知。当前进程后续所有的函数调用,都会收到 SIGTRAP 信号,给父进程一个控制权。如果不想让父进程追踪调试,就不要传这个参数。后面的 pid, addr, data 参数就会被忽略。
ptrace 调用后,run_target 就会通过调用 execl 把拿到的程序代码当做一个参数传进去。此处,系统内核会在进程执行程序前暂停它,并且发送一个信号给父进程。
那么,父进程这时候做了啥呢?
void run_debugger(pid_t child_pid){int wait_status;unsigned icounter = 0;procmsg("debugger started\n");/* Wait for child to stop on its first instruction */wait(&wait_status);while (WIFSTOPPED(wait_status)) {icounter++;/* Make the child execute another instruction */if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {perror("ptrace");return;}/* Wait for child to stop on its next instruction */wait(&wait_status);}procmsg("the child executed %u instructions\n", icounter);}
回到上面,一旦子进程执行 exec 调用,就会收到 SIGTRAP 信号。父进程等待第一个 wait 函数被调用。如果有一些特殊情况发生,wait 立即返回,此时父进程会检测子进程是否被终止了(如果子进程收到信号终止,WIFSTOPPED 函数会返回 true)。
接着重点来了,父进程调用 ptrace,传入 PTRACE_SINGLESTEP request 给对应的子进程 ID。这意味着告诉操作系统 —— 请重启子进程,并在它执行下一条 CPU 指令前暂停它。同时父进程等待子进程暂停,进入 loop 循环。直到下一个信号不是暂停子进程,loop 循环才会结束。在追踪器(tracer)正常执行期间,这是一个告诉父进程子进程要推出了的信号。WIFEXITED 就会返回 true。
大家有注意到上面另一个变量 icounter 吗?这是用来计算子进程执行的指令总数。
编译下面的代码,放在 tracer 下执行。
#include <stdio.h>int main(){printf("Hello, world!\n");return 0;}
神奇的是,tracer执行了非常久,并统计到有 10w+ 执行的指令。一个简单的 prinft 调用,为什么会执行这么多的指令呢?答案很有意思,默认情况下,Linux 上的 gcc 会把程序链接到 C 动态库上。这意味着在执行任何程序代码前,首先执行的是 dynamic library loader,用来查找方法调用所依赖的共享库。这里需要执行大量的代码,而我们的 tracer 会统计所有执行的指令,不仅是 main 方法的代码。
我们可以通过 static 参数去精简链接,tracer 统计的指令数量会降到到 7000 多。表面上看起来简单的 prinft 函数,实际上它的实现挺复杂。
但这仍然太多,我们看看通过转成汇编代码,去调试指令的执行会不会好点。我们写一个 Hello, world,转成汇编。
section .text; The _start symbol must be declared for the linker (ld)global _start_start:; Prepare arguments for the sys_write system call:; - eax: system call number (sys_write); - ebx: file descriptor (stdout); - ecx: pointer to string; - edx: string lengthmov edx, lenmov ecx, msgmov ebx, 1mov eax, 4; Execute the sys_write system callint 0x80; Execute sys_exitmov eax, 1int 0x80section .datamsg db 'Hello, world!', 0xalen equ $ - msg
很明显,tracer 报了执行的指令一共 7 条,这样我们就很容易验证我们想要的内容。
这个汇编写的代码,可以更好观察 ptrace 另一个牛逼的用处 -- 准确检测被调试代码的状态。下面是另一个版本的 run_debugger 方法。
void run_debugger(pid_t child_pid){int wait_status;unsigned icounter = 0;procmsg("debugger started\n");/* Wait for child to stop on its first instruction */wait(&wait_status);while (WIFSTOPPED(wait_status)) {icounter++;struct user_regs_struct regs;ptrace(PTRACE_GETREGS, child_pid, 0, ®s);unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n",icounter, regs.eip, instr);/* Make the child execute another instruction */if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {perror("ptrace");return;}/* Wait for child to stop on its next instruction */wait(&wait_status);}procmsg("the child executed %u instructions\n", icounter);}
和之前唯一不同的是 while 循环里的几行代码,多了两个新的 ptrace 调用。第一个读取进程寄存器结构体里的值。user_regs_struct 定义在 sys/user.h 里面。如果你去查看这个头文件,上面有一行注释
/* The whole purpose of this file is for GDB and GDB only.Don't read too much into it. Don't use it foranything other than GDB unless know what you aredoing. */
看完注释,感觉咱们是找对了方向。
一旦我们拿到 regs 里所有的寄存器,就可以通过 ptrace(PTRACE_PEEKTEXT, id, regs.eip, data) 函数,返回一个指令,查看当前进程执行的指令信息。eip 全称是 extended instruction porter,是 x86 上的寄存器指针。
$ simple_tracer traced_helloworld[5700] debugger started[5701] target started. will run 'traced_helloworld'[5700] icounter = 1. EIP = 0x08048080. instr = 0x00000eba[5700] icounter = 2. EIP = 0x08048085. instr = 0x0490a0b9[5700] icounter = 3. EIP = 0x0804808a. instr = 0x000001bb[5700] icounter = 4. EIP = 0x0804808f. instr = 0x000004b8[5700] icounter = 5. EIP = 0x08048094. instr = 0x01b880cdHello, world![5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8[5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd[5700] the child executed 7 instructions
到此为止,我们可以拿到 icounter,指令指针,还有指针指向的每一步执行。那怎么验证它是对的呢?可以通过执行 objdump -d 命令
$ objdump -d traced_helloworldtraced_helloworld: file format elf32-i386Disassembly of section .text:08048080 <.text>:8048080: ba 0e 00 00 00 mov $0xe,%edx8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx804808a: bb 01 00 00 00 mov $0x1,%ebx804808f: b8 04 00 00 00 mov $0x4,%eax8048094: cd 80 int $0x808048096: b8 01 00 00 00 mov $0x1,%eax804809b: cd 80 int $0x80
然后观察上面的输出和我们的调试的输出是否相似即可。
我们仍然使用 ptrace,参数传入 PTRACE_ATTACH request 即可。这个上面类似,就不做代码展示。
本文源码使用 gcc 4.4 版本 Wall -pedantic --std=c99 编译。
源码地址