C-Based Verification

Verifying a processor isn't complete without software. This guide covers how to write, compile, and run bare-metal C tests on your RTL.

Why C Verification?

Instruction Randomization (UVM) is great for finding pipeline hazards, but it doesn't prove the processor can run meaningful software.

C Tests prove:

  • Peripheral Integration: Drivers for UART, SPI, GPIO are written in C.
  • Complex Algorithms: Sorting, localized loops, and stack intensive operations.
  • Compliance: Standard benchmarks like CoreMark or Dhrystone are distributed as C source.

The Toolchain Flow


Source (.c)  -->  Compiler (GCC)  -->  Object (.o)  -->  Linker (LD)  -->  ELF Binary
                                                                    |
                                                                    v
                                                                 ObjCopy
                                                                    |
Verilog $readmemh  <--  Hex File (.hex)  <--  Binary (.bin)
                            

Step 1: The Startup Code (crt0.s)

A C program needs a stack. Since you are running on "bare metal" (no OS), you must set up the Stack Pointer (SP) manually in assembly before calling main().


    .section .text.init
    .global _start
_start:
    # 1. Set up the Stack Pointer (Start of Stack RAM)
    la sp, _stack_top
    # 2. Call main()
    call main
    # 3. Halt if main returns (Finite Loop)
    j _finish
                            

Step 2: The Linker Script (link.ld)

The compiler doesn't know where your Instruction Memory (IMEM) or Data Memory (DMEM) is. The Linker Script tells it the memory map.


OUTPUT_ARCH( "riscv" )
ENTRY( _start )
MEMORY
{
  /* Define your memory structure here */
  imem (rx) : ORIGIN = 0x00000000, LENGTH = 4K   /* Instructions */
  dmem (rw) : ORIGIN = 0x00002000, LENGTH = 4K   /* Data/Stack */
}
SECTIONS
{
  .text : {
    *(.text.init)   /* Startup Code First */
    *(.text)        /* Main C Code */
  } > imem
  .data : {
    *(.data)
  } > dmem
  /* Define stack top variable for crt0.s */
  _stack_top = ORIGIN(dmem) + LENGTH(dmem);
}
                            

Step 3: The C Test (test.c)

How does the C code tell the Verilog Testbench if it Passed or Failed? We use a Magic Address.

The Testbench monitors writes to address 0x80000000. Writing `1` means PASS, `0` means FAIL.


// Pointer to the Magic Address logic in Testbench
volatile int* TOHOST_ADDR = (int*) 0x80000000;
void main() {
    int a = 10;
    int b = 20;
    int c = a + b; // ALU Add
    if (c == 30) {
        *TOHOST_ADDR = 1; // PASS
    } else {
        *TOHOST_ADDR = 0; // FAIL
    }
}
                            

Step 4: Compilation

Use the RISC-V GCC cross-compiler to generate the Hex file.

riscv64-unknown-elf-gcc -march=rv32i -mabi=ilp32 -nostdlib -T link.ld crt0.s test.c -o test.elf
riscv64-unknown-elf-objcopy -O verilog test.elf test.hex

Step 5: Verilog Integration

Load the Hex file into your memory and monitor the bus.


module testbench;
    // ... Clock & Reset ...
    // Memory Instance
    reg [31:0] imem [0:1023];
    initial begin
        // Load the compiled C code
        $readmemh("test.hex", imem);
    end
    // Monitor for TOHOST writes
    always @(posedge clk) begin
        if (data_we && data_addr == 32'h8000_0000) begin
            if (data_wdata == 1)
                $display("TEST PASSED!");
            else
                $display("TEST FAILED!");
            $finish;
        end
    end
endmodule