ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

NEMU模拟器调试器实现:寄存器布局、命令解析与表达式求值

NEMU模拟器调试器实现:寄存器布局、命令解析与表达式求值

简介:这份 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 位叫 AX,AX 又劈成 AH 和 AL,这四者在物理上不是四份独立的存储。你在 C 里怎么表达「四个名字、一块内存、不同宽度」?只有 union。框架代码里nemu/include/cpu/reg.h的那个结构体,核心就是一段嵌套的 union:

typedef 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 那三位的取值表:0=eax,1=ecx,2=edx,3=ebx,4=esp,5=ebp,6=esi,7=edi。我一般会这么理解这层设计动机——译码器拿到一条指令,从 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 fail,NEMU 连命令行提示符都不给你。它的思路值得单独说,因为它示范了这类「内存布局」代码该怎么验证:

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就是"10",x 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_write,d、w这类命令将来要靠它改内存。len参数只允许 1、2、4,用assert卡住。这个断言不是形式主义:如果哪天你误传了个 3,paddr_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 序列拿到了,开始递归求值。递归的合法性来自算术表达式的 BNF:

<expr> ::= <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 -1+2这种写法里,-在p命令的参数开头,前一个 token 不存在,必须按一元处理,值是 1 而不是「0 减 1 加 2 再减点什么」。一元负号在递归求值里走的路径也不是主运算符分支,而是eval(p, q)一进来就检查「首个 token 是不是-」,是的话对[p+1, q]求值再取负。

括号的判断单独一个函数check_parentheses(p, q):先确认tokens[p]是(、tokens[q]是),然后从左往右数括号深度,如果在中途(还没走到q)深度就归零了,说明第一对括号没有包住整段,比如(1+2)*(3+4),这时候不能剥括号,得去找主运算符。这个判断漏了的话,(1+2)*(3+4)会被剥成1+2)*(3+4直接崩在越界上。

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/4,assert会直接把程序掐掉。

按addr + i * 4改完之后,还要拿obj/testcase/mov.txt对一遍。mov 用户程序的第一条指令在0x100000,objdump 里那行形如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 1+2*3得到 9。

主运算符选错了,选了最左边的+,于是1 + (2*3)变成(1+2)*3。根因是priority的比较方向反了,把「优先级数字大」当成了「优先级低」。另一种等价错误是遍历方向从左往右,而且用了<=更新,同优先级里留下最左边那个。

核对方法很直接:p 1+2*3必须是 7,p 10-3-2必须是 5,p 100/10/5必须是 2。这三条过了,优先级和结合性基本就站住了。

现象五:p -1+2结果不对,或者程序直接崩。

一元负号被当成了二元减法。find_dominant_op扫到-时如果没做前驱判断,会把它当成主运算符,左子树是空区间[p, i-1],递归进去p > q,越界或者返回垃圾值。

判断前驱 token 的逻辑要写在一个统一的地方,别在两个函数里各判一次——求值路径和主运算符查找路径对「是不是一元」的结论必须一致,否则会出现「主运算符认为它是二元的,求值时又按一元处理」这种自相矛盾。p -1+2应该是 1,p 2*-3应该是 -6,p -(1+2)应该是 -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比,不等就打印新旧值、把状态置成暂停

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

返回列表