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:
@@ -333,25 +333,51 @@ if __name__ == "__main__":
|
||||
# dropped descriptor byte or a frame body written one byte early
|
||||
# changed nothing it looked at.
|
||||
mem = {}
|
||||
order = []
|
||||
|
||||
# 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.
|
||||
# Polling for it afterwards misses it entirely, which first showed up
|
||||
# as a dropped frame looking like a stall. Latch both in tick().
|
||||
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):
|
||||
# A write is issued on a cycle where valid AND ready are both
|
||||
# high, so sample before the edge that completes it.
|
||||
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")
|
||||
if ctx.get(dut.rx_overflow):
|
||||
pulse["ovf"] = True
|
||||
if ctx.get(dut.rx_irq):
|
||||
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):
|
||||
pulse["irq"] = None
|
||||
pulse["ovf"] = False
|
||||
@@ -366,14 +392,23 @@ if __name__ == "__main__":
|
||||
ctx.set(dut.rx_valid, 1)
|
||||
ctx.set(dut.rx_sof, 1 if i == 0 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
|
||||
while not ctx.get(dut.rx_ready):
|
||||
await tick()
|
||||
while True:
|
||||
apply_ready()
|
||||
if ctx.get(dut.rx_ready):
|
||||
break
|
||||
await tick_only()
|
||||
guard += 1
|
||||
if guard > 400:
|
||||
errors.append("send_frame: rx_ready never asserted")
|
||||
if guard > 5000:
|
||||
errors.append(f"send_frame[{tag[0]}] byte {i}: "
|
||||
f"rx_ready never asserted")
|
||||
return
|
||||
await tick()
|
||||
await tick_only()
|
||||
ctx.set(dut.rx_valid, 0)
|
||||
ctx.set(dut.rx_sof, 0)
|
||||
ctx.set(dut.rx_eof, 0)
|
||||
@@ -405,13 +440,13 @@ if __name__ == "__main__":
|
||||
base = (page << 8) + 4
|
||||
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_enabled, 1)
|
||||
await tick(2)
|
||||
|
||||
# ── T1: short frame at page 1 ────────────────────────────────────
|
||||
f1 = [0xAA, 0xBB, 0xCC, 0xDD, 0x08, 0x00, 0x45, 0x00, 0x00, 0x01]
|
||||
tag[0] = "T1"
|
||||
await send_frame(f1)
|
||||
check("T1 rwp advance", await wait_irq(), 2)
|
||||
total1 = len(f1) + 4
|
||||
@@ -432,6 +467,7 @@ if __name__ == "__main__":
|
||||
# the ring. Every earlier test used a short frame, where a dropped
|
||||
# high byte is indistinguishable from a correct zero. ───────────────
|
||||
f2 = [(i * 7 + 3) & 0xFF for i in range(300)]
|
||||
tag[0] = "T2"
|
||||
await send_frame(f2)
|
||||
rwp2 = await wait_irq()
|
||||
total2 = len(f2) + 4
|
||||
@@ -454,12 +490,14 @@ if __name__ == "__main__":
|
||||
ctx.set(dut.rrp, 4) # GC has consumed up to page 4
|
||||
await tick(2)
|
||||
f3 = [(i * 3 + 1) & 0xFF for i in range(300)] # 2 pages each
|
||||
tag[0] = "T4-first"
|
||||
await send_frame(f3)
|
||||
check("T4 first frame stored", await wait_done(), "stored")
|
||||
page4 = descriptor(4) + body(4, 32)
|
||||
|
||||
outcomes = []
|
||||
for _ in range(12):
|
||||
tag[0] = "T4-fill"
|
||||
await send_frame(f3)
|
||||
outcomes.append(await wait_done())
|
||||
if outcomes[-1] == "dropped":
|
||||
@@ -475,11 +513,34 @@ if __name__ == "__main__":
|
||||
# needs: free = (rrp - rwp - 1) mod 15, so rrp = 8 gives 8 free.
|
||||
ctx.set(dut.rrp, 8)
|
||||
await tick(4)
|
||||
tag[0] = "T4-again"
|
||||
await send_frame(f3)
|
||||
check("T4 accepts again after RRP advances", await wait_done(), "stored")
|
||||
|
||||
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 ────────────────────────
|
||||
before = len(mem)
|
||||
ctx.set(dut.rx_enabled, 0)
|
||||
|
||||
Reference in New Issue
Block a user