简介:《PA1-简易调试器1》是一份面向计算机系统课程与NEMU模拟器实验学习者的PDF实验指导,围绕在简化x86全系统模拟器上构建简易调试器展开,并涉及Git分支整理等工程实践。资源包共1个PDF文件,约1.53MB,内容按三个阶段组织:先实现单步执行、寄存器状态打印与内存扫描,再完成表达式求值,最后引入监视点等高级调试特性。文中结合NEMU运行Hello World等示例说明模拟器层次与虚拟化思路,并给出提交说明、评分构成和实验注意事项,便于读者把握约30小时、400行代码量的任务节奏。已有2182人学习,适合需要理解调试器实现、汇编与系统模拟基础的学习者对照实验要求推进练习。
1. 断点打不中,多半不是玄学:简易调试器要先解决哪三件事
目标程序在一个函数里崩了,printf 打了一屏也没看出入参是什么,手边又没有顺手的调试工具。这时候最直接的想法是:能不能自己写一个?PA1 这一阶段给的题目通常就一句话——做一个能启动程序、下断点、单步执行、打印寄存器的简易调试器。听起来像造轮子,实际是在拆三件事:怎么让别人的进程停在你指定的位置,怎么在它停下来时读到它的寄存器和内存,怎么让它继续跑而不破坏现场。这三件事各自对应一套系统调用和一份数据结构,做通了,几百行 C 就能跑起来;顺序搞反了,就会出现「断点明明写进去了,程序却一路跑到退出」这种让人怀疑是玄学的现象。下面按我实际写的顺序讲:先接管进程,再埋断点,再补单步和寄存器,最后把踩过的坑摊开说。适合写过 C、知道进程和虚拟地址大概是怎么回事、但没碰过 ptrace 的人。
2. 用 ptrace 接管一个进程:调试器的骨架只有三十行
2.1 为什么是 ptrace:一个系统调用提供的四类能力
调试器和普通程序的差别不在界面,在权限。它要读别的进程的地址空间、改别的进程的寄存器、让别的进程在任意一条指令后停下来。用户态没有这种权限,只能请内核代劳。Linux 上这套能力被收拢进一个系统调用ptrace,第一个参数是请求码,第二个是被跟踪进程的 pid,后两个是地址和数据。请求码按用途可以分成四类:控制流(PTRACE_CONT继续、PTRACE_SINGLESTEP单步、PTRACE_DETACH脱离)、观察(PTRACE_PEEKTEXT/PEEKDATA读机器字、PTRACE_GETREGS读寄存器组、PTRACE_GETSIGINFO读信号详情)、修改(POKETEXT/POKEDATA写字、SETREGS写寄存器组)、事件订阅(PTRACE_O_TRACECLONE、PTRACE_O_TRACEFORK、PTRACE_O_TRACEEXEC这一组选项,决定子进程、线程、exec 这些事件要不要通知你)。
为什么不用别的路子?把几条常见方案摆在一起看会更清楚:
| 做法 | 能做到 | 做不到 | 适合的场景 |
|---|---|---|---|
| ptrace 接管 | 指令级断点、单步、读写寄存器与内存 | 默认只跟主线程,单步开销大 | 通用调试器 |
| 动态库打桩 | 替换函数入口、统计调用次数 | 看不到指令级状态,改不了寄存器 | 只关心函数级行为 |
| 进程内自己捕 SIGTRAP | 自己埋点自己处理,不需要外部进程 | 只能调试自己,崩溃后现场难保 | 沙箱内自测 |
| 内核探针 | 线上低开销观测 | 不能交互式暂停、不能改寄存器 | 生产环境采样 |
做 PA1 这种交互式调试器,只有第一条路能覆盖「停在任何一条指令」这个需求。代价是所有操作都要经过一次内核往返,单步尤其慢,后面第 4 章会讲这个慢到什么程度、以及怎么绕。
2.2 三行代码的顺序不能换:fork、TRACEME、exec
最小骨架就是父子进程分工:父进程当调试器,子进程当被调试者。子进程先声明「我要被跟踪」,再 exec 目标程序;父进程 waitpid 等它停下来。
#define _GNU_SOURCE #include <sys/ptrace.h> #include <sys/wait.h> #include <sys/types.h> #include <unistd.h> #include <stdio.h> #include <stdlib.h> #include <errno.h> int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s prog [args...]\n", argv[0]); return 1; } pid_t pid = fork(); if (pid < 0) { perror("fork"); return 1; } if (pid == 0) { /* 子进程:先声明「我要被跟踪」,再 exec。 顺序反了内核不报错,但父进程永远等不到第一次停止。 */ if (ptrace(PTRACE_TRACEME, 0, NULL, NULL) < 0) { perror("TRACEME"); _exit(127); } execvp(argv[1], &argv[1]); /* argv[0] 就是目标程序路径 */ perror("execvp"); _exit(127); /* 必须 _exit,不能 return 也不能 exit */ } int status; if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; } if (!WIFSTOPPED(status)) { fprintf(stderr, "child not stopped\n"); return 1; } /* exec 成功后内核会送一个 SIGTRAP,这是我们能开始操作的信号 */ printf("traced pid=%d, first stop sig=%d\n", pid, WSTOPSIG(status)); return 0; /* 后面在这里进入命令循环 */ }几个参数值得单独说。fork返回值三分支,pid == 0才是子进程,这里写错一次就会变成父进程自己在 exec。PTRACE_TRACEME的参数全是 0,它只对「我自己」生效,把当前进程标记为「即将被父进程跟踪」,然后 exec 时内核会在目标程序的第一条用户态指令前停一次,这次停止就是父进程能拿到的第一个抓手。execvp的第二个参数从argv[1]开始传,这样被调试程序的argv[0]自然就是它自己的路径,而不是调试器的名字——少了这一步,main(argc, argv)里打印出来的程序名会是你自己的调试器,排查时很容易看懵。最后那个_exit是血泪经验:exit会 flush 从父进程继承下来的 stdio 缓冲区,如果父进程在 fork 之前用 printf 打过东西,目标程序退出时你会看到那段输出又出现一遍,白白花半小时找重复打印的原因。
编译和第一次试跑:
gcc -g -O0 -Wall -o dbg dbg.c ./dbg ./target-g -O0是给调试器自己留符号,-Wall能挡住%d接pid_t这类在不同平台上宽度不一致的参数问题。
2.3 waitpid 的状态字:WIFSTOPPED、WSTOPSIG 和被吞掉的信号
waitpid返回的 status 是一个打包过的整数,低 8 位是导致停止的信号编号,高位区分退出、被杀、停止、继续这几种情况。不要自己位运算,用<sys/wait.h>提供的宏:WIFEXITED判正常退出,WIFSIGNALED判被杀,WIFSTOPPED判是否停在某个信号上,WSTOPSIG取这个信号编号。调试器的主循环几乎全部逻辑都挂在这个判定上。
这里有个容易被忽略的机制:被跟踪进程收到任何信号(包括它自己的段错误)时,都会先停下来把控制权交给父进程,父进程再用PTRACE_CONT的第四个参数决定这个信号是投递还是吞掉——传 0 表示吞掉,传信号编号表示原样投递。
for (;;) { if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); break; } if (WIFEXITED(status)) { printf("exit code %d\n", WEXITSTATUS(status)); break; } if (WIFSIGNALED(status)){ printf("killed by sig %d\n", WTERMSIG(status)); break; } int sig = WSTOPSIG(status); if (sig == SIGTRAP) { /* 三种可能:exec 后的第一次停、断点命中、单步结束 */ handle_trap(pid); ptrace(PTRACE_CONT, pid, 0, 0); } else { /* 其它信号必须原样投递,否则目标程序的 SIGSEGV 会被你吃掉, 表现成「程序莫名其妙卡住不退出」 */ ptrace(PTRACE_CONT, pid, 0, sig); } }SIGTRAP有三个来源,必须再分一层。最省事的办法是自己维护一个「上一步是不是单步」的小标志;更规范的办法是用PTRACE_GETSIGINFO取siginfo_t,看si_code:TRAP_BRKPT是int3断点,TRAP_TRACE是单步结束。两种做法都能用,前者代码少,后者在多线程场景下更稳。
3. 断点不是暂停键:0xCC、符号表与五步闭环
3.1 一条单字节指令撑起的断点
x86-64 里0xCC是int3的机器码,只有一字节。CPU 取到它就把控制权交给内核,内核对处于被跟踪状态的进程把这次异常转成SIGTRAP并让它停住,父进程的waitpid随即返回。
强调「单字节」是有原因的。断点要能埋在任意一条指令的起始地址上,如果断点指令占两个字节,替换时就一定会覆盖下一条指令的首字节,还原时还要重新算长度,遇到变长指令编码很容易算错,最后变成「断点一打,程序就跑飞」。单字节替换只动一个字节,指令边界永远对齐,这就是软件断点最讨巧的地方。顺带说一句,int 0x80的机器码是CD 80,不能拿来当断点,除了长度问题,0x80这个字节还可能正好是相邻指令的一部分。
另一个常见误解是「代码段只读,写不进去」。ptrace的写操作走的是内核的内存访问路径,强制绕过页表写保护,所以不需要先mprotect,也不需要有写权限。这条特性在做热补丁类工具时同样成立,但要注意它只对被你跟踪的那个进程有效。
3.2 地址从哪来:解析 .symtab 拿函数入口
用户下的是b f这种符号名,不是十六进制地址,所以调试器要先把名字翻译成地址。可执行文件里的.symtab就是干这个的:每个Elf64_Sym有名字偏移、类型、绑定、所在节下标和st_value。
#include <elf.h> #include <fcntl.h> #include <limits.h> #include <sys/mman.h> #include <sys/stat.h> #include <string.h> typedef struct { char name[64]; unsigned long addr; } Sym; /* 返回读到的函数符号个数;只处理带 .symtab 的、未被 strip 的文件 */ int load_func_syms(const char *path, Sym *tab, int cap, int *is_pie) { int fd = open(path, O_RDONLY); if (fd < 0) return 0; struct stat st; if (fstat(fd, &st) < 0) { close(fd); return 0; } void *base = mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); close(fd); if (base == MAP_FAILED) return 0; Elf64_Ehdr *eh = (Elf64_Ehdr *)base; if (memcmp(eh->e_ident, ELFMAG, SELFMAG) != 0) { munmap(base, st.st_size); return 0; } *is_pie = (eh->e_type == ET_DYN); /* PIE 程序是 ET_DYN,地址要加基址 */ Elf64_Shdr *sh = (Elf64_Shdr *)((char *)base + eh->e_shoff); int n = 0; for (int i = 0; i < eh->e_shnum && n < cap; i++) { if (sh[i].sh_type != SHT_SYMTAB) continue; /* 被 strip 后只剩 .dynsym */ if (sh[i].sh_link >= eh->e_shnum) continue; /* 字符串表下标越界保护 */ Elf64_Sym *syms = (Elf64_Sym *)((char *)base + sh[i].sh_offset); const char *strs = (const char *)base + sh[sh[i].sh_link].sh_offset; int cnt = sh[i].sh_size / sizeof(Elf64_Sym); for (int j = 0; j < cnt && n < cap; j++) { if (ELF64_ST_TYPE(syms[j].st_info) != STT_FUNC) continue; if (syms[j].st_value == 0) continue; if (syms[j].st_shndx == SHN_UNDEF) continue; /* 外部符号,本文件里没定义 */ snprintf(tab[n].name, sizeof tab[n].name, "%s", strs + syms[j].st_name); tab[n].addr = syms[j].st_value; n++; } } munmap(base, st.st_size); return n; }参数和边界逐个说。e_shoff是节头表在文件里的偏移,e_shnum是节数量,这两个字段决定你从哪开始遍历。SHT_SYMTAB是静态符号表,strip之后就没了,只剩SHT_DYNSYM动态符号表,导出函数还能查到,静态函数查不到——这是后面避坑章节里一条的根源。st_value的语义取决于文件类型:ET_EXEC(非 PIE)里它就是运行时虚拟地址,ET_DYN(PIE)里它是文件内偏移,必须加装载基址。把这两种情况混在一起处理是新手第一大坑,加了基址反而全错。SHN_UNDEF表示这个符号在本文件里只有声明没有定义,地址是 0,不跳过就会出现「断点地址 0x0,一继续就段错误」。
PIE 程序的基址从/proc/<pid>/maps里取,找目标文件偏移为 0 的那一段:
/* 从 /proc/<pid>/maps 找目标文件第一段映射的起始地址 */ unsigned long load_base(pid_t pid, const char *path) { char maps[64], line[512], real[PATH_MAX]; if (!realpath(path, real)) return 0; snprintf(maps, sizeof maps, "/proc/%d/maps", pid); FILE *f = fopen(maps, "r"); if (!f) return 0; unsigned long base = 0; while (fgets(line, sizeof line, f)) { unsigned long start, end, off; char perms[8], dev[16], name[PATH_MAX] = ""; int k = sscanf(line, "%lx-%lx %7s %lx %15s %*s %4095s", &start, &end, perms, &off, dev, name); if (k < 6) continue; if (off == 0 && strcmp(name, real) == 0) { base = start; break; } } fclose(f); return base; }sscanf里那个%*s是跳过 inode 字段,它在 maps 里没有用处但会占位。判断条件必须是「偏移为 0」,因为一个可执行文件在 maps 里会出现四五段(代码、只读数据、可写数据),只有第一段是从文件开头映射的,它的起始地址才是装载基址。取到之后,PIE 程序的运行时地址等于base + st_value,非 PIE 程序直接用st_value,一行都不能省。
3.3 埋点、命中、还原、单步、重埋:五步闭环
有了地址就可以动手改字节了。先写两个薄封装,把ptrace的返回值检查收在一处:
typedef struct { unsigned long addr; long orig; int on; } Bp; static long peek(pid_t pid, unsigned long addr) { errno = 0; /* 必须先清零,见第 5 章 */ long w = ptrace(PTRACE_PEEKTEXT, pid, (void *)addr, NULL); if (w == -1 && errno != 0) { perror("PEEKTEXT"); exit(1); } return w; } static void poke(pid_t pid, unsigned long addr, long word) { if (ptrace(PTRACE_POKETEXT, pid, (void *)addr, (void *)word) < 0) { perror("POKETEXT"); exit(1); } } void bp_set(pid_t pid, Bp *bp) { long w = peek(pid, bp->addr); bp->orig = w; /* 记住完整的 8 字节 */ poke(pid, bp->addr, (w & ~0xffL) | 0xcc); /* 只替换最低字节 */ bp->on = 1; }注意bp->orig保存的是整个机器字而不是单个字节。PEEKTEXT和POKETEXT的粒度是一个机器字(x86-64 上是 8 字节),一次读改写会连带影响同一字里的其他字节,所以必须把整字存下来、整字写回去,只拿一个unsigned char存原字节,还原时会把相邻 7 个字节写成 0。
断点命中之后的处理是整个调试器最核心的十行:
void bp_hit(pid_t pid, Bp *bp) { struct user_regs_struct regs; ptrace(PTRACE_GETREGS, pid, 0, ®s); /* 1) 还原原始字节,否则下一步单步又会撞上 0xCC */ poke(pid, bp->addr, bp->orig); /* 2) rip 回退:int3 执行完,rip 已经指向下一条指令了 */ regs.rip = bp->addr; ptrace(PTRACE_SETREGS, pid, 0, ®s); /* 3) 单步执行被替换掉的那条原指令 */ ptrace(PTRACE_SINGLESTEP, pid, 0, 0); int status; waitpid(pid, &status, 0); /* 4) 重新埋点,供下一次命中 */ bp_set(pid, bp); /* 5) 继续跑 */ ptrace(PTRACE_CONT, pid, 0, 0); }每一步省掉都会翻车,而且症状各不相同。省掉第 1 步,单步会立刻再撞上0xCC,看起来像「断点永远停不下来,一直在同一处打转」。省掉第 2 步,程序从断点的下一条指令开始执行,原来那条指令被跳过,如果它恰好是push rbp或sub rsp, N,栈帧就歪了,函数返回时直接SIGSEGV,而崩溃点离真正的原因隔了几十行。省掉第 4 步,断点只生效一次,第二次调用同一函数就再也停不下来。这个「还原—回退—单步—重埋—继续」的顺序不是设计出来的,是被这三类 bug 逼出来的。
4. 单步、寄存器与内存:把调试器做成能用的样子
4.1 PTRACE_SINGLESTEP:一次只走一条指令,代价是每次都进内核
PTRACE_SINGLESTEP的语义是「执行一条指令后停下来报告」。实现上内核会给被调试进程设置 x86 的 trap flag,CPU 每执行完一条指令就产生一次调试异常,内核再把这个异常转成SIGTRAP交给父进程。所以一次单步至少包含两次上下文切换,比正常执行慢三到四个数量级,在循环里连按单步会明显卡顿,这是机制决定的,不是代码写得烂。
/* 单步一条指令,返回停止原因:0=正常单步完成,其它=被信号打断 */ int step_once(pid_t pid) { if (ptrace(PTRACE_SINGLESTEP, pid, 0, 0) < 0) { perror("SINGLESTEP"); return -1; } int status; if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return -1; } if (WIFEXITED(status)) return -2; int sig = WSTOPSIG(status); if (sig != SIGTRAP) { /* 目标程序自己收到了信号 */ ptrace(PTRACE_CONT, pid, 0, sig); /* 投递出去,别吞 */ return sig; } return 0; }三个参数都是 0:pid 是目标进程,addr 和 data 在单步请求里
本文还有配套的精品资源,点击获取