NEMU PA1 调试器实现:单步、表达式求值与监视点

发布时间:2026/11/2 13:48:37
NEMU PA1 调试器实现:单步、表达式求值与监视点

简介:这份 PDF 文档面向正在完成计算机系统课程 PA 实验的学习者,讲解如何在 NEMU 这一简化版 x86-32 全系统模拟器上构建简易调试器,适合已具备 C 语言与汇编基础、希望打通调试与虚拟化认知的中高年级学生。文档按阶段拆解任务:先实现单步执行、打印寄存器状态与扫描内存三项基础功能,再扩展出表达式求值,最后完成监视点,并交代 git 分支整理、提交规范以及代码占 70%、实验报告占 30% 的评分构成;同时借红白机模拟器与 Hello World 的分层对比,说明 monitor、CPU、存储管理等模块各自的职责。压缩包内仅含 1 个 PDF,约 1.53MB,篇幅精炼,便于打印或随工程目录随时查阅。目前已有 2183 人学习,可作为实验前的任务梳理与进度自查清单,帮助读者明确各阶段产出与分数权重,减少返工。

1. 从一条 assertion fail 开始:PA1 简易调试器到底在做什么

NEMU 的 PA1 简易调试器,我第一次把框架代码编译跑起来,屏幕上只有一行 assertion fail——寄存器结构体还没实现,连命令行提示符都进不去。这份资源就是从这个断点开始的一套实验指导:给一个 x86-32 子集模拟器的 monitor 补上调试功能,分三段推进,先做单步执行si、打印寄存器info r、扫描内存x N EXPR,再做表达式求值p EXPR,最后做监视点w EXPR/d N。代码量 400 行上下,30 小时量级,评分里代码占七成、报告占三成,阶段一就吃掉一半的分。适合刚学完 C 和汇编、想搞清楚“一条指令是怎么被一条条喂进那个虚拟 CPU”的开发者,也适合天天敲 GDB 命令、却没自己实现过调试器的人。不用先啃 ELF 解析和 x86 解码表,但指针、结构体、递归这三样得能上手就写,否则第三个必做任务就会卡住。

2. 框架结构拆解:monitor、CPU、内存、设备四层各管什么

拿到任何一份模拟器框架,最怕的做法是从main开始顺序往下读,读三百行就迷失在没见过的结构体和宏里。这套代码的组织其实很规整,先把模块边界认清楚,后面每加一个命令都知道该往哪个文件里落笔。

2.1 目录树与四个模块的职责边界

框架把 NEMU 切成四块:monitor、CPU、存储管理、设备。前三块里,CPU、存储管理和设备合起来构成一个“虚拟计算机系统”,用户程序在这个系统上跑;monitor 站在这个系统外面,一边负责跟宿主系统交互(读写文件、打印输出),一边承担调试器职责。这个分层是整份代码里最值得先记牢的一句话,因为后面遇到的所有“这个函数该放哪”的问题,答案都在它里面。

我拿到的这版目录大致长这样,原文里点到的关键文件都在:

路径职责
nemu/src/monitor/monitor.c初始化入口、restart()、init_monitor()
nemu/src/monitor/cpu-exec.ccpu_exec()主循环,指令一条条执行的地方
nemu/src/monitor/debug/ui.cui_mainloop()、命令表cmd_table、各命令实现
nemu/src/monitor/debug/expr.c词法规则表、make_token()、递归求值
nemu/src/monitor/debug/elf.c读 ELF 符号表和字符串表
nemu/src/monitor/debug/watchpoint.c监视点结构池与检查逻辑
nemu/src/cpu/reg.creg_test(),寄存器实现的自检
nemu/src/cpu/exec/exec.copcode_table、exec(),单条指令执行
nemu/src/cpu/decode/modrm.cModR/M 字节解码,mov 指令用到
nemu/include/debug.hLog()/Assert()/panic()三个宏
nemu/include/cpu/reg.hCPU_state结构体定义
testcase/src/mov.S自带的用户程序,全是 mov 指令
obj/testcase/mov.txt上面那个程序的 objdump 结果,用来对拍

