a3b3e052bc
Replaces 54 MHz oversampling with a slave clocked by EXI CLK itself:
exiclk posedge sample MOSI (Mode 3 samples on edge 2)
exiclk_n negedge drive MISO (must settle between the master's samples)
CS_N async reset for both
EXI CLK (pin 44) is at die (6,0) = global buffer net 6 on the 5k-sg48, so it
can drive a real clock network with no board change.
This removes the capture domain entirely. Bit-level timing no longer
crosses a clock boundary -- MISO is clock-to-Q instead of 3 ticks of a foreign
clock -- and the only crossings left are the byte FIFOs, which have a whole
byte-time (~296 ns) of slack rather than a fraction of a bit.
AsyncFIFO needs a posedge clock for its internal reset CDC, so the FIFO sides
use reset-less POSEDGE domains on the same pin; the engine pops on the posedge
and hands the byte to the negedge output stage through a register. The FIFOs
must not see CS: resetting one side of an AsyncFIFO desynchronises its gray
pointers.
Two bugs found and fixed along the way:
* one pop per byte boundary is all that is needed -- pops during the header
drain whatever the previous transaction left (replacing the CS-fall flush)
and the turnaround-byte push lands in data slot 0. No slot-threshold or
header-length logic at all.
* rx_byte/rx_valid had to become COMBINATIONAL. Registered, they need one
more clock edge, and after a transaction's final bit the clock stops and
CS rises -- so the last byte of every transaction was lost (a 4-byte write
landed as DE AD BE 00, a 34-byte TX as 15 bytes).
At the HARDWARE rate (EXI_HALF=1, 27 MHz) the device ID, NWAYS, PAR
read/write and the W5100->SPRAM RX path now all pass -- none of which has ever
worked at 27 MHz before. Testbenches now drive the SPI clock with real
delays, since there is no capture domain to count ticks against.
STILL BROKEN: the SPRAM/DMA stream is offset by one byte (its round trip
exceeds the single turnaround byte), and TX lands 15 of 34 bytes. Do not
flash this yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
12 KiB
Python
272 lines
12 KiB
Python
"""ExiCapture — fast EXI byte-capture front-end (capture domain, 54 MHz).
|
||
|
||
Wraps the SPIMode3Slave bit engine and bridges it to the slower `exi` domain
|
||
(24 MHz) 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)
|
||
|
||
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.
|
||
|
||
TX response gating
|
||
------------------
|
||
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).
|
||
"""
|
||
|
||
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(local=True, async_reset=True)
|
||
m.domains.exiclk_n = cd_n = ClockDomain(local=True, async_reset=True,
|
||
clk_edge="neg")
|
||
m.domains.exififo_w = cd_fw = ClockDomain(local=True, 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.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="exi")
|
||
tx_fifo = AsyncFIFO(width=8, depth=self._tx_depth,
|
||
w_domain="exi", 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 ──
|
||
# 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.
|
||
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="exi", init=1)
|
||
cs_n_prev = Signal(init=1)
|
||
m.d.exi += 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
|
||
|
||
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("exi").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("exi").repeat(1)
|
||
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."""
|
||
ctx.set(dut.spi_cs_n, 0)
|
||
ctx.set(dut.spi_clk, 1)
|
||
await ctx.delay(HP * (HALF))
|
||
for h in hdr:
|
||
await spi_byte(ctx, h)
|
||
for _ in range(20): # clock-idle gap
|
||
await core_drain_rx(ctx, rx_seen)
|
||
await ctx.tick("exi").repeat(1)
|
||
for r in responses:
|
||
await push_tx(ctx, r)
|
||
await ctx.delay(HP * (2))
|
||
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("exi").repeat(1)
|
||
return miso
|
||
|
||
async def testbench(ctx):
|
||
rx_seen = []
|
||
await ctx.delay(HP * (2))
|
||
|
||
# ── 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]}")
|
||
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 ─
|
||
# 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.
|
||
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)
|
||
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]}")
|
||
|
||
sim = Simulator(dut)
|
||
sim.add_clock(Period(MHz=24), domain="exi")
|
||
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.")
|