Loading…
A RISC-V CPU Is More Than RTL: Verifying and Evaluating a Processor

A RISC-V CPU Is More Than RTL: Verifying and Evaluating a Processor

hardware riscv computer-architecture rtl RISC-V RV32 Toolchain Bare-Metal Compiler Linker ELF ABI Verification ACT4 Spike riscv-dv Formal Verification Memory Ordering CoreMark Dhrystone Benchmarking QEMU Verilator

A processor becomes useful when a program can travel from readable C to instructions in memory, run from a known reset state, produce a checked result, and expose enough execution data to guide the next change. The RTL is one part of that path.

This post looks at a RISC-V CPU from the software and verification perspective: what software needs from the hardware, how to check that the processor executes it correctly, and how to interpret the resulting performance measurements. The three questions are simple: can a program run, what evidence supports its correctness, and what do the measured numbers actually mean?

Getting the first program to run on a new core is usually called bring-up. Most of bring-up is not RTL work. It is a set of agreements: where memory lives, where execution starts, which instructions the compiler is allowed to emit, which registers a function may destroy, how the program reaches a timer or a console, and how anyone decides that the printed answer is correct. Those agreements are what I mean by a contract throughout this post. They are not documents. They are values that have to match in the RTL, the linker script, the startup assembly, and the compiled C at the same time.

I followed that path on the dual-issue RV32I core used as the common target in this series. The core was already built. What I built and measured here was the software around it: compiler target, ABI, linker script, startup, memory images, checked execution, and benchmarks. QEMU appears as a functional reference, not as a cycle model.

I am writing this for someone who can read RTL and understands how a RISC-V pipeline works, but who may not have built the software side from scratch. The hardware terms are therefore not re-explained from first principles; the software terms are introduced where the hardware first needs them. You do not need to have read the earlier posts. Designing the pipeline itself, FPGA implementation, and physical timing closure are outside this post’s scope.

The runs were bare-metal, which means there is no operating system underneath the program. Nothing loads it, nothing sets up its stack, nothing provides printf, and nothing catches it when it returns from main. The program talks directly to the core’s instruction memory, data memory, timer, console, and trap logic. An RTOS can sit on top of exactly the same interface later, but it is not the subject here.

Two words appear often below. A workload is just a program written to exercise the core, with a result that can be checked. A reference model is a second implementation of the same instruction set, used to say what the answer should have been. Here the reference models are QEMU for the bring-up workload, Spike for instruction-suite exit-code comparison, and Sail for ACT4 signature references. They provide independent architectural behavior, not the timing of this pipeline. Agreement with them is useful evidence, but the adapters and the quantities being compared still determine what each check establishes.

The order below is the order I needed to make the first C program run cleanly. It starts at the hardware boundary and moves toward measurement:

software-visible platform
  -> tools that produce and inspect the program
  -> compiler target and ABI
  -> linker layout and reset startup
  -> ELF to instruction/data memory images
  -> one checked C workload on QEMU and RTL
  -> counters, verification, and failure isolation
  -> memory ordering
  -> compiler and ISA sweeps
  -> CoreMark and Dhrystone
  -> commands and failure notes

The dependency chain through those steps is strict. The compiler does not choose final addresses. The linker does not invent a stack. Startup cannot initialize sections that the linker did not define. The testbench cannot load a file format it does not understand. Many early bring-up failures are one of those agreements being wrong, not the pipeline being wrong.

Three terms used throughout, in the sense they are used here. A testbench is the Verilog wrapper around the core that is not part of the hardware: it generates clock and reset, provides memories, and observes signals to decide when a run is over. A trap is a hardware-initiated transfer to a handler, covering both exceptions raised by an instruction and interrupts raised by a device. An ELF file is the standard container the linker produces, holding the program’s bytes along with tables saying where each piece belongs in memory.

Reading guide. This is a long-form walkthrough with reference material included, not a checklist to memorize.

  • For the main story, follow the platform contract, complete measured run, verification results, and conclusion.
  • For a first software port, read the toolchain, ABI, linker, and startup sections in order. The full listings are there to revisit while implementing each step.
  • For correctness, start with what was actually checked, then read the relevant suite’s results and limitations. Configuration is not the same thing as completed verification.
  • For performance, read the counter definitions before the compiler sweeps and benchmarks. The command listings and raw benchmark output can wait until reproduction.

The core and the platform contract

The target is a dual-issue RV32I core with branch prediction, hazard handling, load-use optimizations, a machine timer, and a small memory-mapped console. Dual-issue means it can fetch, issue, and retire two instructions per cycle, which matters later when the cycle counts come out lower than the instruction counts.

The software-visible platform is everything the program can observe: the core’s top level, the address decoder, the two memories, the device registers, and the control-register and trap logic. The pipeline internals are not visible to software except through how many cycles they take.

Reset, ISA, and execution environment

A C program does not begin at main in hardware. When reset is released, the core starts fetching from a fixed address, called the reset vector or reset PC. On this platform that address is 0x00000000. Whatever word sits there is executed as an instruction. Registers hold whatever the hardware resets them to, which for most of the register file is nothing useful. There is no stack yet, no initialized global variables, and no runtime. The first code at that address has to create the environment that compiled C assumes already exists.

That first code is the startup file, and its first label is conventionally called _start. Everything in this post follows from that: the linker has to place _start at address zero, the memory image has to contain it there, and the testbench has to load that image before releasing reset.

The table below is the environment I targeted. The third column is the part that matters when writing software.

ItemValue on this platformWhat it changes for software
Reset PC0x00000000The first instruction of _start must be linked at address 0
Base ISARV32I32-bit registers, integer and load/store instructions only, unless an extension is enabled
PrivilegeMachine mode onlyNo operating system layer, no page tables, no user/kernel split
Timermtime counter and mtimecmp compare register, both memory-mappedTime is read with an ordinary load instruction, not a special instruction
Compressed instructionsNot implementedEvery instruction is 4 bytes; the compiler must not emit 2-byte encodings
Atomic instructionsNot implementedNo hardware read-modify-write; locks cannot be built from AMO or LR/SC
Floating pointNot implementedFloating-point C is emulated in software routines and is slow
Byte orderLittle-endianThe least significant byte of a word sits at the lowest address
HartsOneNo multicore ordering or cross-hart interrupts to reason about
CachesNoneEvery load and store reaches memory directly; no cache maintenance
CompletionStore to a device register that ends the runThe program has to say it finished; the testbench does not infer it

A hart is RISC-V’s word for one hardware thread of execution: one register file and one instruction stream. This platform has one, so nothing here needs to coordinate between cores.

Machine mode is the highest of the RISC-V privilege levels. A full application-processor implementation has three, machine, supervisor, and user, with supervisor running an operating system kernel and user running applications. This core implements only machine mode, so there is one privilege level, no memory protection unit, and no virtual memory. Every address the program uses is a physical address.

What the ISA name actually says

RISC-V is modular. RV32I is the base: 32-bit registers and the base integer instruction set, which includes arithmetic, logic, shifts, loads, stores, branches, and jumps. Everything else is an extension that a particular implementation may or may not include:

  • M adds integer multiply and divide: mul, mulh, mulhu, mulhsu, div, divu, rem, remu.
  • A adds atomics. That means lr.w and sc.w, a load-reserved and store-conditional pair used to build compare-and-swap, and the amo* instructions such as amoswap.w and amoadd.w that read, modify, and write memory in one indivisible operation. This core does not implement them, so there is no hardware primitive for a lock-free update. On a single hart with interrupts disabled, ordinary loads and stores are enough for shared state, which is the case used later in this post.
  • C adds compressed 16-bit encodings for common instructions, typically shrinking code by a quarter to a third. It also means instructions can start on 2-byte boundaries. This core fetches pc and pc + 4, so a compressed instruction would be misread. That is why every build here is compiled without c.
  • F, D, and Q add single, double, and quad precision floating point, along with a separate floating-point register file. None are present, so float and double arithmetic in C would be compiled into calls to software routines that bit-manipulate integer registers.
  • Zicsr is the small extension that defines the CSR access instructions: csrrw, csrrs, csrrc and their immediate forms.
  • Zifencei defines fence.i, the instruction that synchronizes the instruction stream after code has been written to memory.

A CSR is a control and status register: a separate address space of configuration registers, not part of the x0-x31 register file, reached only through those CSR instructions. Machine-mode CSRs start with m. The machine CSRs used in the bring-up and trap examples include:

CSRPurpose
mtvecTrap vector base address. Where the core jumps when a trap is taken
mepcThe PC to return to. Hardware writes the faulting or interrupted PC here
mcauseWhy the trap happened, encoded as a number
mstatusGlobal state, including the machine interrupt enable bit MIE
miePer-source interrupt enables, including the machine timer bit MTIE
mscratchA scratch register for the trap handler, often used to stash a pointer

mcause uses the top bit to distinguish interrupts from synchronous exceptions. Bit 31 set means interrupt. So 0x80000007 is interrupt number 7, the machine timer, while the plain value 11 is the synchronous environment call from machine mode, which is what ecall raises.

What -march and -mabi mean

Every compile command below carries two flags that decide what the compiler is allowed to produce:

-march=rv32i_zicsr_zifencei -mabi=ilp32

-march names the target instruction set. Read it left to right: rv32 means 32-bit registers, i means the base integer set, and each _z... suffix adds a named extension, here zicsr and zifencei. Underscores separate multi-letter extension names. With this string, GCC will not emit a multiply instruction, a compressed instruction, or a floating-point instruction. If the C code needs one of those operations, GCC calls a library routine instead. That is a compile-time promise about the instruction stream, and it is the single most common thing to get wrong when the RTL implements less than the toolchain assumes.

-mabi names the application binary interface, which is the calling convention and data layout: how wide int, long, and pointers are, which registers carry arguments, which registers a function must preserve, and how the stack is aligned. ilp32 literally means int, long, and pointer are 32 bits. The related names ilp32f and ilp32d keep the same integer sizes but pass floating-point arguments in floating-point registers, which requires F or D hardware. On a 64-bit target the equivalent names are lp64, lp64f, and lp64d.

The two flags are independent, and that independence is where mistakes hide. -march=rv32im -mabi=ilp32 is a normal combination: multiply instructions, integer-only calling convention. -mabi=ilp32d on this core would produce code that moves arguments through floating-point registers that do not exist. The toolchain prefix does not decide either of these; a riscv64-unknown-elf-gcc builds 32-bit code happily when -march and -mabi say so.

Why the M extension was not trusted here

Adding m to -march is a claim about the hardware. I checked that claim with a small directed program instead of assuming it.

The M extension’s multiply instructions come in low and high forms. mul returns the low 32 bits of the product. mulh, mulhu, and mulhsu return the high 32 bits of the full 64-bit product, differing in whether the operands are treated as signed-signed, unsigned-unsigned, or signed-unsigned. Compilers reach for the high forms for 64-bit arithmetic, division by constants, and some hashing and checksum code, so a core that only implements mul will pass simple tests and then fail inside a library routine.

The probe returned this:

OperationFull 64-bit productExpected high wordRTL high word
mulhu of 0xffffffff * 0xffffffff0xfffffffe000000010xfffffffe0x00000000
mulh of -1 * -10x00000000000000010x000000000x00000000
mulhsu of -1 * 0xffffffff0xffffffff000000010xffffffff0x00000000

The middle row is worth reading carefully: its expected result really is zero, so that row alone proves nothing. The first and third rows are the ones that discriminate, and both returned zero from the RTL while QEMU produced the correct high word. Low mul, rem, and remu passed the later upstream tests, but div and divu failed there. The successful small programs must not be read as validation of the complete extension.

So the situation is not “M is broken” and not “M works”. It is a specific subset gap. I kept the CoreMark and Dhrystone builds at rv32i_zicsr_zifencei. The main workload and its optimization sweep used rv32im_zicsr_zifencei with the emitted operations checked for that workload; the separate ISA sweep also measured an RV32I build. I kept the failing probe as a regression test. The main bring-up workload does use low mul, and it passed on RTL.

Address map

An address map is the answer to a simple question: when the core puts an address on the bus, which piece of hardware responds? The core itself does not know. It issues a fetch address or a load/store address, and a decoder looks at the upper address bits and routes the access to instruction memory, data memory, or a device. Software has to be written against that routing, because an address is only meaningful in terms of what answers it.

This platform uses split instruction and data memories, which is the Harvard-style arrangement familiar from the RTL: a fetch port and a data port, each with its own array. Both are 64 KiB for the bring-up workload and benchmarks. The separate architectural-test adapter increases instruction memory to 256 KiB for the larger tests while retaining 64 KiB of data memory; that is a testbench configuration difference, not a different software address base.

RegionAddressSizeWhat lives there
Instruction memory0x0000000064 KiB baselineCode. Fetch slot 0 reads pc, slot 1 reads pc + 4
Data memory0x8000000064 KiBConstants, globals, zero-initialized data, heap area, stack
Device page0x100000004 KiB decode windowTimer, console, exit register, trap counter

Three separate things have to agree with this map: the linker script, which assigns addresses to code and data; the startup code, which uses the symbols the linker produced; and the testbench, which loads the memory images into the arrays the RTL actually reads. When they disagree, the build still succeeds. The link step has no idea what the testbench will load. The failure shows up later as a core fetching zeros, branching into empty memory, or reading a variable that was never initialized.

One RTL detail is worth knowing so it does not cause confusion when reading waveforms. data_mem is parameterized with BASE_ADDR = 0 in rv32i_top, and its index is formed by truncating addr - BASE_ADDR. The upper bits are therefore discarded, so the same data array also answers at low addresses. That aliasing is an implementation artifact. The software here still places writable data at 0x80000000, matching the linker scripts and the addresses the testbench checks.

MMIO registers

MMIO stands for memory-mapped input/output. It means the devices are given addresses in the same address space as memory, and the program talks to them with ordinary load and store instructions. There is no special I/O instruction. A store to 0x10000010 is a sw like any other; the decoder recognizes the address and hands the data to the console instead of to RAM. Reading 0x10000008 is an lw that returns the current timer value rather than a stored word.

This is why the C side can be so small. A device register is expressed as a pointer to a fixed address, and the pointer is declared volatile so the compiler treats every read and write as something that must actually happen. Without volatile, the compiler is entitled to assume that a store nobody reads is dead code and delete it, which would silently remove the store that ends the simulation.

All registers here are 32-bit, and the decode is addr[31:12] == 0x10000, meaning the whole 4 KiB page starting at 0x10000000 belongs to the device block.

AddressNameAccessBehavior
0x10000000mtimecmp lowR/WLow 32 bits of the timer compare value
0x10000004mtimecmp highR/WHigh 32 bits of the timer compare value
0x10000008mtime lowRLow 32 bits of the free-running timer
0x1000000Cmtime highRHigh 32 bits of the free-running timer
0x10000010putchar / UART RXW / RWrite transmits one byte; read returns received data, or 0xffffffff when empty
0x10000014exitW / RWrite requests completion; the testbench captures its data as the exit code. Read returns the completion flag, not that code
0x10000018trap countRHow many traps the core has taken
0x1000001CUART statusRReceive FIFO status

mtime is the standard RISC-V name for the free-running time counter, and mtimecmp is the value it is compared against. Here mtime increments every clock, so it counts core cycles, and it resets to zero. mtimecmp resets to all ones, which is the largest possible value, so no timer interrupt fires until software deliberately programs one. The interrupt condition is level-based rather than edge-based:

irq_mtip = (mtime >= mtimecmp)

Level-based means the request stays asserted for as long as the condition holds. A handler that returns without moving mtimecmp forward will immediately take the interrupt again. That is normal RISC-V timer behavior and it is the reason a timer handler always rearms the comparator.

The console is called a UART, universal asynchronous receiver-transmitter, which is the standard name for a simple serial port. In simulation it is much simpler than the real thing: a written byte is printed by the testbench, and there is no baud rate or framing to configure. The receive side has a small FIFO, a first-in-first-out buffer, holding bytes that arrived, and the status register says whether it is empty. The exit register exists only because a simulation needs a defined way to stop; real hardware would just keep running.

One dual-issue detail becomes visible to software here. The MMIO block handles writes from both slots and suppresses RAM writes on MMIO hits. For simultaneous updates to the same timer-compare word, slot 1 wins; writes to different words can both take effect. The console explicitly emits slot 0 then slot 1 when both write characters. These device-specific rules matter: there is no single rule that every pair of MMIO writes collapses to one.

Alignment and access width

RISC-V load and store instructions carry a width: lb/sb move one byte, lh/sh move a two-byte halfword, lw/sw move a four-byte word. The unsigned load forms lbu and lhu zero-extend instead of sign-extending, which matters when reading bytes into an integer.

An access is naturally aligned when its address is a multiple of its size: a halfword at an even address, a word at an address divisible by four. Aligned accesses are cheap in hardware because the value fits inside one memory word. A misaligned access may straddle two memory words. Supporting it can require extra logic or cycles. Depending on the execution environment, it may be handled in hardware, handled through a trap, or reported as an access/alignment exception; software cannot assume this core supports it.

The intended access-width contract is below. It is not a claim that every case passed: the later halfword-store failure and the open ACT4 store failures qualify the implementation evidence.

InstructionAlignment behavior in this RTL
SB, LB, LBUAny byte offset. A byte always fits inside one word
SH, LH, LHUHalfword-aligned only. A halfword that crosses a word boundary is not supported
SW, LWWord access. The software layout keeps these naturally aligned

In practice the C compiler and the linker keep this safe without attention: a uint32_t global lands on a 4-byte boundary, a struct’s members are padded to their natural alignment, and the stack stays 16-byte aligned. The cases that break the assumption are explicit ones, such as a __attribute__((packed)) struct, or casting a char* into a uint16_t* at an odd offset. Byte accesses avoid alignment requirements, which is why the startup copy loop later in this post copies one byte at a time. That avoids one class of error; it does not establish that every byte-store sequence is correct.

Traps and completion

