Model write-port stalls in the assembler testbench (T5)

eth_wr_ready was held constantly HIGH by every test this file ever had, which
is a serious blind spot: on hardware the SPRAM arbiter stalls that port
constantly, because the GC reads the ring over EXI while frames are written
into it.  Any address bookkeeping that depends on ready timing is invisible
until ready actually goes low.

T5 repeats T2's 300-byte frame with ready asserted 1 cycle in 3 and requires
the descriptor and body offset to be IDENTICAL to the unstalled case.

Result: contention does NOT reproduce the one-byte body offset seen on
hardware.  Under stalls the descriptor is still [0,0,1,48] and all 300 body
bytes land at base+4.  So that hypothesis is eliminated, which was the point
of writing it.

Two harness faults found and fixed while building it, both of which produced
false results rather than no result:

  - rx_irq and rx_overflow are 1-cycle pulses, and rx_overflow fires on the
    very tick that consumes the EOF byte -- inside send_frame.  Polling for it
    afterwards missed it entirely, so a correctly DROPPED frame looked like a
    stall.  Both are now latched in the tick helper.
  - send_frame read rx_ready BEFORE tick() applied the next eth_wr_ready.
    rx_ready is combinational on it, so the loop was a cycle out of phase with
    the accept the DUT actually made: it waited on a byte already taken, and
    reported "rx_ready never asserted" for a frame that had stored correctly.
    Ready is now applied, then read, then clocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:18:04 +02:00