有一点要提前说清楚:opcode_table里现在大部分位置填的是inv,意思是 invalid,要么这条指令还没实现,要么 x86-32 里压根不存在。mov是完整实现了的,所以自带的测试程序能跑通。你后面每次新增指令,都是在这个数组里把某个inv换成真正的处理函数。

2.2 从 restart() 到 cpu_exec():一次运行到底走了哪几步

程序起来的顺序是:先做 monitor 侧的初始化(打开日志文件、读 ELF 符号表和字符串表、编译正则表达式、初始化监视点结构池),然后跑一遍reg_test()验证寄存器结构体实现对不对,通过之后调用restart(),最后进入ui_mainloop()等用户敲命令。

restart()干的事很集中,它模拟一次“开机”:

  • 初始化 ramdisk:把命令行传入的可执行文件从真实磁盘读进内存开头那段区间;
  • 读入入口代码 entry 到固定位置0x100000;
  • 把%eip的初值设成0x100000;
  • 初始化 DRAM 的模拟。

为什么要有 ramdisk 这个东西,值得多想一步。真实机器上可执行文件躺在磁盘里,开机后 BIOS 把它读进内存执行。这套框架把磁盘和 BIOS 都省了,直接拿内存开头一段当“磁盘”用,名字就叫 ramdisk。PA1 阶段用户程序根本用不到 ramdisk,那段区间是给 PA2 的微内核留的——内核负责从 ramdisk 里把程序捞出来放到正确位置。入口代码 entry 则是一种简化:真实机器上电后内存里是随机数据,必须靠 ROM 里的 BIOS 引导,这里直接把有意义的程序怼到0x100000并把 PC 指过去。

在ui_mainloop()里敲c,就进了cpu_exec()。这个循环模拟 CPU 的工作方式——不停取指、执行。每执行一条,exec()会把指令信息写进log.txt,所以调不通的时候第一件事就是打开这个文件,从最后几十行往前看,比在printf里瞎加断点有效得多。

退出这个循环只有两种情况:达到要求的循环次数,或者用户程序执行了nemu_trap指令(机器码0xd6,x86-32 里没有这条指令,是专门加进来标记程序结束的)。cmd_c()调用cpu_exec()时传的是-1,原因就在这里:负的循环次数意味着“不设上限,跑到 trap 为止”。谁来指示程序结束?答案不是main返回,而是那条 trap 指令。

2.3 三个调试宏与内存访问接口

Log()是printf()的加强版,会额外打印所在源文件、行号和函数名。调试信息一多,光看内容根本不知道是哪打的,这个前缀能省下大量 grep 时间。Assert()在条件为假时先输出自定义信息,再触发 assertion fail;panic()相当于无条件 assertion fail,用于“这里绝对不该走到”的分支。

内存访问统一走swaddr_read()/swaddr_write(),不要图省事直接用指针解引用去读模拟内存。swaddr、lnaddr、hwaddr 分别对应虚拟地址、线性地址、物理地址,这三个概念要到 PA3 才真正登场,现在所有地址在数值上一样,但接口先按最终形态写,将来加段机制和分页时不用回来改一遍。血泪经验是:现在省下的这点封装,后面要还三倍。

提示:swaddr_read的签名在不同版本里不一样,有的多一个段寄存器参数。以你手上这份代码的头文件为准,别照抄别人的调用形式。

2.4 分支整理:三条命令别跳过

原文一上来就强调,动手前先在工程目录下把分支理干净,否则影响成绩:

git commit --allow-empty -am "before starting PA1" git checkout -b PA1

第一条命令做一个空提交,把当前 HEAD 钉在“开始 PA1 之前”这个位置,--allow-empty是必须的,因为框架代码你可能一个字都没改过,普通 commit 会拒绝;第二条从当前点拉出PA1分支,之后所有改动都在这个分支上。这么做的好处是随时能git diff master看清楚自己到底动了哪几百行,写实验报告时把 diff 贴进去就是天然的改动清单。我见过有人直接在主干上改,最后交的时候连哪些是自己的代码都分不清。

