简介:NEMU PA1是一个围绕模拟器NEMU与简单调试器(sdb)的计算机系统实践项目,这份PDF以精炼篇幅梳理了其概述、核心原理与实际应用,适合正在学习计算机系统课程、准备搭建或研究NEMU实验的学生开发者参考。文档覆盖了单步执行、打印寄存器、扫描内存、表达式求值等关键调试器功能,并讲解CPU模拟、内存表示与可选设备模拟的实现机制,能够帮助读者理清调试器与模拟器之间的协作关系。同时,文档也结合计算机系统教学、体系结构验证以及嵌入式软件测试等典型场景,说明了NEMU PA1在理解指令执行、内存访问和开发调试中的实际价值。资源采用单个PDF文件封装,大小仅74KB,内容紧凑、便于快速阅读。目前已有797人学习下载,可作为构建NEMU实验环境、理解模拟器内部工作的入门导览资料。
1. 先说结论:为什么模拟器的第一步不是CPU,而是调试器
大多数第一次拿到“写一个模拟器”任务的人,第一反应都是把CPU的取指执行循环先跑起来。但PA1的编排恰恰相反——第一个阶段的核心不是模拟器自己,而是给模拟器配一把手术刀:一个叫sdb的简单调试器(Simple Debugger)。反直觉的地方在于:模拟器本身是个黑匣子,你写完CPU循环之后,根本不知道它执行到了哪条指令、寄存器变成了什么、内存被改成了什么,只能靠printf瞎猜。调试器不是锦上添花的工具,而是唯一能对这个黑匣子做“内窥”的入口,是后续所有阶段开发的基础设施。搞懂PA1,本质上是搞懂这么一件事:怎么把一台看不见内部的模拟器,变成一座随时可观察、可操控的透明机器。
2. CPU与内存的软件化:取指循环和字节数组是怎么撑起一台虚拟机的
2.1 取指-译码-执行循环:把ISA搬进C语言的三个显式步骤
模拟器最核心的驱动力,是一个看似简单到不值得讲的循环。真实CPU里取指、译码、执行是高度并行的流水线,但软件模拟通常把这套硬件行为拍扁成“一拍一循环”,每拍做三件事:从PC对应的guest地址读指令字节;按ISA规则识别这条指令的含义;执行它,并推进PC。
把这条循环写对,是PA1里“客户程序能跑起来”的前提。但这里有个新手极容易翻车的细节:PC到底是什么时候推进的。顺序执行的指令,PC当然是在取出指令后加一条指令的长度;可分支指令要跳到目标地址,执行完必须把PC覆盖成目标值。如果代码写成“最后统一更新PC”,分支指令就得特殊处理“我到底该不该改PC”,规则多且容易漏。
常见的实现思路是先推进、后执行。取出指令时先让cpu.pc += 4,再调用指令的处理函数。顺序指令到这里就完事了;分支指令在执行阶段直接覆盖cpu.pc,顺序推进的+4立刻失效。这样所有指令共享同一个推进逻辑,唯一需要特判的是“会改PC”的那几条。
void cpu_exec_once(void) { vaddr_t pc = cpu.pc; // 先取住当前PC,调试器要用的“当前指令地址” word_t inst = vaddr_read(pc, 4); // 从内存池读4字节,这是取指 cpu.pc = pc + 4; // 先按顺序推进,给每条指令上“默认保险” exec_inst(inst); // 真正执行;若是分支指令,会直接改写cpu.pc }这里vaddr_read(pc, 4)里的“vaddr”值得注意:PA1阶段guest地址和物理地址是同一套编号,地址就是数组下标,所以读内存不过是带边界检查的memcpy。exec_inst则是一张巨大的switch分发表——按指令的操作码字段分发到对应的处理函数。这个循环的显式三步:取住PC、按PC取指、执行指令,构成了整个PA1的“心脏跳动”。调试器里的“单步”,本质就是控制这个循环只跳一次。
理解了这个循环,你就理解了为什么模拟器被称为“状态机”:CPU的全部状态(PC、寄存器组、内存池)在每一拍前都是确定的快照,取指执行只是把这个快照迁移到下一个快照。所谓模拟,就是精确复现这个状态迁移序列。
2.2 内存=一个字节数组:数据总线与地址线如何简化为mem[addr]
PA1阶段的内存模拟,简陋得让刚接触的人觉得“这也能算模拟”?guest的整个物理内存,不过是宿主机进程里用calloc分配的一大块字节数组。没有TLB、没有页表、没有段描述符,甚至连虚拟地址的概念都不存在——地址就直接当数组下标用。
这种简化背后的逻辑是:模拟器在PA1阶段只需要回答一个问题——地址到字节的映射关系是什么?至于地址从哪来、是不是虚拟地址、要不要经过页表翻译,那是后续阶段才引入的新维度。在“只有一个地址空间”的设定下,最诚实的表示就是guest_mem + addr做偏移的字节数组访问。
uint8_t *guest_mem; // 初始化时按配置大小分配,例如128MB word_t host_read(vaddr_t addr, int len) { word_t val = 0; memcpy(&val, guest_mem + addr, len); // 小端法:低字节放低地址,直接memcpy即可 return val; }用memcpy而不是逐字节拼凑,是“以宿主机的整数表达guest整数”的偷懒写法。小端法机器上,guest和host字节序一致,这一行memcpy就等价于硬件上“数据总线把4字节搬进CPU寄存器”。如果host是大端,这里就要手动swap——所以PA1的代码里往往写死小端假设,这也是为什么这类模拟器换到大端机器上运行会“莫名其妙全错”,根源就在这一层字节序约定上。
内存池的另一个作用是让“扫描内存”这个调试功能变得极廉价。真实硬件上你想看0x80000000处的内容,得经过地址总线和内存控制器;模拟器里就是一次带边界检查的指针偏移,然后按十六进制格式化打印。
2.3 状态暴露:寄存器、PC与“关机指令”如何构成最小可执行模型
模拟器的guest状态,集中在一两个结构体里:cpu.pc表示当前指令地址,cpu.gpr[]表示通用寄存器组,外加一个全局的nemu_state表示模拟器运行状态。PA1阶段能看到这些,再加上内存池,就已经构成一个完整的最小可执行模型。
“客户程序跑起来”这件事,在这个模型里被描述成:cpu_exec(n)循环执行n次,或者一直跑到nemu_state变成“关机”。什么时候算关机?当执行到约定的关机指令(通常是hlt或break)时,模拟器置关机标志、退出循环。
void cpu_exec(uint64_t n) { for (int i = 0; i < n && nemu_state != NEMU_QUIT; i++) { cpu_exec_once(); } }这个nemu_state是个容易被忽略的精妙设计:它把“模拟器的生死”从执行逻辑里拆了出来。CPU循环自己不需要关心“我什么时候该停”,它只负责每拍检查一下状态标志。关机指令、调试器的单步命令、甚至未来PA里异常处理的触发,都通过改写这个状态来“接管”CPU的控制权。这也是调试器能插手的第一个缝隙——单步执行只要调一次cpu_exec(1),观察者就能在返回后读取完整的CPU快照。
PA1阶段不要求设备模拟,因为“设备”本质上是“CPU和内存之外的东西”。没有设备,客户程序照样可以算算术、跑循环、访问内存;有了设备反而引入中断和I/O端口这些复杂概念。这一章的设计逻辑是:先保证状态机自己转得起来,再在状态机上开观察窗口。调试器就是这个观察窗口。
3. sdb的原理:单步、内存扫描与表达式求值背后的“暂停-检查-操控”闭环
3.1 单步执行:让执行循环只跑一拍的控制权转移
PA1里sdb的命令集很小,通常只有help、c、q、si [N]、info r、x N EXPR、p EXPR这么几条。但每一条命令背后都对应着一个“暂停-检查-操控”的闭环设计。先看单步:
sdb运行时,模拟器处于一种“等待命令”的暂停态。所谓暂停,不是CPU真被冻结了,而是执行循环根本没被调用——主线程阻塞在读命令行的输入上。这时用户敲下si 10,sdb内部做的事情只有一件:调用cpu_exec(10)。10拍执行完,控制权又回到sdb手里,接着等待下一条命令。
static void cmd_si(char *args) { int n = (args == NULL) ? 1 : atoi(args); // 不带参数就默认单步1拍 cpu_exec(n); }这个机制的关键在于:调试器和模拟器并不是两个并行运行的程序,而是同一个进程内“你方唱罢我登场”的协作关系。sdb是外层循环,cpu_exec是内层循环;内层循环每执行n拍就把状态让渡给外层。所谓“断下来”,就是内层循环把控制权交出来,并且不再自动交回去。理解这个协作关系,你才明白为什么PA1的调试器实现起来如此干净——它不需要任何进程间通信、信号处理或远程调试协议,一次普通函数调用就能实现“暂停”。
3.2 内存扫描与寄存器视图:从guest状态到可读文本的格式化管线
info r和x这两条命令,本质都是“读状态、格式化、打印”。info r直接从cpu.gpr[]取寄存器值打出来,没有任何魔法。x N EXPR则稍微复杂一点:先用表达式求值得到起始地址,然后连续读N个字,每读一个就按十六进制打印,必要时附上ASCII列。
static void cmd_x(char *args) { int n = atoi(args); // 第一个参数:打印多少个字 char *p = strchr(args, ' '); vaddr_t addr = expr(p + 1, NULL); // 第二个参数:表达式,算出起始地址 for (int i = 0; i < n; i++) { word_t val = vaddr_read(addr + i * 4, 4); printf("0x%08x: 0x%08x\n", addr + i * 4, val); } }这段代码里,expr(p + 1)是你自己写的表达式求值函数,它在这里第一次被实际调用。“扫描内存”这个功能的价值,在调试真实客户程序时才会显现:一条指令写坏了某个地址,你用x一扫就能对比前后内容,立刻定位是哪一步污染的。PA1里养成“出事先扫内存、不要瞎猜”的习惯,后面PA2的diff测试、PA3的os实验都会受益。
这里的“格式化管线”思路值得单独说:调试器本质是一个“状态转文本”的转换器。寄存器、内存、PC都是二进制数值,只有通过你规定的格式(十六进制、符号名、地址)变成人能读的文本,调试才有意义。所以info r的打印格式本身也是设计的一部分——对齐的列、固定的字段宽度,都是在降低人眼扫描信息的成本。
3.3 表达式求值:词法切分与递归下降如何把字符串变成数值
p命令是PA1里最具“编译原理味”的部分。它要让用户输入0x80000000 + 2*($pc + 4)这样的表达式,然后算出数值。这里没有现成的eval()函数可用,因为这是一个典型的“把中缀表达式变成语法树再求值”的问题,需要自己实现词法分析和语法分析。
词法分析阶段,把表达式字符串切成token流:数字、变量($pc、$rax这类)、运算符(+ - * /)、括号。这里有个经典陷阱:十六进制数0x1024里的字母x和乘号x混淆。正确处理是让词法分析把0x…整体当成一个数字token,而不是把0、x、1024分开识别。
语法分析阶段,最常见的教学实现是递归下降。它的直觉是:把算符优先级编码成函数调用的层次。加减运算由eval_add处理,它调用处理乘除的eval_mul,再往下是一元负号和括号,最底层是primary(数字或变量)。因为调用顺序天然从左到右,乘除永远比加减先求值。
static int eval_add(Token *tokens, int *pos) { int val = eval_mul(tokens, pos); // 先求乘除部分 while (tokens[*pos].type == TK_ADD || tokens[*pos].type == TK_SUB) { int op = tokens[*pos].type; (*pos)++; int rhs = eval_mul(tokens, pos); // 再取右操作数 val = (op == TK_ADD) ? val + rhs : val - rhs; } return val; }这段代码的巧妙之处在你面前不用纸笔画一遍函数调用过程时很难体会。假设输入1 + 2 * 3:eval_add先调用eval_mul得到1,发现+成立,于是再次调用eval_mul——这次eval_mul内部会完整算出2*3得到6,所以+的右操作数是6不是2。优先级不是靠“规定”,而是靠函数调用关系强约束出来的。你不需要再把优先级写成一个数字表去比较,调用深度已经隐含了秩序。
一元负号的处理是在eval_mul之下再嵌套一层eval_unary:遇到减号就取反后面的primary。PA1的表达式求值做到这里,已经覆盖了“变量、括号、一元负号、乘除加减”这个最小闭环。很多同学在这个阶段第一次真正理解“什么是语法树”——它不在内存里显式存在,但函数调用栈就是它的后序遍历。
3.4 断点(进阶):为什么PA1不强制做,而它与后续阶段衔接
PA1的spec里通常不强制实现断点命令(b和bl),原因是断点会引出一个重要的机制问题:如何在内存里临时修改指令。最简单的实现思路,是先把目标地址的原始指令保存下来,用一条会触发异常的特殊指令(如breakpoint陷阱指令)覆盖它,客户程序执行到此处时模拟器停下,sdb再恢复原始指令继续执行。
这个“保存-覆盖-恢复”的三段式操作,涉及指令缓存的同步、多断点的排序、以及“单步到断点处时怎么办”的边界情况,复杂度远超PA1阶段愿意投入的工程量。所以PA1的定位是先把“可观察”做扎实,断点这类“可操控”能力留给后续阶段。我一般会这么理解:单步是“手动挡”,断点是“定速巡航”——前者只需要控制循环拍数,后者要真正侵入指令流。
这种进阶能力对理解后续PA的意义在于:当你未来要往模拟器里加入异常处理机制时,断点是否被正确恢复、是否在错误的地方恢复,会成为验证CPU异常行为是否正确的试金石。PA1不做的功能,往往是它要为“未来能做对”所铺的伏笔。
4. 常见问题与避坑:调试器对接模拟器时的几个隐藏坑
4.1 为什么所有命令必须走统一入口,而不是直接改状态
sdb的命令处理通常用一个大的strcmp分支表:解析出命令名,匹配到处理函数。但比分支本身更重要的是“所有对模拟器状态的改动都必须在sdb语境下完成”这个约束。如果你在某个命令处理函数里直接改写cpu.pc绕过cpu_exec,你就在创造一种“调试器外的状态变化”——后续排查时你会分不清当前状态是模拟器执行产生的,还是你手动改出来的。
统一入口的核心价值在于:可重复性和因果关系可追溯。每一条命令的执行效果,要么是“让执行循环跑n拍”,要么是“读取并打印状态”,二者必居其一。这样状态机迁移的唯一动力来源只有cpu_exec,任何异常都能回溯到“是哪条命令、哪一步触发的”。
4.2 地址语义、字节序与符号扩展的常见误解
x命令里的地址、表达式求值里的数值、vaddr_read里的参数,这三者到底是同一回事吗?PA1阶段它们确实都指向同一个guest地址空间,但类型上往往有区分:地址用无符号的vaddr_t,算术结果用有符号的int。如果表达式算出负数,被自动转成无符号地址,会变成一个巨大的正数,然后触发内存越界。这类bug症状是“输入合法表达式却报地址越界”,真凶藏在符号扩展里——负数的二进制补码在无符号视角下是一个天文数字。
字节序的问题则更隐蔽。前面说过memcpy能直接工作依赖小端假设,但如果你在host_read里手写了“按字节读再拼装”的代码,拼装顺序写反了,就会得到字节倒置的指令值。症状是PRINTF调试时所有指令看起来都“认识”,但执行结果完全不对——因为你读到的指令数值虽然对,字节序却颠倒,exec_inst分发时永远进不了正确的分支。
/* 错误示范:字节序手拼写反了 */ word_t val = (guest_mem[addr+3] << 24) | (guest_mem[addr+2] << 16) | (guest_mem[addr+1] << 8) | guest_mem[addr]; /* 这在小端机器上等价于把四字节倒过来读,指令码全错 */4.3 具体踩坑记录:现象、原因、解决
踩坑一:单步执行后PC纹丝不动,指令重复执行现象:si命令能执行,但每次停下来打印寄存器,PC值完全相同,程序像在原地打转。 原因:cpu_exec_once里忘了在取指后推进PC,顺序指令没有更新PC。 解决:把PC推进从“执行完统一更新”改成“取指后立即+4,分支指令覆盖”,与前面代码一致。
踩坑二:输入0x80000000时表达式报语法错误现象:p 0x80000000提示“非法token”,但p 2147483648能正常算出数值。 原因:词法分析把0x后面的x当成了字母或乘号,没有把0x加整个数字串识别为十六进制token。 解决:词法层在遇到0x后连续消费十六进制数字字符,合成一个TK_INT,而不是让词法层之外的语法层去拼接。
踩坑三:一元负号无法解析,p -1报错现象:p 1-2正常,但p -1或p -1 + 2直接报语法错误。 原因:语法分析只在primary里期望数字或括号,没给一元负号留位置,遇到行首的减号就没法消化。 解决:在乘除层之下插入eval_unary,识别前置-并递归调用自身,实现取负语义。
踩坑四:内存扫描越界,直接触发panic而不是报错现象:x 10 0x7fffffff瞬间panic退出,sdb整个崩掉。 原因:vaddr_read的边界检查是panic式院判,一旦地址超池就停机;这在教学上是故意的(优于越界访问宿主机内存),但调试时容易误伤。 解决:先查内存配置大小,扫描前手动限制打印范围;另外意识到“非法访问立即panic”是刻意设计,排查方向应该是“为什么地址越界”而不是“为什么panic”。
踩坑五:info r打印出的寄存器值全是0或宿主机数值现象:所有寄存器都打印成0,或者偶尔打印出一些不像guest寄存器值的乱数。 原因:info r从宿主机变量里读寄存器数据,而不是从模拟器维护的cpu.gpr[]结构体读;比如误把%eax当成guest的eax来用。 解决:guest架构的所有寄存器状态只允许存在cpu结构体里,禁止用宿主机原生寄存器变量去承载guest寄存器状态。
5. 设计取舍的思考:教学模拟器为什么用“调试器先行”换取可见性
PA1这套设计里藏着三个值得玩味的取舍,它们共同回答了一个问题:为什么可见性比性能、比完整性更重要。
第一个取舍是“调试器先行,指令集靠后”。按常规直觉,先把指令集实现完再写调试器更“工程”。但这个项目反着来,因为教学场景的痛点不是“模拟器跑得慢”,而是“跑错了不知道错在哪”。调试器先行的直接收益,是你在写每条指令时都能立刻验证:单步看PC推进、扫描内存看效果、打印寄存器看结果。没有这个闭环,后续PA里复杂指令集实现时遇到bug,你会在“指令错、状态错、还是客户程序错”三个可能性里反复折腾。
第二个取舍是“内存用扁平字节数组,不搭虚拟内存框架”。这不是偷懒,而是刻意限制问题域。PA1要教的是“模拟器基础机制”,虚拟地址翻译是另一个独立难点,混在一起会让新手同时面对两个问题,哪个都搞不清。扁平数组让地址的物理含义极简单,一旦引入分页机制,就自然理解“为什么需要一个额外的地址翻译层”——因为你有了“看起来像地址但不是数组下标”的东西。
第三个取舍是“递归下降手写表达式解析,不引lex/yacc”。用工具生成解析器当然更省事,但你学不到“优先级到底是怎么生效的”。手写递归下降让你亲眼看到:语法规则如何被翻译成函数调用顺序,函数栈如何天然承担了语法树的遍历。这既是一次编译原理的微实践,也为后续课程打了一个重要的认知地基。
我对PA1最深的体会是:拿一台“能执行任何程序”的机器去教架构,远不如拿一台“能看见任何内部状态”的机器去教架构。调试器让模拟器从黑匣子变成了透明的状态图。如果你在做PA1,我建议你在完成基本命令后,做这么一个小实验:给自己写一条递归调用的客户程序,在递归进入和退出时分别打印调用栈附近的x内存——你会亲眼看到栈帧的创建与回收,那种“我真切看到了内存生长”的感觉,比任何文档都直观。从那以后,我每写完一个模拟器类组件,都强制自己先给它配一个最小观察工具,再开始写功能本身;先让机制可见,再让机制复杂。这一步省下的排查时间,远比写工具的时间多。希望这份解读能帮你在PA1里少走几步弯路。
本文还有配套的精品资源,点击获取