RISC-V Tetris: architecture spec

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.

Contents

  1. Requirements
  2. Modules and hierarchy
  3. The bus
  4. The CPU
  5. Memory map and memories
  6. I/O registers
  7. The text screen
  8. Clock domains and crossings
  9. Software: calling convention, runtime, the C subset
  10. USB protocol and program loading
  11. The lock-step test method
  12. Budgets
  13. Design decisions

Requirements

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

The bus

SignalDir (CPU)Meaning
bus_reqoutan access is wanted; held until bus_ack
bus_we, bus_addr[31:0], bus_wdata[31:0], bus_be[3:0]outwrite / 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]inread data, valid in the ack clock
bus_ackinone 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

StateClocksDoes
S_FETCH2req with pc; on ack ir <= rdata
S_EXEC1decode 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_MEM2req 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

RangeWhatAccess
0x0000_0000 .. 0x0000_7FFFRAM, 32 KB: code from 0, data after it, .bss after that, the stack from 0x8000 downbyte / half / word read and write
0x1000_0000 + (row × 80 + col) × 4TEXT: 4,800 cells; bits [7:0] character, [11:8] foreground, [15:12] backgroundword write; reads give 0
0x2000_0000 .. 0x2000_0027I/O registers, belowword
MemorySizeImplementationPorts
soc_ram8,192 × 32 (262,144 bits)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 Icarusone, the CPU's; read latency 1
text4,800 × 16 (76,800 bits)lib ram_dpA: CPU writes; B: the screen reads, latency 1
debug capture320 × 240 × 12 (921,600 bits)lib debug_linknot 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)

OffsetNameR/WMeaning
0x00BUTTONSrbit 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)
0x04FRAMEr+1 at every vertical sync (60 Hz); wait_frame() spins on it
0x08RANDOMra 32-bit xorshift, stepped every clock on the board, every read in the simulator (seedable)
0x0CHEXw24 bits = six hex digits on HEX5..0; write BCD (to_bcd()) for decimal
0x10LEDSwLEDR8..0 (LEDR9 is the CPU status)
0x14BUZZERwhalf period of the buzzer square wave in 25 MHz clocks (f = 12.5 MHz / value); 0 = silent
0x18UARTw / rwrite a byte to the PC; read bit 0 = busy (poll before writing)
0x1CSWITCHESrSW9..0
0x20CYCLESra free-running 25 MHz counter, for timing code
0x24EXITwstops 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

DomainWhatCrossing
50 MHzADC IP, gamepad, debouncebuttons into 25 MHz through two flip-flops (slow levels)
25 MHzthe SoC, the text screen, the VGA timing, the USB link, the tone generatornone inside
100 MHzlcd_ctrl (library)the library's own
JTAG (TCK)the memory editor writing PROGinside 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

Registersa0..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
Framesp -= 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)
Expressionsevery 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
Typesint / 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
Acceptedglobals 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
Refusedstruct, union, enum, typedef, float, long, goto, function pointers, variadic functions, local array initialisers, the comma operator (an error with the line)
Imageone 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 kpress A, B, Select, Start, Up, Down, Left, Right, KEY0 for 6 frames (OR-ed into BUTTONS)
Hhold the CPU in reset (LEDR9 blinks)
Rrelease and reset: run from address 0
? Fthe 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

Logic10,658 / 49,760 LEs (21 %); 6,435 registers
Memory1,277,696 / 1,677,312 bits (76 %); without the debug capture 21 %
CPU time per frame416,667 clocks ≈ 125,000 instructions; Tetris uses a few thousand per frame, a full well redraw about 60,000
Timingmet at 25 MHz; the CPU's critical path is the ALU through the register write; a pipelined version could go well beyond 50 MHz
Headroominterrupts 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