The riscv-tests Suite

riscv-tests is 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 called tohost1 for 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 at riscv-software-src/riscv-tests (BSD-3-clause, Copyright The Regents of the University of California); this note reads commit 2ebecad997fa58cd9e5724340ba75aa4b59bd1d0 (2026-08-15) with its env submodule at 6de71edb142be36319e380ce782c3d1830c65d68 (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-p tests on the first attemptlui, sra, srai and ma_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-tests is not the RISC-V compliance suite. The suite that certifies conformance is the separate RISC-V Architectural Certification Tests at riscv/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&lt;&lt;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:

FieldValueMeaning
XLENrv32 / rv64register width the test assumes
privilegeuuser-level: no SYSTEM-opcode instructions in the test body
ssupervisor-level state and instructions
mmachine-level state and instructions
extensionibase integer only — no floating point, no F registers touched
mmultiply/divide
ccompressed
aatomics
f / dsingle- / double-precision floating point
zb*, zfh, zicond, …the corresponding Z-extension
environmentpphysical: virtual memory disabled, only hart 0 boots
pmphysical, all cores boot
ptphysical, timer interrupt every 100 cycles
vvirtual: 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 filesWhat it covers
rv32ui42the whole of RV32I: arithmetic, shifts, branches, jumps, all load/store widths, fence_i, plus simple, ld_st, st_ld, ma_data
rv32um8mul mulh mulhsu mulhu div divu rem remu
rv32uc1rvc — a single test exercising the compressed encodings
rv32ua10A extension (lr/sc, AMOs)
rv32uf / rv32ud11 / 11F and D
rv32mi16machine-level: CSRs, illegal instructions, misaligned fetch and data, breakpoints, scall, PMP
rv32si6supervisor-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_PASS

Falling off the end of the last test reaches bne x0, TESTNUM, passTESTNUM 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. 1 is odd, and 1 >> 1 == 0, so the exit code is 0.
  • Fail computes (TESTNUM << 1) | 1 — also odd, and >> 1 recovers the failing test number. The beqz TESTNUM, 1b in front is a deliberate infinite loop if TESTNUM is still zero, because 0 << 1 | 1 == 1 would 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 Linux exit syscall number) and issue ecall. In the -p environment the ecall is caught by trap_vector, which writes gp — not a0 — to tohost; the a7/a0 setup is what the -v environment needs. In -p it is vestigial, and knowing that stops you looking for an a7-decoding step you do not have to implement.
  • Unhandled exception takes the third path: ori gp, gp, 1337. 1337 is 0x539, odd, so it terminates the run, and 0x539 is a recognisable marker. A tohost of 0x53b means “an exception you did not handle occurred during test 2” — 0x53b = 2 | 0x539not “test 669 failed”. A naive testbench that prints tohost >> 1 will 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 -v column of the table below. Reason: read from source, not run. No -v binary 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, the scall path through vm.c and the 0x0101000000000000 | c console encoding come from reading env/v/riscv_test.h, env/v/entry.S and env/v/vm.c at submodule commit 6de71edb, plus fesvr/device.h’s field accessors. To resolve: build one rv32ui-v-add and run it under Spike, or against a core that has reached Stage 7. The -p column, by contrast, was measured. uncertain

-p (physical)-v (virtual)
Environment headerenv/p/riscv_test.henv/v/riscv_test.h + entry.S + vm.c + string.c
Privilege the body runs inM-mode (entered via mret from the reset vector)U-mode
Address translationnone; satp written to 0 during initSv32 on RV32 (#define SATP_MODE_CHOICE SATP_MODE_SV32), Sv39 on RV64
Needs sfence.vmanoyes — vm.c uses it on every page-table change
Pass/fail pathecalltrap_vectorsw gp, tohostscall from U-mode → M-mode handler in vm.cterminate()do_tohost()
Console outputnonecputchar() writes 0x0101000000000000 | c to tohost
Extra machinery it exercisesalmost nonepage tables, TLB behaviour, page faults, trap delegation, HTIF handshaking with fromhost
Build costone .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=0

67 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/srli

One 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_data rather than being rescued by some path not read here. Reason: this is an inference from two facts that were checked — grep for mtvec_handler, stvec_handler, CAUSE_ and mret in rv64ui/ma_data.S returns nothing, and env/p/riscv_test.h’s trap_vector routes any non-ecall mcause to other_exception — but the core used here returned wrong data rather than trapping, so the trapping path was never executed. To resolve: make the core raise mcause = 4 (load address misaligned) on an unaligned lw and re-run; the expected observation is tohost = 0x53b (3 | 1337), i.e. the same “unhandled exception” signature as rv32uc-p-rvc, not a testnum=3 failure. 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:

SuiteResultNotes
rv32ui-p-* (42)42 pass, 0 fail84–1006 cycles per test
rv32um-p-* (8)8 pass, 0 fail139–502 cycles
rv32uc-p-rvc (1)TRAP tohost=0x53b, TESTNUM=2expected: no compressed decoder exists
rv32mi-p-* (16)10 pass, 5 fail, 1 timeoutsee 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/riscof is archived. "archived": true, last push 2026-04-16. The riscv-arch-test README 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 to pip install riscof is describing a dead path.
  • The canonical repository is riscv/riscv-arch-test, not riscv-non-isa/riscv-arch-test. A raw.githubusercontent.com fetch of the latter returns HTTP 200 because GitHub follows repository renames; the API resolves full_name to riscv/riscv-arch-test. Active, last push 2026-09-04.
  • riscv-software-src/riscv-tests is 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 lossyori 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