A step-by-step tutorial that builds a working RV32I single-cycle processor in Verilog, simulated with Icarus Verilog and inspected with GTKWave, on Ubuntu 24.04. Every module is delivered with a self-checking testbench, so you always know whether a step works.
Developed collaboratively with Claude (Anthropic) — RTL, tests, software, and docs.
A processor that can execute real RISC-V machine code, including:
- Integer arithmetic / logic (
add,sub,and,or,xor,slt, ...) - Immediate instructions (
addi,andi,slli, ...) - Loads and stores (
lw,sw) - Branches and jumps (
beq,bne,blt,jal,jalr) - Upper-immediate instructions (
lui,auipc)
It will run a small assembly program (computing a sum / a Fibonacci sequence) and we will watch the registers change in the waveform viewer.
| Step | Document | What we build | Status |
|---|---|---|---|
| 00 | docs/00-overview.md |
The plan, the ISA, the architecture | written |
| 01 | docs/01-environment-setup.md |
Install & verify the toolchain | written |
| 02 | docs/02-verilog-crash-course.md |
Verilog refresher tied to real code | written |
| 03 | docs/03-alu.md |
The ALU + testbench | done & tested |
| 04 | docs/04-register-file.md |
The 32-register file | done & tested |
| 05 | docs/05-memories.md |
Instruction & data memory | done & tested |
| 06 | docs/06-immediate-and-control.md |
Immediate decode + control unit | done & tested |
| 07 | docs/07-datapath.md |
Wiring the single-cycle datapath | done & tested |
| 08 | docs/08-program-and-run.md |
Assemble a program & run it | done & tested |
| 09 | docs/09-debugging-in-gtkwave.md |
Reading waveforms like a pro | done & tested |
| 10 | docs/10-running-c.md |
Bonus: compile C and run it | done & tested |
| 11 | docs/11-peripherals.md |
Bonus: UART, timer & a bus (SoC) | done & tested |
| 12 | docs/12-initialized-data.md |
Bonus: string/array constants in RAM | done & tested |
| 13 | docs/13-printf-without-libc.md |
Bonus: printf/snprintf via a mini-lib | done & tested |
| 14 | docs/14-fpga-zynq.md |
Bonus: real UART + synthesizable top for Zynq | done & tested |
| 15 | docs/15-interrupts.md |
Bonus: CSRs, traps & a timer interrupt | done & tested |
| 16 | docs/16-mul-div.md |
Bonus: multiply/divide (RV32M) | done & tested |
| 17 | docs/17-pipelining.md |
Bonus: a 5-stage pipelined core | done & tested |
| 18 | docs/18-illegal-instruction-and-isa-faq.md |
FAQ: assembler vs hardware + illegal-instruction trap | done & tested |
| 19 | docs/19-ecall-syscalls.md |
Bonus: ecall system calls + a syscall ABI |
done & tested |
| 20 | docs/20-pipelined-exceptions.md |
Bonus: precise exceptions/interrupts in the pipeline | done & tested |
| 21 | docs/21-privilege-modes.md |
Bonus: machine/user privilege levels + protection | done & tested |
| 22 | docs/22-mmu-virtual-memory.md |
Bonus: Sv32 MMU — virtual memory & page faults | done & tested |
| 23 | docs/23-synthesizable-fpga.md |
Bonus: synthesizable BRAM SoC for Zynq-7010 (multi-cycle core) | done & tested |
| 24 | docs/24-synthesizable-mmu.md |
Bonus: synthesizable Sv32 MMU (multi-cycle page-table walker, BRAM page tables) | done & tested |
| 25 | docs/25-preemptive-multitasking.md |
Bonus: preemptive multitasking — timer-driven two-task scheduler | done & tested |
| 26 | docs/26-atomic-extension.md |
Bonus: RV32A atomic extension (LR/SC + AMOs) for locks & synchronization | done & tested |
| 27 | docs/27-freertos.md |
Bonus: running FreeRTOS (64-bit CLINT + RTOS SoC + port scaffolding) | done & tested |
| 28 | docs/28-freertos-fpga.md |
Bonus: FreeRTOS on the synthesizable Zynq-7010 SoC (BRAM, real UART) | done & tested (sim) |
| 29 | docs/29-debug-stub.md |
Bonus: debug stub — hardware debug module (halt/step/breakpoints) + gdb RSP server | done & tested |
| 30 | docs/30-configurable-uart.md |
Bonus: configurable UART with RX path (runtime baud/data/parity/stop) on Zynq-7010 | done & tested (sim) |
| 31 | docs/31-uart-interrupts.md |
Bonus: UART interrupts + receive-to-idle (machine external interrupt, IDLE-line detection) on Zynq-7010 | done & tested (sim) |
| 32 | docs/32-branch-predictor.md |
Bonus: branch predictor (BTB + 2-bit saturating counters) on the pipelined core, with measured misprediction rate | done & tested (sim) |
| 33 | docs/33-plic.md |
Bonus: PLIC interrupt controller — per-source priority / enable / threshold / claim-complete multiplexing several lines into one MEIP |
done & tested (sim) |
| 34 | docs/34-compressed.md |
Bonus: the C extension — 16-bit instructions, halfword-aligned PCs, word-straddling fetch; ~29% smaller code, verified against the rv32im build | done & tested (sim) |
| 35 | docs/35-floating-point.md |
Bonus: the F extension — single-precision FPU (IEEE-754 round/guard/sticky, 5 rounding modes), the f0–f31 float register file, fcsr; runs real -march=rv32imf gcc output, bit-exact vs host float32 (subnormals flushed) |
done & tested (sim) |
| 36 | docs/36-axi-master.md |
Bonus: an AXI4-Lite master to the Zynq PS — a stall-capable bus (dmem_ready), a 5-channel bus-to-AXI adapter, and a wait-state PS/DDR slave model; the core stalls on variable-latency accesses and reaches PS DDR + peripherals |
done & tested (sim) |
| 37 | docs/37-real-libc.md |
Bonus: a real C library (picolibc) — full printf + malloc/free via a tiny retarget (UART putc, heap symbols, _exit); byte-exact demo, and the FreeRTOS build swapped off the shims onto picolibc |
done & tested (sim) |
| 38 | docs/38-isolated-processes.md |
Bonus: memory-isolated processes — merge the Sv32 MMU (22/24) with the preemptive scheduler (25): a page table per task, satp reloaded on each context switch; two tasks share VA 0x1000 but land in different physical pages |
done & tested (sim) |
| 39 | docs/39-supervisor-delegation.md |
Bonus: supervisor mode + trap delegation — add S-mode (cpu_mc_s, csr_s) with medeleg/mideleg; a user ecall, an illegal instruction, and a software interrupt are delivered straight to an S-mode kernel, while an ecall from S still reaches M |
done & tested (sim) |
| 40 | docs/40-riscv-tests.md |
Bonus: the official riscv-tests suite — the reference per-instruction tests (rv32ui/um/ua/uc/uf) run against five cores through our own target environment; 272 test programs, and the two FPU bugs they found | done & tested (sim) |
| 41 | docs/41-instruction-fetch-tlb.md |
Bonus: translated instruction fetch + TLBs — the PC becomes a virtual address (execute-only pages, instruction page faults), split 4-entry I/D TLBs make a hit cost zero cycles, and sfence.vma invalidates them; 2.36x fewer cycles than walking every access, and the whole rv32ui/um suite passes with translation on |
done & tested (sim) |
| 42 | docs/42-unified-pipeline.md |
Bonus: unify the cores — one 5-stage pipeline (cpu_pipe_u) with RV32IMA, M/U privilege, Sv32 fetch + data translation and I/D TLBs, and stall-capable ready/valid memory ports; precise page faults committed in EX, AMOs as two MEM requests, a shared walker borrowing the D-port; riscv-tests 58/58 in M and with Sv32 on in U |
done & tested (sim) |
| 43 | docs/43-caches.md |
Bonus: I-cache + write-back D-cache in front of a slow, burst-oriented (DDR-like) memory, on the unchanged unified pipeline; hits cost zero cycles, misses one line burst; 11× faster than no caches at latency 10 and 25× at 40 (CPI 2.0 / 2.7 vs 22.5 / 67.6), byte-exact vs the host compiler; D-cache vs a random reference model; MMU programs and riscv-tests through tiny caches | done & tested (sim) |
| 44 | docs/44-smode-and-bpred-in-the-pipeline.md |
Bonus: supervisor mode (medeleg/mideleg, sret, stval, SUM/MXR, translation in S) and the BTB + 2-bit branch predictor folded into the unified pipeline; an S-mode kernel running translated serves its user's page faults (demand paging, fetch faults), the Step 39 demo runs unchanged, and the predictor reproduces Step 32's 3544 → 879 mispredictions |
done & tested (sim) |
riscv-cpu-tutorial/
├── README.md <- this file
├── docs/ <- the tutorial, one markdown file per step
├── rtl/ <- synthesizable hardware (the CPU itself)
├── tb/ <- testbenches (simulation-only checking code)
├── sw/ <- assembly programs the CPU will run
├── tools/ <- helper tools (rvsim: run a program & dump state)
└── build/ <- compiled simulations + .vcd waveforms (generated)
# Compile and run the ALU test
iverilog -g2012 -Wall -o build/alu_tb.vvp rtl/alu.v tb/alu_tb.v
vvp build/alu_tb.vvp
# Open its waveform
gtkwave build/alu_tb.vcdStart with docs/00-overview.md.
tools/rvsim is a small command-line front-end that loads a program onto
the CPU, runs it, and prints the final register file, PC and memory —
without hand-writing a testbench. It accepts a raw .bin, a
.hex ($readmemh word image), or an .elf (converted for you).
# Run a raw binary on the single-cycle core and show the registers
tools/rvsim path/to/prog.bin
# Run the peripheral demo on the SoC (real UART output + self-halt)
tools/rvsim sw/socdemo.hex --core soc --data sw/socdemo_data.hex
# Compiled ELF, trace each cycle, and dump 16 data words from address 0
tools/rvsim prog.elf --trace --cycles 200 --mem 0:16
# Same thing through make
make run BIN=path/to/prog.bin
make run BIN=sw/socdemo.hex CORE=soc DATA=sw/socdemo_data.hexSample output:
──────────────────────────────────────────────────────────────
RISC-V CPU state (cpu: single-cycle core (rtl/cpu.v))
──────────────────────────────────────────────────────────────
program : prog.bin
cycles : 13 halt: PC stalled (spin loop)
pc : 0x00000024 instr: 0x0000006f
x0 zero 0x00000000 x8 s0 0x00000000 x16 a6 0x00000000 x24 s8 0x00000000
x1 ra 0x00000005 x9 s1 0x00000000 x17 a7 0x00000000 x25 s9 0x00000000
...
Useful flags (tools/rvsim --help for all):
| Flag | Meaning |
|---|---|
--core {cpu,soc} |
single-cycle core (default) or full SoC with UART/timer/halt |
--cycles N |
max cycles before stopping (default 200000) |
--trace |
print pc/instr for every retired cycle |
--mem START:COUNT |
hex-dump COUNT data words from byte START (e.g. 0x40:8) |
--data FILE |
preload data memory (auto-derived from an .elf) |
--dec |
also show registers as signed decimals |
--vcd |
also write a GTKWave waveform under build/ |
The run stops on its own when the program spins (j .), runs off the end,
halts via the SoC's syscon device, or hits the cycle cap. Both cores have a
4 KB (1024-instruction) instruction memory, so this is aimed at the small
bare-metal programs in sw/.
Run rvsim with no program (or pass -i) and it drops into an
interactive shell instead of a one-shot dump — a little gdb-for-the-tutorial
where you step the CPU cycle by cycle and watch the registers change, with
inline disassembly, breakpoints and a full trace.
tools/rvsim # empty shell — then: load sw/sum.hex
tools/rvsim -i sw/sum.elf # load a program and start stepping
make sim BIN=sw/sum.hex # same thing through makervsim 6/49 ❯ step
● cycle 7/49 pc 0x0000000c 00315863 bge sp,gp,0x1c
Δ x2 sp 0x00000002 → 0x00000003 (3)
rvsim 7/49 ❯ break 0x1c # stop when PC reaches the store
rvsim 7/49 ❯ continue
● cycle 44/49 pc 0x0000001c 00102023 sw ra,0(zero)
Δ x1 ra 0x00000003 → 0x00000037 (55)
It works by re-running the deterministic simulation to a cycle cursor, so
step, back, goto and continue all just move that cursor — no waveform
digging required. Type help for the full command list; the highlights:
| Command | What it does |
|---|---|
load FILE |
load a .bin/.hex/.elf and reset to cycle 0 |
step [N] / back [N] |
advance / rewind N cycles, showing what changed |
continue |
run to the next breakpoint (or the natural halt) |
goto CYCLE / reset |
jump the cursor to an absolute cycle / to 0 |
break PC / breaks / delete |
set / list / remove PC breakpoints |
regs / mem START:COUNT / trace [N] |
inspect registers / RAM / history |
core {cpu,soc} / cycles N / dec |
switch core / cap / decimal display |
vcd |
write a GTKWave waveform of the current program |
Inline disassembly and breakpoints need a riscv64-unknown-elf-objdump on
PATH; without it the shell still steps and shows the raw instruction word.
tools/rvtests runs riscv-tests,
the reference per-instruction suite, against the cores. It fetches the suite
into third_party/ on first use (pinned commit, gitignored), builds each test
against the target environment in tests/riscv-tests/env/, and runs them all on
a compiled-once harness.
make riscv-tests # default core (cpu_mc)
make riscv-tests CORE=all # every core, plus a summary matrix
make riscv-tests CORE=pipe ARGS="add sra"
tools/rvtests --trace rv32ui-add # one test, cycle by cycle
tools/rvtests --list # what would run──────────────────────────────────────────────────────────────
riscv-tests core: pipe — 5-stage pipeline RV32IM + Zicsr
built: -march=rv32im_zicsr_zifencei -mabi=ilp32
──────────────────────────────────────────────────────────────
rv32ui-add pass 537 cycles
rv32ui-fence_i gap unexpected trap in sub-test 0
...
──────────────────────────────────────────────────────────────
rv32ui 41/41 rv32um 8/8 total 49/49 ALL PASS
| Core | RTL | Suites | Result |
|---|---|---|---|
sc |
cpu_core.v — single-cycle RV32IMA |
ui, um, ua | 59/59 |
mc |
cpu_mc.v — multi-cycle RV32IM |
ui, um | 48/48 |
mc_c |
cpu_mc_c.v — multi-cycle RV32IMC |
ui, um, uc | 49/49 |
mc_f |
cpu_mc_f.v — multi-cycle RV32IMF |
ui, um, uf | 58/58 |
pipe |
cpu_pipe_trap.v — 5-stage pipeline |
ui, um | 49/49 |
mc_tlb |
cpu_mc_tlb.v — multi-cycle RV32IM, run with Sv32 on (identity map, U-mode) through the I/D TLBs |
ui, um | 48/48 |
pipe_u |
cpu_pipe_u.v — unified pipeline RV32IMA, M/S/U, MMU, branch predictor (body in M) |
ui, um, ua | 58/58 |
pipe_u_vm |
cpu_pipe_u.v — same, body in U with Sv32 on |
ui, um, ua | 58/58 |
pipe_c |
cpu_pipe_u.v behind 512 B I$/D$ on slow memory (latency 5) |
ui, um, ua | 58/58 |
pipe_c_vm |
same, body in U with Sv32 on (PTEs read through the D$) | ui, um, ua | 58/58 |
Tests that cannot pass on this hardware (fence_i, ma_data on the word-RAM
cores, fmadd) are still run and reported as documented gaps rather than
skipped, so a gap that closes shows up as readily as one that opens. See
docs/40-riscv-tests.md for what the suite found.
This project was developed collaboratively with Claude, Anthropic's AI assistant. Claude helped design and write the RTL, the self-checking testbenches, the bare-metal software and toolchain flow, and all of the step-by-step tutorial documents — with each module built bottom-up and verified in simulation along the way.
A menu of where this project can go next, grouped by goal. Rough difficulty: 🟢 weekend–week · 🟡 multi-week · 🔴 multi-month.
- ✅ Run FreeRTOS on the Zynq-7010 — synthesizable BRAM SoC (
soc_rtos_fpga+fpga_top_rtos), real UART, verified in sim (Step 28). Vivado bitstream is the remaining manual step. - ✅ AXI4-Lite master to the PS — a stall-capable core (
cpu_mc_stallwaits ondmem_ready), a 5-channel bus-to-AXI4-Lite adapter, and asoc_axithat windows0x4000_0000out to the PS (Step 36). Verified against a wait-state AXI slave modelling PS DDR + a peripheral; reads/writes round-trip correctly through the stalls. Full AXI4 bursts (AXI-HP for DDR bandwidth) wait on a cache or DMA engine to generate them. - 🔴 Tape-out flow — push RTL through OpenLane + SkyWater sky130 to a GDSII layout.
- ✅ Memory-isolated processes — per-task page tables with
satpreloaded on every context switch (Step 38). Two preempted user tasks share the same virtual addresses (private page at VA0x1000, stack at0x2000) yet map to different physical pages, so neither can touch the other's memory; the MMU enforces it, the kernel just hands each task its own address space. Built on the unmodified synthesizable MMU SoC. A TLB (and thesfence.vmait would require) is the natural next step. - ✅ Supervisor mode +
medeleg/mideleg— a third privilege level (S) with per-cause trap delegation (Step 39). Two bit-masks route traps taken below machine mode: a delegated cause vectors straight to the S-mode handler atstvec, updates the supervisor trap CSRs, and enters S — machine mode never runs. The demo delegates a userecall, an illegal instruction, and a supervisor software interrupt to an S-mode kernel, while a non-delegatedecallfrom S still reaches M.sstatus/sie/sipare masked views of the machine registers; built on a freshcpu_mc_s/csr_sso the rest of the regression is untouched. Step 44 brings S-mode to the unified pipeline, where an S-mode kernel running translated serves its user's page faults (demand paging withstval, SUM, fetch faults) without M ever running. - ✅ Instruction-fetch translation + TLB — a new core,
cpu_mc_tlb(Step 41), translates the PC as well as data: user code runs at a virtual address, execute-only pages work, and a leaf withoutXraises an instruction page fault (cause 12). Split fully-associative I/D TLBs (tlb.v, 4 entries each) cache finished walks, so a hit costs no cycles, andsfence.vma(all entries, or by address) invalidates them. The demo shows a stale translation surviving a PTE change until the fence; against a walk-every-access build it takes 2.36x fewer cycles, and rv32ui/um pass 48/48 with every fetch and access translated. ASID-tagged entries (so asatpswitch needs no flush) are the natural next step. - 🔴 Port xv6-riscv, then Linux — xv6 is the realistic next OS; Linux needs S-mode, full MMU+TLB, atomics, device tree.
- ✅ Branch predictor — BTB + 2-bit saturating counters on the pipelined core (Step 32): 3544 flushes → 879 mispredictions on the demo, ~1.3x fewer cycles. Return-address stack is the natural follow-up.
- ✅ I-cache / D-cache — direct-mapped I-cache and write-back, write-allocate D-cache (Step 43) on the unified pipeline's ready/valid ports, in front of
mem_slow, a DDR-like memory (first-word latency, then one word per cycle). Hits cost nothing; a miss is one line burst. On a mixed benchmark the caches give 4.9× / 11× / 25× over no caches at latency 1 / 10 / 40 (CPI 1.8 / 2.0 / 2.7 vs ideal 1.55), and the remaining cost is exactly misses × latency. Verified against a random reference model, byte-exact vs the host compiler, and with the MMU programs and riscv-tests through 256–512-byte caches. A single-port DDR arbiter, AXI4 bursts and a registered (BRAM-sized) lookup are the next steps. - ✅ Unify the cores —
cpu_pipe_u(Step 42): the 5-stage pipeline now has atomics (RV32A), user mode, and the Sv32 MMU with translated fetch and I/D TLBs, behind stall-capable ready/valid memory ports. Data translation sits in EX so page faults are precise for free; fetch faults ride down the pipe as tokens; AMOs are two MEM requests; one walker borrows the D-port. The Step 41 TLB demo runs unchanged (1339 vs 2050 cycles oncpu_mc_tlb), Step 38's isolated processes run with fetch translated (and fail without thesfence.vma), and riscv-tests pass 58/58 both in M and with Sv32 on in U. Step 44 then folds in the two that Step 42 left out: supervisor mode with delegation (Step 39) and the branch predictor (Step 32), so the fast core now has every privilege, memory-system and prediction feature in the project; the C and F extensions (Steps 34/35) and the debug module (Step 29) remain on their own cores. - 🔴 Superscalar / out-of-order — Tomasulo, register renaming, reorder buffer.
- ✅ C (compressed) — RV32IMC core (
cpu_mc_c+rvc_expand, Step 34): 1:1 expansion to base instructions, halfword PCs, straddling fetches resolved in one extra cycle. Same program, 424 → 302 code bytes. A fetch buffer is the natural follow-up. - ✅ F (single-precision floating point) — single-precision FPU, the
f0–f31float register file, andfcsr(Step 35): runs real-march=rv32imfgcc output and matches host float32 bit-for-bit across the normal range (367-point unit test + compiled-program integration test). Subnormals are flushed to zero. Double precision (D) and the fused multiply-add family are the natural follow-ups. - 🟡 Zb (bit-manip) — small, high-value. V (vector) — deep and modern.
- 🔴 RV64 — widen to 64-bit; required for Linux.
- ✅ riscv-tests — the official per-instruction suite, running against five cores (Step 40). riscv-tests ships the tests but deliberately not the environment, so
tests/riscv-tests/env/supplies ours: a Harvard link script and atohostthat is the SYSCON halt register, so the store reporting the result is the store that stops the machine. 272 test programs:sc59/59,mc48/48,mc_c49/49 (whole base suite rebuilt as RVC),mc_f58/58,pipe49/49. It found and fixed two real FPU bugs —fmin.s(-0.0,+0.0)returned+0.0, andfcvt.wu.shad its invalid flag on the wrong side of the range check. Three tests are reported as documented gaps rather than skipped:fence_i(the instruction ROM is write-once),ma_dataon the word-RAM cores (misaligned accesses are silently wrong),fmadd(no fused multiply-add).rv32mi/rv32siare the natural next step and need the machine CSR set built out first. - 🟡 Differential testing vs Spike — same program on core + reference sim, compare state each step.
- 🟡 Benchmark — Dhrystone / CoreMark, report DMIPS/MHz and CoreMark/MHz.
- 🔴 Formal verification — riscv-formal + SymbiYosys.
- 🔴 Two harts — duplicate the core with distinct
mhartid, shared memory + arbiter, cross-core LR/SC (whereaq/rlfinally matter); then SMP FreeRTOS / Linux.
- ✅ Debug stub — hardware debug module (halt/step/4 HW breakpoints/reg+mem access) + gdb RSP server (Step 29); transport bridge to live gdb is the remaining glue.
- ✅ Real libc — picolibc with full
printfandmalloc/free(Step 37). The platform contract is three hooks: a UARTputcforstdout,__heap_start/__heap_endfor the allocator, and_exit. A byte-exact demo (make libc) checks formatting + heap +string.h, and the FreeRTOS build now links picolibc with the header shims and thefirmware.cmini-printf retired (verified running through realprintf). - 🟡 Bootloader + device tree — load/run arbitrary programs; Linux prerequisite.
- ✅ UART interrupts + receive-to-idle — machine external interrupt (cause 11) wired to a configurable UART, with STM32-style IDLE-line detection to receive whole variable-length messages (Step 31).
- ✅ Interrupt controller (PLIC) — per-source priority / enable / threshold / claim-complete multiplexing several lines into one
MEIP(Step 33). Surfaced and fixed a read-side-effect bus bug via a newdmem_reread strobe. - 🟡 Richer peripherals — SPI/I2C masters, GPIO with edge interrupts, a PWM/timer-capture block — each behind the same MMIO + IRQ pattern.
- 🟢🟡 Doom (bare-metal RV32), a tiny TCP stack (lwIP), a shell, or framebuffer graphics — proof it's a computer.