【译文】如何编写一个 Linux 程序的调试器——2.断点

92 阅读13分钟

原文:blog.tartanllama.xyz/writing-a-l… Writing a Linux Debugger Part 2: Breakpoints on March 24, 2017 by Sy Brand

在本系列教程的第一讲中,我们编写了一个能启动另一个程序的小程序作为 minidbg 调试器的基础。接下来将讲解断点在 x86 Linux 中的工作原理,以及如何为 minidbg 添加设置断点的功能。

如何设置断点?

断点主要分为硬断点软断点两种。硬断点通常要通过设置特定于某种体系结构的一组寄存器来产生,软断点则可以通过临时修改正在运行的代码来产生。本文仅关注更简单的软断点。软断点没有数量上的限制,而在 x86 上,最多只能同时设置 4 个硬断点。不过,硬断点不仅能在执行到特定地址上的代码时触发,还可以在读写指定内存地址上的数据时触发。

既然软断点是通过动态修改正在执行的代码来设置的,那么问题来了:

  • 如何修改正在执行的代码?
  • 具体要进行哪些修改?
  • 如何通知调试器(待调试的程序停止在了断点上)?

第一个问题比较简单,我们还是要借助 ptrace()。该系统调用不仅能用来设置程序接受调试,并在停止后继续执行,还能用于读写内存地址上的数据。

对于第二个问题,需要这样修改:当(待调试的进程)执行到位于设置了断点的内存地址上的指令(以下简称为“设置了断点的指令”)时,要使处理器暂停执行该指令,并(由操作系统)向待调试的进程发出信号。

在 x86 上,修改的具体方法是使用 int 3 指令覆盖设置了断点的指令。x86 有一个中断向量表(interrupt vector table),操作系统可以使用它来注册各种事件(如页错误、保护错误和无效操作码等)的处理程序。这有点像在应用程序中注册错误处理回调函数,只不过这是在硬件层面上完成的。当处理器执行到 int 3 指令时,控制权就会移交到断点中断处理程序。在 Linux 下,中断处理程序会向(待调试的)进程发送 SIGTRAP 信号。该过程如下图所示,我们用 0xcc,即 int 3 的指令编码,覆盖了 mov 指令的第 1 个字节,于是(操作系统提供的)中断处理程序将被调用,进而向待调试的进程发送 SIGTRAP 信号。

页错误(page fault)是进程试图访问尚未加载到物理内存的虚拟内存页面时发生的异常。页错误本身不是错误或故障,而是内存管理的一部分。保护错误(protection fault)是进程试图执行未经授权的内存操作时发生的异常,是一种内存保护机制,用于防止非法的内存访问。——译者注

image-20240814113058715

还剩最后一个问题,当(待调试的程序)执行到了设置了断点的指令后,如何通知调试器?上一篇教程曾提到,可以使用 waitpid() 来监听待调试的程序是否收到了(来自操作系统的)信号。因此,我们继续使用这个方法,先设置断点,再让待调试的程序继续执行,并调用 waitpid() 等待其收到 SIGTRAP 信号。调试器还可以将断点的信息呈现给用户,如打印对应的源代码,甚至带有 GUI 的调试器还可以高亮源代码中对应的语句。

实现软断点

breakpoint 类(的对象)表示某个位置(内存地址)上的断点,我们可以根据需要启用或禁用断点。

class breakpoint {
public:
    breakpoint(pid_t pid, std::intptr_t addr)
        : m_pid{pid}, m_addr{addr}, m_enabled{false}, m_saved_data{}
    {}

    void enable();
    void disable();

    auto is_enabled() const -> bool { return m_enabled; }
    auto get_address() const -> std::intptr_t { return m_addr; }

private:
    pid_t m_pid;
    std::intptr_t m_addr;
    bool m_enabled;
    uint8_t m_saved_data; //data which used to be at the breakpoint address
};

is_enabled()get_address() 只是返回了断点的状态,真正的魔法发生在 enable() 方法和 disable() 方法中。

如前所述,我们需要用 int 3指令(编码为0xcc)替换设置了断点的指令。在替换前还需要先保留该地址上原本的指令,因为之后还要执行本该执行的指令——这可不能忘记!

void breakpoint::enable() {
    auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
    m_saved_data = static_cast<uint8_t>(data & 0xff); //save bottom byte
    uint64_t int3 = 0xcc;
    uint64_t data_with_int3 = ((data & ~0xff) | int3); //set bottom byte to 0xcc
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, data_with_int3);

    m_enabled = true;
}

