原文: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)是进程试图执行未经授权的内存操作时发生的异常,是一种内存保护机制,用于防止非法的内存访问。——译者注
还剩最后一个问题,当(待调试的程序)执行到了设置了断点的指令后,如何通知调试器?上一篇教程曾提到,可以使用 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 设置断点:
- 向
debugger
类中添加存储断点的数据结构 - 实现
set_breakpoint_at_address()
函数 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);
}
可以通过如下步骤找到加载地址:
- 开始调试程序
- 留意输出的子进程 ID
- 通过读取
/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