A trap is a controlled transfer of control to a handler, taken by the hardware rather than by a branch instruction. RISC-V splits traps into two kinds. A synchronous trap, usually called an exception, is caused by the instruction being executed: an illegal instruction, a misaligned access, or ecall, which is an explicit request for service. An asynchronous trap, an interrupt, is caused by something outside the instruction stream, such as the timer reaching its compare value.

The hardware sequence is the same in both cases. The core writes the relevant PC into mepc, writes the reason into mcause, and jumps to the address held in mtvec. Software at that address, the trap handler, saves whatever registers it intends to use, services the cause, restores those registers, and executes mret, which returns to mepc. For an exception, mepc points at the faulting instruction; for an interrupt, it points at the instruction that would have executed next. That difference decides whether a handler advances mepc before returning.

For an interrupt to be taken at all, two enable bits must be set: the per-source bit in mie, which is MTIE for the machine timer, and the global machine interrupt enable MIE in mstatus. A timer that is running with mtimecmp programmed but with either enable clear simply never interrupts, which is a common first-bring-up symptom.

This core also has a project-specific CSR at address 0x7C0, named TRAPCTRL in defines.v. It exists for backward compatibility with older tests:

TRAPCTRL bit 0ecall behavior
0Legacy behavior: assert a halt output, which older bare-metal tests used to end a run
1Standard behavior: raise a machine environment call trap, cause 11, and vector to mtvec

When I wanted ecall to behave as a real trap, I set bit 0. With that set, ecall reports cause 11 and the timer reports cause 0x80000007, as described earlier.

Completion is a separate mechanism from traps. The bring-up testbench used for the main workload decides pass or fail from three things:

  1. The program wrote the MMIO exit register, so it finished deliberately rather than hitting the cycle limit.
  2. The value written was zero, the conventional success code.
  3. The 32-bit result word at 0x80000000 matched the expected value passed to the testbench.

The third check is the important one. A program can print PASS and still be wrong if the printing path works and the computation does not, so the testbench reads the result out of memory rather than parsing text. The longer-running testbench used for benchmarks stops on MMIO exit or legacy halt and reports cycles, traps, final PC, and CSR state, but does not check an application result word; the benchmarks validate their own results internally.

Toolchain

The host machine is an x86-64 Linux box and the target is a 32-bit RISC-V core that does not exist as silicon. Nothing on the host can run the target’s instructions natively, so every build here is a cross-build: a compiler running on x86 that emits RISC-V machine code. That is what the long tool prefix means. riscv64-unknown-elf-gcc reads as architecture riscv64, vendor unknown, target environment elf, meaning bare-metal with no operating system ABI. The 64 in the prefix is the toolchain’s default, not a restriction; the same binary produces RV32 code when -march and -mabi ask for it.

This is also a freestanding build, which is a specific term in C. A hosted program may assume the full standard library and an operating system: printf, malloc, file I/O, a loader that sets up the stack, and a runtime that calls main. A freestanding program may assume almost none of that. It gets the language, the compiler’s built-in types, and a handful of headers such as stdint.h that only define types. Everything else, including startup and any output routine, has to be supplied. That is why the source files below are small and explicit rather than relying on a library.

The one library that is still linked is libgcc. It is not a C library; it is the compiler’s own support library, containing routines GCC emits calls to when the target lacks an instruction for something the C code asked for. Counting leading zeros on a core without Zbb, the bit-manipulation extension, becomes a call to __clzsi2. A 32-bit multiply on a core built without M becomes __mulsi3. The naming is systematic: the operation, then si for a 32-bit single-word integer or di for a 64-bit double-word integer, then the operand count. Seeing those symbols in a disassembly is normal, not a mistake, and it is a direct consequence of the -march choice.

I ran the builds on Debian 13 on x86-64 and installed the tools from Debian packages:

sudo apt-get update
sudo apt-get install -y --no-install-recommends \
  gcc-riscv64-unknown-elf \
  binutils-riscv64-unknown-elf \
  qemu-user \
  qemu-system-misc \
  iverilog \
  verilator \
  gdb-multiarch \
  device-tree-compiler \
  srecord

Each package in that list has one job in the flow:

ToolWhat it does here
gcc-riscv64-unknown-elfThe cross-compiler. Turns C and assembly into RISC-V object files, and drives the assembler and linker
binutils-riscv64-unknown-elfThe binary utilities: as assembles, ld links, and objdump, readelf, nm, size, objcopy inspect or convert the result
qemu-userRuns a RISC-V Linux user-space binary by translating its instructions on the host. Used as the functional reference
qemu-system-miscFull-system RISC-V emulation. Installed for completeness; the runs here use user mode
iverilogIcarus Verilog, an event-driven Verilog simulator. Used for the small directed instruction tests
verilatorCompiles Verilog into a cycle-accurate C++ model. Used for the long runs, where simulation speed matters
gdb-multiarchA debugger that understands RISC-V. Used to inspect ELF files and disassemble
device-tree-compilerCompiles device trees, the usual way to describe a RISC-V system’s memory and devices to software. Not needed by these bare-metal images, installed as part of the standard set
srecordConverts between memory image formats. A fallback option for producing hex files; the runs here use the Python loader

The two simulators are worth distinguishing, because both are “running the RTL” but they behave differently. Icarus executes the supported Verilog constructs in an event-driven simulation, and is slow enough that a hundred million cycles is impractical. Verilator compiles the synthesizable subset of the RTL into C++ and then into a native executable, which is far faster, and is what made the CoreMark and Dhrystone runs feasible. Neither is an instruction-set simulator: both execute the actual RTL, so both can expose a bug that QEMU will never show.

The build path was:

C source
  -> GCC with -march/-mabi
  -> object files
  -> linker script + startup + libgcc
  -> ELF executable
  -> elf2hex
  -> instruction/data hex images
  -> Verilator or Icarus testbench

Reading that diagram as a chain of files: the compiler produces .o object files, which contain machine code with unresolved addresses. The linker combines them, assigns final addresses according to the linker script, resolves the references, and writes an ELF executable. ELF, the Executable and Linkable Format, is the standard container for that output; it holds the code and data bytes plus tables describing where each piece belongs in memory. The RTL testbench cannot read ELF, so a converter turns the loadable contents into plain hex text files that Verilog’s $readmemh can load into a memory array.

The first target was not a benchmark score. It was a small program with a known answer, a known exit path, and enough inspection output to tell whether a failure came from code generation, linking, image loading, or the RTL itself.

Tool versions matter more than usual here, because code generation, relaxation decisions, and library helper choices all change between compiler releases. These are the versions the numbers below came from:

PRETTY_NAME="Debian GNU/Linux 13 (trixie)"
VERSION_ID="13"
VERSION_CODENAME=trixie
x86_64
riscv64-unknown-elf-gcc (14.2.0+19) 14.2.0
GNU ld (2.44-3+7+b1) 2.44
qemu-riscv32 version 10.0.13 (Debian 1:10.0.13+ds-0+deb13u1)
Verilator 5.032 2025-01-01 rev (Debian 5.032-1+b2)
Icarus Verilog version 12.0 (stable) ()
Python 3.13.16
GNU gdb (Debian 16.3-1) 16.3

I kept the source files for these runs next to the core’s existing software and testbench directories:

software/bringup/
software/coremark/
software/dhrystone/
software/run_post_benchmarks.sh
tb/tb_bringup.v
tb/tb_bringup.f

The paths are relative to the core directory. The software/ files are the programs and ports used for the measurements; the tb/ files are the simulation harnesses that load the memory images and report completion.

The .f file next to the testbench is a file list: a text file naming the RTL sources to compile, so the simulator command line stays short. tb_bringup.v is the testbench itself, the non-synthesizable wrapper that generates clock and reset, instantiates the core, loads the memory images, and decides when the run is over.

I invoked the compiler directly for the benchmark builds. The installed Debian prefix was riscv64-unknown-elf-gcc, while some older Makefile targets assumed riscv32-unknown-elf-gcc, which is the same toolchain under the name other distributions use.

The ABI as a software/RTL contract

The ISA says which instructions exist. The ABI says how compiled code uses registers and memory when functions call each other. Assembly startup, C code, trap handlers, and any handwritten assembly all have to agree on it. If they do not, a program can assemble and link but still corrupt the stack, lose a return address, or pass arguments in the wrong place. For this target the ABI is ILP32: integers, longs, and pointers are 32 bits, XLEN is 32, and the stack is aligned to 16 bytes at call boundaries.

From the RTL side, the register file is just x0 through x31. The ABI is what gives those numbers software meaning: x2 becomes the stack pointer, x1 becomes the return address, x10 becomes the first argument, and x8 becomes a register that a called function must preserve. If startup leaves sp random, compiled C has no valid place to spill. If a trap handler clobbers a callee-saved register and does not restore it, C code can fail after mret even though the trap itself looked correct.

Register roles

The ABI gives each register a second name that describes its job. The hardware does not enforce any of it; x18 is not protected from being overwritten. The names are a convention that the compiler, the assembly startup, and any handwritten handler all follow.

RegisterABI nameRole
x0zeroHardwired zero
x1raReturn address
x2spStack pointer, callee-saved
x3gpGlobal pointer, initialized by startup if used
x4tpThread pointer, unused in these bare-metal runs
x5-x7, x28-x31t0-t6Temporaries, caller-saved
x8-x9, x18-x27s0-s11Saved registers, callee-saved
x10-x17a0-a7Arguments and return values

The rules that follow from ILP32:

  • The first eight integer arguments are passed in a0 through a7. A ninth argument has nowhere to go in registers, so it is written to the caller’s stack.
  • An integer return value comes back in a0. A 64-bit value, such as uint64_t on this 32-bit target, comes back in the register pair a0 and a1.
  • s0 through s11 and sp must hold the same values after a call as before it.
  • sp is aligned to 16 bytes at every call boundary, even if the function only needs 8 bytes of local storage.
  • A struct larger than two words is not returned in registers at all. The caller allocates space and passes its address in a0 as a hidden first argument, shifting the declared arguments along by one register.
  • Operations without a matching instruction become calls into libgcc, which is why __clzsi2, __ctzsi2, __popcountsi2, and __mulsi3 appear in these binaries.

The terms caller-saved and callee-saved describe who is responsible for preserving a register across a call. Caller-saved registers, the t* temporaries and the a* argument registers, may be destroyed by the called function; if the caller still needs a value in one, the caller must save it before the call. Callee-saved registers, the s* registers, must be restored by the called function before it returns, so the caller can keep values there across calls for free. This is why the disassembly below shows a function pushing s0 to the stack and popping it again: it wanted a register that would survive a call it was about to make.

The stack itself grows downward, from high addresses to low. A function allocates a frame by subtracting from sp and releases it by adding back. The 16-byte alignment rule is why frames come in multiples of 16 even when only two words are stored.

Rules like these are easy to state and easy to misread, so I compiled a small probe and looked at what GCC actually emitted. It has three cases: a function with ten integer arguments to force stack passing, a call that needs a value preserved across it, and a struct return large enough to trigger the hidden pointer.

A measured ABI probe

I compiled the probe with:

riscv64-unknown-elf-gcc \
  -march=rv32im_zicsr_zifencei -mabi=ilp32 \
  -mcmodel=medlow -mno-relax -O2 -ffreestanding -fno-builtin \
  -c software/bringup/abi_probe.c -o abi_probe.o
riscv64-unknown-elf-objdump -d abi_probe.o

Of these flags, -c means compile to an object file and stop, without linking, which is enough to inspect code generation. -O2 is the optimization level. -ffreestanding and -fno-builtin are the bare-metal flags explained with the build script below. -mcmodel=medlow selects the code model, meaning how the compiler forms addresses; medlow uses absolute addressing suitable for a program linked in the low or high 2 GiB of the address space, which covers both 0x00000000 and 0x80000000. -mno-relax disables linker relaxation, discussed at the end of this section. objdump -d disassembles the code sections of the result.

Source:

#include <stdint.h>

struct triple { uint32_t a, b, c; };

__attribute__((noinline))
uint32_t combine10(uint32_t x0, uint32_t x1, uint32_t x2, uint32_t x3,
                   uint32_t x4, uint32_t x5, uint32_t x6, uint32_t x7,
                   uint32_t x8, uint32_t x9)
{
    return x0 + x1 + x2 + x3 + x4 + x5 + x6 + x7 + x8 + x9;
}

__attribute__((noinline))
uint32_t abi_call(uint32_t x)
{
    uint32_t y = combine10(x, x + 1, x + 2, x + 3, x + 4,
                           x + 5, x + 6, x + 7, x + 8, x + 9);
    return y + x;
}

__attribute__((noinline))
struct triple make3(uint32_t x)
{
    struct triple t = { x, x + 1, x + 2 };
    return t;
}

__attribute__((noinline))
uint32_t use3(uint32_t x)
{
    struct triple t = make3(x);
    return t.a + t.b + t.c;
}

__attribute__((noinline)) is a GCC extension that stops the compiler from inlining a function. Without it, -O2 would fold these small functions into their callers and there would be no call sequence left to look at. uint32_t comes from stdint.h and is exactly 32 bits, which on this target is the same width as unsigned int.

The disassembly shows the argument split and stack frame. In the listing, the left column is the byte offset within the object file, the middle column is the instruction word in hex, and the right column is the instruction:

00000000 <combine10>:
   0:   00012303        lw      t1,0(sp)
   4:   00412e03        lw      t3,4(sp)
   8:   01c30333        add     t1,t1,t3
   c:   01130333        add     t1,t1,a7
  10:   01030333        add     t1,t1,a6
  14:   00f30333        add     t1,t1,a5
  18:   00e30333        add     t1,t1,a4
  1c:   00d30333        add     t1,t1,a3
  20:   00c30333        add     t1,t1,a2
  24:   00b30333        add     t1,t1,a1
  28:   00a30533        add     a0,t1,a0
  2c:   00008067        ret

00000030 <abi_call>:
  30:   fe010113        addi    sp,sp,-32
  34:   00950713        addi    a4,a0,9
  38:   00850793        addi    a5,a0,8
  3c:   00e12223        sw      a4,4(sp)
  40:   00f12023        sw      a5,0(sp)
  44:   00750893        addi    a7,a0,7
  48:   00650813        addi    a6,a0,6
  4c:   00550793        addi    a5,a0,5
  50:   00450713        addi    a4,a0,4
  54:   00350693        addi    a3,a0,3
  58:   00250613        addi    a2,a0,2
  5c:   00150593        addi    a1,a0,1
  60:   00812c23        sw      s0,24(sp)
  64:   00112e23        sw      ra,28(sp)
  68:   00050413        mv      s0,a0
  6c:   00000097        auipc   ra,0x0
  70:   000080e7        jalr    ra
  74:   01c12083        lw      ra,28(sp)
  78:   00a40533        add     a0,s0,a0
  7c:   01812403        lw      s0,24(sp)
  80:   02010113        addi    sp,sp,32
  84:   00008067        ret

combine10 receives x0-x7 in a0-a7. It loads x8 and x9 from 0(sp) and 4(sp). abi_call allocates 32 bytes, saves ra and s0, places the ninth and tenth arguments on the stack, and keeps x in s0 across the call.

The stack frame created by abi_call is:

higher addresses
  28(sp): saved ra
  24(sp): saved s0
  ...
   4(sp): tenth argument, x + 9
   0(sp): ninth argument, x + 8
lower addresses

That frame is why the ABI is not just a compiler detail. The core’s load/store unit, the linker’s stack placement, and the compiler’s register allocation all have to agree about what those addresses mean.

The struct-return probe shows the hidden pointer rule:

00000088 <make3>:
  88:   00158693        addi    a3,a1,1
  8c:   00258713        addi    a4,a1,2
  90:   00d52223        sw      a3,4(a0)
  94:   00b52023        sw      a1,0(a0)
  98:   00e52423        sw      a4,8(a0)
  9c:   00008067        ret

000000a0 <use3>:
  a0:   fe010113        addi    sp,sp,-32
  a4:   00050593        mv      a1,a0
  a8:   00410513        addi    a0,sp,4
  ac:   00112e23        sw      ra,28(sp)
  b0:   00000097        auipc   ra,0x0
  b4:   000080e7        jalr    ra
  b8:   00412783        lw      a5,4(sp)
  bc:   00812703        lw      a4,8(sp)
  c0:   00c12503        lw      a0,12(sp)
  c4:   01c12083        lw      ra,28(sp)
  c8:   00e787b3        add     a5,a5,a4
  cc:   00a78533        add     a0,a5,a0
  d0:   02010113        addi    sp,sp,32
  d4:   00008067        ret

struct triple is three words, or 12 bytes, which is more than the two-word register budget. So use3 reserves space in its own frame, puts that address in a0, and moves the declared argument x into a1. make3 receives the pointer in a0 and the value in a1, and writes the three fields through the pointer with sw instructions rather than returning anything in registers. Reading make3 in isolation, the extra argument in a1 looks wrong until the hidden-pointer rule is known.

The call sequence itself is also worth decoding, because it appears everywhere below. RISC-V has no single instruction that loads a full 32-bit address. auipc ra, 0x0 writes pc + (immediate << 12) into ra, and the following jalr ra jumps to that address while writing the return address into ra. Together the pair forms a PC-relative call. The relocations that fill in the offsets are resolved by the linker, which is why they read as zero in an unlinked object file. ret is a pseudo-instruction for jalr zero, 0(ra), a jump to the return address that discards the new link value.

Global pointer and relaxation

Accessing a global variable normally costs two instructions on RISC-V: one to build the upper 20 bits of its address, one to add the lower 12 bits or perform the load. If many globals sit near each other, that cost can be halved by keeping a base address in a register and reaching each variable with a single 12-bit offset from it. That register is gp, the global pointer, and the linker symbol __global_pointer$ defines where it should point. The compiler places small globals in .sdata and .sbss, the small-data sections, so they cluster within range of that base.

Linker relaxation is the optimization that performs the rewrite. The compiler emits the general two-instruction sequence and marks it as relaxable; the linker, which knows the final addresses, replaces it with a single gp-relative instruction when the target is close enough. It can also shorten long jumps. This happens after compilation, which is why the disassembly of an object file and the disassembly of the linked executable can differ.

