译者序
本文是博客https://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1的翻译版本

这是一系列介绍调试器如何工作的文章. 我并不确定这一系列会有多少文章或覆盖多少话题, 因此我打算从最基本的东西开始介绍.

本部分:

我将展示Linux下调试器主要构件的实现–ptrace系统调用. 这篇文章中所有的代码都是在一台32位Ubuntu机器上进行编写的, 因此这些代码具有很强的平台特异性, 尽管将它们移植到别的平台不会很困难.

写作契机

为了理解这篇文章所干的事情, 请想象一下调试器的工作过程. 调试器可以启动一些进程并调试他们, 或者连接到其他的进程. 它可以步进的运行代码, 设置断点并在断点处中断程序, 输出变量值和进行堆栈跟踪. 许多调试器有更加高级的功能, 如及计算表达式的值, 调用正在调试的进程地址空间内的函数, 甚至进行热重载并观测效果.

尽管现代的调试器都是极其复杂的1, 但是令人震惊的是其依赖的基本原理是如此的简单. 调试器仅仅始于几个操作系统和编译器/链接器提供的基本功能, 其他的东西都只是编程上的小事

Linux调试 – ptrace

Linux调试器的核心是ptrace(意为”跟踪”)系统调用2. 它是多功能且复杂的工具, 其允许一个进程控制另一进程的执行, 并窥探其内部结构3.
如果要完整的向你解释ptrace, 可能需要一本中等大小的书籍, 这就是为什么我将聚焦实际案例中的应用. 现在让我们分析一些案例.

步进执行一个进程的代码

我现在将展示一个在”跟踪模式”下运行的进程示例, 我们将单步执行它的代码 – CPU实际执行的机器代码(汇编指令). 我将分部分的展示示例代码, 在文章的末尾你可以找到一个可以下载完整C文件的链接, 你可以下载, 编译, 调试它.

一个更高级的计划是编写一段代码, 它将分出一个子进程, 执行用户提供的命令, 而父进程跟踪子进程. 以下是main函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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;
}

这非常简单: 我们使用fork函数4启动了一个子进程, 后续条件的if分支执行子进程(称为target), else if分支执行父进程(称为debugger).

这是target进程代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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调用, ptracesys/ptrace.h中被声明:

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

第一个参数是一个请求(request), 它可以是许多预定义的PTRACE*常量中的一个或多个. 第二个参数是指定某些请求的一个进程的ID, 第三和第四个参数是
地址和指向数据的指针, 他们用于内存操作. 上述代码片段中的ptrace调用发起一个PTRACE_TRACEME请求, 这意味着这个子进程请求操作系统内核让它的父进程
跟踪它, 这个请求的描述在man手册上写的十分清晰:

(PTRACE_TRACEME请求)表示进程会被父进程跟踪, 任何除了SIGKILL的信号发送给该进程都会触发它的中断, 父进程会被通过wait()函数通知.另外, 之后所有此进程对exec()的调用都会引起SIGTRAP信号被发送给它, 给父进程机会在子进程开始执行前去得到控制权如果子进程没有请求父进程的跟踪, 那么这个信号就不会发出.(进程ID, 地址, 数据会被忽略)

我标注出了这个例子中值得我们注意的地方. 注意run_targetptrace后做的第一件事就是以execl为参数调用这个程序. 就像标注出的片段所示,这使得操作系统内核在程序以execl为参数执行前停下来, 并给父进程发送信号.

现在终于可以看看父进程在做什么了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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就会返回真)

本文最有趣的地方便是父进程接下来要做的事情. 它发起PTRACE_SINGLESTEP参数的ptrace调用, 并给出子进程ID. 这告诉操作系统重启子进程, 但是在执行完下一条指令后停下. 父进程再次等待子进程停下, 这样的循环周而复始. 当wait调用不再发出有关子进程停止的信号时, 这个循环就会终止. 在跟踪器正常运行的时间里, 就会有一个信号告诉父进程子进程退出了(WIFEXITED将在其上返回true).

注意, icounter(计数器) 会记录下子进程执行的所有指令数. 所以这个简单的例子实际上做了一些有用的事情–在命令行上给出程序的名字, 执行这个程序并报告它从开始到终止总共执行的CPU指令数量. 让我们看看它的实际作用.

测试运行

我编译了下面的简单程序, 并在跟踪器下运行它.

1
2
3
4
5
6
#include <stdio.h>

int main() {
printf("Hello, world!\n");
return 0;
}

令我惊讶的是, 跟踪器运行了很长一段时间, 并报告不少于10万条CPU指令被执行. 仅仅只是为了一个简单的printf调用?发生了什么?这个问题的答案是很有趣的5. 默认情况下, gcc在Linux上将C运行库与可执行文件进行动态链接, 这就意味着任何程序运行前第一件事就是让动态链接库加载器寻找并加载需要的动态库, 这需要执行许多代码–我们的跟踪器会跟踪每一条CPU指令, 并不只是main函数, 而是一整个进程.

