Clock the EXI bit engine from the EXI pin: timing closes on all 8 seeds
Replaces the PLL-fed 54 MHz `capture` domain, which oversampled the GC's
27 MHz EXI clock 2x and so put BIT-level timing across a CDC boundary: the
engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a
register (~55 ns) against a 37 ns bit time, so MISO came out ~2 bits late and
the GC read 0x17 as 05 C0. No amount of oversampling fixes that -- 54 MHz
gives only 2 ticks per bit and the part cannot close the 81 MHz that 3x needs.
The engine is now clocked by the pin itself (pin 44 = global buffer input net
6, no board change), making MISO clock-to-Q. ExiCapture creates four domains:
exiclk (posedge, samples MOSI), exiclk_n (negedge, drives MISO), both
async-reset by CS, plus reset-less exififo_w/exififo_r for the byte FIFO sides
-- reset-less because resetting one side of an AsyncFIFO desynchronises its
gray pointers, and posedge because AsyncFIFO has a posedge-only reset CDC
internally and cannot host a negedge side.
Also removes the SPRAM response FIFO. The DMA read was one byte late because
the round trip did not fit the protocol's turnaround byte; the arbiter already
exposed valid/ready, so the FIFO bought a cycle of latency and nothing else.
The response is handled outside the FSM (the reply lands in RHDR_PAD, not
SPRAM_STREAM) with a one-entry hold for when tx_fifo is momentarily full.
The PLL was left instantiated but unused after the rewrite; removing it frees
the hard macro and pin 35 (which stays unconnected only because the board is
already built that way). This is what lifted the pass rate:
domain target before after
clk (sync) 24 MHz 28.1-30.2 26.4-32.2 8/8
capture_clk 54 MHz 2 of 8 PASS (gone)
EXI pin domain 27 MHz -- 45.6-64.4 8/8
Best seed 3: clk 32.18 MHz, EXI 55.86 MHz. The binding constraint has FLIPPED
-- `clk` (1.10-1.34x) is now tighter than the EXI domain (1.69-2.39x) -- so
synth.py ranks seeds by worst RELATIVE margin, not by the EXI number.
synth.py could not have scored this design at all: it required the vanished
capture_clk, and its regex '(\w+)' cannot match nextpnr's exi_clk_$glb_clk
(no $ in \w). add_clock_constraint was also landing on the wrong net -- under
EXI_J4 the clock is the AND of two pads, not either pad -- so nextpnr silently
fell back to a 12 MHz default and under-optimised the whole EXI domain.
Testbenches:
- spi_mode3_slave had lost its testbench entirely in the rewrite. Restored
with a strict Mode 3 master (changes MOSI on the falling edge, samples MISO
in the clock-low phase) and MUTATION-TESTED: flipping either clock edge in
the RTL makes it fail. Both of those bugs previously reached silicon
against a lax master that parked MOSI well before the edge.
- exi_j4 T2/T3 returned [0,4,2,2]. Real bug: the engine latches the FIFO head
at each byte boundary, so a 2-byte command header leaves only ONE drain
slot. DEVID streamed zeros until CS and left two stale bytes -- one too
many -- so the NEXT enumeration came back a slot late. DEVID now pushes
exactly four bytes and stops. Register-space reads may still over-push:
their 4-byte header gives three drain slots.
- Dead ports removed from SPIMode3Slave (hdr_bytes, frame_start, cs_active,
byte_idx) and the mode2 parameter is gone -- there is one correct edge
convention and it is now enforced by tests.
All 14 module suites pass, plus bba_top at EXI_HALF=1/2/8.
CLAUDE.md: rewrote the clocking, CDC inventory and module tables; marked the
2026-09-30 capture_clk measurements historical; and corrected the line in
"Critical Protocol Notes" that still stated the INVERTED edge convention
("MOSI sampled on falling, MISO driven on rising" is Mode 2) -- the original
implementation followed that doc error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -86,9 +86,13 @@ class BBARegisterFile(Elaboratable):
|
||||
self.spram_req_r_rdy = Signal()
|
||||
|
||||
# SPRAM response sync→exi: sync writes these
|
||||
self.spram_rsp_w_data = Signal(8)
|
||||
self.spram_rsp_w_en = Signal()
|
||||
self.spram_rsp_w_rdy = Signal()
|
||||
# SPRAM response, DIRECT from the arbiter -- no FIFO. That FIFO cost
|
||||
# one 24 MHz cycle (42 ns), and the round trip only fits inside the
|
||||
# protocol's turnaround byte without it (333 ns -> 292 ns against a
|
||||
# 315 ns deadline). Responses must therefore be consumed the cycle
|
||||
# they are valid, which is why only ONE request may be outstanding.
|
||||
self.spram_rsp_data = Signal(8)
|
||||
self.spram_rsp_valid = Signal()
|
||||
|
||||
# TX bytes exi→sync: sync reads these
|
||||
self.tx_bytes_r_data = Signal(8)
|
||||
@@ -125,14 +129,12 @@ class BBARegisterFile(Elaboratable):
|
||||
# On the SPRAM path that latency exceeded the protocol's turnaround
|
||||
# byte and shifted every DMA read by one byte. One slow domain now.
|
||||
spram_req = SyncFIFO(width=16, depth=4)
|
||||
spram_rsp = SyncFIFO(width=8, depth=4)
|
||||
tx_bytes = SyncFIFO(width=8, depth=16)
|
||||
tx_ctrl = SyncFIFO(width=16, depth=4)
|
||||
rx_wptr = SyncFIFO(width=8, depth=4)
|
||||
rx_rptr = SyncFIFO(width=8, depth=4)
|
||||
|
||||
m.submodules.spram_req = spram_req
|
||||
m.submodules.spram_rsp = spram_rsp
|
||||
m.submodules.tx_bytes = tx_bytes
|
||||
m.submodules.tx_ctrl = tx_ctrl
|
||||
m.submodules.rx_wptr = rx_wptr
|
||||
@@ -144,10 +146,6 @@ class BBARegisterFile(Elaboratable):
|
||||
spram_req.r_en .eq(self.spram_req_r_en),
|
||||
self.spram_req_r_rdy .eq(spram_req.r_rdy),
|
||||
|
||||
spram_rsp.w_data .eq(self.spram_rsp_w_data),
|
||||
spram_rsp.w_en .eq(self.spram_rsp_w_en),
|
||||
self.spram_rsp_w_rdy .eq(spram_rsp.w_rdy),
|
||||
|
||||
self.tx_bytes_r_data .eq(tx_bytes.r_data),
|
||||
tx_bytes.r_en .eq(self.tx_bytes_r_en),
|
||||
self.tx_bytes_r_rdy .eq(tx_bytes.r_rdy),
|
||||
@@ -237,6 +235,8 @@ class BBARegisterFile(Elaboratable):
|
||||
# popped (bounds prefetch and is drained at end)
|
||||
sp_addr = Signal(16)
|
||||
outstanding = Signal(4)
|
||||
sp_hold = Signal(8) # response held when tx_fifo was full
|
||||
sp_pending = Signal()
|
||||
SP_LIMIT = 4 # max prefetch depth in flight
|
||||
|
||||
# Effective address of the current data byte — a REGISTERED running
|
||||
@@ -301,7 +301,6 @@ class BBARegisterFile(Elaboratable):
|
||||
self.tx_data.eq(0),
|
||||
# Combinational so the FIFO advances in the SAME cycle as the pop —
|
||||
# a registered r_en would let `pop` re-fire on the same byte.
|
||||
spram_rsp.r_en.eq(0),
|
||||
]
|
||||
|
||||
# ── Transaction FSM (proactive push/pull over byte FIFOs) ────────
|
||||
@@ -404,7 +403,8 @@ class BBARegisterFile(Elaboratable):
|
||||
m.next = "DEVID"
|
||||
with m.Else():
|
||||
m.next = "CHDR_2"
|
||||
with m.Elif(~is_write & (cmd_reg == 0) & id_phase & self.tx_rdy):
|
||||
with m.Elif(~is_write & (cmd_reg == 0) & id_phase
|
||||
& self.tx_rdy & (byte_ctr < 4)):
|
||||
# Device-ID query: keep feeding the ID while byte 1 is
|
||||
# still on the wire so the tx FIFO stays ahead of the GC.
|
||||
# NOT one-shot -- the FIFO is only 2 deep, and stopping
|
||||
@@ -506,8 +506,15 @@ class BBARegisterFile(Elaboratable):
|
||||
m.next = "HEADER0"
|
||||
|
||||
with m.State("DEVID"):
|
||||
# Stream 04 02 02 00 until CS rises.
|
||||
with m.If(self.tx_rdy & self.cs_active):
|
||||
# Push EXACTLY the four ID bytes, then stop.
|
||||
# The engine latches the tx_fifo head at every byte boundary,
|
||||
# and a 2-byte command header leaves only ONE drain slot
|
||||
# before the data phase. Streaming zeros until CS left two
|
||||
# stale bytes behind, one too many to drain, so the NEXT
|
||||
# enumeration came back a slot late (04 02 02 00 -> 00 04 02 02).
|
||||
# Register-space reads may over-push safely: their 4-byte
|
||||
# header gives three drain slots.
|
||||
with m.If(self.tx_rdy & self.cs_active & (byte_ctr < 4)):
|
||||
m.d.comb += [self.tx_data.eq(devid), self.tx_en.eq(1)]
|
||||
m.d.sync += byte_ctr.eq(byte_ctr + 1)
|
||||
with m.If(self.rx_rdy):
|
||||
@@ -521,21 +528,19 @@ class BBARegisterFile(Elaboratable):
|
||||
# Issue read requests ahead (prefetch, bounded by SP_LIMIT) and
|
||||
# push responses into tx_fifo; the capture domain pops them as
|
||||
# the GC clocks. Drain rx dummies as they arrive.
|
||||
issue = Signal()
|
||||
pop = Signal()
|
||||
m.d.comb += issue.eq(self.cs_active & spram_req.w_rdy
|
||||
& (outstanding < SP_LIMIT))
|
||||
m.d.comb += pop.eq(spram_rsp.r_rdy & self.tx_rdy)
|
||||
|
||||
with m.If(issue):
|
||||
# With no response FIFO the reply must be taken the cycle it
|
||||
# is valid, so keep exactly ONE request in flight and only
|
||||
# issue when tx_fifo has somewhere to put the answer. The GC
|
||||
# consumes a byte every ~296 ns and the SPRAM round trip is a
|
||||
# couple of 24 MHz cycles, so serialising costs nothing.
|
||||
# Only ONE request in flight (no response FIFO), and only
|
||||
# when tx_fifo can take the answer.
|
||||
with m.If(self.cs_active & spram_req.w_rdy & self.tx_rdy
|
||||
& (outstanding == 0) & ~sp_pending):
|
||||
m.d.comb += spram_req.w_data.eq(sp_addr)
|
||||
m.d.comb += spram_req.w_en.eq(1)
|
||||
m.d.sync += sp_addr.eq(sp_addr + 1)
|
||||
with m.If(pop):
|
||||
m.d.comb += self.tx_data.eq(spram_rsp.r_data)
|
||||
m.d.comb += self.tx_en.eq(1)
|
||||
m.d.comb += spram_rsp.r_en.eq(1)
|
||||
m.d.sync += outstanding.eq(outstanding + issue - pop)
|
||||
m.d.sync += [sp_addr.eq(sp_addr + 1),
|
||||
outstanding.eq(outstanding + 1)]
|
||||
|
||||
with m.If(self.rx_rdy):
|
||||
m.d.comb += self.rx_en.eq(1) # drain dummy bytes
|
||||
@@ -547,14 +552,28 @@ class BBARegisterFile(Elaboratable):
|
||||
# CS deasserted: drain in-flight SPRAM responses and rx dummies,
|
||||
# then idle. Leftover prefetch in tx_fifo is flushed by
|
||||
# ExiCapture on the next CS assertion.
|
||||
with m.If(spram_rsp.r_rdy):
|
||||
m.d.comb += spram_rsp.r_en.eq(1)
|
||||
m.d.sync += outstanding.eq(outstanding - 1)
|
||||
with m.If(self.rx_rdy):
|
||||
m.d.comb += self.rx_en.eq(1)
|
||||
with m.If((outstanding == 0) & ~self.rx_rdy & ~spram_rsp.r_rdy):
|
||||
with m.If((outstanding == 0) & ~sp_pending & ~self.rx_rdy):
|
||||
m.next = "HEADER0"
|
||||
|
||||
# ── SPRAM response: consumed in ANY state ────────────────────────
|
||||
# The request is issued in RHDR_LO but the reply lands ~2 cycles later,
|
||||
# while the FSM is still in RHDR_PAD waiting for the turnaround byte.
|
||||
# With no response FIFO there is nothing to catch it there, so handle
|
||||
# it outside the FSM: push straight to tx_fifo if it has room, else
|
||||
# hold it one cycle. SPRAM reads and register reads never overlap, so
|
||||
# driving tx here cannot collide with the FSM's own pushes.
|
||||
with m.If(self.spram_rsp_valid & self.tx_rdy & ~sp_pending):
|
||||
m.d.comb += [self.tx_data.eq(self.spram_rsp_data), self.tx_en.eq(1)]
|
||||
m.d.sync += outstanding.eq(outstanding - 1)
|
||||
with m.Elif(self.spram_rsp_valid):
|
||||
m.d.sync += [sp_hold.eq(self.spram_rsp_data), sp_pending.eq(1),
|
||||
outstanding.eq(outstanding - 1)]
|
||||
with m.Elif(sp_pending & self.tx_rdy):
|
||||
m.d.comb += [self.tx_data.eq(sp_hold), self.tx_en.eq(1)]
|
||||
m.d.sync += sp_pending.eq(0)
|
||||
|
||||
# ── Command-space write, applied the cycle after CHDR_3 ──────────
|
||||
with m.If(cmd_wr):
|
||||
m.d.sync += cmd_wr.eq(0)
|
||||
@@ -679,28 +698,28 @@ if __name__ == "__main__":
|
||||
spram_mem = {0x100 + i: (0xA0 + i) & 0xFF for i in range(64)}
|
||||
|
||||
async def spram_model(ctx):
|
||||
"""Model the SPRAM (sync side): answer spram_req with mem[addr].
|
||||
"""Model the SPRAM + arbiter as a request/grant port.
|
||||
|
||||
One request at a time, with cleanly-pulsed r_en/w_en so the FIFO pop
|
||||
and the response push stay in lock-step (no double-response races).
|
||||
The response FIFO is gone: the arbiter drives spram_rsp_valid directly
|
||||
for exactly ONE cycle, one cycle after it takes a request. Mirror that
|
||||
precisely -- a response held high for two cycles makes the register
|
||||
file push the same byte twice.
|
||||
"""
|
||||
state = "POP"
|
||||
busy = False
|
||||
held = 0
|
||||
async for vals in ctx.tick("sync").sample(
|
||||
reg.spram_req_r_rdy, reg.spram_req_r_data, reg.spram_rsp_w_rdy):
|
||||
rdy, addr, rsp_rdy = vals[-3:]
|
||||
reg.spram_req_r_rdy, reg.spram_req_r_data):
|
||||
rdy, addr = vals[-2:]
|
||||
ctx.set(reg.spram_req_r_en, 0)
|
||||
ctx.set(reg.spram_rsp_w_en, 0)
|
||||
if state == "POP":
|
||||
if rdy:
|
||||
held = spram_mem.get(addr, 0)
|
||||
ctx.set(reg.spram_req_r_en, 1) # consume the request
|
||||
state = "RESP"
|
||||
else: # RESP
|
||||
if rsp_rdy:
|
||||
ctx.set(reg.spram_rsp_w_data, held)
|
||||
ctx.set(reg.spram_rsp_w_en, 1) # deliver the response
|
||||
state = "POP"
|
||||
ctx.set(reg.spram_rsp_valid, 0)
|
||||
if busy: # deliver, one cycle after accept
|
||||
ctx.set(reg.spram_rsp_data, held)
|
||||
ctx.set(reg.spram_rsp_valid, 1)
|
||||
busy = False
|
||||
elif rdy: # accept one request
|
||||
held = spram_mem.get(addr, 0)
|
||||
ctx.set(reg.spram_req_r_en, 1)
|
||||
busy = True
|
||||
|
||||
errors = []
|
||||
|
||||
|
||||
+32
-43
@@ -2,11 +2,11 @@
|
||||
|
||||
Clock domains
|
||||
-------------
|
||||
capture : 54 MHz, from 12 MHz oscillator via SB_PLL40_CORE (REFERENCECLK fed
|
||||
from a global-buffer input pin; DIVR=0 DIVF=71 DIVQ=4). CORE (not
|
||||
PAD) so the reference clock is not tied to the dedicated PLL pad — it
|
||||
enters on a clock-capable GBIN pin, leaving board routing free.
|
||||
exi/sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01)
|
||||
sync : 24 MHz, from the iCE40UP5K internal SB_HFOSC (÷2, CLKHF_DIV=0b01).
|
||||
Everything except the EXI bit engine. No PLL, no crystal.
|
||||
exiclk : the GC's EXI clock pin itself (~27 MHz), posedge — created inside
|
||||
exiclk_n : the same pin, negedge ................ `ExiCapture`.
|
||||
exififo_* the same pin, posedge, reset-less — the byte-FIFO sides.
|
||||
|
||||
Submodule instantiation and signal wiring
|
||||
-----------------------------------------
|
||||
@@ -125,40 +125,30 @@ class BBATop(Elaboratable):
|
||||
m = Module()
|
||||
|
||||
# ── Clock domain generation ───────────────────────────────────────
|
||||
# Three domains, two physical sources (1 PLL + 1 internal HFOSC):
|
||||
# capture @ 54 MHz (PLL) — SPI bit engine only; oversamples the
|
||||
# 27 MHz EXI clock 2× (robust Mode-3).
|
||||
# exi @ 24 MHz (HFOSC) — register file / transaction FSM.
|
||||
# sync @ 24 MHz (HFOSC) — SPRAM, RX/TX engines, ethernet master.
|
||||
# exi and sync share the HFOSC net (frequency- and phase-matched); the
|
||||
# AsyncFIFOs between them are still valid CDC and keep the module
|
||||
# boundaries clean. Only the tiny capture front-end needs the fast
|
||||
# clock — which is why 27 MHz-EXI / OG performance is reachable on the
|
||||
# iCE40UP5K even though the register-file logic tops out ~44 MHz.
|
||||
# ONE generated domain, one physical source:
|
||||
# sync @ 24 MHz (internal SB_HFOSC ÷2) — register file, SPRAM,
|
||||
# RX/TX engines, ethernet master. Everything that is not the EXI
|
||||
# bit engine lives here.
|
||||
#
|
||||
# The EXI front-end is NOT clocked from here: `ExiCapture` builds its
|
||||
# own exiclk/exiclk_n domains directly from the GC's EXI clock pin.
|
||||
# That is what removed the old 54 MHz PLL-fed `capture` domain, which
|
||||
# oversampled the EXI clock 2× and put BIT-level timing across a CDC
|
||||
# boundary (~55 ns of synchroniser latency against a 37 ns bit) — the
|
||||
# cause of the `0x17` -> `05 C0` MISO shift on hardware. With the
|
||||
# engine clocked by the pin, MISO is clock-to-Q and the only crossings
|
||||
# left are byte-level FIFOs with a full byte-time of slack.
|
||||
#
|
||||
# Consequence for timing closure: `capture_clk` is GONE as a
|
||||
# constraint. It was the binding path for this whole design and the
|
||||
# reason builds only closed on a minority of seeds.
|
||||
if platform is not None:
|
||||
# capture @ 54 MHz: icepll -i 12 -o 54 → DIVR=0 DIVF=71 DIVQ=4.
|
||||
# 54 MHz = 2× the 27 MHz EXI clock — the minimum oversampling that
|
||||
# cleanly implements SPI Mode 3. The isolated SPI bit engine closes
|
||||
# ~91 MHz on this device; the byte-FIFO read path brings the
|
||||
# integrated capture domain to ~62 MHz, so 54 closes with margin.
|
||||
m.domains += ClockDomain("capture")
|
||||
m.submodules.pll = Instance(
|
||||
"SB_PLL40_CORE",
|
||||
p_FEEDBACK_PATH = "SIMPLE",
|
||||
p_DIVR = 0,
|
||||
p_DIVF = 71,
|
||||
p_DIVQ = 4,
|
||||
p_FILTER_RANGE = 1,
|
||||
i_REFERENCECLK = platform.request("clk12").i,
|
||||
i_RESETB = Const(1, 1),
|
||||
i_BYPASS = Const(0, 1),
|
||||
o_PLLOUTGLOBAL = ClockSignal("capture"),
|
||||
)
|
||||
|
||||
# exi & sync @ 24 MHz: one SB_HFOSC (÷2) drives both slow domains.
|
||||
# The bulky register-file / SPRAM / W5500 logic is routing-bound at
|
||||
# sync @ 24 MHz from the internal oscillator. No PLL and no
|
||||
# external crystal are needed any more, so the 12 MHz input (pin
|
||||
# 35) and the PLL hard macro are both free.
|
||||
# The register-file / SPRAM / ethernet logic is routing-bound at
|
||||
# ~33–44 MHz on the UP5K; 24 MHz closes with large margin. The byte
|
||||
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 slow cycles per byte.
|
||||
# rate (27 MHz EXI ÷ 8 ≈ 3.4 MHz) leaves ~7 sync cycles per byte.
|
||||
m.domains += ClockDomain("sync")
|
||||
m.submodules.hfosc = Instance(
|
||||
"SB_HFOSC",
|
||||
@@ -167,7 +157,7 @@ class BBATop(Elaboratable):
|
||||
i_CLKHFPU = Const(1, 1),
|
||||
o_CLKHF = ClockSignal("sync"),
|
||||
)
|
||||
# (simulation: test harness provides capture/exi/sync clocks via add_clock)
|
||||
# (simulation: the test harness drives the EXI pins and `sync` directly)
|
||||
|
||||
# ── Submodules ────────────────────────────────────────────────────
|
||||
cap = ExiCapture() # SPI bit engine (capture) + byte FIFOs
|
||||
@@ -191,7 +181,7 @@ class BBATop(Elaboratable):
|
||||
# ── External pin connections ──────────────────────────────────────
|
||||
m.d.comb += [
|
||||
# EXI inputs (to capture-domain front-end)
|
||||
cap.spi_clk .eq(self.exi_clk),
|
||||
cap.spi_clk .eq(self.exi_clk), # drives the exiclk domains
|
||||
cap.spi_mosi.eq(self.exi_mosi),
|
||||
cap.spi_cs_n.eq(self.exi_cs_n),
|
||||
# EXI outputs
|
||||
@@ -244,9 +234,8 @@ class BBATop(Elaboratable):
|
||||
]
|
||||
# SPRAM response: arb result → reg sync→exi FIFO write side
|
||||
m.d.comb += [
|
||||
reg.spram_rsp_w_data.eq(arb.exi_rsp_data),
|
||||
reg.spram_rsp_w_en .eq(arb.exi_rsp_valid),
|
||||
# arb does not need w_rdy feedback (spram_rsp FIFO is deeper than latency)
|
||||
reg.spram_rsp_data .eq(arb.exi_rsp_data),
|
||||
reg.spram_rsp_valid.eq(arb.exi_rsp_valid),
|
||||
]
|
||||
|
||||
# ── BBARegisterFile ↔ TXFrameDrain (sync domain FIFO sides) ──────
|
||||
@@ -421,7 +410,7 @@ if __name__ == "__main__":
|
||||
# `capture` domain to count ticks against. Drive the SPI clock with real
|
||||
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning
|
||||
# of HALF so the rates in the table above still hold.
|
||||
HP = Period(MHz=54) * HALF
|
||||
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
|
||||
# Gap between transactions (CS high). cs_active reaches the exi domain
|
||||
# through a 2-stage FFSynchronizer at 24 MHz, so CS must stay high for
|
||||
# ~3 exi cycles or the register file never sees the transaction end and
|
||||
|
||||
+75
-50
@@ -1,28 +1,45 @@
|
||||
"""ExiCapture — fast EXI byte-capture front-end (capture domain, 54 MHz).
|
||||
"""ExiCapture — EXI byte-capture front-end, clocked by the GC's EXI clock.
|
||||
|
||||
Wraps the SPIMode3Slave bit engine and bridges it to the slower `exi` domain
|
||||
(24 MHz) through two AsyncFIFOs:
|
||||
Creates the pin-driven clock domains (so the byte FIFOs can name them), wraps
|
||||
the SPIMode3Slave bit engine, and bridges it to the 24 MHz `sync` domain
|
||||
through two AsyncFIFOs:
|
||||
|
||||
capture (54 MHz) exi (24 MHz)
|
||||
┌────────────────────┐ rx_fifo ───► received bytes (header + data)
|
||||
│ SPIMode3Slave │ (8-bit, capture→exi)
|
||||
│ (bit engine) │ tx_fifo ◄─── response bytes to drive on MISO
|
||||
└────────────────────┘ (8-bit, exi→capture)
|
||||
EXI clock pin (~27 MHz) sync (24 MHz, HFOSC)
|
||||
┌─────────────────────┐ rx_fifo ───► received bytes (header + data)
|
||||
│ SPIMode3Slave │ (8-bit, exififo_w→sync)
|
||||
│ (bit engine) │ tx_fifo ◄─── response bytes to drive on MISO
|
||||
└─────────────────────┘ (8-bit, sync→exififo_r)
|
||||
|
||||
Why split: the bit engine must oversample a 27 MHz EXI clock 2×, which needs a
|
||||
54 MHz clock — far faster than the register-file logic can close (~44 MHz).
|
||||
Only this small, shallow front-end runs fast; everything else stays at 24 MHz.
|
||||
Domains created here
|
||||
--------------------
|
||||
exiclk EXI clock pin, posedge, async-reset by CS -- samples MOSI
|
||||
exiclk_n EXI clock pin, negedge, async-reset by CS -- drives MISO
|
||||
exififo_w EXI clock pin, posedge, RESET-LESS -- rx FIFO write side
|
||||
exififo_r EXI clock pin, posedge, RESET-LESS -- tx FIFO read side
|
||||
|
||||
TX response gating
|
||||
The FIFO domains must NOT see CS: resetting one side of an AsyncFIFO
|
||||
desynchronises its gray pointers against the other side. They are also both
|
||||
posedge, because AsyncFIFO instantiates a posedge-only reset CDC internally and
|
||||
cannot host a negedge side.
|
||||
|
||||
Why pin-clocked: oversampling the EXI clock from a faster domain put BIT-level
|
||||
timing across a CDC boundary (~55 ns of synchroniser latency against a 37 ns
|
||||
bit), so MISO came out ~2 bits late and the GC read 0x17 as 05 C0. Clocking
|
||||
from the pin makes MISO clock-to-Q and leaves these byte FIFOs as the only
|
||||
crossing, each with a full byte-time (~296 ns) of slack.
|
||||
|
||||
TX response timing
|
||||
------------------
|
||||
Every EXI transaction begins with 2 header bytes (write_flag/addr/len) during
|
||||
which the GC ignores MISO. The core cannot have produced a response yet (it
|
||||
hasn't even decoded the header), so the wrapper must NOT pop tx_fifo for those
|
||||
2 bytes. A per-transaction counter (`txld_cnt`, reset by frame_start) gates the
|
||||
pop: header bytes drive a don't-care 0xFF; from the first data byte onward the
|
||||
wrapper pops tx_fifo (one byte per tx_load). `tx_hold` is registered at tx_load
|
||||
time — before the FIFO advances — so the bit engine latches the correct byte on
|
||||
the following SPI rising edge (the classic FWFT-advance off-by-one is avoided).
|
||||
The engine pops tx_fifo once per byte boundary, unconditionally -- there is no
|
||||
header counter and no slot threshold. The byte popped at the end of byte N is
|
||||
presented during byte N+1, so pops during the header drain whatever the
|
||||
previous transaction left in the FIFO (this replaces the old CS-fall flush),
|
||||
and the response the register file pushes during the protocol's turnaround
|
||||
byte lands in data slot 0.
|
||||
|
||||
⚠️ Command space has only ONE drain slot (2-byte header), so a command-space
|
||||
responder must push exactly its bytes and stop -- see the DEVID note in
|
||||
bba_register_file.py.
|
||||
"""
|
||||
|
||||
from amaranth import *
|
||||
@@ -96,15 +113,15 @@ class ExiCapture(Elaboratable):
|
||||
# (plain) no reset -- the byte FIFOs. Resetting only ONE side of an
|
||||
# AsyncFIFO desynchronises its gray pointers and corrupts it,
|
||||
# so the FIFOs must NOT see CS.
|
||||
m.domains.exiclk = cd_p = ClockDomain(local=True, async_reset=True)
|
||||
m.domains.exiclk_n = cd_n = ClockDomain(local=True, async_reset=True,
|
||||
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
|
||||
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
|
||||
clk_edge="neg")
|
||||
m.domains.exififo_w = cd_fw = ClockDomain(local=True, reset_less=True)
|
||||
m.domains.exififo_w = cd_fw = ClockDomain(reset_less=True)
|
||||
# NOTE: both FIFO domains are POSEDGE. AsyncFIFO instantiates a
|
||||
# posedge-only reset CDC internally, so a negedge domain cannot host a
|
||||
# FIFO side. The engine pops in `exififo_r` (posedge) and hands the
|
||||
# byte to its negedge output stage through a register.
|
||||
m.domains.exififo_r = cd_fr = ClockDomain(local=True, reset_less=True)
|
||||
m.domains.exififo_r = cd_fr = ClockDomain(reset_less=True)
|
||||
m.d.comb += [
|
||||
cd_p .clk.eq(self.spi_clk), cd_p .rst.eq(self.spi_cs_n),
|
||||
cd_n .clk.eq(self.spi_clk), cd_n .rst.eq(self.spi_cs_n),
|
||||
@@ -144,9 +161,9 @@ class ExiCapture(Elaboratable):
|
||||
]
|
||||
|
||||
# ── TX: core pushes responses; the engine pops one per byte-time ──
|
||||
# The engine suppresses the pop during the protocol's turnaround byte
|
||||
# and drains stale bytes during the rest of the header, so no
|
||||
# CS-fall flush or slot-threshold logic is needed here any more.
|
||||
# Pops are UNCONDITIONAL (one per byte boundary). Header pops drain
|
||||
# whatever the previous transaction left behind, so no CS-fall flush
|
||||
# or slot-threshold logic is needed here any more.
|
||||
m.d.comb += [
|
||||
tx_fifo.w_data.eq(self.tx_data),
|
||||
tx_fifo.w_en .eq(self.tx_en),
|
||||
@@ -181,11 +198,9 @@ if __name__ == "__main__":
|
||||
# ratio is ~2; use 4 here for a clean, well-oversampled functional check.
|
||||
HALF = 4
|
||||
|
||||
# No capture domain any more: the bit engine is clocked by the EXI
|
||||
|
||||
# pin, so drive the SPI clock with real delays instead of ticks.
|
||||
|
||||
HP = Period(MHz=54) * HALF
|
||||
# No capture domain any more: the bit engine is clocked by the EXI pin,
|
||||
# so drive the SPI clock with real delays instead of counting ticks.
|
||||
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
|
||||
|
||||
async def spi_byte(ctx, mosi_val):
|
||||
"""Clock one SPI Mode 3 byte; return the assembled MISO byte."""
|
||||
@@ -216,20 +231,24 @@ if __name__ == "__main__":
|
||||
ctx.set(dut.tx_en, 0)
|
||||
|
||||
async def do_txn(ctx, hdr, responses, n_data, rx_seen):
|
||||
"""One EXI transaction: clock `hdr` bytes, model the clock-idle gap
|
||||
(drain rx + prefetch `responses` into tx_fifo), then clock `n_data`
|
||||
data bytes; return the MISO data bytes read."""
|
||||
"""One EXI transaction, modelling the REAL protocol shape.
|
||||
|
||||
The engine pops tx_fifo at every byte boundary, so a response must be
|
||||
in the FIFO before the LAST header byte ends to land in data slot 0.
|
||||
That byte is the protocol's turnaround byte (docs/exi_protocol_real.md)
|
||||
and is exactly when the register file pushes. Pushing after the whole
|
||||
header -- as this bench used to -- is one slot too late.
|
||||
"""
|
||||
ctx.set(dut.spi_cs_n, 0)
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
for h in hdr:
|
||||
for h in hdr[:-1]:
|
||||
await spi_byte(ctx, h)
|
||||
for _ in range(20): # clock-idle gap
|
||||
await core_drain_rx(ctx, rx_seen)
|
||||
await ctx.tick("sync").repeat(1)
|
||||
for r in responses:
|
||||
for r in responses: # pushed during the turnaround
|
||||
await push_tx(ctx, r)
|
||||
await ctx.delay(HP * (2))
|
||||
await spi_byte(ctx, hdr[-1])
|
||||
await core_drain_rx(ctx, rx_seen)
|
||||
miso = [await spi_byte(ctx, 0x00) for _ in range(n_data)]
|
||||
ctx.set(dut.spi_cs_n, 1)
|
||||
await ctx.delay(HP * (HALF))
|
||||
@@ -242,24 +261,30 @@ if __name__ == "__main__":
|
||||
rx_seen = []
|
||||
await ctx.delay(HP * (2))
|
||||
|
||||
# Real register-space header: [0x80, ah, al, 0x00] -- the trailing
|
||||
# byte is the turnaround slot.
|
||||
HDR = [0x80, 0x00, 0x12, 0x00]
|
||||
|
||||
# ── T1: header + 2 data bytes read back ──────────────────────────
|
||||
miso = await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 2, rx_seen)
|
||||
print(f"T1 rx={[hex(b) for b in rx_seen[:2]]} MISO={[f'0x{b:02X}' for b in miso]}")
|
||||
if rx_seen[:2] != [0x12, 0x34]:
|
||||
errors.append(f"T1 header rx wrong: {rx_seen[:2]}")
|
||||
miso = await do_txn(ctx, HDR, [0xA5, 0x5A], 2, rx_seen)
|
||||
print(f"T1 rx={[hex(b) for b in rx_seen[:4]]} MISO={[f'0x{b:02X}' for b in miso]}")
|
||||
if rx_seen[:4] != HDR:
|
||||
errors.append(f"T1 header rx wrong: {[hex(b) for b in rx_seen[:4]]}")
|
||||
if miso != [0xA5, 0x5A]:
|
||||
errors.append(f"T1 MISO wrong: {[hex(b) for b in miso]}")
|
||||
|
||||
# ── T2: prefetch over-push must NOT leak into the next transaction ─
|
||||
# ── T2: over-push must NOT leak into the next transaction ────────
|
||||
# Txn A pushes 2 responses but the GC clocks only 1 data byte, leaving
|
||||
# one stale byte in tx_fifo. Txn B must read its OWN fresh responses,
|
||||
# proving the CS-fall flush cleared the stale prefetch.
|
||||
# a stale byte in tx_fifo. Txn B must read its OWN responses. There
|
||||
# is no CS-fall flush any more: the engine's pops during the header
|
||||
# drain whatever the previous transaction left behind.
|
||||
rx_seen.clear()
|
||||
await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 1, rx_seen) # leaves 0x5A
|
||||
misoB = await do_txn(ctx, [0x12, 0x34], [0x11, 0x22], 2, rx_seen)
|
||||
await do_txn(ctx, HDR, [0xA5, 0x5A], 1, rx_seen) # leaves 0x5A
|
||||
rx_seen.clear()
|
||||
misoB = await do_txn(ctx, HDR, [0x11, 0x22], 2, rx_seen)
|
||||
print(f"T2 MISO after over-push: {[f'0x{b:02X}' for b in misoB]} (want 0x11 0x22)")
|
||||
if misoB != [0x11, 0x22]:
|
||||
errors.append(f"T2 flush failed — stale byte leaked: {[hex(b) for b in misoB]}")
|
||||
errors.append(f"T2 stale byte leaked: {[hex(b) for b in misoB]}")
|
||||
|
||||
sim = Simulator(dut)
|
||||
sim.add_clock(Period(MHz=24), domain="sync")
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ if __name__ == "__main__":
|
||||
# pin, so drive the SPI clock with real delays instead of ticks.
|
||||
|
||||
|
||||
HP = Period(MHz=54) * HALF
|
||||
HP = Period(MHz=54) # ONE capture-equivalent tick; delays are HP * n
|
||||
DEVICE_ID = [0x04, 0x02, 0x02, 0x00]
|
||||
|
||||
class _Harness(Elaboratable):
|
||||
|
||||
+137
-17
@@ -53,10 +53,10 @@ class SPIMode3Slave(Elaboratable):
|
||||
rx_byte : last complete received byte (valid while rx_valid is high)
|
||||
rx_valid : 1-cycle pulse in `exiclk` when rx_byte holds a new byte
|
||||
tx_byte : byte to transmit; sampled at each byte boundary
|
||||
tx_load : 1-cycle pulse in `exiclk_n` asking upstream for the next byte
|
||||
hdr_bytes : how many leading bytes are header (MISO don't-care). Pops are
|
||||
suppressed until past them, so the first response byte the register
|
||||
file pushes lands in data slot 0 rather than being eaten by the header.
|
||||
tx_load : 1-cycle pulse asking upstream for the next byte, one per byte
|
||||
boundary. No header-length or slot-threshold logic: see the TX comment
|
||||
in elaborate() for why unconditional pops are both sufficient and
|
||||
necessary.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -71,29 +71,16 @@ class SPIMode3Slave(Elaboratable):
|
||||
self.tx_byte = Signal(8)
|
||||
self.tx_load = Signal()
|
||||
|
||||
# Header length for this transaction, in bytes. The real protocol has
|
||||
# two forms selected by bit 7 of byte 0 (see docs/exi_protocol_real.md):
|
||||
# register space = 4-byte header, command space = 2-byte.
|
||||
self.hdr_bytes = Signal(3, init=4)
|
||||
|
||||
# Transaction framing, generated in the slow domain by the parent.
|
||||
self.frame_start = Signal()
|
||||
self.cs_active = Signal()
|
||||
|
||||
def elaborate(self, platform):
|
||||
m = Module()
|
||||
|
||||
# ── RX: sample MOSI on the RISING edge (Mode 3 samples on edge 2) ──
|
||||
rx_shift = Signal(8)
|
||||
rx_bit = Signal(3)
|
||||
byte_idx = Signal(3, init=0) # bytes seen this transaction (saturating)
|
||||
|
||||
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # MSB-first: shift left, new bit at LSB
|
||||
m.d.exiclk += rx_shift.eq(nxt)
|
||||
m.d.exiclk += rx_bit.eq(rx_bit + 1)
|
||||
with m.If(rx_bit == 7):
|
||||
with m.If(byte_idx != 7):
|
||||
m.d.exiclk += byte_idx.eq(byte_idx + 1)
|
||||
|
||||
# rx_byte/rx_valid are COMBINATIONAL, not registered. The clock stops
|
||||
# and CS rises immediately after a transaction's final bit, so a
|
||||
@@ -138,3 +125,136 @@ class SPIMode3Slave(Elaboratable):
|
||||
m.d.comb += self.spi_miso.eq(Mux(self.spi_cs_n, 1, tx_shift[7]))
|
||||
|
||||
return m
|
||||
|
||||
|
||||
# ── Testbench ─────────────────────────────────────────────────────────────
|
||||
|
||||
class _Harness(Elaboratable):
|
||||
"""Wraps the slave with the two pin-driven domains its parent normally
|
||||
creates, so the engine can be exercised standalone."""
|
||||
|
||||
def __init__(self):
|
||||
self.dut = SPIMode3Slave()
|
||||
|
||||
def elaborate(self, platform):
|
||||
m = Module()
|
||||
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
|
||||
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
|
||||
clk_edge="neg")
|
||||
d = self.dut
|
||||
m.d.comb += [
|
||||
cd_p.clk.eq(d.spi_clk), cd_p.rst.eq(d.spi_cs_n),
|
||||
cd_n.clk.eq(d.spi_clk), cd_n.rst.eq(d.spi_cs_n),
|
||||
]
|
||||
m.submodules.dut = d
|
||||
return m
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from amaranth.sim import Simulator, Period
|
||||
|
||||
HP = Period(MHz=27) / 2 # one EXI half-period at the hardware rate
|
||||
top = _Harness()
|
||||
dut = top.dut
|
||||
|
||||
failures = []
|
||||
|
||||
def check(name, got, want):
|
||||
ok = got == want
|
||||
print(f" {'ok ' if ok else 'FAIL'} {name}: got {got}, want {want}")
|
||||
if not ok:
|
||||
failures.append(name)
|
||||
|
||||
# A STRICT Mode 3 master. Unlike a convenience model that parks MOSI long
|
||||
# before the edge, this one changes MOSI exactly ON the falling edge and
|
||||
# samples MISO in the CLK-low phase. That is what makes the edge
|
||||
# convention observable: a slave that sampled MOSI on the falling edge
|
||||
# would capture the previous bit, and one that drove MISO on the rising
|
||||
# edge would come out a bit late. Both bugs shipped to hardware against a
|
||||
# lax master that could not see them.
|
||||
async def spi_byte(ctx, mosi_val, probe_rx=False):
|
||||
miso = 0
|
||||
rx = None
|
||||
for bit in range(7, -1, -1):
|
||||
ctx.set(dut.spi_clk, 0) # falling edge
|
||||
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
|
||||
await ctx.delay(HP)
|
||||
miso = (miso << 1) | ctx.get(dut.spi_miso) # sample in CLK-low
|
||||
if probe_rx and bit == 0:
|
||||
rx = (ctx.get(dut.rx_valid), ctx.get(dut.rx_byte))
|
||||
ctx.set(dut.spi_clk, 1) # rising edge: slave samples
|
||||
await ctx.delay(HP)
|
||||
return (miso, rx) if probe_rx else miso
|
||||
|
||||
async def cs_low(ctx):
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
ctx.set(dut.spi_cs_n, 0)
|
||||
await ctx.delay(HP)
|
||||
|
||||
async def cs_high(ctx):
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
await ctx.delay(HP)
|
||||
ctx.set(dut.spi_cs_n, 1)
|
||||
await ctx.delay(HP * 4)
|
||||
|
||||
async def tb(ctx):
|
||||
ctx.set(dut.spi_cs_n, 1)
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
ctx.set(dut.tx_byte, 0)
|
||||
await ctx.delay(HP * 4)
|
||||
|
||||
# T1 — RX samples MOSI on the RISING edge, MSB first.
|
||||
# rx_byte/rx_valid are combinational and peak during the final bit,
|
||||
# which is the instant the parent's FIFO captures them.
|
||||
await cs_low(ctx)
|
||||
seen = []
|
||||
for b in (0x80, 0x00, 0x31, 0x00):
|
||||
_, (valid, byte) = await spi_byte(ctx, b, probe_rx=True)
|
||||
seen.append((valid, byte))
|
||||
await cs_high(ctx)
|
||||
check("T1 rx bytes", [b for _, b in seen], [0x80, 0x00, 0x31, 0x00])
|
||||
check("T1 rx_valid", [v for v, _ in seen], [1, 1, 1, 1])
|
||||
|
||||
# T2 — TX: the byte latched at the end of slot N appears in slot N+1,
|
||||
# MSB first. Slot 0 is the reset value, before any load.
|
||||
await cs_low(ctx)
|
||||
ctx.set(dut.tx_byte, 0xA5)
|
||||
s0 = await spi_byte(ctx, 0x00)
|
||||
ctx.set(dut.tx_byte, 0x3C)
|
||||
s1 = await spi_byte(ctx, 0x00)
|
||||
ctx.set(dut.tx_byte, 0x00)
|
||||
s2 = await spi_byte(ctx, 0x00)
|
||||
await cs_high(ctx)
|
||||
check("T2 miso slot0 (pre-load)", hex(s0), hex(0x00))
|
||||
check("T2 miso slot1", hex(s1), hex(0xA5))
|
||||
check("T2 miso slot2", hex(s2), hex(0x3C))
|
||||
|
||||
# T3 — tx_load pulses exactly once per byte, during the final bit.
|
||||
await cs_low(ctx)
|
||||
loads = 0
|
||||
for _ in range(3):
|
||||
for bit in range(8):
|
||||
ctx.set(dut.spi_clk, 0)
|
||||
await ctx.delay(HP)
|
||||
loads += ctx.get(dut.tx_load)
|
||||
ctx.set(dut.spi_clk, 1)
|
||||
await ctx.delay(HP)
|
||||
await cs_high(ctx)
|
||||
check("T3 tx_load pulses", loads, 3)
|
||||
|
||||
# T4 — MISO releases HIGH when CS is deasserted, so it never fights
|
||||
# another EXI device sharing the bus.
|
||||
check("T4 miso idles high", ctx.get(dut.spi_miso), 1)
|
||||
|
||||
sim = Simulator(top)
|
||||
sim.add_testbench(tb)
|
||||
with sim.write_vcd("SPIMode3Slave.vcd"):
|
||||
sim.run()
|
||||
|
||||
if failures:
|
||||
print("\nFAILURES:")
|
||||
for f in failures:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("\nAll tests passed.")
|
||||
|
||||
+85
-38
@@ -36,7 +36,7 @@ from exi_bba.bba_top import BBATop
|
||||
# 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
|
||||
# that design needed the PLL for its 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
|
||||
@@ -48,6 +48,10 @@ from exi_bba.bba_top import BBATop
|
||||
# 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.
|
||||
# NOTE 2026-10-03: the PLL is GONE (the bit engine is clocked by the EXI pin),
|
||||
# so the silicon conflict no longer applies and pin 35 is electrically usable
|
||||
# again. It stays unconnected anyway because the BOARD is already built with
|
||||
# ETH_INT on pin 13 — this is now a hardware fact, not a tool constraint.
|
||||
#
|
||||
# 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
|
||||
@@ -64,17 +68,18 @@ from exi_bba.bba_top import BBATop
|
||||
# directly — leaving o_RGB* dangling (the old code) never bonds them and the
|
||||
# LEDs stay dark. See the "rgb" Resource + elaborate() below, and BRINGUP.md.
|
||||
|
||||
# nextpnr P&R options. The binding constraint is the isolated 54 MHz capture
|
||||
# domain (the SPI Mode-3 bit engine); the 24 MHz sync domain has wide margin.
|
||||
# nextpnr P&R options. Since the PLL-fed 54 MHz capture domain was removed
|
||||
# (the bit engine is now clocked by the EXI pin itself), the EXI domain has
|
||||
# ~2.4x margin against its 27 MHz target and the 24 MHz `sync` domain is the
|
||||
# TIGHTER of the two. The sweep therefore ranks seeds by worst RELATIVE
|
||||
# margin, not by the EXI number.
|
||||
#
|
||||
# TRIED (2026-08 shell build) and REVERTED — prioritising routing toward the
|
||||
# critical capture paths did NOT help: `--router router2 --tmg-ripup
|
||||
# --placer-heap-timingweight 30 --placer-heap-critexp 4` left capture at
|
||||
# 41-49 MHz (best 49.07, still < 54.02) across seeds AND eroded the slow-clock
|
||||
# margin (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). The
|
||||
# capture shortfall is congestion + inherent path delay at this LC level, not a
|
||||
# routing-priority tuning problem, so weighting P&R toward it only robs the
|
||||
# sync domain. Plain --opt-timing is the better baseline.
|
||||
# TRIED (2026-08, against the old capture domain) and REVERTED — prioritising
|
||||
# routing toward the critical paths did NOT help: `--router router2
|
||||
# --tmg-ripup --placer-heap-timingweight 30 --placer-heap-critexp 4` left
|
||||
# capture at 41-49 MHz across seeds AND eroded the slow-clock margin
|
||||
# (24.4-25.9 MHz vs the 28-33 that plain --opt-timing gives). Plain
|
||||
# --opt-timing is the better baseline.
|
||||
_PNR_TIMING_OPTS = "--opt-timing"
|
||||
|
||||
# UART pin override for bring-up. The re-bba-rb V1 lab unit had its UART_RXD
|
||||
@@ -212,6 +217,12 @@ class IceBreakerPlatform(LatticeICE40Platform):
|
||||
|
||||
# ── BBATop with platform resource wiring ─────────────────────────────────
|
||||
|
||||
# The GameCube's EXI clock ceiling. libogc's EXI_SPEED32MHZ is a nominal
|
||||
# name; the bus actually tops out here, and the bit engine is clocked by it.
|
||||
_EXI_HZ = 27e6
|
||||
_EXI_TARGET = 27.0 # MHz, as the sweep scores it
|
||||
|
||||
|
||||
class BBATopSynth(BBATop):
|
||||
"""BBATop with platform pin connections added in elaborate()."""
|
||||
|
||||
@@ -238,6 +249,26 @@ class BBATopSynth(BBATop):
|
||||
else:
|
||||
exi_clk_i, exi_mosi_i, exi_cs_n_i = exi.clk.i, exi.mosi.i, exi.cs_n.i
|
||||
|
||||
# The EXI clock pin now clocks the bit engine directly (see
|
||||
# BBATop's clock-domain comment), so it is a REAL clock net and
|
||||
# must be constrained. Without this nextpnr defaults it to
|
||||
# 12 MHz and happily reports PASS on a build that would miss bits
|
||||
# on a console. The GC tops out at ~27 MHz ("EXI_SPEED32MHZ" is a
|
||||
# nominal name, not the actual rate).
|
||||
# Constrain the net that actually BECOMES the clock, which
|
||||
# differs between the two build variants:
|
||||
# plain -- self.exi_clk is a pure alias of the pad and yosys
|
||||
# absorbs it, leaving the pad net (nextpnr calls it
|
||||
# 'exi_0__clk__i_$glb_clk').
|
||||
# EXI_J4 -- the clock is the AND of the two pads, a distinct net
|
||||
# ('exi_clk_$glb_clk'); constraining either pad then
|
||||
# misses, and nextpnr silently falls back to its 12 MHz
|
||||
# default and under-optimises the whole EXI domain.
|
||||
if _EXI_J4:
|
||||
platform.add_clock_constraint(self.exi_clk, _EXI_HZ)
|
||||
else:
|
||||
platform.add_clock_constraint(exi.clk.i, _EXI_HZ)
|
||||
|
||||
m.d.comb += [
|
||||
self.exi_clk .eq(exi_clk_i),
|
||||
self.exi_mosi .eq(exi_mosi_i),
|
||||
@@ -373,7 +404,8 @@ if __name__ == "__main__":
|
||||
|
||||
best_seed = 1
|
||||
best_fmax = 0.0
|
||||
results = [] # (seed, fmax_clk, fmax_capture, verdict), scored only
|
||||
best_detail = None
|
||||
results = [] # (seed, fmax_clk, fmax_exi, verdict), scored only
|
||||
for seed in range(1, n_seeds + 1):
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Seed {seed}/{n_seeds}")
|
||||
@@ -412,10 +444,10 @@ if __name__ == "__main__":
|
||||
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).
|
||||
# names are 'clk' (sync, 24 MHz target) and the EXI pin's promoted
|
||||
# global-clock net (27 MHz target), whose name nextpnr derives from
|
||||
# the net and which CONTAINS a '$' — so the capture group must not be
|
||||
# \w+ (that silently matched nothing and left seeds unscored).
|
||||
# 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.
|
||||
@@ -433,49 +465,63 @@ if __name__ == "__main__":
|
||||
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)
|
||||
r"Max frequency for clock\s+'([^']+)':\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:
|
||||
# The EXI domain is clocked by the GC's clock PIN, so nextpnr names it
|
||||
# after the net it was promoted from (e.g. "exi_0__clk__i_$glb_clk")
|
||||
# rather than a domain name we chose. Match on substring, and require
|
||||
# EXACTLY ONE match so a renamed or duplicated net can never be scored
|
||||
# silently against the wrong clock.
|
||||
exi_keys = [k for k in fmax if "exi" in k]
|
||||
if "clk" not in fmax or len(exi_keys) != 1:
|
||||
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
|
||||
fmax_clk, fmax_exi = fmax["clk"], fmax[exi_keys[0]]
|
||||
verdict = ("PASS" if fmax_exi >= _EXI_TARGET and fmax_clk >= 24.0
|
||||
else "FAIL")
|
||||
results.append((seed, fmax_clk, fmax_capture, verdict))
|
||||
results.append((seed, fmax_clk, fmax_exi, 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"exi fmax: {fmax_exi:.2f} MHz (target {_EXI_TARGET}) "
|
||||
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
|
||||
# Rank by whichever domain has the least RELATIVE margin. The old
|
||||
# script ranked by capture_clk because it was always the binding one;
|
||||
# with the PLL gone the EXI domain has ~2.3x margin and `clk` can
|
||||
# easily be the tighter of the two, so ranking by EXI alone would pick
|
||||
# a seed that is worse overall.
|
||||
score = min(fmax_exi / _EXI_TARGET, fmax_clk / 24.0)
|
||||
if score > best_fmax:
|
||||
best_fmax = score
|
||||
best_seed = seed
|
||||
best_detail = (fmax_clk, fmax_exi)
|
||||
|
||||
# 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.
|
||||
# Per-seed summary. 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}")
|
||||
print(f" {'seed':>4} {'clk (≥24)':>10} {f'exi (≥{_EXI_TARGET})':>16} verdict")
|
||||
for seed, fc, fexi, verdict in results:
|
||||
print(f" {seed:>4} {fc:>10.2f} {fexi:>16.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}")
|
||||
overall = "PASS" if best_fmax >= 1.0 else "FAIL"
|
||||
if best_detail:
|
||||
print(f"\nBest seed: {best_seed} clk {best_detail[0]:.2f} MHz, "
|
||||
f"exi {best_detail[1]:.2f} MHz "
|
||||
f"(worst margin {best_fmax:.2f}x) — {overall}")
|
||||
else:
|
||||
print(f"\nBest seed: {best_seed} — {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 — "
|
||||
@@ -487,9 +533,10 @@ if __name__ == "__main__":
|
||||
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.")
|
||||
print("\nREFUSING TO FLASH: no seed met both timing constraints. "
|
||||
"The EXI front-end is clocked BY the GC's 27 MHz clock, so a "
|
||||
"failing seed will miss bits on a console. Re-run with more "
|
||||
"seeds (--seeds 16) or reduce logic.")
|
||||
elif do_flash:
|
||||
print(f"\nFlashing with seed {best_seed}...")
|
||||
opts = f"{_PNR_TIMING_OPTS} --seed {best_seed} --timing-allow-fail"
|
||||
|
||||
Reference in New Issue
Block a user