原作者:Eli Bendersky
http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1
这是关于调试器如何工作的系列文章中的第一篇。我仍然不确定这个系列将包含多少文章,以及将涉及什么主题,但我将以基础开始。
在这部分
我将展示在Linux上调试器实现的主要构建块——ptrace系统调用。本文里的所有代码都是在32位Ubuntu机器上开发的。注意这些代码非常特定于平台,虽然将它移植到其他平台不是特别困难。
动机
为了理解我们将在哪里开始,尝试想象一下一个调试器要完成它的工作需要什么。调试器可以启动某个进程并调试它,或者将自己附身到一个现存的进程。它可以单步通过代码,设置断点并运行至那里,检查变量值及栈的踪迹。许多调试器具有先进的特性,比如在被调试进程的地址空间里执行表达式及调用函数,甚至在线改变进程代码并观察效果。
虽然现代调试器是复杂的东西[1],但构建它们的基础却惊人地简单。调试器从操作系统,编译器及链接器提供的少数基本服务开始,剩下的只是一个简单的编程问题。
Linux调试——ptrace
Linux调试器的瑞士军刀是ptrace系统调用[2]。它是一个万能的,相当复杂的工具,允许一个进程控制另一个进程的执行并窥探(peek)、拨弄(poke)其内部[3]。要完全解释ptrace需要一本中部头的书,这是为什么我将仅关注例子中它的一些实用用法。
让我们深入吧。
单步通过进程的代码
现在我将要开发一个以“跟踪”模式运行一个进程的例子,代码——其中我们将单步通过其由CPU执行的机器码(汇编指令)。我将分批显示例子代码,解释每个部分,在文章的末尾你将找到下载完整的,你可以编译、执行、玩耍的C文件。
计划的高级层面上是编写分裂为执行用户提供命令子进程,以及跟踪子进程的父进程的代码。首先,main函数:
intmain(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]);
elseif (child_pid > 0)
run_debugger(child_pid);
else {
perror("fork");
return -1;
}
return0;
}
相当简单:我们以fork开始一个子进程[4]。后续条件的if分支运行子进程(这里称为“目标”),elseif分支运行父进程(这里称为“调试者”)。
下面是目标进程:
voidrun_target(constchar* 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调用。Ptrace是这样声明的(在sys/ptrace.h):
long ptrace(enum __ptrace_requestrequest, pid_t pid,
void *addr, void *data);
第一个参数是一个request,它可以是许多预定义的PTRACE_*常量之一。第二个参数为某些request指定一个进程ID。第三及第四个参数是地址及数据指针,用于操纵内存。在上面代码里的ptrace调用进行PTRACE_TRACEME请求,表示这个子进程请求OS内核让其父进程追踪它。Man-page上关于请求的描述相对清楚:
表示这个进程将被其父进程追踪。发送给这个进程的任何信号(除了SIGKILL)将导致它暂停,并通过wait()通知父进程。同样,该进程所有后续的exec()调用将导致向它发送SIGTRAP,让父进程有机会在新程序开始执行前获得控制权。如果父进程不期望追踪它,进程可能不应该进行这个请求(pid,addr及data被忽略)。
我已经在这个例子里高亮了我们感兴趣的部分。注意run_target在ptrace后立即使用execl运行作为参数给出的程序。正如高亮部分解释的,这使得OS内核在进程就要在execl里运行这个程序前暂停了它,并向其父进程发出一个信号。
现在,看父进程干什么的时机已成熟:
voidrun_debugger(pid_tchild_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_SINGLESTEP请求给出子进程ID以调用ptrace。这样做是为了告诉OS——请重新开始子进程,但在它执行了下一条指令后暂停它。再一次,父进程等待子进程暂停,循环继续。当从wait调用得到的信号不是关于子进程暂停时,循环将终止。在追踪者正常运行期间,这将是告诉父进程子进程退出的信号(对此WIFEXITED将返回true)。
注意icounter记录了子进程执行的指令数。因此我们这个简单例子实际上起了一些作用——在命令行里给定一个程序名,它执行这个程序并报告从开始到结束所运行的CPU指令数。
测试运行
我编译了以下的例子程序并在调试者下运行它:
#include <stdio.h>
intmain()
{
printf("Hello, world!\n");
return0;
}
令我惊讶,调试者花了相当长的时间来运行超过100,000条指令并报告之。因为一个简单的printf调用?出了什么事?答案非常有趣[5]。默认的,gcc在Linux上动态地将程序与C运行时库链接。这意味着在任何程序执行时,首先运行的是查找所需的共享库的动态库载入器。这是相当多的代码——记住这里我们初步的调试者查看每一条指令,不只是main函数,而是整个进程。
因此,当我以-static标记链接测试程序时(并确认可执行文件增大了500KB左右,对C运行时库的静态链接是合乎逻辑的),追踪仅报告了大约7000条指令。仍然很多,但非常合理,如果你能记起在main之前仍然需要运行libc的初始化,在main之后要进行清理工作。另外,printf是一个复杂的函数。
仍然不满意,我希望看到可测试的东西——即在整个运行中我可以对每一条执行的指令负责。当然,这可以汇编代码做到。
因此我使用这个版本的“Hello,world!”,并反汇编它:
section .text
; The _start symbolmust be declared for the linker (ld)
global _start
_start:
; Prepare argumentsfor 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 thesys_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方法:
voidrun_debugger(pid_tchild_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_structregs;
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 muchinto it. Don't use it for
anything other than GDBunless know what you are
doing. */
我不知道你怎么样,但这让我觉得我们走对了:-)。不管怎么说,回到例子。一旦我们在regs得到了所有的寄存器,我们可以使用PTRACE_PEEKTEXT调用ptrace,向它传递regs.eip(x86上的扩展指令指针)作为地址来窥探进程当前的指令。我们得到的是指令[6]。让这个新追踪者运行我们的汇编代码片段:
$ 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:
$ 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
很容易观察到这与我们追踪输出间的相应关系。
依附到一个运行的进程
正如你了解的,调试器还可以依附到一个已经运行的进程。现在你不会惊讶地发现这也是由ptrace完成的,它可以接受一个PTRACE_ATTACH请求。这里我不准备展示例子代码,因为根据我们已经经历过的代码它应该很容易实现。出于教学目的,这里采取的做法更方便(因为我们可以在子进程开始时暂停它)。
代码
在本文中展示的简单调试者完整的C源代码(更高级的,指令打印版本)可以在这里获取。gcc4.4版本使用-Wall-pedantic --std=c99可以干净利落地编译之。
结论与下一步
无可否认,这部分没有涉及太多——我们仍然离手头上的调试器十万八千里。不过,我希望它至少已经使得进程调试变得不那么神秘。Ptrace确实是一个全面的系统调用,目前我们仅展示了少许。
单步代码是有用的,但仅是在某种程度上。以我上面展示的C“Hello,world”为例。为了到达main,需要通过几千行的C运行时库初始化代码。这非常不方便。最理想我们希望能在main的入口放置断点并从那里开始单步。有道理,在该系列的下一部分我将展示如何实现断点。
参考
在准备本文时,我发现下列有用的资源:
[1] 我没有检查,但我确信gdb的代码行数至少是6位数。
[2]运行man 2 ptrace获取所有的启示。
[3] Peek 与poke是众所周知的系统编程术语,表示直接读写内存内容。
[4] 本文假设读者具有基本的Unix/Linux编程经验。我假定你了解(至少概念上)fork,exec族函数及Unix信号。
[5] 至少如果你像我一样对底层细节着迷:-)
[6] 警告:正如我上面提醒的,这是高度特定于平台的。我做一些简化的假设——例如,x86指令不一定能放入4字节(在我的32位Ubuntu机器上unsigned的大小)。事实上,许多都不能。要有意义地窥探指令要求我们手头有一个完整的反汇编器。这里我们没有,但真正的调试器是有的。