346 lines
17 KiB
Python
346 lines
17 KiB
Python
"""Synthesis script for BBATop → re-bba-rb interposer board (iCE40UP5K SG48).
|
|
|
|
Run from workspace root:
|
|
python -m exi_bba.synth # synthesize only
|
|
python -m exi_bba.synth --flash # synthesize and flash
|
|
|
|
This file re-declares IceBreakerPlatform inline so that importing
|
|
rebbarb/rebbarb.py (which has a module-level platform.build() call) is avoided.
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
from amaranth import *
|
|
from amaranth.build import *
|
|
from amaranth.vendor import LatticeICE40Platform
|
|
|
|
from exi_bba.bba_top import BBATop
|
|
|
|
|
|
# ── Platform definition ───────────────────────────────────────────────────
|
|
# Real re-bba-rb board pin map, pulled directly from the schematic netlist
|
|
# (U9 = iCE40UP5K-SG48ITR) — not iCEbreaker PMOD placeholders.
|
|
#
|
|
# EXI (GC side) : CLK=44(G6) MOSI=4(IOB_8a) MISO=3(IOB_9b) CS=45 INT=46
|
|
# W5100 bus (IOT bank, indirect parallel):
|
|
# A0=42 A1=38 D0=34 D1=32 D2=31 D3=28 D4=27 D5=26 D6=25 D7=23
|
|
# CS_N=43 RD_N=37 WR_N=36 RST_N=2 (net /ethernet/ETH_RST, NOT on the IOT
|
|
# bank — separate pin)
|
|
# Board ties the W5100's upper address lines A[14:2] to 0 (only A[1:0]
|
|
# wired); DATA[7:0] is bidirectional (SB_IO tristate, shared output-enable).
|
|
#
|
|
# INT_N=13 — MOVED off the schematic's original pin 35: pin 35 physically
|
|
# conflicts with the iCE40UP5K's PLL hard macro (nextpnr: "PLL bel
|
|
# 'X12/Y31/pll_3' cannot be used... conflicts with input... on pin 35"),
|
|
# and this design needs the PLL for the 54 MHz capture-domain clock. Pin 35
|
|
# cannot be used as regular I/O at all while the PLL is instantiated,
|
|
# regardless of what drives it — this is a fixed silicon constraint, not a
|
|
# routing choice. Pin 13 was picked because it's the ONLY spare GPIO on this
|
|
# exact SG48 package/board combo: the 5k-sg48 package has 39 usable I/O pins
|
|
# total (per icestorm's icebox.py pin database), and every other one is
|
|
# already assigned to a real signal on this board — pin 13 shows up in the
|
|
# schematic netlist as an unrouted U9 pad, nothing else was available.
|
|
# DONE on the board: the schematic + PCB now carry /ethernet/ETH_INT on U9
|
|
# pin 13, and pin 35 is left unconnected (net
|
|
# "unconnected-(U9A-IOT_46b_G0-Pad35)") so the PLL hard macro can claim it.
|
|
# Verified against the schematic netlist 2026-07-31 — do not "restore" pin 35.
|
|
#
|
|
# Debug (J4 header, unpopulated): DBG0=9 DBG1=10 DBG2=11 DBG3=12 DBG4=6
|
|
# UART (FT2232H channel B): net UART_TXD is the FTDI's OUTPUT, so it is the
|
|
# FPGA's RX input, and vice versa for UART_RXD — FPGA RX=18(UART_TXD net),
|
|
# FPGA TX=19(UART_RXD net). Do not swap by "TXD means transmit" instinct.
|
|
# Status LEDs: D6(green,heartbeat)=LED_G=pin47, D7(red,EXI-activity)=LED_R=
|
|
# pin48 — both wired ACTIVE-HIGH (LED anode toward the FPGA pin via its
|
|
# series resistor, cathode to GND), unlike the iCEbreaker's own onboard
|
|
# LEDs which are active-low. No physical button exists on this board (the
|
|
# iCEbreaker's BTN_N was dev-board-only); panel_btn is tied idle instead.
|
|
# RGB status LED (D11=red/rx, D12=green/tx, D13=yellow/ready) is on the
|
|
# iCE40UP5K's dedicated SB_RGBA_DRV pins 39/40/41 — fixed by the chip
|
|
# package on any board, not board-specific, so no resource needed here.
|
|
|
|
class IceBreakerPlatform(LatticeICE40Platform):
|
|
device = "iCE40UP5K"
|
|
package = "SG48"
|
|
default_clk = "clk12"
|
|
|
|
resources = [
|
|
Resource("clk12", 0,
|
|
Pins("20", dir="i"),
|
|
Clock(12e6),
|
|
Attrs(GLOBAL=True, IO_STANDARD="SB_LVCMOS")),
|
|
|
|
# EXI interface (GC side, SPI Mode 3)
|
|
Resource("exi", 0,
|
|
Subsignal("clk", Pins("44", dir="i")),
|
|
Subsignal("mosi", Pins("4", dir="i")),
|
|
Subsignal("miso", Pins("3", dir="o")),
|
|
Subsignal("cs_n", Pins("45", dir="i")),
|
|
Subsignal("int_n", Pins("46", dir="o")),
|
|
Attrs(IO_STANDARD="SB_LVCMOS")),
|
|
|
|
# W5100 indirect parallel bus
|
|
Resource("w5100", 0,
|
|
Subsignal("addr", Pins("42 38", dir="o")),
|
|
Subsignal("data", Pins("34 32 31 28 27 26 25 23", dir="io")),
|
|
Subsignal("cs_n", Pins("43", dir="o")),
|
|
Subsignal("rd_n", Pins("37", dir="o")),
|
|
Subsignal("wr_n", Pins("36", dir="o")),
|
|
Subsignal("int_n", Pins("13", dir="i")),
|
|
Subsignal("rst_n", Pins("2", dir="o")),
|
|
Attrs(IO_STANDARD="SB_LVCMOS")),
|
|
|
|
# Bring-up status panel: D6/D7 discrete LEDs (active-high on this
|
|
# board). RGB (pins 39/40/41) is driven via SB_RGBA_DRV — not
|
|
# declared here as a platform resource (see BBATopSynth.elaborate).
|
|
# No onboard button on this board (see note above).
|
|
Resource("ledr", 0, Pins("48", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),
|
|
Resource("ledg", 0, Pins("47", dir="o"), Attrs(IO_STANDARD="SB_LVCMOS")),
|
|
|
|
# UART debug console → FT2232H Channel B.
|
|
# On the PC: open the second USB serial port at 115200 8N1.
|
|
Resource("uart", 0,
|
|
Subsignal("tx", Pins("19", dir="o")),
|
|
Subsignal("rx", Pins("18", dir="i")),
|
|
Attrs(IO_STANDARD="SB_LVCMOS")),
|
|
]
|
|
|
|
connectors = []
|
|
|
|
def toolchain_program(self, products, name):
|
|
iceprog = os.environ.get("ICEPROG", "iceprog")
|
|
with products.extract(f"{name}.bin") as bitstream_filename:
|
|
subprocess.check_call([iceprog, bitstream_filename])
|
|
|
|
|
|
# ── BBATop with platform resource wiring ─────────────────────────────────
|
|
|
|
class BBATopSynth(BBATop):
|
|
"""BBATop with platform pin connections added in elaborate()."""
|
|
|
|
def elaborate(self, platform):
|
|
m = super().elaborate(platform)
|
|
|
|
if platform is not None:
|
|
exi = platform.request("exi", 0)
|
|
w5100 = platform.request("w5100", 0)
|
|
|
|
m.d.comb += [
|
|
self.exi_clk .eq(exi.clk.i),
|
|
self.exi_mosi .eq(exi.mosi.i),
|
|
self.exi_cs_n .eq(exi.cs_n.i),
|
|
exi.miso.o .eq(self.exi_miso),
|
|
exi.int_n.o .eq(self.int_n),
|
|
|
|
# W5100 parallel bus (DATA[7:0] bidirectional via SB_IO)
|
|
w5100.addr.o .eq(self.w5100_addr),
|
|
w5100.data.o .eq(self.w5100_data_o),
|
|
w5100.data.oe .eq(self.w5100_data_oe),
|
|
self.w5100_data_i.eq(w5100.data.i),
|
|
w5100.cs_n.o .eq(self.w5100_cs_n),
|
|
w5100.rd_n.o .eq(self.w5100_rd_n),
|
|
w5100.wr_n.o .eq(self.w5100_wr_n),
|
|
self.w5100_int_n .eq(w5100.int_n.i),
|
|
w5100.rst_n.o .eq(self.w5100_rst_n),
|
|
]
|
|
|
|
# ── Bring-up status panel → onboard LEDs ────────────────────────
|
|
# All 5 panel LEDs mapped:
|
|
# LEDG (pin 47) = led[0] heartbeat
|
|
# LEDR (pin 48) = led[1] EXI activity
|
|
# RGB (pins 39/40/41) = led[2] rx / led[3] tx / led[4] ready
|
|
# No physical button on this board — panel_btn tied idle/released.
|
|
if self._status_panel:
|
|
ledr = platform.request("ledr", 0)
|
|
ledg = platform.request("ledg", 0)
|
|
led = self.panel_led
|
|
|
|
m.d.comb += [
|
|
ledg.o.eq(led[0]), # heartbeat (active-high LED)
|
|
ledr.o.eq(led[1]), # EXI activity (active-high LED)
|
|
# all 3 bits idle/released (active-low idle = 1) — no
|
|
# physical button exists on this board to read
|
|
self.panel_btn.eq(C(0b111, 3)),
|
|
]
|
|
|
|
# iCEbreaker RGB LED has no series resistors — must use
|
|
# SB_RGBA_DRV (raw pad driver with built-in current source).
|
|
# RGB0=red→rx_act RGB1=green→tx_act RGB2=blue→ready
|
|
# Verify colour-to-element mapping against schematic at bring-up.
|
|
m.submodules.rgb_drv = Instance("SB_RGBA_DRV",
|
|
p_CURRENT_MODE="0b1",
|
|
p_RGB0_CURRENT="0b000001",
|
|
p_RGB1_CURRENT="0b000001",
|
|
p_RGB2_CURRENT="0b000001",
|
|
i_CURREN=Const(1, 1),
|
|
i_RGBLEDEN=Const(1, 1),
|
|
i_RGB0PWM=led[2],
|
|
i_RGB1PWM=led[3],
|
|
i_RGB2PWM=led[4],
|
|
o_RGB0=Signal(name="rgb_r"),
|
|
o_RGB1=Signal(name="rgb_g"),
|
|
o_RGB2=Signal(name="rgb_b"),
|
|
)
|
|
|
|
# ── UART debug console → FT2232H Channel B ─────────────────────
|
|
if self._uart_console:
|
|
uart = platform.request("uart", 0)
|
|
m.d.comb += [
|
|
uart.tx.o .eq(self.uart_tx),
|
|
self.uart_rx .eq(uart.rx.i),
|
|
]
|
|
|
|
return m
|
|
|
|
|
|
# ── Entry point ───────────────────────────────────────────────────────────
|
|
#
|
|
# Seed sweep: nextpnr placement is stochastic. With ~22% LC utilisation
|
|
# routing dominates timing, so different seeds can vary fmax by ±20%.
|
|
# Pass --seeds N to try N seeds (default 1, i.e. seed 1 only).
|
|
# The build directory is reused across seeds; the final artefact in
|
|
# build/top.bin is the result of the last (or best) seed tried.
|
|
|
|
if __name__ == "__main__":
|
|
do_flash = "--flash" in sys.argv
|
|
n_seeds = next((int(sys.argv[i+1]) for i, a in enumerate(sys.argv)
|
|
if a == "--seeds"), 1)
|
|
|
|
print(f"Synthesizing BBATop for {IceBreakerPlatform.device}-"
|
|
f"{IceBreakerPlatform.package} "
|
|
f"(do_program={do_flash}, seeds=1..{n_seeds})")
|
|
|
|
best_seed = 1
|
|
best_fmax = 0.0
|
|
results = [] # (seed, fmax_clk, fmax_capture, verdict), scored only
|
|
for seed in range(1, n_seeds + 1):
|
|
print(f"\n{'='*60}")
|
|
print(f" Seed {seed}/{n_seeds}")
|
|
print(f"{'='*60}")
|
|
opts = (f"--opt-timing --seed {seed} --timing-allow-fail")
|
|
|
|
# A Platform instance can only be built ONCE — amaranth's
|
|
# TemplatedPlatform.prepare() does `assert not self._prepared`. Reusing
|
|
# one platform across the sweep made seeds 2..N raise a bare
|
|
# AssertionError (empty message, so the handler below printed nothing),
|
|
# after which the fmax parser re-read the PREVIOUS seed's build/top.tim
|
|
# and reported identical numbers for every seed — a sweep that looked
|
|
# like it ran but never did. Build a fresh platform per seed.
|
|
platform = IceBreakerPlatform()
|
|
|
|
# Each seed gets its OWN build directory. Sharing one `build/top.tim`
|
|
# across the sweep is not safe on this workspace: /workspace is a WSL2
|
|
# drvfs (Windows drive) mount, and a re-read of a file just rewritten
|
|
# by a child process can return stale or partially-flushed content.
|
|
# That silently mis-scored the sweep — seeds were credited with other
|
|
# seeds' numbers, including nextpnr's PRE-routing placement estimates
|
|
# (which run ~8 MHz optimistic), so a 53.08 MHz FAIL got reported as a
|
|
# 61.94 MHz PASS. Deleting the stale file first did NOT fix it; only
|
|
# not sharing the path does. Do not "simplify" this back to build/.
|
|
build_dir = f"build/seed{seed}"
|
|
build_ok = True
|
|
|
|
try:
|
|
platform.build(BBATopSynth(status_panel=True, uart_console=True), do_program=False,
|
|
verbose=True, nextpnr_opts=opts, build_dir=build_dir)
|
|
except Exception as exc:
|
|
# nextpnr exits non-zero even with --timing-allow-fail on some
|
|
# versions; treat as non-fatal timing failure.
|
|
build_ok = False
|
|
print(f" [seed {seed}] build exception (timing?): "
|
|
f"{type(exc).__name__}: {exc}")
|
|
|
|
# Parse fmax from nextpnr log in build/top.tim (if present). Domain
|
|
# names are 'clk' (exi/sync, 24 MHz target) and 'capture_clk'
|
|
# (54 MHz target, the tighter constraint) — NOT 'exi', which never
|
|
# matched any real log line (this regex silently reported 0.0 MHz
|
|
# for every seed until fixed).
|
|
# nextpnr reports each domain TWICE: once post-placement (an estimate)
|
|
# and once post-routing. Only the post-route number is real, so take
|
|
# the LAST occurrence of each domain — never the first.
|
|
#
|
|
# A seed is only scored if BOTH domains were actually found. Defaulting
|
|
# a missing value to 0.0 and scoring it anyway is how this script once
|
|
# declared "Best seed: 8 ... PASS" while seed 8 in fact failed at
|
|
# 49.66 MHz: unparsed seeds silently mixed with real ones. An
|
|
# unparseable seed must be reported and skipped, never ranked.
|
|
fmax = {}
|
|
for tf in sorted(set(glob.glob(f"{build_dir}/*.tim"))):
|
|
try:
|
|
# errors="replace": a decode hiccup must not abort the parse
|
|
# and leave the seed looking like a legitimate 0.0 MHz result.
|
|
with open(tf, errors="replace") as f:
|
|
for line in f:
|
|
m_ = re.search(
|
|
r"Max frequency for clock\s+'(\w+)':\s*([\d.]+)\s*MHz", line)
|
|
if m_:
|
|
fmax[m_.group(1)] = float(m_.group(2))
|
|
except OSError as exc:
|
|
print(f" [seed {seed}] could not read {tf}: {exc}")
|
|
|
|
if "clk" not in fmax or "capture_clk" not in fmax:
|
|
print(f" [seed {seed}] NO USABLE TIMING REPORT "
|
|
f"(found {sorted(fmax) or 'nothing'}) — NOT SCORED"
|
|
f"{'' if build_ok else '; build also reported an error'}")
|
|
continue
|
|
|
|
fmax_clk, fmax_capture = fmax["clk"], fmax["capture_clk"]
|
|
verdict = ("PASS" if fmax_capture >= 54.02 and fmax_clk >= 24.0
|
|
else "FAIL")
|
|
results.append((seed, fmax_clk, fmax_capture, verdict))
|
|
print(f" [seed {seed}] clk fmax: {fmax_clk:.2f} MHz (target 24) "
|
|
f"capture_clk fmax: {fmax_capture:.2f} MHz (target 54.02) "
|
|
f"{verdict}"
|
|
f"{'' if build_ok else ' [build reported an error]'}")
|
|
# capture_clk is the binding constraint (tighter target, historically
|
|
# the one that swings ±20% with seed) — rank seeds by it.
|
|
if fmax_capture > best_fmax:
|
|
best_fmax = fmax_capture
|
|
best_seed = seed
|
|
|
|
# Per-seed summary. capture_clk swings hard with seed on this design, so
|
|
# the pass RATE matters as much as the best number — a design that only
|
|
# closes on a minority of seeds has no real margin.
|
|
print(f"\n{'='*60}")
|
|
print(f" Seed sweep summary ({len(results)}/{n_seeds} seeds scored)")
|
|
print(f"{'='*60}")
|
|
print(f" {'seed':>4} {'clk (≥24)':>10} {'capture_clk (≥54.02)':>21} verdict")
|
|
for seed, fc, fcap, verdict in results:
|
|
print(f" {seed:>4} {fc:>10.2f} {fcap:>21.2f} {verdict}")
|
|
n_pass = sum(1 for *_, v in results if v == "PASS")
|
|
if results:
|
|
print(f"\n passing seeds: {n_pass}/{len(results)} "
|
|
f"({[s for s, *_, v in results if v == 'PASS']})")
|
|
|
|
overall = "PASS" if best_fmax >= 54.02 else "FAIL"
|
|
print(f"\nBest seed: {best_seed} capture_clk fmax: {best_fmax:.2f} MHz "
|
|
f"(target 54.02) — {overall}")
|
|
if overall == "PASS" and n_pass * 2 < len(results):
|
|
print(" NOTE: a MINORITY of seeds close timing. The bitstream from "
|
|
"the best seed is usable, but this design has little margin — "
|
|
"treat any logic addition as likely to break timing.")
|
|
|
|
# build/ now holds one subdirectory per seed; the flashable bitstream for
|
|
# the best seed is build/seed<N>/top.bin. There is no top-level build/top.bin.
|
|
if results:
|
|
print(f"\nBitstream for best seed: build/seed{best_seed}/top.bin")
|
|
|
|
if do_flash and overall == "FAIL":
|
|
print("\nREFUSING TO FLASH: no seed met the capture-domain timing "
|
|
"constraint. The EXI front-end samples a 27 MHz clock and will "
|
|
"miss bits. Re-run with more seeds (--seeds 16) or reduce logic.")
|
|
elif do_flash:
|
|
print(f"\nFlashing with seed {best_seed}...")
|
|
opts = f"--opt-timing --seed {best_seed} --timing-allow-fail"
|
|
# Fresh platform again — the sweep above already consumed one per seed.
|
|
# Reuse the best seed's own build dir so the flashed bitstream is the
|
|
# one that was actually measured.
|
|
IceBreakerPlatform().build(
|
|
BBATopSynth(status_panel=True, uart_console=True), do_program=True,
|
|
verbose=True, nextpnr_opts=opts, build_dir=f"build/seed{best_seed}")
|
|
|
|
print("Done.")
|