a3b3e052bc
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>
141 lines
6.4 KiB
Python
141 lines
6.4 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 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):
|
|
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()
|
|
|
|
# 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)
|
|
|
|
# Transaction framing, generated in the slow domain by the parent.
|
|
self.frame_start = Signal()
|
|
self.cs_active = 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)
|
|
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)
|
|
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
|