ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NEMU模拟器指令实现与差分测试实战:一生一芯PA2阶段学习笔记

NEMU模拟器指令实现与差分测试实战:一生一芯PA2阶段学习笔记 上一篇写NEMU的时候还在聊怎么把工程编译出来、怎么跑通一个最简单的镜像。这篇继续聊一聊“一生一芯”预学习阶段NEMU代码学习的第二篇从代码层面把NEMU真正拆开看清楚主要覆盖PA2阶段接触到的指令实现思路、调试基础设施的用法以及很多人看到名字就发怵的差分测试diff test。如果你也是零基础开始跟“一生一芯”或者正准备做南京大学的NEMU实验这篇内容应该能帮你省下不少对着源码发呆的时间。NEMU这个模拟器表面上看是一个会执行指令的虚拟机实际上是一台可以用代码“看见”每一根线怎么走的教学计算机。我在预学习阶段最大的感受是它不教你背计算机组成原理而是让你亲手把CPU跑起来一条指令一条指令地验证。下面直接进入正题从代码地图开始说起。1. 先回顾一下上一篇的进度再聊聊PA2到底要干嘛1.1 从“能跑起来”到“真正跑起来”的差距上一篇我做到了让NEMU编译通过并且能加载一个简单的镜像文件跑起来。但“跑起来”和“真正跑起来”之间还有一道巨大的鸿沟默认的NEMU其实只实现了少数几条指令很多程序一跑就会掉进invalid opcode的死胡同。PA2的核心目标就是把这台“残缺”的模拟器补完让它能执行RISC-V架构的真实程序。我记得自己第一次在NEMU里跑一个带跳转的测试用例时看着屏幕上的HIT BAD TRAP完全不知道发生了什么。后来才明白PA2阶段你要做的不是调一个bug而是实现一整条指令流水线里的关键环节取指、译码、执行、更新PC。这个过程中你会接触到NEMU的基础设施包括寄存器查看、单步执行、监视点以及后面让我又爱又恨的差分测试。1.2 NEMU的整体代码地图你接下来要动的文件很多第一次接触NEMU的人打开源码目录会懵掉不知道从哪儿看起。我自己总结了一份精简地图按重要程度排序nemu/src/cpu/cpu-exec.c核心执行引擎cpu_exec()在这里exec_once()也在这里你每次单步执行的本质就是调用它。nemu/src/isa/riscv32/inst.cRISC-V指令的译码和执行逻辑PA2阶段你大部分时间都在改这个文件。nemu/src/isa/riscv32/reg.c寄存器相关的读写实现。nemu/src/memory/paddr.c和vaddr.c物理内存和虚拟内存的读写接口实现指令时必须搞清楚走的是哪一层。nemu/src/monitor/monitor.c模拟器的启动入口也是命令行交互的起点。如果你用的是较新的NEMU版本目录结构可能略有不同但核心模块跑不出上面这几个。我的建议是动手改代码之前先把这些文件从头到尾通读一遍不需要每一行都懂但要能回答上“一条指令从内存到CPU执行中间经过哪几个函数”。2. 理解NEMU的“心脏”——取指-译码-执行的循环2.1 cpu_exec 和 exec_onceNEMU的主循环NEMU本质上是一个无限循环取指令执行指令更新PC再来一次。这个循环的核心就是cpu_exec()和exec_once()这一对函数。cpu_exec(uint64_t n)负责执行 n 条指令n 为 -1 时表示一直执行直到遇到停机指令。它内部有一个简单的计数器每执行一条指令就调用一次exec_once()。而exec_once()做的事情可以拆成三步根据当前cpu.pc从内存中取出指令这条指令的原始编码会存到s-isa.inst.val。调用isa_exec_once()完成译码和执行。更新全局的cpu.pc。我在学习时一直有个误区以为PC的更新是统一处理的。实际上NEMU里PC的更新分散在指令的译码和执行逻辑中靠s-dnpc下一条PC来传递。你在写指令时如果忘了设置s-dnpc程序就会乱跳这也是PA2阶段最常见的bug来源之一。2.2 从裸指令编码到可执行操作译码宏的巧妙设计RISC-V的指令编码格式比较规整但32位指令的手工译码依然繁琐。NEMU用了一套宏来做这件事典型写法长这样#define INSTPAT(pattern, mask, name, type, expr) ...第一次看到时我完全看不懂这个宏想干什么。后来才意识到它本质上是把“匹配指令编码”和“执行操作”绑定在一起。你定义一个指令模式比如??????? ????? ????? 001 ????? 00000 11表示一条加法指令NEMU会在译码时逐条比对命中后就执行对应的表达式。实际写指令时我不建议你直接去啃宏展开而是先照着模板抄。以RISC-V的addi为例核心动作就三步读rs1寄存器加上立即数写回rd寄存器最后设置dnpc为当前pc4。把这个流程跑通了后面所有指令都只是这三个动作的变体。2.3 操作数机制s-src、s-dest、s-imm 到底在干嘛NEMU的译码结果都挂在一个叫Decode的结构体上你在指令实现里频繁见到的s-imm、s-src1、s-dest就是它的成员。刚开始我不明白为什么非要多这一层直接读寄存器不行吗后来才体会到这层抽象的妙处它可以统一不同指令的操作数访问方式。比如有些指令的源操作数来自寄存器有些来自立即数有些来自内存。如果你在每条指令里都自己写一遍“读寄存器、读内存”的逻辑代码会非常冗余而且容易出错。用s-src1、s-src2、s-dest统一表达之后大部分指令的执行体都可以用一套模板套出来。不过要注意操作数的设置需要在译码阶段完成而不是执行阶段。我一开始把s-imm的赋值写在执行逻辑里结果某些指令行为正常某些完全错乱排查了很久才发现是时序问题。3. 从零开始实现指令我建议的顺序和踩坑记录3.1 先别急着写代码学会读RISC-V指令手册PA2最大的坑之一是很多同学拿到任务就开始写指令写了一半才发现自己连立即数怎么编码都没搞清楚。我的建议是动手之前先花半天时间读RISC-V Unprivileged Spec里关于基础整数指令的部分不用全读重点看这几块指令格式图R型、I型、S型、B型、U型、J型立即数编码规则尤其是B型指令的立即数是按bit拆散的各指令的语义比如lui是装载高位立即数auipc是给PC加上一个20位立即数读手册的时候可以配合NEMU的nemu/src/isa/riscv32/inst.c里已经实现的几条指令对照着看。我当时是先看已有的addi实现然后照着它的格式写slli、xor这些同类型指令等于先“抄作业”再自己独立写效率高很多。3.2 用最简单的指令打通流程addi 是关键我的建议是第一条指令实现addi它几乎是所有I型指令的样板。你已经能看到NEMU里很可能已经有这条指令的实现因为框架自带的基础指令集通常包含它那就把它当作参照物把整个“取指→译码→执行→更新PC”的流程在脑中完整过一遍。当你开始实现自己的第一条指令时我强烈建议你实现lui。它逻辑简单但容易出错需要把20位立即数左移12位同时把低12位置零。很多人直接写s-imm 12但忘了立即数本身只有20位有效数据移位前必须做掩码处理。用这个指令练手能帮你形成“先处理操作数、再执行运算、最后写回”的肌肉记忆。3.3 PA2基础设施和指令实现是同时推进的PA2的另外一个特点是指令实现和调试基础设施是联动在一起的。你以为自己在写指令其实同时也在用单步执行、断点、log这些工具来辅助调试。我自己的顺序是先实现lui、jal、beq这类控制流指令因为它们的dnpc处理方式和普通指令不一样最容易暴露问题。然后是整数运算指令包括算术、逻辑、移位注意区分逻辑右移和算术右移。最后是访存指令lb/lh/lw/sb/sh/sw这些涉及内存读写要重点检查字节序和符号扩展。每实现完一组指令就用NEMU自带的测试用例跑一遍不要攒到最后一次性验证否则一个小错误会被后面几十条指令的噪音淹没。4. NEMU自带的调试工具单步、监视点、log和trace4.1 用命令行交互调程序比printf好用一百倍NEMU启动后进入命令行交互模式支持si单步执行、info r查看寄存器、x查看内存、p表达式求值、watchpoint监视点等命令。我一开始不习惯总是想用printf打日志后来发现这些命令才是真正的效率神器。比如你实现了一条beq不确定分支跳转是否正确就可以先si单步到这条指令然后用info r看两个源寄存器的值接着再si一条看PC是否跳到了预期位置。整个过程完全可控不涉及重新编译。更重要的是NEMU的命令行交互模式本质上就是一个简化版调试器。学会用它来定位问题后面接触GDB、OpenSBI调试时思路是相通的。4.2 如何写出能帮你定位问题的日志虽然命令行工具很强大但某些场景下日志还是不可替代的。比如你连续执行几千条指令后程序崩溃手动单步根本不现实。NEMU提供了一套trace宏典型用法是在译码执行的关键路径上打点Log(pc 0x%08x, inst 0x%08x, cpu.pc, s-isa.inst.val);我不建议你在每条指令上都加这种日志因为输出量会大到完全没法看。更推荐的做法是只在你怀疑出问题的指令类型上打日志或者在条件满足时才输出。比如怀疑内存访问异常就在访存函数里加判断如果访问地址超出范围就打印PC和指令编码。这种日志配合trace开关能让你在几万条指令的迷雾中精准定位到第一个出错点。4.3 理解CPU状态和停机机制别被“HIT BAD TRAP”吓到NEMU里程序正常退出的标志是执行到一条特定的停机指令这时候会显示HIT GOOD TRAP。如果执行到非法指令或地址访问异常就会显示HIT BAD TRAP。我第一次看到BAD TRAP时以为是模拟器坏了后来才知道是程序自己跑飞了。遇到BAD TRAP第一反应应该是我的指令实现有问题而不是测试用例有问题。这时候回到命令行模式用si逼近出错点再结合info r观察寄存器值通常很快就能找到是哪条指令把PC带偏了。5. 差分测试把你写的模拟器和真正的CPU进行对拍5.1 为什么需要差分测试人眼没法检查几千条指令的每一条PA2进行到一半你可能会陷入一种“自我感觉良好但程序就是跑不对”的状态。这种时候就需要差分测试diff test来救场。差分测试的思路特别朴素准备一个“参考答案”模拟器也就是参考模型让它和NEMU同时执行相同的程序每执行完一条指令就对比一次CPU状态PC、寄存器、内存等。如果两边状态一致说明这条指令实现正确如果不一致立刻就能锁定是哪条指令出的问题。人眼检查几千条指令不现实但机器对比几千次状态只要一眨眼。在“一生一芯”的流程里参考模型通常是QEMU这类成熟模拟器。你写的NEMU是“考生”QEMU是“标准答案”二者对拍差异就是你的bug。5.2 NEMU差分测试的实现思路共享内存和远程状态同步我第一次看差分测试的实现代码时被里面的共享内存机制绕得头晕。简单来说NEMU不会真的去启动一个完整的QEMU进程然后逐个对比而是通过共享内存约定一块区域QEMU把它的CPU状态写进去NEMU读出来和自身状态做对比。大致的流程是这样的编译时指定宏开启difftest支持NEMU启动后再拉起参考模型通常是fork出一个子进程。两块共享内存就位一块用来同步程序入口状态CPU初始化时的寄存器值另一块用来逐指令同步状态。NEMU每执行完一条指令就调用一次ref_difftest_step()从共享内存里把参考模型的PC、寄存器数组读出来和自己当前的cpu.pc、cpu.gpr逐一比对。如果发现不一致打印出具体是哪条指令、哪个寄存器对不上然后直接报错终止。这个设计最妙的地方在于它把“对比”操作从网络通信降级成了内存读写速度快到一个指令周期内就能完成。刚开始看代码时你可能觉得这跟NEMU教学模拟器的定位不搭但等你跑起来就会发现没有这个机制指令验证的工作量是不可想象的。5.3 差分测试的常见坑和排错技巧差分测试好用但它也不是开箱即用的。我踩过的坑主要有这几个第一个坑是参考模型没有正确初始化。如果你的NEMU加载程序后寄存器值和参考模型不一致那从第一条指令开始就会疯狂报错。排查方法很简单先打印两者的初始寄存器值看是否完全一致特别关注mepc、mstatus这些CSR寄存器。第二个坑是比对时机不对。NEMU的exec_once()里有一个宏控制是否调用差分测试但你要注意是在指令执行前还是执行后对比。如果执行后状态还没写回就去读参考模型得到的一定是对不上的结果。第三个坑是参考模型执行了NEMU不支持的指令。这种情况常见于你还没实现所有指令但测试程序里已经用到了。解决办法不是去改参考模型而是分批实现指令每实现一小批就跑一次差分测试让参考模型始终只跑你支持的那部分指令集。差分测试排错还有一个技巧不要只看第一个不一致的寄存器。有时候NEMU的PC已经跑飞后面所有寄存器都会跟着错。正确做法是先用si和info r手动模拟到出错的指令前几步确认那条指令本身是否正确再来判断是PC更新问题还是操作数读写问题。6. 常见问题速查与避坑清单6.1 几个高频报错和对应的解决思路我整理了一张自己在PA2阶段遇到的典型问题表希望对你有帮助。现象可能原因排查建议执行到某条指令后PC跳到0或随机值指令的dnpc未正确设置检查该指令执行结束后是否更新cpu.pc s-dnpc检查分支指令目标地址计算是否加了偏移量寄存器值全是0或出现奇怪数值译码阶段操作数读取出错用info r确认寄存器编号是否正确检查立即数是否做了符号扩展内存访问报错或值不对访存指令字节序或地址计算有问题检查vaddr_read和vaddr_write的参数小端环境要先确认是否存在字节交换逻辑差分测试从第一条指令就开始报错参考模型初始化与NEMU不一致先对比两项的初始cpu.pc和各寄存器值重点看CSR类寄存器是否有遗漏镜像能跑但结果莫名错误某条指令的运算逻辑有细微错误用最小测试用例单步执行观察每一步的中间值6.2 几条写给新手的实操心得第一个心得不要过度依赖网上别人写好的指令实现。NEMU的代码框架在不同版本之间差异不小别人的写法放到你的版本上多半要改。你要做的是看懂NEMU给的那几条样例指令然后照着它的风格写自己的实现保持代码风格统一比直接复制代码更重要。第二个心得每实现完一条指令立刻用最简单的测试用例验证。我自己的习惯是维护一个hello.c的极简程序里面只调用几种指令每次加完新指令就重新编译镜像跑一遍。这样做的好处是一旦测试失败新增的那条指令几乎必定是元凶。第三个心得日志分级很有用。不要一上来就在所有指令里加上Log()而是用条件编译或者日志级别控制。我自己会在调试阶段打开一个TRACE_EXEC开关专门输出最近100条指令的执行记录出问题后倒着看比盲猜效率高得多。第四个心得实在找不出bug试试删掉代码重写。听起来很反直觉但我在实现RISC-V的B型指令时反复修了三个小时最后重写了一遍就过了。很多时候你已经被错误的假设绕住了重写反而能逼着你重新审视指令语义。在我做NEMU学习第二阶段的过程中最大的体会是这个模拟器真正的价值不在于“把指令实现完”这个结果而在于让你亲眼见证一条高级语言程序如何一步一步变成CPU能执行的状态变化。差分测试第一次跑通时那种“我的模拟器和参考模型完全同步”的成就感是看多少篇博客都换不来的。最后再分享一个小技巧如果你在某条指令上卡住了试着在纸上把这条指令的“输入-输出”完整画出来包括每个操作数的二进制编码和流向。NEMU的代码逻辑都能在纸上推演出来当你发现纸上的推演结果和代码行为不一致时bug的位置基本上就水落石出了。
返回列表