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 = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user