PA1简易调试器实战:寄存器、表达式求值与监视点 简介这份PDF是计算机系统课程PA1“简易调试器”的实验指导讲义面向正在学习NEMU模拟器、需要完成调试器基础功能的高校学生与自学者。内容围绕单步执行、打印寄存器状态、扫描内存三项调试能力展开并延伸到表达式求值与监视点等进阶任务同时交代代码实现占70%、实验报告占30%的评分构成以及实验前使用git整理分支的版本管理要求。压缩包仅含1个PDF文件大小约1.53MB便于在本地阅读和随实验进度查阅。讲义还通过GNU/Linux、红白机模拟器与NEMU运行Hello World的层次对比解释x86-32子集模拟与虚拟化概念并给出阅读源码、关注当前模块和“未测试代码永远是错的”等实践提示。已有2183人学习适合需要厘清三阶段任务边界、对照评分权重安排进度、补齐模拟器与调试器知识的学习者参考。1. 先给 git 留一条干净分支再动手写 PA1 简易调试器拿到 PA1 简易调试器的框架代码我第一件事不是打开源文件而是在工程目录里敲两行 gitgit commit --allow-empty -am before starting PA1和git checkout -b PA1。原因很实在——后面要在这套 x86 模拟器里改寄存器结构体、补命令、写表达式求值加起来约 400 行、30 小时的量任何一次改错都需要一个能立刻回到“动手前”的后悔药。这条空提交配合新分支就是最廉价的那颗。这套模拟器框架里monitor 模块下那个调试器是本次的主角单步执行、打印寄存器、扫描内存、表达式求值、监视点三个阶段层层递进代码实现占七成、报告占三成。它适合刚学完程序设计、想真正看懂“程序是怎么被一条条执行”的人也适合想补一段调试器开发经历的人。下面按框架、三个阶段的实现、踩坑、回归验证的顺序拆开讲。2. 拆开 monitor 与寄存器结构体先理清一条命令的执行链路读框架代码最容易犯的错是从第一个文件开始顺序往下读读到modrm.c那种硬件细节就卡死。我的建议是先锁定一条链路从命令输入到指令执行把这条线上的函数认全其余的先当黑匣子。这条链路走通了后面补功能就是往固定的插槽里塞代码。2.1 从 ui_mainloop 到 cpu_exec 的完整链路初始化阶段的重点只有三个入口。init_monitor()负责打开日志、读入 ELF 符号表、编译正则表达式、初始化监视点池这些细节现阶段完全不用懂看到Log()里打出来的信息说明它跑通了就行。接着是restart()它模拟“计算机启动”初始化 ramdisk、读入 entry、把%eip设成0x100000。这里的简化值得留意——真实机器上电后内存是随机的要先跑 ROM 里的固件再把程序从磁盘搬进内存框架直接让 monitor 把一个有意义的程序塞到固定地址省掉了这段。启动完成后进入ui_mainloop()命令表cmd_table就在nemu/src/monitor/debug/ui.c里每一项是“命令名 处理函数 帮助文本”的三元组。readline()负责读一行strtok()负责切参数匹配到哪一项就调哪个函数。键入c之后进入cpu_exec()那是个大循环exec()取一条指令、执行、%eip前进循环到次数用尽或者撞上nemu_trap机器码0xd6x86 本身没有这条指令纯粹是为了标记程序结束而加的才退出。搞清楚这条链路之后你会明白本次三个必做任务其实都挂在同一个位置命令处理函数。si是给cpu_exec传不同参数info r是读cpu这个全局结构体x是调swaddr_read()。它们不碰指令译码也不碰内存映射所以难度集中在“参数怎么解析”和“输出格式怎么定”而不是“模拟器怎么模拟”。还有一点值得提前知道opcode_table这个数组决定了框架当前支持哪些指令inv表示 invalid即尚未实现。做 PA1 的时候只需要 mov 能跑你不用动它但等你后面加指令就是在这个表里逐项替换。现在扫一眼知道它长什么样就够了不用深究exec()内部那一堆make_EHelper的宏。2.2 寄存器结构体匿名 struct 加 union 不是炫技nemu/include/cpu/reg.h里的CPU_state是第一个必做任务也是唯一一个“写错了立刻 assert fail”的任务。要求很明确EAX 的低 16 位是 AXAX 又分 AH 和 AL物理上是同一块 4 字节只是访问宽度不同。用普通成员变量拼是拼不出来的必须让它们共享内存。typedef struct { union { union { uint32_t _32; uint16_t _16; uint8_t _8[2]; } gpr[8]; struct { uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; }; uint32_t eip; } CPU_state;逻辑说明外层匿名 union 让gpr[8]和那八个具名成员共享同一块 32 字节写cpu.eax和写cpu.gpr[0]._32是同一件事内层匿名 union 让_32、_16、_8[2]共享同一 4 字节于是cpu.gpr[0]._16天然就是 AX。匿名的好处是不用多写一层.gpr之类的中间名寄存器名可以直接当成员用。参数与边界说明_8[2]的顺序不能反。x86 是小端低地址存低字节所以_8[0]是 AL、_8[1]是 AH。另外这八个成员的声明顺序要和reg_test()的预期一致常见做法是按 eax、ecx、edx、ebx、esp、ebp、esi、edi 排因为reg_test()会拿随机数按顺序写进去再按名字读回来比对顺序错了就是一片 assertion fail。我自己在这块翻过一次车一开始把_8写成uint8_t _8;配位域编译过了测试也过了但等到后面指令实现需要按字节寻址时全乱套。血泪经验是——别在这里图省事union 嵌套是最贴合硬件语义的写法reg_test()通过不代表你的实现真的对它只覆盖了它测的那部分。2.3 用 git 把大改动切成小步框架给了 6000 多行代码你会在里面反复改。我一般每完成一个可验证的小功能就提交一次提交信息写清楚“改了什么、验证方法是什么”比如si: parse N and call cpu_exec。这样做的好处不只是留档等你某天发现扫描内存的输出不对git diff一眼就能看出这次改动碰了哪些文件而不是对着整份工程猜。更实用的是回滚粒度。表达式求值那一块正则规则改错一次就可能让整个 NEMU 在初始化时 assert fail连命令行都进不去。这时候git checkout -- nemu/src/monitor/debug/expr.c比逐行删改快得多。养成“改之前先提交、改之后先跑一遍 mov 程序”的节奏比任何调试技巧都管用。3. 阶段一落地si、info r、x 三条命令怎么写才不出错阶段一占了代码分的五成但它的技术难度其实是三个阶段里最低的翻车基本都翻在参数解析和输出格式上而不是模拟器原理上。这一章按命令逐条拆每条给出可以直接抄的骨架再说清楚参数为什么这么定。3.1 si N把参数正确接进 cpu_exec单步执行的本质是“让 CPU 跑 N 条指令然后停下来”。cpu_exec()的参数是循环次数传 1 就是单步传 10 就是跑十条。麻烦在于si后面可以不跟数字这时缺省为 1跟了数字还要考虑非法输入。static int cmd_si(char *args) { char *arg strtok(NULL, ); // 取 si 之后的第一个参数 uint32_t n 1; // 缺省单步一条 if (arg ! NULL) { char *end NULL; unsigned long v strtoul(arg, end, 10); if (end arg || *end ! \0 || v 0) { printf(Usage: si [N], N 0\n); return 0; } n (uint32_t)v; } cpu_exec(n); return 0; }逻辑说明strtok(NULL, )是在readline已经切过第一段的前提下继续切所以拿到的是si后面的内容。strtoul的第二个参数end用来判断“整段是不是合法数字”end arg表示一个字符都没读进去*end ! \0表示后面还有杂字符这两种都算非法。参数说明cpu_exec的参数类型是uint32_t所以cmd_c里传-1实际是0xFFFFFFFF等于“一直跑到程序自己结束”。这个细节在思考题里问过——-1不是无限循环的魔法值而是类型转换后的一个大数真正指示结束的是nemu_trap指令把状态置成了结束态。理解这一点你才知道为什么c之后程序能自己停下来。一个容易忽略的点si执行完之后%eip是往前走了的连续调两次si和调一次si 2结果一样。如果你发现单步之后寄存器没变先确认exec()有没有真的执行到日志文件log.txt里每条指令都有记录对着看最快。3.2 info r输出什么、按什么顺序输出打印寄存器没有技术含量坑全在“输出得像不像一个调试器”。GDB 的输出是eax 0x00000000 0这种形式十六进制和十进制并排宽度对齐。你照着做就行格式不作硬性规定但可读性会直接影响你后面调试的效率。static void print_regs(void) { const char *names[] {eax,ecx,edx,ebx,esp,ebp,esi,edi}; for (int i 0; i 8; i) { uint32_t v cpu.gpr[i]._32; printf(%-4s 0x%08x %u\n, names[i], v, v); } printf(%-4s 0x%08x %u\n, eip, cpu.eip, cpu.eip); }逻辑说明%-4s左对齐补空格保证四列宽度一致0x%08x补足八位十六进制方便你一眼看出高位是不是被意外清零再跟一个无符号十进制是为了核对有符号运算的结果。参数说明info是带子命令的r打寄存器、w打监视点信息阶段三才用得上所以处理函数里要先切出子命令再分派。子命令为空时打印一行用法提示别让它静默失败——调试器最忌讳的就是“敲了没反应”你会以为是命令没注册。这里有个小习惯值得养成打印顺序固定成 eax 到 edi 再 eip和CPU_state的声明顺序一致。后面你写监视点、写表达式求值里的寄存器取值都能复用同一张名字表改一处就够。3.3 x N EXPR先做只认十六进制数的简化版扫描内存的完整形态是x 10 $esp起点是个表达式。但阶段一做的时候你还没有表达式求值器所以框架建议先做简化版规定 EXPR 只能是一个十六进制数比如x 10 0x100000。这个简化非常值——它让你在阶段一就能验证内存里的指令对不对而不必等阶段二。static int cmd_x(char *args) { char *p args; // args 形如 10 0x100000 if (p NULL) { printf(Usage: x N EXPR\n); return 0; } int n (int)strtol(p, p, 10); // 切出 Np 停在 N 之后 while (*p ) p; // 跳过中间空格 if (n 0 || *p \0) { printf(Usage: x N EXPR\n); return 0; } uint32_t addr (uint32_t)strtoul(p, NULL, 16); // 简化版只认十六进制 for (int i 0; i n; i) { uint32_t cur addr i * 4; printf(0x%08x: 0x%08x\n, cur, swaddr_read(cur, 4)); } return 0; }逻辑说明没有用两次strtok是因为表达式里可能带空格阶段二就会遇到$eax 1strtok会把表达式切成两半。用手动指针推进更稳先strtol吃数字指针自然停在空格处跳过空格后剩下的整段都是表达式。这个写法在阶段二换成expr()时一行都不用改结构。参数说明swaddr_read(addr, 4)的第二个参数是访问宽度不能省模拟器要靠它决定读几个字节、走哪条地址翻译路径。输出按 4 字节对齐每行前缀是这一行的起始地址这是 GDBx/10xw的惯例照着做后面看日志会舒服很多。模拟内存的读写一律走swaddr_read/swaddr_write不要直接解引用指针否则等 PA3 引入地址空间概念时你要回来重写一遍。3.4 拿 objdump 结果做对照验证扫描内存写对没有最直接的办法是对着用户程序的反汇编看。框架自带的测试程序是 mov 指令组成的工程里obj/testcase/mov.txt就是它的 dump 结果。x 10 0x100000打出来的头几条应该和 dump 里0x100000起始的机器码逐字节对得上。如果对不上排查顺序是这样的先确认restart()有没有把 entry 读进去日志里会有提示再确认你解析的起始地址是不是被strtoul的进制参数坑了十进制解析0x100000会得到 0最后才怀疑swaddr_read的宽度参数。绝大多数“内存内容不对”的问题其实出在地址解析上而不是内存模拟上。4. 表达式求值与监视点一套求值器撑起 p、x、w 三条命令阶段二和阶段三是一条线先把字符串切成 token再递归求值最后把求值器接到监视点上让它在每条指令执行后重新算一遍、比对旧值。这条线做扎实了p、x、w三条命令其实是同一个引擎的不同出口。4.1 词法分析正则规则的顺序比数量更重要make_token()的工作方式很朴素一个位置指针从字符串头走到尾每次拿当前位置去依次尝试所有正则规则哪条匹配且匹配起点正好在当前位置就切出一个 token。规则表在expr.c里框架给了和空格串两条其余要你加。static struct rule { char *regex; int token_type; } rules[] { { , NOTYPE}, {0[xX][0-9a-fA-F], NUM}, {[0-9], NUM}, {\\$[a-zA-Z], REG}, {\\, }, {\\-, -}, {\\*, *}, {/, /}, {\\(, (}, {\\), )}, };逻辑说明数组顺序就是匹配优先级靠前的先试。十六进制那条必须排在十进制前面否则0xc0100000会被[0-9]先吃掉一个0剩下的xc0100000直接匹配失败报“no match”。这是我自己踩过的坑报错信息看起来像正则写错实际是顺序写反。参数说明C 字符串里的反斜杠要双写所以正则里的\在代码里是\\。元字符必须转义的有 * ( ) . [ ] { } |和\而/和-在方括号外不算元字符但-放在方括号里表示范围为保险也一并转义成\\-。空格串的 token 类型是NOTYPE识别出来后直接把位置往前推、丢掉不进 tokens 数组。4.2 Token 记录与缓冲区边界Token结构体有两个成员type记类型str记原始子串。类型够用的原因是 - * /这些运算符的值就是它自己但数字和寄存器名光有类型不够求值的时候得知道“是哪个数”“是哪个寄存器”所以子串必须留下来。#define MAX_TOKEN_LEN 32 strncpy(tokens[nr_token].str, substr_start, substr_len); tokens[nr_token].str[substr_len] \0; tokens[nr_token].type rules[i].token_type; nr_token;逻辑说明strncpy不会自动补结尾的\0必须手动补否则打印出来是一串乱码这种 bug 最难查因为它在大部分输入下都正常。写之前加一句Assert(substr_len MAX_TOKEN_LEN, token too long)把越界挡在写入之前。参数说明substr_len来自pmatch.rm_eo是匹配串的长度不是结束位置别和rm_so搞混。KISS 法则在这里的用法很直接——缓冲区不够就assert(0)终止不要写一段“截断并继续”的代码去掩盖问题表达式求值的正确性比“优雅处理超长输入”重要得多后者等你把核心功能测通了再说。还有一处细节识别成功之后position substr_len但空格串那条规则也是要走这一步的只是不进数组。如果漏了推进程序会在原地打转日志刷屏却切不出新 token。4.3 递归求值括号判断与主运算符定位token 切好之后求值就是对tokens[p..q]这个区间的递归。思路来自表达式的 BNF 定义长表达式由短表达式组成那就先求短再求长。三个函数配合——eval、check_parentheses、find_dominant_op。uint32_t eval(int p, int q, bool *success) { if (p q) { *success false; return 0; } if (p q) { // 单个 token if (tokens[p].type NUM) return strtoul(tokens[p].str, NULL, 0); if (tokens[p].type REG) return reg_read(tokens[p].str); *success false; return 0; } if (check_parentheses(p, q)) return eval(p 1, q - 1, success); int op find_dominant_op(p, q); // 优先级最低且在括号外的运算符 if (op 0) { *success false; return 0; } uint32_t v1 eval(p, op - 1, success); uint32_t v2 eval(op 1, q, success); switch (tokens[op].type) { case : return v1 v2; case -: return v1 - v2; case *: return v1 * v2; case /: if (v2 0) { *success false; return 0; } return v1 / v2; default: *success false; return 0; } }逻辑说明check_parentheses判断整段是否被一对“互相匹配”的括号包住——首字符是(、尾字符是)且从头扫到尾时计数器不会在中途归零。这一点很关键(12)*(34)首尾也是括号但它们不是一对不能直接剥。find_dominant_op从右往左扫跳过括号内部先找和-找不到再找*和/。从右往左是为了让左结合性天然成立1-2-3应该先算1-2主运算符是第二个-取最右边那个就对了。如果从左往右找1-2-3会被解析成1-(2-3)结果差得离谱。参数说明success是出参一次解析失败就置 false 往外抛调用方检查后打印错误。不要用全局变量传递错误状态递归里很容易被内层覆盖。另外strtoul的进制传 0让它自己识别0x前缀比固定传 16 更省事。4.4 监视点把求值器复用成一条比较指令监视点的语义是“某个表达式的值变了就停下来”。实现上要三样东西一个池子、两条链表、一个检查时机。池子在init_monitor阶段就初始化为定长的 32 个WPhead挂正在用的free_挂空闲的。typedef struct watchpoint { int NO; struct watchpoint *next; char expr[128]; uint32_t old_value; } WP; void check_watchpoints(void) { for (WP *w head; w ! NULL; w w-next) { bool ok true; uint32_t now expr(w-expr, ok); if (!ok) continue; // 表达式暂时无法求值跳过 if (now ! w-old_value) { printf(Watchpoint %d: %s\n, w-NO, w-expr); printf(Old value %u\nNew value %u\n, w-old_value, now); w-old_value now; // 必须更新否则每条指令都触发 } } }逻辑说明w EXPR注册时求一次值存进old_valuecheck_watchpoints放在cpu_exec的指令循环里、exec()之后调用。为什么放这里而不是放si里——因为c连续运行时也要能触发只挂在si上等于只有单步才生效。参数说明d N删除时按NO找节点从head摘下、挂回free_注意把next清空不然空闲链表会成环。NO建议用池子下标而不是自增计数器删了再加不会撞号调试报告里也好写。表达式字符串要拷贝一份存起来不要存args的指针readline的缓冲区会在下一次输入时被覆盖。这里有个设计上的取舍check_watchpoints里对求值失败直接continue不报错。原因是监视点的表达式可能依赖某个此刻还没被写入的寄存器或内存属于正常情况。但如果你发现监视点从来不触发先单独敲一次p EXPR看能不能算出来这比盯着监视点代码看快。5. 避坑与排查五条血泪记录每条都能让你多熬一夜这一章集中写“现象 → 原因 → 解决”都是这套框架下真实会撞上的问题。有些看起来很低级但代价一点不低——调试公理说得很直白未测试的代码永远是错的而写错的调试器会让你在后续实验里怀疑人生。5.1 命令解析与词法分析上的三个坑现象一x 10 $eax 1只扫出了一个地址后面报用法错误。原因是用两次strtok(NULL, )取参数表达式里的空格把后半段切没了。解决是把参数当成一整段处理先strtol吃掉数字指针推进后跳过空格剩下全部交给expr()。这个改动在阶段一和阶段二之间是兼容的早改早省事。现象二启动 NEMU 直接 assert fail提示正则编译失败命令行都进不去。原因是规则表里的转义写漏了比如把\\写成。在正则里是“前一项重复一次以上”单独出现就是语法错误。解决是逐条核对元字符 * ( ) . [ ] { } | \全部双写-在方括号外虽不是元字符也建议一并转义。现象三p 0xc0100000求值结果是 0或者报 no match。原因是十六进制规则和十进制规则的顺序反了[0-9]先匹配走了开头的0。解决是把0[xX][0-9a-fA-F]排在[0-9]前面。判断方法很简单故意输入一个0x开头的数如果只在第二个字符处开始报错就是顺序问题。5.2 输出与内存访问上的两个坑现象四info r输出全是 0但si之后%eip明显在变。原因通常是结构体成员顺序和reg_test预期不一致测试没通过就跳过了后续初始化或者是打印时用了%d打无符号值高位大数显示成负数被误判成“没写进去”。解决是先确认启动时没有 assertion fail再把格式统一成0x%08x加%u双列输出一眼就能看出到底是值错还是格式错。现象五扫描内存能跑但内容和 objdump 对不上差了几个字节。原因一般不在swaddr_read而在起始地址算错——比如把0x100000用十进制解析成 100000或者循环里的偏移写成i而不是i * 4。解决是把地址和偏移都打印出来再对照 dump0x100000、0x100004、0x100008这类等差序列一眼就能看出步长对不对。还有一条和 git 相关改崩了想回滚结果git checkout -- .把自己刚写的一半代码也扔了。补救办法是平时把提交切细回滚时只 checkout 单个文件或者先用git stash存一下再试。这个习惯建立起来之后你后面调监视点那种来回改的场景会轻松很多。6. 回归验证用一份自检清单把三个阶段串起来三个阶段写完之后最怕的是“单看每条命令都对连起来就出问题”。我一般跑一遍固定流程做回归顺序是从寄存器到内存、从求值到监视点每一步都有明确的预期结果任何一步不对就停在原地查不往后走。make clean make # 重新编译确认没有隐藏的 warning ./nemu -l log.txt obj/testcase/mov # 带上日志跑 mov 程序 # 进入交互后依次输入 # si 3 - eip 应前进三条指令的长度 # info r - 八通用寄存器 eip 全部有值且格式对齐 # x 8 0x100000 - 与 obj/testcase/mov.txt 的机器码逐字节对齐 # p 1 2 * 3 - 7 # p (1 2) * 3 - 9 # p 1 - 2 - 3 - -4验证左结合 # w $eip - 注册监视点序号从 0 开始 # si 1 - 立刻停下并打印 Old/New value # d 0 - 删除后再 si不应再触发 # c - 跑到 nemu_trap 结束日志里能看到最后一条指令这段清单的价值在于把“左结合”和“监视点触发”这两个最容易写错、又最不容易被单条命令暴露的点固定下来。1 - 2 - 3这条尤其值得留着我见过不止一次主运算符找错方向 -优先级对了但结合性反了结果p 1-2-3返回 2 而不是 -4单看p 11永远发现不了。日志文件也要会用。log.txt里每条指令都带%eip和反汇编跑完一遍用grep找最后一个eip看它是不是停在nemu_trap上如果程序异常退出日志的最后几行基本就是案发现场。比起点着鼠标单步这个办法快得多。最后一个习惯每完成一个阶段就提交一次提交信息里写清楚验证方式。我现在的固定做法是——改完任何一个命令处理函数先跑si、再跑info r、再跑x三条都过才提交。从那以后我每次动expr.c都强制走一遍这份清单宁可多花五分钟也不愿意再经历一次“调了三天发现是扫描内存的偏移写错了”的崩溃。希望帮到你。本文还有配套的精品资源点击获取