The failure mode is specific and nasty. Relaxation assumes gp holds __global_pointer$. On a hosted system the C runtime sets that up. On bare metal, nothing does unless the startup code does. If the linker script never defines __global_pointer$, or startup never loads it into gp, the relaxed loads compute addresses from whatever gp happened to contain, and the program reads garbage from the first global access onward. It usually dies before reaching main, which makes it look like a reset or memory-loading bug.

There are two consistent ways to avoid it: define the symbol and initialize gp, or disable relaxation with -mno-relax. The bring-up linker script below does the first, and its startup code loads gp. For the benchmark builds I still passed -mno-relax, to match the core’s existing bare-metal style and to keep the code generation identical across the sweep rather than letting the linker rewrite instructions differently per build.

Linker script, startup, and memory images

The compiler emits each object file with its contents split into named sections. The names are conventions with fixed meanings:

SectionContents
.textExecutable code
.rodataRead-only data: string literals, constant tables
.dataGlobal variables with a non-zero initial value
.bssGlobal variables that must start at zero
.sdata, .sbss, .srodataThe same three categories, for small objects reachable from gp

.bss is the odd one. It has a size and an address, but no bytes in the file, because storing thousands of zero bytes would be wasteful. The name is historical, from “block started by symbol”. Its contents are created at run time by startup code writing zeros. This is why an uninitialized global in C reads as zero even though nothing in the image contains those zeros.

An object file does not know where any of this will live. It contains code with placeholder addresses and relocation entries saying “fill this in once the final address is known”. The linker script is the file that decides. It assigns addresses, groups input sections into output sections, defines symbols that startup code will read, and can fail the build if the result does not fit. The ELF that comes out is then the single source of truth for what should be in memory and where.

Sections and segments are related but not identical. Sections are the linker’s view: .text, .rodata, .data, .bss, symbol tables, debug info. Segments are the loader’s view: contiguous loadable regions described by program headers. A bare-metal loader usually cares about the loadable segments and the addresses inside them, not about every debug or symbol section.

Two address ideas matter here. The VMA, or virtual memory address, is the address the program uses while running. The LMA, or load memory address, is the address where the initialized bytes are stored before startup copies them. In many small bare-metal images they are the same. They differ when initialized data is stored in flash or instruction memory but runs from RAM. .bss has a VMA and a size, but no initialized file payload, because startup creates its zero state at run time.

I used the same split-memory platform as the core’s bare-metal examples: instructions at 0x00000000, data at 0x80000000, stack at the top of the 64 KiB data region.

Linker script

software/bringup/link.ld:

OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY
{
    inst (rx) : ORIGIN = 0x00000000, LENGTH = 64K
    data (rw) : ORIGIN = 0x80000000, LENGTH = 64K
}

SECTIONS
{
    .text : {
        KEEP(*(.text.start))
        *(.text .text.*)
    } > inst

    .result (NOLOAD) : {
        __result_addr = .;
        KEEP(*(.result))
    } > data

    .rodata : {
        *(.rodata .rodata.*)
        *(.srodata .srodata.*)
    } > data

    .data : {
        __data_start = .;
        __global_pointer$ = . + 0x800;
        *(.sdata .sdata.*)
        *(.data .data.*)
        __data_end = .;
    } > data
    __data_load = LOADADDR(.data);

    .bss (NOLOAD) : {
        __bss_start = .;
        *(.sbss .sbss.*)
        *(.bss .bss.*)
        *(COMMON)
        __bss_end = .;
    } > data

    . = ALIGN(16);
    __heap_start = .;
    __stack_top = ORIGIN(data) + LENGTH(data);
    __stack_size = 16K;
    __stack_limit = __stack_top - __stack_size;
    __heap_end = __stack_limit;

    ASSERT(__bss_end <= __stack_limit, "data and stack overlap")
    ASSERT(SIZEOF(.text) <= LENGTH(inst), "instruction image overflow")

    /DISCARD/ : { *(.comment) *(.note.*) *(.eh_frame*) }
}

Reading that script top to bottom:

OUTPUT_ARCH(riscv) records the target architecture in the output file. ENTRY(_start) records the entry symbol, which becomes the ELF entry point; it does not by itself place code anywhere, which is why the .text output section explicitly puts .text.start first.

The MEMORY block names the physical regions. inst (rx) and data (rw) are labels with permission letters, r read, w write, x execute. ORIGIN is the first address and LENGTH the size. These two lines are the address map from earlier, written in the form the linker understands.

Inside SECTIONS, each output section lists the input sections it collects. *(.text .text.*) means “from every input file, take .text and anything starting with .text.”. The wildcard matters because the build uses -ffunction-sections, which puts each function in its own named section such as .text.main. The > inst at the end assigns the whole output section to that memory region. The dot, ., is the location counter: the address the linker has reached. Assignments such as __data_start = .; export the current address as a symbol that assembly and C can reference.

KEEP(...) explicitly retains the matching input sections during garbage collection. GNU ld also treats the entry-point section as a root, so ENTRY(_start) normally protects that section already. KEEP makes the intent explicit and also protects sections such as .result when ordinary references would not be enough.

.result (NOLOAD) is a section that occupies address space but contributes no file bytes, like .bss. It is placed first in data memory so the result word lands at exactly 0x80000000, the address the testbench reads. The C side puts a variable there with a section attribute.

__data_start and __data_end bracket the initialized data at its run address. LOADADDR(.data) returns the address where those bytes are actually stored in the image, the LMA. Startup copies from the LMA to the VMA when they differ. __global_pointer$ = . + 0x800 places the global pointer 2 KiB into the data area, so that signed 12-bit offsets, which reach 2 KiB either way, cover the surrounding small-data sections.

The .bss block brackets the zero-initialized region and also collects COMMON, the legacy placement for tentative definitions. After it, . = ALIGN(16) rounds the location counter up to a 16-byte boundary before the heap symbols are defined.

The stack is defined by arithmetic rather than by a section: __stack_top is the end of the data region, __stack_size is 16 KiB, and __stack_limit is 16 KiB below the top. No memory is reserved for it in the image; it is simply address space that nothing else is allowed to use. The two ASSERT lines enforce that: one fails the link if data has grown into the stack region, the other if code has outgrown instruction memory. Both turn a silent runtime corruption into a build error.

/DISCARD/ drops the listed input sections from the output entirely, here comment, note, and exception-handling frame sections that a freestanding program with no exception unwinding does not need.

The resulting layout is:

instruction memory
  0x00000000  .text, starting with _start
  ...
  0x00010000  end of 64 KiB instruction region

data memory
  0x80000000  .result
              .rodata
              .data
              .bss
              heap area
  0x8000c000  __stack_limit
              16 KiB stack region
  0x80010000  __stack_top, initial sp

The stack grows downward from __stack_top. The heap symbols are present for completeness, but the measured programs here do not depend on a heap allocator.

This platform places .rodata in data memory. That matches the core’s split-memory bare-metal layout and the ELF-to-HEX loader. The .result section gives the testbench a fixed result word at 0x80000000.

Startup code

The startup code is small because this is a freestanding program: there is no operating system loader and no C library runtime to set up the process. The assembly has to create the state that compiled C expects before calling main.

software/bringup/start.S:

.section .text.start,"ax",@progbits
.globl _start
_start:
    la   sp, __stack_top
    .option push
    .option norelax
    la   gp, __global_pointer$
    .option pop

    la   t0, __data_load
    la   t1, __data_start
    la   t2, __data_end
1:  beq  t1, t2, 3f
    lbu  t3, 0(t0)
    sb   t3, 0(t1)
    addi t0, t0, 1
    addi t1, t1, 1
    j    1b

3:  la   t1, __bss_start
    la   t2, __bss_end
2:  beq  t1, t2, 4f
    sb   zero, 0(t1)
    addi t1, t1, 1
    j    2b

4:  call main
    li   t0, 0x10000014
    sw   a0, 0(t0)
5:  j    5b

That is the whole runtime. Line by line:

.section .text.start,"ax",@progbits puts the following code in a section named .text.start, with flags a for allocatable and x for executable, holding program bits. The linker script places this section first, so it lands at address zero. .globl _start makes the label visible to the linker so ENTRY(_start) can find it.

la sp, __stack_top loads the address of the linker-defined stack top into the stack pointer. After this instruction, function calls, spills, and local arrays work. Before it, they would write to whatever address sp held at reset.

The .option push / .option norelax / .option pop bracket temporarily disables relaxation for one instruction. This is the bootstrap problem from the previous section: the instruction that loads gp must not itself be rewritten into a gp-relative access, because gp is not valid until that instruction completes.

The first loop copies initialized data. t0 walks from __data_load, the load address, t1 walks from __data_start to __data_end, the run addresses, and each iteration moves one byte with lbu and sb. Byte access is used deliberately: it does not require word alignment, and the section boundaries are not guaranteed to be word-multiple. The labels 1: and 3: are local numeric labels; 1b means “the nearest label 1 backwards” and 3f means “the nearest label 3 forwards”, which keeps short loops readable without inventing names.

The second loop clears .bss by storing zero, the hardwired zero register, one byte at a time from __bss_start to __bss_end. This is what makes uninitialized globals read as zero.

call main transfers to the C entry point. When main returns, its return value is in a0 by the ABI. li t0, 0x10000014 loads the address of the MMIO exit register and sw a0, 0(t0) writes the return value there, which ends the simulation and reports that value as the exit code. The final j 5b is an infinite loop on itself: if the store somehow does not stop the simulation, the core spins here rather than running off into uninitialized memory.

la, li, call, ret, and j are pseudo-instructions. The assembler expands each into one or two real instructions, which is why the disassembly of this file shows auipc/addi pairs and jalr where the source shows la and call.

In this platform, .data has load and run addresses in the same data memory, so the copy loop copies onto itself and changes nothing. It also supports a different load address if that region is readable through the data port. On a strictly split-memory platform, ordinary loads cannot automatically read instruction memory; a flash or instruction-memory copy path would need an explicitly supported data-side mapping.

The main workload does not enable interrupts, so this startup does not install a trap vector. Trap handling is exercised by separate programs that set mtvec, mie, and mstatus before taking traps, which is what the trap tests later in this post do.

ELF to Verilog memory images

The Verilog testbench does not parse ELF. A Verilog memory is just an array, and the standard way to give it initial contents is $readmemh("file.hex", mem), which reads a text file of hexadecimal words, one per line, into consecutive array elements. So the linked ELF has to be flattened into two such files, one per memory.

The indexing is the part to keep straight. The instruction array is word-indexed, so array element 0 holds the instruction at byte address 0, element 1 holds byte address 4, and so on. The data array is indexed by the data address minus the data base, so element 0 corresponds to 0x80000000. Getting this wrong produces a core that fetches valid-looking instructions from the wrong offsets.

The loader used here is scripts/elf2hex.py. It emits little-endian 32-bit Verilog hex words. Sections with VMA below 0x80000000 go to the instruction image. .data, .sdata, and .rodata also go to the data image.

Internally, the loader parses section headers with objdump -h, extracts section bytes with objcopy --dump-section, places those bytes into zero-filled instruction and data arrays, and writes one 8-digit hex word per line. With INST_WORDS=16384 and DATA_WORDS=16384, each output file contains 16,384 words, covering the full 64 KiB region. That matters because the RTL data memory is not assumed to zero-fill missing words on its own; the hex image supplies the initial state.

INST_WORDS=16384 DATA_WORDS=16384 python3 scripts/elf2hex.py \
  software/bringup/build/workload_rtl.elf \
  hex/inst_mem.hex hex/data_mem.hex

The two environment variables set the array sizes in words: 16,384 words of 4 bytes is 64 KiB. They have to match the sizes the testbench is built with, or the image and the memory disagree about where the region ends.

Building the simulator is a two-step process, because Verilator generates C++ rather than running the Verilog directly:

verilator --cc --timing -Wno-fatal -O2 \
  +define+RTOS_MEM_64K \
  -f tb/tb_bringup.f --top-module tb_bringup \
  --exe --main --Mdir obj_dir_bringup -o Vtb_bringup
make -C obj_dir_bringup -f Vtb_bringup.mk -j2

The options, in order: --cc generates C++ output; --timing enables timing constructs such as delays and event controls, which a testbench needs; -Wno-fatal downgrades lint warnings so they do not abort the build; -O2 optimizes the generated C++; +define+RTOS_MEM_64K defines a Verilog macro that selects the 64 KiB memory configuration, the same way +define+ works in other simulators; -f tb/tb_bringup.f reads the list of source files; --top-module tb_bringup names the top of the hierarchy; --exe --main asks Verilator to also produce an executable with a generated main(); --Mdir sets the output directory for the generated code; and -o names the final binary. The make line then compiles that generated C++.

The output directory matters more than it looks. Verilator bakes parameters such as memory sizes and cycle limits into the generated model at build time. Reusing one obj_dir across different configurations, or leaving a stale hex file in place, produces a run that looks plausible and measures the wrong thing. I used a separate output directory per testbench configuration and regenerated the hex files before every program.

A complete measured run

The first program has one job: produce a number that is easy to verify and hard to get right by accident. A program that prints “hello” demonstrates only the path needed to produce those characters. A program that computes something over an array also exercises loops, branches, arithmetic, shifts, byte loads, and the compiler’s library calls, giving a more discriminating result check without making it exhaustive.

The workload builds a 256-word input array from a fixed formula, so there is no input data to supply and no randomness. It then runs three kernels over that array:

  • scan_words walks the array and mixes in the count of leading zeros, the count of trailing zeros, the population count, and a rotate of each word, with a data-dependent branch on each element. On a core without Zbb, those counts become libgcc calls, so this kernel also tests the calling convention under load. The branch makes the pattern irregular, which exercises the branch predictor rather than a perfectly predicted loop.
  • multiply_mix is a linear congruential generator step, x = x * 1664525 + 1013904223, repeated 128 times with an xor-shift mixed in. It is a long dependency chain of multiplies, so it is sensitive to multiplier latency and to forwarding paths.
  • crc32_bitwise computes the standard CRC-32 checksum one bit at a time. CRC, cyclic redundancy check, is a shift-and-xor remainder computation over a polynomial; the constant 0xedb88320 is the reflected form of the standard CRC-32 polynomial. It is byte-oriented and branch-heavy, which is a different mix again, and it has a published expected value for a known input.

The three results are combined with xor into one word, written to the .result variable at 0x80000000, printed, and compared against a value computed independently. A changed final word makes the testbench fail without needing to identify which kernel broke. A single 32-bit combined result is still not exhaustive: collisions, compensating errors, and unobserved state changes can escape it.

The run followed this order:

  1. Compute the expected result independently from the C algorithm.
  2. Build an RTL image and a QEMU reference image from the same workload source.
  3. Inspect the ELF entry point, loadable segments, symbols, and sizes.
  4. Run the QEMU image to check the architectural result.
  5. Convert the RTL ELF into instruction and data hex images.
  6. Run the Verilator testbench, passing the expected result as a runtime argument.
  7. Compare the result word, exit code, trap count, cycles, and retired instructions.

Workload source

software/bringup/workload.c:

#include <stdint.h>
#include "platform.h"

#define N 256u
#ifndef EXPECTED_RESULT
#define EXPECTED_RESULT 0x00000000u
#endif

static uint32_t input[N];
volatile uint32_t result __attribute__((section(".result")));

static uint32_t crc32_bitwise(const uint8_t *p, unsigned n)
{
    uint32_t crc = 0xffffffffu;
    for (unsigned i = 0; i < n; ++i) {
        crc ^= p[i];
        for (unsigned b = 0; b < 8; ++b) {
            uint32_t mask = 0u - (crc & 1u);
            crc = (crc >> 1) ^ (0xedb88320u & mask);
        }
    }
    return ~crc;
}

static uint32_t scan_words(const uint32_t *p, unsigned n)
{
    uint32_t acc = 0x12345678u;
    for (unsigned i = 0; i < n; ++i) {
        uint32_t x = p[i];
        uint32_t leading = (uint32_t)__builtin_clz(x | 1u);
        uint32_t trailing = (uint32_t)__builtin_ctz(x | 0x80000000u);
        uint32_t ones = (uint32_t)__builtin_popcount(x);
        uint32_t rot = (x << (i & 31u)) |
                       (x >> ((32u - (i & 31u)) & 31u));
        uint32_t scaled = (uint32_t)(i * 12u + 4u);
        acc = (acc ^ rot) + leading + trailing + ones + scaled;
        if (x < acc) acc ^= x;
        else acc += x;
    }
    return acc;
}

static uint32_t multiply_mix(uint32_t x)
{
    for (unsigned i = 0; i < 128; ++i) {
        x = x * 1664525u + 1013904223u;
        x ^= (x >> 13) | (x << 19);
    }
    return x;
}

int main(void)
{
    for (unsigned i = 0; i < N; ++i)
        input[i] = i * 0x9e3779b9u + (i ^ 0xa5a5u);

    uint64_t t0 = plat_mtime64();
    uint32_t a = scan_words(input, N);
    uint32_t b = multiply_mix(a);
    uint32_t c = crc32_bitwise((const uint8_t *)input, sizeof input);
    uint64_t t1 = plat_mtime64();
    result = a ^ b ^ c;

    plat_puts("a="); plat_puthex32(a); plat_putc('\n');
    plat_puts("b="); plat_puthex32(b); plat_putc('\n');
    plat_puts("c="); plat_puthex32(c); plat_putc('\n');
    plat_puts("result="); plat_puthex32(result); plat_putc('\n');
    plat_puts("kernel_mtime_delta="); plat_puthex32((uint32_t)(t1 - t0)); plat_putc('\n');

    if (result == EXPECTED_RESULT) {
        plat_puts("PASS\n");
        return 0;
    }
    plat_puts("FAIL\n");
    return 1;
}

