我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

NEMU PA1调试器实战:寄存器union、表达式求值与监视点

NEMU PA1调试器实战:寄存器union、表达式求值与监视点 简介这份PA1「简易调试器」实验文档面向正在学习计算机系统、操作系统或参与NEMU课程实践的学生围绕在简化版x86全系统模拟器上构建一个具备基本调试能力的工具展开。内容按三个阶段组织阶段一实现单步执行、打印寄存器状态与扫描内存三项基础功能阶段二扩展表达式求值阶段三实现监视点并给出git分支整理、提交方式与评分比例等实验规范同时标注了约30小时、400行代码的预期工作量便于读者评估投入。文档为单个PDF文件压缩包约1.53MB适合在本地阅读并作为动手实践时的对照材料。目前已吸引2183人学习。读者可从中获取实验的整体流程与目标拆解理解monitor、CPU、存储管理与设备四个模块的分工并借助NEMU与红白机模拟器的类比建立虚拟化概念为后续PA阶段打下基础。1. 从 NEMU 的 monitor 说起为什么先写调试器再写模拟器很多人拿到 NEMU 框架代码的第一反应是直接翻cpu/exec目录想看看指令是怎么解码执行的。我当初也这么干结果在 6000 多行代码里绕了一整天连程序从哪条指令开始跑都没搞清楚。PA1 的简易调试器恰好反过来它不要求你立刻理解模拟器内部而是先在虚拟计算机系统外面搭一层 monitor让你有能力观察这个系统。单步执行、打印寄存器、扫描内存这三件事本质上就是给后面所有实验装了一只眼睛——没有这只眼睛PA2 里指令实现出错时你只能靠printf盲猜。这一章之后要落地的东西很具体把CPU_state的 union 布局写对让reg_test()通过把si / info r / x三条命令接到cpu_exec()上再用正则加递归下降做表达式求值最后收到监视点。适合正在做 PA1、卡在阶段 1 或阶段 2 的人也适合想照着这套思路自己写一个小型调试器的从业者。2. 寄存器结构体先过关CPU_state 的 union 布局与 reg_test 自检框架代码跑起来第一件事就是 assertion fail位置在nemu/src/cpu/reg.c的reg_test()里。这不是你的环境有问题是框架故意留的CPU_state里的通用寄存器还没按 i386 的真实重叠关系实现。这一节先把这块骨头啃掉后面三条命令才有数据可打印。2.1 开工前先做分支整理别在主干上写代码评测脚本是按分支收集提交的直接在主干上改最后大概率收不到你的成果。工程目录下先执行这三条# 打一个空提交作为 PA1 的起点标记方便后面 diff 出自己改了哪些文件 git commit --allow-empty -am before starting PA1 # 从当前 HEAD 拉出 PA1 分支并切过去后续所有改动都落在这条分支上 git checkout -b PA1 # 确认当前分支名输出里带 * 的那一行必须是 PA1 git status--allow-empty这个参数容易被忽略如果你的工作区本来就干净普通的git commit会直接报 nothing to commit 然后失败起点标记就打不上。空提交允许在没有文件变更时也生成一个 commit正好用来标注「实验开始前」这个状态。git checkout -b PA1的-b表示新建并切换如果分支已存在会报错那就改用git checkout PA1。做完之后建议用git log --oneline --graph --all看一眼确认 PA1 分支和主干指向同一个 commit且 HEAD 在 PA1 上。这个习惯在后面几个 PA 里能省掉很多「我明明改了为什么评测说没改」的玄学问题。2.2 用匿名 union 把 32/16/8 位寄存器叠在一起i386 的通用寄存器不是八个独立的 32 位盒子EAX的低 16 位是AXAX又拆成AH和AL。C 语言里表达这种「同一块内存、不同宽度视图」的标准手法就是 union 套匿名 struct。定义在nemu/include/cpu/reg.htypedef struct { union { union { uint32_t _32; uint16_t _16; uint8_t _8[2]; /* _8[0] 是低字节 AL_8[1] 是高字节 AH */ } gpr[8]; /* 匿名 struct 直接覆盖在 gpr 数组上成员顺序必须和 gpr 下标一一对应 */ struct { uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; }; uint32_t eip; } CPU_state;这段代码的关键在成员顺序。gpr数组和下面的匿名 struct 共享同一段内存gpr[0]必须对应eaxgpr[1]对应ecx依此类推。这个顺序不是我随便排的是 i386 指令编码里寄存器字段的编号顺序000 是 EAX001 是 ECX010 是 EDX011 是 EBX100 是 ESP101 是 EBP110 是 ESI111 是 EDI。后面 PA2 解码 ModRM 字节时你会直接拿这三位当gpr的下标用顺序错了就是全盘皆错。小端机器上_8[0]是低字节所以AL取_8[0]、AH取_8[1]。如果你的运行环境是大端这两个下标要反过来——不过 x86 模拟器基本不会跑在大端上知道有这回事就行。eip单独放在 union 外面因为它没有 16 位和 8 位的别名视图虽然历史上 IP 存在过但现在的 NEMU 用不到。2.3 reg_test 到底在验什么reg_test()的逻辑是随机生成一批 32 位数据写进gpr[i]._32然后比较不同宽度视图的取值是否自洽。它验的不只是「你有没有定义这些字段」更是在验别名关系写入eax之后读ax应该拿到低 16 位读al应该拿到最低 8 位写ah之后eax的高 8 位要跟着变。所以跑通的标准不是「编译通过」而是启动 NEMU 之后直接落到命令提示符中间没有 assertion fail。看到提示符就说明结构体布局是对的。提示如果 assertion fail 的信息里带着具体的寄存器名和两个数值先别急着改代码把这两个数转成十六进制手工算一下谁是谁的低位通常能立刻定位是顺序错了还是_8下标反了。通过之后建议主动加一段自检代码别完全依赖框架的随机测试。我一般会在reg_test()后面临时插一段/* 临时自检写一个能覆盖高低字节的值逐层读回验证别名关系 */ cpu.eax 0x12345678; assert(cpu.gpr[0]._16 0x5678); /* AX 应为低 16 位 */ assert(cpu.gpr[0]._8[0] 0x78); /* AL 应为最低字节 */ assert(cpu.gpr[0]._8[1] 0x56); /* AH 应为次低字节 */ cpu.gpr[0]._8[1] 0xAB; /* 反向写 AH */ assert(cpu.eax 0x1234AB78); /* EAX 的高 8 位应被改写 */这段的价值在于反向验证随机测试大多只做「写 32 位、读窄位」很少做「写窄位、读 32 位」而后者恰恰是mov %al, ...这类指令最容易翻车的地方。跑通之后记得删掉别留在提交里。2.4 框架里三个宏早点用起来nemu/include/debug.h里的Log()、Assert()、panic()在 PA1 阶段就该养成习惯。Log()比printf多输出文件名、行号和函数名调试信息一多的时候光看内容根本分不清是哪一行打的Assert()在条件为假时能带一条自定义信息再挂掉比裸assert好定位panic()就是无条件的 assertion fail用来标记「这里不该被执行到」。后面的词法分析和递归求值里我会大量用Assert()卡边界这比写一堆if-else打印错误信息省事得多也符合框架反复强调的 KISS先把核心路径跑通边界情况用断言炸出来别一开始就想着优雅降级。3. 单步、看寄存器、扫内存三条命令接到 CPU 执行主循环阶段 1 的三个功能加起来代码量不到 100 行但它们是后面所有调试工作的基础设施。这一节把每条命令从「用户敲入字符串」到「真正读到数据」的链路拆开顺便说清楚几个参数为什么这么传。3.1 命令解析strtok 和 sscanf 各管一段框架里ui_mainloop()用readline()读一整行然后交给cmd_*系列函数。已经实现的cmd_help()是strtok()的现成范例照着抄结构就行。但strtok()有个坑它把分隔符替换成\0并返回片段指针连续调用时第一个参数传NULL表示接着上次的位置切。这意味着你没法用strtok一次性拿到「第一个参数之后的全部剩余内容」——而x N EXPR和p EXPR恰恰需要这个。我的做法是第一个参数用strtok切剩下部分靠指针算术直接取。static int cmd_x(char *args) { if (args NULL) { printf(Usage: x N EXPR\n); return 0; } char *n_str strtok(args, ); if (n_str NULL) { printf(Usage: x N EXPR\n); return 0; } int n atoi(n_str); if (n 0) { printf(x: N must be positive\n); return 0; } /* strtok 已在 N 的末尾写下 \0往后跳过这个 \0 就是表达式起点 */ char *expr n_str strlen(n_str) 1; while (*expr ) expr ; /* 容忍 N 和表达式之间的多个空格 */ if (*expr \0) { printf(Usage: x N EXPR\n); return 0; } bool ok true; word_t addr expr_eval(expr, ok); if (!ok) { printf(x: bad expression \%s\\n, expr); return 0; } for (int i 0; i n; i ) { word_t cur addr i * 4; /* 第二个参数 4 表示读 4 字节部分框架版本还要补一个段寄存器参数填 0 即可 */ word_t data swaddr_read(cur, 4); printf(0x%08x: 0x%08x\t%u\n, cur, data, data); } return 0; }atoi对非法输入返回 0所以n 0的判断同时挡住了「N 写成字母」和「N 写成负数」两种情况。word_t在框架里通常是uint32_t的 typedef用它而不是int是为了避免地址高位被当成符号位。swaddr_read()的第二个参数是读取长度单位字节1/2/4 都合法。扫描内存场景固定传 4和 GDB 的x/10xw一个意思。如果你的框架版本签名是swaddr_read(addr, len, sreg)补一个0或者R_DSPA1 阶段还没启用段机制传什么都不会影响结果。3.2 单步执行n 参数怎么传给 cpu_execcpu_exec()的签名是void cpu_exec(uint32_t n)语义是「最多执行 n 条指令」。cmd_c()里传的是-1转成uint32_t就是0xFFFFFFFF等价于「一直跑到用户程序执行nemu_trap或者手动中断」。这正是思考题 2 想问的谁来指示程序结束答案是那条机器码为0xd6的特殊指令它在exec()里被单独识别执行到就把nemu_state置成ENDcpu_exec()的循环条件随之失效。单步就是把这个 n 变成有限值static int cmd_si(char *args) { int n 1; /* 缺省单步 1 条 */ char *arg strtok(NULL, ); if (arg ! NULL) n atoi(arg); if (n 0) { printf(si: N must be positive\n); return 0; } cpu_exec(n); /* 循环退出后打印下一条待执行指令的地址方便连续单步时对齐位置 */ printf(stopped at eip 0x%08x\n, cpu.eip); return 0; }有一个边界要清楚cpu_exec(n)里的循环是「执行一条、n 减一、判断是否归零」所以si N精确执行 N 条除非中途撞上nemu_trap。如果用户程序在第 3 条就 trap 了你敲si 10实际只会走 3 条然后回到提示符这时候打印的eip是 trap 指令之后的值别误以为单步没生效。3.3 打印寄存器遍历 gpr 数组别一个个写 printf最直接的做法是八行printf但我更推荐写一个isa_reg_display()放进cpu/reg.c用循环遍历void isa_reg_display(void) { /* 顺序和 CPU_state 里 gpr 的下标严格对应改一处必须改另一处 */ static const char *names[] {eax,ecx,edx,ebx,esp,ebp,esi,edi}; for (int i 0; i 8; i ) { printf(%-8s 0x%08x\t%u\n, names[i], cpu.gpr[i]._32, cpu.gpr[i]._32); } printf(%-8s 0x%08x\t%u\n, eip, cpu.eip, cpu.eip); }输出十六进制和十进制两列是有意为之的。调试算术指令时十进制直观调试地址和位运算时十六进制直观两列并排能省掉一次心算。%-8s左对齐保证列宽稳定连续敲几次info r时数值列不会跳来跳去。cmd_info()负责分发子命令static int cmd_info(char *args) { char *sub strtok(NULL, ); if (sub NULL) { printf(Usage: info r|w\n); return 0; } if (strcmp(sub, r) 0) isa_reg_display(); else if (strcmp(sub, w) 0) watchpoint_display(); /* 阶段 3 再补 */ else printf(info: unknown subcommand \%s\\n, sub); return 0; }strcmp返回 0 表示相等这个别写反。sub为 NULL 的情况必须单独判strcmp(NULL, r)是未定义行为在很多平台上直接段错误。3.4 扫描内存的正确性怎么验和 objdump 对拍功能写完不算完得证明它是对的。最硬的验证手段是和objdump的结果逐字节比对。用户程序在testcase/src/mov.S编译产物和反汇编文本在obj/下。先看反汇编# 取用户程序的反汇编作为对拍基准 objdump -d obj/testcase/mov | head -40记下0x100000附近前几条指令的机器码然后在 NEMU 里敲x 16 0x100000按小端序把输出拼回字节序列应该和 objdump 左边那一列完全一致。比如 objdump 显示100000: b8 34 12 00 00 mov $0x1234,%eax那x 16 0x100000的第一行应该是0x100000: 0x00001234第二行0x100004: 0x...。这种对拍能同时验出三件事地址算得对不对、swaddr_read的长度传得对不对、字节序处理有没有问题。注意0x00000000到0x0FFFFF这一段在 PA1 里被当成 ramdisk 用用户程序本身不碰它扫出来大概率是零或者未初始化数据。别拿这段的输出怀疑自己的实现。4. 表达式求值正则切 token 加递归下降两段各写五十行阶段 2 是 PA1 里唯一需要动点脑子的部分。表达式求值这个经典问题有很多解法框架选了「词法分析 递归求值」这条线原因很实际词法分析练正则递归求值练分治和 BNF两件事都是后续 PA 要反复用的基本功。4.1 正则规则表先搞清楚 BRE 和 ERE 的差别框架里rules[]数组每项是「正则字符串 token 类型」的二元组初始化时用regcomp()编译。这里有一个几乎每个人都会踩的坑POSIX 正则分基本正则BRE和扩展正则EREregcomp()默认按 BRE 解释而 BRE 里、?、|、()都不是元字符是普通字符。这直接影响你怎么写规则。static struct rule { char *regex; int token_type; } rules[] { { , TK_NOTYPE}, /* 一个或多个空格识别出来直接丢弃 */ {\\, }, /* 加号在 BRE 里要转义字符串里再转义一次 */ {-, -}, {\\*, *}, {/, /}, {\\(, (}, {\\), )}, {0[xX][0-9a-fA-F], TK_HEX}, /* 十六进制字面量 */ {[0-9], TK_DEC}, /* 十进制字面量 */ {\\$[a-zA-Z], TK_REG}, /* 寄存器名如 $eax */ };双反斜杠的来历C 字符串里\\表示一个反斜杠所以\\传给regcomp()的是\。在 BRE 下\是 GNU 扩展表示「前一个字符出现一次或多次」——但这里我们想匹配的是字面量的加号\在 BRE 下恰好也能匹配加号本身因为没有前导字符可修饰所以能用但语义上是模糊的。更保险的写法是[]用字符类把包起来BRE 和 ERE 下语义都明确。{ , TK_NOTYPE}里的空格是字面量空格后面跟表示「一个或多个」。这条规则必须放在最前面否则空格会匹配不上任何规则make_token()直接返回 false。TK_NOTYPE的作用是「识别到了但不进 tokens 数组」——空格不参与求值识别完就扔。顺带说一句规则数组的顺序有讲究regexec是按顺序逐个试的第一个匹配上且起始位置为 0 的规则胜出。如果把[0-9]放在0[xX][0-9a-fA-F]前面那0xc0100000会被切成0和xc0100000两段后面那段匹配不上任何规则直接报错。所以更具体的规则必须排在更宽泛的规则前面。4.2 make_tokenposition 推进与 str 缓冲的两道防线make_token()的骨架框架已经给了核心是position指针从左往右扫每轮尝试所有规则。你要补的是「匹配成功之后把 token 记下来」这一段static bool make_token(char *e) { int position 0; regmatch_t pmatch; nr_token 0; while (e[position] ! \0) { int i; for (i 0; i NR_REGEX; i ) { /* rm_so 0 保证匹配从当前位置开始而不是从中间某处开始 */ if (regexec(re[i], e position, 1, pmatch, 0) 0 pmatch.rm_so 0) { int len pmatch.rm_eo; char *start e position; position len; if (rules[i].token_type ! TK_NOTYPE) { Assert(nr_token MAX_TOKENS, too many tokens); tokens[nr_token].type rules[i].token_type; /* 只有需要知道具体取值的 token 才存字符串 */ switch (rules[i].token_type) { case TK_DEC: case TK_HEX: case TK_REG: Assert(len (int)sizeof(tokens[nr_token].str), token too long); strncpy(tokens[nr_token].str, start, len); tokens[nr_token].str[len] \0; break; default: break; /* 运算符只记类型就够了 */ } nr_token ; } 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是从字符串任意位置开始找匹配的如果不加这个条件12在处理位置 0 时[0-9]会匹配到1rm_so0但处理位置 1 时它同样会匹配到2rm_so1于是被跳过token 序列变成1 2求值必然出错。第二strncpy之后手动补\0。strncpy在源串长度大于等于 n 时不会补终止符这是 C 标准库最经典的坑之一。不补的话tokens[i].str后面跟着的是上一个 token 的残留内容strtol解析时会读出脏数据。第三长度检查用Assert。框架在 KISS 那一节专门拿这个举例处理缓冲区溢出的正确姿势是先断言炸掉而不是写一套优雅的截断逻辑。表达式求值的核心功能和「超长 token 怎么办」并不耦合用断言把问题暴露出来比写一段可能本身有 bug 的降级代码划算得多。4.3 递归求值的骨架从 BNF 直接翻译成代码算术表达式的 BNF 大致长这样expr :: term | expr term | expr - term term :: factor | term * factor | term / factor factor :: number | ( expr )直接照着写递归下降要维护三层函数框架给的思路更省事不拆层而是在eval(p, q)里「找最低优先级的运算符」找到就一分为二递归下去。/* 返回 tokens[p..q] 中优先级最低、且不在括号内的运算符下标找不到返回 -1 */ static int find_dominant_op(int p, int q) { int i, depth 0, ret -1, ret_prio 0xFF; for (i q; i p; i --) { /* 必须从右往左找 */ int t tokens[i].type; if (t )) depth ; else if (t () depth --; else if (depth 0) { int prio -1; if (t || t -) prio 1; else if (t * || t /) prio 2; if (prio 0 prio ret_prio) { ret i; ret_prio prio; } } } return ret; }从右往左扫是这段代码的灵魂。加减法是左结合的1-2-3必须算成(1-2)-3而不是1-(2-3)。从右往左找最后一个最低优先级运算符天然就把左结合性编码进去了1-2-3里最右边的-被选为主运算符左半是1-2、右半是3递归下去结果就是-4。从左往右扫会选到第一个-算出1-(2-3)2错误但看起来很像那么回事这类 bug 最难抓。主函数照着 BNF 翻译static word_t eval(int p, int q, bool *ok) { if (p q) { *ok false; return 0; } if (p q) { /* 单个 token数字或寄存器 */ if (tokens[p].type TK_DEC) return strtol(tokens[p].str, NULL, 10); if (tokens[p].type TK_HEX) return strtol(tokens[p].str, NULL, 16); if (tokens[p].type TK_REG) return isa_reg_read(tokens[p].str); *ok false; return 0; } /* 整个表达式被一对匹配的括号包住剥掉再递归 */ if (tokens[p].type ( tokens[q].type ) bracket_match(p, q)) { return eval(p 1, q - 1, ok); } int op find_dominant_op(p, q); if (op 0) { *ok false; return 0; } /* 括号不匹配或表达式非法 */ word_t l eval(p, op - 1, ok); if (!*ok) return 0; /* 短路避免用非法值继续算 */ word_t r eval(op 1, q, ok); if (!*ok) return 0; switch (tokens[op].type) { case : return l r; case -: return l - r; case *: return l * r; case /: if (r 0) { printf(divide by zero\n); *ok false; return 0; } return l / r; default: *ok false; return 0; } }bracket_match(p, q)遍历tokens[p..q]做括号计数确认tokens[p]的(和tokens[q]的)是同一对。没有这个检查(12)*(34)会在第一层就被误判成「整体被括号包住」剥成12)*(34后面必然崩。除法判零要单独处理。x86 的div除零会触发异常但这里是调试器的表达式求值直接打印错误并置okfalse交回调用方比让整个 NEMU 挂掉合理。4.4 别忘了阶段 1 的 x 命令要复用这套求值阶段 1 里x N EXPR的 EXPR 规定只能是一个十六进制数那是个权宜之计。阶段 2 做完之后把cmd_x里的地址解析换成expr_eval()立刻就能支持x 10 $esp、x 4 0x1000000x20这类写法——这恰恰是后面 PA 里最常用的调试手法。expr_eval()只是个包装把字符串交给make_tokeneval并统一处理错误word_t expr_eval(char *str, bool *ok) { *ok false; if (!make_token(str)) return 0; /* 词法失败 */ *ok true; word_t v eval(0, nr_token - 1, ok); if (!*ok) printf(eval failed: %s\n, str); return v; }nr_token - 1是 token 数组的最后一个下标。nr_token是全局变量每次make_token都会清零重来所以连续两次求值之间不会串数据——但如果你的实现忘了在make_token开头重置nr_token第二次p命令会把第一次的 token 接着往后追加现象是「第一次算对了第二次开始结果越来越离谱」这个坑下面还会提。5. 排查清单调试器自身翻车的五类现场调试器自己出 bug 是最恶心的情况你用它去查别的模块结果它给你的信息本身就是错的。框架在原文里专门写了一段「总有一天会找上门来的 bug」说的就是这件事。下面五条是我和周围人实际踩过的按「现象 → 原因 → 解决」记下来。5.1 现象info r 打印的 eax 和单独读 AL 值对不上原因基本只有一个gpr数组顺序和匿名 struct 的成员顺序没对齐。很多人会按eax, ebx, ecx, edx这种「字母序」或者「习惯顺序」排但 i386 的编码顺序是eax, ecx, edx, ebx, esp, ebp, esi, edi。差一位整个数组的语义就全错而且reg_test()有一定概率随机到恰好对称的值而不报错。解决把两个列表并排写出来逐项核对再从reg_test()里挑一个失败信息手工把数值转二进制看哪几位对不上。更彻底的做法是加一段编译期检查——C11 有_Static_assert可以断言sizeof(cpu.gpr) sizeof(cpu.eax) * 8虽然不能验顺序但至少能挡住宽度错误。5.2 现象x 10 $esp 扫出来的数据和预期完全不对三个可能按概率排序。第一swaddr_read的长度参数写成了别的值比如把 4 写成了 1读出来的每个 word 只有最低字节有效高位全是垃圾第二地址没按 4 字节对齐某些实现里非对齐访问会走到不同的分支第三你扫的地址落在 ramdisk 区间0x000000到0x0FFFFF那里本来就没有有意义的代码。解决先用x 4 0x100000和 objdump 对拍确认命令本身是对的再去扫$esp。另外在cmd_x里加一条对齐检查if ((cur 3) ! 0) printf(warning: unaligned address 0x%08x\n, cur);把隐患显式化。5.3 现象词法分析在 12 上报 no match或者把 漏掉原因出在正则规则上。在 BRE 里是普通字符没错但如果你照着某些教程写成\\在 ERE 语义下它变成「一个或多个」匹配空串时rm_eo为 0position不推进直接死循环。另一个高频错误是空格规则写成了{, TK_NOTYPE}意图是「匹配一个或多个空格」实际语义却完全不同。解决所有需要匹配字面量的元字符一律用字符类包起来[]、[*]、[(]、[)]、[$]语义在 BRE 和 ERE 下都明确。改完规则之后在make_token的匹配分支里打一行Log(rule %d matched \%.*s\, i, len, start)肉眼过一遍每个字符被哪条规则吃掉比猜快得多。5.4 现象p 1-2-3 算出 2p 8/4/2 算出 4find_dominant_op从左往右扫了。左结合运算符必须从右往左找主运算符理由在 4.3 节说过。除法和减法都是左结合中招概率极高而且结果往往「看起来像个正常数字」不会报错只会让你在别的地方浪费几天。解决循环改成for (i q; i p; i --)并且加一条判断prio ret_prio而不是保证同级运算符里选最右边那个。改完立刻用1-2-3、8/4/2、1-2*3、(1-2)*3四组数据回归这四组能覆盖结合性和优先级的全部组合。5.5 现象代码改完了评测说 PA1 分支上找不到改动git checkout -b PA1之前就已经开始改代码了那些改动留在主干上PA1 分支是从「改之前」的状态拉出来的。或者反过来你在 PA1 分支上改完提交时用了git commit -a但漏掉了新建的文件git commit -a只跟踪已纳入版本控制的文件。解决动代码之前先git status确认分支名改完用git status看未跟踪文件列表新增文件要git add。定期跑git log --oneline --graph --all --decorate看分支拓扑确认 HEAD 确实在 PA1 上。这个习惯在多人协作里是常识在单人实验里同样能救命。6. 监视点收尾把 EXPR 求值复用到 w 和 d 上阶段 3 的监视点看起来是个新功能实现上却几乎是阶段 2 的免费赠品监视点的本质就是「记住一个表达式每执行一条指令就重新求值一次和上次的值比」。表达式求值已经写好了剩下的是数据结构和触发时机。数据结构用一个静态池加空闲链表就够不需要动态分配#define NR_WP 32 typedef struct watchpoint { int NO; /* 展示给用户的编号从 1 开始 */ struct watchpoint *next; char expr[128]; /* 存表达式原文触发时重新求值 */ word_t old; /* 上一次求值的结果 */ bool busy; /* 池管理用false 表示这个槽位空闲 */ } WP; static WP wp_pool[NR_WP];w EXPR做三件事从池里取一个空闲槽、把表达式原文拷进去、立刻求一次值存进old。d N按NO找到节点并置busy false。真正的触发逻辑挂到cpu_exec()的指令循环里每执行完一条就遍历一次链表void watchpoint_check(void) { for (WP *p head; p ! NULL; p p-next) { bool ok true; word_t now expr_eval(p-expr, ok); if (!ok) { printf(watchpoint %d: expression became invalid\n, p-NO); continue; } if (now ! p-old) { printf(Watchpoint %d: %s\n, p-NO, p-expr); printf(Old value %u (0x%08x)\n, p-old, p-old); printf(New value %u (0x%08x)\n, now, now); p-old now; nemu_state NEMU_STOP; /* 让 cpu_exec 的循环条件失效回到提示符 */ } } }两个细节容易漏。第一触发之后必须更新p-old否则程序继续跑的时候同一个监视点会反复打印。第二多条监视点同时变化时全部打印完再置NEMU_STOP别在第一处就return不然你只看得到第一个。写完之后的验证方法我一般这么走先p $eax记下当前值再w $eax然后si几步看触发时机对不对。更狠一点的验证是w $eax 1——监视点支持任意表达式之后$eax每变一次都会触发但表达式本身的值只在特定条件下变用它来测expr_eval在监视点场景下的稳定性很有效。做完这套东西之后有个习惯我一直保留着任何新写的调试功能先用一个已知答案的输入跑一遍比如拿 objdump 的机器码去对x的输出、拿心算结果去对p的输出。调试器是后面所有实验的眼睛眼睛本身出问题你看到的每一条信息都要打折扣。从那以后我每次动expr.c或者reg.c都强制先跑一遍那几组回归数据再去看真正的目标问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表