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