3. 阶段一落地:寄存器结构体、单步、打印、扫描内存

阶段一占了必做任务一半的分,但难度最低,前提是寄存器结构体写对了。这一节按依赖顺序来:先把CPU_state弄对,再看命令是怎么被解析进来的,最后写三个命令的实现。

3.1 CPU_state:用匿名的 struct 套 union

i386 的通用寄存器在物理上不是八个独立的 32 位格子,而是互相重叠的:EAX的低 16 位是AX,AX又分成高 8 位AH和低 8 位AL。写AL会改到EAX的最低位,剩下 24 位不动。这种结构用普通的独立成员表达不了,必须靠 union 让同一块内存有多个视图。

/* nemu/include/cpu/reg.h */ typedef struct { union { union { uint32_t _32; uint16_t _16; uint8_t _8[2]; } gpr[8]; /* 按编号访问:0=eax ... 7=edi */ struct { uint32_t eax, ecx, edx, ebx, esp, ebp, esi, edi; }; /* 按名字访问,与上面共享同一块内存 */ }; uint32_t eip; /* 程序计数器,单独一个 32 位 */ } CPU_state;

逻辑说明:外层 union 让gpr[]数组和具名的eax、ecx等结构体成员叠在同一块 32 字节上,用下标改完用名字读,值是一致的。内层 union 处理宽度重叠,_8[2]是因为 16 位寄存器还能拆成高字节和低字节,_8[0]对应AL、_8[1]对应AH——这个顺序跟小端存储一致,别写反。

参数说明:八个通用寄存器的排列顺序必须是eax, ecx, edx, ebx, esp, ebp, esi, edi,这是 i386 的编号约定,reg_test()和后面指令解码里的寄存器编码都按这个下标取值,顺序错了测试就会 fail。eip放在 union 外面,因为它是独立的程序计数器,不参与通用寄存器的重叠。

匿名 struct/union 是 C11 的特性,编译时如果报错,检查一下Makefile里的-std选项。reg_test()会生成随机数据,反复写低位再读高位,任何一位接错都会立刻触发 assertion fail。这一步过了,NEMU 才会输出命令提示符。

3.2 命令解析:readline 读进来,strtok 切出去

交互用的是 readline 库,readline()从键盘读一行,比gets()多了行编辑能力,上下方向键能翻历史命令——shell 就是这么干的。读进来之后是一整根字符串,得切成命令名和参数。

框架用strtok()做这件事。cmd_help()里已经给了一份完整示例,照着它抄结构就行。命令表是个数组,每项是“名字 + 处理函数 + 帮助文本”的三元组,ui_mainloop()拿到命令名后在表里线性查找,找到就调对应函数,找不到就提示未知命令。

/* ui.c 里的命令表,加命令就是加一行 */ static struct { char *name; char *description; int (*handler)(char *); } cmd_table[] = { { "help", "Display information about all supported commands", cmd_help }, { "c", "Continue the execution of the program", cmd_c }, { "q", "Exit NEMU", cmd_q }, { "si", "Execute N instructions, default 1", cmd_si }, { "info", "info r: registers / info w: watchpoints", cmd_info }, { "x", "x N EXPR: scan N 4-byte words from EXPR", cmd_x }, /* 后面还会加 p、w、d */ };

逻辑说明:这个表是声明成static的,只在ui.c内部可见,加命令不需要改头文件。查表用strcmp逐个比,命令只有十来个,线性扫完全够用,别一上来就想着搞哈希表。

参数说明:handler 的签名统一是int (*)(char *),参数是命令名后面那一段原样字符串(可能为 NULL)。返回 0 表示正常,非 0 的返回值目前没有特殊含义,但保持这个约定,将来想加“命令执行失败”的语义时不用改表结构。

3.3 si、info r、x 三个命令怎么写

si最短,因为执行逻辑框架已经有了,你只是把参数转成数字传进去:

static int cmd_si(char *args) { int n = 1; /* 缺省单步一条 */ if (args != NULL) { if (sscanf(args, "%d", &n) != 1) { printf("si: invalid argument\n"); return 0; } } cpu_exec(n); /* n 为负数表示一直跑到 nemu_trap */ return 0; }

