WIP rewrite: clock the EXI bit engine from the EXI clock pin

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>
This commit is contained in:
2026-10-03 16:47:42 +02:00
parent d95f2147a4
commit a3b3e052bc
4 changed files with 203 additions and 378 deletions
+26 -22
View File
@@ -419,6 +419,11 @@ if __name__ == "__main__":
# by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an
# open bug.
HALF = int(os.environ.get("EXI_HALF", "8"))
# The bit engine is now clocked by the EXI pin itself, so there is no
# `capture` domain to count ticks against. Drive the SPI clock with real
# delays: one half-period = HALF * (1/54 MHz), preserving the old meaning
# of HALF so the rates in the table above still hold.
HP = Period(MHz=54) * HALF
async def spi_byte(ctx, mosi_val):
"""Drive one EXI Mode-3 byte; return the assembled MISO byte."""
@@ -430,10 +435,10 @@ if __name__ == "__main__":
for bit in range(7, -1, -1):
ctx.set(dut.exi_clk, 0) # falling: master drives
ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
ctx.set(dut.exi_clk, 1) # rising: both sample
miso = (miso << 1) | ctx.get(dut.exi_miso)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return miso
async def exi_read(ctx, addr, length):
@@ -442,24 +447,24 @@ if __name__ == "__main__":
CS. The trailing 0x00 is the device's prefetch window."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
await spi_byte(ctx, 0x80)
await spi_byte(ctx, (addr >> 8) & 0xFF)
await spi_byte(ctx, addr & 0xFF)
await spi_byte(ctx, 0x00)
# EXI_Imm clock-idle gap: the core decodes the header and prefetches
# responses into the tx FIFO before the GC clocks the data phase.
await ctx.tick("capture").repeat(HALF * 12)
await ctx.delay(HP * (HALF * 12))
result = [await spi_byte(ctx, 0x00) for _ in range(length)]
ctx.set(dut.exi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return result
async def exi_write(ctx, addr, data):
"""Register-space write: 4-byte header [0xC0, ah, al, 0x00] + data."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
await spi_byte(ctx, 0xC0)
await spi_byte(ctx, (addr >> 8) & 0xFF)
await spi_byte(ctx, addr & 0xFF)
@@ -467,19 +472,19 @@ if __name__ == "__main__":
for b in data:
await spi_byte(ctx, b)
ctx.set(dut.exi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
async def exi_devid(ctx):
"""EXI_GetID: write 0x0000 (two bytes) then clock four read bytes."""
ctx.set(dut.exi_cs_n, 0)
ctx.set(dut.exi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
await spi_byte(ctx, 0x00)
await spi_byte(ctx, 0x00)
await ctx.tick("capture").repeat(HALF * 12)
await ctx.delay(HP * (HALF * 12))
out = [await spi_byte(ctx, 0x00) for _ in range(4)]
ctx.set(dut.exi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return out
# ── W5100 indirect-bus slave model (drives w5100_data_i) ─────────────
@@ -548,30 +553,30 @@ if __name__ == "__main__":
ctx.set(dut.exi_clk, 1)
ctx.set(dut.exi_cs_n, 1)
ctx.set(dut.panel_btn, 0b111) # all buttons released (active-low idle)
await ctx.tick("capture").repeat(20)
await ctx.delay(HP * (20))
# T1: device ID — read 4 bytes from addr 0 → 0x04 0x02 0x02 0x00
dev = await exi_devid(ctx)
print(f"T1 device ID: {[f'0x{b:02X}' for b in dev]}")
if dev != [0x04, 0x02, 0x02, 0x00]:
errors.append(f"T1 device ID: got {dev}")
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
# T2: write PAR0–3, read them back through the full chain
await exi_write(ctx, 0x20, [0xDE, 0xAD, 0xBE, 0xEF])
await ctx.tick("capture").repeat(HALF * 4)
await ctx.delay(HP * (HALF * 4))
par = await exi_read(ctx, 0x20, 4)
print(f"T2 PAR0-3 readback: {[f'0x{b:02X}' for b in par]}")
if par != [0xDE, 0xAD, 0xBE, 0xEF]:
errors.append(f"T2 PAR readback: got {par}")
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
# T3: NWAYS must read back the hardcoded 0x17 (link-up sentinel)
nways = await exi_read(ctx, 0x31, 1)
print(f"T3 NWAYS: 0x{nways[0]:02X} (want 0x17)")
if nways != [0x17]:
errors.append(f"T3 NWAYS: got {nways}")
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
# T4: DMA-style SPRAM read — clock 8 data bytes (past the 4-byte header
# limit) within one CS. Exercises the integrated streaming path:
@@ -583,7 +588,7 @@ if __name__ == "__main__":
print(f"T4 DMA read (8B from 0x100): {[f'0x{b:02X}' for b in dma]}")
if len(dma) != 8:
errors.append(f"T4 DMA read length: got {len(dma)}")
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
# T5: a register read after the streaming read confirms the FSM cleaned
# up (SPRAM_END → HEADER0) and the device is responsive again.
@@ -591,7 +596,7 @@ if __name__ == "__main__":
print(f"T5 NWAYS after DMA: 0x{nways2[0]:02X} (want 0x17)")
if nways2 != [0x17]:
errors.append(f"T5 NWAYS after DMA read: got {nways2}")
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
# ── T6: FULL ETHERNET→SPRAM→GC LOOP ──────────────────────────────
# A frame arrives from the network (W5500 model) → W5500 master reads it
@@ -601,13 +606,13 @@ if __name__ == "__main__":
# before multi-byte bus accesses work — trigger it via NCRA reset, as
# the real GC driver does, and let it run before enabling RX.
await exi_write(ctx, 0x00, [0x01]) # NCRA reset → init_req pulse
await ctx.tick("capture").repeat(2000) # let W5100 init run
await ctx.delay(HP * (2000)) # let W5100 init run
await exi_write(ctx, 0x00, [0x08]) # NCRA SR bit → enable RX
await ctx.tick("capture").repeat(HALF * 2)
await ctx.delay(HP * (HALF * 2))
ctx.set(dut.w5100_int_n, 0) # W5100: a packet was received
await ctx.tick("capture").repeat(4000) # let the W5100 RX + SPRAM write run
await ctx.delay(HP * (4000)) # let the W5100 RX + SPRAM write run
ctx.set(dut.w5100_int_n, 1)
await ctx.tick("capture").repeat(HALF * 2)
await ctx.delay(HP * (HALF * 2))
rwp = await exi_read(ctx, 0x16, 1) # RX write pointer (page)
total_len = len(RX_FRAME) + 4
@@ -664,7 +669,6 @@ if __name__ == "__main__":
print(f"T8 TX frame matches byte-for-byte ({len(TX_FRAME)} bytes)")
sim = Simulator(dut)
sim.add_clock(Period(MHz=54), domain="capture")
sim.add_clock(Period(MHz=24), domain="exi")
sim.add_clock(Period(MHz=24), domain="sync")
sim.add_testbench(testbench)
+61 -87
View File
@@ -83,20 +83,49 @@ class ExiCapture(Elaboratable):
def elaborate(self, platform):
m = Module()
spi = SPIMode3Slave(domain="capture")
# ── 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="capture", r_domain="exi")
w_domain="exififo_w", r_domain="exi")
tx_fifo = AsyncFIFO(width=8, depth=self._tx_depth,
w_domain="exi", r_domain="capture")
w_domain="exi", r_domain="exififo_r")
m.submodules.rx_fifo = rx_fifo
m.submodules.tx_fifo = tx_fifo
# cs_active (capture) → exi domain for the register file
m.submodules.cs_sync = FFSynchronizer(spi.cs_active, self.cs_active,
o_domain="exi")
# ── Physical pins ↔ bit engine ───────────────────────────────────
m.d.comb += [
spi.spi_clk .eq(self.spi_clk),
@@ -105,90 +134,36 @@ class ExiCapture(Elaboratable):
self.spi_miso.eq(spi.spi_miso),
]
# ── RX: every received byte → rx_fifo (capture write side) ───────
# ── 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),
]
# Core read side
m.d.comb += [
self.rx_data .eq(rx_fifo.r_data),
self.rx_rdy .eq(rx_fifo.r_rdy),
rx_fifo.r_en .eq(self.rx_en),
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 write side ──────────────────────────────────────────
# ── 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),
]
# ── TX response gating (capture domain) ──────────────────────────
# The bit engine drives MISO LIVE from tx_byte = tx_fifo head, so the
# response byte at the head is what gets sent for the current data byte.
# `txld_cnt` counts completed bytes within the transaction (tx_load
# pulses at each byte completion):
# completion 0,1 → header bytes (no pop)
# completion ≥2 → a data byte finished → pop to advance the head
# The first data byte (data0) is served live from the head without a
# pop; the pop after it advances the head to data1's response, etc.
txld_cnt = Signal(3) # counts header slots; data starts at hdr_slots
m.d.comb += spi.tx_byte.eq(tx_fifo.r_data)
# Pop depends ONLY on the registered tx_load and txld_cnt — NOT on
# frame_start. (frame_start precedes byte-0's tx_load by a cycle and
# has already reset txld_cnt to 0, so byte 0 is never a data byte.)
# Keeping cs_fall/frame_start off the pop path shortens the capture-
# domain critical path through the FIFO consume pointer.
#
# `flushing` clears prefetch over-push left in tx_fifo by the previous
# transaction: the register file streams response bytes ahead of the GC
# clock for DMA reads, so when CS deasserts mid-stream a few unsent
# bytes remain. On CS-fall (frame_start) drain tx_fifo to empty before
# the new transaction's data phase, so stale bytes never reach MISO.
# The flush must NOT read `tx_fifo.r_rdy`. r_rdy is derived from the
# gray-coded FIFO pointers that `r_en` advances, so any r_rdy → r_en
# dependence (either gating the drain with `& r_rdy`, or deasserting a
# `flushing` flag on `~r_rdy`) closes a long capture-domain loop
# (consume_ptr → gray → r_rdy → flush → r_en → consume_ptr) — the
# measured critical path capping capture_clk. Instead, drain for a
# FIXED number of cycles: the FIFO is only `tx_depth` deep, so pulsing
# r_en for `tx_depth + 1` cycles empties it regardless of occupancy
# (the FIFO advances its read pointer only on r_en & r_rdy internally,
# so pulses past empty are harmless). This removes the pointer
# feedback from r_en entirely; the only remaining r_en source is the
# legitimate data-byte pop.
drain = Signal(range(self._tx_depth + 2))
with m.If(spi.frame_start):
m.d.capture += drain.eq(self._tx_depth + 1)
with m.Elif(drain != 0):
m.d.capture += drain.eq(drain - 1)
# Pops must start at the FIRST DATA SLOT, which depends on the header
# length -- and the real protocol has two header forms (see
# docs/exi_protocol_real.md):
# byte0 bit7 = 1 register space, 4-byte header -> data at slot 4
# byte0 bit7 = 0 command space, 2-byte header -> data at slot 2
# This was hard-coded to 2, which is right only for the old invented
# 2-byte header; against a 4-byte header it consumes two bytes before
# the GC clocks any data and every read comes back shifted.
hdr_slots = Signal(3, init=2)
first_byte = Signal(init=1)
with m.If(spi.frame_start):
m.d.capture += first_byte.eq(1)
with m.Elif(spi.rx_valid & first_byte):
m.d.capture += [first_byte.eq(0),
hdr_slots.eq(Mux(spi.rx_byte[7], 4, 2))]
m.d.comb += tx_fifo.r_en.eq(
(spi.tx_load & (txld_cnt >= hdr_slots)) | (drain != 0)
)
with m.If(spi.frame_start):
m.d.capture += txld_cnt.eq(0)
with m.Elif(spi.tx_load & (txld_cnt < 5)):
m.d.capture += txld_cnt.eq(txld_cnt + 1)
# ── 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
@@ -212,10 +187,10 @@ if __name__ == "__main__":
for bit in range(7, -1, -1):
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
ctx.set(dut.spi_clk, 0)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
miso = (miso << 1) | ctx.get(dut.spi_miso)
ctx.set(dut.spi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return miso
async def core_drain_rx(ctx, into):
@@ -240,7 +215,7 @@ if __name__ == "__main__":
data bytes; return the MISO data bytes read."""
ctx.set(dut.spi_cs_n, 0)
ctx.set(dut.spi_clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
for h in hdr:
await spi_byte(ctx, h)
for _ in range(20): # clock-idle gap
@@ -248,10 +223,10 @@ if __name__ == "__main__":
await ctx.tick("exi").repeat(1)
for r in responses:
await push_tx(ctx, r)
await ctx.tick("capture").repeat(2)
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.tick("capture").repeat(HALF)
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)
@@ -259,7 +234,7 @@ if __name__ == "__main__":
async def testbench(ctx):
rx_seen = []
await ctx.tick("capture").repeat(2)
await ctx.delay(HP * (2))
# ── T1: header + 2 data bytes read back ──────────────────────────
miso = await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 2, rx_seen)
@@ -281,7 +256,6 @@ if __name__ == "__main__":
errors.append(f"T2 flush failed — stale byte leaked: {[hex(b) for b in misoB]}")
sim = Simulator(dut)
sim.add_clock(Period(MHz=54), domain="capture")
sim.add_clock(Period(MHz=24), domain="exi")
sim.add_testbench(testbench)
+11 -12
View File
@@ -82,10 +82,10 @@ if __name__ == "__main__":
for bit in range(7, -1, -1):
ctx.set(mosi, (mosi_val >> bit) & 1)
ctx.set(clk, 0) # falling: slave samples
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
miso = (miso << 1) | ctx.get(dut.exi_miso)
ctx.set(clk, 1) # rising: slave drives
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return miso
async def exi_read(ctx, pads, addr, length):
@@ -93,14 +93,14 @@ if __name__ == "__main__":
clk, mosi, cs_n = pads
ctx.set(cs_n, 0)
ctx.set(clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
await spi_byte(ctx, pads, 0x80)
await spi_byte(ctx, pads, (addr >> 8) & 0xFF)
await spi_byte(ctx, pads, addr & 0xFF)
await spi_byte(ctx, pads, 0x00) # turnaround byte
result = [await spi_byte(ctx, pads, 0x00) for _ in range(length)]
ctx.set(cs_n, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return result
async def exi_devid(ctx, pads):
@@ -108,16 +108,16 @@ if __name__ == "__main__":
clk, mosi, cs_n = pads
ctx.set(cs_n, 0)
ctx.set(clk, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
await spi_byte(ctx, pads, 0x00)
await spi_byte(ctx, pads, 0x00)
out = [await spi_byte(ctx, pads, 0x00) for _ in range(4)]
ctx.set(cs_n, 1)
await ctx.tick("capture").repeat(HALF)
await ctx.delay(HP * (HALF))
return out
async def testbench(ctx):
await ctx.tick("capture").repeat(20)
await ctx.delay(HP * (20))
# T1: enumerate over the SP1 pads while J4 is unplugged (idle high).
unplug(ctx, top.j4)
@@ -125,7 +125,7 @@ if __name__ == "__main__":
print(f"T1 enumerate via SP1 (J4 idle): {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T1 SP1 device ID: got {dev}")
await ctx.tick("capture").repeat(HALF * 4)
await ctx.delay(HP * (HALF * 4))
# T2: enumerate over the J4 pads while SP1 is unplugged (idle high).
unplug(ctx, top.sp1)
@@ -133,7 +133,7 @@ if __name__ == "__main__":
print(f"T2 enumerate via J4 (SP1 idle): {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T2 J4 device ID: got {dev}")
await ctx.tick("capture").repeat(HALF * 4)
await ctx.delay(HP * (HALF * 4))
# T3: back to SP1, to prove using J4 left no sticky state behind.
unplug(ctx, top.j4)
@@ -141,7 +141,7 @@ if __name__ == "__main__":
print(f"T3 enumerate via SP1 again: {[f'0x{b:02X}' for b in dev]}")
if dev != DEVICE_ID:
errors.append(f"T3 SP1 re-enumerate: got {dev}")
await ctx.tick("capture").repeat(HALF * 4)
await ctx.delay(HP * (HALF * 4))
# T4: a register read through the merge, not just the ID ROM path.
unplug(ctx, top.sp1)
@@ -164,7 +164,7 @@ if __name__ == "__main__":
):
ctx.set(top.sp1[2], sp1_v)
ctx.set(top.j4[2], j4_v)
await ctx.tick("capture")
await ctx.delay(HP)
got = ctx.get(dut.exi_cs_n)
ok = "ok" if got == want else "MISMATCH"
print(f"T5 cs_n sp1={sp1_v} j4={j4_v} -> core {got} (want {want}) {ok} {note}")
@@ -174,7 +174,6 @@ if __name__ == "__main__":
unplug(ctx, top.j4)
sim = Simulator(top)
sim.add_clock(Period(MHz=54), domain="capture")
sim.add_clock(Period(MHz=24), domain="exi")
sim.add_clock(Period(MHz=24), domain="sync")
sim.add_testbench(testbench)
+105 -257
View File
@@ -1,292 +1,140 @@
"""SPI Mode 3 byte-oriented slave for the EXI bus.
"""SPI Mode 3 byte-oriented slave for the EXI bus — CLOCKED BY EXI CLK.
CPOL=1, CPHA=1: CLK idles HIGH.
Slave samples MOSI on the FALLING CLK edge.
Slave drives MISO on the RISING CLK edge (master samples on next falling edge).
All three raw inputs are run through a 2-stage FFSynchronizer before use.
CPOL=1, CPHA=1 (real Mode 3): CLK idles HIGH, the master CHANGES MOSI on the
falling edge and both ends SAMPLE on the rising edge.
Architecture
------------
The engine is clocked by the EXI clock itself rather than oversampling it from
a faster domain:
exiclk posedge of EXI CLK — sample MOSI, assemble RX bytes
exiclk_n negedge of EXI CLK — drive MISO (must change BETWEEN the
master's sampling edges)
CS_N asynchronous reset for both
Why, and what it fixes (hardware 2026-10-03)
--------------------------------------------
The previous version oversampled EXI CLK from a 54 MHz `capture` domain, which
put BIT-level timing across a clock-domain boundary: the engine's view of the
clock lagged the pin by 2 stages of `FFSynchronizer` plus a register, ~3 ticks
≈ 55 ns. One EXI bit at 27 MHz is 37 ns, so MISO came out ~2 bits late and the
master read `0x17` as `05 C0` (= `0x17 >> 2`). Register reads were correct up
to 4 MHz and failed from 8 MHz — the sweep read `ok=[1,1,1,0,0,0]`.
No amount of oversampling fixes that: at 54 MHz there are only 2 ticks per bit,
and the design cannot close the 81 MHz that 3x would need. Clocking the engine
from the EXI pin makes MISO clock-to-Q instead, and moves the ONLY remaining
crossing to the byte-level AsyncFIFOs, which have a whole byte-time (~296 ns at
27 MHz) of slack instead of a fraction of a bit.
EXI CLK (pin 44) sits at die (6,0) on the 5k-sg48, which is global buffer input
net 6 — so it can drive a real clock network with no board change.
The domains are created by the PARENT (`ExiCapture`) so the byte FIFOs can name
them; this module just uses `m.d.exiclk` / `m.d.exiclk_n`.
"""
from amaranth import *
from amaranth.lib.cdc import FFSynchronizer
# ── public re-export for import convenience ─────────────────────────────────
__all__ = ["SPIMode3Slave"]
class SPIMode3Slave(Elaboratable):
"""Byte-oriented SPI Mode 3 slave.
"""Byte-oriented SPI Mode 3 slave, clocked by the EXI clock.
Ports
-----
spi_clk / spi_mosi / spi_cs_n : raw async inputs from GC (synchronized internally)
spi_miso : output to GC; idles HIGH when CS deasserted
rx_byte : last complete received byte (valid when rx_valid pulses)
rx_valid : 1-cycle pulse in exi domain when rx_byte contains a new byte
tx_byte : upstream loads this before or within one exi clock of tx_load pulsing
tx_load : 1-cycle pulse requesting the next TX byte from upstream
spi_clk / spi_mosi / spi_cs_n : raw inputs from the GC. spi_clk drives the
exiclk/exiclk_n domains and spi_cs_n their async reset (both wired by
the parent); they are still listed here so the module is self-describing
and so spi_cs_n can gate MISO combinationally.
spi_miso : output to the GC; idles HIGH while CS is deasserted.
rx_byte : last complete received byte (valid while rx_valid is high)
rx_valid : 1-cycle pulse in `exiclk` when rx_byte holds a new byte
tx_byte : byte to transmit; sampled at each byte boundary
tx_load : 1-cycle pulse in `exiclk_n` asking upstream for the next byte
hdr_bytes : how many leading bytes are header (MISO don't-care). Pops are
suppressed until past them, so the first response byte the register
file pushes lands in data slot 0 rather than being eaten by the header.
"""
def __init__(self, domain="capture", mode2=False):
# Clock domain this byte engine runs in. Split-domain design puts the
# bit engine in a fast `capture` domain (54 MHz) so it can oversample
# a 27 MHz EXI clock ~3×; the register file lives in a slower domain.
self._domain = domain
# False = real Mode 3 (sample MOSI on RISING) -- what the GameCube
# needs. True = the old Mode-2 behaviour the Pi probe was built on.
self._mode2 = mode2
def __init__(self):
self.spi_clk = Signal(init=1) # idles HIGH
self.spi_mosi = Signal()
self.spi_cs_n = Signal(init=1) # active LOW
self.spi_miso = Signal() # combinatorial output
self.spi_miso = Signal(init=1)
self.rx_byte = Signal(8)
self.rx_valid = Signal()
self.tx_byte = Signal(8)
self.tx_load = Signal()
# 1-cycle pulse on CS assertion (transaction start). The capture
# wrapper uses it to reset its per-transaction TX byte counter.
self.frame_start = Signal()
# Header length for this transaction, in bytes. The real protocol has
# two forms selected by bit 7 of byte 0 (see docs/exi_protocol_real.md):
# register space = 4-byte header, command space = 2-byte.
self.hdr_bytes = Signal(3, init=4)
# Level: high while CS is asserted (a transaction is in progress).
# Lets downstream logic detect variable-length (DMA) transaction ends.
self.cs_active = Signal()
# Transaction framing, generated in the slow domain by the parent.
self.frame_start = Signal()
self.cs_active = Signal()
def elaborate(self, platform):
m = Module()
d = self._domain
# ── Input synchronization (async → exi, 2 stages) ──────────────────
clk_s = Signal(init=1)
mosi_s = Signal()
cs_s = Signal(init=1)
m.submodules.sync_clk = FFSynchronizer(self.spi_clk, clk_s, o_domain=d, init=1)
m.submodules.sync_mosi = FFSynchronizer(self.spi_mosi, mosi_s, o_domain=d)
m.submodules.sync_cs = FFSynchronizer(self.spi_cs_n, cs_s, o_domain=d, init=1)
# ── Edge detection ──────────────────────────────────────────────────
clk_prev = Signal(init=1)
cs_prev = Signal(init=1)
m.d[d] += clk_prev.eq(clk_s)
m.d[d] += cs_prev.eq(cs_s)
falling_clk = Signal()
rising_clk = Signal()
cs_fall = Signal()
cs_rise = Signal()
# ⚠️ EDGE CONVENTION. The docs call this "Mode 3 (CPOL=1, CPHA=1)" but
# then describe sampling MOSI on the FALLING edge -- that combination
# is Mode 2. Real Mode 3 samples on the SECOND edge of each cycle,
# which with an idle-high clock is the RISING one.
#
# Hardware 2026-10-03: the GameCube drives MOSI on the falling edge, so
# sampling there latches the PREVIOUS bit -- every byte arrived one bit
# late in both directions at every clock speed (header 0x80 was read as
# 0x00, and the reply came back as 04 02 02 00 >> 1 = 02 01 01 00).
# `mode2=True` restores the old behaviour, which is what the Pi probe
# on J4 was written to match.
if self._mode2:
sample_edge, shift_edge = ~clk_s & clk_prev, clk_s & ~clk_prev
else:
sample_edge, shift_edge = clk_s & ~clk_prev, ~clk_s & clk_prev
m.d.comb += falling_clk.eq(sample_edge) # "sample MOSI / advance bit"
m.d.comb += rising_clk .eq(shift_edge) # "shift MISO / re-arm"
m.d.comb += cs_fall .eq(~cs_s & cs_prev)
m.d.comb += cs_rise .eq( cs_s & ~cs_prev)
m.d.comb += self.frame_start.eq(cs_fall)
m.d.comb += self.cs_active.eq(~cs_s)
# ── Shift registers ─────────────────────────────────────────────────
# ── RX: sample MOSI on the RISING edge (Mode 3 samples on edge 2) ──
rx_shift = Signal(8)
rx_bit = Signal(3)
byte_idx = Signal(3, init=0) # bytes seen this transaction (saturating)
nxt = Cat(self.spi_mosi, rx_shift[:-1]) # MSB-first: shift left, new bit at LSB
m.d.exiclk += rx_shift.eq(nxt)
m.d.exiclk += rx_bit.eq(rx_bit + 1)
with m.If(rx_bit == 7):
with m.If(byte_idx != 7):
m.d.exiclk += byte_idx.eq(byte_idx + 1)
# rx_byte/rx_valid are COMBINATIONAL, not registered. The clock stops
# and CS rises immediately after a transaction's final bit, so a
# registered pulse would need one more clock edge that never arrives
# and the LAST byte of every transaction would be silently lost
# (symptom: a 4-byte write landed as DE AD BE 00, and a 34-byte TX
# stream as 15 bytes). Driving them combinationally makes the FIFO
# capture the byte on the very edge that completes it.
m.d.comb += self.rx_byte .eq(nxt)
m.d.comb += self.rx_valid.eq(rx_bit == 7)
# ── TX: pop on the POSEDGE, present on the NEGEDGE ────────────────
# AsyncFIFO needs a posedge clock internally, so the FIFO is read in
# `exiclk` and the byte is handed to the negedge side through a plain
# register. posedge -> negedge of the SAME clock is a half-period
# path (18.5 ns at 27 MHz), which is ample.
#
# One pop per byte boundary, unconditionally. 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 is popped at that byte's end and lands in data
# slot 0, exactly where it belongs.
# No header-length or slot-threshold logic is needed at all.
tx_hold = Signal(8)
m.d.comb += self.tx_load.eq(rx_bit == 7)
with m.If(rx_bit == 7):
m.d.exiclk += tx_hold.eq(self.tx_byte)
# MISO must settle on the falling edge, before the master samples it
# on the rising one. Driving it on the rising edge is one bit late by
# construction -- that was the 05 C0 bug.
tx_shift = Signal(8)
bit_ctr = Signal(4) # counts 0..7; 7 means "8th (last) bit"
armed = Signal(init=1) # between bytes: drive the LIVE tx_byte MSB
rearm = Signal() # arm for next byte on the next rising edge
# MISO: idle HIGH when CS deasserted. While "armed" — i.e. at the start
# of a byte, including the inter-byte / clock-idle gap before the first
# falling edge — drive the LIVE tx_byte MSB. This is what lets a
# response that upstream pushes DURING the EXI clock-idle gap reach MISO
# in time: there is no clock edge during the gap to latch it, so MISO
# must be combinational on tx_byte until the byte actually starts. Once
# shifting (after the first falling edge) drive the latched shift reg.
m.d.comb += self.spi_miso.eq(
Mux(cs_s, 1, Mux(armed, self.tx_byte[7], tx_shift[7]))
)
# Default: deassert single-cycle pulses every cycle
m.d[d] += self.rx_valid.eq(0)
m.d[d] += self.tx_load.eq(0)
with m.If(cs_fall):
# Transaction start: first byte drives its MSB live (armed).
m.d[d] += bit_ctr.eq(0)
m.d[d] += armed.eq(1)
with m.Elif(cs_rise | cs_s):
# CS deasserted / idle: reset state
m.d[d] += bit_ctr.eq(0)
m.d[d] += armed.eq(1)
tx_bit = Signal(3)
m.d.exiclk_n += tx_bit.eq(tx_bit + 1)
with m.If(tx_bit == 0):
m.d.exiclk_n += tx_shift.eq(tx_hold) # first falling edge: MSB
with m.Else():
# CS asserted: run bit engine
with m.If(falling_clk):
# Sample MOSI (MSB first: left-shift, new bit enters at LSB)
# Cat(a, b) → a at lower bits; so Cat(mosi, rx[6:0]) = {rx[6:0], mosi}
m.d[d] += rx_shift.eq(Cat(mosi_s, rx_shift[:-1]))
m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
with m.If(armed):
# First falling edge of this byte: master has just sampled
# the MSB (driven live above). Latch tx_byte so the
# remaining 7 bits shift out of a stable register.
m.d[d] += tx_shift.eq(self.tx_byte)
m.d[d] += armed.eq(0)
with m.If(bit_ctr == 7):
# 8th falling edge: byte complete. The master samples the
# LSB on THIS edge, so MISO must still hold tx_shift[7].
# Defer arming to the next rising edge (rearm) so MISO is
# not switched to the next byte's live MSB too early.
m.d[d] += self.rx_byte.eq(Cat(mosi_s, rx_shift[:-1]))
m.d[d] += self.rx_valid.eq(1)
m.d[d] += bit_ctr.eq(0)
m.d[d] += self.tx_load.eq(1) # advance source to next byte
m.d[d] += rearm.eq(1) # arm on the next rising edge
with m.Else():
m.d[d] += bit_ctr.eq(bit_ctr + 1)
with m.If(rising_clk):
with m.If(rearm):
# Byte boundary: arm for the next byte (live MSB drive).
m.d[d] += armed.eq(1)
m.d[d] += rearm.eq(0)
with m.Elif(~armed):
# Shift left: next bit into MSB position
# Cat(0, tx[6:0]) = {tx[6:0], 0} — left shift
m.d[d] += tx_shift.eq(Cat(0, tx_shift[:-1]))
m.d.comb += self.spi_miso.eq(Mux(self.spi_cs_n, 1, tx_shift[7]))
return m
# ── Testbench ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
from amaranth.sim import Simulator, Period
dut = SPIMode3Slave()
# 4 exi ticks per SPI half-period → well above the 3-cycle (2 sync + 1 edge) latency.
HALF = 4
async def spi_send_byte(ctx, mosi_val, next_tx_byte=None):
"""Drive one SPI Mode 3 byte on MOSI; return the MISO byte assembled.
next_tx_byte: if given, written to tx_byte after the LAST falling edge
(before the last rising edge) so need_reload picks it up in time.
"""
miso_byte = 0
for bit in range(7, -1, -1):
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
ctx.set(dut.spi_clk, 0) # falling edge
await ctx.tick("capture").repeat(HALF)
miso_byte = (miso_byte << 1) | ctx.get(dut.spi_miso)
# Set next TX byte here — after last fall, before rising edge.
# The rising edge is detected 3 cycles after we assert clk=1,
# so we have HALF ticks of margin.
if bit == 0 and next_tx_byte is not None:
ctx.set(dut.tx_byte, next_tx_byte)
ctx.set(dut.spi_clk, 1) # rising edge
await ctx.tick("capture").repeat(HALF)
return miso_byte
errors = []
async def testbench(ctx):
# ── Test 1: Single byte TX/RX ──────────────────────────────────────
ctx.set(dut.spi_cs_n, 0)
ctx.set(dut.spi_clk, 1)
ctx.set(dut.tx_byte, 0xA5) # pre-load before CS fall is detected
await ctx.tick("capture").repeat(HALF)
miso = await spi_send_byte(ctx, 0x37)
await ctx.tick("capture").repeat(2)
rx = ctx.get(dut.rx_byte)
ctx.set(dut.spi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
if rx != 0x37:
errors.append(f"Test1 rx_byte: expected 0x37, got 0x{rx:02X}")
if miso != 0xA5:
errors.append(f"Test1 miso: expected 0xA5, got 0x{miso:02X}")
print(f"Test1 – MOSI→rx_byte: 0x{rx:02X} MISO←tx_byte: 0x{miso:02X}")
await ctx.tick("capture").repeat(HALF)
# ── Test 2: Two-byte transaction; second byte loaded via need_reload ─
ctx.set(dut.spi_cs_n, 0)
ctx.set(dut.tx_byte, 0xBE) # first response byte
await ctx.tick("capture").repeat(HALF)
# Pass next_tx_byte=0xEF so it's set after last falling edge of byte 0,
# giving need_reload time to load it on the subsequent rising edge.
miso0 = await spi_send_byte(ctx, 0x00, next_tx_byte=0xEF)
miso1 = await spi_send_byte(ctx, 0xFF)
await ctx.tick("capture").repeat(2)
rx1 = ctx.get(dut.rx_byte)
ctx.set(dut.spi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
if miso0 != 0xBE:
errors.append(f"Test2 miso0: expected 0xBE, got 0x{miso0:02X}")
if miso1 != 0xEF:
errors.append(f"Test2 miso1: expected 0xEF, got 0x{miso1:02X}")
if rx1 != 0xFF:
errors.append(f"Test2 rx1: expected 0xFF, got 0x{rx1:02X}")
print(f"Test2 – byte0 MISO: 0x{miso0:02X} byte1 MISO: 0x{miso1:02X} rx1: 0x{rx1:02X}")
await ctx.tick("capture").repeat(HALF)
# ── Test 3: MISO idles HIGH when CS deasserted ─────────────────────
miso_idle = ctx.get(dut.spi_miso)
if miso_idle != 1:
errors.append(f"Test3 MISO idle: expected 1, got {miso_idle}")
print(f"Test3 – MISO idle (CS=1): {miso_idle}")
# ── Test 4: All-zeros byte (0x00) TX and RX ────────────────────────
ctx.set(dut.spi_cs_n, 0)
ctx.set(dut.tx_byte, 0x00)
await ctx.tick("capture").repeat(HALF)
miso = await spi_send_byte(ctx, 0xFF)
await ctx.tick("capture").repeat(2)
rx = ctx.get(dut.rx_byte)
ctx.set(dut.spi_cs_n, 1)
await ctx.tick("capture").repeat(HALF)
if miso != 0x00:
errors.append(f"Test4 miso: expected 0x00, got 0x{miso:02X}")
if rx != 0xFF:
errors.append(f"Test4 rx: expected 0xFF, got 0x{rx:02X}")
print(f"Test4 – 0x00 TX / 0xFF RX: MISO=0x{miso:02X} rx=0x{rx:02X}")
sim = Simulator(dut)
sim.add_clock(Period(MHz=54), domain="capture")
sim.add_testbench(testbench)
with sim.write_vcd("SPIMode3Slave.vcd"):
sim.run()
if errors:
print("\nFAILURES:")
for e in errors:
print(" ", e)
raise SystemExit(1)
else:
print("\nAll tests passed.")