41d487e1a7
An audit for signals that are written but never read turned up three things no
longer connected to anything. All verified unreferenced across every module,
testbench and script before removal.
exi_capture cs_n_prev registered on sync, read by nothing.
w5100 udp_rx_sof driven in the UDP rx stream mux; no
consumer uses it. uart_shell needs only
data/valid/eof, because the request
frames the transfer. The MACRAW rx
interface keeps its sof -- the frame
assembler does need it.
w5100 the debug peek 4 ports, 2 FSM states (DBG_RD/DBG_RD_W)
and a branch in the IDLE arbiter's
priority chain. dbg_rd_req was driven by
nothing -- not even its own testbench --
so this was untested logic sitting in the
path that decides what the bus master
does next.
Kept deliberately, against the audit: the unused register-address constants in
both ethernet masters (_IR, _TMSR, _S0_SR, _S0_TX_FSR, _S0_TX_RD and the three
W5500 buffer ones). They cost no logic and they document the chip's register
map in one place; that map was the source of three hardware bugs, and a partial
map invites re-guessing the missing half. Same for status_panel's eth_rst_n,
which is a documented panel feature synth.py has not wired yet -- a gap, not
dead weight. And rx_frame_assembler's geom_honoured, which is one comb line
asserting a property the tests otherwise could not observe.
Also fixed the register file's opening docstring, which claimed the module did
"three jobs" and invited splitting it into three. It does not: the interrupt
level is IR & IMR, a frame arriving is a register side effect, and the read mux
feeds the TX stream combinationally in the cycle the FSM pushes it. Those are
facets of one job that share the register bank, not separable parts. _read_mux
now records why that path must stay unregistered -- one pipeline register there
is 42 ns against a 296 ns byte budget, which is the tx FIFO underrun that the
+1 descriptor hunt ended in.
Full suite passes; bba_top T9 6/6 at EXI_HALF=1, 2 and 8; W5100 elaborates with
enable_udp_test both ways. Sweep: 8/8 seeds PASS, best seed 3 at clk 29.08 MHz
(1.21x the 24 MHz target), and area down again:
ICESTORM_LC 4350 original -> 4328 after the rx_rptr cut -> 4305 now
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
14 KiB
Python
309 lines
14 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
|
|
and launches MISO one bit early
|
|
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.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_fw.clk.eq(self.spi_clk),
|
|
cd_fr.clk.eq(self.spi_clk),
|
|
]
|
|
|
|
spi = SPIMode3Slave(phase_meter=self._phase_meter)
|
|
# The device-ID fast path is ARMED PERMANENTLY. It used to be gated
|
|
# by the register file's `id_phase`, a one-shot cleared on the GC's
|
|
# first NCRA reset and crossed in here with an FFSynchronizer -- so
|
|
# after init the device stopped identifying itself and Swiss's
|
|
# EXI_GetID returned 0x00000000, i.e. "no adapter". Real hardware
|
|
# answers EXI_GetID whenever asked. `id_arm` stays a port because
|
|
# the PHASE_METER diagnostic build needs to turn the fast path off
|
|
# (it would otherwise send ID bytes instead of the constant 0x80).
|
|
m.d.comb += spi.id_arm.eq(0 if self._phase_meter else 1)
|
|
m.submodules.spi = spi
|
|
|
|
# RX bytes cross exiclk -> sync; TX bytes cross sync -> exififo_r.
|
|
# 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)
|
|
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.")
|