The flash subsystem's SCLK previously reused the boot config-SPI's CCLK pad via the ECP5 USRMCLK primitive to save one pin. This made the "exclusive flash bus" claim misleading (SCLK still depended on the config engine's own pad electrically) and carried an unresolved verification gap (USRMCLKTS pad-enable timing never checked against the primary Lattice sysCONFIG Usage Guide). flash_sclk is now a genuine 4th ordinary GPIO pin (E3, bank 7), added purely additively to the real .lpf (git diff: one new line, no existing ball moved). The flash bus is now 4 fully independent wires (sclk/mosi/miso/cs_n), zero pins shared with any ECP5 config primitive -- confirmed by the full-system synthesis reporting USRMCLK 0/1 (0%) utilisation. All 33 project testbenches re-run clean after the port rename (no functional change, only sclk_sim -> sclk). Full-system real synthesis re-verified: 0 constraint errors, Fmax 67.91MHz (up slightly from 66.68MHz, same critical path, not a regression). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013xXuuRUWZScuo1DeYJxs3v
117 lines
5.0 KiB
Python
117 lines
5.0 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_sclk/flash_mosi/flash_miso/flash_cs_n
|
|
# (added 2026-09-04, revised same day to drop the USRMCLK/CCLK
|
|
# coupling -- see rtl/spi_flash_master.v's header) join the same
|
|
# pool for the same reason -- this is a fully independent,
|
|
# ordinary-GPIO SPI bus toward the boot/persistence flash
|
|
# (rtl/spi_flash_master.v), physically distinct 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 pin
|
|
# is shared with any ECP5 config primitive.
|
|
# flash_sclk appended LAST (not inserted among the other flash
|
|
# signals) so this regeneration stays additive: flash_mosi/
|
|
# flash_miso/flash_cs_n keep the exact balls already committed to
|
|
# the datasheet/schematic notes, only one genuinely new ball is
|
|
# allocated for flash_sclk.
|
|
spi_names = ["sclk", "mosi", "miso", "cs_n", "rst", "irq_n", "data_ready_n",
|
|
"flash_mosi", "flash_miso", "flash_cs_n", "flash_sclk"]
|
|
for name, (ball, _) in zip(spi_names, ctrl_pool[0:11]):
|
|
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}")
|