WIP rewrite: clock the EXI bit engine from the EXI clock pin

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>
This commit is contained in:
2026-10-03 16:47:42 +02:00
parent d95f2147a4
commit a3b3e052bc
4 changed files with 203 additions and 378 deletions
+61 -87
View File
@@ -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)