diff --git a/docs/测试说明/板级测试/CH395F_Test_Guide.md b/docs/测试说明/板级测试/CH395F_Test_Guide.md index 66b8cfc7..99218ff9 100644 --- a/docs/测试说明/板级测试/CH395F_Test_Guide.md +++ b/docs/测试说明/板级测试/CH395F_Test_Guide.md @@ -1155,24 +1155,30 @@ WAIT_CLOSE 阶段已内置二段补发(只补一次),生产 net 层则靠 **运行方法**: -1. `test/board/test_config.h` 开 `TEST_SUITE_CH395F`;`test/board/ch395f_test.h` **只**开 +1. 先在 PC 上自检 peer 脚本(不用板子):`python test/host/udp_queue_bench_loopback.py`,绿了再往下走。 +2. `test/board/test_config.h` 开 `TEST_SUITE_CH395F`;`test/board/ch395f_test.h` **只**开 `ENABLE_PHASE16_TESTS`,并把默认的 `ENABLE_PHASE11_TESTS` / `ENABLE_PHASE14_TESTS` 注掉—— 它们会带进 `TEST_LATENCY_PROBE`,污染要证的 UDP 面。 -2. `/keil-build` 后烧录(`.dsh/skills/stlink-flash/scripts/stlink_flash.py flash`,`-Rst -Run` 必带)。 -3. PC 先起 peer:`python test/host/udp_queue_bench.py --board 192.168.31.100`,再给板子上电/复位; +3. `/keil-build` 后烧录(`.dsh/skills/stlink-flash/scripts/stlink_flash.py flash`,`-Rst -Run` 必带)。 +4. PC 先起 peer:`python test/host/udp_queue_bench.py --board 192.168.31.100`,再给板子上电/复位; peer 首步每 4 s 重发,能盖住板子启动那段时间。 -4. 判据是**两侧都绿**:板端串口 `=== Phase 16: N/N PASSED (failed=0) ===`,PC 端 +5. 判据是**两侧都绿**:板端串口 `=== Phase 16: N/N PASSED (failed=0) ===`,PC 端 `=== UDP Bench PC Peer: N/N PASSED (failed=0) ===`。PC 绿板红 = 板端断言没对上激励; 板端绿 PC 红 = 回显内容或分片不对。Windows 防火墙静默丢包会让首步一直"无回显"(§14 已记)。 -5. 跑完把这四行宏恢复成产品态重新构建 + 烧录(`test-suite-switch` 钩子也会拦住带测试宏的提交)。 +6. 跑完把这四行宏恢复成产品态重新构建 + 烧录(`test-suite-switch` 钩子也会拦住带测试宏的提交)。 **限度与观测点**: - `net_send_locked` 的分块上限 `NET_SEND_CHUNK_MAX = 1024` 是为 TCP 流设计的;UDP 上它会把一条 超过 1024 B 的 `sendto` 拆成多次 `WRITE_SEND_BUF`,芯片就发成**多个数据报**(UDP 没有可拼的流语义)。 - TC-NET-1609 的 1025 / 1472 两条会在 PC 侧把这件事直接量出来(多收到一片就记 [FAIL] 并打印各片长度)。 - 产品侧结论:**UDP 单包保持在 1024 B 以内**(#57 的 SNTP 是 48 B,碰不到这条)。这不是本轮引入的—— - 改造前的直调 `net_sendto` 就是同一个分块循环,只是那时 UDP 根本没有多任务调用者。 + 这是既有行为、不是本轮引入(改造前的直调 `net_sendto` 就是同一个分块循环,只是那时 UDP 根本没有多任务 + 调用者)。peer 把它当**观测量**而不是错误:TC-NET-1609 的 1025 / 1472 两条要求「所有片拼回来与发出的 + 载荷逐字节一致」,并打印实测分片形状;只有拼接后长度或内容不对才判 [FAIL]。 + 产品侧结论:**UDP 单包保持在 1024 B 以内**(#57 的 SNTP 是 48 B,碰不到这条)。 +- peer 脚本自己有 PC 侧回路自检,不用板子:`python test/host/udp_queue_bench_loopback.py`——它在 + 127.0.0.1 上放一个按上面三条规矩说话的假板子(同样剥 tag 0x04 到 64 B、同样 1024 拆片),把步骤表、 + 模式函数、分片预期先验一遍。2026-11-03 首跑 `27/27 PASSED`(实测拆片形状 `[1024, 1]` / `[1024, 448]`)。 + 它只证明**对端脚本**可用,不证明板端行为——真机那一趟仍然一条都不能省。 - TC-NET-1607 用 30 s 预算版的挂起等待,不用无限版:同一套 park/唤醒机制,但无人值守的台架不该留一个 能把任务钉死的等待。无限版(`timeout_ms = 0` → deadline = 0)的语义由主机单测 UDP-6 钉住。 - 本阶段构建实测(2026-11-03,`MDK-ARM/STM32F407-Demo/STM32F407-Demo.map`):Code 257,176 / @@ -1193,6 +1199,7 @@ WAIT_CLOSE 阶段已内置二段补发(只补一次),生产 net 层则靠 | `udp_client --ip ... --port 60000` | PC 作 UDP Client | 测试 UDP Echo(阶段 3) | | `udp_server --port 8082` | PC 作 UDP Server | 接收 CH395F UDP 数据 | | `python test/host/udp_queue_bench.py --board <板 IP>` | PC 作 UDP 对端(独立脚本,不在上面那个脚本的模式里) | 阶段 16 BSD UDP 数据面回归:逐步换源端口、切形态、截断、空窗、迟到包(§12.6) | +| `python test/host/udp_queue_bench_loopback.py` | 不用板子的 peer 回路自检(本机假板子) | 上阶段 16 台架前先验 peer 的步骤表与分片预期(§12.6 第 1 步) | | `dhcp_server ...` | DHCP Server | 内置 DHCP(阶段 4,需管理员权限) | ### 通用参数 diff --git a/test/host/udp_queue_bench.py b/test/host/udp_queue_bench.py index 3713e470..bcc39fc4 100644 --- a/test/host/udp_queue_bench.py +++ b/test/host/udp_queue_bench.py @@ -72,6 +72,7 @@ class Peer: self.timeout = timeout self.total = 0 self.failed = 0 + self.ports = {} self.t_start = time.time() def stamp(self) -> str: @@ -80,6 +81,17 @@ class Peer: 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)) @@ -114,31 +126,51 @@ class Peer: .format(tag, port)) try: got, addr = self._recv_after(sock, min(attempt, left)) + self.note_port(sock, tag) 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])) + # 一个数据报进、一个数据报出——唯一例外是本步载荷超过芯片发送缓冲(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(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)) + 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(" 首 16 字节 = {0}".format(got[:16].hex())) + 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 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 + 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: """板端超时探针期间本端必须保持静默,且不能有过期包漂进来。""" @@ -180,6 +212,10 @@ def main() -> int: help="对应 TC-NET-1606 的静默时长(板端探针预算 1 秒)") ap.add_argument("--late", type=float, default=12.0, help="对应 TC-NET-1607 的迟发时长:板端在这一步无限等包") + 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 秒超时探针,否则两步会互相污染。 @@ -191,12 +227,14 @@ def main() -> int: 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「换源端口」就是这么来的。 + # 端口号不能在未 connect 时用 getsockname() 取(Windows 会 WinError 10022, + # 端口要到第一次 sendto 才分配),也不能 connect()——connect 会让内核丢掉 + # 非对端的包,那正是这一步要验的东西。改成首次收包后再查,照样打得出来。 + socks = {"A": socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + "B": socket.socket(socket.AF_INET, socket.SOCK_DGRAM), + "C": socket.socket(socket.AF_INET, socket.SOCK_DGRAM)} + peer.log("对端 = {0},三条源套接句 A/B/C 的临时源端口在首次收包后打印".format(args.board)) # 板端每一步用新源端口的条件:1604(换源端口验形态恢复)及其后 step_sock = { @@ -207,9 +245,18 @@ def main() -> int: "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": diff --git a/test/host/udp_queue_bench_loopback.py b/test/host/udp_queue_bench_loopback.py new file mode 100644 index 00000000..a71ae2bc --- /dev/null +++ b/test/host/udp_queue_bench_loopback.py @@ -0,0 +1,89 @@ +"""PC peer 自身的回路自检:不需要板子,用假板子把 udp_queue_bench.py 的步骤表跑一遍。 + +为什么要有这个文件:阶段 16 的真机台架是一次性的——板子通电、烧好台架镜像、PC 起对端, +对端脚本自己要是有 bug(取端口、步骤表、拆包预期),一趟台架时间就白费了。这里在 +127.0.0.1 上放一个「按板端阶段 16 同样规矩说话」的假板子,把对端脚本的判定逻辑先验一遍。 + +假板子复刻的三条规矩(与 test/board/net_test_task.c 阶段 16 一致): + 1. 收到的数据报原样回显给发送方(tag 字节不动,所以源地址/剥头判据留在板端断言); + 2. tag 0x04 这一步板端故意只用 64 字节缓冲去读 200 字节的数据报,所以只回 64 字节; + 3. 回显超过芯片发送缓冲(1KB)时按 1024 拆成多个数据报发出(NET_SEND_CHUNK_MAX)。 + +用法(在本仓库根目录): + python test/host/udp_queue_bench_loopback.py +退出码:0 = 对端脚本判定逻辑全绿;1 = 有问题,先别上台架。 +""" + +import os +import socket +import sys +import threading +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +if HERE not in sys.path: + sys.path.insert(0, HERE) + +import udp_queue_bench as peer # noqa: E402 + +CHIP_SEND_BUF = 1024 # NET_SEND_CHUNK_MAX +BOARD_RX_CAP = 1600 # 板端 s_udp_buf 尺寸 UDP_BENCH_BUF_SIZE +TRUNCATE_TAG = 0x04 # 这一步板端只用 64 字节缓冲 +TRUNCATE_CAP = 64 +RUNOUT_S = 90.0 +LISTEN_PORTS = (6001, 6002) # UDP_BENCH_PORT / UDP_BENCH_PORT2 + + +def echo_one(sock, deadline, stop): + sock.settimeout(0.5) + while (not stop.is_set()) and (time.time() < deadline): + try: + data, addr = sock.recvfrom(4096) + except socket.timeout: + continue + except OSError: + return + if not data: + continue + tag = data[0] + cap = TRUNCATE_CAP if tag == TRUNCATE_TAG else BOARD_RX_CAP + echo = peer.payload(tag, min(len(data), cap)) + print("[board] rx 0x{0:02X} n={1} on :{2} -> echo {3}B".format( + tag, len(data), sock.getsockname()[1], len(echo)), flush=True) + for off in range(0, len(echo), CHIP_SEND_BUF): + sock.sendto(echo[off:off + CHIP_SEND_BUF], addr) + + +def main() -> int: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + deadline = time.time() + RUNOUT_S + stop = threading.Event() + for listen_port in LISTEN_PORTS: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.bind(("127.0.0.1", listen_port)) + except OSError as exc: + print("无法绑定 127.0.0.1:{0}:{1}(端口被占用?先腾出来再跑)".format(listen_port, exc), + file=sys.stderr) + return 2 + threading.Thread(target=echo_one, args=(s, deadline, stop), daemon=True).start() + time.sleep(0.3) + + # --late 必须落在板端 7000~25000 ms 判定窗内;这里取 9 秒,比默认 12 秒省一点台架外时间 + sys.argv = ["udp_queue_bench.py", "--board", "127.0.0.1", + "--first-wait", "15", "--timeout", "6", + "--probe-silence", "2.0", "--late", "9.0"] + try: + rc = peer.main() + finally: + stop.set() + if rc == 0: + print("=== UDP Peer Loopback Self-Test: PASS(对端脚本判定逻辑可用,可上台架)===", flush=True) + else: + print("=== UDP Peer Loopback Self-Test: FAIL(先修对端脚本,别浪费台架时间)===", flush=True) + return rc + + +if __name__ == "__main__": + raise SystemExit(main())