Files
STM32F4-Base/test/host/udp_queue_bench.py
T
super_agent_bot fc0d3e4b5c
CI / 静态门禁 (pre-commit) (push) Failing after 5m39s
CI / 主机侧单测 (MSVC) (push) Successful in 30s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m15s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 47s
test(net/udp): 阶段 16 台架加固 + 真机首跑证据回填(#73)
第一次上真机把台架的两处软肋照出来了,都不是被测代码的问题:

- 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 在产品里被预处理干净。
2026-11-03 21:28:36 +08:00

319 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())