Two details in that source are load-bearing. volatile uint32_t result __attribute__((section(".result"))) places the variable in the section the linker script puts at 0x80000000, and volatile stops the compiler from keeping it only in a register, so the value really reaches memory where the testbench can read it. The plat_* functions are the small platform layer: plat_puts and puthex32 write bytes to the console register, and plat_mtime64 reads the timer.

EXPECTED_RESULT is passed in at compile time with -D, so the program checks itself as well as being checked externally. That gives two independent failure signals: the program’s own PASS/FAIL print, and the testbench’s memory comparison.

I computed the expected value independently in Python, using the same integer operations and zlib.crc32 over the little-endian input bytes. Doing this on the host first means the expected value does not come from the thing under test:

a=0xd5de5a53
b=0x9d02edb2
c=0x534f45ea
result=0x1b93f20b

The CRC kernel was also checked on its own against the published test vector for CRC-32, which is the checksum of the nine ASCII characters 123456789:

"123456789" -> 0xcbf43926

That test passed on RTL in 499 cycles and 734 retired instructions. Checking it separately matters: if the combined result had been wrong, this test says whether the CRC kernel was the part that failed.

Build commands

I built the RTL image with:

EXPECTED_RESULT=0x1b93f20b bash software/bringup/build.sh

The NAME=value command form sets an environment variable for that one command. The script reads EXPECTED_RESULT and passes it to the compiler as -DEXPECTED_RESULT=0x1b93f20bu, which is how the value reaches the #ifndef in the source. The same mechanism sets OPT, MARCH, and OUT in the sweeps later.

The build script used:

-march=rv32im_zicsr_zifencei
-mabi=ilp32
-mcmodel=medlow
-ffreestanding
-fno-builtin
-fno-common
-O2
-g
-Wall
-Wextra
-ffunction-sections
-fdata-sections
-nostdlib
-Wl,--gc-sections
-lgcc

The flags, and why each one is here:

  • -march and -mabi select the instruction set and calling convention, as described earlier.
  • -mcmodel=medlow selects absolute symbol addressing, appropriate for this address map.
  • -ffreestanding tells GCC that the target has no operating system and no standard library beyond the freestanding headers. It stops the compiler from assuming, for example, that main is called by a hosted runtime.
  • -fno-builtin stops GCC from recognizing a call such as memcpy and replacing it with an inline sequence or a different library call. In a program that supplies its own tiny versions of those routines, the substitution can change what is measured or reference a symbol that does not exist.
  • -fno-common places uninitialized globals directly in .bss instead of emitting them as common symbols, an older mechanism that merges tentative definitions at link time. It makes the section layout predictable.
  • -nostdlib tells the linker not to pull in the standard startup files or the standard library. -lgcc then adds back only the compiler support library, which is still required.
  • -ffunction-sections and -fdata-sections give each function and each global its own section; -Wl,--gc-sections passes --gc-sections to the linker so unreferenced ones are dropped. -Wl, is the general mechanism for forwarding an option through the compiler driver to the linker.
  • -O2 is the optimization level, -g adds debug information without changing the generated code, and -Wall -Wextra turn on the warning sets.

The build script compiled start.S, workload.c, and platform_rtl.c as separate objects, linked them with software/bringup/link.ld and libgcc, and then emitted the inspection files used in the next section: Berkeley size, full disassembly, section headers, program headers, and a sorted symbol list. Unless SKIP_HEX=1 was set, it also regenerated hex/inst_mem.hex and hex/data_mem.hex from the RTL ELF. SKIP_HEX exists because the compiler sweeps build many images in a row and only the one being run needs its hex written.

For the QEMU reference build, I used the same workload.c with three substitutions: a QEMU startup file, a QEMU linker script that puts everything in one RAM region, and a platform_qemu.c whose output function issues a Linux write syscall instead of storing to the console register. QEMU user mode translates that syscall into a host write, so the text appears on the terminal. The computation is identical; only the platform layer differs. This is the point of keeping the hardware access behind a handful of plat_* functions.

ELF inspection

Before running anything, I checked the linked file itself. This is the cheapest place to catch a whole class of bugs: if _start is not at address zero, if the result variable is not at 0x80000000, if the code is larger than instruction memory, or if the file was built for the wrong architecture, no amount of RTL debugging will explain it. Four binutils tools answer those questions: readelf prints the ELF structures, nm lists symbols with their addresses, size summarizes section sizes, and objdump disassembles.

readelf -h prints the file header. The linked RTL ELF reports:

ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           RISC-V
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          52 (bytes into file)
  Start of section headers:          20472 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         4
  Size of section headers:           40 (bytes)
  Number of section headers:         18
  Section header string table index: 17

The fields that matter for bring-up: Class: ELF32 and Data: 2's complement, little endian confirm a 32-bit little-endian image. Type: EXEC means a fully linked executable with fixed addresses, as opposed to REL, a relocatable object, or DYN, a position-independent or shared object. Machine: RISC-V confirms the target. Entry point address: 0x0 is the value the linker took from ENTRY(_start); it must equal the core’s reset PC. The two counts, 4 program headers and 18 section headers, say how many program-header entries and sections follow; only entries of type LOAD describe loadable segments, and the section count is larger because it includes symbol tables, string tables, and debug information that are never loaded.

readelf -A prints the architecture attributes, and file gives a one-line summary:

Attribute Section: riscv
File Attributes
  Tag_RISCV_stack_align: 16-bytes
  Tag_RISCV_arch: "rv32i2p1_m2p0_zicsr2p0_zifencei2p0_zmmul1p0"
ELF 32-bit LSB executable, UCB RISC-V, soft-float ABI, version 1 (SYSV), statically linked, with debug_info, not stripped

The attribute string is the compiler’s record of what it was told to target, expanded with version numbers: rv32i2p1 means the I extension at version 2.1, m2p0 the M extension at 2.0, and so on. zmmul1p0 appears because M implies the multiply-only subset. Tag_RISCV_stack_align: 16-bytes records the ABI’s stack alignment. soft-float ABI in the file output confirms that floating-point arguments, if there were any, would travel in integer registers. These attributes describe the build, not the hardware. They are what the toolchain was told, which is exactly why the M probe earlier was necessary.

readelf -l prints all program headers, including the loadable segments:

Elf file type is EXEC (Executable file)
Entry point 0x0
There are 4 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  RISCV_ATTRIBUT 0x002158 0x00000000 0x00000000 0x0003f 0x00000 R   0x1
  LOAD           0x001000 0x00000000 0x00000000 0x004b4 0x004b4 R E 0x1000
  LOAD           0x002000 0x80000000 0x80000000 0x00158 0x00558 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10

 Section to Segment mapping:
  Segment Sections...
   00     .riscv.attributes
   01     .text
   02     .result .rodata .bss
   03

Of the four headers, only the two marked LOAD describe bytes that go into memory. RISCV_ATTRIBUT points at the architecture attributes read earlier, and GNU_STACK is a marker header saying the stack does not need to be executable; both have zero MemSiz and load nothing.

Each LOAD row says: take FileSiz bytes from file offset Offset and place them at VirtAddr, then occupy MemSiz bytes of memory in total. The first is the code, 0x4b4 bytes at address 0, marked R E for readable and executable. The second is the data, at 0x80000000, marked RW. The section-to-segment mapping below the headers shows which sections each segment covers, which is how .result, .rodata, and .bss can be seen sharing the second segment.

The second row is the one to read carefully: FileSiz is 0x158 but MemSiz is 0x558. The difference, 0x400 bytes, is .bss. Those bytes exist in memory at run time but not in the file, and they must be zeroed before main. That is precisely the loop in the startup code, and here it is visible as arithmetic in the program header. Align 0x1000 is the segment alignment the linker used for file layout.

nm -n lists symbols sorted by address, which shows whether everything landed where the linker script intended:

00000000 T _start
00000074 T main
00000314 T plat_putc
00000320 T plat_puts
00000344 T plat_puthex32
000003a0 T plat_mtime64
000003c4 T __clzsi2
00000410 T __ctzsi2
00000460 T __popcountsi2
80000000 B result
80000158 b input
80000560 B __heap_start
8000c000 A __stack_limit
80010000 B __stack_top

Reading the addresses against the plan: _start at 0x00000000 matches the reset PC; main at 0x74 is 116 bytes of startup code later; the three __clzsi2, __ctzsi2, __popcountsi2 symbols are the libgcc helpers pulled in because the build has no Zbb; result is at 0x80000000 as the testbench expects; input, the 256-word array, follows it; and the stack symbols bound the top 16 KiB of the data region.

The letters are the symbol’s section class: T is code, B is a global in .bss, b is a file-local object in .bss, and A is absolute, a value not tied to any section. __stack_limit is absolute because the linker computed it rather than placing an object.

size -B prints the same information as totals, in the Berkeley format:

   text    data     bss     dec     hex filename
   1544       0    1028    2572     a0c workload_rtl.elf
   1556       4   17416   18976    4a20 workload_qemu.elf

The columns are text, data, and bss, then the total in decimal and hex. The grouping is coarser than the section names: Berkeley size counts read-only data as text, so the 1,544 bytes here are .text at 1,204 plus .rodata at 340. Likewise the 1,028 bytes of bss are the 1,024-byte .bss plus the 4-byte .result. size -A prints the per-section breakdown instead, which is the one to use when a total looks surprising. Either way the image fits the 64 KiB regions with room to spare. The QEMU image shows a much larger BSS because its linker script reserves the 16 KiB stack inside the loaded RAM image, while the RTL image simply points sp at the top of the data region. Comparing the rows helps explain the layout difference, but equal or similar section sizes do not establish identical computation. The shared workload source and checked results provide that evidence.

QEMU functional run

I ran the same C result through QEMU user mode first because it is a fast architectural reference. QEMU user mode executes the RV32 instruction stream and translates Linux user-mode syscalls; it does not instantiate rv32i_top, the MMIO decoder, or this pipeline. This QEMU build uses a Linux user-mode syscall shim for output, not the RTL MMIO page.

qemu-riscv32 software/bringup/build/workload_qemu.elf

qemu-riscv32 is the user-mode emulator for 32-bit RISC-V. It loads the ELF, translates its instructions to host instructions, and runs them. Output:

a=0xd5de5a53
b=0x9d02edb2
c=0x534f45ea
result=0x1b93f20b
kernel_mtime_delta=0x00000000
PASS

The process exit status was 0, which is the value main returned, passed through the QEMU startup code’s exit syscall.

The line to notice is kernel_mtime_delta=0x00000000. The workload brackets its kernels with timer reads, and on QEMU there is no MMIO timer, so the platform stub returns zero for both reads and the difference is zero. That is expected and it is the concrete reason QEMU cannot be used for timing here. What this run establishes is the architectural result: the same C, compiled for the same ISA and ABI, produces 0x1b93f20b on an independent instruction-set implementation. A difference on RTL directs attention to hardware, code generation, image loading, or the platform adapter; one matching run does not rule out undefined C behavior or an error shared by both builds.

RTL run

The RTL run uses the generated instruction and data hex files. Verilator compiles the RTL and testbench into a C++ simulation model; the testbench drives clock and reset, loads the memory images, and observes the core’s exit and retirement signals. The testbench resets the core, lets it run until the MMIO exit register is written, and compares the result word with the expected value supplied as a plusarg.

A plusarg is a runtime argument passed to a Verilog simulation with a leading +, read inside the testbench by $value$plusargs. It is the simulation equivalent of a command-line flag, and it lets one compiled testbench be reused with different expected values or cycle limits.

The check is deliberately not based on the printed UART text. The testbench reads the result word directly out of the data memory array and evaluates:

if ($value$plusargs("expected=%h", expected_runtime)) begin end
if ($value$plusargs("max_cycles=%d", max_cycles_runtime)) begin end

result_word = uut.mem.dmem.mem[(RESULT_ADDR >> 2) % DATA_WORDS];
pass = sim_exit && (exit_code == 0) && (result_word == expected_runtime);

RESULT_ADDR is 0x80000000, the address of the .result section. The UART output is still useful for human inspection, but the pass/fail decision comes from memory, exit code, and the expected-value plusarg.

INST_WORDS=16384 DATA_WORDS=16384 python3 scripts/elf2hex.py \
  software/bringup/build/workload_rtl.elf \
  hex/inst_mem.hex hex/data_mem.hex

./obj_dir_bringup/Vtb_bringup +expected=0x1b93f20b

Output:

a=0xd5de5a53
b=0x9d02edb2
c=0x534f45ea
result=0x1b93f20b
kernel_mtime_delta=0x0000c725
PASS
BRINGUP_RESULT cycles=56653 instret=89676 traps=0 exit_code=0 result=0x1b93f20b expected=0x1b93f20b timed_out=0 pass=1

The last line is the testbench’s verdict, and each field is worth reading: cycles is the total clock count from reset release to the exit store; instret is the number of retired instructions; traps is the hardware trap counter, zero here because this workload takes no traps; exit_code is the value the program wrote to the exit register; result and expected are the memory word and the plusarg; timed_out says whether the cycle limit was hit instead of a deliberate exit; and pass is the conjunction of exit, zero code, and matching result. A run that ends with timed_out=1 has not produced a result at all, whatever it printed before hanging.

Measured summary. CPI is cycles per instruction and IPC is instructions per cycle; both are derived from the two counters, and the next section explains why CPI is below 1 here.

MetricValue
Result0x1b93f20b
Exit code0
RTL cycles56,653
Retired instructions89,676
CPI0.631752
IPC1.58290
Traps0
Kernel mtime delta0xc725 = 50,981 cycles
Testbench result checkPASS

The retired instruction count is larger than the cycle count, which looks wrong until the microarchitecture is taken into account. Retirement is the point at which an instruction’s result becomes architecturally committed. This core can retire two instructions per cycle, so 89,676 instructions in 56,653 cycles is an average of about 1.58 per cycle. The testbench counts them by watching the writeback-stage valid signals, mem_wb_valid0 and mem_wb_valid1, and adding one for each asserted signal per clock.

The QEMU/RTL comparison for this workload is:

RunResultExitCyclesInstretCPI
QEMU user mode0x1b93f20b0not modelednot modelednot modeled
RTL Verilator0x1b93f20b056,65389,6760.631752

Counters and derived metrics

Once a run completes, the next question is what the numbers mean. There are two sources of them: the testbench counts clock cycles and retired instructions from outside the program, and the program can read the MMIO timer around a region it cares about. They measure different spans, and mixing them up is an easy way to quote a wrong number.

The testbench cycle counter increments once per clock after reset. The retired-instruction counter adds one for each asserted retirement-valid signal, mem_wb_valid0 and mem_wb_valid1. CPI is cycles divided by retired instructions; IPC is the reciprocal. A CPI below 1 is not a counter bug on this core. It means the dual-issue pipeline retired more than one instruction per cycle on average.

The MMIO timer is useful when the program itself needs to measure a region. The testbench counters describe the whole run, including startup, initialization, printing, and exit. The mtime delta inside the workload describes only the bracketed kernel region.

For the bring-up workload:

cycles  = 56,653
instret = 89,676
CPI     = cycles / instret = 0.631752
IPC     = instret / cycles = 1.58290

For a region measured by mtime:

region_cycles = mtime_after - mtime_before

Because mtime increments every clock on this platform, a difference of timer readings is a cycle count directly, with no scaling. The kernel region measured 50,981 cycles while the whole run measured 56,653. The 5,672-cycle difference is the array initialization, the printing, the startup loops, and the exit path. That gap is the reason to bracket the region of interest rather than quoting the whole-run number as if it described the kernels.

There is a third measurement path this platform does not have. Standard RISC-V defines mcycle and minstret CSRs that a program can read directly. This core does not implement them, so instruction counts come from the testbench and timing comes from the MMIO timer. The CSR read logic returns zero for unsupported addresses; it must not be assumed to raise an illegal-instruction trap. Those reads therefore cannot supply valid cycle or instruction measurements on this core.

The benchmark scores later are derived from the same timer, using the definitions the benchmarks themselves specify:

CoreMark/MHz = iterations * 1,000,000 / timer_ticks
DMIPS        = Dhrystones_per_second / 1757
DMIPS/MHz    = DMIPS / reporting_MHz

CoreMark reports work per unit of time, normalized per MHz so that results from different clock rates can be compared. Since the timer here counts cycles, iterations * 1,000,000 / ticks is already work per million cycles, which is the same quantity. The Dhrystone constant 1757 is historical: it is the number of Dhrystone iterations per second measured on a VAX 11/780, the machine that defined 1 MIPS in this benchmark’s terms. Dividing by it converts Dhrystones per second into DMIPS, and dividing again by the clock rate in MHz gives DMIPS/MHz.

Absolute seconds and per-second rates need a clock-rate assumption, although the per-MHz figures can be derived directly from cycles. A simulation does not establish an achievable physical clock rate. I therefore fixed a reporting convention of 10 MHz: ten million timer ticks are reported as one second. The convention affects the reported seconds and the per-second rates, and it cancels out of the per-MHz figures.

Verification and measured results

A matching result from one program is a verification check, not complete verification. It does not say which instruction classes, trap paths, or issue-slot combinations were exercised, and it says nothing about the paths that program never took. I therefore separated the checks into layers, each aimed at a different kind of mistake: the cheap ones catch compiler, linker, and memory-image errors; the directed RTL ones catch implementation errors; the architectural suites catch ISA-level mistakes against an external reference; the random stream catches combinations I did not think to write; and the long benchmark runs catch state that only goes wrong after millions of instructions.

The target RTL was not modified for these runs. The verification adapters live in a separate verification/ tree: testbench wrappers, linker scripts, macro headers, RVFI monitor, formal wrapper, and runner scripts. That separation matters. If a test fails, I want to know whether the core disagreed with the ISA or whether my adapter disagreed with the core’s memory map.

Verification layers

The layers answer different questions. Image inspection checks whether the program is placed where the platform expects it. Simulation checks selected executions against a decision rule. Formal checking asks about all modeled behaviors within a specified scope, but only if the solver completes and the assumptions are sound. Benchmarks measure validated workloads, not exhaustive ISA correctness.

