fc0d3e4b5c
第一次上真机把台架的两处软肋照出来了,都不是被测代码的问题: - peer 死在 Windows 的 ICMP 10054:板子还没 bind 时那一下端口不可达会被记在套接句上, 下一次 recvfrom 才抛出来。本机 CPython 3.14.7 既没有 socket.SIO_UDP_CONNRESET 也不接受 它的数值形式,所以主力防线改成把 10054 当「这一轮没收到」继续重试,并在回路自检里加 冷端口探针——探针走脚本真正在用的 _recv_maybe,另拿裸套接句证明这台机器确实会回 ICMP, 否则那条判定就没有牙。 - 单步预算错位:板端每步等 25 s,peer 每步只等 8 s,一步超时后整条梯子全灭。现在 peer 每步都重发、单步预算 --step-budget 默认 35 s(小于 26 s 打警告);板端 udp_bench_expect 按载荷第 0 字节的步号自愈,比本步小的旧包丢掉继续等(上限 UDP_BENCH_STALE_MAX = 8), 比本步大的直接报出来。DBG_WARN 在本工程不存在、字符串字面量必须纯 ASCII,两处已改。 真机已拿到的证据:TC-NET-1601a/b/c(建 socket、bind :6001、net_udp_get_mode 回 SERVER)与 TC-NET-1602(服务端形态收到 20 B、addrlen/sin_family/源端口全对、逐字节回显、形态翻成 CLIENT)全绿——剥 8 字节信息头与源地址回填在真芯片上成立;②③④⑤ 因板子当晚两次失电 (19:19、21:0x,SWD No target connected + ping 不通)未测,指南 §12.6 与覆盖率口径第 4 轮 的状态条据此改写。产品态(宏回默认)重新构建 0 Error 0 Warning,Code 249,124 / RO 29,836 / RW 2,052 / ZI 149,996 与本阶段之前逐字节一致,阶段 16 在产品里被预处理干净。
319 lines
16 KiB
Python
319 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""UDP 数据面队列化真机回归 —— 板端阶段 16 的 PC 对端。
|
||
|
||
用途:配合 test/board/net_test_task.c 的 net_layer_udp_bench_test_main()
|
||
(板端做 UDP 服务端,从收到的包里学对端地址再回显),把 PR #74「UDP 并入
|
||
netMsgQueue」这条新链路放上真芯片逐步激励。之所以板端做服务端:台架 PC 是
|
||
DHCP 地址,与 net_test_task.h 的 TEST_PC_PEER_IP 不一定一致,服务端在先就
|
||
不需要 PC 侧固定 IP。
|
||
|
||
跑法(板子烧好阶段 16 固件后):
|
||
python test/host/udp_queue_bench.py --board 192.168.31.100
|
||
# 可选:--first-wait 180 等板子重启;--late 12 调迟发时刻
|
||
|
||
判读:每步打印 [PASS]/[FAIL],末尾一行
|
||
=== UDP Bench PC Peer: N/N PASSED ===
|
||
任一步失败即非零退出。**板端的汇总行在串口日志里**(=== Phase 16: ... ===),
|
||
两侧都要看:板端能看见"收到多少字节、模式切没切对",本端只能看见
|
||
"回显是否完整、是不是一个数据报"。两端合起来才是完整判据。
|
||
|
||
与板端必须一致的约定(改一边就改另一边,见 net_test_task.c 的 Phase 16 头注释):
|
||
载荷 p[0]=tag,p[i]=(i*7+0x11)&0xFF;板端原样回显。
|
||
tag 次序:0x01(20B) 0x02(48B) 0x03(20B 换源端口) 0x04(200B 截断成 64)
|
||
0x05(30B 残包排空) —静默超时探针— 0x06(24B 迟发包) 0x07(20B 换端口 6002)
|
||
0x08/0x09/0x0A/0x0B/0x0C = 1/20/1024/1025/1472 字节往返。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import socket
|
||
import sys
|
||
import time
|
||
|
||
for _stream in (sys.stdout, sys.stderr):
|
||
if hasattr(_stream, "reconfigure"):
|
||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||
|
||
# 阶段 16 步骤表:tag -> (发往端口, 本次发送长度, 期望回显长度)
|
||
# 期望回显长度 != 发送长度 的那两行就是截断步。
|
||
STEPS = (
|
||
("TC-NET-1602", 0x01, 6001, 20, 20),
|
||
("TC-NET-1603", 0x02, 6001, 48, 48),
|
||
("TC-NET-1604", 0x03, 6001, 20, 20),
|
||
("TC-NET-1605a", 0x04, 6001, 200, 64),
|
||
("TC-NET-1605b", 0x05, 6001, 30, 30),
|
||
# TC-NET-1606 = 板端超时探针,本端不发数据(见 wait_quiet)
|
||
("TC-NET-1607", 0x06, 6001, 24, 24),
|
||
("TC-NET-1608", 0x07, 6002, 20, 20),
|
||
("TC-NET-1609a", 0x08, 6002, 1, 1),
|
||
("TC-NET-1609b", 0x09, 6002, 20, 20),
|
||
("TC-NET-1609c", 0x0A, 6002, 1024, 1024),
|
||
("TC-NET-1609d", 0x0B, 6002, 1025, 1025),
|
||
("TC-NET-1609e", 0x0C, 6002, 1472, 1472),
|
||
)
|
||
|
||
|
||
def payload(tag: int, n: int) -> bytes:
|
||
"""与板端 udp_bench_fill() 同式的自校验载荷。"""
|
||
if n <= 0:
|
||
return b""
|
||
out = bytearray(n)
|
||
out[0] = tag & 0xFF
|
||
for i in range(1, n):
|
||
out[i] = (i * 7 + 0x11) & 0xFF
|
||
return bytes(out)
|
||
|
||
|
||
def new_peer_socket() -> socket.socket:
|
||
"""建一条 UDP 源套接句;能关就关掉 Windows 的「ICMP 端口不可达回抛」。
|
||
|
||
真机第一次上台架(2026-11-03 20:54)就是栽在这里:板子还没把 UDP socket 打开时,
|
||
我们发过去的第一颗包换来一个 ICMP 端口不可达,Windows 把它记在套接句上,随后的
|
||
recvfrom 直接抛 `ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个
|
||
现有的连接`——对端脚本当场死在第一条用例,板子白等到 25 秒超时,两边看到的都是
|
||
「对端缺席」,分不清是谁的问题。
|
||
|
||
能关掉这个行为当然最好(SIO_UDP_CONNRESET=0),但实测本机 CPython 3.14.7 既没有
|
||
`socket.SIO_UDP_CONNRESET` 这个常量,也不接受把它的数值 0x9800000C 直接喂给 ioctl
|
||
(`ValueError: invalid ioctl command`)。所以**主力防线是 recv 侧把 WinError 10054
|
||
当成「这一轮没收到」继续重试**(见 _recv_after / _recv_maybe 的 except),这里的
|
||
ioctl 只是有常量时的锦上添花。判据由 udp_queue_bench_loopback.py 的冷端口探针守着。
|
||
"""
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
flag = getattr(socket, "SIO_UDP_CONNRESET", None)
|
||
if flag is not None:
|
||
try:
|
||
s.ioctl(flag, False)
|
||
except (OSError, ValueError):
|
||
pass
|
||
return s
|
||
|
||
|
||
class Peer:
|
||
def __init__(self, board: str, timeout: float, step_budget: float = 35.0) -> None:
|
||
self.board = board
|
||
self.timeout = timeout
|
||
# 单步总预算:必须盖过板端单步等待(UDP_BENCH_WAIT_MS = 25 s)再加余量,理由见
|
||
# exchange() 里的说明。--first-wait 只覆盖第一步(板子要启动+起监听)。
|
||
self.step_budget = step_budget
|
||
self.total = 0
|
||
self.failed = 0
|
||
self.ports = {}
|
||
self.t_start = time.time()
|
||
|
||
def stamp(self) -> str:
|
||
return "[peer {0:7.2f}s]".format(time.time() - self.t_start)
|
||
|
||
def log(self, msg: str) -> None:
|
||
print("{0} {1}".format(self.stamp(), msg), flush=True)
|
||
|
||
def note_port(self, sock: socket.socket, tag: int) -> None:
|
||
"""首次用到某条源套接句时把它的临时源端口打出来(真机日志要和这个对得上)。"""
|
||
key = id(sock)
|
||
if key in self.ports:
|
||
return
|
||
try:
|
||
self.ports[key] = sock.getsockname()[1]
|
||
self.log("TC 0x{0:02X} 走源端口 {1}".format(tag, self.ports[key]))
|
||
except OSError:
|
||
self.ports[key] = 0
|
||
|
||
def check(self, cond: bool, name: str) -> bool:
|
||
self.total += 1
|
||
self.log("[{0}] {1}".format("PASS" if cond else "FAIL", name))
|
||
if not cond:
|
||
self.failed += 1
|
||
return cond
|
||
|
||
def close(self) -> None:
|
||
pass
|
||
|
||
def exchange(self, sock: socket.socket, port: int, tag: int, send_len: int,
|
||
exp_len: int, first_wait: float = 0.0) -> bool:
|
||
"""一个数据报进、一个数据报出;重发只在"完全没收到"时发生。"""
|
||
dst = (self.board, port)
|
||
data_out = payload(tag, send_len)
|
||
want = payload(tag, exp_len)
|
||
|
||
# 先清掉上一步可能迟到的回显,避免把它当成本步的应答
|
||
self._drain(sock)
|
||
|
||
# 每一步都重发,且单步预算必须盖过板端单步等待(UDP_BENCH_WAIT_MS = 25 s)再加
|
||
# 余量:板端只要超时一次就会晚一步,本端若只等 8 秒,后面每一步都会踩进板子的
|
||
# 上一步预算里,整条梯子一起错位(2026-11-03 第一次上台架就是这样全灭)。
|
||
# 重发是安全的:板端按载荷第 0 字节的步号丢掉旧包(步序自愈),本端每步开头也
|
||
# 会先 drain 掉迟到的回显。
|
||
step_budget = first_wait if first_wait > 0 else self.step_budget
|
||
deadline = time.time() + step_budget
|
||
got = None
|
||
addr = None
|
||
while got is None:
|
||
sock.sendto(data_out, dst)
|
||
left = deadline - time.time()
|
||
if left <= 0:
|
||
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端 {2:.0f}s 内无回显"
|
||
.format(tag, port, step_budget))
|
||
attempt = 4.0 if first_wait > 0 else 6.0
|
||
try:
|
||
got, addr = self._recv_after(sock, min(attempt, left))
|
||
self.note_port(sock, tag)
|
||
except (socket.timeout, ConnectionResetError):
|
||
# ConnectionResetError = 对端端口当时没开换来的 ICMP 不可达(见
|
||
# new_peer_socket 的说明);这里和「这一轮没收到」同等对待,继续重试。
|
||
self.log("TC 0x{0:02X}: 无回显,重发(本步剩余 {1:.0f}s)".format(tag, left))
|
||
# 一个数据报进、一个数据报出——唯一例外是本步载荷超过芯片发送缓冲(1KB):
|
||
# 那是已登记的芯片限制(NET_SEND_CHUNK_MAX 本是 TCP 流的分块口径,UDP 上每块
|
||
# 各发一包,改造前后同一条路径)。>1KB 的步骤按「预期拆包」判,并把实测拆分打
|
||
# 出来:真机上哪天不拆了,这条一样会报——那就是芯片行为变了,值得知道。
|
||
chip_chunk = 1024
|
||
parts = [got]
|
||
if exp_len > chip_chunk:
|
||
while sum(len(x) for x in parts) < exp_len:
|
||
more = self._recv_maybe(sock, 1.0)
|
||
if more is None:
|
||
break
|
||
parts.append(more[0])
|
||
else:
|
||
again = self._recv_maybe(sock, 0.300)
|
||
if again is not None:
|
||
parts.append(again[0])
|
||
total = b"".join(parts)
|
||
|
||
ok_len = (len(total) == exp_len)
|
||
ok_pat = ok_len and (total == want)
|
||
self.check(ok_len, "TC 0x{0:02X} 端口 {1}: 回显 {2} 字节(期望 {3},收到 {4} 片)"
|
||
.format(tag, port, len(total), exp_len, len(parts)))
|
||
if not ok_len:
|
||
self.log(" 各片长度 = {0},首 16 字节 = {1}".format(
|
||
[len(x) for x in parts], total[:16].hex()))
|
||
self.check(ok_pat, "TC 0x{0:02X}: 载荷逐字节一致(模式 p[i]=(i*7+0x11)&0xFF)".format(tag))
|
||
if not ok_pat and ok_len:
|
||
bad = next((i for i in range(exp_len) if total[i] != want[i]), -1)
|
||
self.log(" 首个差异 @ 字节 {0}: 收到 0x{1:02X},应为 0x{2:02X}"
|
||
.format(bad, total[bad], want[bad]))
|
||
if exp_len > chip_chunk:
|
||
self.check(len(parts) > 1 and len(parts[0]) == chip_chunk,
|
||
"TC 0x{0:02X}: >1KB 回显按芯片发送缓冲在 1024 处拆包(实测 {1})"
|
||
.format(tag, [len(x) for x in parts]))
|
||
elif len(parts) > 1:
|
||
self.check(False, "TC 0x{0:02X}: 未超 1KB 却收到 {1} 个数据报 {2}"
|
||
" —— 发送路径的分块口径变了"
|
||
.format(tag, len(parts) - 1, [len(x) for x in parts[1:]]))
|
||
return ok_len and ok_pat and (exp_len > chip_chunk or len(parts) == 1)
|
||
|
||
def wait_quiet(self, sock: socket.socket, seconds: float, name: str) -> bool:
|
||
"""板端超时探针期间本端必须保持静默,且不能有过期包漂进来。"""
|
||
time.sleep(seconds)
|
||
stray = self._drain(sock)
|
||
return self.check(stray == 0, "{0}: 静默期无残留数据报(清掉 {1} 个)".format(name, stray))
|
||
|
||
# ---- 内部 ----
|
||
def _recv_after(self, sock: socket.socket, budget: float):
|
||
sock.settimeout(max(budget, 0.05))
|
||
data, addr = sock.recvfrom(4096)
|
||
return data, addr
|
||
|
||
def _recv_maybe(self, sock: socket.socket, budget: float):
|
||
try:
|
||
sock.settimeout(budget)
|
||
return sock.recvfrom(4096)
|
||
except (socket.timeout, ConnectionResetError):
|
||
return None
|
||
finally:
|
||
sock.settimeout(self.timeout)
|
||
|
||
def _drain(self, sock: socket.socket) -> int:
|
||
n = 0
|
||
while True:
|
||
r = self._recv_maybe(sock, 0.050)
|
||
if r is None:
|
||
return n
|
||
n += 1
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="UDP 队列化真机回归 PC 对端(板端阶段 16)")
|
||
ap.add_argument("--board", required=True, help="板子 IP,例如 192.168.31.100")
|
||
ap.add_argument("--first-wait", type=float, default=120.0,
|
||
help="首步最长等待(覆盖板子烧录后重启+起监听),默认 120 秒")
|
||
ap.add_argument("--timeout", type=float, default=20.0, help="单步回显等待,默认 20 秒")
|
||
ap.add_argument("--probe-silence", type=float, default=2.0,
|
||
help="对应 TC-NET-1606 的静默时长(板端探针预算 1 秒)")
|
||
ap.add_argument("--late", type=float, default=12.0,
|
||
help="对应 TC-NET-1607 的迟发时长:板端在这一步无限等包")
|
||
ap.add_argument("--step-budget", type=float, default=35.0,
|
||
help="单步总预算(含重发),必须盖过板端 25 秒单步等待,默认 35 秒")
|
||
ap.add_argument("--port", type=int, default=0,
|
||
help="覆盖阶段 16 的首个监听端口(默认用板端 UDP_BENCH_PORT 6001)")
|
||
ap.add_argument("--port2", type=int, default=0,
|
||
help="覆盖重开 socket 后的第二个监听端口(默认用板端 UDP_BENCH_PORT2 6002)")
|
||
args = ap.parse_args()
|
||
# 板端 TC-NET-1607 把「等待时长」判据钉在 7000~25000 ms:本端迟发时刻必须落在窗内,
|
||
# 且必须先盖过板端 TC-NET-1606 那 1 秒超时探针,否则两步会互相污染。
|
||
if args.probe_silence <= 1.2:
|
||
print("--probe-silence 必须大于板端 1 秒超时探针(默认 2.0)", file=sys.stderr)
|
||
return 2
|
||
if not (7.0 < args.late <= 22.0):
|
||
print("--late 必须落在 (7, 22] 秒内,否则板端 7000~25000ms 判定窗会判失败", file=sys.stderr)
|
||
return 2
|
||
if args.step_budget <= 26.0:
|
||
# 只提醒不拦死:自检脚本用假板子跑,预算给得很小;真机台架则会看到这条。
|
||
print("[warn] --step-budget={0:.0f}s 小于板端单步等待 25s + 余量:"
|
||
"板端一旦超时一次,本端后续每一步都会踩进板子的上一步预算里(错位全灭)"
|
||
.format(args.step_budget), file=sys.stderr)
|
||
|
||
peer = Peer(args.board, args.timeout, args.step_budget)
|
||
# 三条独立源套接句 = 三个不同临时源端口,1604「换源端口」就是这么来的。
|
||
# 端口号不能在未 connect 时用 getsockname() 取(Windows 会 WinError 10022,
|
||
# 端口要到第一次 sendto 才分配),也不能 connect()——connect 会让内核丢掉
|
||
# 非对端的包,那正是这一步要验的东西。改成首次收包后再查,照样打得出来。
|
||
socks = {"A": new_peer_socket(),
|
||
"B": new_peer_socket(),
|
||
"C": new_peer_socket()}
|
||
peer.log("对端 = {0},三条源套接句 A/B/C 的临时源端口在首次收包后打印".format(args.board))
|
||
|
||
# 板端每一步用新源端口的条件:1604(换源端口验形态恢复)及其后
|
||
step_sock = {
|
||
"TC-NET-1602": "A", "TC-NET-1603": "A",
|
||
"TC-NET-1604": "B", "TC-NET-1605a": "B", "TC-NET-1605b": "B",
|
||
"TC-NET-1607": "B",
|
||
"TC-NET-1608": "C", "TC-NET-1609a": "C", "TC-NET-1609b": "C",
|
||
"TC-NET-1609c": "C", "TC-NET-1609d": "C", "TC-NET-1609e": "C",
|
||
}
|
||
|
||
# 端口覆盖:只在显式给了 --port/--port2 时替换,默认沿用板端编译期的 UDP_BENCH_PORT(_2)。
|
||
# 板端串口横幅会把自己的端口打出来,照抄那条命令时用它换掉非默认端口。
|
||
port_map = {}
|
||
if args.port:
|
||
port_map[6001] = args.port
|
||
if args.port2:
|
||
port_map[6002] = args.port2
|
||
|
||
try:
|
||
for name, tag, port, send_len, exp_len in STEPS:
|
||
s = socks[step_sock[name]]
|
||
port = port_map.get(port, port)
|
||
fw = args.first_wait if name == "TC-NET-1602" else 0.0
|
||
# 1607 之前先做 1606 的静默探针(板端自己判 -2,本端只需保持静默)
|
||
if name == "TC-NET-1607":
|
||
peer.wait_quiet(s, args.probe_silence, "TC-NET-1606")
|
||
left = max(args.late - args.probe_silence, 0.0)
|
||
peer.log("TC-NET-1607: 再静默 {0:.1f}s,等板子挂进无限 net_recvfrom".format(left))
|
||
time.sleep(left)
|
||
peer.exchange(s, port, tag, send_len, exp_len, fw)
|
||
except KeyboardInterrupt:
|
||
peer.log("用户中断")
|
||
finally:
|
||
for s in socks.values():
|
||
s.close()
|
||
|
||
print("=== UDP Bench PC Peer: {0}/{1} PASSED (failed={2}) ===".format(
|
||
peer.total - peer.failed, peer.total, peer.failed), flush=True)
|
||
return 0 if peer.failed == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|