Files
rebbarb/exi_bba/uart_shell.py
T
Roflin c16afb6eea Add integrated UART UDP bring-up shell (socket 3) + capture critical-path fix
An interactive UART command shell drives a UDP send/receive test on the
W5100's socket 3, running ALONGSIDE the live BBA (socket-0 MACRAW) with
EXI keeping bus priority. MACRAW+UDP coexistence is W5100S-datasheet
confirmed (S4.6 + "4 independent SOCKETs"). Now the default flash build
(--console selects the old event-log console).

New / changed gateware:
- uart_shell.py (new): rebbarb> shell over FT2232H channel B. Commands:
  help; udp unicast <ip> [msg]; udp broadcast [msg]. After each send it
  waits (bounded, else "timeout") for a reply and prints "rx <payload>".
  Line buffer + message ROM live in block RAM with a sequential parser
  (LC-efficient); 1-deep RX holding reg keeps pastes intact. 8 sim tests.
- w5100_parallel_master.py: configurable UDP socket (default 3) with UDP
  send AND receive (IP-stack init, runtime dest IP, WIZnet UDP RX header
  + payload). Gated by enable_udp_test so the MACRAW path is unchanged
  when off. Tests U1-U4 + MACRAW T1-T5.
- exi_capture.py: CAPTURE-DOMAIN CRITICAL-PATH FIX. The TX byte-FIFO
  read-enable was gated by its own gray-coded ready
  (r_en = ... | (flushing & r_rdy)), forming a consume_ptr -> gray ->
  r_rdy -> flush -> r_en -> consume_ptr loop that capped capture_clk.
  Replaced the r_rdy-based "drain until empty" flush with a fixed-length
  drain counter (FIFO is only tx_depth deep), removing the pointer
  feedback from r_en. Path 24.3 -> 19.6 ns; flush behavior preserved.
- bba_top.py: wire shell <-> W5100 UDP (send + rx); shell additive.
- synth.py: shell default build; env-var UDP network config; documents a
  reverted PNR-timing-priority experiment.

Timing (--seeds 8, default shell build, 67% LC): capture closes on 4/8
seeds (best seed 4 = 58.36 MHz, +8%), clk passes on all. This is BETTER
than the pre-shell 2/8 baseline because the flush fix improved the
capture domain intrinsically. Flash build/seed4/top.bin.

Bring-up caveats (unchanged): W5100 socket register addresses / UDP
header format are datasheet-derived (confirm on hardware); UDP_SRC_IP /
subnet / gateway must match the LAN for unicast ARP.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:36:47 +00:00

758 lines
34 KiB
Python

