The riscv-tests Suite
riscv-testsis the set of hand-written assembly unit tests that came out of UC Berkeley with RISC-V itself, and it is the first thing a from-scratch core should be run against once it executes more than one instruction. Each test is a single self-checking program: it computes something with a known answer, compares, and writes a single word to a fixed memory location calledtohost—1for pass, an odd value encoding the failing test number otherwise. Your testbench’s entire job is to load the program and watch that address. The suite lives atriscv-software-src/riscv-tests(BSD-3-clause, Copyright The Regents of the University of California); this note reads commit2ebecad997fa58cd9e5724340ba75aa4b59bd1d0(2026-08-15) with itsenvsubmodule at6de71edb142be36319e380ce782c3d1830c65d68(2025-04-01).The reason definitely-not-esp32 MOC makes this a stage of its own is not that testing is virtuous. It is that your own tests encode your own misunderstandings. To demonstrate that rather than assert it, this note includes a real experiment: a 217-line single-cycle RV32IM core was written specifically for this note, run against the suite under Verilator 5.046, and failed four of the 42
rv32ui-ptests on the first attempt —lui,sra,sraiandma_data. Three of those four turned out to be one bug, and it was not a RISC-V bug at all. That result, and the diagnosis, is the spine of everything below.One more thing this note exists to settle:
riscv-testsis not the RISC-V compliance suite. The suite that certifies conformance is the separate RISC-V Architectural Certification Tests atriscv/riscv-arch-test, and the RISCOF tool that most write-ups still name as its runner has been deprecated and its repository archived. Both facts are verified below.
Mental Model — A Program Whose Only Output Is One Word
A riscv-tests test is not a testbench, a framework, or a script. It is one .S file, preprocessed by cpp, assembled and linked into a bare-metal ELF that begins at 0x80000000 and communicates its verdict by storing to memory. That is the whole design, and it is the reason the suite works on a Verilog simulation, an FPGA, an emulator and a real chip without modification: it assumes nothing except a processor, some RAM, and someone watching one address.
The repository’s README frames this as a test virtual machine (TVM) — a deliberately restricted execution environment that “hides differences between alternative implementations by defining: the set of registers and instructions that can be used; which portions of memory can be accessed; the way the test program starts and ends execution; the way that test data is input; the way that test results are output.”
flowchart TB RST["reset at 0x80000000<br/>j reset_vector"] --> INIT subgraph INIT["reset_vector — RVTEST_CODE_BEGIN prologue"] X["INIT_XREG: li x1..x31, 0"] --> MC["RISCV_MULTICORE_DISABLE<br/>csrr a0, mhartid; spin if != 0"] MC --> RN["INIT_RNMI: csrwi mnstatus"] RN --> SA["INIT_SATP: csrwi satp, 0"] SA --> PMP["INIT_PMP: pmpaddr0 = all,<br/>pmpcfg0 = NAPOT|R|W|X"] PMP --> DEL["DELEGATE_NO_TRAPS:<br/>mie=0, medeleg=0, mideleg=0"] DEL --> TV["li gp, 0<br/>csrw mtvec, trap_vector"] TV --> XL["CHECK_XLEN"] XL --> MRET["csrw mepc, test_2<br/>mret"] end MRET --> BODY subgraph BODY["the actual tests"] T2["test_2: li gp,2 … bne x14,x7,fail"] --> T3["test_3: li gp,3 …"] T3 --> TN["… test_38"] end TN --> PF{"TEST_PASSFAIL<br/>bne x0, gp, pass"} PF -->|reached the end| PASS["pass: li gp,1<br/>li a7,93; li a0,0; ecall"] PF -->|a bne jumped here| FAIL["fail: gp = (gp<<1)|1<br/>li a7,93; ecall"] PASS --> TRAP FAIL --> TRAP EXC["any unexpected exception"] --> TRAP subgraph TRAP["trap_vector"] CHK{"mcause == 8, 9 or 11<br/>(an ecall)?"} CHK -->|no| OTH["other_exception:<br/>ori gp, gp, 1337"] CHK -->|yes| WT OTH --> WT["write_tohost:<br/>sw gp, tohost<br/>sw x0, tohost+4<br/>j write_tohost"] end
The control flow of every -p variant test, reconstructed from env/p/riscv_test.h. What it shows: the reset prologue is longer than most of the tests, and it is entirely CSR manipulation — which means a core that has no CSRs at all cannot reach test_2, let alone fail it. The insight to take: look at the EXC arrow into trap_vector. Every path out of a test — pass, fail, and unhandled exception — converges on one store to tohost. That is why a testbench needs to watch exactly one address and nothing else, and why an unimplemented instruction shows up as a tohost value rather than a hang.
The prologue deserves one observation that is not obvious and saves a lot of confusion: each INIT_* macro arms mtvec at the label immediately following it before doing anything risky. INIT_PMP is the clearest case:
#define INIT_PMP \
la t0, 1f; \
csrw mtvec, t0; \
li t0, (1 << (31 + (__riscv_xlen / 64) * (53 - 31))) - 1; \
csrw pmpaddr0, t0; \
li t0, PMP_NAPOT | PMP_R | PMP_W | PMP_X; \
csrw pmpcfg0, t0; \
.align 2; \
1:If your core has no pmpaddr0, the csrw raises an illegal-instruction exception, the hardware jumps to mtvec — which was deliberately set to label 1: two instructions earlier — and execution simply continues past the failed setup. The tests are tolerant of missing optional CSRs by construction. What they are not tolerant of is a missing mtvec: get that wrong and the very first illegal instruction sends the program counter somewhere undefined. This is the practical dependency the MOC’s stage ordering implies — Stage 4 needs the beginnings of Stage 6’s Control and Status Registers before a single rv32ui-p test can run.
The Naming Scheme, Decoded
A test binary is named <TVM>-<environment>-<instruction>, e.g. rv32ui-p-add. Three fields, read left to right:
| Field | Value | Meaning |
|---|---|---|
| XLEN | rv32 / rv64 | register width the test assumes |
| privilege | u | user-level: no SYSTEM-opcode instructions in the test body |
s | supervisor-level state and instructions | |
m | machine-level state and instructions | |
| extension | i | base integer only — no floating point, no F registers touched |
m | multiply/divide | |
c | compressed | |
a | atomics | |
f / d | single- / double-precision floating point | |
zb*, zfh, zicond, … | the corresponding Z-extension | |
| environment | p | physical: virtual memory disabled, only hart 0 boots |
pm | physical, all cores boot | |
pt | physical, timer interrupt every 100 cycles | |
v | virtual: virtual memory enabled, test runs in U-mode |
The riscv-tests naming scheme, from the repository README’s two tables. What it shows: the name is a complete specification of what the test needs from your machine. The insight to take: rv32ui-p-add is the least demanding combination in the entire suite — 32-bit, user-level, integer-only, physical addressing, single hart. If your core cannot pass that one, no other test in the suite is worth running yet.
Counting the RV32 test sources in the repository at the commit read here gives the shape of the work ahead of a Stage 4 core:
| Directory | .S files | What it covers |
|---|---|---|
rv32ui | 42 | the whole of RV32I: arithmetic, shifts, branches, jumps, all load/store widths, fence_i, plus simple, ld_st, st_ld, ma_data |
rv32um | 8 | mul mulh mulhsu mulhu div divu rem remu |
rv32uc | 1 | rvc — a single test exercising the compressed encodings |
rv32ua | 10 | A extension (lr/sc, AMOs) |
rv32uf / rv32ud | 11 / 11 | F and D |
rv32mi | 16 | machine-level: CSRs, illegal instructions, misaligned fetch and data, breakpoints, scall, PMP |
rv32si | 6 | supervisor-level |
The MOC’s Stage 4 instruction — “build and run riscv-tests rv32ui-p-* against your core in Verilator, and do not proceed until every one passes. Then rv32um for the M extension, then rv32uc” — is exactly the first three rows, 51 programs.
One structural detail worth knowing before you go looking for the source of rv32ui-p-add: the rv32* test files are six-line shims. The real test is the RV64 one, re-included with the TVM macro redefined:
# isa/rv32ui/add.S, in its entirety
# See LICENSE for license details.
#include "riscv_test.h"
#undef RVTEST_RV64U
#define RVTEST_RV64U RVTEST_RV32U
#include "../rv64ui/add.S"The RV64 source contains 64-bit constants like 0xffffffffffff8000; the MASK_XLEN(x) macro in test_macros.h truncates them to __riscv_xlen bits at assembly time. So when you read rv64ui/add.S you are reading the RV32 test too, with the top halves discarded.
Anatomy of a Test: RVTEST_RV32U, TEST_CASE, and TESTNUM
Every test body is built from one macro. From isa/macros/scalar/test_macros.h:
#define TEST_CASE( testnum, testreg, correctval, code... ) \
test_ ## testnum: \
li TESTNUM, testnum; \
code; \
li x7, MASK_XLEN(correctval); \
bne testreg, x7, fail;Four things happen, in order: label the test, record the test number in TESTNUM, run the code under test, and branch to fail if the result register does not hold the expected value. TESTNUM is #define TESTNUM gp — register x3. That choice is not arbitrary: the RISC-V psABI lists x3/gp as “Unallocatable” in the standard integer calling convention, so no compiler-generated code will ever clobber it. The suite commandeers the one register the ABI guarantees is nobody’s.
Everything else is a wrapper. TEST_RR_OP fills in the register-register case:
#define TEST_RR_OP( testnum, inst, result, val1, val2 ) \
TEST_CASE( testnum, x14, result, \
li x11, MASK_XLEN(val1); \
li x12, MASK_XLEN(val2); \
inst x14, x11, x12; \
)and rv64ui/add.S invokes it 15 times with boundary values chosen to break naive implementations — 0x00000000 + 0x00000000, 0x7fffffff + 0x00007fff (positive overflow), 0x80000000 + 0xffff8000 (negative overflow), 0xffffffff + 0x00000001 (wrap to zero). Then it does something a hand-written test rarely does. It tests the microarchitecture:
TEST_RR_DEST_BYPASS( 20, 0, add, 24, 13, 11 );
TEST_RR_DEST_BYPASS( 21, 1, add, 25, 14, 11 );
TEST_RR_DEST_BYPASS( 22, 2, add, 26, 15, 11 );
TEST_RR_SRC12_BYPASS( 23, 0, 0, add, 24, 13, 11 );
...
TEST_RR_ZEROSRC1( 35, add, 15, 15 );
TEST_RR_ZERODEST( 38, add, 16, 30 );TEST_RR_DEST_BYPASS(n, k, …) inserts exactly k nops between producing a value and consuming it, then runs the whole sequence twice through a loop so the second iteration hits a warm pipeline. That is a direct probe of Operand Forwarding and Load-Use Hazard — the tests exist because the same authors were bringing up pipelined cores and knew where the bodies are buried. TEST_RR_ZERODEST writes to x0 and checks it is still zero, which catches the classic The Register File bug of implementing x0 as a normal register that happens to start at zero.
Test numbering starts at 2, never 1, and never 0. The reason is arithmetic and is explained in the next section.
TEST_PASSFAIL closes every file:
#define TEST_PASSFAIL \
bne x0, TESTNUM, pass; \
fail: \
RVTEST_FAIL; \
pass: \
RVTEST_PASSFalling off the end of the last test reaches bne x0, TESTNUM, pass — TESTNUM is nonzero because the last test set it, so control goes to pass. Every bne inside a TEST_CASE targets the fail label just above it.
How a Test Signals Pass or Fail: tohost, fromhost, and HTIF
This is the part your testbench has to implement, so it is worth getting exactly right rather than approximately right.
RVTEST_DATA_BEGIN emits two 8-byte, 64-byte-aligned globals into their own section:
#define RVTEST_DATA_BEGIN \
.pushsection .tohost,"aw",@progbits; \
.align 6; .global tohost; tohost: .dword 0; .size tohost, 8; \
.align 6; .global fromhost; fromhost: .dword 0; .size fromhost, 8; \
.popsection; \
.align 4; .global begin_signature; begin_signature:and the linker script env/p/link.ld puts that section on its own page:
SECTIONS
{
. = 0x80000000;
.text.init : { *(.text.init) }
. = ALIGN(0x1000);
.tohost : { *(.tohost) }
. = ALIGN(0x1000);
.text : { *(.text) }
...
}In practice tohost lands at 0x80001000 for almost every test — measured across the 67 RV32 binaries built for this note, 65 had tohost at 0x80001000, one at 0x80002000 (rv32ui-p-ld_st) and one at 0x80003000 (rv32uc-p-rvc), the two whose .text.init overflows a 4 KiB page. Do not hard-code 0x80001000. Read the symbol out of the ELF, which is what the reference host does — fesvr/htif.cc looks it up by name:
if (symbols.count("tohost") && symbols.count("fromhost")) {
tohost_addr = symbols["tohost"];
fromhost_addr = symbols["fromhost"];
} else {
fprintf(stderr, "warning: tohost and fromhost symbols not in ELF; "
"can't communicate with target\n");
}nm is the one-liner equivalent: th=0x$(riscv64-linux-gnu-nm $test | awk '$3=="tohost"{print $1}').
The value written
The full protocol is called HTIF (Host-Target InterFace) and it treats the 64-bit word as three fields. From fesvr/device.h:
uint8_t device() { return tohost >> 56; }
uint8_t cmd() { return tohost >> 48; }
uint64_t payload() { return tohost << 16 >> 16; }packet-beta 0-47: "payload (48 bits)" 48-55: "cmd" 56-63: "device"
The 64-bit tohost word. What it shows: three fields, not one integer — device 0 is the syscall proxy, device 1 is the character console. The insight to take: the -p tests only ever write device 0, command 0, so the top 16 bits are zero and the word degenerates to “payload”. That is why a 32-bit store to tohost plus a zero store to tohost+4 is sufficient, and why a testbench that reads only the low 32 bits works — for -p. A console-printing test writes 0x0101_0000_0000_0000 | char, and a testbench that ignores the top bits will misread that as a bizarre exit code.
For device 0, command 0, fesvr/syscall.cc splits on the low bit of the payload:
void syscall_t::handle_syscall(command_t cmd)
{
if (cmd.payload() & 1) // test pass/fail
{
htif->exitcode = cmd.payload();
if (htif->exit_code())
std::cerr << "*** FAILED *** (tohost = " << htif->exit_code() << ")" << std::endl;
return;
}
else // proxied system call
dispatch(cmd.payload());
cmd.respond(1);
}Odd payload means “the program is finished”; even payload means “this is a pointer to a syscall argument block”. The exit code is payload >> 1 — which is why sys_exit on the other side of the same file writes htif_exit(code << 1 | 1).
Now the two macros make complete sense:
#define RVTEST_PASS \
fence; li TESTNUM, 1; li a7, 93; li a0, 0; ecall
#define RVTEST_FAIL \
fence; \
1: beqz TESTNUM, 1b; \
sll TESTNUM, TESTNUM, 1; \
or TESTNUM, TESTNUM, 1; \
li a7, 93; addi a0, TESTNUM, 0; ecall- Pass sets
TESTNUM = 1.1is odd, and1 >> 1 == 0, so the exit code is 0. - Fail computes
(TESTNUM << 1) | 1— also odd, and>> 1recovers the failing test number. Thebeqz TESTNUM, 1bin front is a deliberate infinite loop ifTESTNUMis still zero, because0 << 1 | 1 == 1would report a pass. That is why test numbering starts at 2: number 0 is unrepresentable and number 1 is the pass code. - Both set
a7 = 93(the Linuxexitsyscall number) and issueecall. In the-penvironment theecallis caught bytrap_vector, which writesgp— nota0— totohost; thea7/a0setup is what the-venvironment needs. In-pit is vestigial, and knowing that stops you looking for ana7-decoding step you do not have to implement. - Unhandled exception takes the third path:
ori gp, gp, 1337.1337is0x539, odd, so it terminates the run, and0x539is a recognisable marker. Atohostof0x53bmeans “an exception you did not handle occurred during test 2” —0x53b = 2 | 0x539— not “test 669 failed”. A naive testbench that printstohost >> 1will report nonsense here; special-case the marker.
Here is the actual code, disassembled out of a binary built for this note, so none of the above is taken on faith:
$ riscv64-linux-gnu-objdump -d --section=.text.init build/rv32ui-p-add
80000004 <trap_vector>:
80000004: 34202f73 csrr t5,mcause
80000008: 00800f93 li t6,8 # CAUSE_USER_ECALL
8000000c: 03ff0863 beq t5,t6,8000003c <write_tohost>
80000010: 00900f93 li t6,9 # CAUSE_SUPERVISOR_ECALL
80000014: 03ff0463 beq t5,t6,8000003c <write_tohost>
80000018: 00b00f93 li t6,11 # CAUSE_MACHINE_ECALL
8000001c: 03ff0063 beq t5,t6,8000003c <write_tohost>
...
80000038 <handle_exception>:
80000038: 5391e193 ori gp,gp,1337
8000003c <write_tohost>:
8000003c: 00001f17 auipc t5,0x1
80000040: fc3f2223 sw gp,-60(t5) # 80001000 <tohost>
80000044: 00001f17 auipc t5,0x1
80000048: fc0f2023 sw zero,-64(t5) # 80001004 <tohost+0x4>
8000004c: ff1ff06f j 8000003c <write_tohost>Note the final j write_tohost: the store is repeated forever. A testbench that samples tohost once per cycle cannot miss it, and one that polls slowly cannot miss it either. That loop is the reason the protocol is robust across wildly different observers.
What your testbench must therefore do, minimally: (1) read the tohost address from the ELF symbol table; (2) snoop the core’s data-store port for a write to that address; (3) if the value is 1, pass; if odd and & 1337 == 1337, an unhandled exception during test value & ~1337; if otherwise odd, test value >> 1 failed; (4) enforce a cycle budget, because a core that hangs writes nothing at all. The suite README asks for exactly that last point: “Any given test environment for running tests should also include a timeout facility, which will class a test as failing if it does not successfully complete a test within a reasonable time bound.”
-p Versus -v: Physical and Virtual Variants
Every test compiles into two binaries from the same source. They demand very different machines.
Uncertain
Verify: the
-vcolumn of the table below. Reason: read from source, not run. No-vbinary was built or executed for this note — the machine used has no RV32 MMU model and the core written here has none either, so the claims about Sv32,sfence.vma, thescallpath throughvm.cand the0x0101000000000000 | cconsole encoding come from readingenv/v/riscv_test.h,env/v/entry.Sandenv/v/vm.cat submodule commit6de71edb, plusfesvr/device.h’s field accessors. To resolve: build onerv32ui-v-addand run it under Spike, or against a core that has reached Stage 7. The-pcolumn, by contrast, was measured. uncertain
-p (physical) | -v (virtual) | |
|---|---|---|
| Environment header | env/p/riscv_test.h | env/v/riscv_test.h + entry.S + vm.c + string.c |
| Privilege the body runs in | M-mode (entered via mret from the reset vector) | U-mode |
| Address translation | none; satp written to 0 during init | Sv32 on RV32 (#define SATP_MODE_CHOICE SATP_MODE_SV32), Sv39 on RV64 |
Needs sfence.vma | no | yes — vm.c uses it on every page-table change |
| Pass/fail path | ecall → trap_vector → sw gp, tohost | scall from U-mode → M-mode handler in vm.c → terminate() → do_tohost() |
| Console output | none | cputchar() writes 0x0101000000000000 | c to tohost |
| Extra machinery it exercises | almost none | page tables, TLB behaviour, page faults, trap delegation, HTIF handshaking with fromhost |
| Build cost | one .S file | -std=gnu99 -O2, plus entry.S and three C files, plus a per-test -DENTROPY= seed |
What the two environments demand. What it shows: -v is not “the same tests with paging on” — it is a small operating system wrapped around the test. The insight to take: for definitely-not-esp32 MOC’s RV32IMC core, -p is the only relevant variant and will stay so until the project grows an Memory Management Unit and Sv32 Virtual Memory — which the MOC has at Stage 7 and marks as the expensive alternative to Physical Memory Protection. Building -v binaries for a core with no MMU is not a stretch goal; it is a category error.
Building the Suite
The documented route is autoconf && ./configure --prefix=$RISCV/target && make, which requires riscv64-unknown-elf-gcc on PATH. That toolchain was not present on the machine used for this note. The suite builds anyway, because the compile line in isa/Makefile is a single command with no hidden dependencies:
RISCV_GCC_OPTS ?= -static -mcmodel=medany -fvisibility=hidden -nostdlib -nostartfiles
$$(RISCV_GCC) -march=rv32g -mabi=ilp32 $$(RISCV_GCC_OPTS) \
-I../env/p -Imacros/scalar -T../env/p/link.ld $$< -o $$@Substituting the installed cross-compiler (see The RISC-V Cross-Compilation Toolchain) builds every RV32 physical test:
$ riscv64-linux-gnu-gcc --version | head -1
riscv64-linux-gnu-gcc (GCC) 16.1.1 20260501 (Red Hat Cross 16.1.1-1)
$ for t in simple add addi and andi auipc beq bge bgeu blt bltu bne fence_i \
jal jalr lb lbu lh lhu lw ld_st lui ma_data or ori sb sh sw st_ld \
sll slli slt slti sltiu sltu sra srai srl srli sub xor xori; do
riscv64-linux-gnu-gcc -march=rv32g -mabi=ilp32 -static -mcmodel=medany \
-fvisibility=hidden -nostdlib -nostartfiles -I../env/p -Imacros/scalar \
-T../env/p/link.ld rv32ui/$t.S -o build/rv32ui-p-$t || echo "FAIL $t"
done
rv32ui-p built ok=42 fail=067 binaries in total across rv32ui (42), rv32um (8), rv32uc (1) and rv32mi (16). Two flags in that line are worth understanding rather than copying.
-march=rv32g, not rv32imc. rv32g expands to rv32i2p1_m2p0_a2p1_f2p2_d2p2_zicsr2p0_zifencei2p0_... — crucially including Zicsr and Zifencei, which the reset prologue needs for csrw mtvec and which rv32imc does not imply under GCC’s default -misa-spec=20191213. It does not include C, so every instruction in a rv32ui-p binary is a full 32 bits. That is a gift for Stage 4: you can pass the entire integer suite before writing a compressed decoder.
.option rvc inside the source. So how does rv32uc-p-rvc contain compressed instructions if it is built with -march=rv32g? The test forces the assembler’s hand per-instruction:
#define RVC_TEST_CASE(n, r, v, code...) \
TEST_CASE (n, r, v, .option push; .option rvc; code; .align 2; .option pop)An honest consequence: objdump reads the ELF’s arch attributes to choose a decoder, and those say “no C”, so a plain objdump -d of rv32uc-p-rvc renders the compressed halfwords as .word garbage. Pass -M no-aliases and an explicit architecture, or just read the source.
Running a Test Under a Verilator Harness
To make the rest of this note an experiment rather than an assertion, a deliberately minimal RV32IM core was written for it: 217 lines of Verilog, single-cycle, unified memory, a flat 4096-entry CSR array with no permission checks, ecall/ebreak/mret and an illegal-instruction trap. It is not a good core. It is a checkable one, and it is roughly what a Stage 2 The Single-Cycle Processor looks like a week in.
The harness is 39 lines of C++:
#include "Vrv32im_soc.h"
#include "verilated.h"
int main(int argc, char** argv) {
Verilated::commandArgs(argc, argv);
uint32_t tohost = strtoul(argv[1], nullptr, 0); // from `nm`
uint64_t max_cyc = strtoull(argv[2], nullptr, 0); // the timeout the README asks for
Vrv32im_soc* dut = new Vrv32im_soc;
dut->clk = 0; dut->rst = 1;
for (int i = 0; i < 4; i++) { dut->clk = !dut->clk; dut->eval(); }
dut->rst = 0;
for (uint64_t cyc = 0; cyc < max_cyc; cyc++) {
dut->clk = 1; dut->eval();
if (dut->wr_valid && dut->wr_addr == tohost) { // <-- the whole protocol
uint32_t v = dut->wr_data;
if (v == 1) printf("PASS (%llu cycles)\n", cyc);
else if ((v & 1337u) == 1337u) printf("TRAP tohost=0x%08x unhandled "
"exception, TESTNUM=%u\n", v, v & ~1337u);
else printf("FAIL tohost=0x%08x testnum=%u\n", v, v >> 1);
break;
}
dut->clk = 0; dut->eval();
}
}The core exposes three debug outputs — wr_valid, wr_addr, wr_data — mirroring its store port. That is the only interface the harness needs; it never reaches into the design’s internals, which means the same harness survives the Stage 3 rewrite into a Classic Five-Stage Pipeline unchanged. See Testbenches and RTL Verification for the general shape.
Loading is the last mechanical problem. The ELF becomes a one-word-per-line hex file that $readmemh consumes:
riscv64-linux-gnu-objcopy -O binary --gap-fill 0 "$1" "$t"
od -An -tx4 -v "$t" | tr -s ' ' '\n' | grep -v '^$'sequenceDiagram autonumber participant MK as build script participant NM as riscv64-…-nm participant TB as Verilator harness (C++) participant DUT as rv32im_soc (RTL) participant MEM as mem[] (unified) MK->>MK: gcc -march=rv32g … -T env/p/link.ld → rv32ui-p-add MK->>MK: objcopy -O binary | od -tx4 → prog.hex NM-->>TB: tohost = 0x80001000 TB->>MEM: $readmemh("prog.hex", mem) at elaboration TB->>DUT: rst = 1 for 2 clocks, then rst = 0 DUT->>MEM: fetch 0x80000000 → j reset_vector loop reset prologue DUT->>DUT: zero x1..x31, csrw mtvec, csrw pmpcfg0 end DUT->>DUT: csrw mepc, test_2 → mret loop test_2 … test_38 DUT->>DUT: li gp,n → compute → bne result, expected, fail end alt all tests matched DUT->>DUT: pass → li gp,1 → ecall else a bne branched DUT->>DUT: fail → gp = (gp<<1)|1 → ecall end DUT->>DUT: trap: mcause==11 → write_tohost DUT->>MEM: sw gp, 0x80001000 DUT-->>TB: wr_valid=1, wr_addr=0x80001000, wr_data=gp TB->>TB: gp==1 gives PASS (508 cycles), exit 0 Note over DUT: the store repeats forever (j write_tohost),<br/>so no observer can miss it
One test running end to end. What it shows: the handshake between four programs — the compiler, nm, the C++ harness and the RTL — and where each hands off. The insight to take: step 9 (wr_valid/wr_addr/wr_data) is the only wire between “my design” and “the official suite”. Everything upstream is a build script and everything downstream is a printf. That is a two-hour integration job, not a two-week one, which is the practical argument for doing Stage 4 rather than skipping it.
What actually happened
First run, rv32ui-p only, no fixes applied:
rv32ui-p-add PASS (508 cycles) rv32ui-p-lui FAIL tohost=0x00000007 testnum=3
rv32ui-p-addi PASS (285 cycles) rv32ui-p-ma_data FAIL tohost=0x00000007 testnum=3
rv32ui-p-and PASS (528 cycles) rv32ui-p-sra FAIL tohost=0x00000007 testnum=3
... rv32ui-p-srai FAIL tohost=0x00000007 testnum=3
---- pass=38 fail=4 ----
Four failures, all at test 3, all tohost = 0x7 — which is (3 << 1) | 1. Reading the failing sources shows immediately that three of the four are the same failure, because lui.S test 3 is:
TEST_CASE( 3, x1, 0xfffffffffffff800, lui x1, 0xfffff; sra x1,x1,1);lui was never broken. sra was. And the bug was this line of Verilog:
3'b101: alures = f7[5] ? ($signed(x1) >>> shamt) : (x1 >> shamt);which is wrong for a reason that has nothing to do with RISC-V. In Verilog, if either operand of the conditional operator is unsigned, the result is unsigned and both operands are converted to unsigned — so $signed(x1) is demoted before >>> runs, and >>> on an unsigned value is a logical shift. Arithmetic right shift silently became logical right shift. The fix is to keep the signed operation out of the ternary:
3'b101: if (f7[5]) alures = $signed(x1) >>> shamt; // sra/srai
else alures = x1 >> shamt; // srl/srliOne line. pass=38 became pass=41.
The same bug then reappeared, unprompted, in the M extension:
rv32um-p-div FAIL tohost=0x00000007 testnum=3
rv32um-p-rem FAIL tohost=0x00000007 testnum=3
rv32um-p-divu PASS rv32um-p-mul PASS rv32um-p-mulh PASS
Signed division and remainder failed; unsigned ones passed. Same cause — $signed(x1) / $signed(x2) sitting inside a ternary with unsigned arms for the divide-by-zero and overflow special cases. Hoisting them into their own signed wires fixed both:
wire signed [31:0] sq = $signed(x1) / $signed(x2); // must be its OWN signed wire
wire signed [31:0] sr = $signed(x1) % $signed(x2);
wire [31:0] q_s = dz ? 32'hFFFF_FFFF : (ovf ? 32'h8000_0000 : sq);pass=8 fail=0.
The fourth failure, ma_data, was a different animal and is the more interesting one. Its test 3 is lw from an address one byte past a word boundary:
MISALIGNED_LOAD_TEST(3, lw, s0, 1, SEXT(0x04030201, 32))The core read one memory word and shifted, so it could handle misalignment within a word and not across one. But the deeper question is whether the test is even fair, and the ratified Unprivileged ISA says a core may legitimately refuse:
Loads and stores whose effective address is not naturally aligned to the referenced datatype […] have behavior dependent on the EEI. An EEI may guarantee that misaligned loads and stores are fully supported […] An EEI may not guarantee misaligned loads and stores are handled invisibly. In this case, loads and stores that are not naturally aligned may either complete execution successfully or raise an exception.
ma_data.S defines no mtvec_handler, so a core that raises an address-misaligned exception lands in other_exception, gets gp |= 1337, and fails.
Uncertain
Verify: that a trapping core really fails
rv32ui-p-ma_datarather than being rescued by some path not read here. Reason: this is an inference from two facts that were checked —grepformtvec_handler,stvec_handler,CAUSE_andmretinrv64ui/ma_data.Sreturns nothing, andenv/p/riscv_test.h’strap_vectorroutes any non-ecallmcausetoother_exception— but the core used here returned wrong data rather than trapping, so the trapping path was never executed. To resolve: make the core raisemcause = 4(load address misaligned) on an unalignedlwand re-run; the expected observation istohost = 0x53b(3 | 1337), i.e. the same “unhandled exception” signature asrv32uc-p-rvc, not atestnum=3failure. uncertain
In other words, rv32ui-p-ma_data asserts an execution environment interface that guarantees misaligned support — a design decision, not a correctness requirement. That is worth knowing before you spend a day “fixing” a core that was never wrong. (The core here was extended to read a 64-bit window {mem[i+1], mem[i]} and shift, which is ~6 lines and makes the test pass; refusing and documenting the refusal would have been equally defensible.)
Final tally, riscv64-linux-gnu-gcc 16.1.1 / Verilator 5.046 / 217-line core:
| Suite | Result | Notes |
|---|---|---|
rv32ui-p-* (42) | 42 pass, 0 fail | 84–1006 cycles per test |
rv32um-p-* (8) | 8 pass, 0 fail | 139–502 cycles |
rv32uc-p-rvc (1) | TRAP tohost=0x53b, TESTNUM=2 | expected: no compressed decoder exists |
rv32mi-p-* (16) | 10 pass, 5 fail, 1 timeout | see below |
Measured results. What it shows: a 217-line core passes the entire RV32I and RV32M user-level suite, which is genuinely encouraging — and immediately fails the machine-level suite, which is genuinely informative. The insight to take: the rv32uc line is what a correct “not implemented” looks like. 0x53b is 2 | 1337: the compressed instruction at test 2 decoded as illegal, trapped, and the environment reported it. That is a better outcome than a hang, and it is the environment’s design doing its job.
The rv32mi failures name exactly the shortcuts the core took. mcsr fails because the flat CSR array returns 0 for misa, mvendorid and friends instead of legal values. illegal times out because nothing distinguishes an illegal instruction from a legal one in the CSR space. shamt fails at test 3 because the decoder ignores instr[25], so slli x1, x1, 32 executes instead of raising an illegal-instruction exception as RV32 requires. None of those would ever have appeared in a hand-written test, because the author of the core is the author of the tests and does not know what he got wrong. That is the MOC’s thesis, reproduced.
riscv-tests Is Not the Architectural Test Suite
These two are routinely conflated, and the conflation matters because one of them is a certification artifact and the other is not.
flowchart TB subgraph UT["riscv-tests — unit tests"] RT["riscv-software-src/riscv-tests<br/>BSD-3, © Regents of UC<br/>active, not archived"] RTE["riscv/riscv-test-env<br/>(the 'env' submodule)"] RT --- RTE RTUSE["hand-written .S per instruction<br/>self-checking, fixed expected values<br/>signals via tohost"] RT --> RTUSE RTFOR["FOR: bring-up, regression,<br/>'does my ALU do sra'"] RTUSE --> RTFOR end subgraph AT["Architectural Certification Tests"] ACT["riscv/riscv-arch-test<br/>RISC-V International"] ACT4["ACT4 Framework<br/>Makefile + Python"] UDB["riscv-unified-db<br/>DUT config: extensions + parameters"] SAIL["riscv/sail-riscv<br/>formal reference model<br/>computes expected results"] ACT --> ACT4 UDB --> ACT4 SAIL --> ACT4 ACT4 --> ELFS["self-checking ELFs<br/>tailored to YOUR configuration"] ACTFOR["FOR: certifying that a design<br/>faithfully implements the spec"] ELFS --> ACTFOR end RISCOF["riscv-software-src/riscof<br/>ARCHIVED (verified 2026-09-04)<br/>'replaces the deprecated riscof tool'"]:::dead RISCOF -.->|"superseded by"| ACT4 NOTE["'These are not verification tests<br/>and additional verification should<br/>be run on all processors.'"]:::quote ACT --- NOTE classDef dead fill:#eee,stroke:#999,stroke-dasharray: 4 4 classDef quote fill:#ffd,stroke:#cc0
The two test-suite families and the deprecated tool between them. What it shows: riscv-tests compares against constants baked into the source; the ACTs compare against results computed by the Sail formal model configured to match your design. The insight to take: that difference is why only one of them can certify. A test with a hard-coded expected value cannot express “correct for a core with 8 PMP entries and no misaligned support”; a test generated against a configured reference model can.
Three claims here were verified against the GitHub API on 2026-09-04, because they are the ones that decay:
riscv-software-src/riscofis archived."archived": true, last push 2026-04-16. Theriscv-arch-testREADME states the replacement directly: the ACTs “are used with the ACT4 Framework, a Makefile and Python based tool that replaces the deprecated riscof tool.” Any tutorial that tells you topip install riscofis describing a dead path.- The canonical repository is
riscv/riscv-arch-test, notriscv-non-isa/riscv-arch-test. Araw.githubusercontent.comfetch of the latter returns HTTP 200 because GitHub follows repository renames; the API resolvesfull_nametoriscv/riscv-arch-test. Active, last push 2026-09-04. riscv-software-src/riscv-testsis not archived and remains actively maintained; its HEAD at the time of reading was 2026-08-15.
The ACT framework’s own scope statement is the sentence to remember: “These are not verification tests and additional verification should be run on all processors.” Passing certification is a floor, not a proof.
There is one piece of good news for anyone who builds the harness above. The ACTs use the same tohost convention — from a reference rvmodel_macros.h in the arch-test repository:
#define RVMODEL_HALT_PASS \
li x1, 1 ;\
la t0, tohost ;\
sw x1, 0(t0) ;\
sw x0, 4(t0) ;\
self_loop_pass: j self_loop_pass
#define RVMODEL_HALT_FAIL \
li x1, 3 ;\
...Pass is 1 and fail is 3, written to a tohost symbol declared exactly as riscv-tests declares it. The store-and-spin idiom is identical. A testbench built for riscv-tests will observe ACT results with no changes — only the value decoding differs (a constant 3 instead of an encoded test number). That is a strong argument for building the tohost watcher properly the first time.
The Honest Limits of Passing rv32ui
Passing all 42 rv32ui-p tests means your core is not wrong in the 42 ways this suite checks. It does not mean much more, and the gaps are worth enumerating because it is easy to declare victory here.
Coverage is per-instruction, not per-interaction. add.S tests add after 0, 1 and 2 nops. It does not test add followed by a taken branch followed by a load whose address depends on the add. Cross-instruction interactions in a pipeline are where hazards actually live, and the suite samples them rather than covering them.
No memory-ordering coverage in rv32ui. fence is a no-op in the tests’ single-hart world. Anything to do with store buffers, write-combining or peripheral side effects is untested — which matters the moment Stage 5 adds a Universal Asynchronous Receiver-Transmitter whose registers must not be reordered or cached.
Almost no privileged coverage in the user-level tests. The reset prologue writes mtvec, mepc, mstatus, pmpcfg0, medeleg and mideleg, but never checks any of them. A core that accepts every CSR write and stores it in a flat array — exactly what the core in this note does — sails through rv32ui and then fails 5 of 16 rv32mi tests. rv32mi is where trap semantics, illegal-instruction detection and misa/mvendorid legality actually get exercised, and it is not on the MOC’s Stage 4 list. It should be on your Stage 6 list.
No performance, no timing, no synthesis. The suite says nothing about Cycles Per Instruction, nothing about whether your critical path closes at any useful clock, and nothing about resource usage. A core that passes every test and runs at 3 MHz has passed every test.
Expected values are constants, not a model. The suite cannot check anything that is legitimately implementation-defined — which is precisely the ma_data situation above, and precisely why certification needed a configurable reference model.
The -p environment hides real bring-up problems. No caches, no MMU, no interrupts (INTERRUPT_HANDLER is defined as j other_exception, i.e. “no interrupts should occur”), no multi-hart. The pt variant adds a timer interrupt every 100 cycles and is the natural follow-on once Core Local Interruptor exists.
A subtle one worth guarding against: CHECK_XLEN for RV32 is li a0, 1; slli a0, a0, 31; bltz a0, 1f; RVTEST_PASS; 1: — if the sign check fails, the test reports pass and exits. That is intentional (an RV64 machine running an RV32 test should not report a failure), but it means a core with a broken slli or a broken bltz can report a spurious pass on every test in the suite while executing almost nothing. If every test passes in suspiciously few cycles — under about 90 — check the cycle counts before celebrating.
Failure Modes and Common Misunderstandings
“tohost is at 0x80001000.” Usually, not always. Two of the 67 binaries built here put it elsewhere. Read the symbol from the ELF.
“tohost = 0x53b means test 669 failed.” No. 0x53b is 2 | 1337: an unhandled exception during test 2. Decode the 1337 marker before the >> 1. Be aware that this encoding is lossy — ori gp, gp, 1337 cannot be inverted, so 2 | 1337 and 3 | 1337 are both 0x53b and any test number whose set bits are already present in 1337 (0x539) is indistinguishable from a smaller one. The marker tells you roughly where the exception happened; a waveform tells you exactly. See Waveform Debugging.
“The test hung, so my core is broken.” Possibly, but check the reset prologue first. RISCV_MULTICORE_DISABLE reads mhartid and spins on a nonzero value — a core that returns garbage for mhartid hangs before test 2 and looks like a deep bug. Likewise, if mtvec is not writable, the first unsupported csrw jumps to an undefined address.
“I need riscv32-unknown-elf-gcc to build the suite.” No. Everything in this note was built with a distribution riscv64-linux-gnu-gcc; the isa/Makefile compile line has no toolchain-specific dependency beyond -march/-mabi. See The RISC-V Cross-Compilation Toolchain.
“My core is RV32IMC so I should build the tests with -march=rv32imc.” Build them the way the Makefile does, with -march=rv32g. rv32imc does not imply Zicsr under GCC’s default -misa-spec=20191213, so the reset prologue’s csrw mtvec, t0 will not assemble.
“riscv-tests is the compliance suite.” It is not, and the tool most tutorials name for the real one (RISCOF) is archived. Use riscv/riscv-arch-test with the ACT4 framework if you need certification; use riscv-tests for bring-up and regression, which is what it is good at.
“Passing rv32ui means the core is correct.” It means the core is not wrong in 42 specific ways. rv32mi, a pipeline stress test, a real workload, and eventually the ACTs are all still ahead.
“A test that passes fast is fine.” Not necessarily — see the CHECK_XLEN early-exit above. Record cycle counts and watch for outliers; a suspiciously cheap pass is a signal, not a win.
See Also
- definitely-not-esp32 MOC — Stage 4, “Prove It Is Actually a RISC-V”
- The RISC-V Cross-Compilation Toolchain — Stage 0; how the 67 binaries above were built
- Verilator — the simulator the harness compiles to
- Testbenches and RTL Verification — the general shape of driving a design and checking it
- The Single-Cycle Processor — the kind of core that should be run against this suite first
- Classic Five-Stage Pipeline · Operand Forwarding · Load-Use Hazard — what the
*_BYPASStest cases are probing - The Register File — why
TEST_RR_ZERODESTexists - Control and Status Registers · RISC-V Trap Handling — the machinery the reset prologue and
trap_vectordepend on - Physical Memory Protection —
INIT_PMPin the prologue, andrv32mi-p-pmpaddr - Sv32 Virtual Memory · Memory Management Unit — what the
-vvariants require - RV32IMC — the target ISA, and why
rv32uccomes last - Cycles Per Instruction — the number this suite deliberately says nothing about
- Waveform Debugging — what to do when a test fails and the test number is not enough
- Computer Architecture MOC — the concept companion