调试器是怎样工作的(二):断点

本文是 How debuggers work: Part 2 - Breakpoints 的中文翻译,如有错误或表述不当之处,敬请指正。

上文中,我们介绍了调试器中的重要模块——系统调用ptrace. 在本文中,我将介绍断点是如何实现的。

软中断

在x86架构上实现断点,需要用到软中断(software interrupt, 软件中断,又叫“陷阱”)。在深入细节前,我想先解释下中断和陷阱的概念。

在程序员看来,CPU具有单执行流,它按照顺序一条接着一条执行指令。为了处理异步事件,例如I/O、硬件定时器等,CPU使用中断。硬中断(hardware interrupt,硬件中断)通常是具有特殊“响应电路”的专用电信号。中断信号出现时,此电路会响应中断,使得CPU停止当前进程,保存CPU的状态,并跳转到预定义的中断处理程序。当中断处理程序执行完毕后,CPU将从停止处再次恢复执行。

软中断在原理上与硬中断类似,但在实现上略有差异。 CPU支持特殊指令模拟中断。当特殊指令运行时,CPU把它当作中断——停止当前正在运行的进程,保存CPU的状态并跳转到中断处理程序处。这样的“陷阱”是现代操作系统中许多核心功能,例如任务调度、虚拟内存、内存保护、调试等得以实现的基础。

某些编程错误(例如除以0)也会被CPU当作陷阱,它们通常被称为“异常”。在这里,硬件与软件之间的界限变得模糊,很难说这种异常是硬中断还是软中断。

int 3的原理

简单来说,断点的功能基于一个名为int 3的特殊陷阱。int是x86的术语,表示“陷阱指令”(trap instruction)——它会调用预定义的中断处理程序。 x86支持int指令,可以通过一个8位的操作数指定中断类型,因此理论上支持256种陷阱。前32个编号被CPU保留,其中,第3个正是我们感兴趣的——它被称为“调试器陷阱”。

Intel's Architecture software developer's manual, volume 2A

INT 3 指令生成一个特殊的单字节操作码 (0xCC),它可以调用调试异常处理程序。 (这个单字节操作码很有用,它可以替换任何指令的第一个字节,包括单字节指令,而不会覆盖其它指令)。

括号中的内容很重要,但现在解释还为时尚早,之后我会解释它。

int 3的实现

当进程执行到int 3指令时,操作系统将暂停当前进程。在Linux中,它将向进程发送信号SIGTRAP.

回顾上文提到的内容,跟踪进程(调试器)会接收到子进程(或附加在上面的被调试进程)接收到的所有信号,来看个例子。

手动设置断点

我将展示如何在程序中设置断点,以下是被调试的目标程序:

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, len1
    mov     ecx, msg1
    mov     ebx, 1
    mov     eax, 4

    ; Execute the sys_write system call
    int     0x80

    ; Now print the other message
    mov     edx, len2
    mov     ecx, msg2
    mov     ebx, 1
    mov     eax, 4
    int     0x80

    ; Execute sys_exit
    mov     eax, 1
    int     0x80

section    .data

msg1    db      'Hello,', 0xa
len1    equ     $ - msg1
msg2    db      'world!', 0xa
len2    equ     $ - msg2

之所以使用汇编,是为了避免使用C语言时可能出现的编译问题和符号问题。上述程序的功能很简单:首先输出“Hello”,然后在下一行输出“world!”。

我想在程序输出“Hello”后设置一个断点,因此,这个断点应该设置在第一条int 0x80指令后,也就是指令mov edx, len2处。首先,我们需要知道这条指令的地址,运行objdump -d

traced_printer2:     file format elf32-i386

Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000033  08048080  08048080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         0000000e  080490b4  080490b4  000000b4  2**2
                  CONTENTS, ALLOC, LOAD, DATA

Disassembly of section .text:

08048080 <.text>:
 8048080:     ba 07 00 00 00          mov    $0x7,%edx
 8048085:     b9 b4 90 04 08          mov    $0x80490b4,%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:     ba 07 00 00 00          mov    $0x7,%edx
 804809b:     b9 bb 90 04 08          mov    $0x80490bb,%ecx
 80480a0:     bb 01 00 00 00          mov    $0x1,%ebx
 80480a5:     b8 04 00 00 00          mov    $0x4,%eax
 80480aa:     cd 80                   int    $0x80
 80480ac:     b8 01 00 00 00          mov    $0x1,%eax
 80480b1:     cd 80                   int    $0x80

因此,断点应设置在地址0x8048096处。等等,调试器好像不是这么用的?在实际中,我们会在某行代码或某个函数上设置断点,而不是通过内存地址设置断点。确实如此,但目前我们还做不到这一点——如果想像真正的调试器那样设置断点,还需要了解符号和调试信息,这些都是后话了。目前,我们只使用内存地址。

为什么是0x8040096?

0x8048096本身并无多大含义,它只是可执行文件中text段开头的几个字节。如果仔细查看上面的输出,可以看到text段从0x08048080开始。这告诉操作系统将从该地址开始的text段映射到虚拟内存中。在 Linux 上,这些地址可以是绝对的(可执行文件在加载到内存中时不会被重定位),因为使用虚拟内存系统,每个进程都有自己的虚拟内存,并将整个32位地址空间视为自己的地址空间(也叫线性地址)。

