test(net/udp): 阶段 16 UDP 数据面真机回归台架(板端 + PC peer),台账补实测数字(#73)
CI / 静态门禁 (pre-commit) (push) Failing after 5m27s
CI / 主机侧单测 (MSVC) (push) Successful in 33s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 7m6s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 48s

- 板端 net_layer_udp_bench_test_main:TC-NET-1601~1609,逐步覆盖主机测替代不了的
  5 条台架必验——形态切换与 bind 后首包、48 B 应答在客户端形态的载荷完整(TRAP-NET-22
  的真机对照)、截断包排空、挂起等待期间的 IWDG 余量、DesIP 先于 OPEN_SOCKET 的顺序。
  每步都有 25 s 预算,PC 缺席时报 [FAIL] 而不是把 testTask 钉死。
- PC peer test/host/udp_queue_bench.py:板端先 bind 当服务端,对端地址从收到的包里学
  (PC 是 DHCP,TEST_PC_PEER_IP 那个固定地址不在本机上);对 >1024 B 的回显额外监听
  第二片,把 net_send_locked 分块在 UDP 上的后果量出来。
- ch395f_test.h:阶段 16 宏默认关(与其它阶段一致),RAM 台账补 2026-11-03 实测——
  台架镜像 RW_IRAM1 96,232 B / 73.4%,阶段 16 自身代价 1,600 B;产品镜像 RW+ZI 与改造前逐字节相同。
- 文档:CH395F_Test_Guide §12.6 跑法与判据 + Python 脚本表加一行;覆盖率口径第 4 轮
  「台架必验」指向本阶段的可执行形态,并写明尚未在真机上跑过。
This commit is contained in:
2026-11-03 20:18:24 +08:00
parent fff961d803
commit 8a81fa9e41
7 changed files with 669 additions and 1 deletions
+233
View File
@@ -0,0 +1,233 @@
#!/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)
class Peer:
def __init__(self, board: str, timeout: float) -> None:
self.board = board
self.timeout = timeout
self.total = 0
self.failed = 0
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 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)
deadline = time.time() + (first_wait if first_wait > 0 else self.timeout)
got = None
addr = None
while got is None:
sock.sendto(data_out, dst)
# 首步:板子可能还在启动/还没 bind,每 4 秒重发一次直到首步总预算用尽。
# 重发不会污染下一步——下一步开头会先 drain 掉多出来的那一份回显。
attempt = 4.0 if first_wait > 0 else self.timeout
left = deadline - time.time()
if left <= 0:
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端无回显(预算内)"
.format(tag, port))
try:
got, addr = self._recv_after(sock, min(attempt, left))
except socket.timeout:
if first_wait <= 0:
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端 {2:.0f}s 内无回显"
.format(tag, port, self.timeout))
self.log("TC 0x{0:02X}: 无回显,重发(首步剩余 {1:.0f}s)".format(tag, left))
# 一个数据报进必须只出一个数据报出:再等一小会儿看有没有第二片
again = self._recv_maybe(sock, 0.300)
extra = []
if again is not None:
extra.append(len(again[0]))
ok_len = (len(got) == exp_len)
ok_pat = ok_len and (got == want)
self.check(ok_len, "TC 0x{0:02X} 端口 {1}: 回显 {2} 字节(期望 {3})".format(tag, port, len(got), exp_len))
if not ok_len:
self.log(" 首 16 字节 = {0}".format(got[: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 got[i] != want[i]), -1)
self.log(" 首个差异 @ 字节 {0}: 收到 0x{1:02X},应为 0x{2:02X}".format(bad, got[bad], want[bad]))
if extra:
self.check(False, "TC 0x{0:02X}: 一个请求收到 {1} 个额外数据报 {2}"
" —— 回显被分片了(芯片发送缓冲 = 1KB,>1KB 的分块会各发一包)"
.format(tag, len(extra), extra))
return ok_len and ok_pat and not extra
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:
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 的迟发时长:板端在这一步无限等包")
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
peer = Peer(args.board, args.timeout)
sock_a = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock_c = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
socks = {"A": sock_a, "B": sock_b, "C": sock_c}
peer.log("对端 = {0},本机临时源端口 A={1} B={2} C={3}".format(
args.board, sock_a.getsockname()[1], sock_b.getsockname()[1], sock_c.getsockname()[1]))
# 板端每一步用新源端口的条件: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",
}
try:
for name, tag, port, send_len, exp_len in STEPS:
s = socks[step_sock[name]]
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())