逻辑说明:args为 NULL 说明用户只敲了si,走缺省值 1;给了参数就按给的条数执行。sscanf比atoi好一点,能通过返回值判断到底有没有解析出整数,atoi("abc")返回 0 却不报错,属于把错误悄悄咽下去。

参数说明:n直接进cpu_exec,正数表示执行这么多条就返回;传-1表示不设上限。注意这里没有做“n 太大”的保护,调试时手滑敲一个si 100000,等着跑就行,中途想停只能结束进程,这是框架的现有行为。

info是带子命令的形式,用strtok切第一个词:

static int cmd_info(char *args) { char *sub = strtok(args, " "); if (sub == NULL) { printf("info: need subcommand\n"); return 0; } if (strcmp(sub, "r") == 0) { printf("eax 0x%08x ecx 0x%08x edx 0x%08x ebx 0x%08x\n", cpu.eax, cpu.ecx, cpu.edx, cpu.ebx); printf("esp 0x%08x ebp 0x%08x esi 0x%08x edi 0x%08x\n", cpu.esp, cpu.ebp, cpu.esi, cpu.edi); printf("eip 0x%08x\n", cpu.eip); } else if (strcmp(sub, "w") == 0) { /* 阶段三再补:遍历监视点链表打印编号、表达式、当前值 */ } else { printf("info: unknown subcommand '%s'\n", sub); } return 0; }

逻辑说明:cpu是全局唯一的CPU_state实例,指令执行时改的就是它,打印出来就是“此刻 CPU 长什么样”。格式上统一用0x%08x补足八位,对齐好看,跟 objdump 的输出也方便对比。

参数说明:strtok(args, " ")会原地把第一个空格改成\0,所以传进来的args被就地改写了——这一点很关键,如果同一个字符串还要用第二次,必须先备份。

x稍微绕一点,因为格式是x N EXPR,两个参数之间可能有多个空格,而表达式内部也可能有空格:

static int cmd_x(char *args) { char *n_str = strtok(args, " "); char *expr_str = strtok(NULL, ""); /* 取剩下的全部,别再用 " " 切 */ if (n_str == NULL || expr_str == NULL) { printf("usage: x N EXPR\n"); return 0; } int n = atoi(n_str); bool ok = true; word_t addr = expr(expr_str, &ok); /* 阶段二之前可以先只认 0x 开头的数 */ if (!ok) { printf("x: bad expression\n"); return 0; } for (int i = 0; i < n; i++) { word_t cur = addr + i * 4; /* 老版本签名多一个段寄存器参数,按你手上的头文件来 */ uint32_t data = swaddr_read(cur, 4); printf("0x%08x: 0x%08x\n", cur, data); } return 0; }

逻辑说明:第二个strtok的分隔符传空字符串"",意思是“把当前位置到串尾的所有内容当成一个整体返回”,空格原样保留。如果这里也传" ",表达式里一旦有空格(比如$eax + 4),后半截就被切掉了,求值结果必然是错的。循环里按 4 字节步进,一次读一个双字,打印的格式跟 GDB 的x/10xw addr类似。

参数说明:n是要打印的字数,不是字节数,实际覆盖的内存范围是n * 4字节;addr是表达式算出来的起始地址,PA1 阶段可以先只支持一个十六进制字面量,等阶段二做完再把expr()换上。

写完拿什么验证?原文给的办法很实在:直接x 10 0x100000,把内存开头那段打出来,再打开obj/testcase/mov.txt,那是用户程序mov.S的 objdump 结果,两边对着看。指令字节一致,说明你读地址没错;不一致但读出来的东西看着像代码,多半是起始地址或步进错了。这一步做扎实,后面调 PA2 的时候能省掉几天几夜的排查。

4. 表达式求值与监视点:从 token 流到“值变了就停”

阶段二和阶段三共用同一个核心:表达式求值。p EXPR直接用它,x N EXPR用它算地址,w EXPR用它反复取值判断变化。所以求值器写歪了,后面三个命令全跟着歪。

