Files
vt1200/harness/udpstream/vtcmd.py
T
arti 0563cb08e8 udpstream: LANCE command payload -- peek/poke/jump/reboot, benchmark, driver-matched RX
lance_cmd.c is a payload that drives the LANCE with its own code (no ROM
calls) and answers a command protocol on ethertype 0x88B6, so a unit whose
chip we have taken over can still be inspected:

  read (peek), stats, poke, jump, reboot

The reply path was dead for a while and the cause was not the chip: the RX
ring had been grown to 16 entries (0x3020..0x309F) while TXR stayed at
0x3060, so all eight TX descriptors lay inside the RX ring.  The chip's RX
write-back set OWN in what we believed were TX descriptors, and every reply
was discarded by transmit_poll() as "owned but not start of packet"
(r 00309A 8000 / w 00309A 0000 in the DMA log).  TX ring moved to 0x30C0,
with a compile-time guard so the rings cannot overlap again.

Receive path aligned with the three drivers available locally (4.4BSD
pmax if_le.c, NetBSD am7990.c, Linux 7990.c):

  * STP *and* ENP required on one descriptor, else the frame is counted as
    chained and dropped rather than parsed from the middle of a buffer;
  * the 4-byte FCS the chip writes into the buffer is subtracted, as all
    three do (rmd3 - 4);
  * re-arm restores RMD2 and clears RMD3, ownership written last;
  * TX kick is TDMD|INEA (no reference driver re-writes STRT);
  * a TX slot still owned is skipped instead of clobbered.

RX buffers are 1526 B so a full 1514-byte MTU frame fits one descriptor;
1500 B would have chained it.

vtcmd.py drives all of it; bench-sweep.sh sweeps size x rate.  Measured on
the real VT1200 (10 Mbit half-duplex hub): 856-858 fps of 1514-byte frames
at 99.9-100% delivery, i.e. wire limited, and 1500 fps of 60-byte frames at
99.8-99.9%.  NETWORK_REPORT.md sections 68-72 record the evidence, including
the 64-descriptor build that died with an illegal opcode on the unit and was
reverted.
2026-10-30 16:57:15 +02:00

294 lines
11 KiB
Python

