实战:调试器(ptrace+断点+单步执行)
调试器这东西,说白了就是给程序做「解剖」的工具。你写了个程序,跑起来结果不对,又不想满世界加 printf,这时候就需要一个调试器。我自己做 C++ 开发这么多年,调试器几乎天天用。但很多人只是会用 GDB,却不知道它底层是怎么工作的。
今天我们就来亲手写一个迷你调试器。用 Linux 的 ptrace 系统调用,实现断点设置和单步执行。嗯,这活儿其实没你想的那么神秘。
ptrace 是什么?
ptrace 是 Linux 提供的一个系统调用。它允许一个进程(调试器)去观察和控制另一个进程(被调试程序)的执行。说白了,就是让调试器能「附身」到目标进程上。
我刚开始接触 ptrace 时,觉得它像个黑魔法。后来发现,它其实就是一套标准的「监控-中断-恢复」机制。
核心操作就这几个:
- PTRACE_TRACEME:子进程告诉内核「我要被跟踪」
- PTRACE_ATTACH:调试器附加到正在运行的进程
- PTRACE_PEEKTEXT:读取目标进程的内存
- PTRACE_POKETEXT:写入目标进程的内存
- PTRACE_CONT:让目标进程继续执行
- PTRACE_SINGLESTEP:单步执行一条指令
关键点:ptrace 只能在 Linux 上用。Windows 有类似的 Debug API,但接口完全不同。如果你做跨平台调试器,得分别实现两套后端。
断点的实现原理
断点是怎么工作的?你想想看,调试器怎么知道程序执行到了某一行?
其实原理很简单:把目标地址的指令替换成一条特殊指令。在 x86 架构上,这条指令就是 int 3(机器码 0xCC)。当 CPU 执行到 0xCC 时,会触发一个中断,操作系统会把这个中断转发给调试器。
具体步骤是这样的:
- 读取目标地址的原始指令(1 个字节)
- 把该地址的内容改成 0xCC
- 目标进程执行到该地址时,触发断点
- 调试器收到信号,处理断点
- 恢复原始指令,让程序继续执行
我曾经踩过一个坑:多线程程序里设断点。如果你只在一个线程里设了断点,其他线程可能也会跑到那个地址。嗯,这时候断点触发就乱了。所以真正的调试器会做线程级的管理。
单步执行的原理
单步执行比断点更直接。它利用 CPU 的 TF 标志位(Trap Flag)。当 TF 置 1 时,CPU 每执行完一条指令就会触发一次调试异常。
ptrace 封装了这个操作:你调用 ptrace(PTRACE_SINGLESTEP, pid, 0, 0),内核会帮你设置 TF 标志,然后让进程执行一条指令,再停下来通知你。
小技巧:单步执行非常慢。如果你要跳过一个大循环,千万别用单步。我一般先用断点跳到循环外面,再单步执行关键路径。
动手写一个迷你调试器
下面我们写一个简单的调试器。它能启动一个子进程,设置断点,然后单步执行。代码我尽量精简,但保留了核心逻辑。
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/user.h>
// 断点结构
typedef struct {
unsigned long addr; // 断点地址
unsigned char orig; // 原始指令字节
} Breakpoint;
// 设置断点
void set_breakpoint(pid_t pid, unsigned long addr, Breakpoint *bp) {
bp->addr = addr;
// 读取原始指令
bp->orig = ptrace(PTRACE_PEEKTEXT, pid, (void*)addr, 0) & 0xFF;
// 写入 int 3 (0xCC)
unsigned long data = ptrace(PTRACE_PEEKTEXT, pid, (void*)addr, 0);
data = (data & ~0xFF) | 0xCC;
ptrace(PTRACE_POKETEXT, pid, (void*)addr, (void*)data);
printf("[调试器] 在 0x%lx 设置断点\n", addr);
}
// 恢复断点处的原始指令
void restore_breakpoint(pid_t pid, Breakpoint *bp) {
unsigned long data = ptrace(PTRACE_PEEKTEXT, pid, (void*)bp->addr, 0);
data = (data & ~0xFF) | bp->orig;
ptrace(PTRACE_POKETEXT, pid, (void*)bp->addr, (void*)data);
printf("[调试器] 恢复 0x%lx 的原始指令\n", bp->addr);
}
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:被调试的程序
ptrace(PTRACE_TRACEME, 0, 0, 0);
execl("./target", "target", NULL);
perror("execl");
exit(1);
} else if (pid > 0) {
// 父进程:调试器
int status;
wait(&status); // 等待子进程启动
// 假设我们要在 0x400000 处设断点(实际地址需要从 ELF 文件获取)
unsigned long bp_addr = 0x400000;
Breakpoint bp;
set_breakpoint(pid, bp_addr, &bp);
// 让子进程继续执行,直到断点
ptrace(PTRACE_CONT, pid, 0, 0);
wait(&status);
if (WIFSTOPPED(status)) {
printf("[调试器] 子进程在断点处停止,信号: %d\n", WSTOPSIG(status));
// 恢复原始指令
restore_breakpoint(pid, &bp);
// 单步执行 3 条指令
for (int i = 0; i < 3; i++) {
ptrace(PTRACE_SINGLESTEP, pid, 0, 0);
wait(&status);
printf("[调试器] 单步 %d 完成\n", i + 1);
}
}
// 让子进程继续运行到结束
ptrace(PTRACE_CONT, pid, 0, 0);
wait(&status);
printf("[调试器] 子进程退出\n");
}
return 0;
}
注意:上面的代码中,断点地址 0x400000 是随便写的。实际项目中,你需要解析 ELF 文件,找到函数入口地址。可以用 readelf 或 libelf 库来做。
调试器的核心流程
我把整个调试器的执行流程画了一张图。你看一眼就明白了。
实际项目中的坑
写一个玩具调试器不难,但要做成产品级的工具,坑多着呢。我列几个我亲身经历过的:
- 断点命中后恢复指令:你恢复原始指令后,程序计数器(RIP)还指向断点地址。你得先让 RIP 回退一个字节,再执行原始指令。否则会跳过去。
- 多线程同步:调试多线程程序时,ptrace 只能跟踪一个线程。你需要用
PTRACE_SEIZE和PTRACE_INTERRUPT来管理其他线程。 - 信号处理:被调试程序收到信号时,调试器也会收到。你得区分是程序自己的信号还是调试触发的信号。我曾经没处理好,导致程序被调试器「误杀」了。
- 性能问题:单步执行非常慢。如果你要调试一个大型程序,建议只在关键路径上设断点,不要全程单步。
我的习惯:写调试器时,先实现断点功能,再实现单步。断点是最常用的功能,单步更多是辅助分析。另外,一定要加日志输出,不然你根本不知道程序跑到哪了。
总结
ptrace 是 Linux 调试器的基石。断点靠的是指令替换(int 3),单步靠的是 CPU 的 TF 标志。这两招学会了,你就能理解 GDB、LLDB 这些工具的核心原理。
当然,真正的调试器远比这个复杂。还要处理符号表、源码映射、变量查看、调用栈回溯等等。但核心的「监控-中断-恢复」机制,就是上面这几行代码。
嗯,如果你有兴趣,可以试着扩展一下:加上条件断点、或者支持远程调试。这些都是在 ptrace 基础上加一层逻辑而已。
一句话总结:调试器不神秘,ptrace 就是它的心脏。断点和单步,就是心脏的两次跳动。
公众号:蓝海资料掘金营,微信 deep3321