8edfc86027
Convert the memory-bus guidance from a loose list of request/response fields into packed mem_req_t and mem_rsp_t payload structs carried by separate valid/ready handshakes. Spell out that the same channel shape is instantiated independently for the instruction bus and data bus so the design remains Harvard at the bus level while still sharing a single contract. Clarify fault ownership before RTL exists: slaves only raise rsp.err for access faults they can own, the LSU detects misaligned loads/stores before issuing a D-bus request, the decoder owns illegal instructions and ebreak/ecall decode events, and the future MMU owns page faults before translated requests reach the bus. Update the roadmap to use the structured bus field names, correct the misalignment tests and GCC illegal-instruction explanation, and prefer modern RISC-V device-tree ISA properties with an optional legacy riscv,isa string for older kernels.
692 lines
32 KiB
Markdown
692 lines
32 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
|
||
|
||
---
|
||
|
||
## Memory Map (target — fixed in Phase 0, evolves through Phase 14)
|
||
|
||
```
|
||
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 14)
|
||
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 14
|
||
```
|
||
|
||
Reset PC = `0x2000_0000`. Locking this in now keeps the linker script and crt0
|
||
stable across phases.
|
||
|
||
---
|
||
|
||
## Phase 0 — Architecture Contract
|
||
|
||
### 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
|
||
|
||
### 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
|
||
|
||
### 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
|
||
|
||
### 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 9), 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")
|
||
|
||
### 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)
|
||
|
||
### 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)
|
||
|
||
### 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 9
|
||
(access faults), Phase 12 (atomics), and Phase 14 (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
|
||
|
||
### 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
|
||
|
||
### 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 9 once CSRs
|
||
land, and `rv32ima_zicsr_zifencei` in Phase 12 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 9 (`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 C Program
|
||
What: Write something real — a serial monitor that accepts commands over UART
|
||
and responds. Or a tiny Forth interpreter. Something interactive that proves
|
||
the core is solid.
|
||
|
||
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 9 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 — CSRs + M-Mode Trap Handling
|
||
|
||
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 10 — Timer
|
||
|
||
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 9 actually works end-to-end.
|
||
|
||
---
|
||
|
||
## Phase 11 — Interrupt Controller
|
||
|
||
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 12 — A Extension (Atomics)
|
||
|
||
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 13 — Pipeline (Optional but educational)
|
||
|
||
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 + 12) after pipelining catches regressions.
|
||
|
||
---
|
||
|
||
## Phase 14 — SPI Flash Boot + DRAM
|
||
|
||
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 15 — S-Mode, U-Mode, Sv32 Virtual Memory
|
||
|
||
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 16 — Linux Boot Contract (SBI / Device Tree / ABI)
|
||
|
||
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 17 bring-up, where the failure mode is "kernel hangs silently
|
||
in early boot" with no console. Doing it explicitly turns Phase 17 into a
|
||
debugging exercise on a known-good ABI surface.
|
||
|
||
---
|
||
|
||
## Phase 17 — Linux
|
||
|
||
What: Build a minimal RISC-V Linux kernel against the device tree and
|
||
boot path defined in Phase 16. 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 9) →
|
||
`rv32ima_zicsr_zifencei` (Phase 12).
|
||
- `riscv-tests` repo cloned and buildable (Phase 8.5 onward; rv32ua added at
|
||
Phase 12)
|
||
- Device-tree compiler `dtc` (Phase 16+)
|
||
- OpenSBI source (Phase 16+, 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`)
|