"""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