实战:调试器(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. 读取目标地址的原始指令(1 个字节)
  2. 把该地址的内容改成 0xCC
  3. 目标进程执行到该地址时,触发断点
  4. 调试器收到信号,处理断点
  5. 恢复原始指令,让程序继续执行

我曾经踩过一个坑:多线程程序里设断点。如果你只在一个线程里设了断点,其他线程可能也会跑到那个地址。嗯,这时候断点触发就乱了。所以真正的调试器会做线程级的管理。

单步执行的原理

单步执行比断点更直接。它利用 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 库来做。

调试器的核心流程

我把整个调试器的执行流程画了一张图。你看一眼就明白了。

迷你调试器执行流程 开始 fork() 创建子进程 子进程:TRACEME + execl 父进程:等待 + 设断点 执行到断点处触发 int 3 收到 SIGTRAP,处理断点 单步执行 / 继续运行

实际项目中的坑

写一个玩具调试器不难,但要做成产品级的工具,坑多着呢。我列几个我亲身经历过的:

  • 断点命中后恢复指令:你恢复原始指令后,程序计数器(RIP)还指向断点地址。你得先让 RIP 回退一个字节,再执行原始指令。否则会跳过去。
  • 多线程同步:调试多线程程序时,ptrace 只能跟踪一个线程。你需要用 PTRACE_SEIZEPTRACE_INTERRUPT 来管理其他线程。
  • 信号处理:被调试程序收到信号时,调试器也会收到。你得区分是程序自己的信号还是调试触发的信号。我曾经没处理好,导致程序被调试器「误杀」了。
  • 性能问题:单步执行非常慢。如果你要调试一个大型程序,建议只在关键路径上设断点,不要全程单步。

我的习惯:写调试器时,先实现断点功能,再实现单步。断点是最常用的功能,单步更多是辅助分析。另外,一定要加日志输出,不然你根本不知道程序跑到哪了。

总结

ptrace 是 Linux 调试器的基石。断点靠的是指令替换(int 3),单步靠的是 CPU 的 TF 标志。这两招学会了,你就能理解 GDB、LLDB 这些工具的核心原理。

当然,真正的调试器远比这个复杂。还要处理符号表、源码映射、变量查看、调用栈回溯等等。但核心的「监控-中断-恢复」机制,就是上面这几行代码。

嗯,如果你有兴趣,可以试着扩展一下:加上条件断点、或者支持远程调试。这些都是在 ptrace 基础上加一层逻辑而已。

一句话总结:调试器不神秘,ptrace 就是它的心脏。断点和单步,就是心脏的两次跳动。


公众号:蓝海资料掘金营,微信 deep3321