本文原创公开首发于 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_ATTACHattach 到已运行进程
PTRACE_TRACEME子进程主动要求被跟踪
PTRACE_PEEKDATA读内存
PTRACE_POKEDATA写内存
PTRACE_GETREGS读寄存器
PTRACE_SETREGS写寄存器
PTRACE_SINGLESTEP执行一条指令

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

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

用 Mermaid 时序图展示断点改写全流程——调试器先 PEEK 读出原指令,再 POKE 写入 int3(0xCC),CPU 执行到该字节时触发 #BP 异常并向调试器发送 SIGTRAP,从而暂停目标进程。

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

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

4. 项目骨架

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

5. 核心代码逐段讲解

5.1 启动子进程并跟踪

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 断点结构体与安装

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(保持断点持续有效)

代码实现:

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 打印寄存器与调用栈

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 个断点
ccontinue
s单步
r打印寄存器
q退出

主循环伪代码:

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. 编译 & 运行

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 调试的窗户纸。

无彩蛋,完整源码已在正文给出,复制即可编译运行。
欢迎评论区贴上你的断点截图或遇到的奇怪信号,一起交流!