Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/fpga.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: FPGA

# Synthesis, place and route of the iCE40 top level, with the numbers in the job summary. The UART simulation
# that checks the same top level runs in rtl.yml, next to the other cocotb suites.
on:
push:
paths: ['rtl/**', 'fpga/**', '.github/workflows/fpga.yml']
pull_request:
paths: ['rtl/**', 'fpga/**', '.github/workflows/fpga.yml']

jobs:
ice40:
runs-on: ubuntu-24.04

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install Yosys and nextpnr (WebAssembly builds, no system packages)
run: pip install yowasp-yosys yowasp-nextpnr-ice40

- name: Synthesize, place and route for iCE40UP5K
shell: bash
env:
YOSYS: yowasp-yosys
NEXTPNR: yowasp-nextpnr-ice40
run: |
set -o pipefail
mkdir -p fpga/ice40/build # tee writes the summary before build.sh creates the directory
bash fpga/ice40/build.sh 2>&1 | tee fpga/ice40/build/summary.txt

- name: Job summary
if: always()
run: |
{
echo "## iCE40UP5K (iCEBreaker) build"
echo '```'
cat fpga/ice40/build/summary.txt 2>/dev/null || echo "build did not complete"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
4 changes: 4 additions & 0 deletions .github/workflows/rtl.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,7 @@ jobs:

- name: cocotb - 5-stage core lockstep vs ISS (hazard-free programs)
run: bash sim/run.sh pipe

# The iCE40 top level runs fib on the pipeline and prints 6765 over its UART (decoded in simulation).
- name: cocotb - FPGA top prints fib over UART
run: bash sim/run.sh fpga
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ sw/build/
!.tools/emsdk.sh
!.tools/riscv-gcc.sh
sim/results*.xml

# FPGA build products (fpga/ice40/build.sh)
fpga/ice40/build/
17 changes: 16 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ every pipeline register, forwarding path and stall comes from a `dbg_*` port on
five pages (how it works, architecture, the pipeline, hazards, verification), each with a live `MiniRun` embed where it helps.
72 Vitest and 17 Playwright tests pass. Still to do: Vercel deploy (needs the user's go-ahead), the hero GIF/MP4, and the
CodeMirror editor, pass-by-pass replay and default-branch items that the plan lists under Phase 8.
- Next: Phase 9 (FPGA synthesis), which can run alongside the Phase 8 launch work.
- Phase 9 (FPGA, iCE40UP5K): DONE in simulation and synthesis. `fpga/ice40/fpga_top.sv` runs the pipelined core on a program
preloaded into BRAM (`fpga/images/fib.*.hex`), prints the console over a 115200-baud UART, and shows the run on the LEDs.
`bash fpga/ice40/build.sh`: 4958/5280 LCs (93%), 17/30 BRAM, 14.98 MHz post-route, no latches. `bash sim/run.sh fpga` decodes the
UART in simulation (fib prints 6765). The board is not yet tested: UART and LED pins are unconstrained. ECP5 not done.
- Next: the board bring-up (pins for the UART and LEDs, a bitstream, then fib on the board), and the ECP5 target if wanted.

## Interfaces Phase 2+ must honour (defined by the ISS, `sim/golden/iss.py`)
- Retire record fields, lane/mask conventions, forced alignment, `cycle_dependent` masking: see the module docstring and PLAN 2.4.
Expand Down Expand Up @@ -132,3 +136,14 @@ every pipeline register, forwarding path and stall comes from a `dbg_*` port on
- A `.gitignore` rule with a trailing comment is not an exception: git reads the comment as part of the pattern. Keep comments on
their own lines (this hid the WASM binary from git until Phase 7's CI caught it).
- An embedded run starts at cycle 1, so "Halted" appears only after stepping to the end. Tests must click "Go to end" first.

## Gotchas found in Phase 9
- Yosys's SystemVerilog parser is narrower than the simulators': no package imports in module headers, no typed string parameters,
no ANSI-style function arguments, and no early `return` in those functions. `fpga/prepare_rtl.py` converts a copy for synthesis;
the simulators keep the originals. The two functions were also rewritten in plain form in the RTL.
- A memory whose read merges the write data inside the same process maps to flip-flops, not block RAM (72,000 logic cells on this
part). `mem_sync` now reads first and merges after the read register, which gives the same values and maps to BRAM.
- Verilator treats width warnings as errors in the FPGA top: the UART bit-period comparison needed a sized constant.
- The "sv2v" route for converting SystemVerilog was not available: its release download was blocked by the permission check, so the
converter is the local replacement. Don't retry the download.
- nextpnr prints the timing twice; the last line is post-route, and that is the number to quote.
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Third in the series after [tiny-gpu](https://github.com/deaneeth/tiny-gpu) and [
</div>

> **Live: [tiny-cpu.vercel.app](https://tiny-cpu.vercel.app)**. The app at `/app`, the landing page and the docs are
> deployed. FPGA synthesis numbers (Phase 9) are the remaining piece of the plan.
> deployed. The iCE40 synthesis (Phase 9) builds and runs in simulation; the board itself is not yet tested.
>
> See [`docs/PLAN.md`](docs/PLAN.md) for the full roadmap.

Expand All @@ -31,7 +31,20 @@ rtl/*.sv -> Verilator C++ -> Emscripten -> web/public/tiny_cpu.wasm -> React isl
load-use stalls and taken-branch flushes.
- The same hardware in the browser: an assembler (byte-identical to GNU's), an editor, and a viewer that steps through
every clock edge with the pipeline, the single-cycle datapath, a hazard lab and a programs comparison.
- FPGA synthesis numbers for iCE40 and ECP5 (Phase 9, in progress).
- FPGA synthesis for the iCE40UP5K (iCEBreaker): 14.98 MHz post-route, 93% of the logic cells, no latches. See the FPGA section.

## FPGA

The iCE40UP5K (iCEBreaker) build of the pipelined core, with a UART and LEDs around it, synthesizes cleanly (`bash fpga/ice40/build.sh`):

| Resource | Used | Available |
|---|---|---|
| Logic cells | 4958 | 5280 (93%) |
| Block RAM | 17 | 30 |

The clock reaches 14.98 MHz post-route, above the 12 MHz oscillator, and the netlist has no latches. In simulation the top
level prints `6765` over its UART and reports PASS on the LEDs (`bash sim/run.sh fpga`). The board itself is not yet tested:
the UART and LED pins still need to be placed for the board. Details in [`fpga/README.md`](fpga/README.md).

## Verification

Expand Down
61 changes: 61 additions & 0 deletions fpga/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# fpga/

Synthesis of TinyCPU for FPGAs. The iCE40 top level (`ice40/fpga_top.sv`) runs the 5-stage pipeline on a program preloaded
into block RAM, prints its console output over a 115200-baud UART, and shows the run on the LEDs.

## iCE40UP5K (iCEBreaker)

```bash
source scripts/env.sh # Yosys and nextpnr from .venv (see below)
bash fpga/ice40/build.sh # synthesis, latch check, place and route, fmax
bash sim/run.sh fpga # the same top level in simulation: decodes the UART and checks the LEDs
```

One-time setup, in the project's virtualenv:

```bash
.venv/bin/pip install yowasp-yosys yowasp-nextpnr-ice40
```

The results (post-route, Yosys 0.70, nextpnr 0.11.1):

| Resource | Used | Available |
|---|---|---|
| Logic cells (LUT + FF) | 4958 | 5280 (93%) |
| Block RAM (4 Kbit) | 17 | 30 |
| DSP | 0 | 8 |

- **Clock**: 14.98 MHz post-route, against the 12 MHz oscillator. The check passes with margin.
- **Latches**: none. `build.sh` fails if Yosys finds a latch cell (`select -assert-none`), and `check -assert` runs too.

## How it fits together

- **The program is in block RAM.** The memories take an optional `$readmemh` image (`IMEM_INIT`, `DMEM_INIT` on
`tiny_cpu_top`). Simulation leaves them empty, so nothing changes there. `gen_image.py` turns a built program
(`sw/build/programs/<name>.{imem,dmem}.bin`) into the hex files in `images/`. The default image is `fib`, which prints `6765`.
- **Console output**: a 255-byte FIFO takes the core's CONSOLE stores and a UART transmitter sends them. A byte written to a full
FIFO is dropped and sets the overflow LED.
- **LEDs**: running, done (TOHOST written), pass, fault, overflow.
- **Reset**: a 65,536-clock power-on reset.

## Yosys and the RTL copy

Yosys's SystemVerilog parser is narrower than the simulators'. The RTL uses package imports in module headers, typed string
parameters, and ANSI-style function arguments, which Yosys does not accept. So `prepare_rtl.py` copies the RTL for the
synthesis run and rewrites those constructs. It qualifies each package symbol as `tiny_cpu_pkg::NAME`, and turns the typed
string parameters into untyped ones. The original files are not changed by it, and the simulators read them as before.

Two RTL changes came out of this, and both are verified by the simulation suites:

- The two functions in `forward_unit.sv` and `hazard_unit.sv` use the old-style argument list, and `forward_unit`'s `pick`
uses a result variable instead of early returns. The logic is the same.
- `mem_sync.sv` moved its read-new bypass out of the memory into a merge after the read register. The value on `rdata` is the
same at every edge. The memory now has a plain read port, which block RAM inference needs. Without this change the data
memory was built from flip-flops and the design needed about 72,000 logic cells.

## Not done

- **Board pins**: `icebreaker.pcf` pins only the 12 MHz clock. The UART and the LEDs are placed automatically
(`--pcf-allow-unconstrained`), so a bitstream would not yet drive the board's UART or LEDs. Pin them for your board before programming it.
- **Bitstream and board test**: the build stops after place and route. The UART behaviour is checked in simulation only.
- **ECP5**: not yet. The plan lists ULX3S as an option; the flow here is iCE40 only.
37 changes: 37 additions & 0 deletions fpga/gen_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Turn a program's binary images into $readmemh files for the FPGA memories (one 32-bit word per line).

python3 fpga/gen_image.py fib # reads sw/build/programs/fib.{imem,dmem}.bin
-> fpga/images/fib.imem.hex, fpga/images/fib.dmem.hex

The files are zero-padded to the full memory size, so $readmemh overwrites every word.
"""

import struct
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
IMEM_WORDS = 1024
DMEM_WORDS = 1024


def words(path: Path, count: int) -> list[int]:
data = path.read_bytes()
if len(data) > 4 * count:
raise SystemExit(f"{path}: {len(data)} bytes does not fit {count} words")
data += b"\0" * (4 * count - len(data))
return list(struct.unpack(f"<{count}I", data))


def main(name: str) -> None:
out = ROOT / "fpga" / "images"
out.mkdir(parents=True, exist_ok=True)
src = ROOT / "sw" / "build" / "programs"
for kind, count in (("imem", IMEM_WORDS), ("dmem", DMEM_WORDS)):
ws = words(src / f"{name}.{kind}.bin", count)
(out / f"{name}.{kind}.hex").write_text("".join(f"{w:08x}\n" for w in ws))
print(f"wrote {out / f'{name}.{kind}.hex'} ({count} words)")


if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "fib")
64 changes: 64 additions & 0 deletions fpga/ice40/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Synthesize, place and route fpga_top for the iCE40UP5K (iCEBreaker) and report utilization and fmax.
# Needs the Yosys and nextpnr packages in .venv (see fpga/README.md); run from anywhere.
set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
BUILD="$HERE/build"
mkdir -p "$BUILD"
cd "$HERE"

YOSYS="${YOSYS:-$ROOT/.venv/bin/yowasp-yosys}"
NEXTPNR="${NEXTPNR:-$ROOT/.venv/bin/yowasp-nextpnr-ice40}"
FREQ_MHZ=12 # the iCEBreaker oscillator

# Yosys cannot read the RTL's header-level package import, so it gets a converted copy (see fpga/prepare_rtl.py).
FILES=""
for f in tiny_cpu_pkg alu imm_gen branch_unit lsu decoder regfile csr_counters mmio mem_comb mem_sync \
forward_unit hazard_unit core_single core_pipe tiny_cpu_top; do
FILES+=" $ROOT/rtl/$f.sv"
done
rm -rf "$BUILD/rtl"
python3 "$ROOT/fpga/prepare_rtl.py" "$BUILD/rtl" $FILES "$HERE/fpga_top.sv"
RTL=""
for f in $FILES "$HERE/fpga_top.sv"; do
RTL+=" $BUILD/rtl/$(basename "$f")"
done

echo "[1/3] Yosys: synthesis, latch check"
"$YOSYS" -q -l "$BUILD/yosys.log" -p "
read_verilog -sv $RTL;
hierarchy -top fpga_top -check;
proc;
select -assert-none t:\$dlatch t:\$adlatch t:\$dlatchsr t:\$sr t:\$dffsr;
synth_ice40 -top fpga_top -json $BUILD/fpga.json;
stat;
check -assert"

echo "[2/3] nextpnr: place and route for iCE40UP5K at ${FREQ_MHZ} MHz"
"$NEXTPNR" --up5k --package sg48 --freq "$FREQ_MHZ" \
--json "$BUILD/fpga.json" --pcf "$HERE/icebreaker.pcf" --asc "$BUILD/fpga.asc" \
--pcf-allow-unconstrained --report "$BUILD/report.json" -l "$BUILD/nextpnr.log" >/dev/null

echo "[3/3] Results"
python3 - "$BUILD/report.json" "$BUILD/yosys.log" "$BUILD/nextpnr.log" <<'PY'
import json, re, sys
report = json.load(open(sys.argv[1]))
yosys_log = open(sys.argv[2]).read()
nextpnr_log = open(sys.argv[3]).read()
util = report.get("utilization", {})
def used(name):
u = util.get(name, {})
return u.get("used"), u.get("available")
# nextpnr prints the timing twice; the last one is after routing, the number to quote.
fmax = re.findall(r"Max frequency for clock '([^']+)': ([0-9.]+) MHz", nextpnr_log)[-1:]
print("utilization (used / available):")
for name in ("ICESTORM_LC", "ICESTORM_RAM", "SB_RAM40_4K", "ICESTORM_DSP", "SB_IO"):
if name in util:
u, a = used(name)
print(f" {name:14s} {u} / {a}")
for clk, mhz in fmax:
print(f"fmax (post-route) for '{clk}': {mhz} MHz")
PY
echo "Logs: $BUILD/yosys.log, $BUILD/nextpnr.log"
98 changes: 98 additions & 0 deletions fpga/ice40/fpga_top.sv
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// iCE40 top level for TinyCPU. The 5-stage pipeline (CORE="PIPE") runs the program preloaded into block RAM
// from fpga/images/. Console bytes leave on a 115200-baud UART through a 255-byte FIFO (a full FIFO sets
// `overflow` and drops the byte). The LEDs show the run: running, done (TOHOST written), pass, fault, overflow.
module fpga_top #(
parameter int unsigned CLK_HZ = 12_000_000, // iCEBreaker oscillator
parameter int unsigned BAUD = 115_200,
parameter string IMEM_INIT = "../images/fib.imem.hex",
parameter string DMEM_INIT = "../images/fib.dmem.hex"
) (
input logic clk,
output logic uart_tx,
output logic [4:0] led
);

// Power-on reset: hold reset for 2^16 clocks after configuration.
logic [15:0] por_cnt = '0;
logic rst_n;
assign rst_n = &por_cnt;
always_ff @(posedge clk) begin
if (!rst_n) por_cnt <= por_cnt + 16'd1;
end

logic console_valid, tohost_valid, halted, fault;
logic [7:0] console_data;
logic [31:0] tohost_data;

tiny_cpu_top #(
.CORE ("PIPE"),
.IMEM_INIT (IMEM_INIT),
.DMEM_INIT (DMEM_INIT)
) u_cpu (
.clk, .rst_n,
.cfg_forward_en (1'b1),
.load_en (1'b0), .load_sel (1'b0), .load_addr ('0), .load_data (32'b0),
.console_valid, .console_data, .tohost_valid, .tohost_data,
.halted, .fault
);

// ---- console FIFO: written by the core's CONSOLE stores, read by the UART below
localparam int unsigned DEPTH = 256;
logic [7:0] fifo [DEPTH];
logic [7:0] wp, rp;
logic overflow;
wire fifo_empty = (wp == rp);
wire fifo_full = ((wp + 8'd1) == rp); // one slot kept free, so a full FIFO is wp+1 == rp

// ---- UART transmitter: 10-bit frame (start, 8 data LSB first, stop), one bit per DIV clocks
localparam int unsigned DIV = CLK_HZ / BAUD;
localparam logic [$clog2(DIV)-1:0] DIV_LAST = ($clog2(DIV))'(DIV - 1);
logic [9:0] tx_sh;
logic [$clog2(DIV)-1:0] tick;
logic [3:0] bitn;
logic busy;

assign uart_tx = busy ? tx_sh[0] : 1'b1;

always_ff @(posedge clk) begin
if (!rst_n) begin
wp <= '0;
rp <= '0;
overflow <= 1'b0;
busy <= 1'b0;
tick <= '0;
bitn <= '0;
tx_sh <= '1;
end else begin
if (console_valid) begin
if (!fifo_full) begin
fifo[wp] <= console_data;
wp <= wp + 8'd1;
end else begin
overflow <= 1'b1;
end
end

if (!busy) begin
if (!fifo_empty) begin
tx_sh <= {1'b1, fifo[rp], 1'b0};
rp <= rp + 8'd1;
busy <= 1'b1;
tick <= '0;
bitn <= '0;
end
end else if (tick == DIV_LAST) begin
tick <= '0;
tx_sh <= {1'b1, tx_sh[9:1]};
bitn <= bitn + 4'd1;
if (bitn == 4'd9) busy <= 1'b0;
end else begin
tick <= tick + 1'b1;
end
end
end

// ---- status LEDs
assign led = {overflow, fault, (tohost_valid && tohost_data == 32'd1), tohost_valid, !halted};

endmodule
3 changes: 3 additions & 0 deletions fpga/ice40/icebreaker.pcf
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# iCEBreaker (iCE40UP5K, sg48). Only the 12 MHz oscillator is pinned here, so the timing constraint is real.
# UART TX and the LEDs are not pinned: place them for your board's header and LEDs before programming it.
set_io clk 35
Loading
Loading