简介:这是一份“简易调试器”实验指导PDF,面向计算机科学与技术专业的学生,目标是引导读者在NEMU模拟器中逐步实现单步执行、寄存器查看、内存扫描、表达式求值和监视点等调试功能,从而理解程序执行、寄存器状态与虚拟化原理。资源详细说明了三阶段任务:阶段1完成三个基础调试功能,阶段2实现表达式求值,阶段3加入监视点;同时明确评分占比(代码70%、报告30%,其中阶段1占50%、阶段2占30%、阶段3占10%、选做占10%),并强调了进入实验前的git分支整理要求。整套资料只有一个PDF文件,压缩包体积仅1.53MB,轻量便携,已有2172人学习使用。内容还深入介绍了NEMU作为一个简化的x86全系统模拟器,如何通过monitor、CPU、存储管理和设备四个模块模拟硬件,并通过运行Hello World与红白机模拟器的对比解释虚拟化思想,让读者在动手编写调试器的同时,掌握汇编调试、内存查看和工程规范等实践技能。适合作为计算机系统课程的实验参考,也可供自学调试器设计的开发者研读。
1. PA1 简易调试器:为什么我要自己动手写一个
第一次用某调试器时,我在断点列表里看到一长串符号,点下一步却发现程序直接飞了。那时候我才意识到,调试器这东西看起来是黑匣子,但它的核心机制并没那么神秘——无非是 ptrace 系统调用、int3 软中断和一张断点表。某高校的 PA1 实验要求写一个简易调试器,我一头扎进去,从零实现了断点、单步、寄存器查看和内存读写,最后发现它比我想象中更接近一个真正的调试器内核。
这个项目标题里的 PA1,通常指编程实践作业的第一阶段,目标是让你在操作系统层面对进程控制有一个"看得见摸得着"的理解。简易调试器不是玩具,它能跑真实的 ELF 可执行文件,能下断点、能单步、能读寄存器,对一个想搞懂底层调试原理的开发者来说,这比背十遍操作系统的进程模型都要来得扎实。适合谁?如果你想弄清楚调试器是怎么工作的,想自己动手复现一个 mini 版本,或者正在做类似的课程实践项目,这篇文章就是你需要的落地路线图。
2. 调试器的地基:ptrace 与进程控制流程
2.1 ptrace 原语:调试器的一切都从这里开始
简易调试器的底层依赖只有一个:ptrace 系统调用。这个东西是 Unix/Linux 下调试器的基石,gdb 追根究底也是通过它来工作的。ptrace 允许一个进程观察和控制另一个进程的执行,包括读写被调试进程的内存、寄存器,拦截信号,以及让被调试进程在系统调用边界停下来。
我第一版代码犯过一个典型错误:上来就调 PTRACE_ATTACH,结果子进程没反应。原因是顺序错了——如果要调试一个还没启动的进程,正确路径是用 fork 创建子进程,在子进程里先调 PTRACE_TRACEME,再执行 execve 加载目标程序。父进程这边用 waitpid 等待子进程停下来,之后才能发各种 PTRACE 命令。子进程一旦被 trace,任何信号(包括 execve 前的 SIGTRAP)都会先交给父进程处理。
// parent.c #include <sys/ptrace.h> #include <sys/wait.h> #include <unistd.h> int main(int argc, char** argv) { pid_t pid = fork(); if (pid == 0) { // 子进程:先 TRACEME,再 exec ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 声明自己被跟踪 execl("/bin/ls", "/bin/ls", "-l", NULL); // 加载目标程序 perror("execl"); return 1; } // 父进程:等待子进程停住 int status; waitpid(pid, &status, 0); // 此时子进程已停在 exec 入口,还没执行任何用户代码 ptrace(PTRACE_CONT, pid, NULL, NULL); // 让它继续跑 return 0; }PTRACE_TRACEME 必须在 exec 之前调用,这是子进程对内核的一次"登记":我允许父进程跟踪我。waitpid 返回后,子进程处于 stopped 状态,此时父进程可以对它执行 PEEKDATA、GETREGS 等操作。PTRACE_CONT 表示继续执行,但下一次遇到信号或断点时,子进程又会停下来通知父进程。
参数说明上有个容易混淆的点:ptrace 的第二个参数始终是被调试进程的 pid,第三个参数在很多命令里是 NULL,第四个参数才是数据。不同架构的 PTRACE_GETREGS 对应的结构体不一样,x86_64 下是 struct user_regs_struct,在 sys/user.h 里定义。如果你用的是 ARM64,字段名会变成 x0-x30,踩坑概率很高。
2.2 调试循环:waitpid 驱动的状态机
有了 ptrace 原语,下一步就是搭调试循环。调试器本质上是一个状态机:启动被调试进程 → 停下 → 用户输入命令 → 执行命令 → 继续 → 再停下。waitpid 的返回值告诉我们子进程发生了什么,状态宏帮我们区分事件类型。
核心是 WIFSTOPPED 和 WSTOPSIG 的组合判断。子进程停下来有两种常见原因:收到了 SIGTRAP(断点或单步完成),或者收到了其他信号。如果收到的是 SIGSEGV 这类信号,gdb 会提示段错误,我们也应该把信号转发给子进程,而不是默默吞掉。这里有一个新手经常搞错的细节:如果信号不是 SIGTRAP,直接 PTRACE_CONT 时要把信号编号传进去,否则子进程永远收不到这个信号。
// loop.c while (1) { int status; waitpid(pid, &status, 0); if (WIFEXITED(status)) { printf("child exited with %d\n", WEXITSTATUS(status)); break; } if (WIFSTOPPED(status)) { int sig = WSTOPSIG(status); if (sig == SIGTRAP) { handle_breakpoint(pid); // 断点触发,回报用户 } else { printf("received signal %d\n", sig); ptrace(PTRACE_CONT, pid, NULL, (void*)(long)sig); } } else if (WIFSIGNALED(status)) { printf("child killed by signal %d\n", WTERMSIG(status)); break; } }这个循环控制了整个调试器的生命线。每一轮 waitpid 返回,都意味着子进程跑到了一个"可观测"的位置。值得留意的是,WIFSTOPPED 只是说停了,不一定是断点——一次性断点(下面会讲)和单步完成也会产生 SIGTRAP。所以在处理 SIGTRAP 时要检查这个地址是不是我们注册过的断点位置,而不是无脑恢复。
2.3 从 fork 到 exec:被调试进程的启动陷阱
调试器启动被调试进程的方式不只有 fork+exec 一条路。常见做法里还有一种 PTRACE_ATTACH 附加到已经在运行的进程,这对简易调试器来说可以先不做,因为涉及权限和竞态。我这里说的启动路径,是指 fork 之后子进程 exec 之前,父子进程之间容易出问题。
一个真实翻车现场是:子进程在 ptrace(PTRACE_TRACEME) 之后,exec 之前,如果父进程的 waitpid 返回了,但子进程其实还没完成 exec 的地址空间重置,这个时候你想读它的寄存器,某些字段还是旧的。我遇到过 GETREGS 返回的 rip 指向一个奇怪的地址,查了半天发现是时机问题——waitpid 返回"exec 前停住"和"exec 后停住"在早期内核版本里语义有细微差别。解决方法是不要立刻读寄存器,先 PTRACE_CONT 一次,再等下个 SIGTRAP,那个 SIGTRAP 一定是 exec 完成后内核主动发的。
另外注意 fork 出来的子进程会继承父进程的信号处理设置。如果父进程屏蔽了某些信号,子进程 exec 后会带着屏蔽字,导致后续断点信号被内核延迟投递,调试器就傻了。我写了个 init_signals 函数,在 fork 前恢复 SIGTRAP 的默认处理,保证子进程环境干净。这个细节在 glibc 的某些版本里尤其重要。
3. 断点机制:int3 软中断与断点表管理
3.1 为什么断点要往指令里写 0xCC
断点的实现原理很粗暴:把目标地址处的机器码替换成 0xCC,也就是 int3 软中断指令。CPU 执行到 0xCC 时会触发一个 SIGTRAP,操作系统把它投递给被调试进程,ptrace 体系下父进程通过 waitpid 得知这个信号。但是问题来了——替换之后,子进程需要恢复原指令才能继续执行,那恢复之后原位置的指令已经被执行过了吗?
这里有个关键点:x86 上 int3 触发异常后,保存的 RIP 指向 int3 指令之后的那条指令。也就是说,如果我们把断点地址设为 A,触发断点时子进程的 RIP 是 A+1,而不是 A。所以恢复断点必须分两步:先把 RIP 改回 A,把 A 处的 0xCC 换回原指令,然后单步执行一次,再把 0xCC 写回去。这个"先恢复、再单步、再插断点"的三步操作,是调试器断点逻辑的核心。
3.2 第一次断点:手写 PTRACE_POKEDATA 完成插桩
直接看代码。我写的 insert_breakpoint 函数做了三件事:读原字节、写 0xCC、把原字节存在断点表里。
// breakpoint.c #define BP_SIZE 1 // int3 只占一个字节 typedef struct { unsigned long addr; unsigned char orig_byte; int active; } Breakpoint; int insert_breakpoint(pid_t pid, Breakpoint* bp, unsigned long addr) { errno = 0; long orig = ptrace(PTRACE_PEEKDATA, pid, (void*)addr, NULL); if (orig == -1 && errno != 0) { perror("PEEKDATA"); return -1; } bp->addr = addr; bp->orig_byte = orig & 0xff; // 只保留低字节,原样保存 ptrace(PTRACE_POKEDATA, pid, (void*)addr, (void*)((orig & ~0xff) | 0xcc)); bp->active = 1; return 0; }逻辑说明:PEEKDATA 一次读 8 字节(x86_64),所以我要把低字节替换成 0xcc,高位保持不动。这样写回时只需要改低字节,不影响相邻指令。orig & ~0xff 里 ~0xff 是掩码,清掉低字节后按位或 0xcc 得到新值。如果目标地址在字符串常量区或只读段,POKEDATA 会返回 EIO,原因下面避坑章会讲。
参数要点:PEEKDATA 的第三个参数是地址,必须按字节对齐吗?实际上 x86_64 下 PEEKDATA 要求 8 字节对齐,如果你断在一条不是 8 字节边界的指令上,读取会失败。这是插图指令里的经典坑,大多数教材里的调试器实现都忽略了这一点。我自己写的时候选择用 process_vm_readv 替代 PEEKDATA 来读任意地址,但这属于进阶内容,第一版先绕开对齐问题,只对 8 字节对齐的地址下断点。
3.3 恢复与单步:三步走的关键时刻
命中断点后,子进程停在断点地址的下一条指令。处理流程是:判断当前 RIP-1 是否在断点表里,如果在,就把 RIP 改成断点地址,恢复原字节,然后 PTRACE_SINGLESTEP 执行一条指令,再插回断点。这里容易出问题的是:为什么 RIP-1 而不是 RIP。int3 异常时 CPU 会把 RIP 设置为"异常指令的下一条",而 int3 长度是 1,所以 RIP-1 就是断点地址。
// resume.c void handle_bp_hit(pid_t pid, Breakpoint* bps, int bp_count) { struct user_regs_struct regs; ptrace(PTRACE_GETREGS, pid, NULL, ®s); unsigned long hit_addr = regs.rip - 1; // 查表:这个地址是不是我们下的断点 for (int i = 0; i < bp_count; i++) { if (bps[i].active && bps[i].addr == hit_addr) { regs.rip = bps[i].addr; // 回退 RIP ptrace(PTRACE_SETREGS, pid, NULL, ®s); // 恢复原字节 ptrace(PTRACE_POKEDATA, pid, (void*)bps[i].addr, (void*)(bps[i].orig_byte)); bps[i].active = 0; // 临时失活 // 单步执行原指令 ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL); waitpid(pid, NULL, 0); // 再插回断点 insert_breakpoint(pid, &bps[i], bps[i].addr); break; } } }这个流程就是断点恢复的标准做法。第一次写断点恢复时,我省掉了单步这一步,直接 PTRACE_CONT,结果断点被反复触发死循环。原因很直接:如果 RIP 已经指向 A+1,原字节又恢复了,但你继续执行,不会再次执行 A 处的指令,也不会再触发 int3。但如果你把 RIP 改回 A 后不单步就恢复 0xCC,那 CPU 又会执行一次 int3,永无止境。所以"恢复原字节 + 单步执行原指令 + 重插断点"缺一不可。
3.4 硬件断点和软件断点的取舍
简易调试器用软件断点就够了,但我还是想提一嘴硬件断点,因为很多人会问。x86 有 4 个调试寄存器 DR0-DR3,可以设置硬件断点,不需要修改指令,对只读代码段、被校验的代码特别有用。但 DR 寄存器在 ptrace 里要通过 PTRACE_POKEUSER 修改,操作复杂,而且架构相关,移植性差。
我自己的经验是:软件断点实现快、逻辑透明,适合学习和实验;如果做的是一个要防篡改的调试器,再用硬件断点。软件断点还有个额外好处:你可以在断点处触发自定义回调,比如记录调用栈、打印变量,这比硬件断点灵活得多。硬件断点不能直接插入回调,必须配合 PTRACE_CONT 手动检查,麻烦许多。
4. 单步执行与寄存器/内存访问:让调试器开口说话
4.1 单步的三种实现:trap flag 与 PTRACE_SINGLESTEP
单步执行最常见的方式是 PTRACE_SINGLESTEP,让内核替我们设置 trap flag(EFLAGS 的 TF 位),执行一条指令后触发单步异常。但理解底层原理很有必要:trap flag 是 x86 的机制,TF=1 时 CPU 在每条指令执行完后都产生一次 #DB 异常,调试器正是靠它实现单步。和断点的 SIGTRAP 一样,单步停止后 waitpid 也返回 SIGTRAP,所以处理逻辑要区分"这是断点触发的 SIGTRAP"还是"这是单步完成的 SIGTRAP"。
我见过一些人直接 PTRACE_GETREGS 改 EFLAGS 的 TF 位,然后 PTRACE_CONT 来实现单步,这也能用,但不如 SINGLESTEP 干净——SINGLESTEP 会保证 tf 位在被调试进程重新执行前设置,不会出现竞态。第一版调试器我都建议用 PTRACE_SINGLESTEP 单步,把自定义 trap flag 当作进阶优化。
// step.c void step_one(pid_t pid) { ptrace(PTRACE_SINGLESTEP, pid, NULL, NULL); waitpid(pid, NULL, 0); print_current_location(pid); // 打印当前 RIP 与所在函数 }这里 print_current_location 只是一个示意函数,实际实现需要读寄存器、解析符号表。注意:每次单步后都要 waitpid 等它停下来,否则你继续发 ptrace 命令会给一个正在运行的进程,内核会返 ESRCH。很多第一次写的人在这里翻车,认为 ptrace 调用后不需要同步,结果第二次单步前进程还没停,白白收到一个错误。
4.2 寄存器读取:GETREGS 是一把万能钥匙
寄存器是调试器展示信息的基础。x86_64 下用 PTRACE_GETREGS 一次性拿回所有通用寄存器;如果想读 fs/gs 这类段寄存器或 debug 寄存器,需要额外命令。我一般用一个 dump_regs 函数把它们格式化输出,重点展示 rax、rbx、rcx、rdx、rsi、rdi、rbp、rsp、rip、eflags 这十几个常用字段。
// regs.c void dump_regs(pid_t pid) { struct user_regs_struct regs; if (ptrace(PTRACE_GETREGS, pid, NULL, ®s) == -1) { perror("GETREGS"); return; } printf("rax=%016lx rbx=%016lx rcx=%016lx\n", regs.rax, regs.rbx, regs.rcx); printf("rdx=%016lx rsi=%016lx rdi=%016lx\n", regs.rdx, regs.rsi, regs.rdi); printf("rbp=%016lx rsp=%016lx rip=%016lx\n", regs.rbp, regs.rsp, regs.rip); printf("eflags=%016lx\n", regs.eflags); }逻辑说明很简单,但有一个值得注意:ptrace 读寄存器返回的是调用时刻的快照。如果你在 PTRACE_CONT 之后不 waitpid 直接 GETREGS,拿到的是子进程在某条指令中间的状态,寄存器值可能是脏的。所以读寄存器之前先确保子进程处于 stopped 状态。
参数说明:GETREGS 的第四个参数是 struct user_regs_struct 的指针。这个结构体在不同架构下字段不同,如果你是 arm64,要改用 PTRACE_GETREGSET,因为这个架构的 ptrace 接口更通用。做跨架构支持时要提前用宏判断,别写死 x86_64。
4.3 内存读写:从 PEEKDATA 到 process_vm_readv
内存读写有两条路:ptrace 的 PEEKDATA/POKEDATA 只能按机器字长访问,且要求对齐;而 process_vm_readv/process_vm_writev 是 Linux 提供的批量接口,可以读任意长度任意地址。简易调试器第一版用 PEEKDATA 就够,但我想把 process_vm_readv 也讲明白,因为它是真正的实用工具。
// mem.c void read_mem(pid_t pid, unsigned long addr, unsigned char* buf, size_t len) { struct iovec local = { buf, len }; struct iovec remote = { (void*)addr, len }; ssize_t n = process_vm_readv(pid, &local, 1, &remote, 1, 0); if (n < 0) { perror("process_vm_readv"); return; } }process_vm_readv 的优势在于不需要对齐,一次读一大块也没问题。它有一个隐患:如果被调试进程有部分页面不在内存中(比如被 swap 出去了),readv 会返回 EFAULT,但 ptrace 的 PEEKDATA 在内核里会自动处理缺页,所以两者行为不完全等价。我的习惯是:读少量关键数据用 PEEKDATA,读大块内存比如栈回溯、字符串、结构体时用 process_vm_readv。
4.4 内存断点的替代方案:watch 点
内存断点也叫 watchpoint,是硬件断点的一种。你可以对某个地址设置读或写监视,当程序访问该地址时触发中断。简易调试器里我一般先把内存读写命令做出来,watch 点留到后面。因为它的实现依赖硬件 debug 寄存器,而且一旦触发,程序会停在访问该地址的那条指令之后,处理逻辑比普通断点复杂。
实际调试场景里,watch 点更多用来查"谁改了这个变量"。在 gdb 里就是 watch 命令,底层是 DR 寄存器。如果你要把简易调试器做到能查野指针、能定位 struct 被清空的问题,watch 点是绕不开的。但第一版建议跳过,先抓稳软件断点和单步,跑通完整流程后再上硬件机制。
5. 避坑指南:简易调试器最常见的 5 个翻车现场
5.1 断点恢复后 RIP 没回退,导致死循环
现象:程序命中断点后,你恢复了原字节并 PTRACE_CONT,结果同一个断点立刻又触发,或者干脆卡死不动。原因:你忘了把 RIP 回退到断点地址。int3 触发时 RIP 保存的是断点后一条指令的地址,如果不回退,程序恢复执行时不会再次执行原始指令,你的断点逻辑落空。解决:在恢复字节前先 GE TREGS 检查 rip,确认命中后手动改 rip 为断点地址,并用 SETREGS 写回。
这个坑我踩过不止一次,尤其是在写多断点处理逻辑时容易忽略。回退 RIP 之后还要注意:如果断点设在函数入口,回退后单步,单步执行的是原始指令,单步结束后 RIP 会正常指向下一条指令,这时重插断点,后续调用还能命中同一个入口。一个小提示:单步完成后 waitpid 的 status 也是 SIGTRAP,判断"是单步完成还是新断点"要看当前 RIP 是否等于某个断点地址加偏移,逻辑别写混了。
5.2 PTRACE_PEEKDATA 对齐问题
现象:读某些地址,比如函数中间的一个字节,ptrace 返回 EIO 或读到错误数据。原因:PEEKDATA 要求在 x86_64 下按 8 字节对齐读取,不对齐会失败。这是内核实现的历史包袱,不是 bug。解决:读地址时先 addr & ~7 对齐到低 8 字节边界,然后从返回的 8 字节里手动取出目标位置的字节,同时要处理跨边界的情况,比如目标地址低字节值已经超出对齐块。
我用一个 read_byte 封装函数,内部读对齐块再移位,彻底屏蔽这个问题。写的时候还要注意字节序,x86_64 是小端,目标字节在对齐块里的偏移就是 addr & 7,不需要再做字节序转换。如果你在内存里读到的数据和文件里的二进制对不上,先检查这个对齐问题。
5.3 信号被吞:子进程收不到 SIGSEGV
现象:被调试程序访问了非法地址,gdb 能停在段错误位置,你的调试器却直接让程序退出了。原因:WIFSTOPPED 后你处理完 SIGTRAP 就 PTRACE_CONT 不传信号,非 SIGTRAP 信号也被你吞了。子进程永远收不到 SIGSEGV,只能默默终止。解决:判断 WSTOPSIG 返回的信号如果不是 SIGTRAP,就在 PTRACE_CONT 的第四个参数传回这个信号编号,让内核把信号重新投递给子进程。
这背后是 ptrace 的信号转发机制:被调试进程收到的信号会先被父进程截获,父进程决定如何处理。如果你不转发,等于每次 SIGSEGV 都被父进程直接丢弃,子进程永远不知道自己崩了。gdb 里你看到的"Program received signal SIGSEGV"就是这么来的,它选择停住并通知你,而不是直接转发。
5.4 符号地址偏移:PIE 程序断点错位
现象:你对一个函数名下断点,打断点显示成功,但程序运行到该函数时却根本没停;或者停在了明显不对的位置。原因:现代 Linux 发行版默认开启 PIE,可执行文件的符号地址是相对基址的偏移,真实运行地址 = 基址 + 偏移。你直接拿 ELF 里的符号地址当真实地址用,当然对不上。解决:读取 /proc/pid/maps 找到可执行文件的加载基址,符号断点地址 = 符号值 + 基址。
这个坑在新手调试器里可以说是最隐蔽的。gdb 自动帮你处理了,你感受不到,但自己实现时跑一遍就露馅。记得在被调试进程 exec 完成后立刻读 maps 记录基址,因为动态链接器可能改变布局。如果你断的是 libc 里的函数,那还得算上共享库的基址,复杂度上一个台阶,第一版先只支持可执行文件主模块。
5.5 多线程程序断点失控
现象:被调试程序是多线程的,设一个断点后,所有线程都往断点撞,调试器输出乱成一团。原因:Linux 的 ptrace 默认跟踪的是单个线程,多线程下每个线程都是独立任务,断点触发会发生在任意一个线程上。解决:如果你只关心主线程,可以使用 PTRACE_SETOPTIONS 设置 PTRACE_O_TRACECLONE,让新线程也自动被跟踪,然后对所有线程的停止事件做过滤;更简单的做法是先用 pthread 库写一个只跑单线程的测试程序,把调试器功能先验证完,再考虑多线程。
6. 进阶技巧:让简易调试器处理动态库与条件断点
走到这一步,你的调试器已经能完成基本工作了。真正让它从"作业"变成"工具"的,是三个实用技巧:动态库断点、条件断点和断点计数。这三个技巧直接解决实际调试里的高频需求,而且实现难度都不大。
动态库断点要先处理一个时序问题。库被加载到进程空间后,你才能对库里的符号下断点。常见做法是用 LD_PRELOAD 在程序启动前加载一个包含构造函数的小 so,在构造函数里用 dlsym 找到目标函数地址,然后把这个地址写到一个约定好的共享内存区域;调试器等子进程 exec 完成后轮询共享内存,一旦地址到了就插入断点。另一种更简单的方式是直接读 /proc/pid/maps 里的库基址,加上符号表中的偏移,两步算出真实地址。后一种方案不需要侵入被调试程序,推荐优先尝试。
条件断点的实现思路是:命中断点后先不立即停下,而是读取指定寄存器的值,判断条件是否满足;不满足就单步恢复继续执行,满足才把控制权交回用户。看起来简单,但要注意一个坑:你的判断代码必须在恢复原指令之前完成,不然断点已经被重插,你再读寄存器时值已经不是"命中瞬间"的值了。我写的条件断点实现里保留了断点命中瞬间的一份寄存器快照,每次命中先 GETREGS 存到断点结构体里,判断时直接用快照数据。这样即使你后面做了多次恢复和单步,条件判断依然能基于命中时刻的真实状态。
断点计数是个被低估的功能。调试循环里遇到一个热路径,你不知道它被命中多少次,计数可以直接告诉你。我习惯在断点表里加一个 counter 字段,命中断点时自增,然后有条件地打印到屏幕上。假如你要查一个循环执行了几次,直接在循环体末尾下断点,跑完看计数,比 log 还准确,因为它不会影响程序执行顺序。
我自己最后悔的一件事,是没有早点做"恢复所有断点"的功能。调试器跑到一半想退出或者重启被调试程序,手动一个一个删断点属实折磨。后来我加了一个 delete_all,统一遍历断点表恢复原字节,顺手把计数器清零。这种小功能看起来不打眼,却让调试器真正能被人日常使用。一次调试循环崩溃导致断点表状态和子进程内存不一致后,我彻底明白了:断点表不是只增不减的静态数组,它就是调试器的核心数据结构,对它的每一次修改,都要考虑到崩溃后的恢复路径。
如果你正卡在某个断点死循环里,别急着怀疑自己写的代码,先用 read_byte 看看断点地址的机器码是不是 0xcc,如果已经不是了,说明你的断点表管理和内存状态脱节了,把恢复逻辑重新捋一遍。希望这篇 PA1 简易调试器的拆解,能帮你少走几段弯路。
本文还有配套的精品资源,点击获取