YAOTU INSIGHTS

从零搭建RISC-V验证框架:基于riscv-tests与Verilator的RV32I处理器核验证实战

从零搭建RISC-V验证框架:基于riscv-tests与Verilator的RV32I处理器核验证实战
1. 为什么我要从零搭一套RISC-V验证框架动手写一个RISC-V处理器核最痛苦的不是写RTL而是写完以后不知道对不对。我见过太多人花两周撸完一个RV32I流水线跑两个自己手写的汇编测试就敢说“功能正常”结果一上真实程序就各种跑飞。处理器验证这件事本质上是在跟状态空间爆炸做斗争——一个32位寄存器堆加几KB内存理论状态数比宇宙原子还多你不可能穷举只能靠一套系统化的验证框架把“大概率出错的地方”全部覆盖住。这套框架的核心目标很明确用riscv-tests官方测试集作为黄金标准驱动你的处理器核跑完RV32I全部指令自动比对结果定位出错指令。它解决的是“我写的CPU到底能不能正确执行指令”这个最基础也最致命的问题。适合谁看正在做RISC-V教学核、FPGA软核、或者公司自研嵌入式核的验证工程师和爱好者。哪怕你用的是Verilator、VCS还是Icarus思路是通用的。我这次搭框架的硬件背景是一颗自己写的五级流水RV32I核跑在FPGA上主频50MHz指令和数据共用一块BRAM。验证环境用Verilator做仿真因为开源、快、跟C测试平台对接方便。下面我把整个框架的设计思路、关键细节、实操步骤和踩过的坑全部摊开讲。2. 验证框架整体设计与选型考量2.1 为什么选riscv-tests而不是自己写测试自己写汇编测试有两个致命问题一是覆盖不全你很难凭记忆把RV32I的37条指令的所有边界情况都想到二是没有权威性你写的测试通过了你也不知道是不是测试本身写错了。riscv-tests是RISC-V官方维护的测试集每条指令都有对应的测试文件每个文件里又包含大量子测试覆盖了正常值、边界值、溢出、对齐异常等各种情况。用它的好处是测试本身经过社区验证你只需要保证你的核能跑通就行。riscv-tests的RV32I测试集包含以下类别测试类别代表文件覆盖内容算术运算rv32ui-p-add, rv32ui-p-sub加减法及溢出逻辑运算rv32ui-p-and, rv32ui-p-or按位逻辑移位运算rv32ui-p-sll, rv32ui-p-sra逻辑/算术移位比较运算rv32ui-p-slt, rv32ui-p-sltu有符号/无符号比较加载存储rv32ui-p-lw, rv32ui-p-sw访存及对齐分支跳转rv32ui-p-beq, rv32ui-p-jal控制流上位立即数rv32ui-p-lui, rv32ui-p-auipc大立即数构造每个测试文件编译后生成一个ELF里面包含指令段、数据段和一个特殊的tohost符号。测试通过时向tohost写1失败时写(测试号1)|1。你的验证框架只需要监控这个地址的写入值就能判断结果。2.2 验证框架的三层架构我把框架分成三层每层职责清晰方便替换和扩展第一层测试生成层。负责把riscv-tests的汇编源编译成Verilog可加载的hex文件。这里用riscv64-unknown-elf-gcc工具链编译时指定-marchrv32i -mabiilp32链接脚本用riscv-tests自带的link.ld。编译完用objcopy转成binary再用脚本转成$readmemh能读的hex格式。第二层仿真驱动层。这是框架的核心用Verilator把RTL编译成C模型然后写一个C测试平台testbench负责加载hex到指令存储器、复位处理器、运行固定周期数、监控tohost地址、超时判断、结果打印。选Verilator而不是VCS的理由很简单——免费、编译快、生成的C模型执行效率高跑几千条指令的测试几秒钟就完事。第三层结果分析层。用Python脚本批量跑所有测试收集每个测试的通过/失败状态失败时自动反汇编定位出错指令。这层用Makefile串起来一条make test就能跑完全部RV32I测试。注意不要一上来就搞UVM那套重型验证方法学。对于RV32I这种指令集级别的验证定向测试加自动化比对已经足够UVM的复杂度会让你在验证环境本身上花掉比写核还多的时间。2.3 关键设计决策tohost监控机制riscv-tests的通过/失败信号是通过写tohost这个特殊内存地址来传递的。这个地址在链接脚本里定义通常是0x80001000。你的处理器核本身不需要知道tohost的存在它只是执行一条普通的store指令。验证框架需要在仿真层面监控这个地址的写入。具体做法是在C测试平台里每次处理器写内存时检查地址是否等于tohost的地址。如果是读取写入值等于1表示测试通过否则表示失败失败值右移一位就是失败的子测试编号。这个机制的好处是完全非侵入式你的RTL不需要为验证做任何修改。3. 核心细节解析与实操要点3.1 工具链安装与测试编译先装RISC-V工具链。Ubuntu下最省事的方式是直接下载预编译包# 下载工具链以2023年版本为例 wget https://github.com/riscv-collab/riscv-gnu-toolchain/releases/download/2023.10.10/riscv64-elf-ubuntu-22.04-gcc-nightly-2023.10.10-nightly.tar.gz tar -xzf riscv64-elf-ubuntu-22.04-gcc-nightly-2023.10.10-nightly.tar.gz export PATH$PWD/riscv64-elf-ubuntu-22.04-gcc-nightly-2023.10.10-nightly/bin:$PATH验证安装riscv64-unknown-elf-gcc --version # 应输出 riscv64-unknown-elf-gcc (gc891d8dc23e) 13.2.0然后克隆riscv-testsgit clone https://github.com/riscv-software-src/riscv-tests.git cd riscv-tests git submodule update --init --recursive编译RV32I测试cd isa make XLEN32 RISCV_PREFIXriscv64-unknown-elf- \ RISCV_GCC_OPTS-marchrv32i -mabiilp32 \ rv32ui-p-add rv32ui-p-sub rv32ui-p-and编译产物在isa/目录下是ELF格式。你需要把它转成hexriscv64-unknown-elf-objcopy -O binary rv32ui-p-add rv32ui-p-add.bin python3 -c import sys with open(rv32ui-p-add.bin,rb) as f: data f.read() with open(rv32ui-p-add.hex,w) as f: for i in range(0, len(data), 4): word int.from_bytes(data[i:i4], little) f.write(f{word:08x}\n) 提示riscv-tests的链接脚本把代码段起始地址设为0x80000000你的处理器复位向量必须指向这个地址。如果你的核复位向量是0x0需要修改链接脚本或者做地址映射。3.2 Verilator仿真环境搭建Verilator的安装很简单sudo apt install verilator # 或者从源码编译最新版 git clone https://github.com/verilator/verilator.git cd verilator autoconf ./configure make -j$(nproc) sudo make install写一个顶层testbench模块把处理器核、指令存储器、数据存储器例化进去module tb_top( input clk, input rst_n ); // 指令存储器 reg [31:0] imem [0:16383]; // 64KB // 数据存储器 reg [31:0] dmem [0:16383]; // 处理器核接口 wire [31:0] imem_addr; wire [31:0] imem_data; wire [31:0] dmem_addr; wire [31:0] dmem_wdata; wire [31:0] dmem_rdata; wire dmem_we; wire dmem_re; // 例化你的处理器核 riscv_core u_core( .clk(clk), .rst_n(rst_n), .imem_addr(imem_addr), .imem_data(imem_data), .dmem_addr(dmem_addr), .dmem_wdata(dmem_wdata), .dmem_rdata(dmem_rdata), .dmem_we(dmem_we), .dmem_re(dmem_re) ); // 指令存储器读 assign imem_data imem[imem_addr[15:2]]; // 数据存储器读写 assign dmem_rdata dmem[dmem_addr[15:2]]; always (posedge clk) begin if (dmem_we) dmem[dmem_addr[15:2]] dmem_wdata; end endmoduleC测试平台的核心逻辑#include Vtb_top.h #include verilated.h int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); Vtb_top* top new Vtb_top; // 加载hex到指令存储器 FILE* f fopen(argv[1], r); unsigned int word; int i 0; while (fscanf(f, %x, word) 1) { top-imem[i] word; } fclose(f); // 复位 top-rst_n 0; for (int i 0; i 10; i) { top-clk 0; top-eval(); top-clk 1; top-eval(); } top-rst_n 1; // 运行 long long cycle 0; const long long MAX_CYCLES 1000000; while (cycle MAX_CYCLES) { top-clk 0; top-eval(); top-clk 1; top-eval(); cycle; // 监控tohost写入 if (top-dmem_we top-dmem_addr 0x80001000) { unsigned int val top-dmem_wdata; if (val 1) { printf(TEST PASSED in %lld cycles\n, cycle); return 0; } else { printf(TEST FAILED: test %d\n, val 1); return 1; } } } printf(TIMEOUT after %lld cycles\n, cycle); return 2; }编译verilator --cc --exe --build -O3 \ -Wno-fatal \ tb_top.v riscv_core.v \ sim_main.cpp \ -o sim注意Verilator对时序逻辑的建模是周期精确的但你的RTL里如果有initial块或者延迟语句Verilator会忽略或报错。确保你的RTL是纯可综合风格。3.3 地址映射与tohost定位riscv-tests的链接脚本link.ld里定义了tohost的地址SECTIONS { . 0x80000000; .text : { *(.text.init) *(.text) } . ALIGN(0x1000); .tohost : { *(.tohost) } ... }tohost通常落在0x80001000附近。你可以在编译后查看符号表确认riscv64-unknown-elf-nm rv32ui-p-add | grep tohost # 输出类似80001000 D tohost如果你的处理器地址空间不是从0x80000000开始有两个选择改链接脚本把起始地址改成你的复位向量或者在你的存储器控制器里做地址重映射。我选的是改链接脚本因为这样riscv-tests的源码不用动。4. 实操过程与核心环节实现4.1 从零跑通第一个测试rv32ui-p-add先确保你的处理器核能执行最基本的指令。把rv32ui-p-add的hex加载进去跑仿真。如果一切正常你应该看到TEST PASSED in 1234 cycles如果失败输出会告诉你失败的子测试编号。比如TEST FAILED: test 3这时候你需要反汇编找到test 3对应的指令riscv64-unknown-elf-objdump -d rv32ui-p-add | grep -A 20 test_3反汇编输出会显示test_3的代码段你对照你的处理器执行轨迹看是哪条指令算错了。4.2 批量运行所有RV32I测试写一个Makefile把所有测试串起来TESTS $(wildcard isa/rv32ui-p-*.hex) RESULTS $(TESTS:.hex.result) all: $(RESULTS) %.result: %.hex sim ./sim $ $ 21 if grep -q PASSED $; then \ echo PASS: $; \ else \ echo FAIL: $; \ fi sim: tb_top.v riscv_core.v sim_main.cpp verilator --cc --exe --build -O3 -Wno-fatal \ tb_top.v riscv_core.v sim_main.cpp -o sim clean: rm -f sim *.result跑make -j$(nproc)并行执行所有测试。我实测下来37个RV32I测试在Verilator上跑完大概需要2分钟每个测试平均3秒。4.3 失败测试的定位流程假设rv32ui-p-sll失败了输出TEST FAILED: test 5。定位步骤反汇编找到test 5的代码riscv64-unknown-elf-objdump -d isa/rv32ui-p-sll | grep -B2 -A30 test_5在C测试平台里加指令trace把每条执行的指令的PC和结果打印出来if (top-clk !top-rst_n) { printf(PC%08x IR%08x\n, top-imem_addr, top-imem_data); }对比反汇编和trace找到第一条结果不一致的指令。如果是移位指令出错重点检查移位器的实现RV32I的SLL/SRL/SRA只使用rs2的低5位作为移位量如果你用了完整的32位就会出错。实操心得我踩过最大的坑是SRA指令。RV32I的SRA是算术右移需要根据符号位填充。我一开始写成了逻辑右移导致所有负数右移结果全错。riscv-tests的sra测试里专门有负数用例一跑就暴露了。4.4 波形调试与信号追踪Verilator支持生成VCD波形Verilated::traceEverOn(true); VerilatedVcdC* tfp new VerilatedVcdC; top-trace(tfp, 99); tfp-open(wave.vcd); // 在仿真循环里定期调用 tfp-dump(cycle);生成的VCD用GTKWave打开可以看每个周期的信号变化。我一般只在失败测试上开波形因为VCD文件很大跑几千周期就上百MB。5. 常见问题与排查技巧实录5.1 测试一直超时怎么办超时通常意味着处理器卡在某个循环里出不来。排查顺序现象可能原因排查方法PC不变化取指失败或复位向量错检查imem_addr是否在有效范围PC在几个地址间跳分支条件算错检查分支比较逻辑PC跳到非法地址跳转目标计算错检查JAL/JALR的立即数拼接一直写内存store指令死循环检查store地址计算我遇到过一次超时查了半天发现是复位向量设成了0x0但代码段在0x80000000处理器取到的全是0非法指令PC一直加4直到地址回绕。改复位向量后立刻跑通。5.2 测试通过但实际程序跑飞riscv-tests通过不代表你的核没问题。riscv-tests是定向测试覆盖的是指令级功能不覆盖流水线冒险、缓存一致性、中断等。我建议在riscv-tests之后再加两层验证随机指令测试用脚本生成随机指令序列在处理器和指令集模拟器如Spike上同时跑比对寄存器状态。真实程序测试跑CoreMark或Dhrystone验证处理器在真实负载下的行为。5.3 Verilator编译报错汇总报错信息原因解决%Error: Unsupported: delayed assignmentRTL里有延迟赋值改成非阻塞赋值%Error: Unsupported: initial block有initial块移到C测试平台%Warning: Signal is not driven信号未驱动检查例化连接%Error: Cannot find file文件路径错用绝对路径或-I指定5.4 性能优化技巧Verilator默认生成的模型比较慢几个优化手段用-O3开启最高优化用--x-assign fast和--x-initial fast减少X传播开销把不关心的信号用/* verilator lint_off */注释掉用--trace只在需要时开平时关掉我实测下来优化后跑一个测试从8秒降到2秒批量跑37个测试从5分钟降到1分半。6. 框架扩展与后续验证方向6.1 从RV32I扩展到RV32IMriscv-tests也有RV32IM的测试rv32um-p-*覆盖乘除法指令。扩展步骤在你的核里实现M扩展的乘法器和除法器编译rv32um测试时加-marchrv32im在Makefile里把rv32um测试加进TESTS变量乘除法是验证重灾区尤其是除零和溢出。RV32M规定除零时商为-1余数为被除数这个边界情况riscv-tests里有专门测试。6.2 加入覆盖率收集Verilator支持行覆盖率和翻转覆盖率verilator --cc --exe --build --coverage ...跑完测试后用verilator_coverage生成报告看哪些RTL行没被执行到。覆盖率低的模块往往是验证盲区需要补充定向测试。6.3 对接形式化验证对于关键模块如ALU、译码器可以用SymbiYosys做形式化验证证明其在所有输入下都满足规范。形式化验证和仿真验证互补仿真找bug快形式化证明无遗漏。6.4 自动化回归测试把整个流程串成CI流水线每次修改RTL后自动跑全部测试失败时自动发通知。我用的是GitLab CI配置很简单test: script: - make -j$(nproc) - make checkmake check检查所有.result文件里是否有FAIL有则返回非零退出码。7. 我在实际项目中的几点体会这套框架我从零搭到跑通全部RV32I测试前后花了大概一周时间其中大部分时间花在调试处理器核本身而不是验证框架上。验证框架搭好之后每次改RTL跑一遍回归测试只要两分钟极大提升了迭代速度。几个真心建议第一先跑通一个最简单的测试再批量跑不要一上来就37个测试一起上失败信息会把你淹没。第二保留每次失败的波形和trace很多bug是间歇性的不保留现场很难复现。第三不要迷信riscv-tests它只是起点不是终点真实处理器的验证还需要随机测试、形式化验证和真实程序测试三层叠加。最后分享一个小技巧如果你的处理器支持指令trace输出把它和Spike的trace做diff能自动定位到第一条不一致的指令。Spike是RISC-V官方指令集模拟器编译时加--enable-commitlog就能输出每条指令的PC和寄存器变化。这个diff方法比人工看波形快十倍强烈推荐。