
1. 从“跑通代码”到“看懂代码”NEMU学习的第一道坎“一生一芯”预学习阶段走到NEMU很多人的心态是这样的前面把verilog、数字电路、计算机组成原理的视频刷了一遍感觉自己什么都懂了结果一打开NEMU的源码目录人直接傻了。nemu/src下面几十个文件cpu、memory、monitor、isa、device每个目录里还套着子目录随便点开一个cpu-exec.c满屏的宏定义和函数指针完全不知道从哪看起。我当初就是这样。记得第一次克隆完ics2022仓库按照文档把NEMU编译跑起来看到终端里跳出Welcome to riscv32-NEMU的时候还挺兴奋但紧接着就开始心虚——我知道它能跑但我不知道它为什么能跑。更扎心的是PA1阶段的作业是“在NEMU里实现reg命令”要求你直接修改源码。这不就是不但要读懂还要动刀。这篇文章我就结合自己实际啃代码的过程把NEMU的启动流程、核心循环、CPU执行机制、RISC-V指令解码这几块讲透。不是为了贴代码逐行注释而是告诉你这些代码为什么要这么组织当你需要改它的时候该去哪里找改完之后怎么验证没改坏这套思路搞明白再做PA哪怕到了PA2、PA3你都会觉得顺手很多。先交代一下背景我用的版本是ics2022框架ISA默认是riscv32也就是NEMU里所称的riscv32-NEMU。这个框架由南京大学计算机体系结构课程维护也是“一生一芯”预学习阶段指定的入门项目。后面提到的文件路径全部基于这个版本。2. 动手之前先摸清家底NEMU的目录结构与模块边界很多人学NEMU有一个误区一上来就点开cpu-exec.c从头读到尾。这文件确实核心但它依赖的东西太多了你读不完就卡住。正确做法是先站在远处看整个工程的轮廓搞清楚每个目录管什么、谁依赖谁然后再决定从哪个文件切入。2.1 顶层目录三个核心模块一条主线NEMU的src目录下真正需要你重点关注的就这么几个目录目录职责我的理解monitor监控与调试包括sdb调试器、ui用户交互这是你平时打交道的交互层cpuCPU核心执行逻辑包括取指、译码、执行整个模拟器的引擎memory客户机物理内存的读写封装CPU和外部世界的桥梁isa指令集架构相关每个ISA一个子目录可替换的“插件层”device设备模拟PA2之后才会用到暂时可以忽略这五个目录之间的关系用一句话概括就是monitor负责告诉cpu“你该跑程序了”cpu从memory里取指令、执行指令至于这条指令到底是什么意思cpu去问isa。所以整个NEMU的架构是一个典型的“前端-执行-后端”结构isa是后端cpu是前端memory是它们之间的通路。2.2 两个容易被忽略但极其重要的文件include与config.h第一次打开NEMU的人十个有九个会忽略根目录下的include文件夹。这个文件夹里放的不是源代码而是所有头文件包括common.h、debug.h、macro.h这些基础设施。我强烈建议你在读任何源码之前先去把include/common.h翻一遍。这个文件里定义了一堆贯穿全项目的宏比如Assert条件不满足就报错退出这是调试代码时最重要的一个宏panic直接报错退出用来表示“这里不应该被执行到”TODO()没实现的桩函数PA作业里到处都有另一个是nemu/src/isa/riscv32/include/isa-def.h它定义了riscv32_CPU_state这个结构体。说白了这个结构体就是RISCV32 CPU的“寄存器堆加PC”的完整镜像。你后续实现reg命令、调试单步执行、理解指令语义都绕不开这个结构体。我学的时候走过的弯路是直接跳到cpu-exec.c发现里面全是gpr、cpu这种东西根本不知道gpr是哪来的。后来才发现isa-def.h里定义了struct riscv32_CPU_state而cpu这个全局变量就是在cpu-exec.c里#include之后生成的。所以现在给你一个建议先把ISA相关头文件看完再去看CPU执行文件顺序反了全是坑。2.3 从init_monitor到cpu_exec启动流程的完整链路NEMU启动流程的核心入口在nemu/src/monitor/monitor.c里的init_monitor()函数。这个函数的调用链如下void init_monitor(int argc, char *argv[]) { // 解析命令行参数 parse_args(argc, argv); // 初始化随机数种子用于后续的指令数统计之类的功能 srand(time(0)); // 初始化内存 init_mem(); // 初始化设备PA2之后再关注 init_device(); // 加载程序镜像到内存 load_img(); // 初始化寄存器 init_isa(); }这步我踩过一个大坑load_img()加载镜像的时候默认文件路径是nemu/build/下的某个elf文件。如果你没有把编译好的测试程序放到正确位置NEMU启动之后会报“没有找到镜像文件”然后退出。我当时总是忘了先把测试程序拷贝到build/目录每次启动都跟个傻子一样盯着Welcome界面看半天。后来学聪明了直接在parse_args里加上镜像路径参数或者在Makefile里编译好一切再跑。init_isa()则是把所有通用寄存器清零、把PC设成RESET_VECTOR也就是0x80000000。这个地址在riscv32里是规定的复位入口qemu、spike这些模拟器也是这么做的。从这里开始CPU执行的第一条指令就是从这个地址取的。3. 核心循环拆解cpu_exec到底在忙什么启动流程搞定之后程序就进入了cpu_exec()函数。这函数是NEMU的主循环理解它你就理解了整台模拟器是怎么“活”起来的。3.1 一个循环三种状态cpu_exec()的核心代码不长但逻辑密度很高void cpu_exec(uint64_t n) { g_nr_guest_inst 0; while (n 0) { // 执行一条指令 exec_once(s, cpu.pc); g_nr_guest_inst; // 检查是否发生事件如断点、单步、退出 if (nemu_state.state NEMU_END || nemu_state.state NEMU_ABORT) { break; } // 指令数到达上限退出 if (nemu_state.halt_by_inst_count) { if (g_nr_guest_inst n) break; } // 处理设备更新PA2涉及 if (g_timer-uptime - last_time 1000) { device_update(); last_time g_timer-uptime; } // 检查是否需要显示CPU状态调试用 if (nemu_state.monitor_flag) { display_stat(); } } }看起来好像就是“循环执指令-检查状态-再执行”但里面藏了几个关键设计第一exec_once是NEMU执行的最小单位。它的实现位于nemu/src/cpu/cpu-exec.c。这个函数干三件事取指、译码、执行。具体对应到cpu-exec.c里的exec_real()。第二循环的退出条件并不只有“执行完n条指令”这一种。比如程序执行了nemu_trap指令后面讲nemu_state.state就会变成NEMU_END这时循环立刻break整个模拟器就停了。PA1阶段你要实现si命令本质上就是调cpu_exec(1)或者cpu_exec(n)让CPU执行N条指令后停下。第三device_update()的存在说明NEMU不是每执行一条指令都去更新设备。它是“攒够一定时间”才去刷新一次这个思路是为了模拟真实硬件的时间片概念同时不至于因为频繁IO拖慢模拟速度。PA2学到设备时你会回来再看这段。3.2 exec_once的内部构造取指、译码、执行如何串起来exec_once是理解NEMU最核心的一个函数。它的源码在nemu/src/cpu/cpu-exec.c里大致长这样static void exec_real(Decode *s) { // 取指从PC指向的地址读取一条指令 s-pc cpu.pc; s-snpc cpu.pc 4; s-isa.instr.val inst_fetch(s-snpc, 4); // 译码根据指令编码找到对应的操作 s-dnpc s-snpc; s-isa.exec(s); // 更新PC cpu.pc s-dnpc; }这里面有几个概念新手很容易绕晕s-pc当前指令的PCs-snpc下一条指令的PCstatic next PC它是“取指之后”的PCs-dnpc动态下一条指令的PCdynamic next PC它是“执行完这条指令之后”真正的下一条PC为什么要区分snpc和dnpc因为大多数指令执行完PC会顺序地变成PC4但遇到跳转指令如jal、j、分支指令如beqPC会变成指令里指定的目标地址。CPU在取指阶段先假设下一条是PC4在执行阶段再根据指令语义修正。这个“先猜测、后修正”的思路和真实CPU里的分支预测、指令流水线设计是同一个道理。我这里补充一个很多人没注意的细节inst_fetch()返回的uint32_t会被存到s-isa.instr.val里。这个instr是一个联合体union它把一条32位的指令拆解成了各个字段比如opcode、rd、rs1、rs2、imm等。这就是为什么后面译码的时候可以直接用s-isa.instr.opcode来取操作码而不是手动做位运算。3.3 为什么NEMU能用“解释执行”模拟任意指令集很多人好奇NEMU又没有硬件电路它凭什么能执行RISC-V的指令答案是它把每条指令都映射成了一个C函数。具体来说在isa/riscv32/下面有一个inst.c文件里面定义了一个结构体数组static const opcode_table_t table[] { // 格式: { {opcode_mask, opcode}, execute_function, NAME } { {0x7f, 0x37}, R_lui, lui }, { {0x7f, 0x17}, R_auipc, auipc }, { {0x7f, 0x6f}, R_jal, jal }, // ... };exec_once取出指令的opcode之后会去这张表里查对应项找到对应的execute_function然后调用它。这些R_xxx函数就是真正的指令实现分布在isa/riscv32/inst/目录下的各个子文件里比如add.c、sub.c、lw.c、sw.c、beq.c等等。所以你要理解的核心逻辑是NEMU不是真的“执行”指令而是用C语言“解释”指令。每条RISC-V指令在NEMU里都对应一段C代码这段C代码模拟了该指令对寄存器、内存、PC造成的影响。这种方式和Java的JVM、Python的解释器本质上一模一样都是“用软件模拟一台虚拟机”。4. RISC-V指令集的实现从opcode到执行函数的匹配机制如果你问NEMU源码里最值得精读的部分是哪块我的答案一定是isa/riscv32/。这块代码是RISC-V指令集的“软件实现”你把它吃透了RISC-V的指令格式、编码方式也就掌握了七八成。4.1 先看懂RISC-V的指令格式固定32位六种类型RISC-V的最大特点之一就是指令格式非常规整。32位指令总长固定按功能分成6种类型R型、I型、S型、B型、U型、J型。每种类型的字段布局不同但opcode字段低7位是固定的。类型用途典型指令字段布局R寄存器-寄存器运算add, sub, andfunct7 rs2 rs1 funct3 rd opcodeI立即数运算/加载addi, lw, jalrimm[11:0] rs1 funct3 rd opcodeS存储sw, sbimm[11:5] rs2 rs1 funct3 imm[4:0] opcodeB条件分支beq, bneimm[12] imm[10:5] rs2 rs1 funct3 imm[4:1] imm[11] opcodeU高位立即数lui, auipcimm[31:12] rd opcodeJ跳转jalimm[20] imm[10:1] imm[11] imm[19:12] rd opcode这个表看起来复杂但NEMU的实现已经帮你处理了大部分麻烦。在isa-def.h里Instr这个联合体把32位指令拆成了各个命名字段typedef union { uint32_t val; struct { uint32_t opcode : 7; uint32_t rd : 5; uint32_t funct3 : 3; uint32_t rs1 : 5; uint32_t rs2 : 5; uint32_t funct7 : 7; }; // 其他类型可以继续添加 } Instr;所以在你的执行函数里取s-isa.instr.rd、s-isa.instr.rs1这种操作特别自然完全不用自己写位运算。这也是NEMU对新手友好的一个地方。4.2 一条add指令的完整旅程我们用最简单的add a0, a1, a2指令机器码通常是0x00c58533走一遍NEMU的执行流程第一步exec_once取指得到0x00c58533存到s-isa.instr.val里。第二步译码。NEMU取出val的低7位也就是opcode字段。0x00c58533的低7位是0x33它在表格里匹配到R型算术运算那一组。第三步调用R_add函数。这个函数在isa/riscv32/inst/add.c里实现大概是make_inst_helper(ADD, add, r, R, rd, rs1, rs2) { // 取寄存器值 uint32_t src1 reg_l(rs1); uint32_t src2 reg_l(rs2); // 运算 uint32_t result src1 src2; // 写回寄存器 reg_w(rd, result); }make_inst_helper这是NEMU自己写的宏用来生成具有相似模式的处理函数。reg_l和reg_w是寄存器的读写宏最终会操作cpu.gpr这个数组。第四步回到exec_real把cpu.pc更新为s-dnpc也就是PC4。至此一条指令就“执行”完了。这个流程看起来简单但它解释了PA阶段你可能会遇到的一个经典问题为什么有些指令的实现你明明看懂了但程序跑起来就是不对。因为NEMU的指令函数不只是改改寄存器还要处理立即数扩展、符号位、内存访问等等细节。以lw为例你需要先计算地址再从vaddr_read读4字节然后还要保证读出来的是一个有符号整数。任何一个细节错了程序就可能在后续某条指令上崩掉。4.3 立即数扩展最容易翻车的地方RISC-V的立即数设计有个特点它不是连续存放在某个固定位置的而是被“打散”在指令的不同bit里。比如S型指令的立即数高7位在inst[31:25]低5位在inst[11:7]B型指令更是把符号位拆到了inst[31]和inst[7]两个位置。NEMU在isa/riscv32/里准备了立即数解码的helper比如#define SEX(imm) ((uint32_t)(int32_t)imm)还有INSTPAT宏这个宏帮你把立即数拆解和符号扩展做得非常方便。但即便如此我依然见过很多人栽在jal和beq的立即数拼接上。这两个指令的立即数需要按位拼起来顺序极其反直觉。我的建议是亲手画一张RISC-V指令格式图把每个bit的位置标出来然后对着NEMU里的实现去核对看它是不是和你画的拼接顺序一致。不要凭记忆写实测下来十次有九次会错。5. 调试手段也是必修课sdb调试器的实现逻辑“一生一芯”预学习阶段的PA1作业就是要求你给NEMU实现一个简易调试器sdb。你需要在sdb.c里补全cmd_help、cmd_c、cmd_si、cmd_info、cmd_x这些命令。这部分难点不在命令本身而在于你要理解sdb的运行机制它是怎么在你输入命令的时候暂停CPU执行、怎么读取寄存器、怎么访问内存的。5.1 sdb的主循环与命令分发sdb的实现核心在nemu/src/monitor/sdb/sdb.c里。它本质上就是一个“读取字符串 - 解析单词 - 匹配命令 - 执行函数”的循环void sdb_mainloop() { while (1) { // 打印提示符 printf((nemu) ); // 读取一行输入 char *str rl_gets(); // 解析第一个单词 char *cmd strtok(str, ); // 在命令表里查找 for (int i 0; i NR_COMMAND; i) { if (strcmp(cmd, cmd_table[i].name) 0) { cmd_table[i].handler(str); break; } } } }cmd_table是一个结构体数组每一项包含命令名、命令描述、处理函数指针。这种“命令表函数指针”的设计在嵌入式系统里非常常见你以后做操作系统、RTOS的时候还会见到。PA1里让你实现si命令底层就是调用cpu_exec(1)static int cmd_si(char *args) { int n 1; if (args ! NULL) { n atoi(args); } cpu_exec(n); return 0; }cmd_info命令则复杂一些它需要区分info r和info w分别显示寄存器和监视点。寄存器显示直接遍历gpr数组调用isa_reg_display()即可。这个函数位于isa/riscv32/reg.c你可以直接打印所有寄存器的值。5.2 监视点watchpoint理解“硬件调试”如何用软件实现如果你想在PA阶段拿到高分cmd_info w和监视点watchpoint的实现是不可回避的。监视点的核心逻辑很简单你在某个变量其实是某个内存地址上设置一个监视点每次CPU执行完一条指令NEMU都会检查这个地址的值有没有发生变化变了就停下并告诉你“检测到变化”。NEMU里监视点用链表实现。nemu/src/monitor/sdb/watchpoint.c里定义了WP结构体typedef struct watchpoint { int NO; uint32_t addr; uint32_t old_val; struct watchpoint *next; } WP;设置监视点时分配一个空闲WP记录地址和当前值执行完每条指令后调用check_watchpoint()遍历链表比对旧值和新值。一旦发现不一致就报告。这个机制实现起来并不复杂但它很形象地模拟了真实硬件调试器里硬件断点/监视点的工作原理。区别在于真实的硬件监视点数量有限一般就4~8个而软件监视点几乎没有数量限制只是速度慢。我建议你在实现这个功能的时候顺手做一个扩展在监视点里记录“旧值 - 新值”的变化过程这样调试时可以直接看到是什么指令改了这个值会比单纯比较有用得多。5.3 断点breakpoint从set-breakpoint到continue的配合断点的实现同样在sdb.c里命令名是bbreakpoint。NEMU里断点的实现方式是保存断点地址的原始指令然后把该地址的指令替换为一条特殊指令NEMU用ebreak之类的陷出指令。当程序执行到断点地址时CPU会陷入NEMU的异常处理这时NEMU检查当前PC是否命中断点如果是就暂停执行把控制权交还给sdb。PA1阶段文档只要求你实现si、info、x、p这些命令断点是可选加分项但如果你能实现出来对理解“异常”这个机制帮助会非常大。而且到了PA2你要在NEMU里加入异常处理那部分内容跟断点机制有不少共通之处。提前写一遍后面会轻松很多。6. CPU状态机视角从寄存器到内存NEMU模拟了什么如果你已经能把sdb跑起来、能单步执行、能看寄存器那说明你已经跨过了“能跑”这个阶段到了“能改”的阶段。再往深走一步我建议你用“状态机”的视角重新审视NEMU。6.1 为什么“状态机”这个词在NEMU里如此重要计算机组成原理教材里常说“CPU是一个有限状态机”这句话在NEMU里可以非常直观地看到。NEMU模拟的机器状态包含三部分寄存器状态cpu.gpr[]、cpu.pc这是CPU内部的状态内存状态pmem[]数组这是存储系统的状态设备状态device相关的状态PA2后才涉及cpu_exec的每一次循环本质上就是“根据当前状态执行一条指令得到一个新状态”。执行完add寄存器值变了执行完lwCPU从内存里读了个数执行完sw内存变了。每一步都有明确的前后状态对应关系。当你调试程序发现结果不对时就该问自己是从哪条指令开始状态偏离了我预期这就是为什么NEMU的调试器那么重要——它是你观察状态变化唯一的工具。而PA2之后的差分测试DIFF Test本质上是比较“NEMU的状态”和“参考模型比如QEMU的状态”是否一致。如果指令执行导致状态分叉说明NEMU在这条指令上实现错了。6.2 内存读写从vaddr_read到paddr_read的层层封装NEMU里读取内存并不是直接访问pmem[]数组。它封装了vaddr_read、vaddr_write、paddr_read、paddr_write这些函数。uint32_t vaddr_read(vaddr_t addr, int len) { return paddr_read(addr, len); }PA1阶段vaddr_read和paddr_read是直接相等的相当于虚拟地址等于物理地址。但到了PA3你要实现地址转换vaddr_read内部就要先做页表翻译再把翻译后的物理地址传给paddr_read。这个分层设计就是为了将来做地址转换留的口子。我见过很多人在PA3的时候回来改vaddr_read结果把paddr_read也顺手改了导致虚拟地址和物理地址混在一起程序直接跑飞。所以从PA1开始你就要养成一个习惯能不改paddr_read就不改所有内存访问逻辑尽量收敛在vaddr_这一层。7. PA阶段最容易踩的坑与自测建议NEMU学习过程中我踩过的坑不少这里把最典型的几个列出来希望能帮你省点时间7.1 坑一gcc编译选项导致的“未定义引用”NEMU使用Makefile构建make的时候会生成nemu/build/下的目标文件。如果你在isa/riscv32/inst/下新增了一个.c文件但忘记把它加进Makefile的源文件列表链接时就会出现“undefined reference”错误。NEMU的Makefile虽然支持通配符自动收集但有些版本比较老需要你手动在nemu/src/isa/riscv32/inst/Makefile或顶层Makefile里加一行。提示改完Makefile之后最好make clean再重新编译否则可能出现“莫名奇妙的问题”比如改的代码没生效。7.2 坑二reg命令显示的寄存器值和预期对不上如果你实现了reg命令显示的值和预期对不上多半是isa_reg_display()和reg_l()用了不同的寄存器索引。比如RISC-V里x0是硬件零寄存器永远为0但如果你不小心在显示的时候用了gpr[0]而没做特殊处理那么x0就会变成一个普通寄存器程序行为就错了。检查方法很简单在NEMU里执行info r看看x0是不是0再随便执行几条指令看看x1ra的值是否符合预期。7.3 坑三测试程序跑起来但结果全错NEMU提供了一个叫am-tests的测试集PA1阶段建议你直接编译几个基础测试比如cpu-tests里的add、sub、lw-sw这些。跑一个测试如果输出不对先用si单步执行再用x命令查看内存再用info r看寄存器三步配合定位问题。单步执行时如果发现某条指令的寄存器值不对十有八九是那条指令的实现有bug。我个人的自测建议是这样的顺序先跑最简单的add指令测试确认加减法指令正确再跑lw-sw测试确认内存读写正确再跑branch测试确认分支跳转正确最后跑jal测试确认跳转链接正确一个模块一个模块地验证不要等所有指令都写完了再一次性测试到时候bug都不知道从哪找。7.4 一个值得做的练习自己动手给NEMU加一条自定义指令学完以上内容之后我给自己的“毕业练习”是给NEMU加了一条自定义指令比如mul乘法指令。步骤是在isa/riscv32/inst/下新建mul.c实现乘法逻辑在isa/riscv32/inst.c的表里注册这条指令重新编译、测试用sdb的si单步验证这个过程虽小但它完整覆盖了“取指-译码-执行-验证”的整个闭环。做完你能明显感觉到NEMU不再是黑盒而是你手里可以随意捏的积木。8. 从NEMU到“一生一芯”后续阶段的衔接思考NEMU学完之后你手里有了一台“可以随时修改、调试、观察内部状态”的模拟CPU。这份能力在后续阶段会持续复用。PA2阶段你要实现更多的指令包括乘除法、原子操作、特权指令还要引入异常和中断机制。你会发现这些内容全部围绕“CPU状态机”扩展异常改变了PC也改变了CSR寄存器控制和状态寄存器本质上就是“在某些条件下跳到另一个状态”。PA3阶段你要在NEMU上运行AMAbstract Machine——一个抽象机器层。AM会给你的NEMU提供一套统一的API让你能写一个跑在NEMU上的简单操作系统内核。到了这一步NEMU就不只是一个玩具模拟器了它是一个真正能跑操作系统的开发平台。PA4之后的“一生一芯”流程你会用Verilog去实现真正的CPU。但NEMU里的经验并不会白费——你会很自然地建立起“CPU如何执行一条指令”“指令如何被解码”“遇到异常怎么处理”这些核心认知。到时候写RTL脑子里会自动浮现NEMU里那张指令解码表的影子。所以我的建议是NEMU学习阶段不要只求“能做PA”别急着往后赶多花点时间把每一条指令的执行流程、每个调试命令背后的机制吃透。这段基础打得越扎实后面写RTL、做SoC、跑Linux的时候你就会越从容。