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到rv64uf
riscv-tests的核心在isa目录,里面按指令集扩展和位宽分成了多个子目录,命名规则大致是rv{32|64}{u|s}{i|m|a|f|d|c}这样的组合。我来解释一下这些字母的含义:
- 前两位数字表示架构位宽,
rv32是32位,rv64是64位 - 第三个字母表示运行模式,
u是用户模式(U-mode),s是监督模式(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-add(ELF格式)、p-add.hex(hex格式)、p-add.bin(纯二进制)、p-add.dump(反汇编)等文件。这些文件各有用途:
- ELF文件:带符号信息,适合在模拟器(比如Spike)里跑,也适合用GDB调试
- hex/bin文件:适合加载到RTL仿真环境的存储器模型里
- dump文件:反汇编结果,用来逐条指令对照你期望的行为
3.3 测试镜像在仿真环境中的加载方式
RTL仿真是CPU设计验证的主战场。在仿真环境里,我一般用两种方式加载测试程序。
第一种方式,也是最常用的,是用$readmemh把hex文件读入存储器的initial block:
reg [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扩展的CPU,rv32um下所有带除法的测试都失败,带乘法的却正常。看波形时发现,除法指令的运算结果完全不对,商和余数都是随机值。后来定位到是除法器的状态机写错了:当被除数为负数时,符号处理逻辑在初始周期覆盖了操作数寄存器的值,导致后续迭代全部基于错误数据。这个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,汇总成报告。这样既能保证每个细节都验证到,又能快速得到整体验证进度,在实际工程中非常有用。