A test oracle is the rule used to decide whether a run is correct: an expected register value, a memory signature, a checksum, or an exit code. Those oracles have different strengths. The inventory below states which one was actually used, and the later sections show the detailed evidence.

What was actually checked

The following inventory separates execution evidence from configured intent. A PASS applies to the stated oracle, not to every behavior of the core.

Check performedActual oracle or observationResult and boundary
Compile and linkISA/ABI flags, entry point, symbols, memory bounds, and image inspectionChecked throughout bring-up; not an execution-correctness test
Directed instruction programsTestbench register checks31/31 passed; finite directed cases
Trap transparency and arrival timingTrapping programs compared with reference twins; timer-fired observations388 pair-suite programs plus 62 arrival points, 0 recorded failures
C bring-up workloadFinal result against QEMU0x1b93f20b matched; not a cycle-timing reference
ACT4 RV32I programsAdapted signature-mode self-check and exit37/39; byte-store and halfword-store tests fail; epilog cause remains open
Upstream rv32ui programsRTL and Spike exit codesRTL 39/41, Spike 40/41; halfword-store divergence and shared misaligned-data failure
Upstream rv32um programsRTL and Spike exit codesRTL 3/8, Spike 8/8; divide and high-half multiply failures
Generated RV32I programsCompletion endpoint and reference exit codeTwo program pairs exit zero; no per-instruction or final-state differential
RVFI formal integrationModel setup, selected bounded jobs, retirement coverSee the per-job table below; generated jobs are not passing proofs
Long benchmark executionBenchmark validation checksCoreMark and Dhrystone validation completed; not exhaustive ISA testing
Functional coverageAttempted trace-to-coverpoint flowDid not complete; no percentage or coverage-closure claim

The 62 arrival points are part of the 450-program trap total, not 62 additional programs on top of 450. Likewise, 87 generated formal jobs are not 87 executed jobs. The random experiment contributes two programs, not 1,000 verified instructions merely because the generator requested 500 per program.

Measured bring-up and directed runs

These are the results of running the lower layers on this revision. “Not used” in the cycles column means the check is a functional one where cycle count was not the quantity being measured.

RunResultCyclesTrapsNotes
Directed instruction tests31 PASS, 0 FAILnot usednot usedIcarus, register checks
Trap-pair programs388 programs, 0 failedper programcheckedEach trap program compared with a reference twin
Timer arrival sweep62 arrival points, 62 firedper programcheckedTotal checked programs: 450, failures: 0
csr_trap_test.elfMMIO exit4682Final mcause=0x80000007, mepc=0x110
Bring-up workloadPASS56,6530QEMU and RTL result match
CRC-32 vectorPASS4990"123456789" -> 0xcbf43926
M high-half probeFAIL on RTL4270mulhu, mulh, mulhsu returned zero; QEMU passes

The directed instruction summary was:

Total checks: 31
Passed: 31
Failed: 0

*** ALL TESTS PASSED ***

The CSR/timer trap test is a small program that programs the timer, enables the interrupt, takes it, and reports the machine CSRs at the end:

Total cycles:  468
Ended by:        MMIO exit store
Traps taken:   2
mepc:          0x00000110
mcause:        0x80000007
mtime:         0x00000000000001d4
mtimecmp:      0x00000000000001ba

Reading the final state: mcause=0x80000007 is the machine timer interrupt, as expected from the encoding described earlier. mepc=0x110 is the instruction the core was about to execute when the interrupt was taken. Two traps were taken rather than one because the timer request is level-sensitive and stays asserted until the handler moves mtimecmp. The final mtime of 0x1d4 is 468 in decimal, matching the cycle count, which is the cross-check that the timer really is counting clocks. The final mtimecmp of 0x1ba is 442, below the current mtime, which is why the request was still pending at exit.

The trap-pair runner produced:

pairs: 388 programs x 2 twins
pairs done: 388 programs, 0 failed
sweep: body 20 blocks, 55 cycles, 62 arrival points
sweep done: 62 arrival points, interrupt fired in 62
TOTAL programs checked: 450, failures: 0

The arithmetic in that output: 388 pairs means 388 trap programs and 388 reference twins, but the comparison unit is the pair, so it counts as 388 checks. The arrival sweep adds 62 more checks, one per interrupt arrival point across a 55-cycle body, giving 450 comparison/sweep cases in total. The runner calls these “programs checked”; that is not a count of 450 total binary executions. Every pair matched and the interrupt fired at all 62 arrival points.

ACT4 architectural compatibility tests

ACT4 is the current RISC-V architectural-test flow. I used it in signature mode: each test writes a sequence of result words into a signature region, and the test’s own epilog checks canaries, update counts, and trap offsets before reaching HALT_PASS or HALT_FAIL. The adapter supplies the platform’s split instruction/data memory map, the MMIO exit address, a linker script, and the rvmodel macros. The ELFs were built with GCC 15.2.0 because the ACT4 configuration tooling, which uses the RISC-V Unified Database (UDB), rejected the older compiler. This is separate from GCC 14.2.0 used for the bring-up workload and benchmarks.

The RV32I suite contained 39 tests. The RTL result was:

ACT4 RTL I: 37/39 passed

The two failures were:

TestExitCyclesInstretTrapsTimeout
I-sb-0014,2885,80600
I-sh-0013,9365,37700

Both tests reach the test’s HALT_FAIL path with zero traps and zero timeouts. An earlier run record reported signature dumps byte-identical to the Sail reference. Those signature artifacts were not retained, so that comparison is not independently auditable from this package. It makes the epilog/self-check path a debugging lead, not a confirmed root cause or grounds to count the tests as passed.

A few passing examples give a sense of the run:

TestExitCycles
I-add-0003,103
I-jal-0008,822
I-bne-0003,785
I-lw-0001,138
I-sw-0001,672
I-nop-000398

The ACT4 result is an architectural-test result on this adapter, not a certification claim. It says that 39 generated RV32I programs were built and run, and that 37 reached the pass path. It does not say that every RV32I corner case is covered, and it does not replace the formal or coverage layers.

riscv-tests with Spike as reference

I also ran the upstream riscv-tests instruction suites. The RTL ELFs use the split memory map and the platform’s MMIO exit. Spike cannot map the RTL’s instruction memory at address zero in this configuration, so each test also has a unified ELF linked at 0x80000000 for Spike. The comparison here is exit-code differential: both models run the same test source, and I compare whether each reaches its pass exit.

The summary was:

SuiteRTL PASSSpike PASSTests
rv32ui394041
rv32um388

The divergences were:

TestRTL exitSpike exitInterpretation
rv32ui-sh90halfword-store failure on RTL
rv32ui-ma_data3156both fail; misaligned-data behavior is not a clean match
rv32um-div70divide failure on RTL
rv32um-divu170unsigned divide failure on RTL
rv32um-mulh150high-half multiply failure on RTL
rv32um-mulhsu150high-half multiply failure on RTL
rv32um-mulhu150high-half multiply failure on RTL

mul, rem, and remu passed on both. rv32ui-fence_i did not build with the adapted environment and is not counted in the 41. This run confirms the earlier M-extension probe: the core is not a complete RV32M implementation in this revision. It also shows a halfword-store problem that the bring-up workload did not expose.

Random instruction streams from riscv-dv

The question for this layer was narrower than “does RV32I work?”: can generated instruction sequences be adapted to this split-memory platform, execute to their completion endpoint on RTL, and also terminate successfully under Spike? This was a two-program smoke experiment, not a random-regression campaign and not instruction-by-instruction differential verification.

What was generated and what was excluded

The recorded bare-mode run used seed 23456, test riscv_rand_instr_test, generator class riscv_instr_base_test, and two iterations. An earlier generation attempt used seed 12345; it is not counted as another two passing programs. The retained adapters/riscv-dv/testlist.yaml requests the following configuration:

SettingValueConsequence for the experiment
Requested instruction count+instr_cnt=500Generator request per program, not a measured retired-instruction count
Subprogram count+num_of_sub_program=0No generated subprogram campaign
CSR instructions+no_csr_instr=1CSR semantics and privilege transitions were not tested here
Fences+no_fence=1Memory-fence behavior was excluded
Breakpoint and wait+no_ebreak=1 +no_wfi=1No breakpoint or wait-for-interrupt validation
Directed instruction streams+no_directed_instr=1No claim that directed hazard templates were exercised
Compressed instructions+disable_compressed_instr=132-bit instruction stream only
Misaligned instructions+enable_misaligned_instr=0No instruction-address misalignment campaign
Unaligned loads and stores+enable_unaligned_load_store=0No deliberate unaligned data-access campaign
Boot and delegation+boot_mode=m +no_delegation=1Single machine-mode environment, not a privilege/delegation test

The intended stimulus contains RV32I integer, load/store, and control-flow combinations. However, the generated assembly and retirement traces did not survive workspace cleanup. Therefore, the configuration is evidence of requested stimulus, not evidence that every enabled opcode, dependency distance, address alignment, or issue-slot pairing actually executed. There is no retained opcode histogram or hazard cross-coverage report.

How the generated code reached the core

The generated program needed platform adaptation before it could run. The bare-metal path removed default initialization includes and the hart-ID CSR access, then replaced the default HTIF exit with a store to the platform exit register at 0x10000014. The RTL image used instruction memory at zero and data memory at 0x80000000. The Spike reference used a separately linked, unified-address-space ELF at 0x80000000, derived from the same generated assembly.

These are not byte-identical ELFs. Relocation changes absolute addresses, and each platform needs its own termination path. That is acceptable for a completion experiment, but it means the comparison is not a lockstep execution of an identical binary. The linker/startup/exit adapters are part of the trusted test environment and must be checked independently.

The retained post-processing script is explicitly marked reconstructed: it is a reproducibility aid, not the byte-identical script that produced the historical results. In particular, its MMIO termination sequence stores zero. Reaching that sequence therefore signals completion, not a checksum comparison against the ISA model. A random program can finish with incorrect intermediate register or memory values unless those values are actually checked.

Measured results

The values below come from results/riscv-dv/summary.json. IPC is calculated as the recorded RTL retired-instruction count divided by the recorded RTL cycle count.

ProgramSeedRTL exitCyclesRetired instructionsIPCSpike exit
riscv_rand_instr_test_02345601622501.5430
riscv_rand_instr_test_1234560991551.5660
Combinedsame campaign2/2 exit 02614051.5522/2 exit 0

The earlier run record reports zero traps and zero timeouts for both RTL executions. Those fields are not included in the compact surviving DV summary, so they are not independently recoverable from that JSON. The surviving Spike log files are empty; the reference exit codes are retained in the summary, not in a detailed instruction log.

What the comparison actually checked

ObservationChecked in this run?Meaning
RTL reaches the adapted completion endpointYes, two programsNeither recorded run remained stuck before the endpoint
Spike terminates with exit code zeroYes, two reference runsThe reference execution completed under its own adapter
RTL and Spike termination codes agreeYes, both pairsAgreement only at the program endpoint
Every retired PC and instruction agreesNoNo normalized retirement-trace comparison
Every destination register write agreesNoArithmetic/forwarding corruption can escape the endpoint check
Every load/store address, mask, and data value agreesNoMemory-path correctness is not established by successful termination
Final architectural register and memory state agreesNoNo retained state digest or signature differential
Each opcode and hazard combination was exercisedNot measuredRequested instruction classes are not measured coverage

Results and discussion

The useful positive result is integration: two generated programs reached the RTL endpoint and completed under Spike. The recorded IPC values above one are consistent with a dual-issue implementation retiring more than one instruction in some cycles. They do not identify the exact instruction pairs, prove that both lanes are architecturally correct, or predict application performance. The retirement counter is itself part of the testbench instrumentation.

The requested count of 500 instructions must not be substituted for the observed counts of 250 and 155. A generator’s static count, the dynamic path taken through branches, startup/termination code, and the measurement window are different quantities. Without the original generated assembly and execution traces, I cannot determine which accounts for the discrepancy here. I therefore report 405 observed retirements across the two runs, not “1,000 instructions verified.”

Most importantly, exit agreement is a weak oracle for random instruction testing. If an ADD writes a wrong value that never affects a checked condition, the test may still exit zero. If a store corrupts data that is never checked, the same thing can happen. The failures already found by the architectural suites, especially halfword stores and unsupported M operations, are not contradicted by these two successful random completions. The DV configuration excludes M, and successful completion does not validate every I operation either.

The next useful increment is not merely increasing the seed count. It is retaining the generated assembly and exact ELF hashes, collecting both retirement streams, normalizing platform-specific startup/exit behavior, and comparing ordered PC, instruction, register-write, and memory events. Only then does a larger seed sweep become a meaningful differential campaign. Opcode counts, operand corner cases, load-use distances, same-cycle dependencies, and branch flushes should be reported separately as measured coverage. None of those reports is claimed here.

Formal checks through RVFI

The formal work asks a different question from simulation: under an explicitly modeled environment, can a bounded execution violate an architectural or retirement-consistency assertion? The important distinction is between a generated check, a successfully built model, a reachable cover target, a completed bounded assertion check, and an unbounded proof. They are not interchangeable milestones. An assertion is a condition required to hold; an assumption restricts the allowed environment; a cover goal asks whether an event can happen at all. A bounded check explores a finite number of modeled steps. Yosys converts the design into a formal model, SymbiYosys (SBy) runs the jobs, and a satisfiability-modulo-theories (SMT) solver such as Z3 tries to answer the resulting logical questions.

What was connected to the checker

RVFI means RISC-V Formal Interface: a standard set of signals describing retired instructions and their architectural effects. The core has two retirement slots but no complete native RVFI port. An external monitor shadows instruction and PC metadata through the pipeline and emits two RVFI channels. Yosys expose exports 44 internal observation signals for that monitor. The target RTL source files are unchanged; the observation interface and any model abstraction are applied externally in the verification flow.

RVFI informationAdapter source or conventionWhy it matters
Valid and instruction identityObserved pipeline-valid state and shadowed instruction wordsA bubble or flushed instruction must not appear as a retirement
Retirement orderChannel 0 older than channel 1 in a cycleRegister and memory dependencies must follow architectural order
PC before/afterShadowed PCs and decoded branch/jump targetsA correct ALU result is insufficient if control flow is wrong
Source register valuesForwarded execution-stage operandsThese must belong to the instruction later reported at retirement
Destination register writeObserved register-file write address/dataSuppressed writes and writes to x0 need the correct RVFI convention
Memory eventObserved memory request, size, address, and dataByte masks and load-data timing must match the RVFI ISA model

This monitor is part of what needs verification. A failing property can expose a core defect, a monitor alignment defect, an environment mismatch, or a spurious behavior introduced by abstraction. It is not automatically an RTL bug.

Generated property inventory

There are 87 generated .sby jobs. The inventory below counts generated jobs, not passing jobs.

FamilyJobsProperty being asked
Instruction semantics, insn_*7437 instruction models on each of two retirement channels: register operands/results, next PC, trap indication, and memory masks/data as applicable
Register consistency, reg_ch*2Source-register observations agree with earlier RVFI writes to the selected register
Forward/backward PC consistency, pc_fwd_*, pc_bwd_*4Adjacent retirement orders agree on predecessor next-PC and successor current-PC
Register causality, causal_ch*2A later register consumer is not retired before the selected producer in a non-causal way
Memory causality, causal_mem_ch*2Accesses to the selected memory location are not retired in a non-causal order
Retirement-order uniqueness, unique_ch*2The selected retirement order is not reused for another retirement
Retirement cover, cover1Separate cover statements ask for two counted retirements on each channel
Total87Generation is not proof completion

The 37 instruction models are ADD, SUB, SLL, SLT, SLTU, XOR, SRL, SRA, OR, AND; ADDI, SLTI, SLTIU, XORI, ORI, ANDI, SLLI, SRLI, SRAI; LUI, AUIPC; JAL, JALR; BEQ, BNE, BLT, BGE, BLTU, BGEU; LB, LH, LW, LBU, LHU; and SB, SH, SW. Fences, CSR behavior, interrupts, illegal-instruction traps, and M-extension operations are not established by this inventory.

Environment assumptions and model limits

The wrapper uses symbolic instruction memory and a small data-memory model. Fetch and data indexing use address bits [9:2], so distinct full addresses can alias in this model. This is not a proof of the real platform’s full address map or MMIO behavior. Both fetched instruction words are constrained by the wrapper’s legal_i predicate on every non-reset cycle, including words that might not eventually retire. The reset assumption in the framework is reset == $initstate.

The legal_i name should not be mistaken for a complete ISA legality decoder. Its opcode/function filters exclude entire unsupported classes but permit some reserved function combinations. Also, selecting a legal opcode does not by itself constrain effective addresses to be aligned. Alignment and access behavior need their own audited environment contract. The present wrapper must not be described as a fully validated RV32I memory system.

For an instruction check, the framework additionally assumes spec_valid at the designated check cycle. Thus an ADD job asks about a trace in which ADD is selected on that channel at that point. If that selection is inconsistent with the bounded model and its other assumptions, the result is PREUNSAT, not architectural success. The generated assume_stmts.vh file being empty does not mean the complete model is assumption-free: assumptions also live in the wrapper, testbench, and checker.

Two monitor risks remain particularly important. First, execution-stage forwarded operands must remain aligned with the retirement instruction after stalls, squashes, and flushes. Second, the wrapper’s simultaneous memory accesses do not explicitly forward an older same-cycle store to a younger aliasing load. A memory assertion can therefore reflect an adapter semantic mismatch rather than the core’s actual memory implementation. Byte/halfword RVFI address, mask, and data conventions also need independent checks.

Solver results before predictor abstraction

AttemptObserved resultWhat can be concluded
Generate all 87 jobs and set up insn_add_ch0Setup completed, return code 0The configured files could be prepared; no assertion was proved
Earlier shallow ADD/channel-0 attempt using Z3PREUNSAT, return code 16No satisfying selected trace under that particular bounded setup; root cause was not isolated
Full-model cover using BoolectorEngine error, broken pipe, return code 16Tool failure, not unreachable retirement and not a property failure
Full-model cover using Z3, depth 8External timeout at 280 seconds; last reported search was step 6No completed cover result within the time budget
Initial predictor-cut experiment including pipeline registersMultiple-driver error while creating SMTInvalid model construction; discarded

