diff --git a/exi_bba/bba_top.py b/exi_bba/bba_top.py index a684a6b..1e740a1 100644 --- a/exi_bba/bba_top.py +++ b/exi_bba/bba_top.py @@ -419,6 +419,11 @@ if __name__ == "__main__": # by one bit (04 02 02 00 -> 02 01 01 00, NWAYS 0x17 -> 0x0B), which is an # open bug. HALF = int(os.environ.get("EXI_HALF", "8")) + # The bit engine is now clocked by the EXI pin itself, so there is no + # `capture` domain to count ticks against. Drive the SPI clock with real + # delays: one half-period = HALF * (1/54 MHz), preserving the old meaning + # of HALF so the rates in the table above still hold. + HP = Period(MHz=54) * HALF async def spi_byte(ctx, mosi_val): """Drive one EXI Mode-3 byte; return the assembled MISO byte.""" @@ -430,10 +435,10 @@ if __name__ == "__main__": for bit in range(7, -1, -1): ctx.set(dut.exi_clk, 0) # falling: master drives ctx.set(dut.exi_mosi, (mosi_val >> bit) & 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) ctx.set(dut.exi_clk, 1) # rising: both sample miso = (miso << 1) | ctx.get(dut.exi_miso) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return miso async def exi_read(ctx, addr, length): @@ -442,24 +447,24 @@ if __name__ == "__main__": CS. The trailing 0x00 is the device's prefetch window.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0x80) await spi_byte(ctx, (addr >> 8) & 0xFF) await spi_byte(ctx, addr & 0xFF) await spi_byte(ctx, 0x00) # EXI_Imm clock-idle gap: the core decodes the header and prefetches # responses into the tx FIFO before the GC clocks the data phase. - await ctx.tick("capture").repeat(HALF * 12) + await ctx.delay(HP * (HALF * 12)) result = [await spi_byte(ctx, 0x00) for _ in range(length)] ctx.set(dut.exi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return result async def exi_write(ctx, addr, data): """Register-space write: 4-byte header [0xC0, ah, al, 0x00] + data.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0xC0) await spi_byte(ctx, (addr >> 8) & 0xFF) await spi_byte(ctx, addr & 0xFF) @@ -467,19 +472,19 @@ if __name__ == "__main__": for b in data: await spi_byte(ctx, b) ctx.set(dut.exi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) async def exi_devid(ctx): """EXI_GetID: write 0x0000 (two bytes) then clock four read bytes.""" ctx.set(dut.exi_cs_n, 0) ctx.set(dut.exi_clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) await spi_byte(ctx, 0x00) await spi_byte(ctx, 0x00) - await ctx.tick("capture").repeat(HALF * 12) + await ctx.delay(HP * (HALF * 12)) out = [await spi_byte(ctx, 0x00) for _ in range(4)] ctx.set(dut.exi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return out # ── W5100 indirect-bus slave model (drives w5100_data_i) ───────────── @@ -548,30 +553,30 @@ if __name__ == "__main__": ctx.set(dut.exi_clk, 1) ctx.set(dut.exi_cs_n, 1) ctx.set(dut.panel_btn, 0b111) # all buttons released (active-low idle) - await ctx.tick("capture").repeat(20) + await ctx.delay(HP * (20)) # T1: device ID — read 4 bytes from addr 0 → 0x04 0x02 0x02 0x00 dev = await exi_devid(ctx) print(f"T1 device ID: {[f'0x{b:02X}' for b in dev]}") if dev != [0x04, 0x02, 0x02, 0x00]: errors.append(f"T1 device ID: got {dev}") - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) # T2: write PAR0–3, read them back through the full chain await exi_write(ctx, 0x20, [0xDE, 0xAD, 0xBE, 0xEF]) - await ctx.tick("capture").repeat(HALF * 4) + await ctx.delay(HP * (HALF * 4)) par = await exi_read(ctx, 0x20, 4) print(f"T2 PAR0-3 readback: {[f'0x{b:02X}' for b in par]}") if par != [0xDE, 0xAD, 0xBE, 0xEF]: errors.append(f"T2 PAR readback: got {par}") - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) # T3: NWAYS must read back the hardcoded 0x17 (link-up sentinel) nways = await exi_read(ctx, 0x31, 1) print(f"T3 NWAYS: 0x{nways[0]:02X} (want 0x17)") if nways != [0x17]: errors.append(f"T3 NWAYS: got {nways}") - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) # T4: DMA-style SPRAM read — clock 8 data bytes (past the 4-byte header # limit) within one CS. Exercises the integrated streaming path: @@ -583,7 +588,7 @@ if __name__ == "__main__": print(f"T4 DMA read (8B from 0x100): {[f'0x{b:02X}' for b in dma]}") if len(dma) != 8: errors.append(f"T4 DMA read length: got {len(dma)}") - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) # T5: a register read after the streaming read confirms the FSM cleaned # up (SPRAM_END → HEADER0) and the device is responsive again. @@ -591,7 +596,7 @@ if __name__ == "__main__": print(f"T5 NWAYS after DMA: 0x{nways2[0]:02X} (want 0x17)") if nways2 != [0x17]: errors.append(f"T5 NWAYS after DMA read: got {nways2}") - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) # ── T6: FULL ETHERNET→SPRAM→GC LOOP ────────────────────────────── # A frame arrives from the network (W5500 model) → W5500 master reads it @@ -601,13 +606,13 @@ if __name__ == "__main__": # before multi-byte bus accesses work — trigger it via NCRA reset, as # the real GC driver does, and let it run before enabling RX. await exi_write(ctx, 0x00, [0x01]) # NCRA reset → init_req pulse - await ctx.tick("capture").repeat(2000) # let W5100 init run + await ctx.delay(HP * (2000)) # let W5100 init run await exi_write(ctx, 0x00, [0x08]) # NCRA SR bit → enable RX - await ctx.tick("capture").repeat(HALF * 2) + await ctx.delay(HP * (HALF * 2)) ctx.set(dut.w5100_int_n, 0) # W5100: a packet was received - await ctx.tick("capture").repeat(4000) # let the W5100 RX + SPRAM write run + await ctx.delay(HP * (4000)) # let the W5100 RX + SPRAM write run ctx.set(dut.w5100_int_n, 1) - await ctx.tick("capture").repeat(HALF * 2) + await ctx.delay(HP * (HALF * 2)) rwp = await exi_read(ctx, 0x16, 1) # RX write pointer (page) total_len = len(RX_FRAME) + 4 @@ -664,7 +669,6 @@ if __name__ == "__main__": print(f"T8 TX frame matches byte-for-byte ({len(TX_FRAME)} bytes)") sim = Simulator(dut) - sim.add_clock(Period(MHz=54), domain="capture") sim.add_clock(Period(MHz=24), domain="exi") sim.add_clock(Period(MHz=24), domain="sync") sim.add_testbench(testbench) diff --git a/exi_bba/exi_capture.py b/exi_bba/exi_capture.py index e038a3d..2567435 100644 --- a/exi_bba/exi_capture.py +++ b/exi_bba/exi_capture.py @@ -83,20 +83,49 @@ class ExiCapture(Elaboratable): def elaborate(self, platform): m = Module() - spi = SPIMode3Slave(domain="capture") + # ── Clock domains driven by the EXI clock pin ───────────────────── + # The bit engine is clocked by EXI CLK itself (pin 44 = die (6,0) = + # global buffer net 6 on the 5k-sg48, so it can drive a real clock + # network). Two edges are needed: Mode 3 samples MOSI on the RISING + # edge and MISO must change on the FALLING edge, between the master's + # sampling points. + # + # Each edge gets TWO domains with the same clock: + # *_rst async-reset by CS -- the bit/byte counters, which must + # restart at every transaction + # (plain) no reset -- the byte FIFOs. Resetting only ONE side of an + # AsyncFIFO desynchronises its gray pointers and corrupts it, + # so the FIFOs must NOT see CS. + m.domains.exiclk = cd_p = ClockDomain(local=True, async_reset=True) + m.domains.exiclk_n = cd_n = ClockDomain(local=True, async_reset=True, + clk_edge="neg") + m.domains.exififo_w = cd_fw = ClockDomain(local=True, reset_less=True) + # NOTE: both FIFO domains are POSEDGE. AsyncFIFO instantiates a + # posedge-only reset CDC internally, so a negedge domain cannot host a + # FIFO side. The engine pops in `exififo_r` (posedge) and hands the + # byte to its negedge output stage through a register. + m.domains.exififo_r = cd_fr = ClockDomain(local=True, reset_less=True) + m.d.comb += [ + cd_p .clk.eq(self.spi_clk), cd_p .rst.eq(self.spi_cs_n), + cd_n .clk.eq(self.spi_clk), cd_n .rst.eq(self.spi_cs_n), + cd_fw.clk.eq(self.spi_clk), + cd_fr.clk.eq(self.spi_clk), + ] + + spi = SPIMode3Slave() m.submodules.spi = spi + # RX bytes cross exiclk -> exi; TX bytes cross exi -> exiclk_n. + # These are the ONLY crossings left, and each has a whole byte-time + # (~296 ns at 27 MHz) of slack -- unlike the old design, where + # bit-level MISO timing crossed a domain with a 37 ns budget. rx_fifo = AsyncFIFO(width=8, depth=self._rx_depth, - w_domain="capture", r_domain="exi") + w_domain="exififo_w", r_domain="exi") tx_fifo = AsyncFIFO(width=8, depth=self._tx_depth, - w_domain="exi", r_domain="capture") + w_domain="exi", r_domain="exififo_r") m.submodules.rx_fifo = rx_fifo m.submodules.tx_fifo = tx_fifo - # cs_active (capture) → exi domain for the register file - m.submodules.cs_sync = FFSynchronizer(spi.cs_active, self.cs_active, - o_domain="exi") - # ── Physical pins ↔ bit engine ─────────────────────────────────── m.d.comb += [ spi.spi_clk .eq(self.spi_clk), @@ -105,90 +134,36 @@ class ExiCapture(Elaboratable): self.spi_miso.eq(spi.spi_miso), ] - # ── RX: every received byte → rx_fifo (capture write side) ─────── + # ── RX: every received byte → rx_fifo ──────────────────────────── m.d.comb += [ rx_fifo.w_data.eq(spi.rx_byte), rx_fifo.w_en .eq(spi.rx_valid), - ] - # Core read side - m.d.comb += [ - self.rx_data .eq(rx_fifo.r_data), - self.rx_rdy .eq(rx_fifo.r_rdy), - rx_fifo.r_en .eq(self.rx_en), + self.rx_data .eq(rx_fifo.r_data), + self.rx_rdy .eq(rx_fifo.r_rdy), + rx_fifo.r_en .eq(self.rx_en), ] - # ── TX: core write side ────────────────────────────────────────── + # ── TX: core pushes responses; the engine pops one per byte-time ── + # The engine suppresses the pop during the protocol's turnaround byte + # and drains stale bytes during the rest of the header, so no + # CS-fall flush or slot-threshold logic is needed here any more. m.d.comb += [ tx_fifo.w_data.eq(self.tx_data), tx_fifo.w_en .eq(self.tx_en), self.tx_rdy .eq(tx_fifo.w_rdy), + spi.tx_byte .eq(tx_fifo.r_data), + tx_fifo.r_en .eq(spi.tx_load), ] - # ── TX response gating (capture domain) ────────────────────────── - # The bit engine drives MISO LIVE from tx_byte = tx_fifo head, so the - # response byte at the head is what gets sent for the current data byte. - # `txld_cnt` counts completed bytes within the transaction (tx_load - # pulses at each byte completion): - # completion 0,1 → header bytes (no pop) - # completion ≥2 → a data byte finished → pop to advance the head - # The first data byte (data0) is served live from the head without a - # pop; the pop after it advances the head to data1's response, etc. - txld_cnt = Signal(3) # counts header slots; data starts at hdr_slots - - m.d.comb += spi.tx_byte.eq(tx_fifo.r_data) - - # Pop depends ONLY on the registered tx_load and txld_cnt — NOT on - # frame_start. (frame_start precedes byte-0's tx_load by a cycle and - # has already reset txld_cnt to 0, so byte 0 is never a data byte.) - # Keeping cs_fall/frame_start off the pop path shortens the capture- - # domain critical path through the FIFO consume pointer. - # - # `flushing` clears prefetch over-push left in tx_fifo by the previous - # transaction: the register file streams response bytes ahead of the GC - # clock for DMA reads, so when CS deasserts mid-stream a few unsent - # bytes remain. On CS-fall (frame_start) drain tx_fifo to empty before - # the new transaction's data phase, so stale bytes never reach MISO. - # The flush must NOT read `tx_fifo.r_rdy`. r_rdy is derived from the - # gray-coded FIFO pointers that `r_en` advances, so any r_rdy → r_en - # dependence (either gating the drain with `& r_rdy`, or deasserting a - # `flushing` flag on `~r_rdy`) closes a long capture-domain loop - # (consume_ptr → gray → r_rdy → flush → r_en → consume_ptr) — the - # measured critical path capping capture_clk. Instead, drain for a - # FIXED number of cycles: the FIFO is only `tx_depth` deep, so pulsing - # r_en for `tx_depth + 1` cycles empties it regardless of occupancy - # (the FIFO advances its read pointer only on r_en & r_rdy internally, - # so pulses past empty are harmless). This removes the pointer - # feedback from r_en entirely; the only remaining r_en source is the - # legitimate data-byte pop. - drain = Signal(range(self._tx_depth + 2)) - with m.If(spi.frame_start): - m.d.capture += drain.eq(self._tx_depth + 1) - with m.Elif(drain != 0): - m.d.capture += drain.eq(drain - 1) - # Pops must start at the FIRST DATA SLOT, which depends on the header - # length -- and the real protocol has two header forms (see - # docs/exi_protocol_real.md): - # byte0 bit7 = 1 register space, 4-byte header -> data at slot 4 - # byte0 bit7 = 0 command space, 2-byte header -> data at slot 2 - # This was hard-coded to 2, which is right only for the old invented - # 2-byte header; against a 4-byte header it consumes two bytes before - # the GC clocks any data and every read comes back shifted. - hdr_slots = Signal(3, init=2) - first_byte = Signal(init=1) - with m.If(spi.frame_start): - m.d.capture += first_byte.eq(1) - with m.Elif(spi.rx_valid & first_byte): - m.d.capture += [first_byte.eq(0), - hdr_slots.eq(Mux(spi.rx_byte[7], 4, 2))] - - m.d.comb += tx_fifo.r_en.eq( - (spi.tx_load & (txld_cnt >= hdr_slots)) | (drain != 0) - ) - - with m.If(spi.frame_start): - m.d.capture += txld_cnt.eq(0) - with m.Elif(spi.tx_load & (txld_cnt < 5)): - m.d.capture += txld_cnt.eq(txld_cnt + 1) + # ── Transaction framing, in the slow domain ────────────────────── + # CS is slow relative to `exi`, so a plain synchroniser is correct and + # there is no longer any reason to detect it in a fast domain. + cs_n_s = Signal(init=1) + m.submodules.cs_sync = FFSynchronizer(self.spi_cs_n, cs_n_s, + o_domain="exi", init=1) + cs_n_prev = Signal(init=1) + m.d.exi += cs_n_prev.eq(cs_n_s) + m.d.comb += self.cs_active.eq(~cs_n_s) return m @@ -212,10 +187,10 @@ if __name__ == "__main__": for bit in range(7, -1, -1): ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1) ctx.set(dut.spi_clk, 0) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) miso = (miso << 1) | ctx.get(dut.spi_miso) ctx.set(dut.spi_clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return miso async def core_drain_rx(ctx, into): @@ -240,7 +215,7 @@ if __name__ == "__main__": data bytes; return the MISO data bytes read.""" ctx.set(dut.spi_cs_n, 0) ctx.set(dut.spi_clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) for h in hdr: await spi_byte(ctx, h) for _ in range(20): # clock-idle gap @@ -248,10 +223,10 @@ if __name__ == "__main__": await ctx.tick("exi").repeat(1) for r in responses: await push_tx(ctx, r) - await ctx.tick("capture").repeat(2) + await ctx.delay(HP * (2)) miso = [await spi_byte(ctx, 0x00) for _ in range(n_data)] ctx.set(dut.spi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) for _ in range(20): # drain data-phase dummies await core_drain_rx(ctx, rx_seen) await ctx.tick("exi").repeat(1) @@ -259,7 +234,7 @@ if __name__ == "__main__": async def testbench(ctx): rx_seen = [] - await ctx.tick("capture").repeat(2) + await ctx.delay(HP * (2)) # ── T1: header + 2 data bytes read back ────────────────────────── miso = await do_txn(ctx, [0x12, 0x34], [0xA5, 0x5A], 2, rx_seen) @@ -281,7 +256,6 @@ if __name__ == "__main__": errors.append(f"T2 flush failed — stale byte leaked: {[hex(b) for b in misoB]}") sim = Simulator(dut) - sim.add_clock(Period(MHz=54), domain="capture") sim.add_clock(Period(MHz=24), domain="exi") sim.add_testbench(testbench) diff --git a/exi_bba/exi_j4.py b/exi_bba/exi_j4.py index 2276d00..923a44b 100644 --- a/exi_bba/exi_j4.py +++ b/exi_bba/exi_j4.py @@ -82,10 +82,10 @@ if __name__ == "__main__": for bit in range(7, -1, -1): ctx.set(mosi, (mosi_val >> bit) & 1) ctx.set(clk, 0) # falling: slave samples - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) miso = (miso << 1) | ctx.get(dut.exi_miso) ctx.set(clk, 1) # rising: slave drives - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return miso async def exi_read(ctx, pads, addr, length): @@ -93,14 +93,14 @@ if __name__ == "__main__": clk, mosi, cs_n = pads ctx.set(cs_n, 0) ctx.set(clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) await spi_byte(ctx, pads, 0x80) await spi_byte(ctx, pads, (addr >> 8) & 0xFF) await spi_byte(ctx, pads, addr & 0xFF) await spi_byte(ctx, pads, 0x00) # turnaround byte result = [await spi_byte(ctx, pads, 0x00) for _ in range(length)] ctx.set(cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return result async def exi_devid(ctx, pads): @@ -108,16 +108,16 @@ if __name__ == "__main__": clk, mosi, cs_n = pads ctx.set(cs_n, 0) ctx.set(clk, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) await spi_byte(ctx, pads, 0x00) await spi_byte(ctx, pads, 0x00) out = [await spi_byte(ctx, pads, 0x00) for _ in range(4)] ctx.set(cs_n, 1) - await ctx.tick("capture").repeat(HALF) + await ctx.delay(HP * (HALF)) return out async def testbench(ctx): - await ctx.tick("capture").repeat(20) + await ctx.delay(HP * (20)) # T1: enumerate over the SP1 pads while J4 is unplugged (idle high). unplug(ctx, top.j4) @@ -125,7 +125,7 @@ if __name__ == "__main__": print(f"T1 enumerate via SP1 (J4 idle): {[f'0x{b:02X}' for b in dev]}") if dev != DEVICE_ID: errors.append(f"T1 SP1 device ID: got {dev}") - await ctx.tick("capture").repeat(HALF * 4) + await ctx.delay(HP * (HALF * 4)) # T2: enumerate over the J4 pads while SP1 is unplugged (idle high). unplug(ctx, top.sp1) @@ -133,7 +133,7 @@ if __name__ == "__main__": print(f"T2 enumerate via J4 (SP1 idle): {[f'0x{b:02X}' for b in dev]}") if dev != DEVICE_ID: errors.append(f"T2 J4 device ID: got {dev}") - await ctx.tick("capture").repeat(HALF * 4) + await ctx.delay(HP * (HALF * 4)) # T3: back to SP1, to prove using J4 left no sticky state behind. unplug(ctx, top.j4) @@ -141,7 +141,7 @@ if __name__ == "__main__": print(f"T3 enumerate via SP1 again: {[f'0x{b:02X}' for b in dev]}") if dev != DEVICE_ID: errors.append(f"T3 SP1 re-enumerate: got {dev}") - await ctx.tick("capture").repeat(HALF * 4) + await ctx.delay(HP * (HALF * 4)) # T4: a register read through the merge, not just the ID ROM path. unplug(ctx, top.sp1) @@ -164,7 +164,7 @@ if __name__ == "__main__": ): ctx.set(top.sp1[2], sp1_v) ctx.set(top.j4[2], j4_v) - await ctx.tick("capture") + await ctx.delay(HP) got = ctx.get(dut.exi_cs_n) ok = "ok" if got == want else "MISMATCH" print(f"T5 cs_n sp1={sp1_v} j4={j4_v} -> core {got} (want {want}) {ok} {note}") @@ -174,7 +174,6 @@ if __name__ == "__main__": unplug(ctx, top.j4) sim = Simulator(top) - sim.add_clock(Period(MHz=54), domain="capture") sim.add_clock(Period(MHz=24), domain="exi") sim.add_clock(Period(MHz=24), domain="sync") sim.add_testbench(testbench) diff --git a/exi_bba/spi_mode3_slave.py b/exi_bba/spi_mode3_slave.py index 991cd06..34e9ccd 100644 --- a/exi_bba/spi_mode3_slave.py +++ b/exi_bba/spi_mode3_slave.py @@ -1,292 +1,140 @@ -"""SPI Mode 3 byte-oriented slave for the EXI bus. +"""SPI Mode 3 byte-oriented slave for the EXI bus — CLOCKED BY EXI CLK. -CPOL=1, CPHA=1: CLK idles HIGH. -Slave samples MOSI on the FALLING CLK edge. -Slave drives MISO on the RISING CLK edge (master samples on next falling edge). -All three raw inputs are run through a 2-stage FFSynchronizer before use. +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 * -from amaranth.lib.cdc import FFSynchronizer - -# ── public re-export for import convenience ───────────────────────────────── __all__ = ["SPIMode3Slave"] class SPIMode3Slave(Elaboratable): - """Byte-oriented SPI Mode 3 slave. + """Byte-oriented SPI Mode 3 slave, clocked by the EXI clock. Ports ----- - spi_clk / spi_mosi / spi_cs_n : raw async inputs from GC (synchronized internally) - spi_miso : output to GC; idles HIGH when CS deasserted - rx_byte : last complete received byte (valid when rx_valid pulses) - rx_valid : 1-cycle pulse in exi domain when rx_byte contains a new byte - tx_byte : upstream loads this before or within one exi clock of tx_load pulsing - tx_load : 1-cycle pulse requesting the next TX byte from upstream + 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, domain="capture", mode2=False): - # Clock domain this byte engine runs in. Split-domain design puts the - # bit engine in a fast `capture` domain (54 MHz) so it can oversample - # a 27 MHz EXI clock ~3×; the register file lives in a slower domain. - self._domain = domain - # False = real Mode 3 (sample MOSI on RISING) -- what the GameCube - # needs. True = the old Mode-2 behaviour the Pi probe was built on. - self._mode2 = mode2 - + 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() # combinatorial output + self.spi_miso = Signal(init=1) self.rx_byte = Signal(8) self.rx_valid = Signal() self.tx_byte = Signal(8) self.tx_load = Signal() - # 1-cycle pulse on CS assertion (transaction start). The capture - # wrapper uses it to reset its per-transaction TX byte counter. - self.frame_start = 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) - # Level: high while CS is asserted (a transaction is in progress). - # Lets downstream logic detect variable-length (DMA) transaction ends. - self.cs_active = Signal() + # Transaction framing, generated in the slow domain by the parent. + self.frame_start = Signal() + self.cs_active = Signal() def elaborate(self, platform): m = Module() - d = self._domain - # ── Input synchronization (async → exi, 2 stages) ────────────────── - clk_s = Signal(init=1) - mosi_s = Signal() - cs_s = Signal(init=1) - - m.submodules.sync_clk = FFSynchronizer(self.spi_clk, clk_s, o_domain=d, init=1) - m.submodules.sync_mosi = FFSynchronizer(self.spi_mosi, mosi_s, o_domain=d) - m.submodules.sync_cs = FFSynchronizer(self.spi_cs_n, cs_s, o_domain=d, init=1) - - # ── Edge detection ────────────────────────────────────────────────── - clk_prev = Signal(init=1) - cs_prev = Signal(init=1) - m.d[d] += clk_prev.eq(clk_s) - m.d[d] += cs_prev.eq(cs_s) - - falling_clk = Signal() - rising_clk = Signal() - cs_fall = Signal() - cs_rise = Signal() - # ⚠️ EDGE CONVENTION. The docs call this "Mode 3 (CPOL=1, CPHA=1)" but - # then describe sampling MOSI on the FALLING edge -- that combination - # is Mode 2. Real Mode 3 samples on the SECOND edge of each cycle, - # which with an idle-high clock is the RISING one. - # - # Hardware 2026-10-03: the GameCube drives MOSI on the falling edge, so - # sampling there latches the PREVIOUS bit -- every byte arrived one bit - # late in both directions at every clock speed (header 0x80 was read as - # 0x00, and the reply came back as 04 02 02 00 >> 1 = 02 01 01 00). - # `mode2=True` restores the old behaviour, which is what the Pi probe - # on J4 was written to match. - if self._mode2: - sample_edge, shift_edge = ~clk_s & clk_prev, clk_s & ~clk_prev - else: - sample_edge, shift_edge = clk_s & ~clk_prev, ~clk_s & clk_prev - m.d.comb += falling_clk.eq(sample_edge) # "sample MOSI / advance bit" - m.d.comb += rising_clk .eq(shift_edge) # "shift MISO / re-arm" - m.d.comb += cs_fall .eq(~cs_s & cs_prev) - m.d.comb += cs_rise .eq( cs_s & ~cs_prev) - m.d.comb += self.frame_start.eq(cs_fall) - m.d.comb += self.cs_active.eq(~cs_s) - - # ── Shift registers ───────────────────────────────────────────────── + # ── 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) - bit_ctr = Signal(4) # counts 0..7; 7 means "8th (last) bit" - armed = Signal(init=1) # between bytes: drive the LIVE tx_byte MSB - rearm = Signal() # arm for next byte on the next rising edge - - # MISO: idle HIGH when CS deasserted. While "armed" — i.e. at the start - # of a byte, including the inter-byte / clock-idle gap before the first - # falling edge — drive the LIVE tx_byte MSB. This is what lets a - # response that upstream pushes DURING the EXI clock-idle gap reach MISO - # in time: there is no clock edge during the gap to latch it, so MISO - # must be combinational on tx_byte until the byte actually starts. Once - # shifting (after the first falling edge) drive the latched shift reg. - m.d.comb += self.spi_miso.eq( - Mux(cs_s, 1, Mux(armed, self.tx_byte[7], tx_shift[7])) - ) - - # Default: deassert single-cycle pulses every cycle - m.d[d] += self.rx_valid.eq(0) - m.d[d] += self.tx_load.eq(0) - - with m.If(cs_fall): - # Transaction start: first byte drives its MSB live (armed). - m.d[d] += bit_ctr.eq(0) - m.d[d] += armed.eq(1) - - with m.Elif(cs_rise | cs_s): - # CS deasserted / idle: reset state - m.d[d] += bit_ctr.eq(0) - m.d[d] += armed.eq(1) - + 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(): - # CS asserted: run bit engine - with m.If(falling_clk): - # Sample MOSI (MSB first: left-shift, new bit enters at LSB) - # Cat(a, b) → a at lower bits; so Cat(mosi, rx[6:0]) = {rx[6:0], mosi} - m.d[d] += rx_shift.eq(Cat(mosi_s, rx_shift[:-1])) + m.d.exiclk_n += tx_shift.eq(Cat(0, tx_shift[:-1])) - with m.If(armed): - # First falling edge of this byte: master has just sampled - # the MSB (driven live above). Latch tx_byte so the - # remaining 7 bits shift out of a stable register. - m.d[d] += tx_shift.eq(self.tx_byte) - m.d[d] += armed.eq(0) - - with m.If(bit_ctr == 7): - # 8th falling edge: byte complete. The master samples the - # LSB on THIS edge, so MISO must still hold tx_shift[7]. - # Defer arming to the next rising edge (rearm) so MISO is - # not switched to the next byte's live MSB too early. - m.d[d] += self.rx_byte.eq(Cat(mosi_s, rx_shift[:-1])) - m.d[d] += self.rx_valid.eq(1) - m.d[d] += bit_ctr.eq(0) - m.d[d] += self.tx_load.eq(1) # advance source to next byte - m.d[d] += rearm.eq(1) # arm on the next rising edge - with m.Else(): - m.d[d] += bit_ctr.eq(bit_ctr + 1) - - with m.If(rising_clk): - with m.If(rearm): - # Byte boundary: arm for the next byte (live MSB drive). - m.d[d] += armed.eq(1) - m.d[d] += rearm.eq(0) - with m.Elif(~armed): - # Shift left: next bit into MSB position - # Cat(0, tx[6:0]) = {tx[6:0], 0} — left shift - m.d[d] += 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 ─────────────────────────────────────────────────────────────── - -if __name__ == "__main__": - from amaranth.sim import Simulator, Period - - dut = SPIMode3Slave() - - # 4 exi ticks per SPI half-period → well above the 3-cycle (2 sync + 1 edge) latency. - HALF = 4 - - async def spi_send_byte(ctx, mosi_val, next_tx_byte=None): - """Drive one SPI Mode 3 byte on MOSI; return the MISO byte assembled. - - next_tx_byte: if given, written to tx_byte after the LAST falling edge - (before the last rising edge) so need_reload picks it up in time. - """ - miso_byte = 0 - for bit in range(7, -1, -1): - ctx.set(dut.spi_mosi, (mosi_val >> bit) & 1) - ctx.set(dut.spi_clk, 0) # falling edge - await ctx.tick("capture").repeat(HALF) - miso_byte = (miso_byte << 1) | ctx.get(dut.spi_miso) - # Set next TX byte here — after last fall, before rising edge. - # The rising edge is detected 3 cycles after we assert clk=1, - # so we have HALF ticks of margin. - if bit == 0 and next_tx_byte is not None: - ctx.set(dut.tx_byte, next_tx_byte) - ctx.set(dut.spi_clk, 1) # rising edge - await ctx.tick("capture").repeat(HALF) - return miso_byte - - errors = [] - - async def testbench(ctx): - # ── Test 1: Single byte TX/RX ────────────────────────────────────── - ctx.set(dut.spi_cs_n, 0) - ctx.set(dut.spi_clk, 1) - ctx.set(dut.tx_byte, 0xA5) # pre-load before CS fall is detected - await ctx.tick("capture").repeat(HALF) - - miso = await spi_send_byte(ctx, 0x37) - await ctx.tick("capture").repeat(2) - rx = ctx.get(dut.rx_byte) - - ctx.set(dut.spi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) - - if rx != 0x37: - errors.append(f"Test1 rx_byte: expected 0x37, got 0x{rx:02X}") - if miso != 0xA5: - errors.append(f"Test1 miso: expected 0xA5, got 0x{miso:02X}") - print(f"Test1 – MOSI→rx_byte: 0x{rx:02X} MISO←tx_byte: 0x{miso:02X}") - - await ctx.tick("capture").repeat(HALF) - - # ── Test 2: Two-byte transaction; second byte loaded via need_reload ─ - ctx.set(dut.spi_cs_n, 0) - ctx.set(dut.tx_byte, 0xBE) # first response byte - await ctx.tick("capture").repeat(HALF) - - # Pass next_tx_byte=0xEF so it's set after last falling edge of byte 0, - # giving need_reload time to load it on the subsequent rising edge. - miso0 = await spi_send_byte(ctx, 0x00, next_tx_byte=0xEF) - miso1 = await spi_send_byte(ctx, 0xFF) - - await ctx.tick("capture").repeat(2) - rx1 = ctx.get(dut.rx_byte) - - ctx.set(dut.spi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) - - if miso0 != 0xBE: - errors.append(f"Test2 miso0: expected 0xBE, got 0x{miso0:02X}") - if miso1 != 0xEF: - errors.append(f"Test2 miso1: expected 0xEF, got 0x{miso1:02X}") - if rx1 != 0xFF: - errors.append(f"Test2 rx1: expected 0xFF, got 0x{rx1:02X}") - print(f"Test2 – byte0 MISO: 0x{miso0:02X} byte1 MISO: 0x{miso1:02X} rx1: 0x{rx1:02X}") - - await ctx.tick("capture").repeat(HALF) - - # ── Test 3: MISO idles HIGH when CS deasserted ───────────────────── - miso_idle = ctx.get(dut.spi_miso) - if miso_idle != 1: - errors.append(f"Test3 MISO idle: expected 1, got {miso_idle}") - print(f"Test3 – MISO idle (CS=1): {miso_idle}") - - # ── Test 4: All-zeros byte (0x00) TX and RX ──────────────────────── - ctx.set(dut.spi_cs_n, 0) - ctx.set(dut.tx_byte, 0x00) - await ctx.tick("capture").repeat(HALF) - - miso = await spi_send_byte(ctx, 0xFF) - await ctx.tick("capture").repeat(2) - rx = ctx.get(dut.rx_byte) - ctx.set(dut.spi_cs_n, 1) - await ctx.tick("capture").repeat(HALF) - - if miso != 0x00: - errors.append(f"Test4 miso: expected 0x00, got 0x{miso:02X}") - if rx != 0xFF: - errors.append(f"Test4 rx: expected 0xFF, got 0x{rx:02X}") - print(f"Test4 – 0x00 TX / 0xFF RX: MISO=0x{miso:02X} rx=0x{rx:02X}") - - sim = Simulator(dut) - sim.add_clock(Period(MHz=54), domain="capture") - sim.add_testbench(testbench) - - with sim.write_vcd("SPIMode3Slave.vcd"): - sim.run() - - if errors: - print("\nFAILURES:") - for e in errors: - print(" ", e) - raise SystemExit(1) - else: - print("\nAll tests passed.")