Adds FPGA-exclusive access to the onboard W25Q128JV SPI NOR flash for weights/bias/network persistence, layered as spi_flash_master (raw SPI, USRMCLK-driven) -> flash_copy_engine (flash<->PSRAM streaming, erase- before-write, Page Program loop) -> flash_slot_manager (16-slot catalog with CRC32), exposed via 8 new SPI opcodes (0x40-0x47). Fixes two pre-existing bugs found during bring-up: a psram_controller.v request lost during power-up, and a one-cycle-pulse race in the PSRAM arbiter request handshake. Full simulation + real Yosys/nextpnr-ecp5 synthesis verification (0 errors, Fmax 66.68MHz) in WORKLOG.md and docs/FPGA-Neural-Flash-Subsystem-Verification.md. Also updates docs/pinout to reflect the 56-signal real .lpf (3 new flash pins) and documents the WRITE_RAM/READ_RAM host backpressure risk found while testing this subsystem. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
113 lines
4.8 KiB
Python
113 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generates synth/ecp5/spi_neuron_top.lpf (LOCATE/IOBUF constraints) for
|
|
spi_neuron_top's SPI + PSRAM ports on the real LFE5U-45F-8BG381C part,
|
|
using Project Trellis's own device database as the source of ball/bank/
|
|
dual-function data (the same data nextpnr-ecp5 itself uses) -- not
|
|
invented numbers.
|
|
|
|
Requires prjtrellis installed (Homebrew: `brew install prjtrellis`) and
|
|
its iodb.json for LFE5U-45F. See docs/FPGA-Neural-Hardware-Design.md §7
|
|
for the full placement rationale (bank/die-edge geometry, why banks 2+3
|
|
hold the PSRAM bus and bank 7 holds SPI/clock/reset).
|
|
|
|
Re-run this whenever the port list of rtl/spi_neuron_top.v's top-level
|
|
SPI/PSRAM interface changes (ADDR_WIDTH, MEM_DATA_WIDTH, etc.) -- it does
|
|
NOT try to read the RTL; the port list/widths are hardcoded below and
|
|
must be kept in sync by hand.
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import glob
|
|
import sys
|
|
|
|
CLK_BALL = "H5" # GR_PCLK7_0, bank 7 -- dedicated global clock pad
|
|
ADDR_BITS = 23 # ADDR_WIDTH (byte address); only [21:0] carry real address, see §3
|
|
DATA_BITS = 16 # MEM_DATA_WIDTH
|
|
|
|
def find_iodb():
|
|
candidates = glob.glob(
|
|
"/opt/homebrew/Cellar/prjtrellis/*/share/trellis/database/ECP5/LFE5U-45F/iodb.json"
|
|
) + glob.glob(
|
|
"/usr/share/trellis/database/ECP5/LFE5U-45F/iodb.json"
|
|
)
|
|
if not candidates:
|
|
sys.exit("prjtrellis iodb.json not found -- install prjtrellis (brew install prjtrellis)")
|
|
return candidates[0]
|
|
|
|
|
|
def load_balls(iodb_path, package="CABGA381"):
|
|
d = json.load(open(iodb_path))
|
|
pkg = d["packages"][package]
|
|
meta_idx = {}
|
|
for m in d["pio_metadata"]:
|
|
meta_idx.setdefault((m["col"], m["row"], m["pio"]), []).append(m)
|
|
out = {}
|
|
for ball, info in pkg.items():
|
|
key = (info["col"], info["row"], info["pio"])
|
|
metas = meta_idx.get(key, [])
|
|
bank = metas[0]["bank"] if metas else None
|
|
funcs = sorted(set(m.get("function", "") for m in metas if m.get("function")))
|
|
out[ball] = dict(col=info["col"], row=info["row"], bank=bank, funcs=funcs)
|
|
return out
|
|
|
|
|
|
def bank_balls(rows, bank, exclude=()):
|
|
items = [(b, v) for b, v in rows.items() if v["bank"] == bank and b not in exclude]
|
|
plain = sorted((x for x in items if not x[1]["funcs"]), key=lambda x: (x[1]["row"], x[1]["col"], x[0]))
|
|
special = sorted((x for x in items if x[1]["funcs"]), key=lambda x: (x[1]["row"], x[1]["col"], x[0]))
|
|
return plain + special
|
|
|
|
|
|
def assign(rows):
|
|
psram_pool = bank_balls(rows, 2) + bank_balls(rows, 3)
|
|
ctrl_pool = bank_balls(rows, 7, exclude={CLK_BALL})
|
|
|
|
a = {}
|
|
for i, (ball, _) in enumerate(psram_pool[0:ADDR_BITS - 1]):
|
|
a[f"psram_a[{i}]"] = ball
|
|
for i, (ball, _) in enumerate(psram_pool[ADDR_BITS - 1:ADDR_BITS - 1 + DATA_BITS]):
|
|
a[f"psram_dq[{i}]"] = ball
|
|
ctrl_names = ["psram_ce_n", "psram_oe_n", "psram_we_n", "psram_lb_n", "psram_ub_n", "psram_zz_n"]
|
|
for name, (ball, _) in zip(ctrl_names, psram_pool[ADDR_BITS - 1 + DATA_BITS:ADDR_BITS - 1 + DATA_BITS + 6]):
|
|
a[name] = ball
|
|
a[f"psram_a[{ADDR_BITS - 1}]"] = psram_pool[ADDR_BITS - 1 + DATA_BITS + 6][0] # always-0 spare bit
|
|
|
|
# Application SPI + reset + host attention pins (irq_n/data_ready_n,
|
|
# added 2026-09-03), all in bank 7 alongside clk -- kept away from
|
|
# the PSRAM bus (banks 2+3) per the same "opposite edges" rationale
|
|
# as the SPI signals. flash_mosi/flash_miso/flash_cs_n (added
|
|
# 2026-09-04, flash-subsystem F5) join the same pool for the same
|
|
# reason -- this is the APPLICATION-side master toward the
|
|
# boot/persistence flash (rtl/spi_flash_master.v), physically
|
|
# distinct pins from both the host SPI above and the dedicated
|
|
# config-SPI pins (never touched by user logic, see
|
|
# docs/FPGA-Neural-Hardware-Design.md §6). No flash_sclk pin here:
|
|
# that clock is generated internally via the USRMCLK primitive
|
|
# (see rtl/spi_flash_master.v's header for the full ECP5
|
|
# rationale), not a normal constrainable I/O.
|
|
spi_names = ["sclk", "mosi", "miso", "cs_n", "rst", "irq_n", "data_ready_n",
|
|
"flash_mosi", "flash_miso", "flash_cs_n"]
|
|
for name, (ball, _) in zip(spi_names, ctrl_pool[0:10]):
|
|
a[name] = ball
|
|
a["clk"] = CLK_BALL
|
|
return a
|
|
|
|
|
|
def write_lpf(assignment, path):
|
|
lines = ["BLOCK ASYNCPATHS;", "BLOCK RESETPATHS;", ""]
|
|
for sig, ball in sorted(assignment.items()):
|
|
lines.append(f'LOCATE COMP "{sig}" SITE "{ball}";')
|
|
lines.append(f'IOBUF PORT "{sig}" IO_TYPE=LVCMOS33;')
|
|
lines.append("")
|
|
open(path, "w").write("\n".join(lines))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
out_path = sys.argv[1] if len(sys.argv) > 1 else "synth/ecp5/spi_neuron_top.lpf"
|
|
rows = load_balls(find_iodb())
|
|
assignment = assign(rows)
|
|
write_lpf(assignment, out_path)
|
|
print(f"wrote {len(assignment)} signal constraints to {out_path}")
|