[关闭]
@MicroCai 2021-03-19T02:11:15.000000Z 字数 5513 阅读 926

debuggers 工作原理:Part 1 - 基础

Archives 翻译


原文链接

这是debuggers工作原理系列的第一篇,本文将从基础讲起。

前言

这部分主要内容会讲解 Linux 上 debuggers 的核心实现 -- ptrace 函数。这篇文章涉及到的所有代码基于 32 位 Ubuntu 机器。因为这块的代码依赖于特定平台和机器环境,迁移到其他平台也不难。

想要了解这块的思路,首先想象下 debugger 它自身是怎么工作的。一个 debugger 可以启动一些进程并进行调试,或者把它放到已有进程进行调试。可以单步调试、设置断点,并且运行起来,检测变量和栈帧信息。很多 debugger 还有各种高级功能,比如在 debugger 进程的地址空间执行表达式、调用方法,甚至实时修改进程代码生效。

虽然现代 debugger 非常复杂,但是它的底层实现机制并不难。基于操作系统、编译器、链接器提供的一些基础能力,再加上一些简单代码就可以完成一个 debugger。

Linux 调试-- ptrace

debugger 的核心就是 ptrace 函数,这是一个功能丰富但并不复杂的工具,可以让一个进程控制另一个进程的执行,并且深入进程内查看各种上下文信息。 ptrace 函数可以讲非常多内容,这边仅关注一些实用的使用例子

进程调试

下面以一个 DEMO 例子,在 debugger trace 模式下,演示单步调试,跟踪 DEMO 进程里面 CPU 执行汇编代码情况。本文会对涉及代码进行讲解,完成代码放在文章末尾,有兴趣的同学可以去下载尝试。

首先写一个能执行用户命令的子进程,一个能跟踪调试子进程的父进程。代码如下

  1. int main(int argc, char** argv)
  2. {
  3. pid_t child_pid;
  4. if (argc < 2) {
  5. fprintf(stderr, "Expected a program name as argument\n");
  6. return -1;
  7. }
  8. child_pid = fork();
  9. if (child_pid == 0)
  10. run_target(argv[1]);
  11. else if (child_pid > 0)
  12. run_debugger(child_pid);
  13. else {
  14. perror("fork");
  15. return -1;
  16. }
  17. return 0;
  18. }

如上条件分支,if 语句里面执行一个子进程(target 进程),else 语句执行父进程(即 debugger 进程)

下面是 target 进程代码

  1. void run_target(const char* programname)
  2. {
  3. procmsg("target started. will run '%s'\n", programname);
  4. /* Allow tracing of this process */
  5. if (ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
  6. perror("ptrace");
  7. return;
  8. }
  9. /* Replace this process's image with the given program */
  10. execl(programname, programname, 0);
  11. }

这里调用一个 ptrace 函数,该方法在 sys/ptrace.h 里面声明

  1. long ptrace(enum __ptrace_request request, pid_t pid,
  2. void *addr, void *data);

第一个 request 参数,表示是它是众多预定义的 PTRACE_ * 类型常量之一。第二个参数给一些 request 指定了进程 ID。后俩个参数是指向相应内存的 address 和 data 指针。上方代码在调用 ptrace 时,传入了 PTRACE_TRACEME 的 request,意味着子进程在请求系统内核,让父进程跟踪调试它。帮助文档对 request 清晰描述如下

  1. 使用此参数表明当前进程可以被其父进程跟踪调试。任何发给当前进程的信号(如 SIGKILL)都会中断自己的执行,并且父进程会通过 wait() 函数收到相应通知。当前进程后续所有的函数调用,都会收到 SIGTRAP 信号,给父进程一个控制权。如果不想让父进程追踪调试,就不要传这个参数。后面的 pid, addr, data 参数就会被忽略。

ptrace 调用后,run_target 就会通过调用 execl 把拿到的程序代码当做一个参数传进去。此处,系统内核会在进程执行程序前暂停它,并且发送一个信号给父进程。

那么,父进程这时候做了啥呢?

  1. void run_debugger(pid_t child_pid)
  2. {
  3. int wait_status;
  4. unsigned icounter = 0;
  5. procmsg("debugger started\n");
  6. /* Wait for child to stop on its first instruction */
  7. wait(&wait_status);
  8. while (WIFSTOPPED(wait_status)) {
  9. icounter++;
  10. /* Make the child execute another instruction */
  11. if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
  12. perror("ptrace");
  13. return;
  14. }
  15. /* Wait for child to stop on its next instruction */
  16. wait(&wait_status);
  17. }
  18. procmsg("the child executed %u instructions\n", icounter);
  19. }

回到上面,一旦子进程执行 exec 调用,就会收到 SIGTRAP 信号。父进程等待第一个 wait 函数被调用。如果有一些特殊情况发生,wait 立即返回,此时父进程会检测子进程是否被终止了(如果子进程收到信号终止,WIFSTOPPED 函数会返回 true)。

接着重点来了,父进程调用 ptrace,传入 PTRACE_SINGLESTEP request 给对应的子进程 ID。这意味着告诉操作系统 —— 请重启子进程,并在它执行下一条 CPU 指令前暂停它。同时父进程等待子进程暂停,进入 loop 循环。直到下一个信号不是暂停子进程,loop 循环才会结束。在追踪器(tracer)正常执行期间,这是一个告诉父进程子进程要推出了的信号。WIFEXITED 就会返回 true。

大家有注意到上面另一个变量 icounter 吗?这是用来计算子进程执行的指令总数。