parent 92f7d716d9
commit f69b4d084a
+69 -8
View File
@@ -333,25 +333,51 @@ if __name__ == "__main__":
# dropped descriptor byte or a frame body written one byte early # dropped descriptor byte or a frame body written one byte early
# changed nothing it looked at. # changed nothing it looked at.
mem = {} mem = {}
order = []
# rx_irq and rx_overflow are 1-CYCLE pulses, and rx_overflow fires on # rx_irq and rx_overflow are 1-CYCLE pulses, and rx_overflow fires on
# the very tick that consumes the EOF byte -- i.e. inside send_frame. # the very tick that consumes the EOF byte -- i.e. inside send_frame.
# Polling for it afterwards misses it entirely, which first showed up # Polling for it afterwards misses it entirely, which first showed up
# as a dropped frame looking like a stall. Latch both in tick(). # as a dropped frame looking like a stall. Latch both in tick().
pulse = {"irq": None, "ovf": False} pulse = {"irq": None, "ovf": False}
# eth_wr_ready stall pattern. Held constantly HIGH by every test this
# file ever had, which is a serious blind spot: on hardware the SPRAM
# arbiter stalls the write port constantly, because the GC is reading
# the ring over EXI while frames are being written into it. Any
# address bookkeeping that depends on ready timing is invisible until
# ready actually goes low. stall[0] = assert ready 1 cycle in N.
stall = [1]
cyc = [0]
async def tick(n=1): def apply_ready():
"""Apply eth_wr_ready for the cycle about to be clocked. It must
be applied BEFORE rx_ready is read: rx_ready is combinational on
it, so reading first and applying second puts the harness a cycle
out of phase with the accept the DUT actually makes."""
cyc[0] += 1
ctx.set(dut.eth_wr_ready, 1 if cyc[0] % stall[0] == 0 else 0)
async def tick_only(n=1):
for _ in range(n): for _ in range(n):
# A write is issued on a cycle where valid AND ready are both # A write is issued on a cycle where valid AND ready are both
# high, so sample before the edge that completes it. # high, so sample before the edge that completes it.
if ctx.get(dut.eth_wr_valid) and ctx.get(dut.eth_wr_ready): if ctx.get(dut.eth_wr_valid) and ctx.get(dut.eth_wr_ready):
mem[ctx.get(dut.eth_wr_addr)] = ctx.get(dut.eth_wr_data) a, d = ctx.get(dut.eth_wr_addr), ctx.get(dut.eth_wr_data)
mem[a] = d
order.append((a, d))
await ctx.tick("sync") await ctx.tick("sync")
if ctx.get(dut.rx_overflow): if ctx.get(dut.rx_overflow):
pulse["ovf"] = True pulse["ovf"] = True
if ctx.get(dut.rx_irq): if ctx.get(dut.rx_irq):
pulse["irq"] = ctx.get(dut.rx_wptr_w_data) pulse["irq"] = ctx.get(dut.rx_wptr_w_data)
async def tick(n=1):
for _ in range(n):
apply_ready()
await tick_only()
tag = ["?"]
async def send_frame(frame): async def send_frame(frame):
pulse["irq"] = None pulse["irq"] = None
pulse["ovf"] = False pulse["ovf"] = False
@@ -366,14 +392,23 @@ if __name__ == "__main__":
ctx.set(dut.rx_valid, 1) ctx.set(dut.rx_valid, 1)
ctx.set(dut.rx_sof, 1 if i == 0 else 0) ctx.set(dut.rx_sof, 1 if i == 0 else 0)
ctx.set(dut.rx_eof, 1 if i == len(frame) - 1 else 0) ctx.set(dut.rx_eof, 1 if i == len(frame) - 1 else 0)
# Apply ready, THEN read rx_ready, THEN clock -- so the
# value the DUT decides on is the one this loop checked.
# Polling before applying had the loop waiting on a byte the
# DUT had already accepted: the frame stored correctly while
# send_frame reported rx_ready never asserted.
guard = 0 guard = 0
while not ctx.get(dut.rx_ready): while True:
await tick() apply_ready()
if ctx.get(dut.rx_ready):
break
await tick_only()
guard += 1 guard += 1
if guard > 400: if guard > 5000:
errors.append("send_frame: rx_ready never asserted") errors.append(f"send_frame[{tag[0]}] byte {i}: "
f"rx_ready never asserted")
return return
await tick() await tick_only()
ctx.set(dut.rx_valid, 0) ctx.set(dut.rx_valid, 0)
ctx.set(dut.rx_sof, 0) ctx.set(dut.rx_sof, 0)
ctx.set(dut.rx_eof, 0) ctx.set(dut.rx_eof, 0)
@@ -405,13 +440,13 @@ if __name__ == "__main__":
base = (page << 8) + 4 base = (page << 8) + 4
return [mem.get(base + i) for i in range(n)] return [mem.get(base + i) for i in range(n)]
ctx.set(dut.eth_wr_ready, 1)
ctx.set(dut.rx_wptr_w_rdy, 1) ctx.set(dut.rx_wptr_w_rdy, 1)
ctx.set(dut.rx_enabled, 1) ctx.set(dut.rx_enabled, 1)
await tick(2) await tick(2)
# ── T1: short frame at page 1 ──────────────────────────────────── # ── T1: short frame at page 1 ────────────────────────────────────
f1 = [0xAA, 0xBB, 0xCC, 0xDD, 0x08, 0x00, 0x45, 0x00, 0x00, 0x01] f1 = [0xAA, 0xBB, 0xCC, 0xDD, 0x08, 0x00, 0x45, 0x00, 0x00, 0x01]
tag[0] = "T1"
await send_frame(f1) await send_frame(f1)
check("T1 rwp advance", await wait_irq(), 2) check("T1 rwp advance", await wait_irq(), 2)
total1 = len(f1) + 4 total1 = len(f1) + 4
@@ -432,6 +467,7 @@ if __name__ == "__main__":
# the ring. Every earlier test used a short frame, where a dropped # the ring. Every earlier test used a short frame, where a dropped
# high byte is indistinguishable from a correct zero. ─────────────── # high byte is indistinguishable from a correct zero. ───────────────
f2 = [(i * 7 + 3) & 0xFF for i in range(300)] f2 = [(i * 7 + 3) & 0xFF for i in range(300)]
tag[0] = "T2"
await send_frame(f2) await send_frame(f2)
rwp2 = await wait_irq() rwp2 = await wait_irq()
total2 = len(f2) + 4 total2 = len(f2) + 4
@@ -454,12 +490,14 @@ if __name__ == "__main__":
ctx.set(dut.rrp, 4) # GC has consumed up to page 4 ctx.set(dut.rrp, 4) # GC has consumed up to page 4
await tick(2) await tick(2)
f3 = [(i * 3 + 1) & 0xFF for i in range(300)] # 2 pages each f3 = [(i * 3 + 1) & 0xFF for i in range(300)] # 2 pages each
tag[0] = "T4-first"
await send_frame(f3) await send_frame(f3)
check("T4 first frame stored", await wait_done(), "stored") check("T4 first frame stored", await wait_done(), "stored")
page4 = descriptor(4) + body(4, 32) page4 = descriptor(4) + body(4, 32)
outcomes = [] outcomes = []
for _ in range(12): for _ in range(12):
tag[0] = "T4-fill"
await send_frame(f3) await send_frame(f3)
outcomes.append(await wait_done()) outcomes.append(await wait_done())
if outcomes[-1] == "dropped": if outcomes[-1] == "dropped":
@@ -475,11 +513,34 @@ if __name__ == "__main__":
# needs: free = (rrp - rwp - 1) mod 15, so rrp = 8 gives 8 free. # needs: free = (rrp - rwp - 1) mod 15, so rrp = 8 gives 8 free.
ctx.set(dut.rrp, 8) ctx.set(dut.rrp, 8)
await tick(4) await tick(4)
tag[0] = "T4-again"
await send_frame(f3) await send_frame(f3)
check("T4 accepts again after RRP advances", await wait_done(), "stored") check("T4 accepts again after RRP advances", await wait_done(), "stored")
await tick(4) await tick(4)
# ── T5: the same frame WITH THE WRITE PORT STALLING ──────────────
# Models arbiter contention: ready asserted only 1 cycle in 3. The
# descriptor and body offset must be identical to the unstalled case.
# This is the case hardware runs in and no previous test ever did --
# hardware reported a frame whose body was one byte late while
# simulation with ready held high was byte-perfect.
ctx.set(dut.rrp, 1)
await tick(2)
stall[0] = 3
f5 = [(i * 5 + 9) & 0xFF for i in range(300)]
tag[0] = "T5"
await send_frame(f5)
rwp5 = await wait_done()
check("T5 stored despite stalls", rwp5, "stored")
pg5 = (ctx.get(dut.rx_wptr_w_data) - 2) & 0xFF # page it was written to
total5 = len(f5) + 4
check("T5 descriptor under stall", descriptor(pg5),
[0x00, 0x00, (total5 >> 8) & 0xFF, total5 & 0xFF])
check("T5 body at base+4 under stall", body(pg5, len(f5)), f5)
stall[0] = 1
await tick(4)
# ── T3: RX disabled — SOF must be ignored ──────────────────────── # ── T3: RX disabled — SOF must be ignored ────────────────────────
before = len(mem) before = len(mem)
ctx.set(dut.rx_enabled, 0) ctx.set(dut.rx_enabled, 0)