使用readelf检查ELF文件头,可以看到

$ readelf -h traced_printer2
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x8048080
  Start of program headers:          52 (bytes into file)
  Start of section headers:          220 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           40 (bytes)
  Number of section headers:         4
  Section header string table index: 3

注意“entry point address”,它也指向0x8048080. 因此,如果我们向操作系统解释ELF文件中编码的指令,它会说:

  1. 将text段映射到地址0x8048080;
  2. 从入口处(entry point)开始执行——即地址0x8048080.

但为什么是0x8048080?由于历史原因,每个进程地址空间的前128MB为堆栈保留。128MB恰好是0x8000000,这恰好是可执行文件中其它段开始的地方。特别是0x8048080,它是Linux的ld链接器的默认入口点,可通过指定-Ttext参数修改入口点。

总之,这个地址没有任何特别之处,我们可以随意修改它。只要ELF可执行文件的格式正确,且程序的入口地址与实际地址一致即可。

使用int 3设置断点

为了在被调试进程中的某个地址处设置断点,调试器将执行以下操作:

  1. 保存目标地址处的数据;
  2. 将目标地址处数据的首字节替换为int 3指令。

之后,当调试器要求操作系统运行被调试的进程(使用上文提到的PTRACE_CONT)时,被调试的进程将会运行,直到运行到int 3指令,进程将会停止,操作系统向它发送一个信号。这是调试器再次进入的地方,它将接收到“被调试进程已经暂停”的信号。之后,调试器将:

  1. 将目标地址处的int 3指令替换为原来的指令;
  2. 将被调试进程的指令指针减1。这是必要的,因为指令指针现在指向int 3的后面,并且已经执行了它;
  3. 允许用户以某种方式与被调试进程交互,因为进程仍在目标地址处暂停。此时,用户可以查看变量值和调用堆栈等;
  4. 当用户想要继续运行时,调试器将负责把断点再次放回目标地址(因为步骤1已经将其删除),除非用户将这个断点删除。

让我们看看这些步骤如何转换为实际代码。我们将使用前文提到的调试器“模板”(fork一个子进程并跟踪它)。完整的代码位于文末。

/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child started. EIP = 0x%08x\n", regs.eip);

/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);

注释

常量PTRACE_PEEKTEXT中,peek一词表示从内存中读取数据,而poke表示向内存中写入数据(即下文中的PTRACE_POKETEXT).

此处,调试器从被调试的进程中获取指令指针,并读取位于0x8048096处的内存,运行上述代码,输出:

[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba

目前为止一切顺利。接下来:

/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);

/* See what's there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);

注意int 3是怎么被插入到目标地址的。以上代码输出:

[13028] After trap, data at 0x08048096: 0x000007cc

正如我们所期望的那样,0xBA被替换为0xCC. 调试器将运行被调试的子进程,一直等到它运行到断点处暂停:

/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);

wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
    procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
}
else {
    perror("wait");
    return;
}

/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);

以上代码会输出:

Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097

注意,在断点前输出“Hello”,这正是我们所期望的。另外,请注意被调试的子进程暂停的位置——在单字节的陷阱指令之后。

最后,正如前面提到的,为了让被调试的子进程继续运行,需要用原始指令替换陷阱指令,并让被调试的子进程继续运行。

/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
*/
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, &regs);

/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);

子进程将输出“World!”并退出程序。 

请注意,我们没有在这里恢复断点。可以在单步模式下执行原始指令,然后将陷阱指令重新写回,最后再执行PTRACE_CONT.

关于int 3的更多内容

是时候解释Intel手册中那条奇怪的注释了,如下:

Intel's Architecture software developer's manual, volume 2A

INT 3 指令生成一个特殊的单字节操作码 (0xcc),它可以调用调试异常处理程序。 (这个单字节操作码很有用,它可以替换任何指令的第一个字节,包括单字节指令,而不会覆盖其它指令)。

x86的int指令占用两个字节——0xcd和一个中断号。int 3可以编码为cd 03,但有一个特殊的单字节指令为它保留——0xcc.

为什么会这样呢?因为这允许我们插入断点,而不会覆盖其它指令。请看以下示例:

    .. some code ..
    jz    foo
    dec   eax
foo:
    call  bar
    .. some code ..

假设我们想在dec eax处设置断点,它是个单字节指令(操作码为0x48)。如果断点指令超过1个字节,断点指令将覆盖下一条指令的一部分(也就是call),从而引发错误。如果jz foo的跳转条件被满足,那么这条指令将直接跳转到函数foo,而不会执行指令dec eax,但由于foo所在地址的指令被部分覆盖,因此这将是个无效的指令。

int 3提供一个特殊的1字节编码就可以解决这个问题。由于1个字节是x86上一条指令被编码的最短字节,从而保证了只有我们希望被中断的指令才会被更改。

上述的许多底层细节可以封装成一个API, 我将这些细节封装到debuglib库中,代码可在文末下载。在这里,我只想展示它的基本用法,我们将跟踪一个C程序,而不是汇编代码。 

