一点声明
原文作者: http://eli.thegreenplace.net/
原文链接: http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1/

translated by Arthur1989, 个人主页: https://godorz.info

我想写一个系列,解释调试器是如何实现的,尽管我不确定总共会有多少篇文章,也不清楚主题到底应该包括些什么,我还是先写了这第一篇.让我们开始吧.

In this part

在这篇文章中,我将解释实现Linux下的调试器的基石--ptrace系统调用.本文的所有例子都是在32位Ubuntu上编写的,需要注意的是,这些代码是高度依赖于计算机体系结构的,好在移植他们应该不会太难.

Motivation

试着想想调试器需要干些什么,它可以启动一个进程并调试之,或者将自己绑定到一个正在运行的进程.调试器还可以在代码中单步执行,,设置断点,查看变量值和堆栈跟踪信息(stack trace)(译注: 堆栈跟踪很大程度上归功于寄存器ebp的设计,由于ebp指向的地址保存的总是"上一层函数调用的ebp值",一直递归寻找,就能找出目的函数的ebp,而在每层函数调用中,都能通过当层的ebp往栈底找到返回地址和参数值,往栈顶找出局部变量值.--这个设计真的是太精巧了---再注: 严重感谢DDD(libfetion项目发起者)的指点: "调试器可以通过保持"ebp"来处理每一个栈针,但有时候为了优化程序,使得程序能更快的运行,有时候不会保存ebp的。因为程序每进行一次函数调用,系统就得将ebp压一次栈,这个相对来说有一定的性能损耗的。另外一种解决方式是使用 unwind 来组织和解析栈信息。它的原理是在编译的时候,将每个函数要压栈的信息(这些信息是可以提前计算出来的)都保存到一个表里,调试器在解析栈的时候,通过查询那个表的函数信息,从而可以完全的解析出整个栈。有关unwind的信息,你可以去看下“调试器是怎样工作的: Part 3 – 调试信息”中讲到的DWARF,它里面有这方面的信息。"--作为参考,不妨读一读这篇文章<Getting the call stack without a frame pointer>). 很多调试器还有一些高级特性,比如说执行表达式,调用在被调试进程内存空间中函数,甚至对正在运行的进程的内存做出修改,并监测它带来的影响.

现代调试器都是集合了各种特技的怪兽[1],但是它们的实现基础却出奇的简单,调试器不过是使用了操作系统和编译器/链接器提供的一些基础服务罢了,剩下的就是编程的问题了.

Linux debugging – ptrace

Linux下调试器的大杀器正是ptrace系统调用[2].它功能强大,但用起来却很复杂,ptrace允许一个进程控制另外一个进程,甚至能够peek and poke被跟踪进程的内部[3].对ptrace的详细解释至少需要半本书的篇幅,所以下面我重点着墨于ptrace的使用.

现在,让我们开始吧.

Stepping through the code of a process

这里有一份代码,它是一个处于"被跟踪"("traced")模式的进程,CPU将单步执行的它机器代码(汇编指令).完整的代码请见后文.

首要的计划是将代码分为两部分,一部分是执行用户所提供的指令(user-supplied command)的子进程,另外一部分是一个父进程,它跟踪子进程.下面是main函数:

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的代码:

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语句.ptrace原型如下(in sys/ptrace.h):

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

参数request有很多预定义的可选常数PTRACE_*,系统调用ptrace将作用于ID为pid的进程之上.参数addr和data分别是地址和指向数据的指针,它们可以用来操作内存.上面代码中的ptrace做出了PTRACE_TRACEME 请求,意味着子进程通知OS,让父进程跟踪自己.PTRACE_TRACEME在man-page中有很清楚的解释:

Indicates that this process is to be traced by its parent. Any signal (except SIGKILL) delivered to this process will cause it to stop and its parent to be notified via wait(). Also, all subsequent calls to exec() by this process will cause a SIGTRAP to be sent to it, giving the parent a chance to gain control before the new program begins execution. A process probably shouldn’t make this request if its parent isn’t expecting to trace it. (pid, addr, and data are ignored.)