4.1 词法分析:先写规则表,再让 make_token 跑起来

“词法分析”这个词听着高端,干的事很朴素:把p $eax + 4 * 3拆成$eax、+、4、*、3五个 token,空格丢掉。硬手写状态机也能做,但规则会越加越乱,用正则更省事——框架已经把正则引擎接好了,你只要往规则表里填行。

/* expr.c 里的规则表,每条是"正则 + token 类型" */ static Rule rules[] = { { " +", NOTYPE }, /* 空格串,识别出来直接丢 */ { "\\+", '+' }, /* 元字符要转义 */ { "\\-", '-' }, { "\\*", '*' }, { "\\/", '/' }, { "\\(", '(' }, { "\\)", ')' }, { "0[xX][0-9a-fA-F]+", NUM }, /* 十六进制,必须排在十进制前面 */ { "[0-9]+", NUM }, { "\\$[a-zA-Z]+", REG }, };

逻辑说明:C 字符串里的\\最终是正则里的\,用来把+、*、(这些元字符转成字面量。顺序有讲究:十六进制那条必须排在十进制前面,因为0x2000也能被[0-9]+匹配上前面那个0,排错了就会把0当成一个独立数字,后面一串x2000直接匹配失败。

参数说明:token 类型就是个整数,不同类型的值只要互不相同即可,能直接用字符 ASCII('+'、'-')的就直接用了,可读性好;NUM、REG、NOTYPE这些另起枚举。

规则表之后是make_token()。它的工作方式很直白:一个position指针从头扫到尾,在每个位置上按顺序试每条规则,哪条先匹配并且起点正好是当前位置,就认这条。框架已经写了骨架,你要补的是“匹配成功之后怎么办”:

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++) { if (regexec(&re[i], e + position, 1, &pmatch, 0) == 0 && pmatch.rm_so == 0) { char *substr_start = e + position; int substr_len = pmatch.rm_eo; position += substr_len; switch (rules[i].token_type) { case NOTYPE: break; /* 空格,丢掉 */ case NUM: case REG: Assert(substr_len < 32, "token too long"); strncpy(tokens[nr_token].str, substr_start, substr_len); tokens[nr_token].str[substr_len] = '\0'; /* 这里不要 break,落到下面统一记录类型 */ default: tokens[nr_token].type = rules[i].token_type; nr_token++; break; } break; /* 匹配成功就跳出规则循环 */ } } if (i == NR_REGEX) { printf("no match at position %d: %s\n", position, e + position); return false; } } return true; }

逻辑说明:rm_so == 0这个判断不能省。regexec是在子串里搜“任意位置”的匹配,如果不限制起点,规则就可能匹配到中间去,position一加就越过了一段没被识别的字符,后面的 token 全乱。strncpy只对NUM和REG做,因为只有这两类需要知道具体内容——求值的时候得知道那个数是 5 还是 0xc0100000、那个寄存器是 eax 还是 esp。+、*这些只记类型就够了,类型本身就说明了问题。

参数说明:tokens数组按识别顺序存,nr_token是当前已识别数量,求值阶段就靠这两个东西定位。str成员是定长缓冲区,超长直接Assert拦下来,不要费劲去想“怎么优雅地截断”——表达式求值的核心功能和这个边界处理不耦合,先把主线跑通,这就是 KISS 法则在这里的具体含义。

4.2 递归求值:先找主运算符,再分治

token 切好之后,求值就是在这条 token 序列上做分治。BNF 定义说得很清楚:一个表达式要么是单个数字或寄存器,要么是(表达式),要么是表达式 运算符 表达式。既然长表达式由短表达式拼成,那就先算短的,再算长的。

用两个下标p、q圈出一段子表达式,求值函数的骨架如下:

/* 在 [p, q] 里找优先级最低、且不在括号内部的运算符 */ static int find_dominant_op(int p, int q) { int op = -1, i, brackets = 0; for (i = q; i >= p; i--) { /* 从右往左,保证左结合 */ int t = tokens[i].type; if (t == ')') brackets++; else if (t == '(') brackets--; else if (brackets == 0) { if (t == '+' || t == '-') op = i; /* 加减优先级最低,找到就记账 */ else if ((t == '*' || t == '/') && op == -1) op = i; } } return op; } 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 == NUM) return strtoul(tokens[p].str, NULL, 0); if (tokens[p].type == REG) return reg_read_by_name(tokens[p].str); *ok = false; return 0; } int op = find_dominant_op(p, q); if (op < 0) { /* 整体被括号包住 */ return eval(p + 1, q - 1, ok); } word_t v1 = eval(p, op - 1, ok); word_t v2 = eval(op + 1, q, ok); switch (tokens[op].type) { case '+': return v1 + v2; case '-': return v1 - v2; case '*': return v1 * v2; case '/': if (v2 == 0) { *ok = false; return 0; } return v1 / v2; default: *ok = false; return 0; } }

逻辑说明:find_dominant_op是整个求值器的心脏。它从右往左扫,用brackets计数跳过括号内部的内容,在括号外找优先级最低的运算符——加减优先于乘除。为什么从右往左?因为减法和除法是左结合的,1 - 2 - 3必须切成(1-2) - 3,从右往左扫时最后一个低优先级运算符先被记下来,又会不断被左边的覆盖,最终停在最左边那个,正好符合左结合。全扫完还是-1,说明这段要么是整体括号包起来的,要么就是单个 token。

参数说明:ok是错误标志,任何一步不合法就置 false 并返回 0,调用方据此决定打印错误还是继续。strtoul的第三个参数传0,它会根据前缀自动识别十进制和0x开头的十六进制,省掉自己判断。