The installed Boolector version was 1.5.118. Its engine failure is recorded, but the log alone does not establish that solver age explains every historical failure. Likewise, PREUNSAT is not enough to blame one particular assumption without isolating it.

Predictor abstraction and retirement reachability

To make a smaller diagnostic model, the formal script cuts the combinational predictor outputs if_pred_taken0 and if_pred_target0 into unconstrained symbolic values. It does not cut the pipeline prediction registers and does not edit the RTL files. This deliberately permits predictions that the concrete predictor might never produce. It is an over-approximation at those signals, not verification of the predictor tables, BTB, or return-address stack.

This distinction affects the direction of the result. A safety result over an audited over-approximation can be useful within its stated bound and assumptions. A counterexample may be spurious. An existential cover witness is weaker still: reachability in the larger abstract behavior set does not establish reachability in the concrete predictor implementation.

The first working abstract cover run used Z3 at depth 10 and completed in 41 seconds. Both cover statements were reached at step 6, with separate witness traces. Each statement asks for the channel’s counted retirements to reach two. It does not require both channels to retire together in the same cycle, and it does not prove the instruction, register, or memory assertions. The saved VCD files are evidence of activity in the abstract monitor/model, not a proof that the entire adapter is correct.

Bounded campaign results

This new diagnostic campaign ran 11 of the 87 generated jobs using Z3 and the two predictor-output cuts. Each job had a 75-second external wall-time budget, including model preparation. The other 76 jobs were not executed in this campaign. Historical attempts are listed separately above.

JobWhat was attemptedResultWall timeLast reported solver stage
coverTwo counted retirements per channel (separate cover goals)PASS46.9 sReached cover statement in step 6
insn_add_ch0ADD semantics, channel 0TIMEOUT75.0 sChecking assumptions in step 8
insn_add_ch1ADD semantics, channel 1TIMEOUT75.0 sChecking assumptions in step 8
insn_lw_ch0Word-load semantics, channel 0TIMEOUT75.0 sChecking assumptions in step 8
insn_sh_ch0Halfword-store semantics, channel 0TIMEOUT75.0 sChecking assumptions in step 8
insn_beq_ch0Equal-branch semantics, channel 0TIMEOUT75.0 sChecking assumptions in step 8
reg_ch0Source-register consistency, channel 0TIMEOUT75.0 sChecking assumptions in step 8
pc_fwd_ch0Forward PC continuity, channel 0TIMEOUT75.0 sChecking assumptions in step 8
unique_ch0Retirement-order uniqueness, channel 0TIMEOUT75.0 sChecking assumptions in step 8
causal_ch0Register causality, channel 0TIMEOUT75.0 sChecking assumptions in step 8
causal_mem_ch0Memory causality, channel 0TIMEOUT75.0 sChecking assumptions in step 8

Campaign totals: 1 PASS, 10 TIMEOUT. Completed bounded safety PASS results: 0. Completed unbounded proofs: 0. The cover PASS is not included in the safety count.

All ten safety jobs exhausted their wall-time budget. Specifically, all ten safety logs stop in assumption satisfiability checking at step 8, before any reported assertion-checking phase. The architectural properties were configured and their models built, but this campaign did not complete even the assumption precheck for those jobs. It would be misleading to describe them as ten completed architectural checks.

The per-job logs are results/formal-<job>.log; results/formal-campaign.json records return codes, times, and cover steps. results/formal-check-inventory.csv lists all 87 jobs, including explicit NOT_RUN_IN_THIS_CAMPAIGN entries, their effective settings, and configuration hashes.

Results and discussion

The formal result is an integration and diagnosis result, not an ISA-proof result. The working cover provides a concrete place to inspect the shadowed PCs, instruction words, operands, memory events, and retirement orders. That is materially more useful than simply counting generated checks, but it remains an abstract witness.

The safety jobs must be read with their individual statuses. PREUNSAT means the selected assumptions had no satisfying trace, TIMEOUT means no answer was obtained within the budget, and ERROR means the tool/model flow did not complete. None counts as PASS. A bounded PASS, if obtained, would apply only to the configured bound, selection cycle, environment, and abstraction; it would not be an unbounded proof of the core.

The patch script currently selects depth 9, skip 8, and check cycle 8 for the bounded campaign. This is a short selected-cycle diagnostic, not a claim that all startup cycles or arbitrary-length execution have been checked. The original generated family-specific depths and the effective patched settings must not be conflated. Results below this depth cannot settle longer dependency chains, predictor recovery sequences, or arbitrary memory interactions.

Before scaling the campaign, the priority is to compare the RVFI monitor with simulation traces on small known programs, validate same-cycle older/younger register and memory dependencies, and isolate the assumption responsible for each unsatisfiable selection. A useful sequence would be: concrete-model retirement witness, per-opcode/per-channel witness, monitor alignment checks, then bounded semantic checks at increasing depths. No predictor-correctness claim, complete memory-order proof, complete ISA proof, or certification claim follows from the current work.

Evidence and reproduction boundary

The retained evidence includes the wrapper, monitor, generated checks, patch_formal_checks.py, run_formal_campaign.py, per-job logs, the campaign JSON, and the first successful abstract-cover witnesses. Historical generated DV assembly, ELFs, detailed simulation logs, and reference traces were not all retained. Reconstructed build helpers are identified as such; they should not be mistaken for original execution evidence.

The compact results table is an index into those artifacts, not a substitute for them. The right summary is: directed and architectural tests found real failures; two random programs completed with a weak endpoint oracle; formal setup and abstract reachability progressed, but architectural correctness remains unproved.

Coverage and legacy RISCOF

I attempted the ACT4 functional-coverage flow, which consumes retirement traces and samples coverpoints. It did not complete: Sail trace generation ran long and consumed disk, and the partial coverage tree was removed. No functional-coverage percentage is claimed here.

I also wrote legacy RISCOF/ISAC adapters, but the installed RISCOF/ISAC combination failed on an import (from riscv_isac.isac import preprocessing), so no legacy coverage run is claimed. ACT4 is the current architectural-test flow; legacy RISCOF results would not be presented as certification even if they had completed.

Isolating one failure

The useful unit of debugging is the smallest program that still shows the difference between reference and RTL.

The M high-half probe is a concrete example of the isolation method:

  1. The C source is small and self-checking.
  2. QEMU produces the expected architectural result.
  3. RTL produces zero for the high-half multiply results.
  4. objdump shows mulhu, mulh, and mulhsu in the failing binary.
  5. The compiler is emitting legal M instructions for -march=rv32im.
  6. The RTL result identifies an implementation-subset issue, not a linker or startup issue.

I constrained the benchmark build to RV32I, kept the failing probe as a directed regression, and did not claim full M support from these runs.

The ACT4 store failures are a different kind of isolation problem. The earlier signature-match report and the observed HALT_FAIL suggest inspecting the signature-update count, trap-offset canary, and final-offset check. Because the reference dumps are no longer available, this remains a hypothesis to reproduce, not an exclusion of store-path defects.

A failure is only useful if someone else can reproduce it, and in a flow with this many inputs it is easy to lose track of which combination failed. The minimum set that pins a run down is:

source or generator seed
compiler version and flags
linker script
ELF SHA-256
instruction and data hex SHA-256
RTL source snapshot
testbench parameters
cycle limit
observed result, exit code, trap count, final PC

The two hashes are what make this practical. A SHA-256 hash is a fingerprint of a file’s bytes: if the ELF hash matches, the exact same image was run, and if the hex hashes match, the exact same memory contents were loaded. That removes the most common source of confusion in this flow, which is a rebuilt image or a stale hex file quietly changing what was measured.

Retirement-aligned comparison

Retirement is the point where an instruction’s architectural effect is committed. In a dual-issue core, two instructions can retire in one cycle, and the younger instruction may be in slot 1. A trace comparison therefore has to follow retirement order, not the order in which instructions were fetched.

The bring-up run compares one result word and the exit code, and reports counters. It does not compare the complete architectural state. The next verification layer is retirement-aligned trace comparison. The core exposes retirement-valid signals:

uut.core.mem_wb_valid0
uut.core.mem_wb_valid1

A useful trace hook needs retirement-aligned metadata, not just a writeback-valid pulse. The current fetch PC is not the PC of the instruction retiring several stages later, and slot 1’s retired PC cannot safely be inferred as the current debug PC plus four after stalls or redirects. The intended record is:

retirement order, channel, retired PC, instruction,
source register addresses/values, destination register/value,
next PC, memory address/masks/data, trap information

This is a trace schema, not a completed simulation exporter. The external RVFI monitor is the starting point, but its alignment must be validated before its records can serve as a differential oracle.

For dual-issue comparison, the reference trace has to be consumed in retirement order, not fetch order. Slot 1 is younger than slot 0 in the same issue pair. Traps need special handling: the reference and RTL agree on the trap cause, mepc, and the first retired instruction after mret only if the trap entry/exit path is transparent. The directed trap-pair tests checked that transparency by comparing trap programs with reference twins that replace ecall with nop and leave the timer disarmed.

Spike was built and used in this run for exit-code differential comparison on riscv-tests and the generated riscv-dv programs. The full serialized retirement-trace exporter and per-instruction trace comparison are not completed here. The RVFI monitor exists for the formal wrapper, but the coverage flow that would consume a serialized retirement trace did not complete. That is the next layer, not a claim made by these numbers.

Memory ordering on this platform

After the basic run works, the next software-visible question is ordering: when does a store become visible to a device, when can an interrupt observe shared state, and when does newly written code become executable? On this small platform the answers are simpler than on a cached multicore system, but they still have to be stated.

Memory ordering is a software/RTL contract. The compiler, the core, and the device decode each have a role.

What volatile does and does not do

The compiler is allowed to assume that a program’s memory operations behave as the C standard describes: a variable that is written and never read can be deleted, two reads of the same variable with no write between them can be collapsed into one, and a loop that only touches local state can be rearranged. Those assumptions are wrong for a device register. A status register changes without the program writing it, and a console register does something every time it is written, even if the program never reads it back.

volatile tells the compiler that accesses to the object are observable and must follow the implementation’s volatile-access rules. It does not universally guarantee one machine instruction per C expression: width, alignment, and the compiler matter. For these aligned 32-bit MMIO helpers, the intended access is one lw or sw, which should be confirmed in the disassembly. It does not provide:

  • atomicity for multiword state,
  • a hardware memory barrier,
  • interrupt mutual exclusion,
  • ordering between ordinary memory and a device on a system with caches or write buffers,
  • multicore synchronization.

A compiler barrier is a third, separate tool. An empty asm statement with a "memory" clobber tells the compiler not to move memory operations across that point, but it emits no hardware instruction. A fence instruction orders hardware memory accesses. fence.i synchronizes the instruction stream after code modification. On this small RTL target the hardware effects may be trivial, but the distinctions still matter when the same code is read as a platform contract.

The modeled platform has one hart, no cache or write buffer, and ideal memory responses. That removes several sources of delayed visibility, but does not prove every ordering case correct. Same-cycle slot interactions, load/store aliasing, byte masks, and MMIO side effects still need checks. In the data-memory write logic, slot 1 has priority for overlapping writes after slot 0. The failed store tests and incomplete formal checks mean this structural observation must not be promoted to a complete memory-order guarantee.

MMIO access pattern

I used 32-bit volatile accesses in the MMIO helpers:

#define MMIO_PUTCHAR (*(volatile uint32_t *)0x10000010u)
#define MMIO_EXIT    (*(volatile uint32_t *)0x10000014u)

static inline void plat_putc(char c)
{
    MMIO_PUTCHAR = (uint8_t)c;
}

static inline void plat_exit(uint32_t code)
{
    MMIO_EXIT = code;
    for (;;) __asm__ volatile ("nop");
}

The exit store is volatile, so the compiler cannot remove it. The testbench stops when the MMIO exit latch is written.

Reading a 64-bit timer on RV32

The timer is 64 bits wide but the bus and registers are 32 bits, so reading it takes two loads. That creates a race that has nothing to do with the compiler: if the low word is read just before it wraps, and the high word just after, the two halves belong to different timer values and the assembled 64-bit number is wrong by nearly four billion ticks.

The standard fix is to read high, read low, read high again, and retry if the high word changed:

uint64_t plat_mtime64(void)
{
    uint32_t hi0, lo, hi1;
    do {
        hi0 = MMIO_MTIME_HI;
        lo  = MMIO_MTIME_LO;
        hi1 = MMIO_MTIME_HI;
    } while (hi0 != hi1);
    return ((uint64_t)hi1 << 32) | lo;
}

If both high reads agree, no wrap happened between them and the low word belongs with them. If they differ, the loop repeats. This is the pattern the RISC-V privileged specification recommends for RV32, and it is the reason plat_mtime64 looks more complicated than two loads.

Interrupt-shared data

An interrupt handler is a second thread of control that can start between any two instructions of the main program. volatile guarantees that the main program re-reads a shared variable rather than using a cached copy, but it says nothing about whether a group of updates is seen together.

For a simple single-writer value published in one naturally aligned 32-bit store and sampled in one load, volatile access can be sufficient under this bare-metal compiler/platform contract. It is not enough for a read-modify-write such as counter++ when both contexts can update the counter. Nor does it make two related words atomic: the handler can run between the two stores, and a reader can observe one updated and one stale. A critical section, a region where the interrupt is temporarily disabled, is what makes the group atomic.

The bare-metal pattern is:

volatile uint32_t tick_count;
volatile uint32_t event_count;

void timer_handler(void)
{
    tick_count++;
    event_count++;
}

uint32_t read_pair(uint32_t *ticks, uint32_t *events)
{
    uint32_t mstatus;
    __asm__ volatile ("csrrci %0, mstatus, 8" : "=r"(mstatus) :: "memory");
    *ticks = tick_count;
    *events = event_count;
    __asm__ volatile ("csrs mstatus, %0" :: "r"(mstatus & 8) : "memory");
    return *ticks;
}

The two inline assembly statements are doing the disabling and restoring. csrrci %0, mstatus, 8 is the CSR read-and-clear-immediate instruction: it reads mstatus into the output register and simultaneously clears the bits in the immediate, here bit 3, which is MIE, the global machine interrupt enable. So one instruction both saves the previous state and disables interrupts. After the two words are read, csrs mstatus, %0 sets back only the MIE bit if it was previously set, which restores the caller’s state rather than blindly enabling interrupts that may have been off. The "memory" clobber on both statements is the compiler barrier that keeps the surrounding loads from being moved across them.

With an RTOS on top, the same problem is normally handled through its critical-section primitives, which do exactly this save-disable-restore sequence with nesting support, rather than by hand at each site.

fence, fence.i, and code loading

RISC-V’s memory model permits some observable reorderings unless software requests the required ordering. fence names predecessor and successor access classes: r for ordinary reads, w for ordinary writes, i for device input, and o for device output. For example, fence w,o orders earlier memory writes before later device output. A compiler barrier and a hardware fence solve different problems; inline assembly generally needs a "memory" clobber as well.

fence.i, from Zifencei, synchronizes earlier stores with subsequent instruction fetches on the executing hart. An instruction cache is not the only concern: prefetched or already decoded instructions can also be stale. Therefore, “no instruction cache” is not sufficient evidence that a no-op implementation is correct.

The conceptual code-loading sequence on a platform with data-writable executable memory is:

for (unsigned i = 0; i < n; ++i)
    dest[i] = src[i];

__asm__ volatile ("fence.i" ::: "memory");

((void (*)(void))dest)();

This is not a tested capability of the split-memory target. Ordinary stores here do not automatically write the instruction array, and the adapted upstream fence_i test did not build. Including zifencei in a compiler target permits the instruction; it does not validate self-modifying code on the hardware.

If a future platform adds DMA, meaning a device reads or writes memory independently of the core, a coherent-memory doorbell pattern might be:

buffer[0] = payload;
buffer[1] = length;
__asm__ volatile ("fence w,o" ::: "memory");
*doorbell = 1;  /* doorbell is a volatile MMIO pointer */

For a device whose documented completion flag means its payload writes are complete, the read-side pattern might be:

while (!(*status_reg & DONE)) /* status_reg is a volatile MMIO pointer */
    ;
__asm__ volatile ("fence i,r" ::: "memory");
consume(buffer);

These are ordering examples, not measured programs from this work. Correctness also depends on the device contract, the interconnect, and any required cache maintenance. Fences alone do not make a non-coherent DMA buffer coherent.

The current platform has no DMA or caches, and no fence-performance or complete fence-semantics result is claimed. Fences also do not make a read-modify-write atomic. Without the A extension, C11 atomic operations may use supported load/store sequences or runtime helpers, depending on the operation and toolchain; their guarantees must come from a correct implementation, not from volatile or an assumed fallback. The interrupt critical section above is the explicit mechanism used for this single-hart example.

Compiler optimization sweep on the measured workload

With the baseline workload passing its result check, I varied compiler settings one at a time. The workload and expected result stayed fixed, so a change in cycles or instruction count could be attributed to code generation rather than to a different program.

The optimization levels are not a single dial from slow to fast. Each selects a different set of transformations:

FlagWhat it asks the compiler for
-O0Almost no optimization. Fastest to compile, most faithful to the source structure, largest and slowest code
-OgOptimizations that preserve a sensible debugging experience
-O1Basic optimizations: dead code removal, simple register allocation, jump simplification
-O2The usual release level: inlining, common subexpression elimination, loop optimizations, instruction scheduling, without transformations that greatly inflate code
-O3Everything in -O2 plus aggressive loop transformations and more inlining, trading code size for speed
-OsLike -O2 but with size-inflating transformations disabled
-OzSize pushed further than -Os, accepting more speed loss
-Ofast-O3 plus transformations that relax strict standards conformance, mostly around floating point
-fltoLink-time optimization: the compiler keeps intermediate representations in the object files and optimizes across file boundaries at link time

