☰
深入NEMU模拟器:从启动流程到指令解码与差分测试
2026/10/7 18:30:48 网站建设 项目流程

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()的内部顺序。它不是随便乱调的,而是严格遵循依赖关系:

  1. 先解析命令行参数,确定镜像文件路径、批处理模式、差分测试开关等。
  2. 初始化内存,也就是为模拟的物理内存分配host端的一段缓冲区。
  3. 初始化寄存器,包括把PC设置到复位地址、通用寄存器清零。
  4. 加载镜像到内存的指定位置,常用的是0x80000000(RISC-V架构约定的启动地址)。
  5. 最后才进入命令循环。

这个顺序背后是有一条逻辑链的:没有内存就没法加载镜像,没有镜像就谈不上执行程序,所以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()是单条指令执行的入口,函数内部做的是标准五步:

  1. 取指(inst_fetch):根据当前PC从内存中读取一条指令。RISC-V32的指令长度默认是4字节,但为了支持压缩指令(RVC)需要额外判断最低两位。
  2. 译码(decode_exec):把指令拆解成操作码、功能码、寄存器索引、立即数等字段,然后去指令表里找到对应的执行函数。
  3. 执行(执行函数内部):真正完成运算、访存、跳转等行为,并更新寄存器或内存。
  4. 更新PC:大多数指令默认pc += 4,但跳转指令会直接修改PC为目标地址。
  5. 更新指令计数:返回本指令执行了多少字节(通常是4),方便上层统计。

我觉得最有意思的是exec_once的返回值设计。它返回的不是“成功”或“失败”,而是这条指令实际占用的字节数。为什么这么设计?因为后续如果要支持压缩指令(16位),或者指令集扩展,取指长度不再固定,这个返回值就变得非常关键。NEMU用这种看似多余的返回值,保留了未来扩展的空间。这提醒了我们一个道理:好的代码会为未来留接口,而不是只满足当下需求。

2.3 指令解码的核心机制

指令解码是NEMU预学习阶段最容易卡住人的地方之一。我第一次看src/isa/riscv32/inst.c里的各种宏定义时,头都是大的。但一旦理解了它的工作方式,你会发现这套机制极其精巧。

NEMU的解码方式本质上是一个“查表+模式匹配”的过程。每条32位指令被当作一个整数,通过掩码和移位提取出不同的字段,然后与指令模式表中的条目做匹配。指令模式表是一个结构体数组,每一项包含三要素:

  • 指令的机器码模式(即哪些位必须固定为某些值)
  • 掩码(哪些位需要参与匹配)
  • 对应的执行辅助函数指针

举个例子,RISC-V的addi指令操作码是0010011,funct3是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] = ...访问底层数组,结果遇到rd=0的指令时行为就错了。正确做法是沿用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格式:opcode=0110011,funct3=110,funct7=0000000,语义很简单:rd = rs1 | rs2,没有任何访存和跳转,非常适合练手。

添加一条指令,表面上看只需要在解码表里增加一行,但实际上涉及的文件和步骤比想象中多。我把整个流程拆解成六个步骤,每一步都可以独立验证:

  1. 在指令解码表中注册指令模式。
  2. 实现指令执行辅助函数,可能是宏或普通函数。
  3. 如果有必要,补充操作数解码逻辑。
  4. 重新编译NEMU。
  5. 编写一段测试汇编或C程序,验证指令行为。
  6. 用单步调试器跟踪执行过程,确认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的值是不是0x0fff,PC是不是正常加了4。如果结果不对,多半是字段提取或者运算逻辑写错了。

如果你想更省事,NEMU内置的sdb调试器也可以直接打印寄存器,还支持断点。不过对于单条指令的验证,单步+查看寄存器已经足够。很多同学跳过了这个验证步骤,直接跑大程序,结果出现问题后根本分不清是CPU问题还是程序问题。先小后大、先单指令后多指令,这个节奏非常重要。

5. 常见错误、调试技巧与差分测试

5.1 典型报错速查表

我在预学习阶段和辅导其他同学时,总结了一批最常见的问题,这里整理成表格,方便对照排查。

现象可能原因排查方向
编译报错INSTPAT相关的宏找不到指令助记符大小写不一致或漏了声明检查新指令名称是否和其他文件里的定义一致
运行时报invalid opcode/illegal instruction指令解码表没匹配上,或掩码写错用调试器打印当前指令的十六进制值,手动对照RISC-V手册
执行结果与预期不符,且PC异常跳变指令长度处理错误,或PC更新逻辑出错单步执行,确认每条指令的PC变化
寄存器x0被修改写寄存器时未使用安全宏检查所有gpr写入点,确认rd=0时被忽略
加载镜像后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的t3=0x1234,但参考模拟器是0x5678”。直接锁定问题指令,剩下的就是盯着这条指令的实现找bug。

不过要注意,差分测试不是万能的。它对比的是指令执行后的架构状态(寄存器、内存),不对比时序和微架构细节。所以它只能帮你验证“功能正确性”,不能帮你优化性能。还有一点,当你的NEMU还没实现某些特权指令或系统寄存器时,差分测试可能会被这些未实现指令卡住。这时候需要先跳过或简化相关指令的对比。

5.4 后续还能怎么深入

掌握了上面这些内容,你的NEMU预学习基本就入门了。但距离真正“吃透”还有很长的路。我自己的体会是,NEMU的每个模块都值得反复琢磨:

  • 内存映射与MMIO:动手实现一个简单的串口设备,让NEMU能把字符输出到host终端。这会让你理解内存映射I/O的本质。
  • 中断与异常:实现RISC-V的ecall指令和异常处理流程,体会CPU如何响应软件中断。
  • watchpoint机制:模拟硬件调试寄存器,实现数据断点功能。这对后续调试复杂程序极有帮助。
  • 指令流水线思想:哪怕只是在软件层面模拟一个两级流水线,也能帮你建立流水线冲突、冒险的直觉。

如果你还想进一步挑战,可以试着给NEMU加一个自制的简单调试器前端,或者把差分测试扩展到更大的测试集。每一步扩展,都是在为后面的处理器设计阶段铺路。

最后说点实在的。NEMU这套代码,初看觉得复杂,但它的设计思路极其清晰:分层、模块化、接口稳定。学它的意义不只是“完成一生一芯的任务”,更是让你提前站在一个体系结构工程师的角度去思考——如何把一个复杂系统拆解成可维护的模块,如何为未来扩展留好接口,如何用测试保证正确性。这些都是写真实RTL代码时同样重要的工程素养。慢慢啃,别急,把每个函数、每个宏都弄清楚,后面的路会顺很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询