ptrace()PTRACE_PEEKDATA 请求用于读取待调试进程中的某块内存区域,此时,还要指定进程 ID 和内存地址。ptrace() 则会返回当前位于该地址的 64 比特的数据。

(data & ~0xff) 用于将此数据中最底端的字节清零。然后将修改后的数据与 int 3 指令进行按位 OR 以设置断点。最后,我们通过 PTRACE_POKEDATA 请求将新数据写入该内存区域。

disable() 的实现与此类似。由于 ptrace() 的内存操作是针对整个字(整块 64 比特的内存区域)而并非单个字节,因此必须先从那一整块设置了断点的内存区域中读出数据,然后用预先保留下来的原始数据还原最底端的字节,最后再将还原后的数据写回这块内存。

void breakpoint::disable() {
    auto data = ptrace(PTRACE_PEEKDATA, m_pid, m_addr, nullptr);
    auto restored_data = ((data & ~0xff) | m_saved_data);
    ptrace(PTRACE_POKEDATA, m_pid, m_addr, restored_data);

    m_enabled = false;
}

minidbg 添加断点设置功能

接下来,将对 debugger 类进行 3 处改造,以支持用户通过 CLI 设置断点:

  1. debugger 类中添加存储断点的数据结构
  2. 实现 set_breakpoint_at_address() 函数
  3. handle_command() 方法需支持 break 命令

这里将断点存储在 std::unordered_map<std::intptr_t, breakpoint> 中,以便轻松检查给定的内存地址上是否有断点,如果有,则获取该 breakpoint 类的对象。

class debugger {
    //...
    void set_breakpoint_at_address(std::intptr_t addr);
    //...
private:
    //...
    std::unordered_map<std::intptr_t,breakpoint> m_breakpoints;
}

set_breakpoint_at_address() 中,我们先创建了一个 breakpoint 类的对象 bp,然后调用 enable() 方法启用它,并将其添加到用于存储断点的 m_breakpoints 中。该方法还会打印一条消息,提示用户在何处设置了断点。最好只将这类信息记录下来,而不是直接打印消息,这样就可以将调试器用作库或命令行工具了。不过,为了简单起见,我在这里还是直接先打印出来了。

void debugger::set_breakpoint_at_address(std::intptr_t addr) {
    std::cout << "Set breakpoint at address 0x" << std::hex << addr << std::endl;
    breakpoint bp {m_pid, addr};
    bp.enable();
    m_breakpoints[addr] = bp;
}

然后,我们就可以扩充 handle_command() 了,调用刚刚实现的 set_breakpoint_at_address() 方法来处理 break 命令:

void debugger::handle_command(const std::string& line) {
    auto args = split(line,' ');
    auto command = args[0];

    if (is_prefix(command, "cont")) {
        continue_execution();
    }
    else if(is_prefix(command, "break")) {
        std::string addr {args[1], 2}; //naively assume that the user has written 0xADDRESS
        set_breakpoint_at_address(std::stol(addr, 0, 16));
    }
    else {
        std::cerr << "Unknown command\n";
    }
}

我这里图省事,只是删除了代表内存地址的字符串的前两个字符 0x,并调用std::stol() 将剩余部分转换为整数。但实际上应该采用更加健壮的解析方法。std::stol() 支持指定进制进行字符串到整数的转换,因此用它可以轻松处理常用十六进制数表示的内存地址。

从断点继续执行

你可能也已经发现了,本想从断点继续(通过 continue 命令)执行,却什么也没有发生。这是因为断点仍设置在内存中,因此会反复命中。简单的解决方法是先禁用断点,然后单步执行,接着重新启用断点,最后再继续执行。不过,为此我们必须先修改程序计数器寄存器,让它先指向断点之前的那条指令。那该如何修改该寄存器呢?别着急,下一篇教程就会讲解操作寄存器的方法了。

这一段讲述的内容我没能复现,也可能我理解有误。

原文如下:

If you try this out, you might notice that if you continue from the breakpoint, nothing happens. That’s because the breakpoint is still set in memory, so it’s hit repeatedly. ...

用 minidbg 调试作者提供的可执行文件“hello”,操作过程如下:

# nm ./hello | fgrep main
...省略
0000000000400936 T main
# ./minidbg ./hello
Started debugging process 18996
minidbg> break 0x400936
Set breakpoint at address 0x400936
minidbg> c
minidbg> c
Hello worldminidbg>

是能继续执行 main() 函数并输出“Hello world”的。——译者注

测试一下

