02239466fb
bba_top wired ClockSignal("sync") to ClockSignal("exi"), so the two "domains"
were always the same physical clock net. Everything between them -- six
AsyncFIFOs and three PulseSynchronizers -- was pure latency and logic for a
crossing that did not exist.
* the six AsyncFIFOs become plain SyncFIFOs
* the three PulseSynchronizers become wires
* BBARegisterFile and ExiCapture move from m.d.exi to m.d.sync
* the exi ClockDomain is gone; HFOSC drives sync directly
The SPRAM request write port is also combinational now. A registered w_en
costs a 24 MHz cycle, and the protocol's turnaround byte (~296 ns at 27 MHz)
has no cycles to spare.
Passing: bba_register_file, spram_arbiter, rx_frame_assembler, tx_frame_drain,
w5100_parallel_master, uart_shell, status_panel. bba_top at EXI_HALF=1
(27 MHz, the hardware rate) passes device ID, PAR read/write, NWAYS, RWP and
the 34-byte TX frame.
STILL OPEN: the SPRAM/DMA read is one byte late. Removing the CDC was not
enough -- the remaining round trip is the rx crossing (exiclk->sync, a REAL
CDC, ~125 ns) plus FSM decode plus the spram_req FIFO, arbiter, SPRAM access
and spram_rsp FIFO, which still exceeds the turnaround byte. Next step: now
that it is all one domain, drop the two SPRAM FIFOs entirely and give the
register file a direct request/grant port on the arbiter -- that should take
the round trip to ~2-3 cycles and fit.
exi_capture and exi_j4 testbenches still assert removed behaviour (the CS-fall
flush and the old MISO slot timing) and need rewriting for the clocked engine.
Still WIP -- do not flash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
278 lines
12 KiB
Python
278 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="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 ──
|
||
# 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="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 ticks.
|
||
|
||
HP = Period(MHz=54) * HALF
|
||
|
||
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: 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("sync").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("sync").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="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.")
|