91c3bf0ece
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>
303 lines
13 KiB
Python
303 lines
13 KiB
Python
"""ExiCapture — EXI byte-capture front-end, clocked by the GC's EXI clock.
|
|
|
|
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:
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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
|
|
------------------
|
|
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 *
|
|
from amaranth.lib.cdc import FFSynchronizer
|
|
from amaranth.lib.fifo import AsyncFIFO
|
|
|
|
from exi_bba.spi_mode3_slave import SPIMode3Slave
|
|
|
|
__all__ = ["ExiCapture"]
|
|
|
|
|
|
class ExiCapture(Elaboratable):
|
|
"""EXI front-end: SPI bit engine (capture domain) + byte FIFOs to core.
|
|
|
|
Physical SPI pins (capture domain)
|
|
----------------------------------
|
|
spi_clk / spi_mosi / spi_cs_n : raw async inputs from the GC
|
|
spi_miso : output to the GC
|
|
|
|
Core-facing RX byte stream (core domain, FWFT read side of rx_fifo)
|
|
------------------------------------------------------------------
|
|
rx_data : current received byte
|
|
rx_rdy : a received byte is available
|
|
rx_en : pop (assert for one core cycle to consume rx_data)
|
|
|
|
Core-facing TX byte stream (core domain, write side of tx_fifo)
|
|
--------------------------------------------------------------
|
|
tx_data : response byte to enqueue
|
|
tx_en : write strobe
|
|
tx_rdy : tx_fifo has room
|
|
"""
|
|
|
|
def __init__(self, rx_depth=4, tx_depth=2):
|
|
self._rx_depth = rx_depth
|
|
self._tx_depth = tx_depth
|
|
|
|
# Physical SPI (capture domain, wired to pins by BBATop)
|
|
self.spi_clk = Signal(init=1)
|
|
self.spi_mosi = Signal()
|
|
self.spi_cs_n = Signal(init=1)
|
|
self.spi_miso = Signal()
|
|
|
|
# Core-facing RX read side
|
|
self.rx_data = Signal(8)
|
|
self.rx_rdy = Signal()
|
|
self.rx_en = Signal()
|
|
|
|
# Core-facing TX write side
|
|
self.tx_data = Signal(8)
|
|
self.tx_en = Signal()
|
|
self.tx_rdy = Signal()
|
|
|
|
# Core-facing: high (exi domain) while a transaction is in progress.
|
|
# The register file uses it to stream variable-length (DMA) reads until
|
|
# CS deasserts.
|
|
self.cs_active = Signal()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
|
|
# ── Clock domains driven by the EXI clock pin ─────────────────────
|
|
# The bit engine is clocked by EXI CLK itself (pin 44 = die (6,0) =
|
|
# global buffer net 6 on the 5k-sg48, so it can drive a real clock
|
|
# network). Two edges are needed: Mode 3 samples MOSI on the RISING
|
|
# edge and MISO must change on the FALLING edge, between the master's
|
|
# sampling points.
|
|
#
|
|
# Each edge gets TWO domains with the same clock:
|
|
# *_rst async-reset by CS -- the bit/byte counters, which must
|
|
# restart at every transaction
|
|
# (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(async_reset=True)
|
|
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
|
|
clk_edge="neg")
|
|
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(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),
|
|
cd_fw.clk.eq(self.spi_clk),
|
|
cd_fr.clk.eq(self.spi_clk),
|
|
]
|
|
|
|
spi = SPIMode3Slave()
|
|
m.submodules.spi = spi
|
|
|
|
# RX bytes cross exiclk -> exi; TX bytes cross exi -> exiclk_n.
|
|
# These are the ONLY crossings left, and each has a whole byte-time
|
|
# (~296 ns at 27 MHz) of slack -- unlike the old design, where
|
|
# bit-level MISO timing crossed a domain with a 37 ns budget.
|
|
rx_fifo = AsyncFIFO(width=8, depth=self._rx_depth,
|
|
w_domain="exififo_w", r_domain="sync")
|
|
tx_fifo = AsyncFIFO(width=8, depth=self._tx_depth,
|
|
w_domain="sync", r_domain="exififo_r")
|
|
m.submodules.rx_fifo = rx_fifo
|
|
m.submodules.tx_fifo = tx_fifo
|
|
|
|
# ── Physical pins ↔ bit engine ───────────────────────────────────
|
|
m.d.comb += [
|
|
spi.spi_clk .eq(self.spi_clk),
|
|
spi.spi_mosi.eq(self.spi_mosi),
|
|
spi.spi_cs_n.eq(self.spi_cs_n),
|
|
self.spi_miso.eq(spi.spi_miso),
|
|
]
|
|
|
|
# ── RX: every received byte → rx_fifo ────────────────────────────
|
|
m.d.comb += [
|
|
rx_fifo.w_data.eq(spi.rx_byte),
|
|
rx_fifo.w_en .eq(spi.rx_valid),
|
|
self.rx_data .eq(rx_fifo.r_data),
|
|
self.rx_rdy .eq(rx_fifo.r_rdy),
|
|
rx_fifo.r_en .eq(self.rx_en),
|
|
]
|
|
|
|
# ── TX: core pushes responses; the engine pops one per byte-time ──
|
|
# 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),
|
|
self.tx_rdy .eq(tx_fifo.w_rdy),
|
|
spi.tx_byte .eq(tx_fifo.r_data),
|
|
tx_fifo.r_en .eq(spi.tx_load),
|
|
]
|
|
|
|
# ── Transaction framing, in the slow domain ──────────────────────
|
|
# CS is slow relative to `exi`, so a plain synchroniser is correct and
|
|
# there is no longer any reason to detect it in a fast domain.
|
|
cs_n_s = Signal(init=1)
|
|
m.submodules.cs_sync = FFSynchronizer(self.spi_cs_n, cs_n_s,
|
|
o_domain="sync", init=1)
|
|
cs_n_prev = Signal(init=1)
|
|
m.d.sync += cs_n_prev.eq(cs_n_s)
|
|
m.d.comb += self.cs_active.eq(~cs_n_s)
|
|
|
|
return m
|
|
|
|
|
|
# ── Testbench ─────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
from amaranth.sim import Simulator, Period
|
|
|
|
dut = ExiCapture()
|
|
errors = []
|
|
|
|
# SPI half-period in capture ticks. At 54 MHz capture / 27 MHz EXI the real
|
|
# 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 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."""
|
|
miso = 0
|
|
for bit in range(7, -1, -1):
|
|
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP * (HALF))
|
|
miso = (miso << 1) | ctx.get(dut.spi_miso)
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP * (HALF))
|
|
return miso
|
|
|
|
async def core_drain_rx(ctx, into):
|
|
"""Pop one byte from the core RX side if available."""
|
|
if ctx.get(dut.rx_rdy):
|
|
into.append(ctx.get(dut.rx_data))
|
|
ctx.set(dut.rx_en, 1)
|
|
await ctx.tick("sync").repeat(1)
|
|
ctx.set(dut.rx_en, 0)
|
|
return True
|
|
return False
|
|
|
|
async def push_tx(ctx, b):
|
|
ctx.set(dut.tx_data, b)
|
|
ctx.set(dut.tx_en, 1)
|
|
await ctx.tick("sync").repeat(1)
|
|
ctx.set(dut.tx_en, 0)
|
|
|
|
async def do_txn(ctx, hdr, responses, n_data, rx_seen):
|
|
"""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[:-1]:
|
|
await spi_byte(ctx, h)
|
|
await core_drain_rx(ctx, rx_seen)
|
|
for r in responses: # pushed during the turnaround
|
|
await push_tx(ctx, r)
|
|
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))
|
|
for _ in range(20): # drain data-phase dummies
|
|
await core_drain_rx(ctx, rx_seen)
|
|
await ctx.tick("sync").repeat(1)
|
|
return miso
|
|
|
|
async def testbench(ctx):
|
|
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, 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: over-push must NOT leak into the next transaction ────────
|
|
# Txn A pushes 2 responses but the GC clocks only 1 data byte, leaving
|
|
# 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, 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 stale byte leaked: {[hex(b) for b in misoB]}")
|
|
|
|
sim = Simulator(dut)
|
|
sim.add_clock(Period(MHz=24), domain="sync")
|
|
sim.add_testbench(testbench)
|
|
|
|
with sim.write_vcd("ExiCapture.vcd"):
|
|
sim.run()
|
|
|
|
if errors:
|
|
print("\nFAILURES:")
|
|
for e in errors:
|
|
print(" ", e)
|
|
sys.exit(1)
|
|
else:
|
|
print("\nAll tests passed.")
|