一元负号-5在这个框架里不会自然支持:find_dominant_op会把-当成二元运算符,左边是空的,eval(p, op-1)里p > q直接失败。简单处理办法是在find_dominant_op里判断:如果-的左边是(或者op == p,那它就是一元负号,跳过;然后在eval里对“最左边是一元负号”的情况单独取负。指针解引用*0x2000同理,需要判断*出现在表达式起始位置或者左邻是运算符时按解引用处理,取到地址后再swaddr_read。这两个特例是这部分最容易翻车的地方,后面避坑章节还会说。

4.3 监视点:一个带旧值的链表 + 每次执行后扫一遍

监视点的语义是“表达式值变了就暂停”,拆开就是三件事:存住表达式、记住上次的值、在合适的时机重新求值对比。

typedef struct watchpoint { int NO; /* 用户看到的编号,从 1 开始 */ struct watchpoint *next; char expr[128]; /* 表达式原文,每次重新求值 */ word_t old_value; /* 上次算出来的值 */ } WP; static WP *head = NULL; /* 正在用的 */ static WP *free_ = NULL; /* 回收池,删掉的挂这里复用 */ static int nr_wp = 0;

逻辑说明:用两个链表是框架的既定设计。head是活跃监视点,free_是空闲池,w命令从free_摘一个出来挂到head,d命令反过来。这样反复增删不会一直malloc/free,虽然在这个规模下差别不大,但结构清晰,编号管理也方便。

参数说明:NO是给用户看的序号,删掉一个之后不必重排,新加的接着往上递增就行——GDB 也是这样,删了 2 号之后再建就是 3 号,不会把 2 号补上。expr存原文而不是求值后的结果,因为每次检查都要重新算,表达式里可能含寄存器和内存,值会变。

检查的挂载点在指令循环里:

/* cpu-exec.c 的 cpu_exec 循环里,每执行完一条调一次 */ bool check_watchpoints(void) { bool hit = false; for (WP *wp = head; wp != NULL; wp = wp->next) { bool ok = true; word_t now = expr(wp->expr, &ok); if (!ok) continue; if (now != wp->old_value) { printf("Watchpoint %d: %s\n", wp->NO, wp->expr); printf("Old value = %u\nNew value = %u\n", wp->old_value, now); wp->old_value = now; /* 必须更新,否则下一轮还会报 */ hit = true; } } return hit; }

逻辑说明:返回值hit为真时,cpu_exec要跳出循环,回到命令行,让用户看看现场。old_value = now这行不能漏——漏了的话,值变化之后每执行一条指令都会喊一次,屏幕上刷满重复信息,看起来像死循环。

参数说明:这里每个监视点每步都重新求值,代价是 O(监视点数 × 表达式长度),PA1 规模下完全可以接受。等以后监视点多了再考虑优化,比如只对涉及被写内存的监视点求值——但那属于二次开发范畴,先把正确性做出来。

d N的实现要注意两件事:一是从head里把节点摘下来之前,先把next指针接好;二是找到节点后不能直接free,要挂回free_。这两点看着琐碎,但漏一个就是段错误或者内存泄漏,后面避坑章节会展开。

5. 避坑排查:五个让调试变成“调自己”的坑

这一章是我自己踩过、也在别人代码里见过的几处。共同特征是:报错信息对不上真正的原因,不把这几条记住,一个 bug 能查两天。

5.1 词法与求值:正则匹配偏一位、表达式被切碎

现象:p 1+2返回 3,p 10-3返回 7,但p 0x2000+4报no match at position 1,或者把0当成一个数、x2000+4整段匹配失败。

原因:规则表里十进制[0-9]+排在十六进制0[xX][0-9a-fA-F]+前面。regexec按数组顺序试,0x2000的第一个字符0就被十进制规则吃掉,position前进一步,剩下x2000谁也匹配不上。

解决:把更具体的规则往前排,十六进制在十进制之前、==在=之前、<=在<之前,这条原则在所有 token 规则表里通用。改完拿几个边界表达式过一遍:0x0、0XFF、10/3、(1+2)*3。

现象:p $eax + 4明明写对了,结果却只算了$eax或者报错。

原因:命令解析时对参数用了strtok(args, " ")取全部剩余部分,表达式里第一个空格之后的内容被丢掉;或者反过来,在cmd_p里又strtok了一次,把表达式切成了两半。

解决:命令名之后的整段原文只能取一次,用strtok(NULL, "")(分隔符传空串)拿到尾巴,此后无论表达式里有几个空格都不再切。取到之后立刻strdup一份存起来,因为expr()内部如果再做字符串处理,原始的readline缓冲区会被二次污染。

5.2 内存与输出:读出来的字节顺序不对

现象:x 4 0x100000打印出来的值和obj/testcase/mov.txt里的 objdump 结果对不上,字节像是倒着的。

原因:把swaddr_read(addr, 4)的返回值当成“内存里那四个字节”直接按字节打印了。x86 是小端,0x100000处存的是b8 01 00 00 00这样的指令编码,按 32 位读出来是0x000001b8,低字节在前。如果期望看到的是“和 objdump 一模一样的字节序列”,就得按字节逐个读再拼,而不是一次读四个字节。

解决:想对拍 objdump,用swaddr_read(cur + i, 1)按单字节读,printf("%02x ", byte)顺序输出;想看 32 位数值,就用 4 字节读,但要清楚它是小端解释后的结果。两种输出用途不同,别混着比较。

现象:info r打印出来的eax一直是 0,但程序明明已经跑过mov指令。

原因:cpu是全局实例,exec()改的是它,打印的也是它,正常不会不一致——除非你为了图快,在cmd_info里打印的是reg_test()里那份临时局部变量,或者打印了reg_l(R_EAX)却没确认这个宏展开后读的是哪个下标。

解决:打印时直接读cpu.eax这类具名成员,最直白也最不容易出错。如果坚持用下标访问,写个断言把名字和下标对一遍,Assert(&cpu.gpr[0]._32 == &cpu.eax, "gpr index mismatch"),跑一次就能确认布局。

5.3 监视点与执行控制:删不掉、老重复、停不下来

现象:d 2之后info w里 2 号确实没了,但程序跑到那一步仍然会暂停并打印 2 号的提示。

原因:从head摘节点时只改了当前节点的next,没有改前一个节点的next,链表断成了两截,head侧还留着一段旧链;或者节点挂回了free_却没从head摘掉,两边都指着它。

解决:删除单向链表节点必须拿到前驱,写一个find_wp(int no)和一个remove_wp(WP *prev, WP *wp),先接prev->next = wp->next再处理wp自己。调试期可以在每次增删后把整条链打出来,肉眼确认结构对得上。

现象:监视点第一次命中之后,之后每条指令都打印一遍同样的 old/new。

原因:命中时只打印,忘了把wp->old_value更新成新值。下一轮比较时旧值还是最开始那个,自然一直不等。

解决:打印之后立刻赋值。同时想清楚“暂停之后用户继续执行”的语义:是继续用新的 old_value 往下比,还是保持暂停时刻的值?按 GDB 的习惯是更新,因为监视点关注的是“值发生变化”这个事件,不是“值不等于某个固定数”。

现象:c之后程序一直跑,永远不停;或者si 100000敲下去回不来。

原因:cpu_exec的退出条件只有两个——达到循环次数、遇到nemu_trap。如果用户程序里没有 trap 指令(比如自己手写的测试汇编忘了加),或者 trap 的解码没在opcode_table里注册,循环就永远不会退出。

解决:检查testcase/src/mov.S结尾是不是有那个0xd6字节;确认opcode_table里对应的处理函数把nemu_state置成了END。另外,cpu_exec的循环次数参数是int,传-1表示不限次数,写条件时注意别把负数判成 0 次。

6. 二次开发:把调试器改造成能自动跑的测试脚本

框架给的调试器是交互式的,敲一条执行一条。真要用它长期干活,最有价值的一步改造是让它支持“脚本化”:把一串命令写进文件,让 NEMU 读进来顺序执行,跑完输出结果。这样回归测试就能自动化,改了求值器之后一条命令跑完全部用例,不用手敲二十遍。

改造点在ui_mainloop。现在它是readline()拿一行、查表、执行,可以把输入源抽出来:

/* 新增 cmd_source:source FILE 逐行执行文件里的命令 */ static int cmd_source(char *args) { FILE *fp = fopen(args, "r"); if (fp == NULL) { printf("source: cannot open %s\n", args); return 0; } char line[256]; while (fgets(line, sizeof(line), fp) != NULL) { line[strcspn(line, "\r\n")] = '\0'; /* 去掉行尾换行 */ if (line[0] == '\0' || line[0] == '#') continue; /* 复用主循环的“解析 + 查表”逻辑,抽成 do_command(char *line) */ do_command(line); } fclose(fp); return 0; }

逻辑说明:关键动作是把主循环里“切命令名、查表、调 handler”那段抽成独立的do_command(),交互模式和文件模式都调它。抽出来之后,source只是换了个输入来源,其余行为完全一致。

参数说明:line开 256 字节够用,表达式一般不会更长;strcspn用来兼容 Windows 换行;#开头当注释跳过,方便在脚本里写说明。

配套的做法是准备一份对拍脚本:

# 每次改完求值器就跑一遍,输出和预期 diff 一下 ./nemu -b -l log.txt testcase/mov & # 或按框架的实际参数形式传可执行文件,然后: echo "source cases/expr.cmd" | ./nemu

更省事的办法是给p命令加个静默模式,只输出结果值不带提示语,然后把结果重定向到文件,跟一份手工算好的期望值diff。这一步做完,后面无论怎么重构求值器,心里都有底。

另一个值得动的地方是给x命令加宽度参数,x 10w 0x100000按 4 字节、x 10b按 1 字节,格式解析放在原有的两个参数之前做一个前缀判断就行,改动量不到三十行,但对拍 objdump 的时候顺手很多。

我自己的习惯是:每次动完求值相关的代码,先跑一遍source cases/expr.cmd,输出对不上就立刻回退,绝不带着一个“看起来对”的求值器去写监视点。踩过一次坑——求值器里一元负号没处理,p -1返回 0,当时没在意,结果监视点表达式里写了-$ebp,调试了整整一个下午才发现问题根本不在监视点。从那以后我每次改表达式求值,都强制走一遍脚本对拍再往下写。希望帮到你。

本文还有配套的精品资源,点击获取