上手试一下

编译下面的代码,放在 tracer 下执行。

  1. #include <stdio.h>
  2. int main()
  3. {
  4. printf("Hello, world!\n");
  5. return 0;
  6. }

神奇的是,tracer执行了非常久,并统计到有 10w+ 执行的指令。一个简单的 prinft 调用,为什么会执行这么多的指令呢?答案很有意思,默认情况下,Linux 上的 gcc 会把程序链接到 C 动态库上。这意味着在执行任何程序代码前,首先执行的是 dynamic library loader,用来查找方法调用所依赖的共享库。这里需要执行大量的代码,而我们的 tracer 会统计所有执行的指令,不仅是 main 方法的代码。

我们可以通过 static 参数去精简链接,tracer 统计的指令数量会降到到 7000 多。表面上看起来简单的 prinft 函数,实际上它的实现挺复杂。

但这仍然太多,我们看看通过转成汇编代码,去调试指令的执行会不会好点。我们写一个 Hello, world,转成汇编。

  1. section .text
  2. ; The _start symbol must be declared for the linker (ld)
  3. global _start
  4. _start:
  5. ; Prepare arguments for the sys_write system call:
  6. ; - eax: system call number (sys_write)
  7. ; - ebx: file descriptor (stdout)
  8. ; - ecx: pointer to string
  9. ; - edx: string length
  10. mov edx, len
  11. mov ecx, msg
  12. mov ebx, 1
  13. mov eax, 4
  14. ; Execute the sys_write system call
  15. int 0x80
  16. ; Execute sys_exit
  17. mov eax, 1
  18. int 0x80
  19. section .data
  20. msg db 'Hello, world!', 0xa
  21. len equ $ - msg

很明显,tracer 报了执行的指令一共 7 条,这样我们就很容易验证我们想要的内容。

深入指令

这个汇编写的代码,可以更好观察 ptrace 另一个牛逼的用处 -- 准确检测被调试代码的状态。下面是另一个版本的 run_debugger 方法。

  1. void run_debugger(pid_t child_pid)
  2. {
  3. int wait_status;
  4. unsigned icounter = 0;
  5. procmsg("debugger started\n");
  6. /* Wait for child to stop on its first instruction */
  7. wait(&wait_status);
  8. while (WIFSTOPPED(wait_status)) {
  9. icounter++;
  10. struct user_regs_struct regs;
  11. ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
  12. unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
  13. procmsg("icounter = %u. EIP = 0x%08x. instr = 0x%08x\n",
  14. icounter, regs.eip, instr);
  15. /* Make the child execute another instruction */
  16. if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
  17. perror("ptrace");
  18. return;
  19. }
  20. /* Wait for child to stop on its next instruction */
  21. wait(&wait_status);
  22. }
  23. procmsg("the child executed %u instructions\n", icounter);
  24. }

和之前唯一不同的是 while 循环里的几行代码,多了两个新的 ptrace 调用。第一个读取进程寄存器结构体里的值。user_regs_struct 定义在 sys/user.h 里面。如果你去查看这个头文件,上面有一行注释

  1. /* The whole purpose of this file is for GDB and GDB only.
  2. Don't read too much into it. Don't use it for
  3. anything other than GDB unless know what you are
  4. doing. */

看完注释,感觉咱们是找对了方向。
一旦我们拿到 regs 里所有的寄存器,就可以通过 ptrace(PTRACE_PEEKTEXT, id, regs.eip, data) 函数,返回一个指令,查看当前进程执行的指令信息。eip 全称是 extended instruction porter,是 x86 上的寄存器指针。

  1. $ simple_tracer traced_helloworld
  2. [5700] debugger started
  3. [5701] target started. will run 'traced_helloworld'
  4. [5700] icounter = 1. EIP = 0x08048080. instr = 0x00000eba
  5. [5700] icounter = 2. EIP = 0x08048085. instr = 0x0490a0b9
  6. [5700] icounter = 3. EIP = 0x0804808a. instr = 0x000001bb
  7. [5700] icounter = 4. EIP = 0x0804808f. instr = 0x000004b8
  8. [5700] icounter = 5. EIP = 0x08048094. instr = 0x01b880cd
  9. Hello, world!
  10. [5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8
  11. [5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd
  12. [5700] the child executed 7 instructions

到此为止,我们可以拿到 icounter,指令指针,还有指针指向的每一步执行。那怎么验证它是对的呢?可以通过执行 objdump -d 命令

  1. $ objdump -d traced_helloworld
  2. traced_helloworld: file format elf32-i386
  3. Disassembly of section .text:
  4. 08048080 <.text>:
  5. 8048080: ba 0e 00 00 00 mov $0xe,%edx
  6. 8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx
  7. 804808a: bb 01 00 00 00 mov $0x1,%ebx
  8. 804808f: b8 04 00 00 00 mov $0x4,%eax
  9. 8048094: cd 80 int $0x80
  10. 8048096: b8 01 00 00 00 mov $0x1,%eax
  11. 804809b: cd 80 int $0x80

然后观察上面的输出和我们的调试的输出是否相似即可。

如何使用 debugger 调试正在执行的进程

我们仍然使用 ptrace,参数传入 PTRACE_ATTACH request 即可。这个上面类似,就不做代码展示。

源码

本文源码使用 gcc 4.4 版本 Wall -pedantic --std=c99 编译。
源码地址

参考文章

Playing with ptrace, Part I
How debugger works

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注