The contract between the hardware, the toolchain and the programs: the bus, the memory map, every register, the
CPU's states, the calling convention, what the compiler accepts, and the budgets. For someone who will add a peripheral,
extend the compiler, or build the next computer on this one.
An RV32I CPU complete enough to run compiled C: all base integer instructions, byte / half / word memory access, a flat
32-bit address space. No privileged mode, no interrupts, no CSRs, no M extension.
32 KB of RAM for code and data, initialised at power-up with a program, rewritable over JTAG while running.
A text screen of 80 × 60 cells with foreground and background colours, drawn by hardware from a RAM the CPU writes.
Memory-mapped input (the pad, keys, switches, time, randomness) and output (digits, LEDs, buzzer, UART, exit).
A toolchain in the repo, Python only: assembler, C compiler, simulator; one command from C to an image.
Every program runs identically on the simulator and on the RTL (lock-step traces), and the board is testable over USB.
Tetris, playable with the shield's pad, at 60 frames per second.
Modules and hierarchy
riscv_tetris_top board level, 25 / 50 / 100 MHz
├─ pll25, Screens_dispaly (lib) clocks; VGA timing and the LCD copy
├─ gamepad + adc, debounce (lib) the pad at 50 MHz, two flip-flops into 25 MHz
├─ rv32_soc the computer
│ ├─ rv32_cpu RV32I, multi-cycle
│ ├─ soc_ram 8192 × 32, byte enables (IP: altsyncram PROG; SIM: array)
│ ├─ ram_dp (lib) "text" 4800 × 16, CPU write port, screen read port
│ └─ I/O registers in rv32_soc itself
├─ text_screen cells → pixels, font8x8 (lib), block glyphs, palette
├─ USB letters, hold / reset in the top
├─ seven_segment ×6 (lib) HEX register → digits
└─ debug_link, tx_mux, jtag_uart (lib) USB: status, capture, the program's UART, the letters in
write / read, the word address (bits 1:0 are 0), the data (a byte or half is replicated across the word), the byte enables
bus_rdata[31:0]
in
read data, valid in the ack clock
bus_ack
in
one clock after the first req clock (ack <= req && !ack)
A write is performed by the slave in the first req clock (first = req && !ack); a read is issued then and
answered in the ack clock. The CPU drops req after the ack, so a slave never sees a second access before it finished
the first. A slower slave may delay its ack; nothing else changes. Addresses are decoded on addr[31:28]:
0 = RAM, 1 = TEXT, 2 = I/O; anything else reads 0 and ignores writes.
The CPU
State
Clocks
Does
S_FETCH
2
req with pc; on ack ir <= rdata
S_EXEC
1
decode ir combinationally: immediates (I S B U J), rs1v / rs2v, the ALU (add sub sll slt sltu xor srl sra or and; the I forms use the immediate), the branch condition, pc_next. LUI AUIPC JAL JALR OP OP-IMM: write rd (if not x0), pc <= pc_next, back to FETCH. LOAD / STORE: to S_MEM. ecall / ebreak: S_HALT. Illegal opcode: S_HALT with fault.
S_MEM
2
req with rs1 + imm (word-aligned on the bus, be by size and addr[1:0]); on ack a load's byte / half is selected by addr[1:0] and extended by funct3; pc <= pc + 4
S_HALT
-
halted = 1 until reset; the top blinks LEDR9
Register file: regs[1:31] as flip-flops, read combinationally, reset to 0. x0 reads 0. Trace port: a pulse with
pc, rd and the value for each register write (the testbench writes it to the trace file). No misaligned access
support: the compiler and the runtime never produce one; a misaligned load reads the aligned word (documented, not
trapped). fence is a no-op.
Memory map and memories
Range
What
Access
0x0000_0000 .. 0x0000_7FFF
RAM, 32 KB: code from 0, data after it, .bss after that, the stack from 0x8000 down
altsyncram SINGLE_PORT, width_byteena 4, lpm_hint ENABLE_RUNTIME_MOD=YES, INSTANCE_NAME=PROG, init_file assets/tetris.mif; a behavioural array with $readmemh for Icarus
one, the CPU's; read latency 1
text
4,800 × 16 (76,800 bits)
lib ram_dp
A: CPU writes; B: the screen reads, latency 1
debug capture
320 × 240 × 12 (921,600 bits)
lib debug_link
not part of the computer; drop it to free 55 % of the memory
The map lives in tools/memmap.py, which writes sw/soc.h; the simulator imports it; the Verilog repeats the
numbers in rv32_soc.sv, and tests/test_riscv_tetris.py checks the three agree.
I/O registers (0x2000_0000 + offset)
Offset
Name
R/W
Meaning
0x00
BUTTONS
r
bit 0 A, 1 B, 2 Select, 3 Start, 4 Down, 5 Up, 6 Right, 7 Left, 8 KEY0, 9 KEY1; 1 = held (the pad, OR the USB-injected keys)
0x04
FRAME
r
+1 at every vertical sync (60 Hz); wait_frame() spins on it
0x08
RANDOM
r
a 32-bit xorshift, stepped every clock on the board, every read in the simulator (seedable)
0x0C
HEX
w
24 bits = six hex digits on HEX5..0; write BCD (to_bcd()) for decimal
0x10
LEDS
w
LEDR8..0 (LEDR9 is the CPU status)
0x14
BUZZER
w
half period of the buzzer square wave in 25 MHz clocks (f = 12.5 MHz / value); 0 = silent
0x18
UART
w / r
write a byte to the PC; read bit 0 = busy (poll before writing)
0x1C
SWITCHES
r
SW9..0
0x20
CYCLES
r
a free-running 25 MHz counter, for timing code
0x24
EXIT
w
stops the simulation / testbench with the value as the exit code; on the board sets a status flag (the program keeps running)
The text screen
For the scan pixel (pxl_x, pxl_y) the cell is (pxl_x >> 3, pxl_y >> 3); the address of the next pixel's cell
(row × 80 + col, computed as row × 64 + row × 16 + col) goes to the text RAM, whose answer arrives for the
current pixel. The glyph row comes from font8x8 (ASCII 0x20..0x5F; lower case folds up) for characters below
0x80; for 0x80..0x83 it is made in logic: full block, inner 6 × 6 block, checkerboard shade, 1-pixel frame. The pixel
is the foreground colour where the glyph bit is set, else the background; both through the 16-entry CGA palette
(RGB444). Outside 640 × 480 the output is black.
Clock domains and crossings
Domain
What
Crossing
50 MHz
ADC IP, gamepad, debounce
buttons into 25 MHz through two flip-flops (slow levels)
25 MHz
the SoC, the text screen, the VGA timing, the USB link, the tone generator
none inside
100 MHz
lcd_ctrl (library)
the library's own
JTAG (TCK)
the memory editor writing PROG
inside altsyncram's runtime-mod logic; the CPU is held in reset meanwhile ('H')
The CPU reset is asynchronous (the PLL lock) but released synchronously through two flip-flops; 'H' holds it, 'R'
pulses it for 32 clocks.
Software: calling convention, runtime, the C subset
Registers
a0..a7 arguments and a0 the result; ra the return address; s0 the frame pointer; sp the stack (16-byte aligned frames); t0..t6 scratch (the runtime uses them); s1..s11 unused by the compiler
Frame
sp -= size; ra at size-4(sp); s0 at size-8(sp); s0 = sp + size; parameters are spilled to the frame; locals at s0-12 and below (4-byte slots, arrays in place)
Expressions
every expression leaves its value in a0; binary operators push the left value, evaluate the right, pop the left into a1; a constant right operand uses the immediate forms; literal-literal is folded
Runtime (crt0.s)
_start at 0: sp = 0x8000, zero .bss, call main, write EXIT with its return value. __mul (shift-add), __udivmod (restoring), __div / __mod (signs like C), __udiv / __umod
Types
int / unsigned int 32-bit, char / unsigned char 8-bit (lb / lbu), pointers 32-bit, arrays of one or two dimensions; char promotes to int on load; casts to char truncate and extend
Accepted
globals with constant initialisers (arrays, strings, string pointers), functions (up to 8 parameters, prototypes), if / else, while, do, for (declaration in init), switch / case / default, break, continue, return; all operators but the comma; sizeof; #include, #define (with arguments), #ifdef / #ifndef / #else / #endif, #undef
Refused
struct, union, enum, typedef, float, long, goto, function pointers, variadic functions, local array initialisers, the comma operator (an error with the line)
Image
one segment: crt0, then the C code, then .data, then .bss between __bss_start / __bss_end; the assembler writes 8,192 words
USB protocol and program loading
Letter (PC → board)
Effect
a b e s u d l r k
press A, B, Select, Start, Up, Down, Left, Right, KEY0 for 6 frames (OR-ed into BUTTONS)
H
hold the CPU in reset (LEDR9 blinks)
R
release and reset: run from address 0
? F
the library debug link: a status line; the 320 × 240 screen capture
tools/load.py x.mif = 'H', quartus_stp -t scripts/mem_edit.tcl write PROG x.mif, 'R'. The board → PC
direction carries the program's UART bytes and the debug link's answers through tx_mux (whole lines at a time).
The lock-step test method
Trace lines: pc rd value for a register write (the simulator adds ~ when the value came from FRAME,
CYCLES, RANDOM or BUTTONS) and pc W addr value be for a store. The simulator writes it per instruction; the
testbench from the CPU's trace port and the bus. trace_compare.py demands identical lines, except: a ~ line
needs only the same pc and rd; a store needs equality on the enabled bytes. Any divergence prints both traces around
it with line numbers; the .sym file maps the pc to a function. The method found the CPU's only bug and three
compiler bugs; it is the project's regression test (tests/test_riscv_tetris.py, Icarus in CI).
Budgets
Logic
10,658 / 49,760 LEs (21 %); 6,435 registers
Memory
1,277,696 / 1,677,312 bits (76 %); without the debug capture 21 %
CPU time per frame
416,667 clocks ≈ 125,000 instructions; Tetris uses a few thousand per frame, a full well redraw about 60,000
Timing
met at 25 MHz; the CPU's critical path is the ALU through the register write; a pipelined version could go well beyond 50 MHz
Headroom
interrupts and a timer (one register and a vector), an M extension (the MAX 10 has 144 multipliers), a second program in the UFM, a bigger RAM if the capture buffer goes
Design decisions
Multi-cycle, not pipelined. Three states, no hazards, no forwarding, one bus port: the CPU is 300 lines you can
single-step in a waveform, and it is fast enough by a factor of fifty for a game. Speed was not the goal; proof was.
One bus protocol for everything. RAM, text and I/O all look the same to the CPU; a new peripheral is a case
line in rv32_soc.sv and a line in memmap.py.
The toolchain in Python, in the project. A gcc cross-compiler is 300 MB and a black box; the user of this BKM
learns more from a 1,300-line compiler whose output they can read, and the AI can extend it when a program needs
something. The price: a C subset and slow code; both are documented and tested.
Lock-step traces as the definition of correct. The simulator is the specification the RTL must match; the trace
format is the simplest thing that catches every register and memory effect.
Programs over JTAG.ENABLE_RUNTIME_MOD on the RAM plus two letters make the edit-compile-run loop ten
seconds; the FPGA is rebuilt only when the hardware changes.
A text screen, not pixels. 76,800 bits of RAM, a few lines of logic, and a C program draws with a store.