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
+26 -22
View File
@@ -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)