跟踪C程序 

事实上,调试C语言实现的程序与汇编语言并无太大的不同,只是更难找到断点的位置。请看以下程序:

#include <stdio.h>


void do_stuff()
{
    printf("Hello, ");
}


int main()
{
    for (int i = 0; i < 4; ++i)
        do_stuff();
    printf("world!\n");
    return 0;
}

如果我想在do_stuff处设置一个断点。我将使用objdump反汇编可执行文件,反汇编的结果中包含很多内容。特别是其中的text段,包含了许多C runtime的初始化代码。我们只关注输出中的do_stuff

080483e4 <do_stuff>:
 80483e4:     55                      push   %ebp
 80483e5:     89 e5                   mov    %esp,%ebp
 80483e7:     83 ec 18                sub    $0x18,%esp
 80483ea:     c7 04 24 f0 84 04 08    movl   $0x80484f0,(%esp)
 80483f1:     e8 22 ff ff ff          call   8048318 <puts@plt>
 80483f6:     c9                      leave
 80483f7:     c3                      ret

我们将断点设置在地址0x080483e4处,这是do_stuff的首地址。此外,由于此函数在循环中被调用的,因此我们希望保存断点的位置,一直到循环结束。我们将使用debuglib库,以下是完整代码:

void run_debugger(pid_t child_pid)
{
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(0);
    procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid));

    /* Create breakpoint and run to it*/
    debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
    procmsg("breakpoint created\n");
    ptrace(PTRACE_CONT, child_pid, 0, 0);
    wait(0);

    /* Loop as long as the child didn't exit */
    while (1) {
        /* The child is stopped at a breakpoint here. Resume its
        ** execution until it either exits or hits the
        ** breakpoint again.
        */
        procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid));
        procmsg("resuming\n");
        int rc = resume_from_breakpoint(child_pid, bp);

        if (rc == 0) {
            procmsg("child exited\n");
            break;
        }
        else if (rc == 1) {
            continue;
        }
        else {
            procmsg("unexpected: %d\n", rc);
            break;
        }
    }


    cleanup_breakpoint(bp);
}

我们只使用了函数create_breakpointresume_from_breakpointcleanup_breakpoint. 以上代码的输出如下:

$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run 'traced_c_loop'
[13363] child now at EIP = 0x00a37850
[13363] breakpoint created
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
world!
[13363] child exited

正如我们所料!

代码

bp_manual.c
 /* Code sample: manual setting of a breakpoint, using ptrace
**
** Eli Bendersky (http://eli.thegreenplace.net)
** This code is in the public domain.
*/
#include <stdio.h>
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <syscall.h>
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <unistd.h>
#include <errno.h>
#include "debuglib.h"


/* For some reason this declaration isn't available from string.h */
extern char* strsignal(int);


void run_debugger(pid_t child_pid)
{
    int wait_status;
    struct user_regs_struct regs;
    procmsg("debugger started\n");

    /* Wait for child to stop on its first instruction */
    wait(&wait_status);

    /* Obtain and show child's instruction pointer */
    ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
    procmsg("Child started. EIP = 0x%08x\n", regs.eip);

    /* Look at the word at the address we're interested in */
    unsigned addr = 0x8048096;
    unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
    procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);

    /* Write the trap instruction 'int 3' into the address */
    unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
    ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);

    /* See what's there again... */
    unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
    procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);

    /* Let the child run to the breakpoint and wait for it to
    ** reach it 
    */
    ptrace(PTRACE_CONT, child_pid, 0, 0);

    wait(&wait_status);
    if (WIFSTOPPED(wait_status)) {
        procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
    }
    else {
        perror("wait");
        return;
    }

    /* See where the child is now */
    ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
    procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);

    /* Remove the breakpoint by restoring the previous data
    ** at the target address, and unwind the EIP back by 1 to 
    ** let the CPU execute the original instruction that was 
    ** there.
    */
    ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
    regs.eip -= 1;
    ptrace(PTRACE_SETREGS, child_pid, 0, &regs);

    /* The child can continue running now */
    ptrace(PTRACE_CONT, child_pid, 0, 0);

    wait(&wait_status);

    if (WIFEXITED(wait_status)) {
        procmsg("Child exited\n");
    } 
    else {
        procmsg("Unexpected signal\n");
    }
}


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

这里可以找到完整的源代码,其中包含:

  • debuglib.h, debuglib.c:封装了调试器的基本功能
  • bp_manual.c:文中的示例代码1
  • bp_use_lib.c:文中的示例代码2

小结

到目前为止,我们已经介绍了如何实现断点。虽然不同操作系统在实现细节上有所差异,但在x86上,基本上都是用int 3替换我们希望进程停止的指令。 

相信很多像我一样的读者,并不满足于指定内存地址设置断点这件事。我们更希望“在函数do_stuff处设置断点”或者“在函数do_stuff中的某一行设置断点”。在下一篇文章中,我将展示如何实现这一点。

参考资料

posted @ 2024-05-31 13:07  overxus  阅读(58)  评论(0编辑  收藏  举报