825 lines
38 KiB
Markdown
825 lines
38 KiB
Markdown
# RISC-V CPU Core — Build Roadmap
|
||
## Target: Digilent Arty A7 100T / Vivado 2025.2+ / SystemVerilog
|
||
## Clock: 50 MHz (100 MHz xtal ÷ 2 via MMCM)
|
||
## End goal: Boot Linux on a custom RISC-V core
|
||
|
||
---
|
||
|
||
## Scope Guide
|
||
|
||
Each phase is marked with a learning-scope label:
|
||
|
||
- **Easy/fun** — good weekend/evening work. These phases have visible progress,
|
||
tight feedback loops, and form a complete worthwhile project even if you stop
|
||
before the Linux work.
|
||
- **Hard stretch** — still rewarding, but expect deeper debugging, more spec
|
||
reading, and longer gaps between visible milestones.
|
||
- **Overkill/hard** — Linux-capable SoC work. Valuable if you want the full
|
||
summit, but too much to treat as the baseline definition of success.
|
||
|
||
Recommended casual target: reach Phase 8, where GCC-built C runs on your CPU and
|
||
`riscv-tests` gives you confidence in the ISA implementation. Everything after
|
||
that is enrichment or a long-term expedition.
|
||
|
||
Recommended middle-game target: continue through Phases 9-11 and build your own
|
||
BIOS, ELF loader, and tiny kernel before attempting Linux. This gives you a
|
||
playable computer with your own firmware and command line without requiring the
|
||
full Linux boot contract.
|
||
|
||
---
|
||
|
||
## Memory Map (target — fixed in Phase 0, evolves through Phase 17)
|
||
|
||
```
|
||
0x0000_0000 – 0x0FFF_FFFF reserved boot aperture (256 MB decode region;
|
||
actual SPI flash is 16 MB on Arty A7,
|
||
mapped at 0x0000_0000–0x00FF_FFFF in Phase 17)
|
||
0x1000_0000 – 0x1000_0FFF MMIO (UART; later: timer, PLIC)
|
||
0x2000_0000 – 0x2000_FFFF instruction BRAM (64 KB)
|
||
0x8000_0000 – 0x8000_FFFF data BRAM (64 KB) → DRAM 0x8000_0000–0x8FFF_FFFF in Phase 17
|
||
```
|
||
|
||
Reset PC = `0x2000_0000`. Locking this in now keeps the linker script and crt0
|
||
stable across phases.
|
||
|
||
---
|
||
|
||
## Phase 0 — Architecture Contract [Easy/fun]
|
||
|
||
### 0.1 — SystemVerilog Package
|
||
What: Create a `.sv` package file with enums (ALU operations, opcode types, branch
|
||
types) and structs (fetch_out_t, decode_out_t, exec_out_t, mem_out_t) that define
|
||
the signals passed between pipeline stages.
|
||
|
||
Why: This is the "API" of your CPU. Every module you build will import this package.
|
||
If you get the struct definitions reasonable now, adding new instructions later means
|
||
adding a field to a struct — not re-plumbing wires across the whole design.
|
||
|
||
Future role: These structs stay forever. When you pipeline the core, the pipeline
|
||
registers are literally just these structs stored in flip-flops. When you add CSR
|
||
instructions, you add a field to decode_out_t. The package grows but never gets
|
||
replaced.
|
||
|
||
### 0.2 — Block Diagram (on paper)
|
||
What: Draw the major blocks (fetch, decode, ALU, register file, memory, writeback)
|
||
and the signals between them. Label signals with the struct names from 0.1.
|
||
|
||
Why: You need a physical reference to look at while coding. Screen diagrams get
|
||
buried in tabs. Paper on a wall stays visible.
|
||
|
||
Future role: You'll update this as the design grows. It becomes your architectural
|
||
source of truth when things get complex.
|
||
|
||
---
|
||
|
||
## Phase 1 — ALU + M Unit [Easy/fun]
|
||
|
||
### 1.1 — Combinational ALU + Simulation
|
||
What: Build a combinational ALU that handles all RV32I arithmetic/logic ops
|
||
(add, sub, and, or, xor, slt, sltu, shifts). Inputs: two 32-bit operands +
|
||
operation select from your enum. Outputs: 32-bit result.
|
||
|
||
Why: The ALU is the computational heart. Building it first gives you a
|
||
self-contained module to practice your testbench workflow.
|
||
|
||
Testbench focus: signed vs unsigned comparisons (SLT vs SLTU), arithmetic shift
|
||
right vs logical shift right, signed overflow.
|
||
|
||
Future role: This module is final. It goes into the finished core unchanged.
|
||
|
||
### 1.2 — Multi-cycle M Unit + Simulation
|
||
What: A separate module for the M extension (mul, mulh, mulhsu, mulhu, div,
|
||
divu, rem, remu) with a `start` / `busy` / `done` handshake. Multiply uses
|
||
DSP48s and completes in 2-3 cycles; divide is iterative (one bit per cycle,
|
||
~33 cycles for 32-bit). Outputs a 32-bit result on `done`.
|
||
|
||
Why M extension early: GCC emits mul/div constantly; without hardware, libgcc
|
||
emulation is slow and painful. Why a separate module: a 32-bit combinational
|
||
divider will not meet timing on Artix-7 at any reasonable Fmax — divide must
|
||
be iterative. Baking the stall handshake in from day one means the datapath
|
||
(Phase 4) is built around it from the start, not retrofitted.
|
||
|
||
Testbench focus: division by zero (RISC-V spec gives specific results, not an
|
||
exception — `div(x,0) = -1`, `rem(x,0) = x`), signed overflow on division
|
||
(`INT_MIN / -1` returns `INT_MIN`), `mulh*` upper-half results, back-to-back
|
||
operations without dropping `busy`.
|
||
|
||
Future role: Final. The handshake interface is reused unchanged when the core
|
||
is pipelined.
|
||
|
||
### 1.3 — ALU + M Unit on FPGA with VIO
|
||
What: Synthesize both on the Arty. Attach Vivado VIO cores to inputs, outputs,
|
||
and the M unit handshake. Use the Vivado hardware manager to drive operands
|
||
and read results.
|
||
|
||
Why: Confirms behavior in real hardware and catches synthesis issues early
|
||
(timing on the multiply path, divide unit FSM state encoding). Also gets you
|
||
comfortable with the VIO workflow.
|
||
|
||
What is VIO: A Vivado IP that lets you poke values into signals and read them
|
||
out through JTAG, right from the Vivado GUI. Think virtual switches and LEDs
|
||
with 32-bit width and no board wiring.
|
||
|
||
Future role: VIO familiarity pays off throughout the project.
|
||
|
||
---
|
||
|
||
## Phase 2 — Register File [Easy/fun]
|
||
|
||
### 2.1 — Register File Module + Simulation
|
||
What: Build the RISC-V register file — 32 registers, each 32 bits wide. Two read
|
||
ports (rs1, rs2), one write port (rd). Register x0 is hardwired to zero (writes to
|
||
it are ignored, reads always return 0).
|
||
|
||
Why: Every instruction reads from and/or writes to registers. This is the second
|
||
fundamental building block after the ALU. It's simple (it's basically a small RAM
|
||
with some special behavior on address 0) but getting the read/write timing right
|
||
matters.
|
||
|
||
Testbench focus: read-after-write in the same cycle (does the new value forward?),
|
||
write to x0 then read x0 (must be zero), read two different registers simultaneously.
|
||
|
||
Future role: This module is final. Might eventually get a third read port if you add
|
||
certain extensions, but for RV32IM it's done.
|
||
|
||
### 2.2 — Optional: VIO validation
|
||
What: Put it on FPGA with VIO if you want extra confidence. Usually sim is enough
|
||
for a register file since the logic is simple.
|
||
|
||
---
|
||
|
||
## Phase 3 — Decoder [Easy/fun]
|
||
|
||
### 3.1 — Instruction Decoder + Simulation
|
||
What: A combinational module that takes a raw 32-bit instruction word and produces
|
||
your decode_out_t struct: ALU operation, source/destination register addresses,
|
||
immediate value (sign-extended), memory read/write flags, branch type, etc.
|
||
|
||
Why: The decoder is the "brain" that tells every other module what to do. RISC-V has
|
||
six instruction formats (R, I, S, B, U, J) and the immediate bits are scattered
|
||
differently in each. Getting the immediate extraction and sign extension right is the
|
||
main challenge.
|
||
|
||
How to test: Use the RISC-V toolchain as your reference. Write small assembly
|
||
snippets, assemble them with `riscv64-unknown-elf-as`, then `objdump -d` to get the
|
||
binary encoding. Feed those encodings to your testbench and verify every output field.
|
||
This also gets you familiar with the cross-compilation toolchain you'll need later.
|
||
|
||
Testbench focus: Every instruction format. Pay special attention to immediate sign
|
||
extension (bit 31 of the instruction is always the sign bit in RISC-V — that's a
|
||
deliberate design choice). Verify that the decoder handles all R-type, I-type, S-type,
|
||
B-type, U-type, and J-type correctly.
|
||
|
||
Future role: When you add CSR instructions (Phase 12), you'll add a new case to the
|
||
decoder and a new field to the struct. The structure of the decoder doesn't change.
|
||
|
||
---
|
||
|
||
## Phase 4 — First CPU ("It's Alive") [Easy/fun]
|
||
|
||
### 4.1 — Fetch + Datapath Integration
|
||
What: Create an instruction BRAM initialized from a .mem file, mapped at
|
||
`0x2000_0000`. Add a PC register that resets to `0x2000_0000` and advances
|
||
by 4 when the next instruction is ready. Wire the chain:
|
||
BRAM[PC] → decoder → ALU/M unit → register file writeback. Support R-type and
|
||
I-type arithmetic for now (add, sub, addi, and, or, xor, slt, slti, lui,
|
||
auipc, shifts), plus the M-extension ops via the M unit's handshake.
|
||
|
||
Why: This is the first time all your modules work together as a CPU. It can't
|
||
branch, it can't access data memory, but it executes a sequence of arithmetic
|
||
instructions correctly. The wiring is where most bugs live — wrong bit ranges,
|
||
swapped operands, forgetting to connect a signal.
|
||
|
||
Important — "single-cycle" is a logical model: BRAM has a 1-cycle read
|
||
latency, so fetch is registered (PC → address one cycle, instruction available
|
||
the next). Each instruction takes 2 cycles end-to-end at minimum, and stalls
|
||
extend that whenever the M unit is busy. Plan the control FSM around this:
|
||
states like FETCH → EXECUTE, with a stall in EXECUTE while `m_busy` is high.
|
||
|
||
How to test: Hand-assemble 10-20 instructions into a .mem file. Calculate the
|
||
expected register state after each instruction by hand. Compare against what
|
||
the CPU actually produces.
|
||
|
||
What is a .mem file: A text file with hex values, one per line. Vivado can
|
||
initialize BRAMs from these. Each line is one 32-bit instruction.
|
||
|
||
Future role: This is your core. Everything from here on adds capabilities to it.
|
||
|
||
### 4.2 — ILA Verification on FPGA
|
||
What: Synthesize the CPU on the Arty. Attach ILA (Integrated Logic Analyzer) probes
|
||
to the PC, register write port, and ALU output. Set a trigger (e.g., when PC reaches
|
||
the last instruction). Inspect the captured waveform.
|
||
|
||
Why: Confirms hardware behavior matches simulation. Gets you comfortable with ILA,
|
||
which becomes your primary debugging tool for all future phases.
|
||
|
||
What is ILA: A Vivado IP that captures signal values over time into on-chip memory,
|
||
like an oscilloscope for internal FPGA signals. You set trigger conditions and view
|
||
waveforms in the Vivado hardware manager.
|
||
|
||
Future role: You'll drop ILA probes on different signals throughout the project.
|
||
Knowing how to use it well is arguably the most important FPGA debug skill.
|
||
|
||
---
|
||
|
||
## Phase 5 — Branches and Jumps (Control Flow) [Easy/fun]
|
||
|
||
### 5.1 — Branch Instructions
|
||
What: Add a branch comparator (separate from the ALU — it checks rs1 vs rs2 for
|
||
equality, less-than, etc.) and a mux that selects between PC+4 and the branch target.
|
||
Implement all B-type branches: beq, bne, blt, bge, bltu, bgeu.
|
||
|
||
Why: Without branches, the CPU can only run straight-line code. Branches give
|
||
you loops and conditionals — the core of any real program. Keeping the branch
|
||
comparator separate from the ALU is mostly a clarity choice: branches and
|
||
arithmetic have different semantics, and the comparator is small. (Whether you
|
||
resolve branches in decode or execute when you pipeline later is a separate
|
||
trade-off involving forwarding paths — don't lock that decision in now.)
|
||
|
||
Test: A loop that increments a register from 0 to 10, then falls through. Verify with
|
||
ILA that the branch is taken exactly 10 times and the final register value is 10.
|
||
|
||
Future role: When you pipeline, branch handling becomes the source of pipeline hazards
|
||
and you'll add branch prediction. But the comparator logic stays.
|
||
|
||
### 5.2 — Jump Instructions (jal, jalr)
|
||
What: Add jal (jump and link — PC-relative) and jalr (jump and link register —
|
||
absolute). Both store PC+4 into the destination register before jumping.
|
||
|
||
Why: jal/jalr are how RISC-V implements function calls and returns. `jal` calls a
|
||
function, `jalr` returns from it (by jumping to the address saved in the link
|
||
register). Without these, you can't have functions — and C is nothing but functions.
|
||
|
||
Test: Write a call/return sequence. Main calls a subroutine via jal, subroutine does
|
||
some work, returns via jalr. Verify the return address is correct and execution
|
||
resumes in the right place.
|
||
|
||
Future role: Final. These instructions don't change.
|
||
|
||
---
|
||
|
||
## Phase 6 — Load/Store (Data Memory) [Easy/fun]
|
||
|
||
### 6.1 — Word Load/Store (lw, sw)
|
||
What: Add a data BRAM mapped at `0x8000_0000` (64 KB) and a load/store unit.
|
||
For now, only 32-bit aligned access (lw and sw).
|
||
|
||
Define the full memory bus contract here, not just the subset Phase 6.1
|
||
uses. Defining it once means Phase 6.2 (sub-word), Phase 7 (MMIO), Phase 12
|
||
(access faults), Phase 15 (atomics), and Phase 17 (DRAM) all plug into the
|
||
same interface without rework. The contract (see `CLAUDE.md` for the full
|
||
spec) is two payload structs (`mem_req_t`, `mem_rsp_t`) plus loose
|
||
valid/ready signals on each channel:
|
||
|
||
- `req_valid` (master→slave) / `req_ready` (slave→master) + `mem_req_t req`
|
||
- `rsp_valid` (slave→master) / `rsp_ready` (master→slave) + `mem_rsp_t rsp`
|
||
|
||
`mem_req_t` carries `addr`, `we`, `size`, `wdata`, `wstrb`, `amo`.
|
||
`mem_rsp_t` carries `rdata`, `err`.
|
||
|
||
For Phase 6.1 specifically: `req.size` is always `2'b10` (word),
|
||
`req.wstrb` is `4'b1111` on stores, `req.amo` is `4'h0`, `rsp.err` is
|
||
unused (BRAM never faults). The fields exist in the struct from day one
|
||
even when tied off.
|
||
|
||
Note: BRAM is 1-cycle read latency, so a load takes one extra cycle beyond
|
||
the EXECUTE state. The control FSM extends to FETCH → EXECUTE → MEM_WAIT →
|
||
WRITEBACK for loads. This is exactly why valid/ready is in the bus from
|
||
day one.
|
||
|
||
Why: A CPU without data memory can only work with 32 values (the registers). Load/
|
||
store connects the CPU to the outside world. Starting with word-only access keeps the
|
||
byte-lane logic simple while you verify the memory path works.
|
||
|
||
Why valid/ready now: BRAM responds in one cycle, so ready is always high. But when
|
||
you swap in DRAM later, responses take many cycles. If your interface already has
|
||
valid/ready handshaking, the swap is painless. If you hardwire it now, you'll have
|
||
to redesign the memory path later. Five minutes of future-proofing saves a weekend
|
||
of rework.
|
||
|
||
Test: Store values to various addresses, load them back, verify they match.
|
||
|
||
Future role: The bus interface is the foundation for your entire memory map (BRAM,
|
||
DRAM, UART, timer, interrupt controller — all hang off this bus).
|
||
|
||
### 6.2 — Byte and Halfword Access (lb, lbu, lh, lhu, sb, sh)
|
||
What: Add byte and halfword loads (signed and unsigned) and stores. This means byte
|
||
lane selection (which byte within a 32-bit word) and sign extension (lb sign-extends
|
||
to 32 bits, lbu zero-extends).
|
||
|
||
Why: C uses char (byte) and short (halfword) types constantly. String operations are
|
||
byte-by-byte. You can't run real C code without these.
|
||
|
||
Implementation: the load/store unit drives `req.size` (00=byte, 01=halfword,
|
||
10=word) and `req.wstrb` (which byte lane within the 32-bit word is being
|
||
written). On load, the unit muxes the right byte/halfword out of `rsp.rdata`
|
||
and applies sign/zero extension based on the opcode.
|
||
|
||
Decision for this project: **trap on misaligned access** rather than support
|
||
it in hardware. Hardware support for misaligned word access on Artix-7 is
|
||
expensive (two BRAM cycles + merge logic) and the kernel can emulate via
|
||
trap.
|
||
|
||
Critically, the LSU detects misalignment **locally, before issuing a bus
|
||
request** — it has the opcode size and the low address bits available the
|
||
moment the instruction is decoded. No misaligned request is ever placed on
|
||
the D-bus. The LSU raises the architectural cause directly: mcause 4 for a
|
||
load (`lh`/`lw` with bad alignment) and mcause 6 for a store/AMO. This
|
||
keeps `rsp.err` reserved for slave-reported faults (unmapped, peripheral
|
||
access violations) and matches how the trap classification table in
|
||
`CLAUDE.md` divides responsibility.
|
||
|
||
Testbench focus: Sign extension (loading 0xFF as signed byte should give
|
||
0xFFFFFFFF, as unsigned should give 0x000000FF). Byte lane selection
|
||
(storing 0xAB at address `0x8000_0001` must update only that byte).
|
||
Misaligned `lh`/`lw`/`sh`/`sw` produce the right mcause without ever
|
||
touching the bus.
|
||
|
||
Future role: Final. These instructions don't change.
|
||
|
||
### 6.3 — ILA on the Memory Bus
|
||
What: Attach ILA to the memory bus signals. Run a program that computes Fibonacci
|
||
numbers and stores them into a data array. Verify the memory contents.
|
||
|
||
Why: Memory bugs are subtle (off-by-one addresses, wrong byte lane, sign extension
|
||
errors). ILA on the bus lets you see exactly what's happening each cycle.
|
||
|
||
---
|
||
|
||
## Phase 7 — Memory-Mapped UART [Easy/fun]
|
||
|
||
### 7.1 — UART TX Module
|
||
What: A standalone UART transmitter. Baud rate 115200, 8 data bits, no parity,
|
||
1 stop bit (8N1). At 50 MHz the baud divisor is `50_000_000 / 115_200 ≈ 434`
|
||
clocks per bit. Interface: input byte, send signal, busy flag.
|
||
|
||
Why: This is your CPU's mouth. Once connected, the CPU can print to a serial terminal.
|
||
This replaces ILA as your primary debug tool for software — you can printf-debug your
|
||
C programs.
|
||
|
||
Test standalone: Wrap it in a tiny FSM that sends "hello\n" on reset. Synthesize,
|
||
connect to your terminal at 115200 baud. If you want, verify the waveform on your
|
||
scope — you'll see actual start bits, data bits, stop bits on the TX pin.
|
||
|
||
Future role: This exact module gets memory-mapped in 7.3 and eventually becomes the
|
||
console for your BIOS and Linux.
|
||
|
||
### 7.2 — UART RX Module
|
||
What: A standalone UART receiver. Same 8N1 format. Oversamples the input (typically
|
||
16x baud rate) to find bit centers. Outputs received byte + valid flag.
|
||
|
||
Why: This is your CPU's ear. Needed for any interactive console — typing commands,
|
||
sending data to the board.
|
||
|
||
Test: Send bytes from your terminal, verify they appear correctly. If you want to be
|
||
extra thorough, use your Eclypse + DAC zmod to generate serial waveforms with
|
||
deliberate timing skew and verify the receiver handles it.
|
||
|
||
Future role: Same as TX — becomes the console UART.
|
||
|
||
### 7.3 — Bus Decoder + Memory-Mapped I/O
|
||
What: Add an address decoder to your memory bus. Routing:
|
||
|
||
```
|
||
0x2000_0000 – 0x2000_FFFF instruction BRAM
|
||
0x8000_0000 – 0x8000_FFFF data BRAM
|
||
0x1000_0000 UART TX data (W: byte to send)
|
||
0x1000_0004 UART RX data (R: pops one byte from RX FIFO)
|
||
0x1000_0008 UART status (R: bit0 = tx_busy, bit1 = rx_valid)
|
||
```
|
||
|
||
Note this is a split register layout (separate TX/RX/status addresses), not
|
||
a 16550-style shared data register. Cleaner for a learning core; can be
|
||
reshaped later if you want a 16550-compatible model.
|
||
|
||
Why: Memory-mapped I/O is how CPUs talk to peripherals in the real world. The
|
||
CPU doesn't know it's talking to a UART — it just does a store to an address,
|
||
and the bus decoder routes it to the right place. This is the same pattern
|
||
used by every SoC ever.
|
||
|
||
Test: Write a program that stores ASCII bytes to `0x1000_0000` in a loop,
|
||
polling `0x1000_0008` bit 0 between writes. See the message in your terminal.
|
||
This is the most important milestone in the project — your CPU is now a
|
||
computer that communicates with the outside world through software.
|
||
|
||
Future role: The bus decoder grows as you add peripherals (timer, PLIC, DRAM)
|
||
but the structure stays. UART mapping stays at these addresses.
|
||
|
||
### 7.4 — "Hello World" (Hand-Assembled)
|
||
What: Hand-write an assembly program that prints "hello from rv32" to the UART
|
||
address (`0x1000_0000`), polling status bit 0 to wait for `tx_busy` to clear.
|
||
Assemble it, load into BRAM, run.
|
||
|
||
Why: Pure emotional milestone. Your CPU, your UART, your program, your message on
|
||
screen.
|
||
|
||
---
|
||
|
||
## Phase 8 — GCC Toolchain Integration [Easy/fun]
|
||
|
||
### 8.1 — Linker Script + Startup Code
|
||
What: Write a linker script that tells GCC where your instruction memory, data
|
||
memory, and stack live. Anchor `.text`/`.rodata` at `0x2000_0000`,
|
||
`.data`/`.bss` at `0x8000_0000`, stack growing down from the top of data BRAM
|
||
(`0x8001_0000`). Write crt0.S (C runtime startup): set the stack pointer,
|
||
zero out the BSS section (uninitialized global variables), call main.
|
||
|
||
Why: GCC doesn't just compile C to instructions — it expects a runtime environment.
|
||
The linker script defines the memory layout, and crt0 sets up the minimal environment
|
||
that C code assumes exists (a stack, zeroed globals).
|
||
|
||
Future role: The linker script evolves as your memory map grows (adding DRAM, flash).
|
||
crt0 grows when you add CSRs (setting up trap vectors in startup).
|
||
|
||
### 8.2 — First GCC Program
|
||
What: Write a trivial main() that prints a string to the UART by writing bytes
|
||
to `0x1000_0000`. Compile with:
|
||
```
|
||
riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 \
|
||
-nostdlib -T linker.ld -o firmware.elf crt0.S main.c
|
||
```
|
||
Convert: `objcopy -O binary firmware.elf firmware.bin`. Convert binary to
|
||
`.mem` format. Load into BRAM. Run.
|
||
|
||
March string discipline: advertise only what the hardware decodes. `rv32im`
|
||
is correct for Phase 8 because CSRs and `fence.i` aren't decoded yet. If the
|
||
program (or crt0) emits a CSR or `fence.i` op before then, the **decoder**
|
||
asserts `illegal_instr` and the Phase 8.3 halt latches the offending PC and
|
||
instruction word — catching the bug visibly rather than NOPing through it.
|
||
(Illegal instructions are a decode event, not a bus event; the memory bus
|
||
is not involved.) Switch to `rv32im_zicsr_zifencei` in Phase 12 once CSRs
|
||
land, and `rv32ima_zicsr_zifencei` in Phase 15 once atomics land.
|
||
|
||
Note on GCC 11+: newer toolchains require `_zicsr` / `_zifencei` in the
|
||
march string only when source code actually uses CSR or `fence.i`
|
||
instructions (since those moved out of base RV32I in the 2019 spec). Pure
|
||
arithmetic + UART-poke C does not need them.
|
||
|
||
Why: This proves your CPU is compatible with a real compiler. Any bugs in your
|
||
instruction implementation will surface here — GCC will use instructions in
|
||
combinations you never thought to hand-test.
|
||
|
||
Expect to iterate: GCC will probably emit an instruction you haven't implemented yet.
|
||
That's fine — check the illegal instruction, add it, resynthesize. This is normal.
|
||
|
||
### 8.3 — Fill Remaining RV32I Gaps
|
||
What: Implement any RV32I instructions you deferred. Common ones: all shift
|
||
variants (sll, srl, sra, slli, srli, srai), set-less-than variants (slti,
|
||
sltiu), `fence` (decode as NOP — there is no cache yet, so memory ordering
|
||
is trivially satisfied).
|
||
|
||
`ecall` / `ebreak` / unrecognized opcodes / `fence.i` / any CSR op all go
|
||
to a single illegal-instruction handler that **halts the core and asserts a
|
||
testbench-visible `illegal_instr` signal** with the offending PC and
|
||
instruction word latched. Do NOT decode them as NOPs — GCC emits `ebreak`
|
||
in `__builtin_trap`, abort paths, and some divide-by-zero configurations,
|
||
and silently NOPing past those destroys debuggability. The halt becomes a
|
||
real exception in Phase 12 (`mcause = 2` illegal, `mcause = 3` ebreak,
|
||
`mcause = 11` ecall-from-M).
|
||
|
||
Why: GCC's output will exercise the full ISA. You need complete RV32I
|
||
coverage for any non-trivial C code to work, and you need loud failures —
|
||
not silent ones — for the instructions you haven't implemented yet.
|
||
|
||
Test: Compile progressively more complex C programs. String manipulation, struct
|
||
usage, switch statements (these generate jump tables — exercises jalr with computed
|
||
addresses), recursive functions.
|
||
|
||
### 8.4 — Milestone: Meaningful Standalone C Program
|
||
What: Write something real but still self-contained — a checksum demo, tiny
|
||
benchmark, string/array exercise, or simple UART-driven calculator. Keep the
|
||
full serial monitor for Phase 9, where it becomes the BIOS instead of a one-off
|
||
test program.
|
||
|
||
Why: Confidence builder. You now have a working RISC-V computer that runs
|
||
compiled C and talks over serial. Everything after this is enrichment.
|
||
|
||
### 8.5 — riscv-tests Compliance
|
||
What: Clone `riscv-software-src/riscv-tests`. Build the `rv32ui-p-*` and
|
||
`rv32um-p-*` tests (the "p" variant assumes physical addressing, no virtual
|
||
memory — perfect for this stage). Each test is a tiny program that exercises
|
||
one or more instructions and writes a pass/fail code to a known address.
|
||
Write a small testbench harness that loads each test ELF into instruction
|
||
BRAM, runs the core until the test signals completion, and reports pass/fail.
|
||
|
||
Why: Hand-written testbenches catch the bugs you thought to look for. The
|
||
official suite catches the ones you didn't — corner cases in shifts, sign
|
||
extension on byte loads, immediate decoding for every format, M-extension
|
||
overflow cases. Doing this *before* Phase 12 means you're building trap
|
||
handling on a known-good ISA implementation, not stacking unknowns.
|
||
|
||
Expect to find bugs. That's the point. Fix each one, re-run the suite, move
|
||
on when rv32ui and rv32um pass clean.
|
||
|
||
Future role: Re-run the suite after every meaningful change to the core. It
|
||
becomes regression coverage for the rest of the project.
|
||
|
||
---
|
||
|
||
## Phase 9 — GCC-Built BIOS / Serial Monitor [Easy/fun]
|
||
|
||
What: Build a small BIOS in freestanding C/assembly with its own linker script,
|
||
startup code, UART driver, and command loop. It runs from the existing BRAM
|
||
image and gives you an interactive prompt over serial.
|
||
|
||
Suggested commands:
|
||
|
||
- `help` — list commands
|
||
- `peek <addr>` / `poke <addr> <value>` — inspect and edit memory-mapped state
|
||
- `dump <addr> <len>` — hex-dump memory
|
||
- `fill <addr> <len> <value>` — initialize memory
|
||
- `regs` — print a software-maintained register/trap snapshot when available
|
||
- `memtest` — run a small RAM/BRAM test
|
||
- `load` — receive a raw binary or hex stream over UART
|
||
- `run <addr>` — jump to a loaded program
|
||
- `reboot` — return to reset or spin until manual reset
|
||
|
||
Important hardware contract: a UART-loaded program needs somewhere executable
|
||
to live. In the early Harvard design, instruction BRAM is fetch-only unless you
|
||
deliberately expose a writable path. Pick one simple learning-friendly option:
|
||
|
||
- make instruction BRAM dual-port, with the CPU fetch path on one port and a
|
||
D-bus write/debug port on the other;
|
||
- add a small "program RAM" window that is writable through the D-bus and
|
||
fetchable through the I-bus;
|
||
- or defer `run`/ELF execution until you add one of those executable write paths.
|
||
|
||
Why: This is the first point where the board feels like your own computer. It
|
||
also becomes a practical debug tool for later hardware and firmware work.
|
||
|
||
Test: Boot to a prompt, use `poke`/`peek` on UART registers and RAM, load a tiny
|
||
raw program, jump to it, and have it return or print a message.
|
||
|
||
Future role: The BIOS can remain as a recovery/debug monitor even after flash,
|
||
DRAM, and Linux enter the picture.
|
||
|
||
---
|
||
|
||
## Phase 10 — Minimal ELF Loader [Easy/fun]
|
||
|
||
What: Teach the BIOS to receive and run the simplest possible RISC-V ELF32
|
||
binaries. Support statically linked, non-relocatable, little-endian
|
||
`EM_RISCV` executables with `PT_LOAD` segments only. Copy each loadable segment
|
||
to its physical address, zero the `memsz - filesz` tail for BSS, set a known
|
||
stack pointer, and jump to `e_entry`.
|
||
|
||
Deliberate limits:
|
||
- no dynamic linking
|
||
- no relocations
|
||
- no virtual memory
|
||
- no privilege separation
|
||
- no demand paging
|
||
- no filesystem
|
||
|
||
ABI for tiny programs: start with a tiny fixed contract. For example, `a0`
|
||
receives a pointer to a BIOS call table, `sp` points at the top of data RAM,
|
||
and returning from `main` jumps back to the monitor with an integer status.
|
||
Before Phase 12 traps exist, programs are trusted firmware payloads, not isolated
|
||
user processes.
|
||
|
||
Why: ELF loading is a major confidence milestone. You are no longer baking every
|
||
program into the FPGA bitstream; you can compile a new binary, send it over
|
||
serial, and run it on your CPU.
|
||
|
||
Test: Compile several tiny programs with fixed link addresses: hello world,
|
||
integer arithmetic, memory copy, and a program that returns a status code to
|
||
the BIOS. Confirm the BIOS rejects malformed or unsupported ELF files loudly.
|
||
|
||
Future role: This loader becomes the conceptual ancestor of the later bootloader
|
||
that copies kernels from flash to DRAM.
|
||
|
||
---
|
||
|
||
## Phase 11 — Tiny Kernel + Command Shell [Easy/fun]
|
||
|
||
What: Build a tiny kernel as a separate GCC-built ELF loaded by the BIOS.
|
||
Before Phase 12, "kernel" means bare-machine firmware with a command shell, not
|
||
an isolated privileged OS. It owns a simple console, command parser, memory
|
||
allocator, and a table of services. Keep it intentionally small: no MMU, no
|
||
userspace isolation, no real filesystem.
|
||
|
||
Suggested kernel features:
|
||
|
||
- banner and prompt
|
||
- `help`, `uptime`, `mem`, `dump`, `loadelf`, `run`, `reboot`
|
||
- direct UART console driver or BIOS-backed console calls
|
||
- bump allocator for kernel data structures
|
||
- simple program table showing loaded ELF images
|
||
- trusted program launch with an agreed calling convention
|
||
- status return from launched programs back to the shell
|
||
|
||
Simple ELF binaries: compile tiny freestanding programs that use the kernel or
|
||
BIOS service table for `putchar`, `getchar`, and exit. The first binaries can be
|
||
as small as "print a line", "sum an array", and "echo typed characters". The
|
||
goal is not POSIX; the goal is a complete end-to-end loop: compile on your host,
|
||
send over UART, load as ELF, run on your CPU, return to your shell.
|
||
|
||
Why: This is the rewarding middle game between "bare-metal C works" and "Linux
|
||
boot hangs somewhere in early init." You get OS-shaped learning while the system
|
||
is still small enough to understand in one sitting.
|
||
|
||
Future role: Phase 12 turns `ecall`, exceptions, and trap vectors into real
|
||
architectural mechanisms. At that point, the ad hoc service table can evolve into
|
||
a syscall ABI and the kernel can start handling faults instead of trusting every
|
||
program.
|
||
|
||
---
|
||
|
||
## Phase 12 — CSRs + M-Mode Trap Handling [Hard stretch]
|
||
|
||
What: Add Control and Status Registers (mstatus, mtvec, mepc, mcause, mtval, mie,
|
||
mip) and the CSR instructions (csrrw, csrrs, csrrc, csrrwi, csrrsi, csrrci). Add
|
||
mret (return from trap). Implement the trap entry mechanism: on an exception or
|
||
interrupt, save PC to mepc, jump to mtvec, set mcause.
|
||
|
||
Why: Traps and interrupts are how the CPU handles errors (illegal instruction, bad
|
||
memory access) and hardware events (timer fired, UART received a byte). No operating
|
||
system can function without this. M-mode (machine mode) is the highest privilege
|
||
level and the only one you need for bare-metal code and simple kernels.
|
||
|
||
Future role: This is the foundation for all OS-level functionality. Linux needs this
|
||
plus S-mode (supervisor mode), which you'll add later.
|
||
|
||
---
|
||
|
||
## Phase 13 — Timer [Hard stretch]
|
||
|
||
What: Implement mtime (a free-running 64-bit counter) and mtimecmp (comparison
|
||
register). When mtime >= mtimecmp, a timer interrupt fires.
|
||
|
||
Why: Every operating system needs a timer tick for scheduling. Even bare-metal
|
||
firmware needs delays and timeouts. This is your first interrupt source, which also
|
||
validates that the trap handling from Phase 12 actually works end-to-end.
|
||
|
||
---
|
||
|
||
## Phase 14 — Interrupt Controller [Hard stretch]
|
||
|
||
What: Build a minimal PLIC (Platform-Level Interrupt Controller) or a simplified
|
||
version. Connect UART RX as an interrupt source. Implement interrupt priority and
|
||
enable/disable.
|
||
|
||
Why: Polling the UART wastes CPU cycles. With interrupts, the CPU does other work
|
||
and only handles UART when data arrives. A real system has many interrupt sources —
|
||
the PLIC manages them. This is required for Linux.
|
||
|
||
---
|
||
|
||
## Phase 15 — A Extension (Atomics) [Hard stretch]
|
||
|
||
What: Implement the RV32A atomic instructions: `lr.w` / `sc.w` (load-reserved /
|
||
store-conditional) and the AMO ops (`amoswap.w`, `amoadd.w`, `amoand.w`,
|
||
`amoor.w`, `amoxor.w`, `amomin.w`, `amomax.w`, `amominu.w`, `amomaxu.w`).
|
||
Switch the GCC march string to `rv32ima_zicsr_zifencei`. Run `rv32ua` from
|
||
`riscv-tests`.
|
||
|
||
Why now: Mainline Linux's RISC-V kernel is built against `rv32ima` /
|
||
`rv64ima` — atomics are not optional for an unmodified kernel build. The
|
||
kernel uses LR/SC and AMO for spinlocks, refcounts, and futexes; without
|
||
them you maintain a non-standard fork. Adding A before the Linux work means
|
||
you discover atomics-related bugs in a small, focused phase rather than
|
||
wedged inside a kernel boot.
|
||
|
||
Single-hart implementation: the reservation set is just one register
|
||
(reserved address + valid bit). LR sets it; any store to that address (or
|
||
context switch) clears it; SC checks it and either commits or returns 1.
|
||
AMOs are "read, op, write" sequences that the bus must perform atomically —
|
||
on this single-master core that's free; the load/store unit just holds the
|
||
bus across the read-modify-write. This becomes meaningful only when DRAM
|
||
or DMA enters the picture.
|
||
|
||
Future role: Final for single-hart. If you ever go multi-hart, the
|
||
reservation set and bus become more involved.
|
||
|
||
---
|
||
|
||
## Phase 16 — Pipeline (Optional but educational) [Hard stretch]
|
||
|
||
What: Insert pipeline registers between your stages (fetch|decode|execute|
|
||
memory|writeback). Handle data hazards (forwarding/stalling) and control
|
||
hazards (branch prediction or pipeline flush).
|
||
|
||
Why: Your single-cycle core's clock speed is limited by the longest
|
||
combinational path (probably through the ALU). Pipelining lets each stage
|
||
run in one short cycle. This is the classic computer architecture exercise
|
||
and deeply educational.
|
||
|
||
Why optional: A non-pipelined core can run at 50 MHz on the Artix-7. That's
|
||
plenty for booting Linux. Pipeline if you want to learn, not because you
|
||
must — and if you do, do it before DRAM/firmware work piles on. Re-running
|
||
`riscv-tests` (Phase 8.5 + 15) after pipelining catches regressions.
|
||
|
||
---
|
||
|
||
## Phase 17 — SPI Flash Boot + DRAM [Overkill/hard]
|
||
|
||
What: Add an SPI flash controller to boot from the on-board flash (instead
|
||
of BRAM initialization). The Arty A7-100T has a 16 MB Quad-SPI flash; map
|
||
it at `0x0000_0000–0x00FF_FFFF` inside the reserved boot aperture.
|
||
Integrate AMD/Xilinx MIG IP for the DDR3L on the Arty (256 MB). Map DRAM at
|
||
`0x8000_0000–0x8FFF_FFFF`, replacing the data BRAM in that range.
|
||
|
||
Boot flow: reset PC moves to `0x0000_0000` (flash). A small first-stage
|
||
copies the kernel/firmware image from flash to DRAM, sets up the trap
|
||
vector, and jumps to DRAM.
|
||
|
||
Why: BRAM is tiny (~600 KB total on Artix-7 100T). Linux needs megabytes.
|
||
DRAM gives you 256 MB; flash gives you persistent storage for the
|
||
bootloader. This is how real embedded systems boot.
|
||
|
||
Bus implications: DRAM via MIG has multi-cycle, variable-latency responses.
|
||
This is the first time `req_valid`/`req_ready`/`rsp_valid`/`rsp_ready`
|
||
actually matter — the handshake you wired in Phase 6.1 finally earns its
|
||
keep.
|
||
|
||
---
|
||
|
||
## Phase 18 — S-Mode, U-Mode, Sv32 Virtual Memory [Overkill/hard]
|
||
|
||
What: Add supervisor and user privilege modes. Implement Sv32 page table
|
||
walking (two-level page tables, 4 KB pages). Add the `satp` CSR, page-fault
|
||
exceptions (instruction/load/store page-fault), `sfence.vma`, and S-mode
|
||
CSRs (`sstatus`, `stvec`, `sepc`, `scause`, `stval`, `sie`, `sip`,
|
||
`sscratch`).
|
||
|
||
Why: Linux runs the kernel in S-mode and user programs in U-mode. Virtual
|
||
memory gives each process its own address space and protects the kernel
|
||
from user code. This is the last big architectural piece before Linux.
|
||
|
||
Test: a hand-written M-mode "kernel" that maps a U-mode page, traps on
|
||
syscall, returns a value, then returns to U-mode. Verify all three privilege
|
||
modes round-trip via `mret` / `sret`.
|
||
|
||
---
|
||
|
||
## Phase 19 — Linux Boot Contract (SBI / Device Tree / ABI) [Overkill/hard]
|
||
|
||
What: Lock down everything Linux expects at the moment of `kernel_entry`.
|
||
This is a real subproject, not a footnote inside "port Linux".
|
||
|
||
Required deliverables:
|
||
- **Boot register state at kernel entry**: `a0` = hart ID (0 for single-hart),
|
||
`a1` = physical address of the device tree blob, `satp = 0`, all other
|
||
state per the [RISC-V Linux boot protocol](https://docs.kernel.org/arch/riscv/boot.html).
|
||
- **Image alignment**: rv32 kernel image base must be 4 MiB-aligned in
|
||
physical memory (rv64 is 2 MiB). Reflect this in the linker layout for the
|
||
loaded kernel and the bootloader's copy destination.
|
||
- **Device tree**: hand-write a `.dts` describing CPU, memory (DRAM base +
|
||
size), CLINT (timer + soft IPI), PLIC (interrupt controller bindings),
|
||
and the UART node. For the CPU node, prefer the **modern** ISA properties:
|
||
```
|
||
riscv,isa-base = "rv32i";
|
||
riscv,isa-extensions = "i", "m", "a", "zicsr", "zifencei";
|
||
mmu-type = "riscv,sv32";
|
||
```
|
||
Optionally include the legacy `riscv,isa = "rv32ima_zicsr_zifencei"`
|
||
string for older kernels that don't yet parse the split form. Compile
|
||
with `dtc` to a `.dtb` and ship it in flash alongside the kernel.
|
||
- **UART driver/binding decision**: the split TX/RX/status UART from
|
||
Phase 7 is *not* `8250/16550`-compatible. Pick one:
|
||
- (a) Add a 16550-subset wrapper (THR/RBR shared at offset 0, LSR at
|
||
offset 5, IER at offset 1, FCR/LCR mostly stubs). Then the in-tree
|
||
`8250_dw` or `of_serial` driver works with a stock binding.
|
||
- (b) Write a small custom Linux serial driver and define a
|
||
`compatible = "fpgacore,uart"` binding. More work, more learning.
|
||
- **Boot firmware**: choose between
|
||
- (a) **Direct M-mode Linux**: use the kernel's `CONFIG_RISCV_M_MODE`
|
||
path. Less portable, no SBI required, fewer moving pieces. Reasonable
|
||
for a learning bring-up.
|
||
- (b) **OpenSBI + S-mode Linux**: build OpenSBI as the M-mode firmware,
|
||
Linux runs in S-mode. Standard production path, more Vivado/Linux build
|
||
surface area.
|
||
|
||
Why: Skipping this phase means hitting all of these problems simultaneously
|
||
during Phase 20 bring-up, where the failure mode is "kernel hangs silently
|
||
in early boot" with no console. Doing it explicitly turns Phase 20 into a
|
||
debugging exercise on a known-good ABI surface.
|
||
|
||
---
|
||
|
||
## Phase 20 — Linux [Overkill/hard]
|
||
|
||
What: Build a minimal RISC-V Linux kernel against the device tree and
|
||
boot path defined in Phase 19. Build an initramfs with BusyBox. Load
|
||
kernel + DTB + initramfs to flash. Boot to a shell prompt over UART.
|
||
|
||
Why: This is the summit. A Linux shell running on a CPU you built from
|
||
scratch.
|
||
|
||
---
|
||
|
||
## Quick Reference: What You Need Installed
|
||
|
||
- Vivado 2025.2 or later (synthesis, simulation, ILA, VIO)
|
||
- RISC-V GCC toolchain (`riscv64-unknown-elf-gcc`, multilib build). March
|
||
string evolves: `rv32im` (Phase 8) → `rv32im_zicsr_zifencei` (Phase 12) →
|
||
`rv32ima_zicsr_zifencei` (Phase 15).
|
||
- `riscv-tests` repo cloned and buildable (Phase 8.5 onward; rv32ua added at
|
||
Phase 15)
|
||
- Device-tree compiler `dtc` (Phase 19+)
|
||
- OpenSBI source (Phase 19+, only if you choose the SBI boot path)
|
||
- Terminal program (minicom, picocom, or PuTTY) for UART, 115200 8N1
|
||
- Text editor you like for SystemVerilog
|
||
- The RISC-V ISA spec (Volume 1: Unprivileged, Volume 2: Privileged) — free PDFs
|
||
- The Linux RISC-V boot protocol doc (`Documentation/arch/riscv/boot.rst`)
|