7ac0cbe66c
PHASE_METER on a console settled what two rounds of delay-cutting could not.
Per-speed dump, first two slots per EXI speed index, with the register file
and both byte FIFOs out of the MISO path:
0:8080 1:8080 2:8080 3:8080 4:8080 5:C040
Perfect to 16 MHz, one bit late at 27 MHz. The 8-slot dump at 27 MHz read
C0 40 40 40 C0 40 40 40 -- a UNIFORM one-bit lag (ones at bits 1, 9, 17, 25,
33, ...), with extra highs at bits 0 and 32 where libogc splits the read into
two 4-byte transfers and MISO tri-states to its pull-up in the gap. The ID
dump agreed exactly.
Decisive detail: ~9 ns had already been removed from the fabric output path
(the EXI_J4 pad merge, then the CS mux, then the entire fabric route by moving
the output into an SB_IO register) and the hardware result stayed BYTE
IDENTICAL each time. Nine nanoseconds out of an 18.5 ns budget cannot change
nothing if propagation were the whole story. What is left is the part no RTL
change can reach: input pad + GLOBAL CLOCK NETWORK INSERTION + IOB clock-to-Q
+ output pad, plus the host's own setup. There was no fabric delay left to
cut, so cutting delay was the wrong move.
So double the budget instead. MISO is now launched on the RISING edge
carrying the bit the host will sample at the NEXT rising edge, so the whole
path gets a FULL period (37 ns at 27 MHz) rather than half. This is safe in
both directions: the insertion delay that caused the problem now covers the
host's hold time on the previous bit, and a full period covers its setup.
- tx_out is clocked on the rising edge and loads at rx_bit == 7, one
bit-time earlier than a falling-edge shifter loading at rx_bit == 0.
- tx_hold and the entire exiclk_n domain are gone: the engine now has ONE
clock edge and ONE phase reference.
- synth.py's SB_IO output register moves to NEG_TRIGGER=0.
- Byte alignment is unchanged -- T2/T5 still land A5 and 3C in the same
slots. Only the sub-bit launch timing moved.
Testbench consequence, and it matters: bba_top's master sampled MISO AFTER
raising the clock. With a zero-delay simulation and a rising-edge launch that
reads the NEXT bit, so the suite would have reported everything one bit early
while hardware was correct. Both masters now sample in the CLK-LOW phase --
the value the host latches ON the rising edge -- which is also exactly what
the hardware notes say a probe must do.
T2 slot 0 now reads 0xFF (the CS-reset parking value that keeps MISO high
while deselected) instead of 0x00; it is a header byte the host ignores.
T6 re-anchored to the rising edge and still proves miso_next predicts
spi_miso bit for bit, so the SB_IO register stays equivalent to the fabric
model rather than an untested divergence.
--seeds 8: clk 28.9-32.1 MHz, EXI 62.1-65.4 MHz, 8/8 PASS. The EXI numbers
rise sharply (41.6-49.7 -> 62.1-65.4) because the setup path into the IOB
register is now posedge->posedge against a FULL period instead of
posedge->negedge against a half -- the same doubling, visible internally.
Best seed 3 (clk 32.09, EXI 62.78), flashed.
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
|
|
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)
|
|
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)
|
|
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.")
|