A RISC-V instruction set simulator currently supporting the RISC-V base integer instruction set (RV32I) and the M-extension (multiply/divide).
The simulator executes RV32IM machine code binaries, modelling the fetch/decode/execute pipeline of a single-cycle 32-bit RISC-V core. It implements all RV32I base instructions and all 8 M-extension instructions, with correct handling of spec-defined corner cases such as division by zero, division overflow, and signed/unsigned remainder behaviour.
- Full RV32I base ISA (R, I, S, B, U, J-type instructions)
- M-extension:
MUL,MULH,MULHU,MULHSU,DIV,DIVU,REM,REMU - Harvard memory model with separate instruction and data address spaces
- Little-endian Byte-addressable memory
- EBREAK halt detection
runtoEbreak()execution mode and single-stepStep()interface- Google Test suite with 200+ parameterised tests across all instruction types including edge-cases
A CPU cycle starts by fetching the instruction. The Fetch() function reads the instruction from the instruction memory and writes it to the instruction register. The memory address of the instruction to fetch is held in the program counter.
Next, the 32-bit instruction is decoded by Decode() in order to extract the instruction fields. Firstly, the opcode field is found which determines the instruction type (e.g R-type) that tells us where the rest of the instruction fields are located in the 32-bit instruction. All RISC-V instructions contain the opcode in bits[6:0] of the instruction.
After determining the instruction type, the rest of the fields (e.g rs1, rs2, rd) are extracted and stored in a DecodedInstruction struct defined in cpu.hpp. The combination of opcode, funct3 and funct7 fields determines the operation to perform such as add.
The operation is performed on the operands defined in the instruction. In the case of add, the first operand is in the first source register rs1 and the second is in rs2. The 32-bit operands are read from the register file and added together, the result gets written to register rd and the program counter is incremented to the next instruction. It can be thought of as an instruction pointer because it contains the memory address of the instruction to execute.
Some instructions read/write to the data memory (RAM) or change the value of the program counter conditionally such as branch instructions which are B-type or unconditionally like J-type instructions.
rv32-iss/
├── CMakeLists.txt
├── README.md
├── src/
│ ├── main.cpp # CLI entry point
│ ├── cpu.cpp # CPU class
│ ├── decode.cpp # Instruction decoding logic
│ ├── execute.cpp # Instruction execution logic
│ ├── memory.cpp # Byte-addressable memory and register file classes
│ └── headers/
│ ├── cpu.hpp
│ ├── decode.hpp
│ └── memory.hpp
└── tests/
├── test_decode.cpp # Unit tests for decoding logic
├── test_execute.cpp # Unit tests for execution logic
├── test_memory.cpp # Unit tests for memory/register file
└── programs/
└── factorial_12.bin # RISC-V program that caclulates 12!
Prerequisites: CMake ≥ 3.28, g++ 13.3 (c++20).
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build buildbuild/rv32_sim program.bin -p=trueThe simulator reads an assembled RISC-V binary executable, the optional command -p=true prints the architectural state of the CPU (Registers + PC) after execution. The CPU will run until it encounters the ebreak instruction or reaches the maximum number of cycles (default = 5000) for safety.
To make assembled binary programs check out my RISC-V assembler.
In tests/programs there is an assembled binary that calculates the factorial of 12 (maximum factorial that can fit in 32-bits) to run it:
build/rv32_sim tests/programs/factorial_12.bin -p=trueWhich outputs the following:
Calculated 12! in 143 cycles
Register a0 = 479001600
---------------------------- ARCHITECTURAL STATE ----------------------------
Program Counter: 72
Instruction Register : 1048691
Register 0 : 0
Register 1 : 72
Register 2 : 1000
...
...
Register 31 : 0
The CPU was unit tested using GoogleTest.
| File | What it tests |
|---|---|
test_decode.cpp |
Instruction fields decoding and immediate extraction |
test_execute.cpp |
ALU execution, load/store, branches, jumps |
test_memory.cpp |
Register file, byte-addressable memory, bounds checking |
Edge cases tested:
- Immediate sign extension and maximum size
- Arithmetic overflow
- Shift correctness
- M-extension corner cases
- Unsigned comparisons
To run the test suite:
./build/iss_tests WARNING: the memory test suite contains a test for the maximum memory size possilbe in RV32. This will attempt to allocate ~4GB on the heap which could potentially crash your computer. Add this flag to skip this test:
--gtest_filter=-MemoryTest.max_resize- Add
RVFintsructions (single-precision floating-point) - Add Cache