本文是英文博客 How debuggers work: Part 2 - Breakpoints 的中文翻译,这是关于调试器如何工作的系列文章的第二部分。请务必在此之前阅读第一部分。

本文将演示如何在调试器中实现断点。断点是调试的两个重要功能之一一 另一个功能是能够检查进程内部的变量。我们已经看过该系列第一部分中对数值检查的介绍。本文将介绍断点的工作原理。

软件中断

系统使用软件中断(trap)在x86体系结构上实现断点。在我们深入了解细节之前,我想解释一般中断(interrupts)和陷阱(trap)的概念。

CPU线性处理机器指令。当处理IO和硬件定时器等异步事件时,CPU会触发中断。CPU使用特殊的“响应电路”,发送专用的电信号,实现硬件中断。该电路捕捉到中断的激活并使CPU停止其当前执行的指令,保存其状态,并跳转到预先定义的中断处理程序所在地址。当处理程序完成其工作时,CPU将恢复到中断触发前的状态,继续执行指令。

软件中断在原理上类似,但在实现上略有不同。 CPU为软件提供模拟硬件中断的特殊指令。当执行这样的指令时,CPU将其视为中断 - 停止其正常的执行流程,保存其状态并跳转到处理程序例程。这种“陷阱”的设计使现代操作系统很多神奇的功能(任务调度,虚拟存储器,存储器保护,调试)得以实现。

一些编程错误(例如除以0)也被CPU视为陷阱,并且通常被称为“异常”(exception)。在这里硬件和软件没有清晰的界限,因为很难说这些异常是硬件中断还是软件中断。

INT 3 原理

简单来说,CPU通过一个名为int 3的特殊陷阱来实现断点。int 特指 x86 中的“陷阱指令” —— 调用预定义的中断处理函数。 x86 支持int指令带有8个操作数,用于指定中断的种类,因此理论上支持256种陷阱。前32种由CPU为自己保留,int 3被称为“调试器陷阱”。

以下为INTEL开发者手册中的原文(Intel’s Architecture software developer’s manual):

INT 3指令生成一个特殊的单字节操作码(CC),用于调用异常处理程序。(这种单字节形式很有价值,因为它可用于用断点替换任何指令的第一个字节,包括其他单字节指令,而不会覆盖其他代码)。

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

以上的代码功能很简单,在两行中分别打印 “Hello” 和 “World!“。现在我想在打印 “Hello” 完后设置一个断点。我们先使用 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

我们需要在第一个 int 0x80 后设置断点,具体的地址是0x8048096。注意,真正的调试器在代码行和函数上设置断点,而不是在一些裸存储器地址上设置断点。但是我们距离那里还很远 —— 为了像真正的调试器那样设置断点,我们仍然必须首先阐明符号和调试信息,并且系列中还需要另外一两部分来实现这些主题。现在,我们将使用裸内存地址。

在调试器中设置断点

要在跟踪进程中的某个目标地址处设置断点,调试器将执行以下操作:

  1. 记住存储在目标地址的数据
  2. int 3指令替换目标地址的第一个字节

然后,当调试器要求OS运行该进程时(正如我们在上一篇文章中看到的那样使用PTRACE_CONT),该进程将运行并最终触发 int 3。这是调试器再次进入的地址,接收到其子进程(或跟踪进程)已停止的信号。它可以:

  1. 用原始指令替换目标地址处的 int 3 指令
  2. 将跟踪过程的指令指针向后滚动一个。这是必需的,因为此时指令指针现在指向 int 3 之后,已经执行中断指令。
  3. 允许用户以某种方式与进程交互,因为进程仍然在期望的目标地址处停止。此时调试器允许您查看变量值,调用堆栈等的部分。
  4. 当用户想要继续运行时,除非用户要求取消断点,否则调试器将负责将断点放回目标地址(因为它已在步骤1中删除)。

让我们看看其中一些步骤是如何转换为实际代码的。我们将使用第1部分中介绍的调试器“模板”(分析子进程并跟踪它)。在任何情况下,本文末尾都有一个指向该示例的完整源代码的链接。

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

这里调试器从跟踪进程中获取指令指针,并检查当前存在于0x8048096的 word。当运行跟踪本文开头列出的汇编程序时,会打印:

[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

最后,如前所述,为了让子进程继续运行,我们必须做一些工作。我们用原始指令替换陷阱,让进程继续运行。

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

请注意,我们不会在此处恢复断点。这可以通过在单步模式下执行原始指令,然后放回陷阱然后再执行PTRACE_CONT 来完成。本文后面演示的调试库实现了这一点。

回到 INT 3

我们重新研究 int 3,根据英特尔手册:

这种单字节形式很有价值,因为它可用于用断点替换任何指令的第一个字节,包括其他一个字节指令,而不会覆盖其他代码

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

为什么这样?因为这允许我们插入断点而不会覆盖多个指令。这很重要。考虑以下示例代码:

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

假设我们想在 dec eax上放置一个断点。这恰好是单字节指令(操作码为0x48)。如果替换断点指令超过1个字节,我们将被强制覆盖下一个指令(call)的一部分,这会使其乱码并可能产生完全无效的东西。jz foo将会触发跳转,进程不会再 dec eax 处停止,CPU将直接执行后面的无效指令。

int 3 进行特殊的1字节编码可以解决这个问题。由于1字节是指令可以在x86上运行的最短字节,因此我们保证只有想要中断的指令才会被更改。

细节封装

我们将代码示例中的许多低级细节封装成API。我已经对一个名为debuglib的小实用程序库进行了一些封装 - 它的代码可以在本文末尾下载。在这里,我只是想展示一下它的用法示例。我们将跟踪用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来反汇编可执行文件,查看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,我们可以不必直接修改EIP和进程的内存空间。在跟踪上面显示的简单C代码时,让我们看看它打印的是什么:

$ 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

完整代码

链接 是此部分的完整源代码文件,具体包含以下内容:

  • debuglib.hdebuglib.c —— 用于封装调试器的一些内部工作的简单库
  • bp_manual.c - 本文首先介绍的设置断点的“手动”方式。使用debuglib库获取一些样板代码。
  • bp_use_lib.c - 对其大多数代码使用debuglib,如在C程序中跟踪循环的第二个代码示例中所示。

总结

我们已经介绍了如何在调试器中实现断点。虽然操作系统之间的实现细节不同,但是当你使用x86构架时,它基本上都是同一主题的变体 —— 用int 3代替我们希望停止的指令。