"""Interactive UART command shell (sync domain, 24 MHz).
A tiny line-oriented console for bring-up over the FT2232H channel-B UART
(115200 8N1). Prints a ``rebbarb> `` prompt, echoes typed characters (with
backspace editing), and on Enter parses one command:
help list commands
udp unicast <ip> [msg] send a UDP datagram to <ip>:DST_PORT (W5100 ARPs)
udp broadcast [msg] send a UDP datagram to 255.255.255.255:DST_PORT
`msg` is optional; when omitted a built-in default payload is sent. The shell
drives the W5100 master's runtime UDP-send interface (`udp_send_req`,
`udp_dst_ip`, and the `udp_pl_*` payload stream) — it does not touch the MACRAW
BBA path.
Grammar notes (it's a debug shell, not bash):
* commands and keywords are case-insensitive;
* tokens are separated by exactly one space;
* the destination is parsed as a dotted IPv4 literal;
* everything after the address token (unicast) / keyword (broadcast) is the
raw payload, preserving case and embedded spaces, to end of line.
On the PC:
udp unicast → nc -ul 6464 (or: sudo tcpdump -n udp port 6464)
udp broadcast → nc -ul 6464 (limited broadcast reaches the segment)
"""
from amaranth import *
from amaranth.lib.cdc import FFSynchronizer
from amaranth.lib.memory import Memory
__all__ = ["UARTShell"]
# ── Printable message ROM ──────────────────────────────────────────────────
_MSGS = {
"BANNER": b"\r\nre-bba-rb ethernet test\r\n",
"PROMPT": b"rebbarb> ",
"HELP": (b"commands:\r\n"
b" help show this\r\n"
b" udp unicast <ip> [msg] send UDP to <ip>\r\n"
b" udp broadcast [msg] send UDP broadcast\r\n"),
"SENT": b"sent\r\n",
"ERR": b"? (try 'help')\r\n",
"BADIP": b"bad ip\r\n",
"BUSY": b"busy\r\n",
"CRLF": b"\r\n",
"BKSP": b"\b \b",
"RXPFX": b"rx ",
"TIMEOUT": b"timeout\r\n",
}
_ORDER = list(_MSGS)
_ROM = b"".join(_MSGS[k] for k in _ORDER)
_OFF = {}
_acc = 0
for _k in _ORDER:
_OFF[_k] = (_acc, _acc + len(_MSGS[_k]))
_acc += len(_MSGS[_k])
_DEFAULT_PAYLOAD = b"rebbarb-udp-test"
# return codes for the shared print routine
_RET_PROMPT = 0 # after printing, (re)issue the prompt then read input
_RET_INPUT = 1 # after printing, go straight back to reading input
_RET_PARSE = 2 # after printing (the CR echo), parse the line
_RET_RXWAIT = 3 # after printing, wait for a UDP reply (with timeout)
_RET_RXBODY = 4 # after printing "rx ", stream the reply payload
class UARTShell(Elaboratable):
def __init__(self, clk_freq=24_000_000, baud_rate=115_200,
default_payload=_DEFAULT_PAYLOAD, lbuf_len=64,
reply_timeout_cycles=24_000_000, poll_gap_cycles=24_000):
self._div = round(clk_freq / baud_rate)
self._defpl = list(default_payload)
self._lbuf_n = lbuf_len
# After a send, wait this many sync cycles for a reply before giving up
# (default ~1 s at 24 MHz) so the console never hangs; poll the socket
# RX every `poll_gap_cycles` (~1 ms) in between.
self._to_cyc = reply_timeout_cycles
self._gap_cyc = poll_gap_cycles
# UART pins
self.uart_tx = Signal(init=1)
self.uart_rx = Signal(init=1)
# W5100 UDP-send interface (drive the W5100ParallelMaster)
self.udp_send_req = Signal()
self.udp_dst_ip = Signal(32)
self.udp_pl_data = Signal(8)
self.udp_pl_valid = Signal()
self.udp_pl_last = Signal()
self.udp_pl_ready = Signal()
self.udp_test_busy = Signal()
# W5100 UDP-receive interface (poll for a reply after each send)
self.udp_rx_req = Signal()
self.udp_rx_busy = Signal()
self.udp_rx_none = Signal()
self.udp_rx_data = Signal(8)
self.udp_rx_valid = Signal()
self.udp_rx_eof = Signal()
self.udp_rx_ready = Signal()
def elaborate(self, platform):
m = Module()
div = self._div
N = self._lbuf_n
# Message ROM lives in a block RAM (SB_RAM40_4K) instead of LUTs — the
# ~230-byte string table was the single biggest LUT consumer (a wide
# addressed mux). PRINT already streams one byte per UART-byte-time, so
# the block RAM's 1-cycle read latency is hidden (one prime cycle on
# entry). The tiny default payload stays in logic (cheap, and keeps the
# payload path combinational — no streaming-latency hazard).
m.submodules.rom_mem = rom_mem = Memory(
shape=unsigned(8), depth=len(_ROM), init=list(_ROM))
rom_rd = rom_mem.read_port() # synchronous (block RAM)
defrom = Array([Const(b, 8) for b in self._defpl])
DEF_LEN = len(self._defpl)
# ── UART TX (8N1) ─────────────────────────────────────────────────
tx_cnt = Signal(range(div))
tx_bits = Signal(range(11))
tx_shreg = Signal(10, init=0b1111111111)
tx_busy = Signal()
tx_load = Signal()
tx_byte = Signal(8)
m.d.comb += [tx_busy.eq(tx_bits != 0), self.uart_tx.eq(tx_shreg[0])]
with m.If(tx_load & ~tx_busy):
m.d.sync += [tx_shreg.eq(Cat(Const(0, 1), tx_byte, Const(1, 1))),
tx_bits.eq(10), tx_cnt.eq(div - 1)]
with m.Elif(tx_busy):
with m.If(tx_cnt == 0):
m.d.sync += [tx_shreg.eq(Cat(tx_shreg[1:], Const(1, 1))),
tx_bits.eq(tx_bits - 1), tx_cnt.eq(div - 1)]
with m.Else():
m.d.sync += tx_cnt.eq(tx_cnt - 1)
# ── UART RX (8N1, mid-bit sampling) ───────────────────────────────
rx_sync = Signal(init=1)
rx_prev = Signal(init=1)
rx_cnt = Signal(range(div))
rx_bits = Signal(range(9))
rx_shr = Signal(8)
rx_byte = Signal(8)
rx_valid = Signal()
m.submodules += FFSynchronizer(self.uart_rx, rx_sync, init=1)
m.d.sync += rx_valid.eq(0)
with m.FSM(name="rx"):
with m.State("IDLE"):
m.d.sync += rx_prev.eq(rx_sync)
with m.If(rx_prev & ~rx_sync):
m.d.sync += rx_cnt.eq(div // 2 - 1)
m.next = "START"
with m.State("START"):
with m.If(rx_cnt == 0):
with m.If(~rx_sync):
m.d.sync += [rx_cnt.eq(div - 1), rx_bits.eq(7)]
m.next = "DATA"
with m.Else():
m.next = "IDLE"
with m.Else():
m.d.sync += rx_cnt.eq(rx_cnt - 1)
with m.State("DATA"):
with m.If(rx_cnt == 0):
m.d.sync += [rx_shr.eq(Cat(rx_shr[1:], rx_sync)),
rx_cnt.eq(div - 1)]
with m.If(rx_bits == 0):
m.next = "STOP"
with m.Else():
m.d.sync += rx_bits.eq(rx_bits - 1)
with m.Else():
m.d.sync += rx_cnt.eq(rx_cnt - 1)
with m.State("STOP"):
with m.If(rx_cnt == 0):
with m.If(rx_sync):
m.d.sync += [rx_byte.eq(rx_shr), rx_valid.eq(1)]
m.next = "IDLE"
with m.Else():
m.d.sync += rx_cnt.eq(rx_cnt - 1)
# 1-deep RX holding register: decouples byte capture from the shell's
# echo/print activity so a character arriving while the shell is busy
# echoing (or printing a short response) isn't dropped. This keeps a
# back-to-back paste of a command line intact — the incoming and echo
# rates match, so at most one byte is ever in flight during an echo.
# (A byte arriving during a long print, e.g. 'help' output, can still
# be lost, but you don't type into a response.)
rx_pending = Signal()
rx_hold = Signal(8)
with m.If(rx_valid):
m.d.sync += [rx_hold.eq(rx_byte), rx_pending.eq(1)]
# ── Line buffer (block RAM) ───────────────────────────────────────
# Stored in an SB_RAM40 rather than flip-flops; the parser reads it
# SEQUENTIALLY (one byte per cycle) through a single registered read
# port. This is what lets the 64-byte buffer AND its wide read muxes
# leave the LC fabric (they were the dominant cost). `la` drives the
# read address; `lbuf_rd.data` is valid the cycle after `la` settles.
m.submodules.lbuf_mem = lbuf_mem = Memory(
shape=unsigned(8), depth=N, init=[])
lbuf_wr = lbuf_mem.write_port()
lbuf_rd = lbuf_mem.read_port()
llen = Signal(range(N + 1))
la = Signal(range(N))
m.d.comb += lbuf_rd.addr.eq(la)
m.d.comb += la.eq(0) # default; read states override per-cycle
# write port: addr/data always presented; a store pulses `en` in INPUT.
m.d.comb += [lbuf_wr.addr.eq(llen), lbuf_wr.data.eq(rx_hold),
lbuf_wr.en.eq(0)]
def low(c):
return Mux((c >= ord('A')) & (c <= ord('Z')), c | 0x20, c)
# Command templates, matched sequentially in the SCAN state. Padded to
# a common length so a runtime index is always in range (guarded by the
# per-template length so padding bytes are never actually compared).
_TH, _TU, _TB, _TA = "help", "udp unicast ", "udp broadcast", "udp bcast"
_TMAX = 14
def _tmpl(s):
return Array([Const(ord(c), 8) for c in s.ljust(_TMAX, "\x00")])
THarr, TUarr, TBarr, TAarr = _tmpl(_TH), _tmpl(_TU), _tmpl(_TB), _tmpl(_TA)
# ── Shared print routine ──────────────────────────────────────────
pr_ptr = Signal(range(len(_ROM) + 1))
pr_end = Signal(range(len(_ROM) + 1))
pr_ret = Signal(3)
to_ctr = Signal(range(self._to_cyc + 1)) # reply timeout countdown
gap = Signal(range(self._gap_cyc + 1)) # inter-poll gap countdown
# ROM read port is addressed by the print pointer (1-cycle latency).
m.d.comb += rom_rd.addr.eq(pr_ptr)
def start_print(msg_key, ret, nxt="PRINT_PRIME"):
s, e = _OFF[msg_key]
m.d.sync += [pr_ptr.eq(s), pr_end.eq(e), pr_ret.eq(ret)]
m.next = nxt
# ── Parse / send scratch ──────────────────────────────────────────
ip_b = Array([Signal(8, name=f"ip{i}") for i in range(4)])
ip_pos = Signal(range(N + 1))
octet = Signal(9)
noct = Signal(3)
kwend = Signal(range(N + 1))
use_def = Signal()
req_r = Signal()
ech = Signal(8) # character currently being echoed
# sequential command scan
si = Signal(range(_TMAX + 2)) # scan index
mh, muni, mbc, mbca = (Signal(init=1), Signal(init=1),
Signal(init=1), Signal(init=1))
dh, dbc, dbca = Signal(), Signal(), Signal() # delimiter-ok captures
# payload streaming (prefetch: `la` runs one byte ahead of pl_byte)
pl_base = Signal(range(N + 1))
pl_len = Signal(range(N + 1))
j = Signal(range(N + 1))
pl_byte = Signal(8)
m.d.comb += [self.udp_send_req.eq(req_r),
self.udp_dst_ip.eq(Cat(ip_b[3], ip_b[2], ip_b[1], ip_b[0])),
self.udp_pl_data.eq(pl_byte)]
tx_load_i = Signal()
tx_byte_i = Signal(8)
m.d.comb += [tx_load.eq(tx_load_i), tx_byte.eq(tx_byte_i)]
# ── Shell FSM ─────────────────────────────────────────────────────
with m.FSM(name="shell"):
with m.State("BOOT"):
start_print("BANNER", _RET_PROMPT)
# One prime cycle so the block RAM read (rom_rd.data = rom[pr_ptr])
# is valid before the first byte is loaded.
with m.State("PRINT_PRIME"):
m.next = "PRINT"
# Generic ROM printer → dispatch on pr_ret. rom_rd.data holds
# rom[pr_ptr] (address is comb-driven; stable across the tx-busy
# wait, so it is valid whenever ~tx_busy lets a byte load).
with m.State("PRINT"):
with m.If(~tx_busy):
with m.If(pr_ptr == pr_end):
with m.Switch(pr_ret):
with m.Case(_RET_PROMPT):
m.next = "PROMPT"
with m.Case(_RET_INPUT):
m.next = "INPUT"
with m.Case(_RET_PARSE):
m.next = "PARSE"
with m.Case(_RET_RXWAIT):
m.next = "RX_INIT"
with m.Case(_RET_RXBODY):
m.next = "RX_BODY"
with m.Else():
m.d.comb += [tx_load_i.eq(1), tx_byte_i.eq(rom_rd.data)]
m.d.sync += pr_ptr.eq(pr_ptr + 1)
with m.State("PROMPT"):
m.d.sync += llen.eq(0)
start_print("PROMPT", _RET_INPUT)
# Read + echo a line into lbuf until CR/LF.
with m.State("INPUT"):
with m.If(rx_pending):
m.d.sync += rx_pending.eq(0)
with m.If((rx_hold == 0x0D) | (rx_hold == 0x0A)):
start_print("CRLF", _RET_PARSE)
with m.Elif((rx_hold == 0x08) | (rx_hold == 0x7F)):
with m.If(llen != 0):
m.d.sync += llen.eq(llen - 1)
start_print("BKSP", _RET_INPUT)
with m.Elif((rx_hold >= 0x20) & (rx_hold < 0x7F)):
with m.If(llen != N):
m.d.comb += lbuf_wr.en.eq(1) # store at addr=llen
m.d.sync += [llen.eq(llen + 1), ech.eq(rx_hold)]
m.next = "ECHO"
# Echo one stored character.
with m.State("ECHO"):
with m.If(~tx_busy):
m.d.comb += [tx_load_i.eq(1), tx_byte_i.eq(ech)]
m.next = "INPUT"
# Decide which command the line holds — set up the sequential scan.
with m.State("PARSE"):
with m.If(llen == 0):
m.next = "PROMPT"
with m.Else():
m.d.sync += [si.eq(0), mh.eq(1), muni.eq(1),
mbc.eq(1), mbca.eq(1), dh.eq(0),
dbc.eq(0), dbca.eq(0)]
m.next = "SCAN_RD"
# Scan the first up-to-14 bytes, comparing each against all four
# command templates in parallel (one byte/cycle from block RAM).
with m.State("SCAN_RD"):
m.d.comb += la.eq(si) # address the current byte
m.next = "SCAN_USE"
with m.State("SCAN_USE"):
m.d.comb += la.eq(si)
c = lbuf_rd.data
lc = low(c)
inb = si < llen # byte position is within the line
with m.If(si < len(_TH)):
m.d.sync += mh.eq(mh & inb & (lc == THarr[si]))
with m.If(si == len(_TH)):
m.d.sync += dh.eq((si >= llen) | (c == ord(' ')))
with m.If(si < len(_TU)):
m.d.sync += muni.eq(muni & inb & (lc == TUarr[si]))
with m.If(si < len(_TB)):
m.d.sync += mbc.eq(mbc & inb & (lc == TBarr[si]))
with m.If(si == len(_TB)):
m.d.sync += dbc.eq((si >= llen) | (c == ord(' ')))
with m.If(si < len(_TA)):
m.d.sync += mbca.eq(mbca & inb & (lc == TAarr[si]))
with m.If(si == len(_TA)):
m.d.sync += dbca.eq((si >= llen) | (c == ord(' ')))
with m.If(si == len(_TB)): # scanned enough to decide
m.next = "EVAL"
with m.Else():
m.d.sync += si.eq(si + 1)
m.next = "SCAN_RD"
# Classify from the accumulated match/delimiter bits (same priority
# and semantics as the old parallel parser).
with m.State("EVAL"):
with m.If(mh & dh):
start_print("HELP", _RET_PROMPT)
with m.Elif(muni): # "udp unicast " prefix present
m.d.sync += [ip_pos.eq(len(_TU)), octet.eq(0), noct.eq(0),
ip_b[0].eq(0), ip_b[1].eq(0),
ip_b[2].eq(0), ip_b[3].eq(0)]
m.next = "IP_RD"
with m.Elif(mbc & dbc):
m.d.sync += [kwend.eq(len(_TB)),
ip_b[0].eq(0xFF), ip_b[1].eq(0xFF),
ip_b[2].eq(0xFF), ip_b[3].eq(0xFF)]
m.next = "BC_RD"
with m.Elif(mbca & dbca):
m.d.sync += [kwend.eq(len(_TA)),
ip_b[0].eq(0xFF), ip_b[1].eq(0xFF),
ip_b[2].eq(0xFF), ip_b[3].eq(0xFF)]
m.next = "BC_RD"
with m.Else():
start_print("ERR", _RET_PROMPT)
# Sequential dotted-quad IPv4 parse from lbuf[ip_pos ...].
with m.State("IP_RD"):
m.d.comb += la.eq(ip_pos)
m.next = "IP_USE"
with m.State("IP_USE"):
m.d.comb += la.eq(ip_pos)
c = lbuf_rd.data
with m.If((ip_pos == llen) | (c == ord(' '))):
# end of address token → commit final octet + payload bounds
with m.If((noct == 3) & (octet <= 255)):
m.d.sync += ip_b[3].eq(octet)
with m.If(ip_pos == llen):
m.d.sync += use_def.eq(1) # no payload
with m.Else(): # c == ' '
m.d.sync += [pl_base.eq(ip_pos + 1),
pl_len.eq(llen - (ip_pos + 1)),
use_def.eq((ip_pos + 1) >= llen)]
m.next = "SEND_SETUP"
with m.Else():
start_print("BADIP", _RET_PROMPT)
with m.Elif(c == ord('.')):
with m.If((noct < 3) & (octet <= 255)):
m.d.sync += [ip_b[noct].eq(octet), noct.eq(noct + 1),
octet.eq(0), ip_pos.eq(ip_pos + 1)]
m.next = "IP_RD"
with m.Else():
start_print("BADIP", _RET_PROMPT)
with m.Elif((c >= ord('0')) & (c <= ord('9'))):
m.d.sync += [octet.eq(octet * 10 + (c - ord('0'))),
ip_pos.eq(ip_pos + 1)]
m.next = "IP_RD"
with m.Else():
start_print("BADIP", _RET_PROMPT)
# Broadcast payload: everything after the keyword's trailing space.
with m.State("BC_RD"):
m.d.comb += la.eq(kwend)
m.next = "BC_USE"
with m.State("BC_USE"):
m.d.comb += la.eq(kwend)
with m.If((llen > kwend) & (lbuf_rd.data == ord(' '))):
m.d.sync += [pl_base.eq(kwend + 1),
pl_len.eq(llen - (kwend + 1)),
use_def.eq((kwend + 1) >= llen)]
with m.Else():
m.d.sync += use_def.eq(1)
m.next = "SEND_SETUP"
with m.State("SEND_SETUP"):
with m.If(self.udp_test_busy):
start_print("BUSY", _RET_PROMPT)
with m.Else():
with m.If(use_def):
m.d.sync += [pl_base.eq(0), pl_len.eq(DEF_LEN)]
m.d.sync += j.eq(0)
m.next = "PL_PRIME"
# Prime the prefetch register with payload byte 0 (block RAM read
# for typed payload; the default payload is combinational).
with m.State("PL_PRIME"):
m.d.comb += la.eq(pl_base) # fetch typed byte 0
m.next = "PL_PRIME2"
with m.State("PL_PRIME2"):
m.d.comb += la.eq(pl_base)
m.d.sync += pl_byte.eq(Mux(use_def, defrom[0], lbuf_rd.data))
m.next = "SEND_REQ"
# Hold the request until the W5100 acknowledges by going busy.
with m.State("SEND_REQ"):
m.d.comb += la.eq(pl_base + j + 1) # prefetch next byte
m.d.sync += req_r.eq(1)
with m.If(self.udp_test_busy):
m.d.sync += req_r.eq(0)
m.next = "SEND_STREAM"
# Feed payload bytes as the W5100 consumes them. `pl_byte` holds
# the current byte; `la` prefetches the next so it is ready by the
# time the master pulls it (block RAM 1-cycle latency hidden).
with m.State("SEND_STREAM"):
m.d.comb += la.eq(pl_base + j + 1)
m.d.comb += [self.udp_pl_valid.eq(1),
self.udp_pl_last.eq(j + 1 == pl_len)]
with m.If(self.udp_pl_ready):
with m.If(j + 1 == pl_len):
m.next = "SEND_WAIT"
with m.Else():
m.d.sync += [pl_byte.eq(Mux(use_def, defrom[j + 1],
lbuf_rd.data)),
j.eq(j + 1)]
with m.State("SEND_WAIT"):
with m.If(~self.udp_test_busy):
# datagram is out; now wait (bounded) for a reply.
start_print("SENT", _RET_RXWAIT)
# ── Wait for a UDP reply on the socket, with a timeout ────────────
with m.State("RX_INIT"):
m.d.sync += to_ctr.eq(self._to_cyc)
m.next = "RX_POLL"
# Ask the W5100 to check the socket RX buffer.
with m.State("RX_POLL"):
m.d.comb += self.udp_rx_req.eq(1)
with m.If(self.udp_rx_busy):
m.next = "RX_WAIT"
# One of: a datagram streams (udp_rx_valid) or none (udp_rx_none).
with m.State("RX_WAIT"):
with m.If(self.udp_rx_valid):
start_print("RXPFX", _RET_RXBODY)
with m.Elif(self.udp_rx_none):
m.d.sync += gap.eq(self._gap_cyc)
m.next = "RX_GAP"
# Idle a bit between polls; count down the overall timeout.
with m.State("RX_GAP"):
with m.If(to_ctr == 0):
start_print("TIMEOUT", _RET_PROMPT)
with m.Elif(gap == 0):
m.next = "RX_POLL"
with m.Else():
m.d.sync += [gap.eq(gap - 1), to_ctr.eq(to_ctr - 1)]
# Stream the reply payload to the UART (throttled by tx_busy).
with m.State("RX_BODY"):
with m.If(self.udp_rx_valid & ~tx_busy):
m.d.comb += [tx_load_i.eq(1), tx_byte_i.eq(self.udp_rx_data),
self.udp_rx_ready.eq(1)]
with m.If(self.udp_rx_eof):
m.next = "RX_BODY_END"
with m.State("RX_BODY_END"):
start_print("CRLF", _RET_PROMPT)
return m
# ── Testbench ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
from amaranth.sim import Simulator, Period
# Bit period is irrelevant to the shell logic, so use a tiny divisor to
# keep the simulation fast; the real build uses 24 MHz / 115200 (div≈208).
# Small reply timeout/gap so the timeout test finishes quickly.
CLK, BAUD = 24_000_000, 3_000_000
DIV = round(CLK / BAUD) # = 8 sim cycles per bit
dut = UARTShell(clk_freq=CLK, baud_rate=BAUD,
reply_timeout_cycles=4000, poll_gap_cycles=300)
errors = []
# ── UART line helpers (drive uart_rx, sample uart_tx) ──────────────────
async def send_byte(ctx, val):
ctx.set(dut.uart_rx, 0)
await ctx.tick().repeat(DIV)
for i in range(8):
ctx.set(dut.uart_rx, (val >> i) & 1)
await ctx.tick().repeat(DIV)
ctx.set(dut.uart_rx, 1)
await ctx.tick().repeat(DIV)
async def send_line(ctx, s):
for ch in s:
await send_byte(ctx, ord(ch))
await send_byte(ctx, 0x0D) # Enter
# Background collector: continuously samples uart_tx into a byte list.
tx_chars = bytearray()
async def tx_collector(ctx):
while True:
# wait for start bit
if ctx.get(dut.uart_tx) == 0:
await ctx.tick().repeat(DIV // 2)
if ctx.get(dut.uart_tx) != 0:
continue
b = 0
for i in range(8):
await ctx.tick().repeat(DIV)
b |= ctx.get(dut.uart_tx) << i
await ctx.tick().repeat(DIV) # stop
tx_chars.append(b)
else:
await ctx.tick()
# Background responder: models the W5100 UDP interface for BOTH directions.
# On udp_send_req it drains + records the payload (send handshake); on
# udp_rx_req it either streams a staged reply datagram or pulses udp_rx_none
# (nothing waiting) — mirroring the real master's poll-driven RX.
captured = {"payload": None, "dst": None, "count": 0}
staged = {"reply": None} # bytes to deliver on the next RX poll
async def w5100_udp_model(ctx):
while True:
await ctx.tick()
if ctx.get(dut.udp_send_req):
ctx.set(dut.udp_test_busy, 1)
dst = ctx.get(dut.udp_dst_ip)
await ctx.tick().repeat(6)
pl = bytearray(); last = False; guard = 0
while not last:
ctx.set(dut.udp_pl_ready, 1)
got = ctx.get(dut.udp_pl_valid)
if got:
pl.append(ctx.get(dut.udp_pl_data))
last = bool(ctx.get(dut.udp_pl_last))
await ctx.tick()
if got:
# Space consumes ~5 cycles apart, like the W5100's bus
# cycle — the shell's block-RAM prefetch needs ≥1 cycle
# between pulls to present the next byte.
ctx.set(dut.udp_pl_ready, 0)
await ctx.tick().repeat(4)
guard += 1
if guard > 5000: break
ctx.set(dut.udp_pl_ready, 0)
await ctx.tick().repeat(6)
captured["payload"] = bytes(pl)
captured["dst"] = [(dst >> 24) & 0xFF, (dst >> 16) & 0xFF,
(dst >> 8) & 0xFF, dst & 0xFF]
captured["count"] += 1
ctx.set(dut.udp_test_busy, 0)
elif ctx.get(dut.udp_rx_req):
ctx.set(dut.udp_rx_busy, 1)
await ctx.tick().repeat(4) # emulate RSR/RD/header latency
rep = staged["reply"]
if rep is None:
ctx.set(dut.udp_rx_none, 1)
await ctx.tick()
ctx.set(dut.udp_rx_none, 0)
else:
staged["reply"] = None
for i, b in enumerate(rep):
ctx.set(dut.udp_rx_data, b)
ctx.set(dut.udp_rx_valid, 1)
ctx.set(dut.udp_rx_eof, 1 if i == len(rep) - 1 else 0)
g = 0
while not ctx.get(dut.udp_rx_ready):
await ctx.tick(); g += 1
if g > 20000: break
await ctx.tick() # consume cycle
ctx.set(dut.udp_rx_valid, 0)
ctx.set(dut.udp_rx_eof, 0)
ctx.set(dut.udp_rx_busy, 0)
async def wait_prompt(ctx, timeout=400_000):
"""Wait until the tail of tx_chars ends with 'rebbarb> '."""
for _ in range(timeout):
if tx_chars.endswith(b"rebbarb> "):
return True
await ctx.tick()
return False
async def testbench(ctx):
ctx.set(dut.uart_rx, 1)
# udp_pl_ready / udp_test_busy are owned by w5100_udp_model — do not
# drive them here (two testbenches on one signal deadlocks the send).
# Boot banner + first prompt.
if not await wait_prompt(ctx):
errors.append("no initial prompt"); return
print(f"boot tx: {bytes(tx_chars)!r}")
# T1: 'help' lists commands.
tx_chars.clear()
await send_line(ctx, "help")
await wait_prompt(ctx)
if b"udp unicast" not in tx_chars or b"udp broadcast" not in tx_chars:
errors.append(f"T1 help missing commands: {bytes(tx_chars)!r}")
print(f"T1 help ok ({len(tx_chars)} bytes)")
# T2: unicast with payload, and a staged reply → 'rx <reply>' printed.
tx_chars.clear(); captured["count"] = 0
staged["reply"] = b"pong-A"
await send_line(ctx, "udp unicast 192.168.1.55 hello world")
await wait_prompt(ctx)
if captured["dst"] != [192, 168, 1, 55]:
errors.append(f"T2 dst {captured['dst']} != [192,168,1,55]")
if captured["payload"] != b"hello world":
errors.append(f"T2 payload {captured['payload']!r} != b'hello world'")
if b"sent" not in tx_chars:
errors.append(f"T2 no 'sent' ack: {bytes(tx_chars)!r}")
if b"rx pong-A" not in tx_chars:
errors.append(f"T2 reply not printed: {bytes(tx_chars)!r}")
print(f"T2 unicast+reply: dst={captured['dst']} "
f"payload={captured['payload']!r} tx={bytes(tx_chars)!r}")
# T3: broadcast default payload, NO reply → 'timeout' printed (no hang).
tx_chars.clear(); staged["reply"] = None
await send_line(ctx, "udp broadcast")
await wait_prompt(ctx)
if captured["dst"] != [255, 255, 255, 255]:
errors.append(f"T3 dst {captured['dst']} != broadcast")
if captured["payload"] != bytes(_DEFAULT_PAYLOAD):
errors.append(f"T3 payload {captured['payload']!r} != default")
if b"timeout" not in tx_chars:
errors.append(f"T3 no timeout on no-reply: {bytes(tx_chars)!r}")
print(f"T3 broadcast default→timeout: dst={captured['dst']} "
f"payload={captured['payload']!r}")
# T4: broadcast with payload + reply.
tx_chars.clear(); staged["reply"] = b"Hello World"
await send_line(ctx, "udp broadcast ping123")
await wait_prompt(ctx)
if captured["payload"] != b"ping123":
errors.append(f"T4 payload {captured['payload']!r} != b'ping123'")
if b"rx Hello World" not in tx_chars:
errors.append(f"T4 reply not printed: {bytes(tx_chars)!r}")
print(f"T4 broadcast+reply: payload={captured['payload']!r} "
f"tx={bytes(tx_chars)!r}")
# T5: case-insensitive command + uppercase preserved in payload.
tx_chars.clear(); staged["reply"] = b"ok"
await send_line(ctx, "UDP UNICAST 10.0.0.9 MixedCase")
await wait_prompt(ctx)
if captured["dst"] != [10, 0, 0, 9]:
errors.append(f"T5 dst {captured['dst']} != [10,0,0,9]")
if captured["payload"] != b"MixedCase":
errors.append(f"T5 payload {captured['payload']!r} != b'MixedCase'")
print(f"T5 case: dst={captured['dst']} payload={captured['payload']!r}")
# T6: bad IP → 'bad ip', no send.
tx_chars.clear(); before = captured["count"]
await send_line(ctx, "udp unicast 1.2.3 x")
await wait_prompt(ctx)
if b"bad ip" not in tx_chars:
errors.append(f"T6 no 'bad ip': {bytes(tx_chars)!r}")
if captured["count"] != before:
errors.append("T6 sent despite bad ip")
print(f"T6 bad ip ok (no send)")
# T7: unknown command → error.
tx_chars.clear()
await send_line(ctx, "frobnicate")
await wait_prompt(ctx)
if b"?" not in tx_chars:
errors.append(f"T7 no error marker: {bytes(tx_chars)!r}")
print(f"T7 unknown ok")
# T8: backspace editing (type 'helX' <bs> 'p' → 'help'). The backspace
# echo is 3 bytes ("\b \b"), longer than one incoming byte time, so a
# realistic source pauses after it (a human always does). Settle
# between edits — this is the one spot the 1-deep RX register can't
# absorb, and it never occurs with paste (no backspaces) or typing.
tx_chars.clear()
for ch in "helX":
await send_byte(ctx, ord(ch))
await ctx.tick().repeat(DIV * 15) # > 1-byte echo (10 bits)
await send_byte(ctx, 0x08) # backspace removes 'X'
await ctx.tick().repeat(DIV * 40) # > 3-byte "\b \b" echo (~30 bits)
await send_byte(ctx, ord('p'))
await ctx.tick().repeat(DIV * 15)
await send_byte(ctx, 0x0D)
await wait_prompt(ctx)
if b"udp unicast" not in tx_chars:
errors.append(f"T8 backspace edit failed: {bytes(tx_chars)!r}")
print(f"T8 backspace edit ok")
sim = Simulator(dut)
sim.add_clock(Period(MHz=24))
sim.add_testbench(testbench)
sim.add_testbench(tx_collector, background=True)
sim.add_testbench(w5100_udp_model, background=True)
sim.run()
if errors:
print("\nFAILURES:")
for e in errors:
print(" ", e)
sys.exit(1)
print("\nAll UARTShell tests passed.")