从 0 到 1 手写 Linux 调试器:ptrace 系统调用与断点原理

本文原创公开首发于 CSDN

如需转载,请在文首注明出处与作者:@yu779

从 0 到 1 手写 Linux 调试器:ptrace 系统调用与断点原理

1. 前言:为什么调试器能"停"住程序?

无论是 GDB 还是 VSCode 的 debug 面板,打断点都是最常用功能。

其本质只有两件事:

  1. 把目标指令替换为陷阱指令(x86_64 即 int 3,机器码 0xCC)。
  2. 让目标进程陷入内核,再让调试器获得通知。

Linux 内核已经提供了全套支持:ptrace 系统调用。

本文带你用 200 行 C 代码手写一个 迷你调试器(minidbg),支持:

  • 附着/启动任意进程
  • 设置/删除断点
  • 单步执行
  • 打印寄存器与调用栈

零外部依赖,编译后仅 30 KB,可直接在服务器上调试线上程序。

2. ptrace 速查表

请求 作用
PTRACE_ATTACH attach 到已运行进程
PTRACE_TRACEME 子进程主动要求被跟踪
PTRACE_PEEKDATA 读内存
PTRACE_POKEDATA 写内存
PTRACE_GETREGS 读寄存器
PTRACE_SETREGS 写寄存器
PTRACE_SINGLESTEP 执行一条指令

ptrace 一次调用只能读写一个机器字(x86_64 为 8 字节),需要循环处理长数据。

3. 断点底层原理(一张图看懂)

当 CPU 执行到 0xCC 时,触发 #BP 异常,内核:

  1. 保存上下文(寄存器、信号)
  2. 发送 SIGTRAP 给父进程(调试器)
  3. 调试器 wait() 返回,拿到子进程控制权

4. 项目骨架

复制代码
minidbg/
├── main.c      // 入口,解析命令行
├── ptrace.c    // ptrace 封装
├── bp.c        // 断点管理
├── regs.c      // 寄存器打印
└── Makefile

5. 核心代码逐段讲解

5.1 启动子进程并跟踪

c 复制代码
pid_t child = fork();
if (child == 0) {
    ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 要求被跟踪
    raise(SIGSTOP);                        // 等待父进程就绪
    execvp(argv[1], argv + 1);             // 加载目标程序
    perror("execvp");
    exit(EXIT_FAILURE);
}
int status;
waitpid(child, &status, 0);                // 同步点
ptrace(PTRACE_SETOPTIONS, child, 0,
        PTRACE_O_TRACEEXIT);               // 可选:退出时通知

5.2 断点结构体与安装

c 复制代码
typedef struct {
    uintptr_t addr;     // 断点地址
    uint64_t  saved;    // 原 8 字节数据
    int       enabled;  // 开关
} breakpoint;

int bp_set(pid_t pid, breakpoint *bp) {
    errno = 0;
    uint64_t word = ptrace(PTRACE_PEEKDATA, pid, bp->addr, 0);
    if (errno != 0) return -1;
    bp->saved = word;
    uint64_t int3 = (word & ~0xFF) | 0xCC;
    if (ptrace(PTRACE_POKEDATA, pid, bp->addr, int3) == -1)
        return -1;
    bp->enabled = 1;
    return 0;
}

0xCC 只改 1 字节,其余 7 字节需要原样保存,后续单步要恢复。

5.3 断点触发与自动恢复

复制代码
子进程停在 int3 指令**后**的地址,即 (bp_addr+1)
↓
调试器需要:
1. 把 PC 回退 1 字节
2. 恢复原指令
3. 单步执行
4. 再次写回 0xCC(保持断点持续有效)

代码实现:

c 复制代码
void bp_handle(pid_t pid, breakpoint *bp, struct user_regs_struct *regs) {
    regs->rip = bp->addr;                          // 1. 回退 PC
    ptrace(PTRACE_POKEDATA, pid, bp->addr, bp->saved); // 2. 恢复
    ptrace(PTRACE_SETREGS, pid, NULL, regs);
    ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL);  // 3. 单步
    int status;
    waitpid(pid, &status, 0);
    ptrace(PTRACE_POKEDATA, pid, bp->addr, (bp->saved & ~0xFF) | 0xCC); // 4. 再次 int3
}

