Skip to content

Commit d27f847

Browse files
committed
feat: RISC-V SoC with a memory-mapped systolic-array NPU accelerator
A full system-on-chip: a 5-stage pipelined RV32I CPU, boot ROM, RAM, and a memory-mapped systolic matrix-multiply NPU on one address-decoded data bus. Real firmware (assembled by an included RV32I assembler) loads two matrices into the accelerator, starts it, polls STATUS until done, reads the result back, and writes it to RAM -- hardware/software co-design with a start/done offload handshake. Testbench boots the SoC and verifies the NPU-computed product [[1,2],[3,4]] x [[5,6],[7,8]] = [[19,22],[43,50]] (4/4). Verified on Icarus + CI.
0 parents  commit d27f847

12 files changed

Lines changed: 771 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: sim
2+
on: [push, pull_request]
3+
4+
jobs:
5+
icarus:
6+
runs-on: ubuntu-latest
7+
steps:
8+
- uses: actions/checkout@v4
9+
- name: Install Icarus Verilog
10+
run: sudo apt-get update && sudo apt-get install -y iverilog
11+
- name: Re-assemble firmware (sanity check the toolchain)
12+
run: |
13+
python3 tools/assemble.py firmware/firmware.s > /tmp/fw.hex
14+
echo "assembled $(wc -l < /tmp/fw.hex) instructions"
15+
- name: Compile
16+
run: iverilog -g2012 -o sim.out rtl/rv32i_core.sv rtl/systolic_mm.sv rtl/npu_periph.sv rtl/soc_top.sv tb/soc_tb.sv
17+
- name: Run self-checking testbench
18+
run: |
19+
vvp sim.out | tee sim.log
20+
grep -q "ALL TESTS PASSED" sim.log

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Simulation artifacts
2+
*.vcd
3+
*.out
4+
sim.out
5+
*.hex
6+
work/
7+
*.jou
8+
*.log
9+
xsim.dir/
10+
.Xil/
11+
*.wdb
12+
*.pb
13+
__pycache__/

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Efe Demir
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