I built the same workload at each of these levels and ran every image under both QEMU and RTL. All RTL runs produced 0x1b93f20b, exit code 0, and zero traps, so every row passed the same observed-result check. The cycle differences describe these builds and this input, not proof of correctness for all inputs.

Optimizationsize -B text bytesQEMURTL cyclesInstretCPI
-O02,252PASS199,146211,7940.940282
-Og1,672PASS84,734122,5200.691593
-O11,544PASS56,64489,8810.630211
-O21,544PASS56,65389,6760.631752
-O31,780PASS44,26672,1580.613459
-Os1,532PASS55,77490,8270.614069
-Oz1,532PASS55,77490,8270.614069
-Ofast1,780PASS44,26672,1580.613459
-O2 -flto1,532PASS56,63089,6520.631665

The size column is the Berkeley size -B total for code plus read-only data, not the .text section alone.

Reading the table, the first thing to notice is the size of the -O0 penalty: 199,146 cycles against 56,653 at -O2, roughly 3.5 times slower, because every variable lives in memory and every operation is a load, an arithmetic instruction, and a store.

-O3 and -Ofast produce a larger optimized image at 1,780 bytes but the fewest cycles at 44,266, because they cut the retired instruction count to 72,158 from 89,676. On this workload the extra inlining and loop work pays for itself. -Ofast matching -O3 exactly is expected: the workload is all integer arithmetic, so the floating-point relaxations that distinguish -Ofast have nothing to act on.

-Os and -Oz produce the smallest image at 1,532 bytes and, slightly surprisingly, fewer cycles than -O2 while retiring more instructions. That combination is only possible because CPI changed: 0.614 against 0.632. The lower average CPI is consistent with a more favorable instruction mix, pairing, or stall pattern; without a stall/pairing breakdown, the counts alone do not isolate the cause. It is a small effect on one workload, not a general claim that -Os is faster.

-O2 -flto shaves 12 bytes and 23 cycles, which is what cross-file optimization can do when the program is three small files and most of the work is already inside one function.

Every CPI in the table is below 1, which is the dual-issue effect described earlier, not a measurement error.

These numbers are workload-specific. They are not CoreMark scores.

The table also separates static and dynamic effects. The size -B text total is a static property of the linked image. Cycles and retired instructions are dynamic properties of this particular input and execution path. A smaller image can be slower, and a larger image can be faster, because optimization changes both instruction count and the mix of instructions the pipeline sees.

ISA string sweep and extension probes

The next sweep varied the ISA string instead of the optimization level. This is useful even when the RTL does not implement every extension, because it shows what the compiler would emit if the hardware supported it and keeps unsupported builds out of the RTL results.

The extensions in this sweep, in the order they appear:

  • c, compressed instructions, the 16-bit encodings described earlier.
  • zba, address generation: sh1add, sh2add, sh3add compute (a << n) + b in one instruction, which is exactly the shape of an array index calculation.
  • zbb, basic bit manipulation: clz, ctz, cpop for leading zeros, trailing zeros, and population count, plus rotates, minimum and maximum, and sign/zero extension.
  • zbc, carry-less multiply, clmul and clmulh, used mainly for CRC and cryptographic polynomial arithmetic.

I compiled the same workload for each ISA string. QEMU accepted all of them because it implements these extensions. I ran RTL only for the RV32I build and the checked workload-specific use of M instructions. That is not a claim of complete RV32M support; the unsupported compressed and bit-manipulation builds remain “not run”.

ISA stringsize -B text bytesQEMURTL cyclesInstretCPI
rv32i_zicsr_zifencei1,572PASS57,93691,3350.634324
rv32im_zicsr_zifencei1,544PASS56,65389,6760.631752
rv32imc_zicsr_zifencei1,268PASSnot runnot runnot run
rv32im_zba_zicsr_zifencei1,544PASSnot runnot runnot run
rv32im_zbb_zicsr_zifencei961PASSnot runnot runnot run
rv32im_zba_zbb_zicsr_zifencei961PASSnot runnot runnot run
rv32im_zbc_zicsr_zifencei1,544PASSnot runnot runnot run

The compressed and bit-manipulation builds are compiler probes. They show what GCC can do when the target supports those extensions. They are not RTL performance results for this core. QEMU passing one of those builds only says the reference model can execute the emitted ISA. It does not say this RTL implements that ISA.

Two rows in that table are worth comparing directly. Adding m to rv32i shrank the code from 1,572 to 1,544 bytes and the run from 57,936 to 56,653 cycles, because the multiply in multiply_mix became a mul instruction instead of a call to __mulsi3. Adding c on top shrank it to 1,268 bytes, about 18 percent, which is the typical compressed-instruction saving. Adding zbb took it to 961 bytes, because the three bit-count operations in scan_words each collapsed from a libgcc call to a single instruction.

Those savings are real, and they are exactly the argument for implementing the extensions. They are not results this core achieved.

To see the effect per operation rather than per program, I compiled a set of one-operation functions under each ISA and measured their body sizes. The six useful comparisons are shown below. The retained source also contains a p_ctz probe using x | 1u, which forces the trailing-zero count to zero; I exclude that case rather than presenting it as a meaningful general CTZ comparison. No corrected probe was rerun for this editorial review.

__attribute__((noinline)) uint32_t p_clz(uint32_t x) { return (uint32_t)__builtin_clz(x | 1u); }
__attribute__((noinline)) uint32_t p_popcount(uint32_t x) { return (uint32_t)__builtin_popcount(x); }
__attribute__((noinline)) uint32_t p_rotl(uint32_t x, uint32_t n) { return (x << (n & 31u)) | (x >> ((32u - (n & 31u)) & 31u)); }
__attribute__((noinline)) uint32_t p_min(uint32_t a, uint32_t b) { return a < b ? a : b; }
__attribute__((noinline)) uint32_t p_sh1add(uint32_t a, uint32_t b) { return (a << 1) + b; }
__attribute__((noinline)) uint32_t p_mul(uint32_t a, uint32_t b) { return a * b; }

For each ISA build, the function body sizes were read from the symbol table. They include the wrapper instructions and call overhead, but exclude the body of any separately linked library helper.

ISAclzpopcountrotlminsh1addmul
rv32i322824201228
rv32im32282420128
rv32imc2218181266
rv32im_zba3228242088
rv32im_zbb12888128
rv32im_zba_zbb1288888
rv32im_zbc32282420128

The operations are: clz count leading zeros, popcount count set bits, rotl rotate left, min minimum of two values, sh1add shift-left-by-one-and-add, and mul a 32-bit multiply. Sizes are in bytes; without compressed instructions, a 32-byte function contains eight instructions. The compressed rows do not have that fixed bytes-per-instruction relationship.

The pattern in the rows: on plain rv32i, clz and popcount cost 28 to 32 bytes because each is a call to a libgcc routine plus the surrounding prologue. mul costs 28 bytes on rv32i and 8 bytes with m, which is the call replaced by one instruction and a return. With zbb, clz drops to 12 bytes, popcount to 8, and min and rotl to 8, because each becomes a single instruction. zba alone only changes sh1add, from 12 bytes to 8. zbc changes nothing here because the probe has no carry-less multiply in it.

The Zbb version of the leading-zero function is three instructions:

00000000 <p_clz>:
   0:   00156513        ori     a0,a0,1
   4:   60051513        clz     a0,a0
   8:   00008067        ret

The ori a0,a0,1 is there because the C source used __builtin_clz(x | 1). __builtin_clz is undefined for an input of zero, so forcing the low bit set makes the call defined for every input; the compiler keeps that guarantee in the emitted code.

The Zba version of shift-and-add is two:

00000088 <p_sh1add>:
  88:   20b52533        sh1add  a0,a0,a1
  8c:   00008067        ret

The current RTL target does not implement C, Zba, Zbb, or Zbc. Those rows are useful for compiler comparison and for planning a future ISA extension, not for claiming current hardware support.

CoreMark: port, run rules, and measured results

CoreMark is a benchmark from EEMBC, the Embedded Microprocessor Benchmark Consortium, written specifically for embedded cores. It exists because Dhrystone, the older standard, is small enough that a compiler can optimize most of it away and its result depends heavily on the string routines supplied. CoreMark’s workload is harder to defeat: it processes a linked list, does matrix operations on small matrices, and runs a state machine over an input string, and it computes CRCs over its own results so that a wrong answer is detected rather than merely fast.

The unit of work is one iteration of all three kernels. The score is iterations per second, normally reported per MHz of clock so that cores at different frequencies can be compared.

The source is split into two parts, and the split is the whole basis of a valid run. The algorithm files, core_main.c, core_list_join.c, core_matrix.c, core_state.c, and core_util.c, must not be modified. The port layer is the part a platform is expected to supply: how to read a clock, how to print, where the data buffer lives, and what seed values to use. Changing the algorithm invalidates the score; changing the port is the intended way to run it on new hardware.

The core tree already contained CoreMark source files under scripts/coremark/. Before using them I checked that they were unmodified, by comparing their MD5 hashes against the upstream EEMBC source. MD5 is used here only as a file-identity check, which is what the CoreMark rules ask for, not as a security hash:

FileMD5
core_main.c4a9e6dadce1ac3866381021fbe843fc9
core_list_join.c9007fe7861b60ee6f210d156b62974c8
core_matrix.c5fa21a0f7c3964167c9691db531ca652
core_state.cfb49e7605c125306575a83f14f5798ac
core_util.c45540ba2145adea1ec7ea2c72a1fbbcb

The existing port layer had a stub clock: a function that returned a constant, which means the benchmark could run but could not measure anything. A score computed from it would be meaningless. I replaced it with a read of the MMIO timer’s low word:

CORETIMETYPE barebones_clock(void)
{
    return MMIO_MTIME_LO;
}

The low word alone is enough here because it wraps after about four billion cycles and the timed region is around a hundred million. A longer run would need the 64-bit read described earlier.

Port settings, with what each one controls:

SettingValue
TOTAL_DATA_SIZE2000 bytes
MEM_METHODMEM_STATIC
SEED_METHODSEED_VOLATILE
MULTITHREAD1
HAS_FLOAT0
CLOCKS_PER_SEC10,000,000
Timer sourcemtime low word, core cycles
Reporting clock convention10 MHz
Iterations160
Compilerriscv64-unknown-elf-gcc 14.2.0
ISArv32i_zicsr_zifencei
ABIilp32
Optimization-O2
Other flags-mcmodel=medlow -mno-relax -ffreestanding -fno-builtin -fno-common

TOTAL_DATA_SIZE is the size of the working buffer the three kernels share. It is fixed at 2,000 bytes, the standard “2K” configuration, so the working set fits in this platform’s data memory and the result is comparable to other 2K runs.

MEM_METHOD set to MEM_STATIC means the buffer is a static array rather than heap-allocated. There is no malloc in this program, so the alternative would not link.

SEED_METHOD set to SEED_VOLATILE means the seed values that drive the kernels come from volatile variables supplied by the port. This matters: if the seeds were compile-time constants, the compiler could in principle constant-fold parts of the computation. Volatile seeds force the work to happen at run time.

MULTITHREAD is 1 because there is one hart, and HAS_FLOAT is 0 because there is no floating-point hardware and the benchmark’s integer path is what is being measured.

CLOCKS_PER_SEC is the number of timer ticks the port reports per second, which is how CoreMark converts its raw tick count into a time. The timer counts core cycles, so this value is the clock-rate convention: 10,000,000 ticks reported as one second.

The 10 MHz value is a reporting convention for simulated time. The timer counts core cycles. CoreMark/MHz is computed from iterations and timer ticks, so it does not depend on an FPGA clock frequency.

The port layer is the part that connects the fixed benchmark to this platform. CoreMark asks the port for time, output, seeds, and memory. I supplied those through MMIO mtime, MMIO putchar, volatile seed values, and static buffers. The algorithm files themselves stayed unchanged. I used 160 iterations because that made the timed portion exceed the 10-second reporting threshold under the stated timer convention.

I built and ran it with:

MODE=performance OPT=-O2 ITERATIONS=160 \
  MARCH=rv32i_zicsr_zifencei \
  OUT=software/coremark/build_perf \
  bash software/coremark/build.sh

./obj_dir_bench/Vtb_rtos

MODE selects the seed set, ITERATIONS fixes the iteration count rather than letting the benchmark auto-calibrate, and OUT keeps each build in its own directory so the images and their hashes stay separate.

The benchmark needs about a hundred million cycles, which is far past the default limit of the result-checking testbench, so it runs on the longer-running testbench with the limit raised. That limit is a Verilog parameter, so it is set at elaboration time with -G:

verilator --cc --timing -Wno-fatal -O2 \
  +define+RTOS_MEM_64K \
  -GMAX_CYCLES=300000000 -GTRAP_LOG=0 \
  -f tb/tb_rtos.f --top-module tb_rtos \
  --exe --main --Mdir obj_dir_bench -o Vtb_rtos
make -C obj_dir_bench -f Vtb_rtos.mk -j2

-GMAX_CYCLES=300000000 sets the cycle limit to 300 million, comfortably above the run length, and -GTRAP_LOG=0 turns off per-trap printing, which would otherwise produce a large log for no benefit since these runs take no traps.

CoreMark performance run

The performance run uses the standard performance seed set. Its numbers:

MetricValue
Run typePerformance
Iterations160
Total timer ticks100,926,610
Total RTL cycles100,962,809
Timed-region seconds at 10 MHz10.092661
CoreMark/MHz1.58531
Cycles per iteration, total RTL cycles631,017.56
Traps0
End causeMMIO exit store
ValidationCorrect operation validated

The raw output, which is what the benchmark prints over the MMIO console:

2K performance run parameters for coremark.
CoreMark Size    : 666
Total ticks      : 100926610
Total time (secs): 10
Iterations/Sec   : 16
Iterations       : 160
Compiler version : riscv64-unknown-elf-gcc 14.2.0
Compiler flags   : -O2 rv32i_zicsr_zifencei ilp32 performance iter=160
Memory location  : STATIC
seedcrc          : 0xe9f5
[0]crclist       : 0xe714
[0]crcmatrix     : 0x1fd7
[0]crcstate      : 0x8e3a
[0]crcfinal      : 0xbd59
Correct operation validated. See README.md for run and reporting rules.
COREMARK_WRAPPER_DONE

Reading the fields: CoreMark Size: 666 is the per-algorithm working-buffer size: the 2,000-byte allocation divided among three enabled algorithms, rounded down. It is a size, not a checksum. Total ticks is the raw timer difference around the timed region. Iterations/Sec: 16 is 160 iterations divided by the 10 reported seconds, integer-rounded. seedcrc is a checksum of the seed values themselves, confirming the intended seed set was used. The four lines after it report checksums: crclist, crcmatrix, and crcstate describe the three kernels, and crcfinal is the accumulated run checksum. CoreMark compares its validation CRCs against expected values for the recognized seeds; Correct operation validated reports that those checks passed. A run that prints a score without that line is not a valid result.

The score itself is not printed by this port because the port’s printf shim is minimal; it is computed from the printed ticks and iterations using the formula given earlier: 160 iterations over 100,926,610 ticks is 1.58531 iterations per million cycles, which is the CoreMark/MHz figure in the table.

ELF size:

   text    data     bss     dec     hex filename
  16141      24    2020   18185    4709 coremark_performance.elf

SHA-256 of that ELF, so the exact image can be identified later:

ddef75447bec0e4ec55c2c5151c21a428bf980584b06120e3ec2112219ae5af2

CoreMark validation run

CoreMark defines a second seed set for validation. It exercises the same kernels with different inputs, and the expected CRCs differ, so it catches a class of errors that a single seed set can miss, such as a data-dependent path that happens to work for the performance seeds. It is reported separately and is not the performance score.

MetricValue
Run typeValidation
Iterations160
Total timer ticks108,624,022
Total RTL cycles108,660,082
Timed-region seconds at 10 MHz10.8624022
CoreMark/MHz1.47297
Cycles per iteration, total RTL cycles679,125.51
Traps0
End causeMMIO exit store
ValidationCorrect operation validated

The benchmark output was:

2K validation run parameters for coremark.
CoreMark Size    : 666
Total ticks      : 108624022
Total time (secs): 10
Iterations/Sec   : 16
Iterations       : 160
Compiler version : riscv64-unknown-elf-gcc 14.2.0
Compiler flags   : -O2 rv32i_zicsr_zifencei ilp32 validation iter=160
Memory location  : STATIC
seedcrc          : 0x18f2
[0]crclist       : 0xe3c1
[0]crcmatrix     : 0x0747
[0]crcstate      : 0x8d84
[0]crcfinal      : 0xd307
Correct operation validated. See README.md for run and reporting rules.
COREMARK_WRAPPER_DONE

ELF size:

   text    data     bss     dec     hex filename
  16141      32    2012   18185    4709 coremark_validation.elf

SHA-256:

85a94518de6a2b74b3b90ff230468e6763be8bd183685b1b993838a083a7595e

The two runs also differ slightly in size, 24 versus 32 bytes of initialized data and 2,020 versus 2,012 bytes of BSS, because the seed tables differ. Both report the same 16,141-byte size -B text total. Equal size alone does not prove byte-identical code; it establishes only that this reported size did not change.

CoreMark conditions

CoreMark publishes run rules so that scores from different vendors mean the same thing. The ones that apply to a bare-metal run like this:

  • The timed portion ran for at least 10 seconds under the stated timer convention.
  • Performance and validation seed sets were both run.
  • The CoreMark algorithm source files were not modified.
  • All CoreMark source files used the same compiler flags.
  • Platform-specific code was confined to the port layer, startup, linker script, and printf/UART shim.
  • The result CRCs were checked by CoreMark itself.

A separate platform limit determines how this number should be interpreted. Both memories in this simulation respond in the same cycle, with no wait states, no cache misses, and no bus contention. Real hardware with flash, SDRAM, or a shared bus will be slower, sometimes much slower. The score characterizes the core with ideal memory, not a system. It is also a simulation result, so it carries no claim about achievable clock frequency; that requires synthesis and timing analysis on a real target.

