Establish the repository as a documentation-first plan for a custom SystemVerilog RISC-V CPU targeting the Digilent Arty A7 100T. Add the initial README, roadmap, and contributor guidance that define the starting RV32IM direction, Vivado/RISC-V toolchain expectations, basic SystemVerilog conventions, and the phased path from an architecture contract toward a Linux-capable SoC. This commit intentionally contains planning and interface direction only; RTL, firmware, testbenches, and Vivado project files are left for later phases.
20 KiB
RV32IM CPU Core — Build Roadmap
Target: Digilent Arty A7 100T / Vivado 2025.2.1 / SystemVerilog
End goal: Boot Linux on a custom RISC-V core
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
1.1 — ALU Module + Simulation
What: Build a combinational ALU that handles all RV32I operations (add, sub, and, or, xor, slt, sltu, shifts) AND the M extension operations (mul, mulh, mulhsu, mulhu, div, divu, rem, remu). 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. Including M extension now costs almost nothing (a few extra case statements) but saves you from touching this module again later.
Why M extension from day one: GCC emits multiply/divide constantly. Without it, the compiler falls back to software emulation via libgcc — slow and painful. Baking it in now means your first GCC-compiled program just works.
Testbench focus: signed vs unsigned comparisons (SLT vs SLTU), arithmetic shift right vs logical shift right, signed overflow, division by zero (RISC-V spec says specific results, not an exception).
Future role: This module is final. It goes into the finished core unchanged.
1.2 — ALU on FPGA with VIO
What: Synthesize the ALU on the Arty. Attach Vivado VIO (Virtual I/O) cores to the inputs and outputs. Use the Vivado hardware manager to feed operands and read results in real time.
Why: Confirms the ALU works in real hardware, not just simulation. Gets you comfortable with the VIO workflow — you'll use it again. Also catches synthesis issues early (e.g., if your multiply path is too slow for the clock).
What is VIO: A Vivado IP that lets you poke values into signals and read signals out through JTAG, right from the Vivado GUI. Think of it as virtual switches and LEDs but with 32-bit width and no board wiring.
Future role: VIO familiarity pays off throughout the project. The ALU itself is final.
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. Add a PC (program counter) register that starts at 0 and increments by 4 each cycle. Wire the chain: BRAM[PC] → decoder → ALU → register file writeback. Support only R-type and I-type arithmetic for now (add, sub, addi, and, or, xor, slt, slti, lui, auipc, shifts).
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.
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 a design choice that pays off when you pipeline later (branch decision can be made in the decode stage without waiting for ALU).
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 and a load/store unit. For now, only 32-bit aligned access (lw and sw). Define a simple memory bus interface with address, write data, read data, write enable, and valid/ready signals.
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.
Testbench focus: Sign extension (loading 0xFF as signed byte should give 0xFFFFFFFF, as unsigned should give 0x000000FF). Unaligned access behavior (RISC-V base spec says misaligned access can trap — decide if you want to support it or trap).
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. Fixed baud rate (115200 is standard), 8 data bits, no parity, 1 stop bit (8N1). 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. Address range 0x00000000-0x0FFFFFFF routes to BRAM (instruction + data), address 0x10000000 routes to UART TX (write the byte to send), 0x10000000 routes to UART RX (read the received byte). Add a status register at 0x10000004 (TX busy flag, RX data available flag).
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 0x10000000 in a loop. 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, interrupt controller, DRAM) but the structure stays. UART mapping stays at this address.
7.4 — "Hello World" (Hand-Assembled)
What: Hand-write an assembly program that prints "hello from rv32" to the UART address. 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. 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 0x10000000. Compile with: riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -nostdlib -T linker.ld -o firmware.elf. Convert: objcopy -O binary firmware.elf firmware.bin. Convert binary to .mem format. Load into BRAM. Run.
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 (NOP for now — you have no cache), ecall/ebreak (NOP for now — you have no trap handling).
Why: GCC's output will exercise the full ISA. You need complete RV32I coverage for any non-trivial C code to work.
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.
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 — 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 single-cycle core can run at maybe 50-80 MHz on the Artix-7. That's plenty for booting Linux. Pipeline if you want to learn, not because you must.
Phase 13 — SPI Flash Boot + DRAM
What: Add an SPI flash controller to boot from the on-board flash (instead of BRAM initialization). Integrate AMD/Xilinx MIG IP for the DDR3 on the Arty A7. Update your memory map: flash at boot, copy to DRAM, jump to DRAM.
Why: BRAM is tiny (a few hundred KB on Artix-7 100T). Linux needs megabytes. DRAM gives you 256MB. Flash gives you persistent storage for the bootloader. This is how real embedded systems boot.
Phase 14 — S-Mode, U-Mode, Sv32 Virtual Memory
What: Add supervisor and user privilege modes. Implement Sv32 page table walking (two-level page tables, 4KB pages). Add the satp CSR, page fault exceptions, and sfence.vma.
Why: Linux runs the kernel in S-mode, 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 major architectural piece before Linux.
Phase 15 — Linux
What: Port a minimal Linux kernel (or use an existing RISC-V port). Write a device tree for your SoC. Build an initramfs with BusyBox. Boot to a shell prompt.
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.1 (synthesis, simulation, ILA, VIO)
- RISC-V GCC toolchain (riscv64-unknown-elf-gcc)
- Terminal program (minicom, picocom, or PuTTY) for UART
- Text editor you like for SystemVerilog
- The RISC-V ISA spec (Volume 1: Unprivileged, Volume 2: Privileged) — free PDFs