2aa50a7cc3
Hardware: "timeout: no match (0 seen, 0 bad desc, 0 lapped)". Zero frames
reached the ring at all -- a regression from 83a62d6, which reset the REGISTER
FILE's copy of RWP on an NCRA reset but not RXFrameAssembler's own `rwp`.
They are two copies of the same pointer. Resetting one leaves them
disagreeing about where the ring is: a driver that syncs RRP to the register
file's value (page 1) points somewhere the writer is not (wherever it left
off), the free-space sum shrinks with every frame, and once it falls below
_MAX_FRAME_PAGES the new overflow protection drops EVERY frame. Silently,
because a dropped frame never reaches the ring to be counted -- hence 0 seen
rather than an error.
So fixing one half of a two-copy pointer created exactly the inconsistency the
other half had just been fixed for. That is the third time in this session
that two copies of one piece of state has caused a hardware fault: the W5100's
init, then RRP, now RWP. When adding state that the GC can also observe,
reset BOTH copies from the same event or do not keep two.
The reset is placed AFTER the FSM so it takes priority over ADVANCE_RWP in the
same cycle. No in-flight frame needs abandoning: the same NCRA write that
pulses ncra_rst also clears the SR bit, so rx_enabled is low and the FSM is
already in IDLE.
T6 covers it and is mutation-tested. Without the reset the frame lands on the
wrong page entirely and carries a previous frame's bytes:
rwp back to page 1: got 4, want 2
descriptor: got [0,0,1,48], want [0,0,0,44]
body index 0: got 9, want 192
--seeds 8: clk 28.9-30.8 MHz, EXI 61.0-65.7 MHz, 8/8 PASS. Best seed 2
(clk 30.83, EXI 65.72), flashed. Gateware only -- the .dol is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
619 lines
28 KiB
Python
619 lines
28 KiB
Python
"""RX frame assembler — sync domain (24 MHz).
|
||
|
||
Receives raw ethernet frames from W5500SPIMaster and writes them into the SPRAM
|
||
ring buffer in MX98730EC format.
|
||
|
||
Ring buffer layout (SPRAM byte addresses)
|
||
------------------------------------------
|
||
0x0100–0x0FFF 15 pages × 256 bytes = 3840 bytes
|
||
Pages 0x01–0x0F; page 0x00 is reserved.
|
||
Page wrap: after 0x0F → 0x01 (skip 0x00).
|
||
|
||
Frame descriptor (4 bytes at page start)
|
||
-----------------------------------------------
|
||
Byte 0: LRPS (last received packet status) — 0x00
|
||
Byte 1: 0x00
|
||
Byte 2: total_length[15:8] (big-endian; includes 4 descriptor bytes)
|
||
Byte 3: total_length[7:0]
|
||
Bytes 4+: raw ethernet frame
|
||
|
||
Write sequence
|
||
--------------
|
||
1. Issue 4 SPRAM writes of 0x00 (placeholder descriptor).
|
||
2. For each byte received from W5500, issue one SPRAM write.
|
||
3. After EOF: rewrite descriptor bytes 2 and 3 with actual length.
|
||
4. Advance RWP, push to rx_wptr FIFO, pulse rx_irq.
|
||
"""
|
||
|
||
from amaranth import *
|
||
|
||
__all__ = ["RXFrameAssembler"]
|
||
|
||
_RX_PAGE_FIRST = 0x01
|
||
_RX_PAGE_LAST = 0x0F
|
||
_PAGES_TOTAL = _RX_PAGE_LAST - _RX_PAGE_FIRST + 1 # 15
|
||
# Worst-case pages one frame can occupy: 1518 + 4 descriptor bytes -> 6 pages.
|
||
_MAX_FRAME_PAGES = 6
|
||
|
||
|
||
class RXFrameAssembler(Elaboratable):
|
||
"""Writes incoming ethernet frames into the SPRAM ring buffer.
|
||
|
||
W5500 streaming interface (sync domain)
|
||
----------------------------------------
|
||
rx_data / rx_valid / rx_ready : byte stream
|
||
rx_sof / rx_eof : frame delimiters (same cycle as rx_valid)
|
||
|
||
SPRAM write interface (to SPRAMArbiter, sync domain)
|
||
-----------------------------------------------------
|
||
eth_wr_addr / eth_wr_data / eth_wr_valid / eth_wr_ready
|
||
|
||
CDC outputs (wired by BBATop)
|
||
-----------------------------
|
||
rx_wptr_w_data / rx_wptr_w_en / rx_wptr_w_rdy
|
||
rx_irq : 1-cycle pulse → PulseSynchronizer input
|
||
rx_enabled : controlled by NCRA SR bit (from BBARegisterFile)
|
||
"""
|
||
|
||
def __init__(self):
|
||
# W5500 stream in
|
||
self.rx_data = Signal(8)
|
||
self.rx_valid = Signal()
|
||
self.rx_ready = Signal()
|
||
self.rx_sof = Signal()
|
||
self.rx_eof = Signal()
|
||
|
||
# SPRAM write out
|
||
self.eth_wr_addr = Signal(16)
|
||
self.eth_wr_data = Signal(8)
|
||
self.eth_wr_valid = Signal()
|
||
self.eth_wr_ready = Signal()
|
||
|
||
# RWP FIFO write-side (sync→exi)
|
||
self.rx_wptr_w_data = Signal(8)
|
||
self.rx_wptr_w_en = Signal()
|
||
self.rx_wptr_w_rdy = Signal()
|
||
|
||
# rx_irq pulse (→ PulseSynchronizer)
|
||
self.rx_irq = Signal()
|
||
|
||
# RX gate from NCRA SR bit
|
||
self.rx_enabled = Signal()
|
||
|
||
# ── Ring-overflow protection (RBFI) ───────────────────────────────
|
||
# The GC's read pointer, so we can tell how much of the ring is still
|
||
# unread. Without it this module overwrote pages the GC had not read
|
||
# yet: a traffic burst corrupted whatever frame the driver was midway
|
||
# through, with no indication that it had happened.
|
||
self.rrp = Signal(8, init=_RX_PAGE_FIRST)
|
||
# Pulses when a frame had to be DROPPED because the ring was full.
|
||
# Drives IR[7] (RBFI), which the GC's IMR enables (0x86).
|
||
self.rx_overflow = Signal()
|
||
|
||
# The GC's NCRA reset. MUST reset `rwp` here as well as the register
|
||
# file's copy: they are two copies of the same pointer, and resetting
|
||
# only one leaves them disagreeing about where the ring is. A driver
|
||
# that then syncs RRP to the register file's value is pointing
|
||
# somewhere the writer is not, the free-space calculation shrinks with
|
||
# every frame, and once it falls below _MAX_FRAME_PAGES EVERY frame is
|
||
# dropped -- silently, because a dropped frame never reaches the ring
|
||
# to be counted. Hardware symptom: "0 seen, 0 bad desc, 0 lapped".
|
||
self.ncra_rst = Signal()
|
||
|
||
def elaborate(self, platform):
|
||
m = Module()
|
||
|
||
# ── Ring-buffer state ─────────────────────────────────────────────
|
||
rwp = Signal(8, init=_RX_PAGE_FIRST) # current RX write page (1–15)
|
||
|
||
# Write address within current frame
|
||
wr_addr = Signal(16)
|
||
# Number of frame data bytes received
|
||
data_ctr = Signal(12)
|
||
# Total length = data_ctr + 4
|
||
total_len = Signal(12)
|
||
|
||
# Descriptor base (rwp*256) — saved when frame starts
|
||
desc_base = Signal(16)
|
||
|
||
# Placeholder descriptor byte counter (0..3)
|
||
desc_ctr = Signal(2)
|
||
|
||
# Number of pages consumed by this frame (rounded up)
|
||
pages_used = Signal(5)
|
||
|
||
# Default: no pulses
|
||
m.d.sync += self.rx_irq.eq(0)
|
||
m.d.sync += self.rx_wptr_w_en.eq(0)
|
||
m.d.sync += self.rx_overflow.eq(0)
|
||
|
||
# ── How many pages may we still consume? ──────────────────────────
|
||
# rwp == rrp means EMPTY, so writing all 15 pages would wrap back to
|
||
# "empty" and lose the lot. Usable = (rrp - rwp - 1) mod 15, which is
|
||
# 14 when the ring is empty.
|
||
rrp_s = Signal(4)
|
||
m.d.comb += rrp_s.eq(Mux((self.rrp >= _RX_PAGE_FIRST)
|
||
& (self.rrp <= _RX_PAGE_LAST),
|
||
self.rrp, _RX_PAGE_FIRST))
|
||
raw = Signal(5)
|
||
free = Signal(5)
|
||
with m.If(rrp_s >= rwp):
|
||
m.d.comb += raw.eq(rrp_s - rwp)
|
||
with m.Else():
|
||
m.d.comb += raw.eq(rrp_s + _PAGES_TOTAL - rwp)
|
||
m.d.comb += free.eq(Mux(raw == 0, _PAGES_TOTAL - 1, raw - 1))
|
||
|
||
# A frame's length is not known until EOF, so reserve the worst case:
|
||
# 1518 bytes + a 4-byte descriptor is 6 pages. Starting a frame with
|
||
# less room than that risks running into unread data partway through,
|
||
# which is worse than refusing it outright.
|
||
# REGISTERED, not combinational. The free-space calculation is a
|
||
# mod-15 subtract plus a comparator, and feeding that into the FSM's
|
||
# branch condition cost real depth in `sync` -- the domain with the
|
||
# least margin in this design (clk fell 27.8-32.2 -> 27.0-28.0 MHz
|
||
# when this was comb). One cycle of staleness is harmless: rwp only
|
||
# moves at end-of-frame and rrp only when the GC writes it, so both
|
||
# are many cycles old by the time the next SOF arrives.
|
||
have_room = Signal()
|
||
m.d.sync += have_room.eq(free >= _MAX_FRAME_PAGES)
|
||
|
||
# Combinatorial outputs
|
||
m.d.comb += total_len.eq(data_ctr + 4)
|
||
|
||
with m.FSM(domain="sync", name="rx_fsm"):
|
||
|
||
with m.State("IDLE"):
|
||
m.d.comb += self.rx_ready.eq(0)
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
with m.If(self.rx_valid & self.rx_sof & self.rx_enabled
|
||
& ~have_room):
|
||
# Ring full: discard this frame rather than overwrite data
|
||
# the GC has not read. This is what the real BBA's RBFI
|
||
# reports, and what this module previously lacked.
|
||
m.next = "DROP_FRAME"
|
||
with m.Elif(self.rx_valid & self.rx_sof & self.rx_enabled):
|
||
frame_base = Signal(16)
|
||
m.d.comb += frame_base.eq(Cat(Const(0, 8), rwp))
|
||
m.d.sync += desc_base.eq(frame_base)
|
||
m.d.sync += wr_addr.eq(frame_base)
|
||
m.d.sync += data_ctr.eq(0)
|
||
m.d.sync += desc_ctr.eq(0)
|
||
m.next = "WRITE_PLACEHOLDER"
|
||
|
||
with m.State("DROP_FRAME"):
|
||
# Consume and discard to EOF. rx_ready MUST stay high here:
|
||
# stalling the ethernet master instead would back pressure into
|
||
# the W5100's own buffer and turn a dropped frame into a wedged
|
||
# receiver.
|
||
m.d.comb += self.rx_ready.eq(1)
|
||
with m.If(self.rx_valid & self.rx_eof):
|
||
m.d.sync += self.rx_overflow.eq(1)
|
||
m.next = "IDLE"
|
||
|
||
with m.State("WRITE_PLACEHOLDER"):
|
||
# Write 4 bytes of 0x00 as placeholder descriptor.
|
||
#
|
||
# ⚠️ Advance on `valid & ready`, NOT on `ready` alone.
|
||
# `eth_wr_valid` is scheduled with m.d.sync and so is LOW for
|
||
# the whole first cycle in this state, while `ready` is tested
|
||
# immediately -- and `ready` is usually already high here.
|
||
# Testing `ready` alone therefore counted a write that was
|
||
# never issued: only THREE placeholder bytes went out and
|
||
# wr_addr ended at desc_base+3, so the frame body started at
|
||
# descriptor byte 3 and EVERY RECEIVED FRAME WAS SHIFTED ONE
|
||
# BYTE EARLY.
|
||
#
|
||
# Hardware symptom (2026-10-09): the ring filled normally and
|
||
# RWP advanced, but the GC's ethertype test read the wrong
|
||
# offset and silently dropped every frame -- "no ACK" from the
|
||
# host with no other sign of trouble. The matching
|
||
# descriptor bug (same cause, in WRITE_LEN_HI) showed up as a
|
||
# descriptor of 00 00 00 01.
|
||
#
|
||
# The first byte is written twice as a consequence of
|
||
# eth_wr_addr trailing wr_addr by a cycle, and desc_base+3 is
|
||
# left unwritten -- both harmless, because every descriptor
|
||
# byte is either zero anyway or overwritten by WRITE_LEN_*.
|
||
# What matters is that wr_addr ends at desc_base+4.
|
||
m.d.sync += self.eth_wr_addr .eq(wr_addr)
|
||
m.d.sync += self.eth_wr_data .eq(0x00)
|
||
m.d.sync += self.eth_wr_valid.eq(1)
|
||
with m.If(self.eth_wr_valid & self.eth_wr_ready):
|
||
m.d.sync += wr_addr.eq(wr_addr + 1)
|
||
with m.If(desc_ctr == 3):
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
m.next = "RECV_AND_WRITE"
|
||
with m.Else():
|
||
m.d.sync += desc_ctr.eq(desc_ctr + 1)
|
||
|
||
with m.State("RECV_AND_WRITE"):
|
||
# Accept bytes from W5500 and write each to SPRAM immediately
|
||
m.d.comb += self.rx_ready.eq(~self.eth_wr_valid | self.eth_wr_ready)
|
||
with m.If(self.rx_valid & (~self.eth_wr_valid | self.eth_wr_ready)):
|
||
m.d.sync += self.eth_wr_addr.eq(wr_addr)
|
||
m.d.sync += self.eth_wr_data.eq(self.rx_data)
|
||
m.d.sync += self.eth_wr_valid.eq(1)
|
||
m.d.sync += wr_addr.eq(wr_addr + 1)
|
||
m.d.sync += data_ctr.eq(data_ctr + 1)
|
||
with m.If(self.rx_eof):
|
||
m.next = "WAIT_LAST_WRITE"
|
||
with m.Elif(self.eth_wr_valid & self.eth_wr_ready):
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
|
||
with m.State("WAIT_LAST_WRITE"):
|
||
# Wait for the last data byte write to be accepted
|
||
with m.If(~self.eth_wr_valid | self.eth_wr_ready):
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
# Compute pages used: ceil((data_ctr + 4) / 256)
|
||
# = (total_len + 255) >> 8 = total_len[11:8] + (total_len[7:0] != 0)
|
||
m.d.sync += pages_used.eq(total_len[8:12] + (total_len[:8] != 0))
|
||
m.next = "WRITE_LEN_HI"
|
||
|
||
# ⚠️ Both length writes must advance on `valid & ready`, NOT on
|
||
# `ready` alone. `valid` is scheduled with m.d.sync and so is low
|
||
# for the whole first cycle in the state, while `ready` is tested
|
||
# immediately -- and coming out of WAIT_LAST_WRITE `ready` is
|
||
# usually already high. Testing `ready` alone therefore fired both
|
||
# valid.eq(1) and valid.eq(0) in the same cycle, the later won, the
|
||
# write was never issued, and the length byte was SILENTLY DROPPED.
|
||
#
|
||
# Hardware symptom (2026-10-09): a descriptor of 00 00 00 01 --
|
||
# byte 2 still at its placeholder 0x00 because the HI write was
|
||
# dropped -- so the GC read a 1-byte frame length and rejected the
|
||
# ring. Intermittent by nature: it depends on whether the arbiter
|
||
# happens to hold `ready` high on the entry cycle, which is why
|
||
# bring-up's 342-byte DHCP frame read a correct 00 00 01 5A and
|
||
# this went unnoticed.
|
||
with m.State("WRITE_LEN_HI"):
|
||
# Overwrite descriptor byte 2 with total_len[15:8]
|
||
m.d.sync += self.eth_wr_addr.eq(desc_base + 2)
|
||
m.d.sync += self.eth_wr_data.eq(total_len[8:12])
|
||
m.d.sync += self.eth_wr_valid.eq(1)
|
||
with m.If(self.eth_wr_valid & self.eth_wr_ready):
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
m.next = "WRITE_LEN_LO"
|
||
|
||
with m.State("WRITE_LEN_LO"):
|
||
# Overwrite descriptor byte 3 with total_len[7:0]
|
||
m.d.sync += self.eth_wr_addr.eq(desc_base + 3)
|
||
m.d.sync += self.eth_wr_data.eq(total_len[:8])
|
||
m.d.sync += self.eth_wr_valid.eq(1)
|
||
with m.If(self.eth_wr_valid & self.eth_wr_ready):
|
||
m.d.sync += self.eth_wr_valid.eq(0)
|
||
m.next = "ADVANCE_RWP"
|
||
|
||
with m.State("ADVANCE_RWP"):
|
||
# next_rwp = ((rwp - 1 + pages_used) % 15) + 1
|
||
next_rwp_raw = Signal(8)
|
||
m.d.comb += next_rwp_raw.eq(rwp + pages_used)
|
||
with m.If(next_rwp_raw > _RX_PAGE_LAST):
|
||
m.d.sync += rwp.eq(next_rwp_raw - _PAGES_TOTAL)
|
||
with m.Else():
|
||
m.d.sync += rwp.eq(next_rwp_raw)
|
||
m.next = "PUSH_WPT"
|
||
|
||
with m.State("PUSH_WPT"):
|
||
with m.If(self.rx_wptr_w_rdy):
|
||
m.d.sync += self.rx_wptr_w_data.eq(rwp)
|
||
m.d.sync += self.rx_wptr_w_en.eq(1)
|
||
m.d.sync += self.rx_irq.eq(1)
|
||
m.next = "IDLE"
|
||
|
||
# ── NCRA reset: the write pointer goes back to the ring start ──────
|
||
# Placed AFTER the FSM so it takes priority over ADVANCE_RWP in the
|
||
# same cycle.
|
||
#
|
||
# This MUST happen here as well as in the register file: they are two
|
||
# copies of the same pointer, and resetting only one leaves them
|
||
# disagreeing about where the ring is. A driver that then syncs RRP to
|
||
# the register file's value points somewhere the writer is not, the
|
||
# free-space calculation shrinks with every frame, and once it drops
|
||
# below _MAX_FRAME_PAGES EVERY frame is dropped -- silently, because a
|
||
# dropped frame never reaches the ring to be counted. Hardware
|
||
# symptom: "timeout: no match (0 seen, 0 bad desc, 0 lapped)".
|
||
#
|
||
# No in-flight frame needs abandoning: the same NCRA write that pulses
|
||
# this also clears the SR bit, so rx_enabled is low and the FSM is in
|
||
# IDLE.
|
||
with m.If(self.ncra_rst):
|
||
m.d.sync += rwp.eq(_RX_PAGE_FIRST)
|
||
|
||
return m
|
||
|
||
|
||
# ── Testbench ─────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
from amaranth.sim import Simulator, Period
|
||
|
||
dut = RXFrameAssembler()
|
||
errors = []
|
||
|
||
def brief(v):
|
||
"""Long byte lists are unreadable in a pass/fail line; show the ends
|
||
and the length, and keep the full value only for a failure."""
|
||
if isinstance(v, list) and len(v) > 8:
|
||
head = ", ".join(str(x) for x in v[:4])
|
||
tail = ", ".join(str(x) for x in v[-2:])
|
||
return f"[{head}, ... {tail}] ({len(v)} bytes)"
|
||
return str(v)
|
||
|
||
def check(name, got, want):
|
||
ok = got == want
|
||
print(f" {'ok ' if ok else 'FAIL'} {name}: "
|
||
f"got {brief(got)}, want {brief(want)}")
|
||
if not ok:
|
||
if isinstance(got, list) and isinstance(want, list):
|
||
first = next((i for i, (g, w) in enumerate(zip(got, want))
|
||
if g != w), None)
|
||
if first is not None:
|
||
print(f" first difference at index {first}: "
|
||
f"got {got[first]}, want {want[first]}")
|
||
errors.append(name)
|
||
|
||
async def testbench(ctx):
|
||
# Final contents of every SPRAM address the DUT wrote.
|
||
#
|
||
# The old testbench declared a writes list and never filled it, and
|
||
# drove the RX stream OPEN-LOOP -- one byte per tick, never checking
|
||
# rx_ready. Both of the 2026-10-09 handshake bugs were therefore
|
||
# invisible to it: it only ever asserted on rwp and rx_irq, so a
|
||
# 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]
|
||
|
||
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):
|
||
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
|
||
"""Push a frame RESPECTING BACK-PRESSURE, the way the real
|
||
ethernet master does. rx_ready is LOW through IDLE and the whole
|
||
placeholder-descriptor write, so a master that advances anyway
|
||
simply loses those bytes -- which is what the old testbench did,
|
||
and why it could not see the frame body land at the wrong
|
||
offset."""
|
||
for i, b in enumerate(frame):
|
||
ctx.set(dut.rx_data, b)
|
||
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 True:
|
||
apply_ready()
|
||
if ctx.get(dut.rx_ready):
|
||
break
|
||
await tick_only()
|
||
guard += 1
|
||
if guard > 5000:
|
||
errors.append(f"send_frame[{tag[0]}] byte {i}: "
|
||
f"rx_ready never asserted")
|
||
return
|
||
await tick_only()
|
||
ctx.set(dut.rx_valid, 0)
|
||
ctx.set(dut.rx_sof, 0)
|
||
ctx.set(dut.rx_eof, 0)
|
||
|
||
async def wait_irq(limit=200):
|
||
for _ in range(limit):
|
||
if pulse["irq"] is not None:
|
||
return pulse["irq"]
|
||
await tick()
|
||
return None
|
||
|
||
async def wait_done(limit=400):
|
||
"""Wait for a frame to be either stored (rx_irq) or discarded
|
||
(rx_overflow), reading the latches so a pulse that fired during
|
||
send_frame is not lost."""
|
||
for _ in range(limit):
|
||
if pulse["ovf"]:
|
||
return "dropped"
|
||
if pulse["irq"] is not None:
|
||
return "stored"
|
||
await tick()
|
||
return "timeout"
|
||
|
||
def descriptor(page):
|
||
base = page << 8
|
||
return [mem.get(base + i) for i in range(4)]
|
||
|
||
def body(page, n):
|
||
base = (page << 8) + 4
|
||
return [mem.get(base + i) for i in range(n)]
|
||
|
||
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
|
||
check("T1 descriptor", descriptor(1),
|
||
[0x00, 0x00, (total1 >> 8) & 0xFF, total1 & 0xFF])
|
||
# The body MUST start at base+4. This is the check that catches a
|
||
# placeholder write being dropped: with only three placeholder bytes
|
||
# written the body landed at base+3 and every frame was shifted one
|
||
# byte early, which on hardware made the GC drop every frame.
|
||
check("T1 body at base+4", body(1, len(f1)), f1)
|
||
|
||
await tick(4)
|
||
|
||
# ── T2: frame LONGER THAN 255 BYTES, so the descriptor's HIGH length
|
||
# byte is non-zero. This is the case that catches a dropped
|
||
# WRITE_LEN_HI: with the high byte left at its placeholder 0x00 the GC
|
||
# read an impossible length (hardware saw 00 00 00 01) and rejected
|
||
# 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
|
||
check("T2 descriptor (len > 255)", descriptor(2),
|
||
[0x00, 0x00, (total2 >> 8) & 0xFF, total2 & 0xFF])
|
||
check("T2 body at base+4", body(2, len(f2)), f2)
|
||
# 304 bytes spans two pages, so page 2 -> page 4.
|
||
check("T2 rwp advance (2 pages)", rwp2, 4)
|
||
|
||
await tick(4)
|
||
|
||
# ── T4: ring overflow must DROP, not overwrite ───────────────────
|
||
# rrp is held still, as if the GC had stopped reading. The writer
|
||
# must refuse new frames once the ring is nearly full and leave the
|
||
# unread ones intact. Before this existed the writer simply lapped
|
||
# the reader, so a traffic burst corrupted whatever frame the driver
|
||
# was midway through -- with no indication it had happened. That is
|
||
# what produced a "descriptor" of 00 00 00 01 on hardware: frame data
|
||
# from the frame that had replaced the one being read.
|
||
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":
|
||
break
|
||
check("T4 ring eventually refuses a frame",
|
||
outcomes[-1] if outcomes else "none", "dropped")
|
||
# THE property: the frame the GC has not read yet is untouched.
|
||
check("T4 unread page preserved", descriptor(4) + body(4, 32), page4)
|
||
|
||
# Once the GC catches up, frames must be accepted again -- a full ring
|
||
# must not latch the receiver off. The writer is at page 14 here, so
|
||
# rrp must move well past it to free the 6 pages a worst-case frame
|
||
# 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)
|
||
|
||
# ── T6: NCRA reset must return the WRITE pointer to page 1 ───────
|
||
# The register file resets its own copy of RWP on an NCRA reset; if
|
||
# this module does not reset its own, the two disagree and the GC ends
|
||
# up pointing somewhere the writer is not. The free-space sum then
|
||
# shrinks with every frame until every frame is dropped -- silently,
|
||
# which on hardware read as "0 seen, 0 bad desc, 0 lapped".
|
||
ctx.set(dut.rrp, 1)
|
||
await tick(2)
|
||
ctx.set(dut.ncra_rst, 1)
|
||
await tick(1)
|
||
ctx.set(dut.ncra_rst, 0)
|
||
await tick(2)
|
||
f6 = [0xC0 | (i & 0x0F) for i in range(40)]
|
||
await send_frame(f6)
|
||
check("T6 rwp back to page 1 after NCRA reset", await wait_irq(), 2)
|
||
total6 = len(f6) + 4
|
||
check("T6 frame lands at page 1", descriptor(1),
|
||
[0x00, 0x00, (total6 >> 8) & 0xFF, total6 & 0xFF])
|
||
check("T6 body at page 1 base+4", body(1, len(f6)), f6)
|
||
|
||
await tick(4)
|
||
|
||
# ── T3: RX disabled — SOF must be ignored ────────────────────────
|
||
before = len(mem)
|
||
ctx.set(dut.rx_enabled, 0)
|
||
ctx.set(dut.rx_data, 0xDE)
|
||
ctx.set(dut.rx_valid, 1)
|
||
ctx.set(dut.rx_sof, 1)
|
||
await tick(8)
|
||
ctx.set(dut.rx_valid, 0)
|
||
ctx.set(dut.rx_sof, 0)
|
||
check("T3 no writes while rx disabled", len(mem), before)
|
||
|
||
sim = Simulator(dut)
|
||
sim.add_clock(Period(MHz=24), domain="sync")
|
||
sim.add_testbench(testbench)
|
||
|
||
with sim.write_vcd("RXFrameAssembler.vcd"):
|
||
sim.run()
|
||
|
||
if errors:
|
||
print("\nFAILURES:")
|
||
for e in errors:
|
||
print(" -", e)
|
||
sys.exit(1)
|
||
print("\nAll tests passed.")
|