2f081a3e69
Removing ~2 ns from the fabric MISO path (EXI_J4 merge + the CS mux) did NOT move the hardware result: the GC still returned a byte-identical 0B 80 at 27 MHz while reading correctly at 16 MHz and below. Identical rather than intermittent means the path is well outside the window, not marginally inside it, so trimming fabric delay further was not going to get there. MISO is source-synchronous -- driven on the falling edge, sampled by the GC on the next rising edge -- so the whole path must fit a HALF period, 18.5 ns at 27 MHz, minus global-buffer insertion, pad, connector and the GC's setup. Driving from fabric spent 8.4-11.5 ns of that on routing alone. An SB_IO output register sits in the IO tile: clock-to-pad is the IO flop's own delay with no fabric routing. nextpnr confirms the path left the fabric entirely -- miso_io.D_OUT_0 packs at die (9,0), the pad itself, SB_IO count is unchanged at 24, and no negedge -> <async> path is reported any more. What replaces it is a setup path INTO the IOB register (10.07 ns against a half-period), which is internal and has nothing stacked on top of it. PIN_TYPE was decoded from yosys's own SB_IO model rather than from memory, because wrong bits there produce a silently dead or mistimed output: [5:4]=10 tri-state, combinational OUTPUT_ENABLE [3]=0,[2]=1 dout = dout_q_0, single-rate REGISTERED output [1:0]=01 simple input (unused) NEG_TRIGGER=1 moves the IO registers to the FALLING edge. To keep simulation and silicon agreeing about MISO, the slave exposes miso_next -- the value the shift register WILL hold after the falling edge -- and spi_mode3_slave T6 proves it predicts spi_miso bit for bit. Without that the IOB register would be an untested sim/hardware divergence, which is the exact class of bug that has cost this design the most time. BEHAVIOUR CHANGE: MISO is now TRI-STATED when CS is high, held high by the pad's PULLUP, instead of actively driven high. EXI MISO is a SHARED bus -- SP1 is on channel 0 alongside memory-card slot A -- so driving it while deselected was wrong and could fight a card. --seeds 8: clk 28.5-30.7 MHz, EXI 41.6-49.7 MHz, 8/8 PASS. The EXI numbers fall (62 -> 49) because the posedge->negedge setup path into the IOB register is now charged to that domain at a half-period constraint; 41.6 MHz worst case is still 1.5x the 27 MHz target. Best seed 3 (clk 30.68, EXI 49.14), flashed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
16 KiB
Python
351 lines
16 KiB
Python
"""SPI Mode 3 byte-oriented slave for the EXI bus — CLOCKED BY EXI CLK.
|
|
|
|
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 *
|
|
|
|
__all__ = ["SPIMode3Slave"]
|
|
|
|
|
|
class SPIMode3Slave(Elaboratable):
|
|
"""Byte-oriented SPI Mode 3 slave, clocked by the EXI clock.
|
|
|
|
Ports
|
|
-----
|
|
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 asking upstream for the next byte, one per byte
|
|
boundary. No header-length or slot-threshold logic: see the TX comment
|
|
in elaborate() for why unconditional pops are both sufficient and
|
|
necessary.
|
|
"""
|
|
|
|
def __init__(self, phase_meter=False):
|
|
# DIAGNOSTIC: when set, every byte the engine transmits is the constant
|
|
# 0x80 instead of the FIFO head. The load/shift/mux/output path is
|
|
# otherwise untouched, so the register file and both byte FIFOs drop
|
|
# out of the picture and what the host reads back is purely the
|
|
# engine's byte PHASE: 0x80 = aligned, 0x40 = 1 bit late, 0x20 = 2 bits
|
|
# late, and so on. Not a functional build.
|
|
self._phase_meter = phase_meter
|
|
|
|
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(init=1)
|
|
# The SAME bit as spi_miso, but combinational from the PRE-edge state,
|
|
# so it can feed an SB_IO output register clocked on the falling edge.
|
|
# Driving MISO from a flop inside the IO block removes the fabric
|
|
# routing between the shift register and the pad, which is what the
|
|
# 27 MHz GameCube read fails on -- see the note in synth.py.
|
|
self.miso_next = Signal(init=1)
|
|
|
|
self.rx_byte = Signal(8)
|
|
self.rx_valid = Signal()
|
|
self.tx_byte = Signal(8)
|
|
self.tx_load = Signal()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
|
|
# ── RX: sample MOSI on the RISING edge (Mode 3 samples on edge 2) ──
|
|
rx_shift = Signal(8)
|
|
rx_bit = Signal(3)
|
|
|
|
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)
|
|
|
|
# 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(0x80 if self._phase_meter else 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.
|
|
# The byte phase comes from `rx_bit` -- the RISING-edge counter -- NOT
|
|
# from a free-running falling-edge counter of its own.
|
|
#
|
|
# A separate tx_bit counting falling edges only stays in step with the
|
|
# host while the host emits exactly as many falls as rises. It does
|
|
# not: a transfer that begins on a rising edge (CS asserted while CLK
|
|
# is low, or the clock gating between two of the host's back-to-back
|
|
# transfers) delivers a rise with no matching fall, and tx_bit silently
|
|
# slips one bit behind for the rest of the transaction. RX never
|
|
# notices, because the rising count always equals the host's bit count.
|
|
#
|
|
# Measured on a GameCube 2026-10-03 with a diagnostic build that drives
|
|
# a constant 0x80 per byte (PHASE_METER=1 in synth.py): every slot read
|
|
# back 0x20 = 0x80 >> 2, i.e. the engine was TWO falling edges behind,
|
|
# identically at 1 MHz and at 27 MHz. Speed-independence is the tell —
|
|
# a propagation problem cannot survive a 1000 ns bit time, and an
|
|
# output-edge error would have moved when the edge was flipped (it did
|
|
# not: the rising-edge and falling-edge builds returned byte-identical
|
|
# 05 C0 for a NWAYS read).
|
|
#
|
|
# Slaving the shift register to rx_bit gives the engine ONE phase
|
|
# reference, the same edge the host counts, so the two cannot drift.
|
|
# init=0xFF so CS (the domain's async reset) parks MISO HIGH by
|
|
# itself. MISO is then tx_shift[7] with NOTHING in between -- no
|
|
# Mux(cs_n, 1, ...) LUT in the data path. That mux cost 1.21 ns of
|
|
# logic AND 3.60 ns of routing to reach it, on a source-synchronous
|
|
# output that has only a HALF period (18.5 ns at 27 MHz) to reach the
|
|
# GameCube's sampling edge. Removing it also frees the placer to put
|
|
# this flop next to the MISO pad.
|
|
tx_shift = Signal(8, init=0xFF)
|
|
with m.If(rx_bit == 0):
|
|
m.d.exiclk_n += tx_shift.eq(tx_hold) # byte boundary: load MSB
|
|
with m.Else():
|
|
m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1]))
|
|
|
|
m.d.comb += self.spi_miso.eq(tx_shift[7])
|
|
# What tx_shift[7] WILL be after the next falling edge. Mirrors the
|
|
# load/shift choice above exactly; T6 proves the two agree.
|
|
m.d.comb += self.miso_next.eq(Mux(rx_bit == 0, tx_hold[7], tx_shift[6]))
|
|
|
|
return m
|
|
|
|
|
|
# ── Testbench ─────────────────────────────────────────────────────────────
|
|
|
|
class _Harness(Elaboratable):
|
|
"""Wraps the slave with the two pin-driven domains its parent normally
|
|
creates, so the engine can be exercised standalone."""
|
|
|
|
def __init__(self):
|
|
self.dut = SPIMode3Slave()
|
|
|
|
def elaborate(self, platform):
|
|
m = Module()
|
|
m.domains.exiclk = cd_p = ClockDomain(async_reset=True)
|
|
m.domains.exiclk_n = cd_n = ClockDomain(async_reset=True,
|
|
clk_edge="neg")
|
|
d = self.dut
|
|
m.d.comb += [
|
|
cd_p.clk.eq(d.spi_clk), cd_p.rst.eq(d.spi_cs_n),
|
|
cd_n.clk.eq(d.spi_clk), cd_n.rst.eq(d.spi_cs_n),
|
|
]
|
|
m.submodules.dut = d
|
|
return m
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import sys
|
|
from amaranth.sim import Simulator, Period
|
|
|
|
HP = Period(MHz=27) / 2 # one EXI half-period at the hardware rate
|
|
top = _Harness()
|
|
dut = top.dut
|
|
|
|
failures = []
|
|
|
|
def check(name, got, want):
|
|
ok = got == want
|
|
print(f" {'ok ' if ok else 'FAIL'} {name}: got {got}, want {want}")
|
|
if not ok:
|
|
failures.append(name)
|
|
|
|
# A STRICT Mode 3 master. Unlike a convenience model that parks MOSI long
|
|
# before the edge, this one changes MOSI exactly ON the falling edge and
|
|
# samples MISO in the CLK-low phase. That is what makes the edge
|
|
# convention observable: a slave that sampled MOSI on the falling edge
|
|
# would capture the previous bit, and one that drove MISO on the rising
|
|
# edge would come out a bit late. Both bugs shipped to hardware against a
|
|
# lax master that could not see them.
|
|
async def spi_byte(ctx, mosi_val, probe_rx=False):
|
|
miso = 0
|
|
rx = None
|
|
for bit in range(7, -1, -1):
|
|
ctx.set(dut.spi_clk, 0) # falling edge
|
|
ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1)
|
|
await ctx.delay(HP)
|
|
miso = (miso << 1) | ctx.get(dut.spi_miso) # sample in CLK-low
|
|
if probe_rx and bit == 0:
|
|
rx = (ctx.get(dut.rx_valid), ctx.get(dut.rx_byte))
|
|
ctx.set(dut.spi_clk, 1) # rising edge: slave samples
|
|
await ctx.delay(HP)
|
|
return (miso, rx) if probe_rx else miso
|
|
|
|
async def cs_low(ctx):
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.spi_cs_n, 0)
|
|
await ctx.delay(HP)
|
|
|
|
async def cs_high(ctx):
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.spi_cs_n, 1)
|
|
await ctx.delay(HP * 4)
|
|
|
|
async def tb(ctx):
|
|
ctx.set(dut.spi_cs_n, 1)
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.tx_byte, 0)
|
|
await ctx.delay(HP * 4)
|
|
|
|
# T1 — RX samples MOSI on the RISING edge, MSB first.
|
|
# rx_byte/rx_valid are combinational and peak during the final bit,
|
|
# which is the instant the parent's FIFO captures them.
|
|
await cs_low(ctx)
|
|
seen = []
|
|
for b in (0x80, 0x00, 0x31, 0x00):
|
|
_, (valid, byte) = await spi_byte(ctx, b, probe_rx=True)
|
|
seen.append((valid, byte))
|
|
await cs_high(ctx)
|
|
check("T1 rx bytes", [b for _, b in seen], [0x80, 0x00, 0x31, 0x00])
|
|
check("T1 rx_valid", [v for v, _ in seen], [1, 1, 1, 1])
|
|
|
|
# T2 — TX: the byte latched at the end of slot N appears in slot N+1,
|
|
# MSB first. Slot 0 is the reset value, before any load.
|
|
await cs_low(ctx)
|
|
ctx.set(dut.tx_byte, 0xA5)
|
|
s0 = await spi_byte(ctx, 0x00)
|
|
ctx.set(dut.tx_byte, 0x3C)
|
|
s1 = await spi_byte(ctx, 0x00)
|
|
ctx.set(dut.tx_byte, 0x00)
|
|
s2 = await spi_byte(ctx, 0x00)
|
|
await cs_high(ctx)
|
|
check("T2 miso slot0 (pre-load)", hex(s0), hex(0x00))
|
|
check("T2 miso slot1", hex(s1), hex(0xA5))
|
|
check("T2 miso slot2", hex(s2), hex(0x3C))
|
|
|
|
# T3 — tx_load pulses exactly once per byte, during the final bit.
|
|
await cs_low(ctx)
|
|
loads = 0
|
|
for _ in range(3):
|
|
for bit in range(8):
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP)
|
|
loads += ctx.get(dut.tx_load)
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
await cs_high(ctx)
|
|
check("T3 tx_load pulses", loads, 3)
|
|
|
|
# T4 — MISO releases HIGH when CS is deasserted, so it never fights
|
|
# another EXI device sharing the bus.
|
|
check("T4 miso idles high", ctx.get(dut.spi_miso), 1)
|
|
|
|
# T5 — a transaction that BEGINS ON A RISING EDGE. The host asserts
|
|
# CS while CLK is already low, so the first edge the device sees is a
|
|
# rise and the transaction carries one fewer FALLING edge than rising.
|
|
# A TX phase counted on falling edges slips one bit here and never
|
|
# recovers; a phase taken from rx_bit cannot.
|
|
#
|
|
# THIS IS THE HARDWARE BUG. On a GameCube the phase-meter build read
|
|
# 0x20 (= 0x80 >> 2) in every slot, identically at 1 MHz and 27 MHz.
|
|
# Nothing in the old testbench started a transaction this way -- every
|
|
# case asserted CS with CLK high -- so the falling and rising counts
|
|
# always matched and the slip was structurally invisible, exactly as
|
|
# the Mode 2/3 mix-up was invisible to a master that parked MOSI early.
|
|
ctx.set(dut.spi_clk, 0) # CLK already LOW ...
|
|
ctx.set(dut.spi_cs_n, 0) # ... when CS asserts
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.tx_byte, 0xA5)
|
|
await spi_byte(ctx, 0x00) # slot 0: header, MISO don't-care
|
|
ctx.set(dut.tx_byte, 0x3C)
|
|
s1 = await spi_byte(ctx, 0x00)
|
|
s2 = await spi_byte(ctx, 0x00)
|
|
await cs_high(ctx)
|
|
check("T5 rising-edge start, slot1", hex(s1), hex(0xA5))
|
|
check("T5 rising-edge start, slot2", hex(s2), hex(0x3C))
|
|
|
|
# T6 — miso_next must equal what spi_miso BECOMES on the next falling
|
|
# edge. The synthesised build drives the pad from an SB_IO register
|
|
# clocked on that edge (synth.py), so if this ever diverges the
|
|
# hardware and the simulation stop agreeing about MISO -- which is
|
|
# precisely the class of bug that has cost this design the most time.
|
|
ctx.set(dut.spi_clk, 1)
|
|
ctx.set(dut.spi_cs_n, 0)
|
|
await ctx.delay(HP)
|
|
ctx.set(dut.tx_byte, 0x6B)
|
|
mismatch = 0
|
|
for _ in range(3):
|
|
for _bit in range(8):
|
|
predicted = ctx.get(dut.miso_next) # before the falling edge
|
|
ctx.set(dut.spi_clk, 0)
|
|
await ctx.delay(HP)
|
|
if ctx.get(dut.spi_miso) != predicted:
|
|
mismatch += 1
|
|
ctx.set(dut.spi_clk, 1)
|
|
await ctx.delay(HP)
|
|
await cs_high(ctx)
|
|
check("T6 miso_next predicts spi_miso", mismatch, 0)
|
|
|
|
sim = Simulator(top)
|
|
sim.add_testbench(tb)
|
|
with sim.write_vcd("SPIMode3Slave.vcd"):
|
|
sim.run()
|
|
|
|
if failures:
|
|
print("\nFAILURES:")
|
|
for f in failures:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("\nAll tests passed.")
|