riscv-tests实战:RISC-V CPU设计验证与指令集自检全解析
CPU设计跑到验证阶段我遇到过不少自称功能全通的设计结果一上riscv-tests就原形毕露。不是这里跳转不对就是CSR读写行为跟规范有出入。有一说一手写几条汇编测试一下焊点电路的时代早就过去了RISC-V指令集测试套件riscv-tests才是检验CPU内核设计是否真正符合ISA规范的硬标准。这篇文章我打算从riscv-tests能解决什么问题讲起拆解它的仓库结构、编译流程、ISA测试的底层自检原理再结合我自己在仿真中排查失败指令的经验最后聊聊riscv-tests覆盖不到的地方该怎么补。内容会比较长但都是实打实的踩坑心得适合正在做RISC-V CPU设计验证的开发者、研究RISC-V架构的学生以及准备把CPU跑上FPGA的硬件工程师参考。1. CPU设计验证的第一道门槛为什么偏偏是riscv-tests很多人刚开始验证CPU的时候第一反应是写几个简单的汇编程序比如算个斐波那契数列、跑个冒泡排序然后在modelsim或者VCS里看看波形觉得pc跑起来了、寄存器有值了就以为CPU没问题。这个阶段作为初步冒烟测试没问题但离验证CPU正确性还差得远。1.1 手写汇编测试的局限性我自己也干过这事写了一段测试代码循环里做累加然后检查最终结果。代码跑通了波形也正常心里还挺高兴。但后来换了一段稍微复杂的指令序列比如带异常的、带分支延迟槽的、或者访问非对齐地址的直接就跑飞了。问题出在哪里手写测试通常只能覆盖你想得到的情况而ISA规范里的边界条件远比你想的多。比如说addi这条指令你测试的时候可能只试了正数加正数、正数加负数但有没有试过立即数取到12位有符号边界值0x7FF和0x800有没有试过目标寄存器就是源寄存器比如addi x5, x5, 1有没有试过rs1是x0的情况这些边角情况手写测试很难系统覆盖。更关键的是手写测试缺少一个统一的判断机制。你在波形里看到一个值算对了但算对了的标准是什么谁来保证期望值本身是对的如果测试程序的期望值就写错了代码跑通了反而是把错误固化下来了。1.2 riscv-tests在RISC-V生态中的位置riscv-tests是RISC-V官方维护的指令集测试套件它的定位就是给CPU实现提供一个统一的正确性参照系。这套测试不是随便写几条指令跑一跑就完事而是针对每一条指令、每一个功能点在汇编层面做了严格的自检验证。简单来说riscv-tests在RISC-V生态里的地位就像Linux内核的selftest之于内核开发或者JUnit之于Java项目——它是验证一个实现是否符合规范的基准线。CPU设计者拿它来验证自己的RTL实现工具链开发者拿它来验证编译器的汇编器输出模拟器开发者比如Spike、QEMU也拿它来验证模拟器本身的行为是否和规范一致。我自己是在RTL仿真阶段开始用riscv-tests的。在这个阶段CPU还没有跑到FPGA上所有的执行行为都是通过仿真波形来观察的。riscv-tests的二进制通过testbench加载到存储器里CPU从复位向量开始取指执行最后通过一个特殊的握手协议告诉host仿真环境测试是pass还是fail。1.3 与其他验证手段的定位差异CPU验证的手段其实很多riscv-tests只是其中一环。我在实际项目中一般按下面这个顺序来做验证验证手段定位覆盖范围执行速度手写冒烟测试快速发现低级错误极窄只覆盖特定路径极快riscv-tests指令集功能正确性覆盖基础ISA及主要扩展快riscv-dv随机指令测试复杂场景与异常组合覆盖流水线冒险、异常竞争等中等形式化验证证明设计正确性针对特定属性慢SoC级系统测试外设交互与系统行为覆盖总线、中断控制器、外设慢riscv-tests夹在冒烟测试和随机指令测试之间。它比冒烟测试严格得多但执行速度又比随机指令测试快定位非常清晰。所以我的习惯是RTL改一版先跑riscv-tests的ISA全集跑通了再上riscv-dv做压力测试。如果riscv-tests都跑不通后面那些高强度的验证做了也是白做。2. 拆开riscv-tests的仓库结构每一层验证了什么拿到riscv-tests的代码后不建议直接上来就make先花点时间把仓库结构摸清楚后面排查问题会省很多力气。2.1 isa目录从rv32ui到rv64ufriscv-tests的核心在isa目录里面按指令集扩展和位宽分成了多个子目录命名规则大致是rv{32|64}{u|s}{i|m|a|f|d|c}这样的组合。我来解释一下这些字母的含义前两位数字表示架构位宽rv32是32位rv64是64位第三个字母表示运行模式u是用户模式U-modes是监督模式S-mode第四个字母表示指令集扩展i是基础整数指令集m是乘除扩展a是原子扩展f/d是单/双精度浮点c是压缩指令扩展举个例子rv32ui下面的测试是在用户模式下运行的基础整数指令测试rv64uf则是在用户模式下运行的浮点指令测试。每个子目录下是以p-、v-、pm-为前缀的测试文件。以rv32ui为例你会看到p-add、p-addi、p-sub、p-lbu、p-sb这样的测试目标基本上每条指令或者每个功能点都有一个独立的测试文件。这种一指令一测试的设计非常好用因为当某个测试失败时你直接就锁定了是哪条指令或哪个功能点出了问题。2.2 benchmarks目录和其他辅助模块benchmarks目录是干什么的它里面放的是dhrystone这类典型的基准测试程序用来评估CPU的性能而不是功能正确性。这些基准测试通常会被移植到SoC环境中运行配合串口或者其他调试接口输出性能数据。除了isa和benchmarks仓库里还有几个值得关注的模块。mt和ut目录分别放的是机器模式和用户模式的附加测试targets目录下是一些平台描述文件用来告诉riscv-tests在特定开发板上如何初始化env目录里放的是测试运行环境相关的代码比如testbench中需要用到的启动代码。我自己在实际使用中最关心的还是isa目录benchmarks一般是在CPU功能验证通过之后做性能评估的时候才会用。2.3 p/v/pm测试格式说明这块很多初学者会搞混。同样是rv32ui目录下的测试p-add、v-add、pm-add之间到底有什么区别p格式physical测试代码运行在物理内存直接映射的环境中不需要开启MMU。p是物理physical的缩写这种格式跑在M-mode或者U-mode不经转换的物理地址上。v格式virtual测试代码运行在开启了虚拟内存的环境中需要MMU的参与。这种格式会测试到地址翻译、页表遍历的过程验证MMU相关逻辑。pm格式physical with misaligned support其实一般理解是physical模式加上特定扩展pm开头表示在物理内存模式下额外验证对非对齐访问等特性的支持。从实际验证的角度来说如果你做的CPU不带MMU比如一些轻量级MCU核那么把p格式的测试跑通就基本够了。如果你在做带MMU的核比如跑Linux的那种v格式的测试就很重要因为它们会逼着你的MMU去做真实的地址翻译。3. 把测试跑起来工具链安装与编译流程讲完了仓库结构这篇的重点来了怎么把测试编译出来然后跑在你的CPU上。3.1 环境准备与工具链选择编译riscv-tests需要RISC-V的GNU工具链binutils、gcc、glibc或者newlib版本都行。我个人的建议是直接用官方的riscv-gnu-toolchain仓库按它的README步骤编译安装。这里有一个坑编译工具链的时候一定要配置multilib支持否则后面给32位目标编译测试的时候会报找不到库。工具链装好之后设置环境变量export RISCV/opt/riscv export PATH$RISCV/bin:$PATHRISCV变量指向工具链的安装路径。riscv-tests编译时依赖这个变量来找到交叉编译器不设置的话会直接报错。3.2 编译riscv-tests的标准流程下载仓库并初始化和编译git clone https://github.com/riscv-software-src/riscv-tests.git cd riscv-tests git submodule update --init --recursive autoconf ./configure --prefix$RISCV make -j$(nproc)这个流程会把所有ISA测试目标全部编译出来。如果你只想编译某个特定的子集比如只想编译rv32ui的测试可以在isa目录下单独执行make -C isa rv32ui-p-add我平时为了调试方便经常只编译单个测试目标。比如我在调加法指令的bug时只需要rv32ui-p-add和rv32ui-p-addi两个测试就够了不需要把全量几百个测试都编译一遍那样太费时间。编译完成后在isa/rv32ui/目录下会生成p-addELF格式、p-add.hexhex格式、p-add.bin纯二进制、p-add.dump反汇编等文件。这些文件各有用途ELF文件带符号信息适合在模拟器比如Spike里跑也适合用GDB调试hex/bin文件适合加载到RTL仿真环境的存储器模型里dump文件反汇编结果用来逐条指令对照你期望的行为3.3 测试镜像在仿真环境中的加载方式RTL仿真是CPU设计验证的主战场。在仿真环境里我一般用两种方式加载测试程序。第一种方式也是最常用的是用$readmemh把hex文件读入存储器的initial blockreg [31:0] mem [0:4095]; initial begin $readmemh(rv32ui-p-add.hex, mem); end这种方式简单直接适用于指令存储器用寄存器和SRAM模型搭建的小型CPU核。第二种方式是利用仿真工具自动将ELF转成Verilog可读取的格式。比如用Verilator的话可以通过--ram-init-file之类的参数加载初始化文件用VCS的话可以用$readmemh或者DPI-C调用。在执行RTL仿真之前一定要确认一件事你的测试程序入口地址和CPU的复位向量是否一致。riscv-tests的p格式测试默认期望从某一个地址开始执行一般由linker脚本决定常见的是0x80000000如果你的CPU复位向量是0x00000000就需要修改linker脚本或者在testbench里做地址映射否则CPU一复位就开始从错误位置取指跑出来的结果完全不可信。4. ISA测试的底层原理一个测试用例是怎么自我检验的跑通riscv-tests是一回事理解它为什么能验证实现正确性是另一回事。这一章我详细拆解一下ISA测试的内部机制。4.1 RVTEST宏与测试框架riscv-tests的每个测试文件都以一段RVTEST宏开头。拿rv32ui/p-add.S来举例简化版RVTEST_CODE_BEGIN RVTEST_CASE(0, clear_scratch, basic) li TESTNUM, 2 li x1, 0x12345678 li x2, 0x87654321 add x3, x1, x2 li x4, 0x99999999 bne x3, x4, fail RVTEST_CODE_END这段代码的逻辑是加载两个操作数到x1和x2执行add指令加载期望结果到x4用bne比较实际结果和期望结果不同则跳转到fail标签这里的TESTNUM是一个全局测试编号用来在fail的时候报告是哪一个测试用例失败。RVTEST_CASE(0, clear_scratch, basic)是在声明一个测试用例中间参数clear_scratch表示在测试前要清空scratch区域basic是这个用例的名称。RVTEST框架的核心价值在于它把测试的公共逻辑抽了出来。比如初始化栈指针、设置trap handler、初始化全局指针等这些脏活累活都由宏在背后帮你做掉了。测试代码里只需要关心我要测什么指令、怎么生成期望值、怎么比较这种抽象让每个测试文件都短小精悍可读性和可维护性大大提高。4.2 签名与pass/fail判定机制riscv-tests的判定机制在不同版本之间有些差异。老版本的测试通过一个叫tohost的符号来向外部通信具体方式是测试代码执行完所有指令后把一个状态值写入tohost变量对应的内存地址host程序比如Spike模拟器或testbench里的monitor读取这个地址就知道测试是通过还是失败。新版本的测试框架引入了签名signature机制。测试运行时会把计算结果写入一片预先分配好的内存区域测试结束后host程序把这片内存区域的数据dump出来和期望签名文件.signature做对比。两者一致才判定测试通过。签名机制比单纯pass/fail标志更严格。因为它检验的不仅是最终结果还涉及测试过程中写入内存的所有数据。这意味着即便你的CPU在某个指令上算出了正确结果但如果它在写入内存时的地址不对、或者写了一部分额外数据比如store指令的掩码逻辑有bug签名比对也能发现。不过在实际的RTL验证中我个人其实更习惯在testbench里做一个简单的monitor。当CPU执行到约定的pass标签时置一个test_pass信号为高电平执行到fail标签时置test_fail信号为高。这样在波形里一眼就能看到哪个测试出错了。4.3 为什么测试通过不等于实现正确这是一个我在多个项目中反复体会到的教训。riscv-tests通过只能说明你的CPU在这个测试覆盖的指令子集上行为符合预期不代表整个CPU设计完全正确。原因有三个第一测试是在特定配置下运行的。比如你编译的是RV32IMC的测试带乘除和压缩指令扩展但测试不会覆盖指令流水线的每一个冒险场景也不会覆盖所有可能的异常组合。第二测试的期望值是固定的。处理器执行的输入模式是确定的没有随机性所以它无法覆盖那些碰运气才能触发的bug比如缓存替换策略在某些特定访问序列下出错。第三riscv-tests主要验证的是架构层面的行为对微架构层面的实现细节不做约束。你的CPU可能在功能上完全正确但存在某些未被测试暴露的性能问题或死锁风险。所以riscv-tests在我的验证流程里是必要不充分的一步。它是你走向更复杂验证的入场券而不是终点。5. 实测中的失败排查从现象到根因的完整链路前面讲了原理这一章讲实操。我在用riscv-tests验证CPU的过程中积累了一套排查问题的思路和方法。下面用几个实际场景来还原完整的排查链路。5.1 测试超时的定位方法riscv-tests跑挂在仿真环境下最常见的现象是超时CPU一直不执行到pass或fail标签仿真时间无限向后走。第一次遇到这种情况我以为是CPU进了死循环。后来仔细查了波形才发现测试代码在某一条store指令上卡住了。CPU试图往一个地址写数据但那个地址在testbench里没有被使能总线上挂起了等待的slave没有响应所以CPU的流水线卡在那条store指令上永远等不到握手完成。这种问题的排查思路是在testbench里设置一个看门狗超时计数器超过一定周期数就自动暂停仿真并导出当前PC值把当前PC值对应到测试程序的地址空间用riscv64-unknown-elf-addr2line或者直接看dump文件反汇编定位卡在哪条指令上检查这条指令涉及的总线操作是否正常完成尤其是存储器模型的地址映射是否覆盖了测试程序的数据段区域我后来在testbench里固定加了一个超时机制每跑一个测试用例就检查一下耗时。这样不仅能及时发现卡死还能顺带估算每条指令的平均执行周期数对评估性能也有帮助。5.2 按指令类别切片定位问题riscv-tests的目录结构天然支持切片定位。当你跑全量测试时如果发现rv32ui下面的测试全部通过但rv32um下的测试大面积失败那问题大概率出在乘除法扩展的实现上。这时不要急着去翻波形先把rv32um的子分类扫一眼看是乘法还是除法的问题。我记得有一次调一个带M扩展的CPUrv32um下所有带除法的测试都失败带乘法的却正常。看波形时发现除法指令的运算结果完全不对商和余数都是随机值。后来定位到是除法器的状态机写错了当被除数为负数时符号处理逻辑在初始周期覆盖了操作数寄存器的值导致后续迭代全部基于错误数据。这个bug如果不是按类切片定位而是傻乎乎地在全量结果里一个个找不知道要折腾多久。5.3 结合波形与日志的联合排查经验光看波形不够光看日志也不够两个结合才有事半功倍的效果。在上一个小节的例子中我用了两路手段交叉验证一路是在RTL里加显示器打印关键信号——把每次除法运算的操作数、状态、结果打印出来另一路是用Spike模拟器跑同一个测试得到正确的寄存器值和内存值作为golden reference。然后把RTL仿真中PC处的寄存器快照和Spike的结果做逐周期对比很快就发现是在第几个周期开始偏离。现象可能问题排查方向测试超时总线挂起、存储器地址映射问题、死循环看门狗超时导出PC检查总线握手某个指令类全部失败功能单元实现错误、操作数符号处理错误分指令类别切片测试对比Spike结果边界值测试失败如立即数0x7FF/0x800立即数符号扩展逻辑错误检查立即数生成单元的符号扩展位带异常测试失败如非法指令trap handler入口错误、CSR读写问题检查mtvec、mcause、mepc行为浮点测试精度不匹配浮点运算单元舍入模式错误检查fcsr的舍入模式配置和实现这些排查经验让我深刻体会到一句话验证CPU设计不要靠蛮力要靠系统性的排查方法。先把问题缩小到一个指令子集然后对比参照实现定位到具体指令最后分析指令的执行路径基本都能找到根因。6. riscv-tests的天花板它验证不了什么以及如何补强前面讲了riscv-tests的用法和优势这一章我来聊聊它的边界。知道工具的边界在哪里比知道怎么用工具更重要。6.1 流水线与异常场景的盲区riscv-tests的ISA测试基本上以单条指令或多条简单指令为主它们依赖的执行路径相对规整分支预测的命中率很高流水线冒险也相对容易应对。问题在于实际运行程序时流水线会遇到各种复杂情况多个异常同时发生比如取指阶段的外部中断和访存阶段的缺页异常同时到达优先级怎么处理分支预测错误导致的指令回滚被错误预测的指令已经写了寄存器或内存需要精确恢复现场这种回滚机制是否正确乱序执行时的存储器顺序一致性如果一个load和一个store操作同一个地址但store先被乱序执行完了load还在等待这个先后关系对不对这些场景在riscv-tests里基本覆盖不到或者说覆盖得非常有限。我自己在做乱序核的时候就经常遇到riscv-tests全绿但随机指令测试跑一遍就红的尴尬情况。6.2 补强方案riscv-dv与arch-test针对riscv-tests覆盖不到的场景业界有标准的补强方案。第一个是Google的riscv-dv这是一套基于约束随机指令生成的方法。它的工作方式不是跑固定的测试用例而是根据配置随机生成大量指令序列然后同时在一个参照模型比如Spike模拟器和目标RTL上执行最后比较两者的执行结果。随机指令序列中天然包含大量流水线冒险、异常组合、CSR交互等复杂场景能非常有效地暴露riscv-tests发现不了的问题。我在用riscv-dv时最常用的方式是通过uvm环境集成。riscv-dv会生成几万条随机指令的测试程序装入RTL仿真跑完后再将执行过程中寄存器和内存的最终状态与Spike的对比结果输出。只要有一处不一致就说明CPU的行为和Spike不一致再进一步定位。第二个是riscv-arch-test。它和riscv-tests相似但更强调与架构规范的强一致性由RISC-V基金会国际规范工作组维护。riscv-arch-test的测试用例组织形式也是按指令分类但它对测试环境的要求更高要求你的验证平台支持SAIL模拟器作为参照模型。如果你的目标是做RISC-V的架构合规性认证riscv-arch-test是必经之路。6.3 我的建议测试应该什么时候跑、跑透到什么程度根据我的项目经验给出一套测试节奏建议在RTL开发的第一版冒烟测试中先跑rv32ui-p-*确保基础整数指令全部通过。这几条指令是整个CPU的基石连它们都过不了后续开发无从谈起。每实现一个功能模块比如乘法器、浮点单元、MMU立即编译并运行对应的riscv-tests子集。不要攒到所有功能都做完了再统一跑那样一旦失败排查范围会特别大。跑通全量riscv-tests后再上riscv-dv做压力测试。建议至少跑几万条随机指令把流水线冒险和异常组合的坑都踩一遍。如果做的是带MMU的设计不要只跑p格式测试v格式测试也一定要跑通因为页表遍历和TLB行为是riscv-tests的p格式覆盖不到的。跑透到什么程度算够我的判断标准是全量riscv-tests ISA测试通过加上riscv-dv压力测试连续跑10000条指令不出错这样的CPU设计才算达到功能正确性的合格线。接下来才谈得上性能优化和上板验证。最后再分享一个小技巧。在跑riscv-tests的时候我习惯每次只跑一个测试目标来定位问题比如make -C isa rv64um-p-div对着一条除法指令死磕。但全量测试阶段我会把目标改成跑所有测试然后让脚本自动收集哪些测试目标pass、哪些fail汇总成报告。这样既能保证每个细节都验证到又能快速得到整体验证进度在实际工程中非常有用。