#!/usr/bin/env python3
"""vtcmd.py -- talk to the LANCE command payload (harness/udpstream/lance_cmd.c).
The payload runs entirely on its own (no OS stack), so this is the only way to
inspect the machine while it owns the chip.
Protocol, ethertype 0x88B6, both directions:
[14] 'K' [15] 'S' [16] cmd
cmd 1 read : [17..20] address (32-bit big-endian BIT address), [21..22] len
cmd 2 stats: no arguments
cmd 3 poke : [17..20] address (BIT), [21..22] len, [23..] the bytes
cmd 4 jump : [17..20] address (BIT) -- reply is sent, then control moves
cmd 5 reboot: no arguments -- jumps to the ROM reset vector
reply : [16] cmd echoed, [17..18] status, [19..] data
Every address in this protocol is a BIT address, the way the TMS34010 sees
memory. For a byte address, send byte_addr * 8.
Usage:
# one-off statistics (frames/bytes the payload has seen)
sudo python3 vtcmd.py stats --iface vt1200
# write 4 bytes at bit address 0x018D0300 (byte 0x31A60)
sudo python3 vtcmd.py poke --iface vt1200 --addr 0x018D0300 --data "de ad be ef"
# run code at an address, or soft-reset the machine back into the firmware
sudo python3 vtcmd.py jump --iface vt1200 --addr 0x018C0000
sudo python3 vtcmd.py reboot --iface vt1200
# read 32 bytes of memory at bit address 0x018D0000
sudo python3 vtcmd.py read --iface vt1200 --addr 0x018D0000 --len 32
# throughput: blast N frames, then ask the payload how many it counted
sudo python3 vtcmd.py bench --iface vt1200 --count 4000 --size 1514
"""
import argparse
import socket
import struct
import sys
import time
ETHCMD = 0x88B6
VT_MAC = bytes.fromhex("08002b164b5a")
def parse_mac(s):
return bytes(int(p, 16) for p in s.split(":"))
def iface_mac(iface):
with open("/sys/class/net/%s/address" % iface) as f:
return parse_mac(f.read().strip())
def open_sock(iface):
s = socket.socket(socket.AF_PACKET, socket.SOCK_RAW, socket.htons(ETHCMD))
s.bind((iface, 0))
return s
def frame(iface, cmd, addr=0, length=0, size=60, payload=b""):
src = iface_mac(iface)
body = bytes([ord('K'), ord('S'), cmd]) + struct.pack(">I", addr) + \
struct.pack(">H", length) + payload
body = body + bytes(max(0, size - 14 - len(body)))
return b"\xff" * 6 + src + struct.pack(">H", ETHCMD) + body
def request(sock, iface, cmd, addr=0, length=0, timeout=2.0, tries=3,
payload=b""):
"""Send a command and wait for its reply.
Retried because the first frame after the payload takes the chip over is
routinely lost: the ring is armed slightly before the receive loop runs.
"""
for attempt in range(tries):
sock.send(frame(iface, cmd, addr, length,
size=max(60, 23 + len(payload)), payload=payload))
end = time.time() + timeout
sock.settimeout(0.2)
while time.time() < end:
try:
data, addr_in = sock.recvfrom(2048)
except socket.timeout:
continue
if len(data) < 19:
continue
if data[12:14] != struct.pack(">H", ETHCMD):
continue
if data[14:16] != b"KS":
continue
if data[16] != cmd:
continue
status = (data[17] << 8) | data[18]
return status, data[19:]
return None, None
def cmd_stats(sock, iface, quiet=False):
status, data = request(sock, iface, 2)
if status is None:
if not quiet:
print("no reply -- is the payload running and the link up?")
return None
rxf, nbytes, cmds, chaindrop, txfull, errbits = struct.unpack(">IIHHHH", data[:16])
flags = []
if errbits & 0x1000:
flags.append("MISS(no descriptor)")
if errbits & 0x0800:
flags.append("MERR")
if errbits & 0x4000:
flags.append("BABL")
if errbits & 0x2000:
flags.append("CERR")
if errbits & 0x8000:
flags.append("ERR")
if not quiet:
print("payload: RXF=%d bytes=%d cmds=%d chained_dropped=%d txfull=%d "
"csr0_err=%#06x%s status=%#06x"
% (rxf, nbytes, cmds, chaindrop, txfull, errbits,
(" [" + ",".join(flags) + "]") if flags else " [none]", status))
return rxf, nbytes, cmds, chaindrop, txfull, errbits
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("cmd", choices=["stats", "read", "poke", "jump",
"reboot", "bench"])
ap.add_argument("--iface", default="vt1200")
ap.add_argument("--addr", type=lambda v: int(v, 0), default=0)
ap.add_argument("--len", type=int, default=32)
ap.add_argument("--data", default="", help="poke: bytes, e.g. 'de ad be ef'")
ap.add_argument("--file", default="", help="poke: read the bytes from a file")
ap.add_argument("--count", type=int, default=0,
help="send exactly N frames instead of a timed burst")
ap.add_argument("--seconds", type=float, default=3.0,
help="duration of the timed burst when --count is 0")
ap.add_argument("--size", type=int, default=1514)
ap.add_argument("--etype", type=lambda v: int(v, 0), default=0x88B7,
help="ethertype for benchmark frames (must NOT be 0x88B6, "
"or the payload answers every one of them)")
ap.add_argument("--dst", default="ff:ff:ff:ff:ff:ff",
help="destination MAC; unicast avoids the switch's "
"broadcast storm control")
ap.add_argument("--rate", type=float, default=0,
help="pace to at most N frames/s (0 = as fast as possible)")
ap.add_argument("--busy", action="store_true",
help="busy-wait instead of time.sleep while pacing; "
"sleep granularity is ~250us and bunches frames "
"into microbursts that the switch drops")
ap.add_argument("--terse", action="store_true",
help="one CSV line per run, for sweeps and for the report")
ap.add_argument("--timeout", type=float, default=2.0)
a = ap.parse_args()
sock = open_sock(a.iface)
if a.cmd == "stats":
return 0 if cmd_stats(sock, a.iface) else 1
if a.cmd == "read":
status, data = request(sock, a.iface, 1, a.addr, a.len, a.timeout)
if status is None:
print("no reply")
return 1
print("read %#010x len %d status %#06x:" % (a.addr, a.len, status))
for i in range(0, min(len(data), a.len), 16):
print(" %04x %s" % (i, data[i:i + 16].hex(" ")))
return 0
if a.cmd == "poke":
if a.file:
with open(a.file, "rb") as f:
blob = f.read()
else:
blob = bytes(int(b, 16) for b in a.data.replace(",", " ").split())
if not blob:
print("nothing to poke: use --data or --file")
return 1
status, data = request(sock, a.iface, 3, a.addr, len(blob), a.timeout,
payload=blob)
if status is None:
print("no reply")
return 1
print("poked %d bytes at bit %#010x (byte %#x) status %#06x"
% (len(blob), a.addr, a.addr >> 3, status))
return 0
if a.cmd in ("jump", "reboot"):
cmd = 4 if a.cmd == "jump" else 5
status, data = request(sock, a.iface, cmd, a.addr, 0, a.timeout)
if status is None:
print("no reply")
return 1
target = struct.unpack(">I", data[:4])[0] if len(data) >= 4 else 0
print("%s: control -> bit %#010x (byte %#x), status %#06x"
% (a.cmd, target, target >> 3, status))
print("the payload has handed control over; it will not answer again")
return 0
# bench: offer frames continuously for --seconds, then let the payload's
# counter settle. A fixed-duration burst makes the window long enough that
# the settle time is negligible, and retrying on ENOBUFS paces the host to
# whatever its socket buffer allows instead of aborting.
before = cmd_stats(sock, a.iface, quiet=a.terse)
size = max(a.size, 60)
body = bytes(size - 14)
f = parse_mac(a.dst) + iface_mac(a.iface) + struct.pack(">H", a.etype) + body
sent = 0
t0 = time.time()
if a.count:
for i in range(a.count):
try:
sock.send(f)
sent += 1
except OSError:
time.sleep(0.001)
sock.send(f)
sent += 1
else:
while time.time() - t0 < a.seconds:
if a.rate:
want = sent / a.rate
behind = want - (time.time() - t0)
if behind > 0:
if a.busy:
end = time.time() + behind
while time.time() < end:
pass
else:
time.sleep(behind)
try:
sock.send(f)
sent += 1
except OSError:
time.sleep(0.001)
t1 = time.time()
# poll until the *data* counter is stable (each stats request is itself a
# received command frame, so comparing rxf alone would never settle)
after = before
quiet = 0
last = before[0] - before[2]
deadline = time.time() + 15.0
while time.time() < deadline:
time.sleep(0.2)
now = cmd_stats(sock, a.iface, quiet=a.terse)
if not now:
break
after = now
cur = now[0] - now[2]
if cur == last:
quiet += 1
if quiet >= 2:
break
else:
quiet = 0
last = cur
t2 = time.time()
if before and after and after[0] >= before[0]:
dt = t1 - t0
dcmds = after[2] - before[2] # our own stats frames
frames = (after[0] - before[0]) - dcmds
nbytes = (after[1] - before[1]) - 60 * dcmds
if a.terse:
print("%d,%g,%.2f,%d,%d,%d,%.0f,%.1f,%.2f,%.1f,%#06x,%d,%d"
% (size, a.rate, dt, sent, frames, nbytes,
frames / dt if dt else 0,
nbytes / 1024.0 / dt if dt else 0,
nbytes * 8 / dt / 1e6 if dt else 0,
100.0 * frames / sent if sent else 0,
after[5], after[4], after[3]))
return 0
print("sent %d frames of %d B over %.3f s (%.0f frames/s offered)"
% (sent, size, dt, sent / dt if dt else 0))
print("payload received %d frames (%d bytes) while they were offered"
% (frames, nbytes))
print(" receive rate: %.0f frames/s, %.1f KiB/s, %.1f Mbit/s"
% (frames / dt if dt else 0,
nbytes / 1024.0 / dt if dt else 0,
nbytes * 8 / dt / 1e6 if dt else 0))
print(" delivery ratio: %.1f%% (below 100%% = the ring or the wire "
"could not keep up)" % (100.0 * frames / sent if sent else 0))
print(" drain after the burst: %.2f s" % max(0.0, t2 - t1))
return 0
if __name__ == "__main__":
sys.exit(main())