本文原创公开首发于 CSDN
如需转载,请在文首注明出处与作者:@yu779
从 0 到 1 手写 Linux 调试器:ptrace 系统调用与断点原理
1. 前言:为什么调试器能"停"住程序?
无论是 GDB 还是 VSCode 的 debug 面板,打断点都是最常用功能。
其本质只有两件事:
- 把目标指令替换为陷阱指令(x86_64 即 int 3,机器码 0xCC)。
- 让目标进程陷入内核,再让调试器获得通知。
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 异常,内核:
- 保存上下文(寄存器、信号)
- 发送 SIGTRAP 给父进程(调试器)
- 调试器 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, ®s);
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. 扩展路线
- 支持 硬件观察点(PTRACE_SET_HW_BREAKPOINT)
- 解析 DWARF,实现 源码级断点 b main.c:42
- 加入 表达式求值 & 反向调试(PTRACE_SYSEMU)
- 用 eBPF + uprobe 实现非侵入断点,性能提升 10 倍
9. 结语
调试器并不神秘,它只是:会读/写内存,会让 CPU 单步,会处理信号。
掌握 ptrace 后,你可以:
- 在线业务 热补丁(直接 POKE 机器码)
- 写 性能剖析工具(采样 PC 寄存器)
- 做 内存扫描器(扫描 0xCC 找隐藏断点)
100 行代码,捅破 Linux 调试的窗户纸。
无彩蛋,完整源码已在正文给出,复制即可编译运行。
欢迎评论区贴上你的断点截图或遇到的奇怪信号,一起交流!