
简介这份 PA1 简易调试器实验文档面向正在学习 NEMU 模拟器与计算机系统课程的学生围绕在简化 x86 全系统模拟环境中实现基础调试工具展开。内容覆盖单步执行、打印寄存器状态、扫描内存三项基础功能并延伸至表达式求值、监视点设置等进阶调试能力同时穿插 NEMU 模块结构、x86-32 子集模拟原理、版本管理要求与评分构成说明便于读者建立从调试器功能到模拟器运行机制的完整认识。资源包共 1 个 PDF 文件大小约 1.53MB适合作为实验阶段的任务说明与实现参考。已有 2183 人学习说明其在相关课程学习者中具有一定参考价值。读者可借助文档梳理三阶段任务重点、理解代码实现与实验报告的评分比例并对照 NEMU 框架定位 monitor、CPU、存储管理和设备等模块为后续调试与扩展打下基础。1. 从 0xd6 说起模拟器为什么必须自带一个调试器在 NEMU 里跑起第一个用户程序时最别扭的不是程序算错了什么而是它凭什么停下来。这台用 C 搭出来的 x86 模拟器没有操作系统替它收尾用户程序撞上机器码 0xd6 那条nemu_trap指令控制权才交回 monitor。PA1 要实现的简易调试器就长在 monitor 这一层单步执行、打印寄存器、扫描内存再往上叠表达式求值和监视点。看着像把 GDB 的命令表抄一遍做起来完全不是——得先想明白一个程序凭什么能观察另一个程序的运行状态又凭什么在它跑到一半时把它按停。这两件事在 NEMU 里是同一件事被运行的用户程序它的寄存器、内存、指令流全都躺在 NEMU 自己的 C 变量里。2. 寄存器结构体为什么 eax、ax、ah、al 能共用同一块内存2.1 从 CPU_state 的定义看 union 的地址共享i386 的通用寄存器有个反直觉的地方EAX 的低 16 位叫 AXAX 又劈成 AH 和 AL这四者在物理上不是四份独立的存储。你在 C 里怎么表达「四个名字、一块内存、不同宽度」只有 union。框架代码里nemu/include/cpu/reg.h的那个结构体核心就是一段嵌套的 uniontypedef struct { union { union { uint32_t _32; // 32 位视图 uint16_t _16; // 16 位视图 uint8_t _8[2]; // 两个 8 位视图_8[0] 是低字节_8[1] 是高字节 } gpr[8]; // 8 个通用寄存器下标 0~7 struct { // 匿名 struct成员名直接冒泡到外层 union uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; }; uint32_t eip; // 程序计数器不参与这层 union } CPU_state;union 的语义是「所有成员共享起始地址大小取最大者」所以gpr[0]._32和eax落在同样的四个字节上gpr[0]._16落在前两个字节上gpr[0]._8[0]落在第一个字节上。x86 是小端序低地址存低字节于是_8[0]天然就是 AL_8[1]天然就是 AH。这一步想通了后面写指令执行时「往 al 写一个字节读 eax 时高 24 位不变」这种语义就自动成立了不需要任何掩码代码。真正省事的是那个匿名的struct成员。C11 允许匿名 struct/union它的字段会直接并入外层作用域于是cpu.eax和cpu.gpr[0]._32是两种写法指向同一块内存。要是没有它你只能在「可读性」和「可索引」之间二选一要么全用命名成员译码器里就得写 switch要么全用数组代码里到处都是cpu.gpr[0]谁也记不住 0 是 eax。2.2 gpr 数组的下标为什么顺序是 eax、ecx、edx、ebx这个顺序不是字母序也不是什么直觉顺序它就是 i386 指令编码里 ModRM 字节中 reg/rm 那三位的取值表0eax1ecx2edx3ebx4esp5ebp6esi7edi。我一般会这么理解这层设计动机——译码器拿到一条指令从 ModRM 里抠出三位就是一个 0 到 7 的整数直接gpr[idx]就能取到操作数连一次分支都不用。如果数组顺序按字母排这里就得多一张映射表或者一个 switch而这段代码在cpu_exec的主循环里每秒钟要跑几百万次。esp排在 4、ebp排在 5 也解释了栈相关指令的编码习惯push/pop 的默认操作数是 0 号寄存器而栈帧相关指令里 esp/ebp 出现频率极高它们的编号紧凑地挨在一起译码后的索引就都落在小整数上。这种「数据结构的顺序迁就指令编码」的做法在模拟器里非常常见代价是可读性——第一次看到gpr[4]的人得查表才知道是 esp。还有一个容易忽略的点eip被放在 union 外面。它不是通用寄存器没有 16 位和 8 位的别名指令里也不能拿它当 ModRM 的操作数只能通过 jmp/call 这类控制流指令隐式修改。把它塞进 gpr 数组会让「8 个通用寄存器」这个不变量被破坏后面写info r遍历时就得特殊处理第 9 个元素。2.3 reg_test 的验证方式随机数比手写常量更狠框架里有个reg_test()跑不过就 assertion failNEMU 连命令行提示符都不给你。它的思路值得单独说因为它示范了这类「内存布局」代码该怎么验证static void reg_test() { uint32_t r[8]; int i; for (i 0; i 8; i ) { r[i] rand(); // 随机值避免手写常量恰好覆盖不到高字节 } for (i 0; i 8; i ) { gpr(i) r[i]; // 从 32 位视图整体写入 } for (i 0; i 8; i ) { assert(gpr(i) r[i]); // 32 位视图读回来必须一致 assert(reg_w(i) (r[i] 0xffff)); // 16 位视图必须是低 16 位 assert(reg_b(i, 0) (r[i] 0xff));// 低字节 assert(reg_b(i, 1) ((r[i] 8) 0xff)); // 高字节 } }这里的gpr(i)、reg_w(i)、reg_b(i, n)是框架提供的宏分别映射到_32、_16、_8[n]三种视图。验证逻辑是「写一种视图从另外三种视图读回来对账」。为什么用rand()而不是写死0x12345678因为手写常量很容易在某个字节上重复比如你写0x00112233和0x00334455高字节都是 0布局写错成「高字节存低地址」也照样能过。随机数把这种漏检概率压到极低。提示reg_test()通过不代表指令语义就对它只保证了「同一个寄存器能被四种宽度正确访问」。写完 mov 指令的执行逻辑之后还得拿用户程序的 objdump 输出和x命令扫出来的内存逐字节对账。3. 命令解析与调试三件套si、info r、x 背后的调用链3.1 readline 到 strtok命令行的第一刀切在哪NEMU 启动后进ui_mainloop()用 readline 库的readline()读一行。选它而不是gets()的理由很实际行编辑、历史记录、Ctrl-C 处理都是现成的方向键上下翻历史这条在调 NEMU 的时候真能救命——你会反复敲同几条si和x。读到的整行字符串交给strtok()切。这个函数有两个必须知道的行为一是它会往原字符串里写\0把分隔符替换掉所以传进去的必须是可写缓冲区传字符串字面量直接段错误二是它内部维护一个静态指针记录「上次切到哪」因此不可重入也不能在同一层逻辑里嵌套切两串。框架代码里cmd_help()给了用法示例值得先读一遍再动手。切出命令名之后按表分发每个命令函数的签名统一是int cmd_xxx(char *args)args指向第一个分隔符之后的位置。si 10的args就是10x 10 $esp的args就是10 $esp。x命令的切法要绕一下因为它有两个参数第二个还可能是带空格的表达式static int cmd_x(char *args) { char *n_str strtok(args, ); // 第一刀切出字数 N char *expr strtok(NULL, ); // 第二刀分隔符集合为空 把剩下整段原样拿走 if (n_str NULL || expr NULL) { printf(usage: x N EXPR\n); return 0; } int n atoi(n_str); bool ok true; word_t addr expr_eval(expr, ok); // 表达式求值结果当起始地址 if (!ok) { printf(bad expression: %s\n, expr); return 0; } for (int i 0; i n; i ) { uint32_t val swaddr_read(addr i * 4, 4); // 每次读 4 字节地址步进 4 printf(0x%08x: 0x%08x\n, addr i * 4, val); } return 0; }strtok(NULL, )这招的关键在于第二参数是空串——分隔符集合为空等于没有任何字符算分隔符函数会把剩余部分整段返回中间的空格全部保留。如果用strtok(NULL, )表达式里的空格会被吞掉、连续空格会被压缩$eax 1就变成$eax了。x的输出格式没有硬性规定但建议对齐成地址: 值两列0x%08x补满八位。后面你要拿这个输出跟 objdump 的文本做逐行比对列没对齐的话肉眼对账会非常痛苦。3.2 si 的循环次数与 -1 这个哨兵si N实现起来三行但里面藏着一个值得琢磨的边界。cmd_si的流程是有参数就解析没参数就当 1然后调cpu_exec(n)。而c命令传的是-1。cpu_exec的参数类型是uint32_t。传-1进去发生隐式转换变成0xffffffff也就是四十多亿。循环写成while (n -- 0)效果就是「一直执行到nemu_trap把状态置成结束」。这个写法能跑但它把「无限」这个语义编码进了一个无符号数的极值里属于典型的隐式约定。想验证它有多脆试试把参数改成int32_t并且循环写成for (int i 0; i n; i )n是-1时循环一次都不进c命令直接失效现象是「敲了 c 之后提示符立刻回来程序一步没跑」。另一种翻车方式是n声明成int但循环里跟unsigned比较-1被提升成UINT_MAX行为虽然碰巧还是「一直跑」但只要有人把n的初值改成别的负数逻辑就彻底说不清了。我一般会这么处理函数签名用int32_t循环写成void cpu_exec(int32_t n) { // n 0 表示不限次数直到用户程序执行 nemu_trap机器码 0xd6 while (n ! 0) { exec_once(); // 取指、译码、执行一条指令 if (nemu_state ! RUNNING) break; if (n 0) n --; // 只有正数才递减负数永远不归零 } }判断条件从「数值够不够」变成「是不是零」-1只是众多负数里的一个语义上「负 无限」和「零 不执行」区分得干干净净。代价是多一次比较但在模拟器主循环这种量级上完全可以忽略。3.3 扫描内存与 swaddr_read现在就要留的地址空间接口x命令最终落到swaddr_read(addr, len)而不是直接读模拟内存的数组。这一层看起来多余因为 PA1 阶段虚拟地址、线性地址、物理地址三者完全相等swaddr_read里面就是一句paddr_read()转发。但它是给 PA3 留的后路等分页开了地址翻译要插在这一层函数体改掉即可所有调用点——包括你刚写的x命令——一行都不用动。框架里还有swaddr_writed、w这类命令将来要靠它改内存。len参数只允许 1、2、4用assert卡住。这个断言不是形式主义如果哪天你误传了个 3paddr_read内部按字节循环拷贝时可能越界读一个字节症状是「扫描出来的值偶尔比预期多一位」追起来能耗掉一整天。addr的初值来源也值得记一笔。restart()把入口代码读到物理地址0x100000同时把eip设成这个值然后才开始执行。所以第一个x 10 0x100000扫出来的应该就是用户程序的第一条指令跟obj/testcase/mov.txt里的第一行对得上。对不上就说明restart()或者swaddr_read有问题别急着怀疑指令实现。4. 表达式求值正则词法分析和 BNF 递归求值怎么接上4.1 token 规则表与 C 字符串转义的双层坑p EXPR、w EXPR、x N EXPR三个命令全都要表达式求值所以这块是 PA1 阶段 2 的核心占分 30%。框架选择的路线是「先词法分析切 token再按 BNF 递归求值」教科书里最经典的两段式。词法分析用正则。规则表是个二元组数组每条规则是「正则 token 类型」enum { NOTYPE 256, TK_NUM, TK_HEX, TK_REG, TK_EQ, TK_NEQ, TK_AND, TK_OR, TK_NOT, TK_DEREF /* 单字符运算符直接用字符本身的 ASCII 码当类型省一个枚举 */ }; static struct rule { char *regex; int token_type; } rules[] { { , NOTYPE}, // 一个或多个空格识别出来但丢弃 {0[xX][0-9a-fA-F], TK_HEX}, // 十六进制必须排在十进制前面 {[0-9], TK_NUM}, {\\$[a-zA-Z], TK_REG}, // $eax 这类寄存器名 {, TK_EQ}, {\\*, TK_DEREF}, // * 是正则量词必须转义 {\\, }, {\\-, -}, {\\(, (}, {\\), )}, };这张表有两个地方几乎人人踩。第一是顺序正则匹配是「从上往下试第一个成功且落在当前位置的就用」所以0[xX][0-9a-fA-F]必须排在[0-9]前面。反过来的话0xc0100000会被[0-9]只吃下一个0剩下xc0100000从x开始匹配报「no match at position 1」。症状很有迷惑性——不是所有表达式都错只有以 0 开头的十六进制数错。第二是转义的双层结构。正则里的$、*、、(、)都是元字符要匹配它们本身得写成\$。但这条规则是写在 C 字符串里的C 又规定\后面跟特定字符是转义序列所以源码里必须写成\\$C 编译器先把它变成\$正则引擎再把它理解成「字面的美元符号」。少写一层反斜杠编译器可能给个 warning也可能静默把你的\*变成别的字符然后你在「为什么p *0x2000一直报语法错」上卡两小时。4.2 make_token 的 position 游标与 Token 结构make_token()的工作方式极其直白一个position游标指向待处理位置每轮把每条规则往e position上试匹配上了就前移游标直到遇见\0。static bool make_token(char *e) { int position 0; int i; regmatch_t pmatch; nr_token 0; while (e[position] ! \0) { for (i 0; i NR_REGEX; i ) { // 注意 e position正则从当前位置开始试 if (regexec(re[i], e position, 1, pmatch, 0) 0 pmatch.rm_so 0) { int substr_len pmatch.rm_eo; Assert(substr_len (int)sizeof(tokens[nr_token].str), token too long at position %d, position); if (rules[i].token_type ! NOTYPE) { // NOTYPE 是空格直接丢 tokens[nr_token].type rules[i].token_type; strncpy(tokens[nr_token].str, e position, substr_len); tokens[nr_token].str[substr_len] \0; // strncpy 不补结尾必须手动写 nr_token ; } position substr_len; break; // 匹配成功就跳出规则循环 } } if (i NR_REGEX) { // 所有规则都没匹配上 printf(no match at position %d\n%s\n%*s^\n, position, e, position, ); return false; } } return true; }pmatch.rm_so 0这个判断是精髓。regexec在剩余串里做的是「搜索」而不是「从头匹配」如果当前位置是abc某条规则是[0-9]它会在后面找到数字并返回成功rm_so就是那个数字的位置。不检查rm_so 0的话2abc这种串会被切出2然后游标跳过整个abc错误被吞掉最后求值结果莫名其妙。Token结构体只有两个成员type和str。str只在需要保留原文的 token 上有意义——数字要知道具体值是多少寄存器要知道是$eax而不是$ecx运算符光有类型就够。所以str是定长数组比如 32 字节。定长就一定会溢出。这里框架给的建议是直接用Assert掐掉而不是写一段截断逻辑理由是 KISS截断之后表达式求值会用一个被悄悄改过的 token 算出错误结果这种 bug 比直接崩难查十倍。用断言把问题在词法阶段就地暴露位置信息还打在报错里改了输入重跑就行。4.3 主运算符的选取优先级、结合性与一元负号token 序列拿到了开始递归求值。递归的合法性来自算术表达式的 BNFexpr :: number | hex | reg | expr op expr | ( expr ) | - expr // 一元负号「长表达式由短表达式构成」这句话翻译成代码就是分治在[p, q]区间里找「最后才计算」的那个运算符也就是主运算符把它左右两边递归求值再套用该运算符的语义。区间只剩一个 token 时直接取原子值。找主运算符的规则有三条缺一不可// 返回 [p, q] 区间内主运算符的下标找不到说明是原子表达式 static int find_dominant_op(int p, int q) { int i, op -1, prio 0x7fffffff, nest 0; for (i q; i p; i --) { // 从右往左扫左结合运算符要取最右边那个 Token *t tokens[i]; if (t-type )) { nest ; continue; } if (t-type () { nest --; continue; } if (nest ! 0) continue; // 括号内部的运算符不属于本层 if (is_unary(tokens, i)) continue; // 一元负号不参与主运算符竞争 int pr priority(t-type); // - 是 1* / 是 2 是 0 if (pr prio) { prio pr; op i; } // 严格小于同优先级保留最右边 } return op; }第一条优先级数字越小优先级越低因为主运算符是「最后算」的那个等价于「优先级最低」。的优先级比低所以$eax 1 2的主运算符是左子树是$eax 1这个方向必须一开始就定对反过来priority返回大数就全盘皆错。第二条从右往左扫并且用严格小于更新。1 - 2 - 3的正确结合是(1 - 2) - 3主运算符是第二个-从左往右扫、严格小于更新留下的会是第一个-算出1 - (2 - 3) 2正确结果是-4。这个错误只在减法和除法上暴露加法和乘法因为有交换律看不出来所以测试用例里务必写p 10-3-2和p 100/10/5。第三条一元负号要排除在候选之外。判断方法是看-前面那个 token如果它是数字、十六进制、寄存器或者右括号说明-是二元减法如果它是左括号、另一个运算符或者根本不存在下标等于p-1那就是一元负号。p -12这种写法里-在p命令的参数开头前一个 token 不存在必须按一元处理值是 1 而不是「0 减 1 加 2 再减点什么」。一元负号在递归求值里走的路径也不是主运算符分支而是eval(p, q)一进来就检查「首个 token 是不是-」是的话对[p1, q]求值再取负。括号的判断单独一个函数check_parentheses(p, q)先确认tokens[p]是(、tokens[q]是)然后从左往右数括号深度如果在中途还没走到q深度就归零了说明第一对括号没有包住整段比如(12)*(34)这时候不能剥括号得去找主运算符。这个判断漏了的话(12)*(34)会被剥成12)*(34直接崩在越界上。5. 常见踩坑排查五类现象背后的真实原因调试器这类代码的特点是「错了不会立刻告诉你而是在你用它调别的东西时把你带沟里」。项目正文里那句警告不是吓唬人扫描内存的 bug 潜伏三天期间你用它排查了无数个不存在的问题。下面五条是按「现象 → 原因 → 解决」整理的踩坑记录。5.1 寄存器与内存访问现象一reg_test()通过了但手动info r打印出来的 eax 和单独打印gpr[0]._32对不上。原因几乎都是把匿名 struct 写成了具名 struct。struct { uint32_t eax, ...; } r;这样写的话r是 union 里的一个独立成员它有自己的一块内存跟gpr数组毫无关系两套存储各写各的。reg_test()之所以能过是因为它只测了gpr那一侧的四种视图压根没碰eax这些名字。解决办法是把r这个名字去掉让它成为匿名成员字段直接挂到外层 union 上。改完再跑reg_test()同时补一句assert(cpu.eax cpu.gpr[0]._32)——地址相等才是真的共享内存值偶然相等不算数。现象二x 10 0x100000输出的第一个字是对的后面全错位。地址步进写成addr i了。内存以字节编址swaddr_read的第二个参数是「读几个字节」不是「读第几个字」。读 4 字节就应该步进 4。另一种错法是swaddr_read(addr, 4 * n)想一次读一大片但这个函数只接受 1/2/4assert会直接把程序掐掉。按addr i * 4改完之后还要拿obj/testcase/mov.txt对一遍。mov 用户程序的第一条指令在0x100000objdump 里那行形如100000: b8 34 12 00 00 mov $0x1234,%eax小端序下你在x输出里看到的第一个字应该是0x000012b8低字节b8在前或者按字节扫的话是b8 34 12 00 00。对不上就说明restart()里的 entry 装载地址或者eip初值有问题。5.2 命令行与表达式现象三si 10只执行了一条就停或者敲c之后提示符立刻回来、一步没跑。前者的原因是sscanf(args, %d, n)的返回值没检查解析失败时n保持初值。如果初值是随机栈垃圾或者 0行为就不确定。后者是第 3.2 节说的-1转无符号问题参数类型和循环条件对不上。改法是把两个参数解析都改成「先看sscanf返回值是不是 1不是就报错返回」cpu_exec的签名改成有符号并把循环条件写成n ! 0加if (n 0) n--。改完专门测三种输入si无参数走 1 条、si 5走 5 条、c跑到 trap。现象四p 12*3得到 9。主运算符选错了选了最左边的于是1 (2*3)变成(12)*3。根因是priority的比较方向反了把「优先级数字大」当成了「优先级低」。另一种等价错误是遍历方向从左往右而且用了更新同优先级里留下最左边那个。核对方法很直接p 12*3必须是 7p 10-3-2必须是 5p 100/10/5必须是 2。这三条过了优先级和结合性基本就站住了。现象五p -12结果不对或者程序直接崩。一元负号被当成了二元减法。find_dominant_op扫到-时如果没做前驱判断会把它当成主运算符左子树是空区间[p, i-1]递归进去p q越界或者返回垃圾值。判断前驱 token 的逻辑要写在一个统一的地方别在两个函数里各判一次——求值路径和主运算符查找路径对「是不是一元」的结论必须一致否则会出现「主运算符认为它是二元的求值时又按一元处理」这种自相矛盾。p -12应该是 1p 2*-3应该是 -6p -(12)应该是 -3三条都过得去才算真的处理干净了。5.3 监视点现象六w *0x2000设完之后程序一跑就立刻停说是「值发生了变化」。监视点池里的old_val没初始化。w命令的正确流程是「先求一次值作为基准记进old_val再把它挂进活跃链表」漏了第一步old_val就是零值或者栈垃圾第一次比较必然不等。改法是w命令内部强制先expr_eval一次求值失败就连监视点都别建。另外触发之后要把old_val更新成新值否则下一次检查还会因为同一个原因再停一次症状是「敲一次 c 停一次但内存其实没变」。6. 监视点的触发判定轮询求值这个笨办法值不值监视点的数据结构长这样typedef struct watchpoint { int NO; // 对外展示的编号删除后不复用 struct watchpoint *next; char expr[128]; // 存表达式原文不是存地址 word_t old_val; // 上一次求值的结果 bool in_use; // 惰性删除标记 } WP;有两处设计选择值得说。一是存表达式原文而不是存求出来的地址w *0x2000这种写法只覆盖一个地址但w $eax 4的值随寄存器变化每次检查都得重新求值存地址没用。二是惰性删除——d 2只是把in_use置假数组不搬动、编号不复用。要是删除时把后面的元素往前挪用户手里的编号立刻全部失效「删了 2 之后 3 变成了 2」这种体验比不删还糟。触发判定放在exec_once()之后遍历活跃监视点逐个expr_eval跟old_val比不等就打印新旧值、把状态置成暂停本文还有配套的精品资源点击获取