A from-scratch RISC-V SoC on real silicon (Zybo Z7-20 / Zynq XC7Z020): a 5-stage RV32I core with I-cache/D-cache, a 16×16 INT8 systolic NPU, an 8-lane SIMT-lite GPU, and a unified compute fabric that lets the GPU borrow the NPU's DSPs — so the full accelerator suite fits in one small FPGA. You write RISC-V assembly on the PC, it streams to the core over UART, runs on the board, and the registers update live.
Headline: the NPU and GPU never run at the same time, so the 8 GPU multiply-lanes are time-shared onto 8 of the NPU's processing-element DSPs. The GPU therefore costs 0 extra DSP — the whole SoC is 202/220 DSP (92 %) instead of 210 (which would not route), and it is verified on real silicon (14/14 on-board tests pass @ 100 MHz). See
docs/UNIFIED_NPU_GPU.md.
The design is a single source of truth in VHDL under rtl/. Everything below is real RTL,
synthesized, and (where noted) verified on the board.
Classic 5-stage pipeline (pc · icache · id · ex · mem · wb, top = rv32_core.vhd):
- RV32I base ISA + Zicsr, trap/exception unit, and FENCE.I.
- Hazard unit + forwarding unit (full EX/MEM/WB bypass), branch-compare unit in EX.
- I-cache and D-cache (
core/icache,core/mem) with an AXI4 master to backing memory; caches infer block RAM (synchronous read). - Verified against an instruction-set simulator bit-for-bit (see
docs/VERIFICATION.md).
An output-stationary systolic array of N×N processing elements (npu_pe.vhd), wrapped by
npu_top.vhd / npu_top16.vhd (N=16) as an uncached MMIO slave at 0x3xxx_xxxx:
- Each PE accumulates
C[i][j] += a*bon INT8 operands; the multiply-accumulate maps to one DSP48E1. A skew feeder streams the A-rows / B-cols; contraction depth K = 1…64 (tiled by SW). - 16×16 = 256 MACs; the first
DSP_BUDGET(200) map to DSP, the rest to LUT fabric. - A 5-stage pipelined read-back isolates the 256:1 accumulator mux and the requantize
multiply (INT32 → INT8:
clip((C*mult + round) >> shift)), closing timing toward 100 MHz. - Map:
0x.000CTRL/STATUS/K/CFG,0x.1000A,0x.2000B,0x.3000C. Seedocs/NPU_DESIGN.md.
A single-instruction, multiple-thread engine: one PC drives 8 lanes in lockstep with a
per-lane predicate mask (gpu_core.vhd, gpu_lane.vhd, gpu_pkg.vhd), MMIO slave at 0x4xxx_xxxx:
- 8 vector regs (V0–V7) × 8 lanes, 8 scalar regs, 256-word instruction memory, and an 8-bank block-RAM scratchpad.
- Vector ISA:
VADD/VSUB/VAND/VOR/VXOR/VSLL/VSRL/VSRA/VMIN/VMAX(LUT ALU),VMUL/VMAC(DSP multiply-add),VSLT/VSEQ(predicate),VLID/VMOVI/VBCAST/VLD/VST, scalarSADDI/SBNZfor loops,MASKON,HALT. - 3-cycle vector-ALU pipeline isolates the DSP multiply; the scratchpad read is BRAM-latency-aware.
See
docs/GPU_DESIGN.md.
The XC7Z020 has only 220 DSP. A 16×16 NPU eats ~200; a separate 8-lane GPU adds 8 → 210, which won't route. But the NPU and GPU never run simultaneously, so the multipliers are time-shared:
npu_pegains aGPU_LANEmode. There is exactly one multiply expressionmul_a*mul_b + mul_cwith muxed operands, so synthesis maps both modes onto a single DSP48E1 — Vivado reports mode(C or P)+A*B(C-input for the GPU add, P-feedback for the NPU accumulate). Proven: 1 DSP per dual-mode PE.- The 8 GPU lanes drop their own multipliers and borrow
PE(0,0..7). The bus is threadedgpu_core → gpu_top ↔ mmio_bridge ↔ npu_top16 → npu_array;gpu_activedrives the PE mode. - Result: full 16×16 NPU + GPU = 202 DSP, same footprint as the silicon-proven core+NPU.
mmio_bridge.vhd exposes the four Pmod headers JA–JE (40 pins) as memory-mapped peripherals
at 0x1xxx_xxxx, controllable directly from assembly with sw/lw (no firmware change):
2× SPI (spi_master), 2× I2C (i2c_master), UART (uart_lite), PWM (pwm_gen), and a 22-pin
GPIO bank (gpio_port), plus the legacy LED/SW/BTN. See docs/PMOD_PERIPHERALS_DESIGN.md.
Dual-core RV32 variants (independent + shared-memory SPMD) exist and pass their testbenches;
OOC-synthesized and timing-closed. See docs/MULTICORE_DESIGN.md / docs/MULTICORE_TIMING.md.
rv32_platform is the whole PL subsystem packaged as a Vivado IP with an AXI-Lite control slave
(rv32_ctrl_axi.vhd). The ARM PS runs a bare-metal monitor (sw/firmware/rv32_monitor.c,
built for the Cortex-A9 in Vitis → rv_firmware.elf) that turns one-line UART commands into
AXI-Lite transactions on the core — so the host can load, run, single-step, and read back the
RV32 over the board's USB-UART, with no DDR (the board's DDR is faulty; everything goes
JTAG/PS-AXI).
| window | what |
|---|---|
0x0000_0000… |
instruction / data RAM (cached, Harvard) |
0x1000_0000 |
Pmod MMIO — LED/SW/BTN, GPIO +0x20, SPI0/1 +0x40/0x60, I2C0/1 +0x80/0xA0, UART +0xC0, PWM +0xE0 |
0x3000_0000 |
NPU 16×16 (CTRL/STATUS/K/CFG, A/B/C buffers) |
0x4000_0000 |
GPU 8-lane (CTRL/STATUS, IMEM +0x1000, scratchpad +0x4000) |
(The PS↔core control slave rv32_ctrl_axi lives at 0x4000_0000 in PS address space — a
different bus from the core's own 0x4 GPU window.)
rtl/ VHDL — single source of truth
core/ 5-stage pipeline: pc·icache·id·ex·mem·wb + rv32_core.vhd
npu/ 16×16 INT8 systolic GEMM (npu_pe is the dual-mode shared-DSP cell)
gpu/ SIMT-lite vector coprocessor (gpu_core/lane/top + gpu_pkg)
soc/ mmio_bridge, Pmod peripherals, rv32_platform, rv32_ctrl_axi, multicore
sim/ testbenches + C/Python golden models
gpu/ npu/ soc/ multicore/
fpga/
scripts/ IP packaging, BD build, bitstream, proofs (see fpga/scripts/README.md)
constraints/ zybo_z7_20_gpio.xdc / zybo_z7_20_pmod.xdc
flash/ on-board bring-up: ps7_init.tcl, jtag_*.tcl, run_*.bat, bitstreams
sw/
host/ PC tools — rv32_gui.py (PySide6), rv32_console.py (CLI), assembler
firmware/ RV32 monitor source notes
rv_ps/rv_firmware/ ARM PS monitor (rv32_monitor.c) + prebuilt rv_firmware.elf
docs/ design + verification docs, diagrams
(gitignored) ip_repo/ ip_build/ vivado_zynq/ — regenerated by the build scripts
| metric | result |
|---|---|
| Whole-SoC DSP (core + caches + 16×16 NPU + GPU) | 202 / 220 (92 %) — separate would be 210 |
| Dual-mode PE | 1 DSP48E1 does NPU MAC or GPU multiply-add ((C or P)+A*B) |
| Routing | 0 failed nets (the separate 210-DSP build stalled here) |
| Timing @ 100 MHz | hold met (+0.045 ns); setup WNS −0.054 ns post-route phys-opt (Fmax ≈ 99.5 MHz) |
| Bitstream | fpga/flash/rv32_unified_100mhz.bit |
| On-board, real silicon @ 100 MHz | NPU GEMM = 39,53,17,23 ✅ · GPU VMUL/VMAC on the shared DSPs ✅ |
| Comprehensive on-board suite | 14/14 PASS — RV32 loop, NPU GEMM, all GPU ops, GPU SBNZ branch-loop |
| Simulation | NPU GEMM golden, GPU tb_unified (vadd/saxpy/relu/vmac) all pass |
# Python pipeline/ISS regressions (no Vivado):
cd sim && python run_ifwb_core.py --programs 4000
# HDL (xsim) regressions:
sim\gpu\run_gpu.bat # GPU SIMT coprocessor -> ALL TESTS PASS
sim\multicore\run_dual.bat # dual-core RV32 SPMD -> ALL PASS
# unified fabric (1-DSP proof + NPU GEMM + GPU on the shared DSPs):
vivado -mode batch -source fpga\scripts\run_unified_proof.tcl # 202 DSP + sim
:: 1) package the PL (rv32_platform) as an IP — version bumped each run so Vivado
:: re-synthesizes from current RTL (a same-version repackage reuses a stale netlist)
vivado -mode batch -source fpga\scripts\package_platform_ip.tcl
:: 2) build the Zynq block design + synth + impl + bitstream (FCLK0 = 100 MHz)
vivado -mode batch -source fpga\scripts\build_npu16_100mhz.tcl
Output: vivado_zynq/.../impl_1/rv32_top_wrapper.bit (copied to fpga/flash/). The Zybo pin
constraints fpga/constraints/zybo_z7_20_{gpio,pmod}.xdc must be in the impl constraint set
(fpga/scripts/add_xdc_rebuild.tcl adds them if a build skipped them). See fpga/scripts/README.md.
With the board on USB, program the FPGA and launch the PS monitor over JTAG:
flash\run_monitor.bat # fpga -f <bitstream>; dow rv_firmware.elf; con (monitor runs on the ARM)
The monitor now listens on the board's PS-UART COM port at 115200. (DDR is faulty, so the
RV32 is driven entirely through JTAG → PS-AXI → rv32_ctrl_axi; no QSPI/DDR boot.)
pip install pyserial PySide6
python sw\host\rv32_gui.py
Pick the board COM port (use sw\host\port_probe.py if unsure — the monitor replies pc=…/st=…),
Connect, type assembly, Run → the Registers panel highlights every value that changed,
live, as it ran on the silicon. Step single-steps; Reset clears. CLI alternative:
python sw\host\rv32_console.py --port COM10
rv32> addi x1,x0,7 ↵ add x3,x1,x2 ↵ run -> x1=7 x3=18
flash\run_comprehensive.bat # 14 cases: RV32 loop, NPU GEMM, every GPU op, GPU branch-loop
Expect ==== COMPREHENSIVE BOARD TEST: 14 PASS / 0 FAIL ====. The kernels are generated by
sw/host/gen_board_tests.py (RV32 assembler + a small GPU-ISA encoder).
AXI-Lite control slave (rv32_ctrl_axi, PS base 0x4000_0000)
| off | R/W | name | meaning |
|---|---|---|---|
| 0x00 | W | CTRL | b0 reset · b1 run · b2 step · b3 clr_commit |
| 0x04 | R | STATUS | b0 halted · b2 run |
| 0x08/0x0C | W | IMEM_ADDR / WDATA | load instruction memory |
| 0x10/0x14 | W | DMEM_ADDR / WDATA | load data memory |
| 0x18/0x1C | W/R | REG_ADDR / RDATA | pick register N, then read it |
| 0x20 | R | PC | current PC |
| 0x24/0x28 | R | LAST_RD / WDATA | last retired rd / written value |
| 0x2C | R | COMMIT_CNT | retired-instruction count |
UART monitor protocol (one line = one command, hex): r reset · i A D imem · d A D dmem ·
g run · s step · x N read reg · m A read dmem · p PC · c commits · t status ·
D dump all regs · L/W/N LED/SW/BTN · h help.
docs/UNIFIED_NPU_GPU.md— the shared-DSP fabric (design + silicon results)docs/NPU_DESIGN.md·docs/GPU_DESIGN.md— acceleratorsdocs/SOC_INTEGRATION.md·docs/SOC_PLATFORM_DESIGN.md— Zynq platformdocs/PMOD_PERIPHERALS_DESIGN.md— Pmod MMIO map + examplesdocs/MULTICORE_DESIGN.md·docs/VERIFICATION.md·docs/BOARD_VERIFICATION_REPORT.md
RV32I+Zicsr+trap+FENCE.I core, I-cache/D-cache/AXI, 16×16 INT8 NPU, 8-lane SIMT GPU, the unified shared-DSP fabric, Pmod peripherals, dual-core variants, the Zynq platform + ARM monitor, and the PC GUI/console are all complete and verified on real XC7Z020 silicon @ 100 MHz — including a 14/14 on-board test suite and live register read-back in the GUI.