调试器如何工作(1)

原作者: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,让父进程有机会在新程序开始执行前获得控制权。如果父进程不期望追踪它,进程可能不应该进行这个请求(pidaddrdata被忽略)。

我已经在这个例子里高亮了我们感兴趣的部分。注意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, &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里。现在是有意思的部分——如果你看一下这个头文件,在靠近头部的注释说到:

/* 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的大小)。事实上,许多都不能。要有意义地窥探指令要求我们手头有一个完整的反汇编器。这里我们没有,但真正的调试器是有的。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值