2f081a3e69
Removing ~2 ns from the fabric MISO path (EXI_J4 merge + the CS mux) did NOT move the hardware result: the GC still returned a byte-identical 0B 80 at 27 MHz while reading correctly at 16 MHz and below. Identical rather than intermittent means the path is well outside the window, not marginally inside it, so trimming fabric delay further was not going to get there. MISO is source-synchronous -- driven on the falling edge, sampled by the GC on the next rising edge -- so the whole path must fit a HALF period, 18.5 ns at 27 MHz, minus global-buffer insertion, pad, connector and the GC's setup. Driving from fabric spent 8.4-11.5 ns of that on routing alone. An SB_IO output register sits in the IO tile: clock-to-pad is the IO flop's own delay with no fabric routing. nextpnr confirms the path left the fabric entirely -- miso_io.D_OUT_0 packs at die (9,0), the pad itself, SB_IO count is unchanged at 24, and no negedge -> <async> path is reported any more. What replaces it is a setup path INTO the IOB register (10.07 ns against a half-period), which is internal and has nothing stacked on top of it. PIN_TYPE was decoded from yosys's own SB_IO model rather than from memory, because wrong bits there produce a silently dead or mistimed output: [5:4]=10 tri-state, combinational OUTPUT_ENABLE [3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output [1:0]=01 simple input (unused) NEG_TRIGGER=1 moves the IO registers to the FALLING edge. To keep simulation and silicon agreeing about MISO, the slave exposes miso_next -- the value the shift register WILL hold after the falling edge -- and spi_mode3_slave T6 proves it predicts spi_miso bit for bit. Without that the IOB register would be an untested sim/hardware divergence, which is the exact class of bug that has cost this design the most time. BEHAVIOUR CHANGE: MISO is now TRI-STATED when CS is high, held high by the pad's PULLUP, instead of actively driven high. EXI MISO is a SHARED bus -- SP1 is on channel 0 alongside memory-card slot A -- so driving it while deselected was wrong and could fight a card. --seeds 8: clk 28.5-30.7 MHz, EXI 41.6-49.7 MHz, 8/8 PASS. The EXI numbers fall (62 -> 49) because the posedge->negedge setup path into the IOB register is now charged to that domain at a half-period constraint; 41.6 MHz worst case is still 1.5x the 27 MHz target. Best seed 3 (clk 30.68, EXI 49.14), flashed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
13 KiB
Python
306 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, phase_meter=False):
|
|
self._rx_depth = rx_depth
|
|
self._tx_depth = tx_depth
|
|
self._phase_meter = phase_meter # diagnostic, see SPIMode3Slave
|
|
|
|
# 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()
|
|
self.miso_next = Signal(init=1) # for an SB_IO output register
|
|
|
|
# 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(phase_meter=self._phase_meter)
|
|
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),
|
|
self.miso_next.eq(spi.miso_next),
|
|
]
|
|
|
|
# ── 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.")
|