如果不知道源代码中的语句对应的内存地址,又要怎样设置断点呢?后续教程是会为 minidbg 添加在函数名或某行源代码上设置断点的功能,但眼下我们只好先手动寻找地址了。

为了测试 minidbg 的断点功能,我编写了一个向 std::cerr(避免缓冲)输出 "Hello world" 的小程序。可以先将断点设置在输出运算符上。这个程序运行后,应该会停止在断点处而且不打印任何内容。然后,我们重新启动调试器,这一次请在输出运算符之后设置断点,那样应该就能看到有内容打印出来了。

使用 objdump 命令可以查看指令对应的内存地址。在 shell 中执行 objdump -d <your program>,就能看到经过反汇编后的代码和对应的地址。应该可以从中找到 main 函数对应的一系列指令,其中包括要在上面设置断点的 callq 指令。例如,我编译了刚刚提到的“hello world”小程序,进行了反汇编,并得到了反汇编之后的 main函数对应的指令:

0000000000001189 <main>:
    1189:       f3 0f 1e fa             endbr64
    118d:       55                      push   %rbp
    118e:       48 89 e5                mov    %rsp,%rbp
    1191:       48 8d 35 6d 0e 00 00    lea    0xe6d(%rip),%rsi        # 2005 <_ZStL19piecewise_construct+0x1>
    1198:       48 8d 3d 81 2e 00 00    lea    0x2e81(%rip),%rdi        # 4020 <_ZSt4cerr@@GLIBCXX_3.4>
    119f:       e8 dc fe ff ff          callq  1080 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
    11a4:       b8 00 00 00 00          mov    $0x0,%eax
    11a9:       5d                      pop    %rbp
    11aa:       c3                      retq

按照刚刚提到的测试方法,我们第一次应该在 0x1198 上设置断点,并确认没有输出“Hello world”。然后重启调试器,再在 0x119f 上设置断点,这一次应该会输出“Hello world”。

但是要注意,这些内存地址可能不是固定的。如果程序被编译为位置无关可执行文件(PIE,position-independent executable)(这是某些编译器的默认设置),则地址只是相对于二进制文件加载位置的偏移量。再加上地址空间布局随机化(ASLR,address space layout randomization),此加载位置将随着程序的每次运行而改变。

可以用 -no-pie 这个编译选项来解决内存地址不固定的问题,但这并不算太好的解决方案,而且不一定对所有项目都有效。此外,还可以通过禁用程序的地址空间布局随机化来找到正确的加载地址。

为此,请在子进程中调用 execute_debugee() 之前添加对 personality() 的调用:

if (pid == 0) {
    //child
    personality(ADDR_NO_RANDOMIZE);
    execute_debugee(prog);
}

可以通过如下步骤找到加载地址:

  1. 开始调试程序
  2. 留意输出的子进程 ID
  3. 通过读取 /proc/<子进程 ID>/maps 这个文件以找到加载地址

例如,这是我的“hello world”小程序的映射文件:

08000000-08001000 r--p 00000000 00:00 56882                      /path/to/hello
08001000-08002000 r-xp 00001000 00:00 56882                      /path/to/hello
08002000-08003000 r--p 00002000 00:00 56882                      /path/to/hello
08003000-08005000 rw-p 00002000 00:00 56882                      /path/to/hello
7fffff7b0000-7fffff7b1000 r--p 00000000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7b1000-7fffff7d3000 r-xp 00001000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7d3000-7fffff7d4000 r-xp 00023000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7d4000-7fffff7db000 r--p 00024000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7db000-7fffff7dc000 r--p 0002b000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7dd000-7fffff7df000 rw-p 0002c000 00:00 948077             /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7df000-7fffff7e0000 rw-p 00000000 00:00 0
7fffff7ef000-7ffffffef000 rw-p 00000000 00:00 0                  [stack]
7ffffffef000-7fffffff0000 r-xp 00000000 00:00 0                  [vdso]

这里最为重要的是第一个标注有 /path/to/hello 的内存地址,即第一行的 0x08000000。这就是二进制文件的加载地址。以该地址为基础,再加上断点的偏移量,即可得到要设置断点的实际地址。


现在,我们的 minidbg 不仅能启动待调试的程序,还允许用户在内存地址上设置断点。接下来我们将继续加入读写内存和寄存器的功能。

无论您有任何问题,都请在评论中告诉我。

可以在此处找到与本教程相关的代码。

原文:blog.tartanllama.xyz/writing-a-l… Writing a Linux Debugger Part 2: Breakpoints on March 24, 2017 by Sy Brand