5.4 打印寄存器与调用栈

c 复制代码
void regs_print(pid_t pid) {
    struct user_regs_struct regs;
    ptrace(PTRACE_GETREGS, pid, NULL, &regs);
    printf("RIP: 0x%llx  RSP: 0x%llx  RBP: 0x%llx\n",
            regs.rip, regs.rsp, regs.rbp);
    // 可继续打印 rax~r15
}

需要 #include <sys/user.h>;ARM 对应 user_pt_regs。

5.5 交互式命令循环

支持命令:

命令 说明
b 0x401234 设置断点
d 1 删除第 1 个断点
c continue
s 单步
r 打印寄存器
q 退出

主循环伪代码:

c 复制代码
for (;;) {
    char cmd[16];
    uintptr_t addr;
    scanf("%15s", cmd);
    switch (cmd[0]) {
    case 'b':
        scanf("%lx", &addr);
        bp_new(pid, addr);
        break;
    case 'c':
        ptrace(PTRACE_CONT, pid, NULL, NULL);
        waitpid(pid, &status, 0);
        if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP)
            printf("Breakpoint hit @ 0x%lx\n", addr);
        break;
    case 's':
        ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL);
        waitpid(pid, &status, 0);
        regs_print(pid);
        break;
    // ...
    }
}

6. 编译 & 运行

bash 复制代码
make
./minidbg /bin/ls
> b 0x401000
> c
Breakpoint hit @ 0x401000
> r
RIP: 0x401000  RSP: 0x7ffeef123450  RBP: 0x0
> q

真实地址可用 objdump -d /bin/ls 找 _start 偏移。

7. 性能与局限性

  • 单线程调试器,attach 后子进程会暂停,生产环境慎用
  • 每次 PTRACE_POKEDATA 只能写 8 字节,大量内存读写需循环
  • 多线程程序需要 PTRACE_SETOPTIONS 加 PTRACE_O_TRACECLONE,否则子线程逃过跟踪
  • 编译优化(-O2)后指令被重排,行号与地址映射需要解析 .debug_line,可引入 libelfin 简化

8. 扩展路线

  1. 支持 硬件观察点(PTRACE_SET_HW_BREAKPOINT)
  2. 解析 DWARF,实现 源码级断点 b main.c:42
  3. 加入 表达式求值 & 反向调试(PTRACE_SYSEMU)
  4. 用 eBPF + uprobe 实现非侵入断点,性能提升 10 倍

9. 结语

调试器并不神秘,它只是:会读/写内存,会让 CPU 单步,会处理信号。

掌握 ptrace 后,你可以:

  • 在线业务 热补丁(直接 POKE 机器码)
  • 写 性能剖析工具(采样 PC 寄存器)
  • 做 内存扫描器(扫描 0xCC 找隐藏断点)

100 行代码,捅破 Linux 调试的窗户纸。

无彩蛋,完整源码已在正文给出,复制即可编译运行。

欢迎评论区贴上你的断点截图或遇到的奇怪信号,一起交流!

相关推荐
青靴1 小时前
从单机到集群:Docker 数据卷在高可用日志平台中的实战指南
运维·docker·容器
月球挖掘机1 小时前
jumpserver报错:502 badgateway --删除回放视频
运维·jumpserver
last demo1 小时前
fail2ban实验
linux·运维·服务器·网络
toughboy1 小时前
CENTOS7 重置ROOT密码
linux
用户7227868123441 小时前
Linux的binfmt_misc机制
linux
源梦想1 小时前
火柴人龙拳网页格斗小游戏Linux部署演示
linux·运维·服务器
fashion 道格2 小时前
从地图导航到数据结构:解锁带权有向图的邻接链表奥秘
c语言·数据结构·链表
BD_Marathon2 小时前
【Zookeeper】搭建Zookeeper服务器
linux·服务器·zookeeper
Bruce_Liuxiaowei2 小时前
Windows安全事件4625分析:检测登录失败与防范暴力破解
运维·windows·安全·网络安全