"""SPI Mode 3 byte-oriented slave for the EXI bus, clocked by the EXI clock. Mode 3 is CPOL=1, CPHA=1: CLK idles HIGH, the master changes MOSI on the FALLING edge, and both ends sample on the RISING edge. The engine is clocked by the EXI clock pin itself rather than oversampled from a faster domain, so there is exactly one clock edge and one phase reference in the whole module: exiclk posedge of EXI CLK. Samples MOSI, assembles RX bytes, and launches MISO -- see `_tx_bits` for why MISO goes out on this edge rather than the falling one. spi_cs_n asynchronous reset of that domain. Oversampling cannot work here, which is why it is not an option to fall back to: it puts BIT-level timing across a clock-domain boundary, and the engine's view of the clock then lags the pin by two `FFSynchronizer` stages plus a register -- about 55 ns against a 37 ns bit time at 27 MHz. Clocking from the pin makes MISO clock-to-Q and leaves the byte-level `AsyncFIFO`s as the only crossing, where a whole byte time (~296 ns) of slack is available instead of a fraction of a bit. EXI CLK is on pin 44, which sits at die (6,0) on the 5k-sg48 = global buffer input net 6, so it drives a real clock network with no board change. The clock domain is created by the PARENT (`ExiCapture`) so the byte FIFOs can name it; this module only uses `m.d.exiclk`. """ 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` domain and `spi_cs_n` is its async reset, both wired by the parent; they are listed here so the module is self-describing. spi_miso Serial output to the GC. Idles HIGH while CS is deasserted. miso_next The same bit, but combinational from the pre-edge state, so it can feed an SB_IO output register clocked on the RISING edge. The synthesised build drives the pad from that register; see `synth.py`. id_arm Enables the device-ID fast path. Normally tied high -- real hardware answers EXI_GetID whenever it is asked -- and driven low only by the PHASE_METER diagnostic build, so the constant test pattern is not replaced by ID bytes. rx_byte Last complete received byte, valid while `rx_valid` is high. rx_valid One-cycle pulse in `exiclk` when `rx_byte` holds a new byte. tx_byte Byte to transmit, sampled at each byte boundary. tx_load One-cycle pulse per byte boundary asking upstream for the next byte. Unconditional: there is no header-length or slot-threshold logic, and `_tx_bits` explains why that is both sufficient and necessary. """ def __init__(self, phase_meter=False): # DIAGNOSTIC build: every transmitted byte becomes the constant 0x80 # instead of the FIFO head, leaving the load/shift/output path # untouched. The register file and both byte FIFOs therefore drop out # of the MISO path, and whatever the host reads back is purely the # engine's byte PHASE -- 0x80 aligned, 0x40 one bit late, 0x20 two # bits late. Not a functional bitstream. 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) self.miso_next = Signal(init=1) self.id_arm = Signal() 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_bit, nxt = self._rx_bytes(m) id_sel, id_byte = self._device_id(m, rx_bit, nxt) self._tx_bits(m, rx_bit, id_sel, id_byte) return m def _rx_bytes(self, m): """Sample MOSI on the rising edge and assemble bytes, MSB first. Returns `(rx_bit, nxt)`, where `nxt` is the shift register's next value -- i.e. the complete byte during the final bit. `rx_byte`/`rx_valid` are COMBINATIONAL rather than 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: a 4-byte write landed as DE AD BE 00, and a 34-byte TX stream as 15 bytes. Driving them combinationally makes the parent's FIFO capture the byte on the very edge that completes it. """ rx_shift = Signal(8) rx_bit = Signal(3) nxt = Cat(self.spi_mosi, rx_shift[:-1]) # shift left, new bit at LSB m.d.exiclk += [rx_shift.eq(nxt), rx_bit.eq(rx_bit + 1)] m.d.comb += [self.rx_byte .eq(nxt), self.rx_valid.eq(rx_bit == 7)] return rx_bit, nxt def _device_id(self, m, rx_bit, nxt): """Answer the device-ID query from this domain, not the register file. Returns `(id_sel, id_byte)` for the TX mux. A command-space header is only TWO bytes, so a reply that travels rx `AsyncFIFO` → `sync` FSM → tx `AsyncFIFO` has one byte time to make it back -- 296 ns at 27 MHz -- against a round trip costing roughly 280-320 ns. That is marginal by construction, and on a console it lost: the ID came back one byte late. The inter-transfer gap cannot rescue it, which is the non-obvious part. The reply must be in hand by the LAST rising edge of the header transfer, and the clock is paused after that edge, so no edge exists during the gap for anything to update. The device ID is a constant and belongs to the physical interface rather than to the register file, so answering it here skips both crossings. `byte 0 == 0x00` identifies the query exactly: bit 7 clear selects command space, bit 6 clear selects read, and the register number is 0. """ byte_idx = Signal(3) # bytes completed this transaction, saturating id_mode = Signal() with m.If(rx_bit == 7): with m.If(byte_idx != 7): m.d.exiclk += byte_idx.eq(byte_idx + 1) with m.If((byte_idx == 0) & (nxt == 0x00) & self.id_arm): m.d.exiclk += id_mode.eq(1) # Byte N of the reply is loaded at the end of header byte N, so the # four ID bytes are selected by byte_idx 1..4. id_byte = Signal(8) with m.Switch(byte_idx): with m.Case(1): m.d.comb += id_byte.eq(0x04) with m.Case(2): m.d.comb += id_byte.eq(0x02) with m.Case(3): m.d.comb += id_byte.eq(0x02) with m.Default(): m.d.comb += id_byte.eq(0x00) id_sel = Signal() m.d.comb += id_sel.eq(id_mode & (byte_idx >= 1) & (byte_idx <= 4)) return id_sel, id_byte def _tx_bits(self, m, rx_bit, id_sel, id_byte): """Shift MISO out, launching each bit a FULL CYCLE EARLY. The host samples MISO on the rising edge. The obvious thing is to drive it on the preceding FALLING edge, which gives the output path a half period -- 18.5 ns at 27 MHz. That is not enough on this part, and the shortfall cannot be removed by any RTL change, because of what the path is made of: MISO changes at (falling edge AT THE CONNECTOR) + input pad delay + GLOBAL CLOCK NETWORK INSERTION <- the big one + IOB clock-to-Q + output pad delay ... and the host still needs its own setup time on top. About 9 ns was removed from the fabric output path in three steps and the console result stayed BYTE IDENTICAL each time. Nine nanoseconds out of 18.5 cannot change nothing if propagation were the whole story, so the rest is the irreducible part: getting the clock into the chip and the data back out. So the fix doubles the budget instead of shrinking the delay: present the bit the host samples at rising edge N on rising edge N-1, giving the whole path a FULL period (37 ns at 27 MHz). The output then changes shortly AFTER the edge on which the host sampled the previous bit, which is safe at both ends -- the insertion delay that caused the problem now covers the host's hold time, and a full period covers its setup. If a LOW speed index ever regresses while 27 MHz still works, that is the HOLD edge of this window (the launch being too early), not a setup problem. `tx_out` is therefore clocked on the rising edge and loads at `rx_bit == 7`, one bit time earlier than a falling-edge shifter loading at `rx_bit == 0`. `init=0xFF` parks MISO high under CS reset. `tx_load` pops once per byte boundary unconditionally. That is sufficient because the register file pushes its response during the header's trailing pad byte, and necessary because the pops are also what drains whatever a previous transaction left in the FIFO. """ tx_out = Signal(8, init=0xFF) tx_src = Signal(8) # the byte loaded at the next byte boundary m.d.comb += tx_src.eq( Mux(id_sel, id_byte, 0x80 if self._phase_meter else self.tx_byte)) m.d.comb += self.tx_load.eq(rx_bit == 7) with m.If(rx_bit == 7): m.d.exiclk += tx_out.eq(tx_src) with m.Else(): m.d.exiclk += tx_out.eq(Cat(0, tx_out[:-1])) m.d.comb += self.spi_miso.eq(tx_out[7]) # What tx_out[7] WILL be after the next rising edge, which is what the # SB_IO output register captures. Mirrors the load/shift choice above # exactly; T6 proves the two agree bit for bit. m.d.comb += self.miso_next.eq(Mux(rx_bit == 7, tx_src[7], tx_out[6])) # ── Testbench ───────────────────────────────────────────────────────────── class _Harness(Elaboratable): """Wraps the slave with the pin-driven domain 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) d = self.dut m.d.comb += [ cd_p.clk.eq(d.spi_clk), cd_p.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. Both details are what make the edge # conventions observable at all: # # * A slave that sampled MOSI on the falling edge would capture the # PREVIOUS bit. A master that parks MOSI early cannot see that, and # a Mode 2/Mode 3 mix-up reached silicon against one that did. # * MISO must be read in the clock-low phase, because the engine drives # it ON the rising edge. Sampling just after raising the clock sees # the NEXT bit in a zero-delay simulation, which would report # everything one bit early while the hardware was correct. 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 and # 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 header byte: the host ignores MISO there # and nothing has been loaded yet, so it reads the CS-reset parking # value (tx_out init=0xFF, which is what holds MISO high while # deselected). The bits the host actually uses start at slot 1. 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(0xFF)) 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 A HARDWARE BUG THIS TEST EXISTS TO CATCH. With a # free-running falling-edge counter the console's phase-meter build # read 0x20 (= 0x80 >> 2) in every slot, identically at 1 MHz and # 27 MHz -- speed independence being the tell that it was not a # propagation problem. Every earlier test asserted CS with CLK high, # so falls and rises always matched and the slip was structurally # invisible. When adding SPI tests, vary the edge the transaction # starts on. 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 rising # edge. The synthesised build drives the pad from an SB_IO register # clocked on that edge (synth.py), so if these ever diverge then the # hardware and the simulation stop agreeing about MISO -- 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): ctx.set(dut.spi_clk, 0) await ctx.delay(HP) predicted = ctx.get(dut.miso_next) # before the rising edge ctx.set(dut.spi_clk, 1) await ctx.delay(HP) if ctx.get(dut.spi_miso) != predicted: mismatch += 1 await cs_high(ctx) check("T6 miso_next predicts spi_miso", mismatch, 0) # T7 — the device-ID fast path. A command-space query (byte 0 = 0x00, # byte 1 = 0x00) must be answered 04 02 02 00 straight from this # domain, with the tx FIFO deliberately feeding 0x00 so that only the # fast path can be the source. The register file cannot make the # round trip inside a 2-byte header's one byte time; on a console it # returned the ID one byte late. ctx.set(dut.id_arm, 1) ctx.set(dut.tx_byte, 0x00) # FIFO deliberately contributes nothing await cs_low(ctx) await spi_byte(ctx, 0x00) # header byte 0 identifies the query await spi_byte(ctx, 0x00) # header byte 1 got = [await spi_byte(ctx, 0x00) for _ in range(4)] await cs_high(ctx) check("T7 device ID", [hex(b) for b in got], [hex(x) for x in (0x04, 0x02, 0x02, 0x00)]) # T8 — and the fast path must be GATED by id_arm, so that a build # which disarms it gets the FIFO byte through untouched. That is what # the PHASE_METER bitstream relies on: its constant 0x80 pattern would # otherwise be replaced by ID bytes in the slots it measures. ctx.set(dut.id_arm, 0) ctx.set(dut.tx_byte, 0x5A) await cs_low(ctx) await spi_byte(ctx, 0x00) await spi_byte(ctx, 0x00) got2 = [await spi_byte(ctx, 0x00) for _ in range(2)] await cs_high(ctx) check("T8 fast path gated by id_arm", [hex(b) for b in got2], [hex(0x5A), hex(0x5A)]) 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.")