91c3bf0ece
Replaces the PLL-fed 54 MHz `capture` domain, which oversampled the GC's
27 MHz EXI clock 2x and so put BIT-level timing across a CDC boundary: the
engine's view of the clock lagged the pin by 2 FFSynchronizer stages plus a
register (~55 ns) against a 37 ns bit time, so MISO came out ~2 bits late and
the GC read 0x17 as 05 C0. No amount of oversampling fixes that -- 54 MHz
gives only 2 ticks per bit and the part cannot close the 81 MHz that 3x needs.
The engine is now clocked by the pin itself (pin 44 = global buffer input net
6, no board change), making MISO clock-to-Q. ExiCapture creates four domains:
exiclk (posedge, samples MOSI), exiclk_n (negedge, drives MISO), both
async-reset by CS, plus reset-less exififo_w/exififo_r for the byte FIFO sides
-- reset-less because resetting one side of an AsyncFIFO desynchronises its
gray pointers, and posedge because AsyncFIFO has a posedge-only reset CDC
internally and cannot host a negedge side.
Also removes the SPRAM response FIFO. The DMA read was one byte late because
the round trip did not fit the protocol's turnaround byte; the arbiter already
exposed valid/ready, so the FIFO bought a cycle of latency and nothing else.
The response is handled outside the FSM (the reply lands in RHDR_PAD, not
SPRAM_STREAM) with a one-entry hold for when tx_fifo is momentarily full.
The PLL was left instantiated but unused after the rewrite; removing it frees
the hard macro and pin 35 (which stays unconnected only because the board is
already built that way). This is what lifted the pass rate:
domain target before after
clk (sync) 24 MHz 28.1-30.2 26.4-32.2 8/8
capture_clk 54 MHz 2 of 8 PASS (gone)
EXI pin domain 27 MHz -- 45.6-64.4 8/8
Best seed 3: clk 32.18 MHz, EXI 55.86 MHz. The binding constraint has FLIPPED
-- `clk` (1.10-1.34x) is now tighter than the EXI domain (1.69-2.39x) -- so
synth.py ranks seeds by worst RELATIVE margin, not by the EXI number.
synth.py could not have scored this design at all: it required the vanished
capture_clk, and its regex '(\w+)' cannot match nextpnr's exi_clk_$glb_clk
(no $ in \w). add_clock_constraint was also landing on the wrong net -- under
EXI_J4 the clock is the AND of two pads, not either pad -- so nextpnr silently
fell back to a 12 MHz default and under-optimised the whole EXI domain.
Testbenches:
- spi_mode3_slave had lost its testbench entirely in the rewrite. Restored
with a strict Mode 3 master (changes MOSI on the falling edge, samples MISO
in the clock-low phase) and MUTATION-TESTED: flipping either clock edge in
the RTL makes it fail. Both of those bugs previously reached silicon
against a lax master that parked MOSI well before the edge.
- exi_j4 T2/T3 returned [0,4,2,2]. Real bug: the engine latches the FIFO head
at each byte boundary, so a 2-byte command header leaves only ONE drain
slot. DEVID streamed zeros until CS and left two stale bytes -- one too
many -- so the NEXT enumeration came back a slot late. DEVID now pushes
exactly four bytes and stops. Register-space reads may still over-push:
their 4-byte header gives three drain slots.
- Dead ports removed from SPIMode3Slave (hdr_bytes, frame_start, cs_active,
byte_idx) and the mode2 parameter is gone -- there is one correct edge
convention and it is now enforced by tests.
All 14 module suites pass, plus bba_top at EXI_HALF=1/2/8.
CLAUDE.md: rewrote the clocking, CDC inventory and module tables; marked the
2026-09-30 capture_clk measurements historical; and corrected the line in
"Critical Protocol Notes" that still stated the INVERTED edge convention
("MOSI sampled on falling, MISO driven on rising" is Mode 2) -- the original
implementation followed that doc error.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
261 lines
10 KiB
Python
261 lines
10 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):
|
|
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)
|
|
|
|
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(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)
|
|
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():
|
|
m.d.exiclk_n += 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 ─────────────────────────────────────────────────────────────
|
|
|
|
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)
|
|
|
|
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.")
|