从 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 天前
智能体选型实战指南
运维·人工智能
yy55271 天前
Nginx 性能优化与监控
运维·nginx·性能优化
炘爚1 天前
C语言(文件操作)
c语言·开发语言
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ1 天前
Linux 查询某进程文件所在路径 命令
linux·运维·服务器
W.D.小糊涂1 天前
gpu服务器安装windows+ubuntu24.04双系统
c语言·开发语言·数据库
05大叔1 天前
网络基础知识 域名,JSON格式,AI基础
运维·服务器·网络
安当加密1 天前
无需改 PAM!轻量级 RADIUS + ASP身份认证系统 实现 Linux 登录双因子认证
linux·运维·服务器
dashizhi20151 天前
服务器共享禁止保存到本地磁盘、共享文件禁止另存为本地磁盘、移动硬盘等
运维·网络·stm32·安全·电脑
内卷焦虑人士1 天前
Windows安装WSL2+Ubuntu 22.04
linux·windows·ubuntu
C羊驼1 天前
C语言:两天打鱼,三天晒网
c语言·经验分享·笔记·算法·青少年编程