因此, 我使用-static选项链接了程序(并确定了这个可执行文件增长了大约500KB的大小, 这对于C运行库的静态链接合乎逻辑), 现在跟踪器只报告了约7000条指令. 这仍然很多, 如果你记得libc初始化需要在main函数之前执行, 内存清理工作必须在main函数之后执行, 那么这完全是有意义的. 另外, printf是一个复杂的函数.

我并不会就此满足, 希望看看一些更具有测试性的东西–即在一次完整的运行中, 我可以解释每一条指令. 于是我反汇编上述的”Hello world”程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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 length
mov edx, len
mov ecx, msg
mov ebx, 1
mov eax, 4

; Execute the sys_write system call
int 0x80

; Execute sys_exit
mov eax, 1
int 0x80

section .data
msg db 'Hello, world!', 0xa
len equ $ - msg

上面这些这些足够了. 现在跟踪器报告7条指令被执行, 我能够很清晰的辨认出他们.

深入研究指令流

汇编编写的程序允许我向你介绍ptrace的另一个相当有用的用法–密切监测被跟踪进程的状态. 下面是run_debugger函数的另一个版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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, &regs);
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_structsys/user.h中定义的. 现在有趣的来了–看看这个头文件开头的注释:

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

// 译文:
// 这个都文件是且只是为GDB编写的
// 不要深入阅读. 不要将其用作任何GDB以外的任何目的
// 除非你知道自己在做什么

现在我不知道你是怎么想的, 但是我觉得我们的方向是这样的:-) 不管怎样, 一旦我们拥有了所有的寄存器信息, 就可以通过带有PTRACE_PEEKTEXT参数的ptrace调用进程的当前指令, 并将regs.eip(x86上的扩展指令寄存器)作为参数传入, 我们会得到指令信息6. 让我们看看这个新跟踪器在汇编代码上运行的结果:

1
2
3
4
5
6
7
8
9
10
11
12
$ 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 = 0x01b880cd
Hello, world!
[5700] icounter = 6. EIP = 0x08048096. instr = 0x000001b8
[5700] icounter = 7. EIP = 0x0804809b. instr = 0x000080cd
[5700] the child executed 7 instructions

好, 现在除了icounter我们还可以看到指令寄存器和每一次它所指向的指令. 如何确认他们是否正确? 我们可以使用objdump -d来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ objdump -d traced_helloworld

traced_helloworld: file format elf32-i386


Disassembly of section .text:

08048080 <.text>:
8048080: ba 0e 00 00 00 mov $0xe,%edx
8048085: b9 a0 90 04 08 mov $0x80490a0,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094: cd 80 int $0x80
8048096: b8 01 00 00 00 mov $0x1,%eax
804809b: cd 80 int $0x80

可见这些输出和我们的跟踪器输出是一一对应的.

调试一个正在运行的进程(attach模式)

正如你所知, 调试器也可以附着到一个正在运行的进程. 现在你应该不会惊讶于发现这也是通过使用ptrace来实现的, 只需要给出PTRACE_ATTACH请求就可以了. 我将不展示这部分的代码, 因为这通过改写我们已有的代码片段可以轻易的实现. 出于教学目的, 这里的方法会更加简单(因为我们恰好可以使子进程在开始时停下来.)

代码

本文中简单跟踪器的完整C代码(经过改进, 可以打印指令信息的版本)可以在此处获取. 可以在使用gcc4.4以上版本带有-Wall -pedantic --std=c99选项时无报错的编译.

总结和接下来的

无可否认的是, 这一部分并没有设计多少东西–我们距离手搓一个真实的调试器还有很远的距离. 然而, 我希望这篇文章可以让你对调试器运行的过程感到少了些神秘感. ptrace是一个用途相当丰富, 而且能力强大的系统调用, 我们仅仅只是试验了其中很少的几个(用法).

单步运行调试代码很有用, 但仅限于一定程度下. 我用上文的C语言的Hello world!为例展示, 在开始执行main函数之前, 也许需要上千条指令初始化C运行库. 这不是很方便. 我们希望能够在main函数的入口点处放一个断点, 从断点处开始单步执行.在这个系列的下面几篇文章中, 我打算展示断点是如何实现的.

参考文献

我在撰写这篇文章时在以下文章中寻找了有用的资源

Playing with ptrace, Part1

How Debugger works


1 我没有检查, 但是我确信GDB的代码(LOC, Line Of Code, 代码行数)至少有100万行

2 请通过man 2 ptrace来查新更详细的说明

3 原文是Peak and poke, 这是一个广为人知的编程术语, 代表直接的读写内存.

4 这篇文章需要你拥有基础的Unix/Linux编程基础, 我假设你至少听说过fork()函数, 以exec为例的一系列函数以及Unix进程信号(如SIGTRAP, SIGKILL等)

5 至少如果你和我一样痴迷于底层细节的话

6 在此警告一下: 正如我之前说过, 这些代码是高度平台特异性的. 我做出了一些简单的断定–例如x86指令并不一定是4个字节长(我的32位Ubuntu上unsigned的大小). 实际上, 很多数据都不是如此的. 想要有效的查看指令需要我们手边有完整的反汇编器. 我们现在并没有, 但是真正的调试器有.

Notice

第一次做烤肉工作qwq, 翻的一股机翻味…

氮素! 如果有什么改进建议的话请提出来 :)