
简介《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_codeTRAP_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_DYNPIE里它是文件内偏移必须加装载基址。把这两种情况混在一起处理是新手第一大坑加了基址反而全错。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, regs); /* 1) 还原原始字节否则下一步单步又会撞上 0xCC */ poke(pid, bp-addr, bp-orig); /* 2) rip 回退int3 执行完rip 已经指向下一条指令了 */ regs.rip bp-addr; ptrace(PTRACE_SETREGS, pid, 0, regs); /* 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 flagCPU 每执行完一条指令就产生一次调试异常内核再把这个异常转成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; }三个参数都是 0pid 是目标进程addr 和 data 在单步请求里本文还有配套的精品资源点击获取