Dhrystone: port and measured result

Dhrystone is a 1984 integer benchmark, and it is still quoted in core datasheets, which is the main reason to run it. Its workload is procedure calls, integer arithmetic, pointer chasing, record manipulation, and string operations, deliberately without floating point or system calls. One iteration is called one Dhrystone, and the rate is normalized against the VAX 11/780 to produce DMIPS as described earlier.

Its weaknesses are worth stating before the number. It is small enough to fit in a tiny cache, its string operations mean the result depends heavily on whose strcpy and strcmp are linked, and modern compilers can inline much of it. Two cores with the same DMIPS figure can behave very differently on real code. It is reported here because it is a standard porting check with a self-checking output, not because it characterizes this pipeline well.

The source has no timing or printing of its own; those come from the platform. I took the Dhrystone source from the riscv-tests benchmark directory and supplied:

  • dhry_mtime(), the timing function, reading the MMIO mtime low word,
  • HZ = 10000000, the ticks-per-second convention, matching the CoreMark port,
  • Too_Small_Time = 10 * HZ, the minimum run duration below which the benchmark warns that the measurement is too short to be reliable,
  • a small printf and debug_printf implemented over the MMIO putchar register, since there is no libc,
  • strcpy, strcmp, memcpy, and memset, written as simple byte loops,
  • the same startup file and linker script as the bring-up workload.

I widened the reporting arithmetic to long long before division to avoid 32-bit overflow in HZ * Number_Of_Runs. The measurement loop itself was not changed.

The overflow is easy to miss on RV32. With HZ = 10000000 and Number_Of_Runs = 220000, the product is 2,200,000,000,000, far beyond a 32-bit long. If that product wraps before division, the benchmark can still print final variable checks and still report a nonsensical rate. The final variable checks validate the benchmark state; the widened arithmetic validates the reporting path.

I built and ran Dhrystone with:

RUNS=220000 OPT=-O2 \
  MARCH=rv32i_zicsr_zifencei \
  OUT=software/dhrystone/build \
  bash software/dhrystone/build.sh

./obj_dir_bench/Vtb_rtos

Measured result:

MetricValue
Dhrystone versionC, Version 2.2
Runs220,000
Total RTL cycles104,578,698
Microseconds per run, printed47
Dhrystones per second at 10 MHz21,052
DMIPS11.9818
DMIPS/MHz1.19818
Total cycles per run, including overhead475.36
Traps0
End causeMMIO exit store

Two notes on the table. Microseconds for one run: 47 is the benchmark’s own integer-rounded figure; the more precise value from the cycle count is 475.36 cycles per run, which at the 10 MHz convention is 47.5 microseconds. And the total cycles include the printing and the final variable checks, so the per-run figure is slightly pessimistic, by the same reasoning as the bring-up workload’s whole-run versus kernel-region difference.

The full output, including the self-check:

Dhrystone Benchmark, Version C, Version 2.2
Program compiled without 'register' attribute
Using mtime @ 10 MHz reporting convention, HZ=10000000

Trying 220000 runs through Dhrystone:
Final values of the variables used in the benchmark:

Int_Glob:            5
        should be:   5
Bool_Glob:           1
        should be:   1
Ch_1_Glob:           A
        should be:   A
Ch_2_Glob:           B
        should be:   B
Arr_1_Glob[8]:       7
        should be:   7
Arr_2_Glob[8][7]:    220010
        should be:   Number_Of_Runs + 10
Ptr_Glob->
  Ptr_Comp:          -2147418336
        should be:   (implementation-dependent)
  Discr:             0
        should be:   0
  Enum_Comp:         2
        should be:   2
  Int_Comp:          17
        should be:   17
  Str_Comp:          DHRYSTONE PROGRAM, SOME STRING
        should be:   DHRYSTONE PROGRAM, SOME STRING
Next_Ptr_Glob->
  Ptr_Comp:          -2147418336
        should be:   (implementation-dependent), same as above
  Discr:             0
        should be:   0
  Enum_Comp:         1
        should be:   1
  Int_Comp:          18
        should be:   18
  Str_Comp:          DHRYSTONE PROGRAM, SOME STRING
        should be:   DHRYSTONE PROGRAM, SOME STRING
Int_1_Loc:           5
        should be:   5
Int_2_Loc:           13
        should be:   13
Int_3_Loc:           7
        should be:   7
Enum_Loc:            1
        should be:   1
Str_1_Loc:           DHRYSTONE PROGRAM, 1'ST STRING
        should be:   DHRYSTONE PROGRAM, 1'ST STRING
Str_2_Loc:           DHRYSTONE PROGRAM, 2'ND STRING
        should be:   DHRYSTONE PROGRAM, 2'ND STRING

Microseconds for one run through Dhrystone: 47
Dhrystones per Second:                      21052

The check works by comparing each global and local variable against the value it must have after the given number of iterations. Arr_2_Glob[8][7] should equal Number_Of_Runs + 10, which is 220,010 here, so it also confirms that the loop really ran the requested number of times. Ptr_Comp is a pointer printed as a signed integer and is explicitly implementation-dependent, so its value is not a check. Everything else matched.

The line Program compiled without 'register' attribute is the benchmark reporting its own build configuration: the original source allows variables to be declared register, and this build does not use that hint.

ELF size:

   text    data     bss     dec     hex filename
   8164       0   10248   18412    47ec dhrystone.elf

The 10,248 bytes of BSS are mostly Dhrystone’s two global arrays, which is why its data footprint is larger than the bring-up workload’s despite having a smaller code/read-only-data footprint than CoreMark.

SHA-256:

4bd9627042854c2273bc069002519254712b59555797a7583c73344b01afde35

Dhrystone is sensitive to string routines, inlining decisions, and reporting arithmetic. The result above is tied to this port, compiler, flags, and memory system.

Commands behind the runs

The commands below are the sequence behind the tables. They are not a separate project; they are the same source files, linker scripts, startup files, loaders, and testbenches described above. They assume the current directory is the core directory and that the toolchain packages above are installed.

The order is the same as the narrative: build the result-checking testbench, build and run the main workload, compare with QEMU, build the long-running benchmark testbench, run CoreMark and Dhrystone, then run the directed instruction and trap-pair suites.

I kept the source tree for these runs next to the core’s existing software directory:

software/bringup/
software/coremark/
software/dhrystone/
software/run_post_benchmarks.sh
tb/tb_bringup.v
tb/tb_bringup.f

Every number above came from a specific binary, and these are the SHA-256 hashes of those binaries and of the two memory images loaded for the main workload. They are in sha256sum format, so sha256sum -c can test whether a rebuilt file matches the historical bytes. A rebuild with changed tools, paths, or restored inputs may legitimately differ; the hashes are identifiers, not a guarantee of reproducible builds:

7890dcf3ea2ab5e9b6a01ba689edc63c3bf7d8d32cccabfb11ad708dfd337ee8  workload_rtl.elf
d5d9ef50b224e3bee6f12bd911e3a463703aad8da61ceb2509593d9ae51d5222  workload_qemu.elf
d6d70e3f69d9d2351db9cbff3da21c83a05733f3f3fb04aa8cf34f74ea9bcda4  inst_mem.hex
4016533501f1d446be934a575a5e6a8cc89d62d583f4ad00bf8f85e491b9b1ec  data_mem.hex
ddef75447bec0e4ec55c2c5151c21a428bf980584b06120e3ec2112219ae5af2  coremark_performance.elf
85a94518de6a2b74b3b90ff230468e6763be8bd183685b1b993838a083a7595e  coremark_validation.elf
4bd9627042854c2273bc069002519254712b59555797a7583c73344b01afde35  dhrystone.elf

The commands I used from the core directory were:

# Result-checking bring-up testbench.
verilator --cc --timing -Wno-fatal -O2 \
  +define+RTOS_MEM_64K \
  -f tb/tb_bringup.f --top-module tb_bringup \
  --exe --main --Mdir obj_dir_bringup -o Vtb_bringup
make -C obj_dir_bringup -f Vtb_bringup.mk -j2

# Main workload.
EXPECTED_RESULT=0x1b93f20b bash software/bringup/build.sh
./obj_dir_bringup/Vtb_bringup +expected=0x1b93f20b

# QEMU reference.
qemu-riscv32 software/bringup/build/workload_qemu.elf

# Long-running benchmark testbench.
verilator --cc --timing -Wno-fatal -O2 \
  +define+RTOS_MEM_64K \
  -GMAX_CYCLES=300000000 -GTRAP_LOG=0 \
  -f tb/tb_rtos.f --top-module tb_rtos \
  --exe --main --Mdir obj_dir_bench -o Vtb_rtos
make -C obj_dir_bench -f Vtb_rtos.mk -j2

# CoreMark performance, CoreMark validation, and Dhrystone.
bash software/run_post_benchmarks.sh

# Directed instruction tests.
python3 scripts/gen_test_hex.py
iverilog -g2005 -f tb/tb_instructions.f -o tb_instructions_sim.vvp
vvp tb_instructions_sim.vvp

# Trap-pair tests.
python3 scripts/rtos/trap_pairs.py --jobs 2

The directed instruction block uses Icarus; the trap-pair runner uses Verilator. gen_test_hex.py generates the directed instruction test images, iverilog -g2005 compiles the Verilog with the 2005 language standard into a .vvp file, and vvp runs it. trap_pairs.py --jobs 2 generates the trap programs and their twins and runs them two at a time.

Two practical notes about this flow. The trap-pair runner writes its hex images into temporary directories, but the benchmark script regenerates hex/inst_mem.hex and hex/data_mem.hex in place for each program. Two RTL flows sharing that directory at the same time will overwrite each other’s images, so I ran them sequentially.

The other note is about parameters. Passing -pvalue+module.PARAM=value to the generated executable did not override the Verilog parameter: it was already baked into the model at elaboration. Simulation runtime arguments cannot change that compiled parameter. The reliable way was -GMAX_CYCLES=300000000 at build time, which is what the command above uses. The bring-up testbench avoids the issue by reading its two settings as plusargs instead of parameters:

+expected=0x1b93f20b
+max_cycles=1000000

That is why one compiled Vtb_bringup binary can check any workload, while the benchmark testbench had to be rebuilt with a larger limit.

Verification commands

The verification adapters live in a separate verification/ tree next to the core. These commands assume generated ELFs, simulator binaries, and framework dependencies have already been restored or built; the basic toolchain install above is not sufficient for every verification flow. The package’s RESTORE.md gives the separate prerequisites. From verification/, the main runners were:

# ACT4 RV32I suite on RTL (ELFs already built by the ACT4 flow).
python3 scripts/run_act4_rtl.py

# Upstream riscv-tests: RTL split ELF plus Spike unified ELF.
python3 scripts/run_riscv_tests.py

# One generated riscv-dv program on RTL.
python3 scripts/run_elf.py \
  adapters/riscv-dv/riscv_rand_instr_test_0.elf \
  results/riscv-dv/riscv_rand_instr_test_0

# Spike reference for the same generated assembly, unified at 0x80000000.
tools/bin/spike --isa=rv32i_zicsr \
  adapters/riscv-dv/spike-ref/riscv_rand_instr_test_0-unified.elf

# Formal check generation and adapter patching (does not modify target RTL).
cd ../riscv-formal/cores/reused-rv32i
python3 ../../checks/genchecks.py
python3 ../../../verification/scripts/patch_formal_checks.py --abstract-predictor
cd checks
sby -f --setup insn_add_ch0.sby
timeout --kill-after=5s 75 sby -f insn_add_ch0.sby

# The 11-job bounded diagnostic campaign (same settings as the results table).
python3 ../../../../verification/scripts/run_formal_campaign.py --timeout 75 \
  --checks cover,insn_add_ch0,insn_add_ch1,insn_lw_ch0,insn_sh_ch0,insn_beq_ch0,reg_ch0,pc_fwd_ch0,unique_ch0,causal_ch0,causal_mem_ch0

The runners write sim.log, result.json, hex images, and a per-suite summary.json under results/. The formal patch script rewrites the generated .sby files to read the core, expose the observation signals, and then read the RVFI wrapper and monitor. The target RTL files are only read, never edited.

Bring-up failure notes

Each of these is a symptom I either hit or checked for, written with the mechanism behind it, because the symptom alone does not identify the layer.

The core times out at PC 0 or spins in a tiny loop. Nothing has executed meaningfully. The first suspects are the image and the reset contract: whether the hex files were regenerated from the ELF that was just built, whether _start is actually linked at address zero, and whether the instruction array size in the testbench matches the size the loader wrote. A core fetching zeros executes whatever 0x00000000 decodes to, repeatedly.

The timer interrupt fires but the tick count is wrong, or it fires continuously. Check the register addresses first, then the order of the two writes that program the 64-bit mtimecmp, then the enable bits mie.MTIE and mstatus.MIE, then TRAPCTRL. A continuously re-firing interrupt is usually the level-sensitive condition described earlier: the handler returned without advancing mtimecmp past mtime.

QEMU passes and the RTL fails on the same source. The disagreement is a debugging lead, not proof that every aspect of the algorithm and reference adapter is right. Check what the two binaries execute and where they read. In order of likelihood: an instruction in the binary that the RTL does not implement, an MMIO address that the QEMU build stubbed out, an unaligned access the RTL does not support, a stale hex file, or a stale Verilator model built with different parameters.

Startup crashes before main, and the disassembly of the linked ELF differs from the disassembly of the object files. Linker relaxation is one candidate. Check whether __global_pointer$ is defined, whether startup loads gp before any global access, and whether the gp load itself is bracketed by .option norelax. A comparison build with -mno-relax helps isolate that candidate; inspect the changed instructions rather than treating symptom disappearance alone as proof.

A benchmark CRC fails. The benchmark is telling you its computation produced a wrong result somewhere in a hundred million cycles. The candidates are the build configuration (data size, seed method, compiler flags differing between files), memory corruption from a stack that grew into the data area, or a data-dependent instruction bug. This is why the directed tests and trap tests come first: they narrow the field before a long run is interpreted.

The cycle count changes between runs of what should be the same program. Compare the ELF hash, the hex hashes, and the testbench parameters. If those match, look for a stale obj_dir or another flow overwriting hex/ concurrently.

The program prints the right result but the testbench reports failure. The printed word is only one observation; a nonzero exit, a missing memory write, or a timeout can still make the run legitimately fail. Verify the expected-value plusarg, the address the testbench reads, the exit code the program wrote, and that the exit store happened at all rather than the run ending on the cycle limit.

A high-half multiply returns zero. This is the confirmed gap in this revision. Check which of mulh, mulhsu, mulhu the compiler emitted, keep the directed probe as a regression, and build for rv32i until the RTL is fixed.

A halfword access corrupts data. Check alignment first, including packed structs and casts from byte pointers, but do not stop there: rv32ui-sh also fails in the adapted instruction suite. The store path and adapter remain debugging candidates; alignment alone is not an established explanation.

Two same-cycle device writes give an unexpected result. Inspect the addressed device: slot 1 wins simultaneous writes to the same timer-compare word, while console characters are emitted in slot order and different compare words can both update. Check the device-specific behavior rather than assuming one global MMIO priority rule.

What this run establishes

The software-to-measurement path was exercised end to end. The verification effort along that path is not complete:

C source
  -> GCC 14.2.0 RV32 compile
  -> ILP32 ABI and libgcc helpers
  -> linker script and startup symbols
  -> ELF32 executable
  -> readelf/nm/objdump inspection
  -> elf2hex instruction and data images
  -> Verilator RTL run from reset PC 0
  -> MMIO UART output
  -> result word and exit-code check
  -> cycle, instret, trap, and timer counters
  -> QEMU functional comparison
  -> ACT4 signature-mode RTL suite
  -> riscv-tests and riscv-dv exit-code comparison against Spike
  -> RVFI setup and abstract retirement cover (safety proofs not completed)
  -> CoreMark and Dhrystone validation

The main workload produced 0x1b93f20b on both QEMU and RTL. The RTL run retired 89,676 instructions in 56,653 cycles, with zero traps. The directed instruction suite passed 31 checks. The trap-pair and timer-arrival suite completed 450 comparison/sweep cases with zero failures. CoreMark performance and validation runs both reported correct operation, and Dhrystone completed 220,000 runs with its final variable checks passing.

The limits are as important as the successful runs:

  • ISA behavior: the compressed and bit-manipulation size savings are compiler measurements, not performance achieved on this RTL. mul, rem, and remu passed the upstream tests, but divide and high-half multiply did not. A benchmark pass does not establish complete RV32I or RV32M support.
  • Architectural suites: ACT4 reached pass on 37 of 39 programs; the two store-related failures remain open. The riscv-tests comparison also shows a halfword-store failure. Those failures are not canceled out by successful application runs.
  • Random and formal checks: two generated programs completed on RTL and Spike, without a retirement-state comparison. Of 11 new formal jobs attempted, the abstract retirement cover passed and ten safety jobs timed out during assumption checking. No completed safety proof is claimed, and abstract reachability is not concrete-core correctness.
  • Coverage: the ACT4 coverage flow and legacy RISCOF/ISAC flow did not complete. There is no coverage percentage, coverage-closure result, or certification claim.
  • Performance: QEMU supplied functional results, not RTL timing. The benchmark scores describe ideal single-cycle memory and a stated 10 MHz reporting convention, not a measured FPGA clock or a real memory system.

The next correctness step is a validated retirement trace against an instruction-set simulator, followed by deeper formal checks with audited assumptions. That remains future work, not a result hidden inside the current pass counts.

What the exercise shows is where the work actually is. The pipeline was already written. Everything that made it run a program and produce a defensible number was in the agreements around it: an address map that the linker, the loader, and the testbench all shared; an ABI that the compiler, the startup assembly, and the trap handlers all honored; an instruction subset that was checked rather than assumed; and a result that was verified against something other than the core itself.