PTRACE_TRACEME被父进程用来跟踪子进程,任何信号(除了SIGKILL)都会暂停子进程,接着阻塞于wait()等待的父进程被唤醒.子进程内部对exec()的调用将发出SIGTRAP信号,这可以让父进程在子进程新程序开始运行之前就完全控制它.如果父进程不打算跟踪子进程,子进程就不应该发出PTRACE_TRACEME请求.(在PTRACE_TRACEME请求时,参数pid,addr和data被忽略.)

注意run_target在做出ptrace调用之后的第一步,就是用execl执行参数programname.正如引文解释的那样,子进程在执行execl的新程序之前会被暂停,而且父进程将收到OS发送的信号.

是时候看看父进程了:

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()就是等待这个信号的,父进程通过WIFSTOPPED查看子进程是否由于信号被暂停.

接下来发生的就是最有趣的事了,父进程通过request值为 PTRACE_SINGLESTEP的对pid为ID的进程做ptrace调用,告诉操作系统,重新唤醒子进程,但是在每条机器指令运行之后暂停.再一次的,父进程阻塞等待子进程暂停并计数,子进程结束(WIFEXITED返回真)后,父进程跳出loop循环.

icounter对子进程执行的每条指令计数.这还挺有用的,哈哈.

A test run

编译下面的代码,运行之,让它被跟踪.

#include <stdio.h>

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

出乎意料的,跟踪进程执行了很长的时间,报告说上面的代码执行了超过100,000条机器指令.开什么玩笑,仅仅一行printf就..? 搞什么鬼啊? 答案很值得讨论[5].Linux下gcc默认将程序动态链接到C运行库,这意味着,当一个程序被运行时,发生的第一件事就是动态库装载器(dynamic library loader)搜索共享库(shared libraries),这就解释了为什么上面的小例子会执行了这么多机器指令.

如果用-static标志编译代码,让它静态链接(这时候可执行文件大小超过了500KB),这时跟踪程序报告说样例执行的机器指令只有大概7,000条.还是有点多,但是考虑到在main之前libc的初始化,还有main退出后的清理工作,这也就说得过去了.再说了,printf也挺复杂的.

但是我还是不满意,我想要的是可验证的程序,也就是说,程序执行的整个过程我都一清二楚.所以我写了一个汇编版本的"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 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条指令被执行,很好,我可以很容易地验证它.

Deep into the instruction stream

有了上面的汇编代码,我们看看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, &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_struct(参考sys/user.h)中.

在获得了所有寄存器的值后,我们接着用PTRACE_PEEKTEXT读取eip(x86的扩展指令指针(extended instruction pointer))对应的机器指令[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

OK,现在除了icounter,我们还能看到eip和它对应的机器指令.怎样验证输出结果呢? 我们可以借助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

objdump -d的输出和跟踪进程的输出是一致的.

Attaching to a running process

正如你知道的那样调试器还可以调试正在运行的进程.很容易知道,这可以用PTRACE_ATTACH请求来实现.我不再给出代码了,因为这不难编写.

The code

完整的代码在这里.用gcc -Wall -pedantic --std=c99 code.c编译它.

Conclusion and next steps

好吧,我承认,这篇文章覆盖的内容很少 -– 离真正可以工作的调试器还差得很远.不管怎么样,我希望至少现在看来,调试器没有那么神秘了.ptrace很好很强大,我们刚上路呢.

在C代码中单步执行确实很有用,但它不是万能的.以C版本的"Hello, world!"为例,在执行main函数之前,程序已经单步执行了上万条用于C库初始化的机器指令了,可以看出,这很不方便.我们的需求其实是在main入口前面放置断点,然后step到main.这该如何实现呢,请看part22.

References

我觉得这些资源不错:

* Playing with ptrace, Part I
* Process tracing using ptrace
* How debugger works


注:

[1] 我没查证过,但是我保证gdb的源代码行数(LOC)至少有六位数.
[2] man 2 ptrace.
[3] Peek and poke是用以读写内存的黑话.
[4] 这篇文章要求读者对Unix/Linux编程有一定的经验.我假设读者熟悉(至少是概念上的) fork, exec族和Unix信号.
[5] 假设你和我一样,对底层细节着迷的话.:-)
[6] 提醒: 这篇文章很多内容是平台相关的.我做了一些设定 –- 比如说,x86指令并不一定要求是4字节.(在我的32位Ubuntu上unsigned的长度).

Getting the call stack without a frame pointer