ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

深入NEMU模拟器:从启动流程到指令解码与差分测试

2026/10/7 18:30:51 拓冰建站 浏览量
深入NEMU模拟器:从启动流程到指令解码与差分测试 1. NEMU学习路径与整体框架1.1 为什么预学习阶段要死磕NEMU代码先聊个大家普遍困惑的问题一生一芯的预学习阶段为什么要花大量时间在NEMU上我最初也以为这就是个简单的教学模拟器随便跑通就完事了。但真正深入之后才发现NEMU其实是把整个计算机系统的核心链条——取指、译码、执行、访存、写回、中断、外设——用最简洁的C代码串了起来。你不需要像看真实CPU数据手册那样面对上千页文档但又保留了和真实硬件几乎一致的关键逻辑抽象。这一点非常关键学体系结构最忌讳的就是只背概念NEMU逼着你去读代码、改代码、调代码把抽象的“程序计数器”“寄存器堆”“内存映射”变成实实在在的变量和函数调用。这一篇是NEMU代码学习的第二篇我会把重点放在代码结构和执行主循环上。如果你已经跟着官方文档把环境配好、把NEMU跑起来、并且能通过前几个简单测试那这篇文章正好帮你把框架吃透。还没跑通的同学也别急我会从整个目录结构讲起把“每一份代码到底在干什么”完全交代清楚看完之后你至少能做到打开任意一个文件能准确说出它属于哪一层、负责什么、和谁交互。1.2 NEMU代码目录到底怎么组织NEMU的代码量不算大但目录规划非常讲究。我第一次打开源码时面对一堆文件夹有点懵后来才发现它的组织逻辑其实和真实处理器设计中的模块划分高度一致。总体来说主要分这么几块src/engine/模拟器引擎入口负责初始化、主循环、内存申请等基础设施。src/monitor/监控层负责命令行交互、单步执行、打印寄存器、sdb调试器简易调试器等相当于给模拟器装上了一个操作界面。src/cpu/CPU核心抽象层最核心的cpu_exec()和exec_once()就在这里。src/isa/指令集架构相关代码里面按照不同架构分成riscv32/、riscv64/、x86/等子目录每个子目录下包含寄存器定义、指令解码表、指令执行宏等。一生一芯预学习阶段主要碰riscv32。src/memory/内存模拟实现包括物理内存分配、地址读写映射、以及后续的MMIO内存映射I/O支持。src/device/设备模拟层NEMU后期会挂上串口、时钟、键盘、VGA等设备这部分代码就是为它们准备的。src/isa/riscv32/下的reg.c、inst.c、init.c是用户改动最多的几个文件——指令添加、寄存器初始化、指令解码宏都在这。一个很重要的点是NEMU把“模拟器通用部分”和“ISA相关部分”做了清晰切割。通用的执行循环、内存模拟、显示器放在外层而具体的指令语义、寄存器数量、位宽定义、特权模式等全部下沉到src/isa/riscv32/这个子目录里。这样做的好处非常明显——以后你切换到 riscv64或者想尝试添加一条新指令只需要动ISA相关文件完全不用碰外层的模拟器框架。这种“接口稳定、实现内聚”的设计思路本身就是很好的工程实践教材。2. 启动流程和执行主循环2.1 从main函数到第一个指令执行很多人看NEMU源码第一个动作就是直接打开src/main.c但这样容易一头扎进细节出不来。更合理的方式是先俯瞰一遍整个启动流程。main函数做的事情非常直白先调用init_monitor()完成一系列初始化包括解析参数、读入镜像文件、初始化内存、初始化寄存器、设置调试器状态然后调用ui_mainloop()进入用户交互循环。在交互循环里你可以输入c让CPU连续执行输入si单步执行输入info r查看寄存器输入x查看内存等。如果你是在批处理模式下运行NEMU会直接执行命令然后退出。这里我特别想强调init_monitor()的内部顺序。它不是随便乱调的而是严格遵循依赖关系先解析命令行参数确定镜像文件路径、批处理模式、差分测试开关等。初始化内存也就是为模拟的物理内存分配host端的一段缓冲区。初始化寄存器包括把PC设置到复位地址、通用寄存器清零。加载镜像到内存的指定位置常用的是0x80000000RISC-V架构约定的启动地址。最后才进入命令循环。这个顺序背后是有一条逻辑链的没有内存就没法加载镜像没有镜像就谈不上执行程序所以init_mem()必须最先完成。你可以把NEMU理解成一个“电子积木”每一块都得按照正确的顺序插好整台机器才能通电运转。想想真实CPU的上电复位过程也是类似——先把缓存和TLB清零、把PC指向复位向量、再开始取第一条指令。2.2 cpu_exec与exec_once到底怎么配合执行主循环是NEMU最重要的核心部分代码量不大但理解它等于理解了CPU运行的基本模型。先说cpu_exec()。这个函数接收一个参数n表示要执行的指令条数。它会先判断当前是否处于NEMU的“运行状态”比如是否遇到了断点、是否被调试器中断如果正常就进入一个循环每次循环调用一次exec_once()执行一条指令同时用一个计数器累加已执行指令数直到执行完n条、或CPU状态发生变化如遇到nemu_trap、或收到外部中断才会返回。exec_once()是单条指令执行的入口函数内部做的是标准五步取指inst_fetch根据当前PC从内存中读取一条指令。RISC-V32的指令长度默认是4字节但为了支持压缩指令RVC需要额外判断最低两位。译码decode_exec把指令拆解成操作码、功能码、寄存器索引、立即数等字段然后去指令表里找到对应的执行函数。执行执行函数内部真正完成运算、访存、跳转等行为并更新寄存器或内存。更新PC大多数指令默认pc 4但跳转指令会直接修改PC为目标地址。更新指令计数返回本指令执行了多少字节通常是4方便上层统计。我觉得最有意思的是exec_once的返回值设计。它返回的不是“成功”或“失败”而是这条指令实际占用的字节数。为什么这么设计因为后续如果要支持压缩指令16位或者指令集扩展取指长度不再固定这个返回值就变得非常关键。NEMU用这种看似多余的返回值保留了未来扩展的空间。这提醒了我们一个道理好的代码会为未来留接口而不是只满足当下需求。2.3 指令解码的核心机制指令解码是NEMU预学习阶段最容易卡住人的地方之一。我第一次看src/isa/riscv32/inst.c里的各种宏定义时头都是大的。但一旦理解了它的工作方式你会发现这套机制极其精巧。NEMU的解码方式本质上是一个“查表模式匹配”的过程。每条32位指令被当作一个整数通过掩码和移位提取出不同的字段然后与指令模式表中的条目做匹配。指令模式表是一个结构体数组每一项包含三要素指令的机器码模式即哪些位必须固定为某些值掩码哪些位需要参与匹配对应的执行辅助函数指针举个例子RISC-V的addi指令操作码是0010011funct3是000那么它的模式就是低7位必须是0010011中间3位必须是000其余字段可以任意。NEMU在匹配时将读入的指令和掩码做按位与再把结果与模式比对一致就说明命中。但是——这里有个很关键的细节——NEMU并不是简单地逐条遍历所有指令去匹配。它采用了一种基于“分派表decode table”的二级分发机制第一级根据操作码opcode的低几位快速定位到一组相关的解码函数。第二级再根据funct3、funct7等字段在组内找到具体的指令实现。这种分级方法本质上和真实CPU里的“译码逻辑树”是一样的思路。如果你见过真实的RISC-V处理器译码模块会发现它也是先用opcode粗分类再用funct字段精确定位。NEMU虽然是个教学模拟器但在这个环节上却保持了和硬件设计的高度一致。这一点我刚开始没有意识到直到后来用Verilog写处理器的时候才恍然大悟原来NEMU早就在软件层面帮我预习了一遍硬件的译码树结构。3. 寄存器、内存与实例级别的读写实现3.1 寄存器堆的实现细节RISC-V架构有32个通用寄存器x0~x31外加一个PC寄存器。NEMU在src/isa/riscv32/reg.c中用结构体定义了一个CPU_state里面的gpr[]数组就是通用寄存器堆pc就是程序计数器。一个大坑是RISC-V规范里x0寄存器是硬连线的零寄存器对它写入不生效读取恒为0。这一点在NEMU里通过gpr()这个宏做了统一封装。你去看代码会发现凡是向通用寄存器写值的操作基本都走gpr(rd) ...或者set_gpr(rd, ...)而读取操作走gpr(rs)。有的同学偷懒在实现指令的时候直接用cpu.gpr[rd] ...访问底层数组结果遇到rd0的指令时行为就错了。正确做法是沿用NEMU提供的宏或者自己封装一层写函数。为什么因为x0的“只读为零”是个架构级约束不是某条指令的特殊情况把约束封装在统一的入口里才能保证所有指令都遵守它。另一个细节是寄存器名字和编号的映射。src/isa/riscv32/reg.c里维护了一张表把寄存器编号映射到ABI名称如ra、sp、t0、a0等方便调试器打印。这张表本身不参与指令执行但在排查问题时特别有用——当你拿到一个程序崩溃现场看到a0的值异常总比看到寄存器编号6要直观得多。3.2 物理内存的访问封装NEMU的内存模拟非常直观在host上申请一块大数组作为guest的物理内存然后通过地址读写函数进行访问。src/memory/paddr.c里定义了paddr_read(paddr, len)和paddr_write(paddr, len, data)两个核心函数。从函数名看它们接收一个物理地址、一个访问长度1/2/4/8字节然后从内存数组里读出或写入数据。但如果你直接拿这两个函数去实现访存指令后期会被坑得很惨。原因在于地址可能是非对齐访问NEMU对部分非对齐访问会报错。地址可能落在MMIO区域需要重定向到对应的设备处理函数。地址可能完全非法超出物理内存范围需要触发异常处理。所以NEMU在paddr_read外层通常还会套一层vaddr_read先做地址翻译后续PA阶段会加入页表机制再判断是不是MMIO地址最后才真正访问物理内存数组。预学习阶段你虽然不会实现完整的内存管理单元但从一开始就应该养成“所有访存都走统一访问接口”的习惯而不是直接操作裸数组。内存大小的问题也值得一提。NEMU默认配置的物理内存大小由宏CONFIG_MBASE和CONFIG_MSIZE决定常用的是从0x80000000开始大小256MB或512MB。之所以选择0x80000000作为起始地址是因为RISC-V规范里约定这是DDR内存的典型映射地址。简单类比你把NEMU的内存数组想象成一条大街起始门牌号是0x80000000每家店铺的地址就是相对这个起始值的偏移。读内存的时候程序给出的是“绝对门牌号”而内存数组下标是“相对第一家的距离”所以必须做一个减法换算。3.3 解析elf文件与镜像加载NEMU启动时需要加载一个可执行文件到模拟内存中。这个文件有两种格式ELF和纯二进制镜像。为了方便NEMU支持两种方式一种是直接加载ELF文件自动解析出代码段、数据段并放到正确地址另一种是用户提供纯二进制文件直接复制到指定的内存起始地址。很多初学者在这里会有个误区认为“加载镜像”就是把整个文件原封不动塞进内存数组。实际上ELF文件包含文件头、段表、符号表等大量元数据这些不是运行时内存里的内容。NEMU需要调用elf_loader()之类的函数逐段读取ELF中的可加载段PT_LOAD类型并在内存中正确布置BSS段清零。这个过程本质上模拟了操作系统的加载器功能。在预学习阶段你很可能不需要自己写加载器但一定要理解镜像和可执行文件之间的区别。因为当你写了一个C程序用交叉编译器编译出ELF文件后如果加载错误程序运行结果会非常诡异可能是PC跑飞到奇怪地址也可能寄存器全变垃圾值。排查这类问题的第一步就是确认PC和入口地址是否对上。4. 添加一条新指令的完整流程4.1 选一条简单的指令动手学习NEMU最有效的方式不是只看代码而是亲手添加一条指令。建议第一选择是逻辑或运算指令or。它属于R-type格式opcode0110011funct3110funct70000000语义很简单rd rs1 | rs2没有任何访存和跳转非常适合练手。添加一条指令表面上看只需要在解码表里增加一行但实际上涉及的文件和步骤比想象中多。我把整个流程拆解成六个步骤每一步都可以独立验证在指令解码表中注册指令模式。实现指令执行辅助函数可能是宏或普通函数。如果有必要补充操作数解码逻辑。重新编译NEMU。编写一段测试汇编或C程序验证指令行为。用单步调试器跟踪执行过程确认PC、寄存器变化符合预期。4.2 修改指令解码表NEMU的指令解码表位于src/isa/riscv32/inst.c。这个文件由一堆宏展开生成乍看之下全是IDEX_I、IDEX_R、make_instr_helper之类的东西非常劝退。但核心逻辑并不复杂。对于or指令你需要做的是确认它属于R-type格式所以使用IDEX_R寄存器-寄存器类型相关宏。在宏展开后解码表会多出一个模式条目指定opcode、funct3、funct7的匹配条件。执行辅助函数要负责从指令中提取rs1、rs2、rd字段然后执行位或运算。NEMU里大量使用了#define宏拼接技术。你看代码时会发现类似INSTPAT(..., or, ...)这种模式这就是在声明一条指令。INSTPAT宏的参数从左到右依次是指令二进制模式、指令助记符、执行辅助函数、以及可能的额外参数。如果你要加的是完全新的指令类型还需要手动编写辅助函数如果是已有指令类型下的变体可能只需要复用现有的函数。这一步骤很容易出问题的地方是掩码写错。or的funct7位是0000000如果你把掩码多包含了某一位或者少包含了都会导致解码匹配失败或者更糟糕——把其他指令误判成or。所以每改完解码表我的习惯是立刻跑一遍官方的指令回归测试确保旧指令没有被破坏。4.3 实现执行辅助函数or指令的实现其实体量极小。按照RISC-V语义就是读两个源寄存器做按位或写回目标寄存器。伪代码如下static inline void decode_or(Decode *s) { word_t src1 s-isa.gpr[ s-isa.inst.r.rs1 ]; word_t src2 s-isa.gpr[ s-isa.inst.r.rs2 ]; word_t result src1 | src2; s-isa.gpr[ s-isa.inst.r.rd ] result; }但这里藏着一个非常重要的架构细节你写回rd的时候有没有考虑rd 0的情况前面提到了x0寄存器是零寄存器写入必须被丢弃。NEMU的gpr宏和set_gpr宏已经处理了这个问题所以你需要确认自己使用的是这些安全接口而不是直接访问底层数组。此外还需要注意s-isa.inst这个联合体的使用。NEMU在取指后会把原始指令数据存放在译码结构里方便后续提取字段。inst.r.rs1这种访问方式是通过位域或掩码操作实现的。如果你研究过RISC-V的机器码布局就会知道rs1位于指令的[19:15]位rs2位于[24:20]位rd位于[11:7]位NEMU的联合体设计就是把这些位域提取封装成了看起来像“结构体成员”的访问方式。这种从“裸位操作”到“语义化字段访问”的封装是写模拟器时提升代码可读性的关键手法。4.4 编译、运行与单步调试验证写完代码后编译是第一步关卡。NEMU使用Makefile构建系统一般直接输入make就能编译。但我建议你养成一个习惯编译时看一眼输出特别注意有没有警告。有些警告比如类型不匹配短期内不影响运行但长期埋雷。编译通过后怎么验证新指令是对的最直接的方式是写一段汇编代码只用到or指令.text .globl _start _start: li t0, 0x0f0f li t1, 0x00ff or t2, t0, t1 # 正确结果 t2 0x0fff把这段代码编译成镜像加载进NEMU然后用si单步执行。每执行一条指令用info r查看寄存器变化。重点确认执行完or后t2的值是不是0x0fffPC是不是正常加了4。如果结果不对多半是字段提取或者运算逻辑写错了。如果你想更省事NEMU内置的sdb调试器也可以直接打印寄存器还支持断点。不过对于单条指令的验证单步查看寄存器已经足够。很多同学跳过了这个验证步骤直接跑大程序结果出现问题后根本分不清是CPU问题还是程序问题。先小后大、先单指令后多指令这个节奏非常重要。5. 常见错误、调试技巧与差分测试5.1 典型报错速查表我在预学习阶段和辅导其他同学时总结了一批最常见的问题这里整理成表格方便对照排查。现象可能原因排查方向编译报错INSTPAT相关的宏找不到指令助记符大小写不一致或漏了声明检查新指令名称是否和其他文件里的定义一致运行时报invalid opcode/illegal instruction指令解码表没匹配上或掩码写错用调试器打印当前指令的十六进制值手动对照RISC-V手册执行结果与预期不符且PC异常跳变指令长度处理错误或PC更新逻辑出错单步执行确认每条指令的PC变化寄存器x0被修改写寄存器时未使用安全宏检查所有gpr写入点确认rd0时被忽略加载镜像后PC跑到奇怪地址入口地址和镜像段地址不匹配用readelf -h查看ELF入口地址检查加载逻辑差分测试大量不匹配某条指令实现有误或影响了后续PC从单步开始跑定位第一条不一致的指令这张表不是万能的但覆盖了大多数预学习阶段的翻车场景。最笨也最有效的方法就是把程序拆小把执行步骤拆细用单步执行逐条核对。5.2 实用调试技巧NEMU的sdb调试器是个被很多人低估的工具。在你还没实现复杂设备的时候调试主要靠它。几个我用得最多的操作si N单步执行N条指令配合寄存器打印能快速确认指令执行流。info r查看所有寄存器状态。这一步对检查指令是否写错寄存器特别有用。x N 地址查看从指定地址开始的内存内容。比如你可以查看栈上数据或者验证内存写入是否生效。w 表达式设置监视点当某个变量或内存位置变化时暂停执行。这在追踪“哪个指令偷偷改了某地址”时是神器。另外还有个很多人不知道的技巧你可以在exec_once()里临时加打印把每条指令的PC、原始机器码、目标寄存器、源操作数打出来。虽然这种方式影响性能但在调试早期非常直观。我通常会写一个条件编译宏需要的时候开启排查完再关掉。5.3 差分测试到底怎么玩提到NEMU绕不开“差分测试”DIFTEST。这也是网上热词里“南京大学nemu差分测试”的由来。简单说差分测试就是同时运行两个模拟器——一个是你的NEMU另一个是参考实现通常是QEMU——让它们执行同一条指令然后对比寄存器状态和内存状态。如果两边不一致说明你的NEMU实现有错误并且能精确到具体是哪条指令出了错。差分测试的接入点通常在cpu_exec()里。每执行完一条指令NEMU会调用一次差分对比函数把当前PC、寄存器堆等状态发送给参考模拟器参考模拟器返回它自己的状态两边做一次严格比对。这个机制的价值怎么强调都不为过。想象你写了一个有几百条指令的程序里面某条指令悄悄算错了如果没有差分测试你只能在程序崩溃后抓瞎。有了差分测试它能立刻告诉你“第12345条指令时NEMU的t30x1234但参考模拟器是0x5678”。直接锁定问题指令剩下的就是盯着这条指令的实现找bug。不过要注意差分测试不是万能的。它对比的是指令执行后的架构状态寄存器、内存不对比时序和微架构细节。所以它只能帮你验证“功能正确性”不能帮你优化性能。还有一点当你的NEMU还没实现某些特权指令或系统寄存器时差分测试可能会被这些未实现指令卡住。这时候需要先跳过或简化相关指令的对比。5.4 后续还能怎么深入掌握了上面这些内容你的NEMU预学习基本就入门了。但距离真正“吃透”还有很长的路。我自己的体会是NEMU的每个模块都值得反复琢磨内存映射与MMIO动手实现一个简单的串口设备让NEMU能把字符输出到host终端。这会让你理解内存映射I/O的本质。中断与异常实现RISC-V的ecall指令和异常处理流程体会CPU如何响应软件中断。watchpoint机制模拟硬件调试寄存器实现数据断点功能。这对后续调试复杂程序极有帮助。指令流水线思想哪怕只是在软件层面模拟一个两级流水线也能帮你建立流水线冲突、冒险的直觉。如果你还想进一步挑战可以试着给NEMU加一个自制的简单调试器前端或者把差分测试扩展到更大的测试集。每一步扩展都是在为后面的处理器设计阶段铺路。最后说点实在的。NEMU这套代码初看觉得复杂但它的设计思路极其清晰分层、模块化、接口稳定。学它的意义不只是“完成一生一芯的任务”更是让你提前站在一个体系结构工程师的角度去思考——如何把一个复杂系统拆解成可维护的模块如何为未来扩展留好接口如何用测试保证正确性。这些都是写真实RTL代码时同样重要的工程素养。慢慢啃别急把每个函数、每个宏都弄清楚后面的路会顺很多。