Makefile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# RISC-V NPU SoC — simulation shortcuts
2+
SRC := rtl/rv32i_core.sv rtl/systolic_mm.sv rtl/npu_periph.sv rtl/soc_top.sv tb/soc_tb.sv
3+
4+
sim: ## Compile + run the SoC testbench
5+
iverilog -g2012 -o sim.out $(SRC)
6+
vvp sim.out
7+
8+
firmware: ## Re-assemble firmware.s -> hex
9+
python tools/assemble.py firmware/firmware.s
10+
11+
wave: sim ## Run, then open waveforms in GTKWave
12+
gtkwave soc.vcd
13+
14+
clean:
15+
rm -f sim.out *.vcd *.log
16+
17+
.PHONY: sim firmware wave clean

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# RISC-V SoC with an NPU Accelerator (SystemVerilog)
2+
3+
[![sim](https://github.com/efedemi/riscv-npu-soc/actions/workflows/ci.yml/badge.svg)](https://github.com/efedemi/riscv-npu-soc/actions/workflows/ci.yml)
4+
5+
A complete **system-on-chip**: a pipelined RISC-V CPU, a boot ROM, RAM, and a
6+
**memory-mapped systolic matrix-multiply NPU** on one data bus — running real
7+
firmware. The CPU's program loads two matrices into the accelerator, starts it,
8+
polls for completion, reads the result back, and writes it to memory. This is
9+
**hardware/software co-design**: the same offload-to-an-accelerator pattern every
10+
real AI chip uses, built from the ground up and verified end to end.
11+
12+
```
13+
┌───────────────┐ instructions ┌───────────────┐
14+
│ Boot ROM │───────────────▶│ RV32I CPU │
15+
│ (firmware) │ │ (5-stage │
16+
└───────────────┘ │ pipeline) │
17+
└───────┬───────┘
18+
data bus │ (address-decoded)
19+
┌─────────────────┴──────────────────┐
20+
addr[31]=0 addr[31]=1
21+
│ │
22+
┌─────▼─────┐ ┌────────▼────────┐
23+
│ RAM │ │ NPU (memory- │
24+
│ │ │ mapped systolic │
25+
└───────────┘ │ matrix-multiply)│
26+
└─────────────────┘
27+
```
28+
29+
## What happens when it runs
30+
31+
The firmware (`firmware/firmware.s`) executes on the CPU and:
32+
1. writes matrix **A** and matrix **B** into the NPU's operand registers (stores
33+
to `0x8000_00xx`),
34+
2. writes the **CONTROL** register to start the multiply,
35+
3. **polls STATUS** until the NPU raises `done`,
36+
4. reads the result matrix **C** back and stores it to RAM.
37+
38+
The testbench boots the SoC and confirms RAM holds
39+
`[[1,2],[3,4]] × [[5,6],[7,8]] = [[19,22],[43,50]]`**computed by the NPU,
40+
orchestrated entirely by software running on the CPU.**
41+
42+
## Built from
43+
- **CPU:** [riscv-rv32i-pipeline](https://github.com/efedemi/riscv-rv32i-pipeline) — the 5-stage core, reused here.
44+
- **NPU datapath:** [systolic-matmul](https://github.com/efedemi/systolic-matmul) — the systolic array, wrapped (`rtl/npu_periph.sv`) as a memory-mapped accelerator with a start/done control FSM.
45+
- **Toolchain:** `tools/assemble.py` — a small RV32I assembler I wrote; it turns `firmware/firmware.s` into the machine code in the boot ROM (no hand-typed hex).
46+
47+
## Simulate
48+
49+
```bash
50+
# (optional) re-assemble the firmware
51+
python tools/assemble.py firmware/firmware.s
52+
53+
# build + run the SoC
54+
iverilog -g2012 -o sim.out rtl/rv32i_core.sv rtl/systolic_mm.sv \
55+
rtl/npu_periph.sv rtl/soc_top.sv tb/soc_tb.sv
56+
vvp sim.out
57+
```
58+
59+
## NPU register map (base `0x8000_0000`)
60+
61+
| Offset | Register | Access |
62+
|---|---|---|
63+
| `0x00``0x0C` | `A[0][0] … A[1][1]` | write |
64+
| `0x10``0x1C` | `B[0][0] … B[1][1]` | write |
65+
| `0x20` | `CONTROL` (bit0 = start) | write |
66+
| `0x24` | `STATUS` (bit0 = done) | read |
67+
| `0x30``0x3C` | `C[0][0] … C[1][1]` | read |
68+
69+
## Design notes
70+
71+
- **Memory-mapped accelerator.** The NPU lives in the CPU's address space, so the
72+
CPU talks to it with plain `lw`/`sw` — exactly how a driver pokes registers on
73+
real hardware. No special instructions needed.
74+
- **Decoupled, multi-cycle offload.** The matrix multiply takes many cycles, so
75+
the NPU exposes a `start`/`done` handshake and the CPU **polls STATUS** — the
76+
same pattern as a DMA engine or GPU command queue. Compute and control run
77+
concurrently.
78+
- **Address decode** is a single bit (`addr[31]`): high → NPU, low → RAM. Trivial
79+
to extend to more peripherals with a wider decode.
80+
- **One-bit-at-a-time bring-up.** Each block (CPU, array, NPU wrapper) was verified
81+
in isolation first; only then integrated — which is why the full system passed
82+
on the first boot.
83+
84+
---
85+
*Built by Efe Demir.*

firmware/firmware.s

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# =============================================================================
2+
# firmware.s - runs on the RV32I CPU; offloads a 2x2 matrix multiply to the
3+
# memory-mapped NPU accelerator, then writes the result matrix into RAM.
4+
#
5+
# NPU register map (base 0x8000_0000):
6+
# 0x00..0x0C : A[0][0] A[0][1] A[1][0] A[1][1] (write)
7+
# 0x10..0x1C : B[0][0] B[0][1] B[1][0] B[1][1] (write)
8+
# 0x20 : CONTROL (write 1 -> start)
9+
# 0x24 : STATUS (read bit0 -> done)
10+
# 0x30..0x3C : C[0][0] C[0][1] C[1][0] C[1][1] (read)
11+
#
12+
# Computes A=[[1,2],[3,4]] x B=[[5,6],[7,8]] = [[19,22],[43,50]]
13+
# and stores C into RAM words 0..3.
14+
# =============================================================================
15+
lui x1, 0x80000 # x1 = NPU base address (0x8000_0000)
16+
17+
addi x2, x0, 1
18+
sw x2, 0(x1) # A[0][0] = 1
19+
addi x2, x0, 2
20+
sw x2, 4(x1) # A[0][1] = 2
21+
addi x2, x0, 3
22+
sw x2, 8(x1) # A[1][0] = 3
23+
addi x2, x0, 4
24+
sw x2, 12(x1) # A[1][1] = 4
25+
26+
addi x2, x0, 5
27+
sw x2, 16(x1) # B[0][0] = 5
28+
addi x2, x0, 6
29+
sw x2, 20(x1) # B[0][1] = 6
30+
addi x2, x0, 7
31+
sw x2, 24(x1) # B[1][0] = 7
32+
addi x2, x0, 8
33+
sw x2, 28(x1) # B[1][1] = 8
34+
35+
addi x2, x0, 1
36+
sw x2, 32(x1) # CONTROL = 1 -> start the NPU
37+
38+
poll:
39+
lw x3, 36(x1) # read STATUS
40+
beq x3, x0, poll # spin until done != 0
41+
42+
lw x4, 48(x1) # C[0][0]
43+
sw x4, 0(x0) # RAM[0]
44+
lw x4, 52(x1) # C[0][1]
45+
sw x4, 4(x0) # RAM[1]
46+
lw x4, 56(x1) # C[1][0]
47+
sw x4, 8(x0) # RAM[2]
48+
lw x4, 60(x1) # C[1][1]
49+
sw x4, 12(x0) # RAM[3]
50+
51+
done:
52+
jal x0, done # halt

rtl/npu_periph.sv

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// =============================================================================
2+
// npu_periph.sv — the systolic NPU wrapped as a memory-mapped accelerator
3+
// -----------------------------------------------------------------------------
4+
// Register map (byte offsets within the peripheral):
5+
// 0x00..0x0C A[0][0] A[0][1] A[1][0] A[1][1] (write operands)
6+
// 0x10..0x1C B[0][0] B[0][1] B[1][0] B[1][1] (write operands)
7+
// 0x20 CONTROL : write bit0=1 -> start a multiply
8+
// 0x24 STATUS : read bit0 -> done
9+
// 0x30..0x3C C[0][0] C[0][1] C[1][0] C[1][1] (read results)
10+
//
11+
// On start it clears the array, streams the stored operands in with the correct
12+
// systolic skew, then raises `done`. The CPU polls STATUS and reads the result.
13+
// Fixed for a 2x2 array (the register map is size-specific).
14+
// =============================================================================
15+
`default_nettype none
16+
17+
module npu_periph #(parameter int N = 2, parameter int DATA_W = 8, parameter int ACC_W = 32) (
18+
input wire clk, rst_n,
19+
input wire [31:0] addr,
20+
input wire [31:0] wdata,
21+
input wire we,
22+
output reg [31:0] rdata
23+
);
24+
logic [DATA_W-1:0] A_reg [0:1][0:1];
25+
logic [DATA_W-1:0] B_reg [0:1][0:1];
26+
27+
typedef enum logic [1:0] {IDLE, CLR, RUN, DONE} st_t;
28+
st_t state;
29+
logic [5:0] t;
30+
logic done, arr_clr;
31+
32+
// operands fed to the array this cycle (skewed)
33+
logic [DATA_W-1:0] a0, a1, b0, b1;
34+
wire [N*DATA_W-1:0] a_left = {a1, a0};
35+
wire [N*DATA_W-1:0] b_top = {b1, b0};
36+
wire [N*N*ACC_W-1:0] c_flat;
37+
38+
systolic_mm #(.N(N), .DATA_W(DATA_W), .ACC_W(ACC_W)) u_arr (
39+
.clk, .rst_n, .clr(arr_clr), .a_left, .b_top, .c_flat);
40+
41+
// helper nets (avoid procedural part-selects of wdata)
42+
wire [DATA_W-1:0] wbyte = wdata[DATA_W-1:0];
43+
wire wd0 = wdata[0];
44+
wire [7:0] off = addr[7:0];
45+
46+
// result slices as nets
47+
wire [ACC_W-1:0] c00 = c_flat[0*ACC_W +: ACC_W];
48+
wire [ACC_W-1:0] c01 = c_flat[1*ACC_W +: ACC_W];
49+
wire [ACC_W-1:0] c10 = c_flat[2*ACC_W +: ACC_W];
50+
wire [ACC_W-1:0] c11 = c_flat[3*ACC_W +: ACC_W];
51+
52+
// ---- skewed operand feed (combinational, active during RUN) ----
53+
always_comb begin
54+
a0 = '0; a1 = '0; b0 = '0; b1 = '0;
55+
if (state == RUN) begin
56+
if (t == 0) a0 = A_reg[0][0]; else if (t == 1) a0 = A_reg[0][1];
57+
if (t == 1) a1 = A_reg[1][0]; else if (t == 2) a1 = A_reg[1][1];
58+
if (t == 0) b0 = B_reg[0][0]; else if (t == 1) b0 = B_reg[1][0];
59+
if (t == 1) b1 = B_reg[0][1]; else if (t == 2) b1 = B_reg[1][1];
60+
end
61+
end
62+
63+
// ---- control FSM + operand register writes ----
64+
always_ff @(posedge clk or negedge rst_n) begin
65+
if (!rst_n) begin
66+
state <= IDLE; t <= 0; done <= 0; arr_clr <= 0;
67+
end else begin
68+
arr_clr <= 1'b0;
69+
case (state)
70+
IDLE: begin
71+
if (we) begin
72+
case (off)
73+
8'h00: A_reg[0][0] <= wbyte;
74+
8'h04: A_reg[0][1] <= wbyte;
75+
8'h08: A_reg[1][0] <= wbyte;
76+
8'h0C: A_reg[1][1] <= wbyte;
77+
8'h10: B_reg[0][0] <= wbyte;
78+
8'h14: B_reg[0][1] <= wbyte;
79+
8'h18: B_reg[1][0] <= wbyte;
80+
8'h1C: B_reg[1][1] <= wbyte;
81+
8'h20: if (wd0) begin state <= CLR; arr_clr <= 1'b1; done <= 1'b0; t <= 0; end
82+
default: ;
83+
endcase
84+
end
85+
end
86+
CLR: begin state <= RUN; t <= 0; end
87+
RUN: begin
88+
t <= t + 1'b1;
89+
if (t >= 6) begin state <= DONE; done <= 1'b1; end
90+
end
91+
DONE: begin
92+
done <= 1'b1;
93+
if (we && off == 8'h20 && wd0) begin state <= CLR; arr_clr <= 1'b1; done <= 1'b0; t <= 0; end
94+
end
95+
endcase
96+
end
97+
end
98+
99+
// ---- read mux (combinational) ----
100+
always_comb begin
101+
case (off)
102+
8'h24: rdata = {31'b0, done};
103+
8'h30: rdata = c00;
104+
8'h34: rdata = c01;
105+
8'h38: rdata = c10;
106+
8'h3C: rdata = c11;
107+
default: rdata = 32'h0;
108+
endcase
109+
end
110+
endmodule
111+
112+
`default_nettype wire

0 commit comments

Comments
 (0)