本文是英文博客 How debuggers work: Part 1 - Basics 的中文翻译,介绍了gdb的核心调用 ptrace

简介

我们首先需要想象调试器需要哪些功能,来完成其工作。调试器可以启动某个进程并对其进行调试,或将自身附加到现有进程。它可以单步执行代码,设置断点并运行它们,检查变量值和堆栈跟踪。许多调试器具有高级功能,例如在被调试进程的地址空间中执行表达式和调用函数,甚至可以即时更改进程代码并观察效果。

虽然现代调试器是复杂的野兽,但令人惊讶的是它们构建的基础却很简单。调试器只从操作系统、编译器和链接器提供的一些基本服务开始,其余只是一个简单的编程问题。

Linux 调试器——ptrace

Linux调试器的核心是ptrace系统调用。它是一个多功能的复杂工具,允许一个进程控制另一个进程的运行,并检测它的内部情况。完全解释ptrace可能需要一本书,本文主要专注于实例中的一些应用。

现在要开发一个以“跟踪”模式运行进程的示例,其中我们将单步执行其汇编指令。如果想要更进一步,我们可以把程序分为两块:子进程负责执行用户提供的指令;父进程负责跟踪和监控子进程。

以下是主函数代码:

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;
}

以下是运行用户指令的函数:

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);

第一个参数是一个请求,它可能是许多预定义的PTRACE_ *常量之一。第二个参数指定请求的进程ID。第三个和第四个参数是内存操作的地址和数据指针。上面代码片段中的ptrace调用产生PTRACE_TRACEME请求,这意味着子进程要求操作系统内核让其父进程跟踪它。 man ptrace中的描述非常清楚:

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.)

请注意,run_target在调用ptrace之后,会调用excel程序,将用户的命令作为参数传入,这导致子进程在执行execl之前就收到SIGTRAP信号,停止进程并向父进程发送信号。

接着,我们看看父进程运行的函数:

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,检查子进程是否是由于接收到信号而中止。

接着,父进程调用ptrace,传入PTRACE_SINGLESTEP和子进程id。这样做是告诉操作系统 —— 请重新启动子进程,但在执行下一条指令后停止它。同样,父进程等待子进程停止并且循环执行相同操作。当指令执行结束,子进程退出(WIFEXITED将在其上返回true),循环将终止。

请注意,icounter会计算子进程执行的指令数量。因此,我们的简单示例实际上做了一些有用的事情 —— 给定命令行上的程序名称,它执行程序并返回从开始到结束运行所花费的CPU指令量。让我们看看它的实际效果。

代码测试

我们写一个简答的程序用于测试,代码如下:

#include <stdio.h>


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

出乎意料的是,跟踪器运行了很长时间,并报告说执行了超过100,000条指令。具体原因是,在默认情况下,Linux上的gcc动态地将程序链接到C运行时库。这意味着在执行任何程序时运行的第一件事是动态库加载器,它查找所需的共享库。这是相当多的代码,我们的基本跟踪器会查看每个指令,而不仅仅是主要功能。

因此,当我将测试程序编译链接时,加入-static(可执行文件的重量增加了大约500KB),跟踪仅报告了7,000条指令。数量仍然很多,原因在于必须在main之前进行libc初始化,并且在main之后进行清理。此外,printf本身十分复杂。

我们看一下程序的汇编代码:

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

指令流分析

通过分析汇编代码,我们可以使用ptrace的另一个功能——检测进程的状态。我们进一步修改调试器的代码:

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中定义。现在这是有趣的部分 —— 如果你看看这个头文件,靠近顶部的评论说:

/* 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.  */

一旦我们在regs中保存所有寄存器,我们就可以通过用调用ptrace,使用PTRACE_PEEKTEXT请求来查看进程的当前指令,并将其作为地址传递给regs.eipx86上的扩展指令指针),得到具体指令。让我们看看这个新的跟踪器在我们的汇编编码片段上运行的情况:

$ 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

可以很容易地观察到它与我们的跟踪输出之间的对应关系。

本文提供的简单跟踪器的完整 源代码(更高级的指令打印版本)可在此处获得。它在gcc的4.4版本上用-Wall -pedantic --std = c99完美编译。

结论

单步执行代码很有用,但仅限于某种程度。拿 “Hello,world” 程序为例,要获得main,可能需要几万条初始化指令才能完成。我们理想的情况是能够在主要入口处设置断点并从那里开始。在本系列的下一部分中,我打算展示如何实现断点。