链路时延M1-M3收尾:Phase12-14基线与生产路径Δ实测、D10/E-02/D17修复、D20定位与硬化、文档整理

This commit is contained in:
2026-09-22 06:12:16 +08:00
parent ac48c6d716
commit e5733b3454
42 changed files with 18294 additions and 1168 deletions
+14 -4
View File
@@ -9,7 +9,7 @@
* 修改记录:
* 2026-07-22 全面重构为单连接模式,增加大文件传输、边界条件等测试
*
* === 测试阶段一览(硬件层 1~5 → NET 层 6~11,严格顺序执行) ===
* === 测试阶段一览(硬件层 1~5/12 → NET 层 6~11/13/14,严格顺序执行) ===
* Phase | 层 | 描述
* ---------|----------|-----------------------------------------------
* Phase 1 | 硬件层 | 独立运行:寄存器读写 + SPI 命令路径验证
@@ -23,6 +23,10 @@
* Phase 9 | NET 层 | testTask:Select/Poll I/O 多路复用
* Phase 10 | NET 层 | testTask:边界条件测试(零长度、DMA 边界、缓冲溢出)
* Phase 11 | NET 层 | testTask:KeepAlive 超时断开测试
* Phase 12 | 硬件层 | 独立运行:SPI 字节命令 / DMA 时延基线(L1/L2,无需 PC)
* Phase 13 | NET 层 | testTask:芯片 + 队列时延(L3~L7/L10,需 PC peer,独立运行)
* Phase 14 | NET 层 | testTask:生产路径时延(L8~L11 + 模拟器 A/B,需 PC peer)〔M3 规划〕
* 时延分段定义/判定基线/执行流程:docs/测试说明/链路延迟测试说明.md
*/
#ifndef CH395F_TEST_H
@@ -45,7 +49,7 @@ extern "C" {
* Phase 4 (DHCP) 会改变 IP,必须独立运行;
* 两层同时启用会在下方触发 #error。
*/
/* ---- 硬件层(阶段 1~5)---- */
/* ---- 硬件层(阶段 1~5/12)---- */
//#define ENABLE_PHASE1_TESTS /* [硬] 寄存器读写 + SPI 命令路径 */
//#define ENABLE_PHASE2_TESTS /* [硬] TCP Client 收发 + 关闭重连 */
//#define ENABLE_PHASE3_TESTS /* [硬] UDP Server Echo */
@@ -58,16 +62,22 @@ extern "C" {
//#define ENABLE_PHASE9_TESTS /* [网] Select/Poll I/O 多路复用 */
//#define ENABLE_PHASE10_TESTS /* [网] 边界条件测试 */
//#define ENABLE_PHASE11_TESTS /* [网] KeepAlive 超时断开测试 */
/* ---- 链路时延基线(M1;分段/阈值见 docs/测试说明/链路延迟测试说明.md)---- */
//#define ENABLE_PHASE12_TESTS /* [硬] SPI 字节命令 / DMA 时延基线(L1/L2,无需 PC)—— M1 已完成,基线见说明文档 §4.1 */
/* ---- 链路时延(M2/M3;分段/阈值见 docs/测试说明/链路延迟测试说明.md)---- */
//#define ENABLE_PHASE13_TESTS /* [网] 芯片 + 队列时延(L3~L7/L10,需 PC peer)—— M2 已完成,基线见说明文档 §4.2 */
#define ENABLE_PHASE14_TESTS /* [网] 生产路径时延(L8~L11 + 模拟器 Δ)—— M3,需 peer --mode remo(:7003) */
#if defined(ENABLE_PHASE1_TESTS) || defined(ENABLE_PHASE2_TESTS) || \
defined(ENABLE_PHASE3_TESTS) || defined(ENABLE_PHASE4_TESTS) || \
defined(ENABLE_PHASE5_TESTS)
defined(ENABLE_PHASE5_TESTS) || defined(ENABLE_PHASE12_TESTS)
#define ENABLE_HW_LAYER_TESTS 1
#endif
#if defined(ENABLE_PHASE6_TESTS) || defined(ENABLE_PHASE7_TESTS) || \
defined(ENABLE_PHASE8_TESTS) || defined(ENABLE_PHASE9_TESTS) || \
defined(ENABLE_PHASE10_TESTS) || defined(ENABLE_PHASE11_TESTS)
defined(ENABLE_PHASE10_TESTS) || defined(ENABLE_PHASE11_TESTS) || \
defined(ENABLE_PHASE13_TESTS) || defined(ENABLE_PHASE14_TESTS)
#define ENABLE_NET_LAYER_TESTS 1
#endif
+11 -3
View File
@@ -13,6 +13,7 @@
#include "ch395f_test_task.h"
#include "ch395f_test.h"
#include "net_test_task.h"
#include "net_latency_test.h"
#include "cmsis_os.h"
#include "net_task.h"
#include "net_socket.h"
@@ -49,8 +50,10 @@ test_stats_t g_test_stats;
* =================================================================== */
/* 解析点分十进制 IP 字符串为数组(避免引入 sscanf 库)。
* 仅硬件层阶段(1~5)使用,NET 层无需此辅助。 */
#if defined(ENABLE_HW_LAYER_TESTS)
* 仅硬件层 Phase 2(TCP Client 目标 IP)与 Phase 5(大文件目标 IP)使用;
* 守卫精确到使用方,避免只启用其他 HW 阶段时产生 #177-D
* (2026-09-22:仅启用 Phase 12 构建实证暴露此潜在警告)。 */
#if defined(ENABLE_PHASE2_TESTS) || defined(ENABLE_PHASE5_TESTS)
static void test_parse_ip(const char *str, uint8_t *ip_arr)
{
uint32_t val;
@@ -68,7 +71,7 @@ static void test_parse_ip(const char *str, uint8_t *ip_arr)
}
}
}
#endif /* ENABLE_HW_LAYER_TESTS */
#endif /* ENABLE_PHASE2_TESTS || ENABLE_PHASE5_TESTS */
#ifdef ENABLE_PHASE1_TESTS
static void phase1_run(void) {
@@ -1377,6 +1380,11 @@ void ch395f_test_run(void)
#endif
#ifdef ENABLE_PHASE5_TESTS
phase5_run();
#endif
#ifdef ENABLE_PHASE12_TESTS
/* Phase 12(链路时延基线 L1/L2,M1):独立于 Phase 1~5 的 SPI 时延
* 采样,放硬件层阶段末尾执行(说明文档:链路延迟测试说明.md) */
net_latency_phase12_run();
#endif
}
#else
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FTP 数据连接回归测试(D20 验证 + 日后回归)
背景(2026-09-22 台架实测 D20):同一 FTP 会话内**第二个数据连接必失败**
(425 Can't open data connection)。根因是单连接 Socket 复用时的"陈旧 DISCONNECT"
误判:上一条数据连接的 FIN/ACK 在 Socket 已复用为新连接之后才上报,新连接被
net 层当作"刚建立就断开"释放。修复见 net_socket.c handle_disconnect_event 的
陈旧事件甄别(回读芯片 TCP 状态,仍 ESTABLISHED 则丢弃该中断)。
本脚本在一个会话里连续做多次数据连接(LIST/NLST/STOR/RETR),并做大文件往返
内容校验;任一环节失败即退出码 1。
用法:
& '.venv\\Scripts\\python.exe' test/ftp_regression.py --host 192.168.1.100
"""
import argparse
import io
import sys
import time
from ftplib import FTP
SMALL = b"D20-small-" + b"A" * 1017 # 1024B
BIG = bytes(((i * 7 + 3) & 0xFF) for i in range(32768)) # 32KB 模式数据
def main(argv=None):
ap = argparse.ArgumentParser(description="FTP data-connection regression (D20)")
ap.add_argument("--host", default="192.168.1.100", help="device IP")
ap.add_argument("--port", type=int, default=21, help="FTP control port")
ap.add_argument("--timeout", type=int, default=20, help="socket timeout (s)")
args = ap.parse_args(argv)
results = []
def step(name, fn):
"""执行一步并记录结果;不抛异常,便于一次跑完全部步骤。"""
t0 = time.time()
try:
extra = fn()
dt = time.time() - t0
results.append((name, True, dt))
print(" %-16s OK %6.2fs %s" % (name, dt, extra if extra is not None else ""))
return True
except Exception as e: # noqa: BLE001
dt = time.time() - t0
results.append((name, False, dt))
print(" %-16s FAIL %6.2fs %s %s" % (name, dt, type(e).__name__, e))
return False
f = FTP()
print("[ftp] connecting %s:%d ..." % (args.host, args.port))
f.connect(args.host, args.port, timeout=args.timeout)
f.login("anonymous", "x@x")
print("[ftp] login ok: %s" % f.getwelcome())
# 同一会话内连续 8 次数据连接(含两次大文件)—— D20 的失败点是第 2 次
step("LIST #1", lambda: "%d lines" % len(f.nlst()))
step("STOR #2 1KB", lambda: f.storbinary("STOR d20s.tmp", io.BytesIO(SMALL)) or "226")
got = bytearray()
step("RETR #3 1KB", lambda: (f.retrbinary("RETR d20s.tmp", got.extend), "226")[1])
if bytes(got) != SMALL:
print(" !! RETR #3 content mismatch (%d bytes)" % len(got))
results.append(("RETR#3-content", False, 0.0))
else:
results.append(("RETR#3-content", True, 0.0))
step("LIST #4", lambda: "%d lines" % len(f.nlst()))
step("STOR #5 32KB", lambda: f.storbinary("STOR d20b.tmp", io.BytesIO(BIG)) or "226")
big = bytearray()
step("RETR #6 32KB", lambda: (f.retrbinary("RETR d20b.tmp", big.extend), "226")[1])
if bytes(big) != BIG:
print(" !! RETR #6 content mismatch (%d/%d bytes)" % (len(big), len(BIG)))
results.append(("RETR#6-content", False, 0.0))
else:
results.append(("RETR#6-content", True, 0.0))
step("LIST #7", lambda: "%d lines" % len(f.nlst()))
step("cleanup", lambda: (f.delete("d20s.tmp"), f.delete("d20b.tmp"), "deleted")[2])
step("QUIT", lambda: f.quit() or "221")
ok = sum(1 for _, good, _ in results if good)
print("[ftp] %d/%d steps passed" % (ok, len(results)))
return 0 if ok == len(results) else 1
if __name__ == "__main__":
sys.exit(main())
+836
View File
@@ -0,0 +1,836 @@
#!/usr/bin/env python3
"""latency_peer.py - Link latency test peer for Phase 13 (M2).
Server-driven protocol: the MCU (testTask, port 8080) sends one operation
byte; the peer executes the corresponding traffic pattern. See
docs/测试说明/链路延迟测试说明.md §2.5 for the full protocol spec.
Ops (MCU -> peer):
0x01 L5 recv flood : peer sends 50 x 100B (5ms apart)
0x02 L5 send drain : peer drains 50 x 100B
0x03 L6 : peer sends 10B request, expects 10B echo (timed)
0x04 L7 : peer sends 9B LCDMEM request, expects 3209B frame
0x05 L3 send drain : peer drains 32 x 4096B
0xFF done : session complete
The peer records host-side (L4-side) timing: first byte / frame total /
per-batch arrival curve / >=500ms spikes (TCP retransmission fingerprint).
Both the serial log and this JSON carry HOST timestamps -> direct correlation.
Usage:
python latency_peer.py --host 192.168.1.100 --port 8080 --json m2_net.json
python latency_peer.py --mode net --ftp-stress ... (E-02 head-of-line repro)
Pure stdlib (socket/json/time/argparse/threading). ASCII output only.
"""
import argparse
import json
import socket
import sys
import threading
import time
# --- Protocol constants (mirror test/net_latency_test.c Phase 13 defines) ---
HELLO = b"\x5a\xa5\x58\x01"
OP_L5_RECV = 0x01
OP_L5_SEND = 0x02
OP_L6 = 0x03
OP_L7 = 0x04
OP_L3 = 0x05
OP_DONE = 0xFF
L5_CHUNKS = 20 # 2026-09-22 run1: 50 chunks saturated the chip RX buffer
# (window close -> retransmit re-delivery -> RST); 20 @ 50ms
# = 1KB/s quiet baseline (see 链路延迟测试说明.md §8)
L5_LEN = 100
L6_SIZE = 10
L7_REQ_LEN = 9
L7_FRAME_LEN = 3209
L3_SENDS = 16 # mirror firmware LAT_P13_L3_SENDS (run3: 32->16, 64 chunk samples)
L3_LEN = 4096
SPIKE_MS = 500.0 # retransmission granularity fingerprint (D1)
# --- Production RemoDispBus constants (mirror drivers/BSP/UI/ui_net_sink.h) ---
REMO_TAG_CLIENT = 0xAA
REMO_TAG_DEVICE = 0xBB
REMO_CMD_KEEP = 0
REMO_CMD_INIT = 1
REMO_CMD_KEY = 2
REMO_CMD_LCDMEM = 3
REMO_MEMSIZE = 3200 # 160x160 1bpp
REMO_LCDMEM_FRAME = 4 + 4 + REMO_MEMSIZE + 1 # 3209 (header + offset + bitmap + CRC)
REMO_CYCLE_DEADLINE_S = 45.0 # per-cycle recv deadline (E-02 stress: up to 30s)
def now_ns():
return time.perf_counter_ns()
def iso_now():
return time.strftime("%Y-%m-%d %H:%M:%S")
def recv_exact(sock, n, deadline_ns):
buf = bytearray()
while len(buf) < n:
left = deadline_ns - now_ns()
if left <= 0:
raise TimeoutError("recv_exact deadline (%d/%d bytes)" % (len(buf), n))
try:
sock.settimeout(max(left, 1) / 1e9 / 5.0)
d = sock.recv(n - len(buf))
except socket.timeout:
continue
if not d:
raise ConnectionError("peer connection closed")
buf.extend(d)
return bytes(buf)
def l7_request(seq=0):
"""Production-shaped 9B LCDMEM request (ui_net_sink.c remo_handle):
[TAG 0xAA][CMD 0x03][LEN=4 BE][offset u32 BE = 0][CRC = XOR of the 4 offset bytes].
LEN is the DATA-segment length (4), NOT the requested bitmap size; the offset
field must be 0 (device only serves full-frame requests)."""
data = bytes([0, 0, 0, 0])
crc = 0
for b in data:
crc ^= b
return bytes([0xAA, 0x03, 0x00, 0x04]) + data + bytes([crc])
def l7_frame_ok(data):
"""Validate the 3209B production LCDMEM response (ui_net_sink.c remo_send):
[TAG 0xBB][CMD 0x03][LEN=3204 BE][offset u32 = 0][1bpp bitmap 3200B]
[CRC = XOR of the 3204 data bytes] -> 4 + 3204 + 1 = 3209B."""
if len(data) != L7_FRAME_LEN:
return False
if data[0] != 0xBB or data[1] != 0x03 or data[2] != 0x0C or data[3] != 0x84:
return False
payload = data[4:4 + 3204]
crc = 0
for b in payload:
crc ^= b
if data[4 + 3204] != crc:
return False
if payload[:4] != bytes(4):
return False
for i in range(3200):
if payload[4 + i] != (i & 0xFF):
return False
return True
def pattern(n, seed=0):
return bytes((seed + i) & 0xFF for i in range(n))
# ================= RemoDispBus production client (M3, :7003) =================
def xor_bytes(data):
crc = 0
for b in data:
crc ^= b
return crc
def remo_request(cmd, data=b""):
"""Client->device frame: [0xAA][CMD][LEN_H][LEN_L][DATA][CRC = XOR(DATA)]."""
body = bytes(data)
return (bytes([REMO_TAG_CLIENT, cmd, (len(body) >> 8) & 0xFF, len(body) & 0xFF])
+ body + bytes([xor_bytes(body)]))
def remo_lcdmem_request():
"""9B full-frame LCDMEM request (offset 0) - what the production client sends."""
return remo_request(REMO_CMD_LCDMEM, bytes(4))
def recv_upto(sock, want, deadline_ns):
"""Bounded read: never consumes more than `want` bytes (no over-read of a
following frame/op byte - the run10 lesson)."""
left = deadline_ns - now_ns()
if left <= 0:
raise TimeoutError("frame deadline")
try:
sock.settimeout(max(left, 1) / 1e9 / 5.0)
return sock.recv(want)
except socket.timeout:
return b""
def read_remo_frame(sock, deadline_ns, ack_probe=False):
"""Read exactly one device frame; returns tag/cmd/data/CRC + arrival curve.
The first read is bounded to 8B so the segment-arrival curve stays
fine-grained; subsequent reads are bounded by the remaining frame length
(device never pipelines: one response per request).
ack_probe=True: as soon as the frame is known to be incomplete, send one
byte the device parser discards (0x00, not TAG 0xAA). The resulting TCP
segment carries a piggybacked ACK for the data already received, which
discriminates "sender holds the last partial segment waiting for an ACK
(Nagle-style)" from "chip-internal fixed timer" - without any packet capture."""
buf = bytearray()
batches = []
first_ns = None
total = None
while True:
want = (8 - len(buf)) if total is None else (total - len(buf))
if want <= 0:
break
d = recv_upto(sock, min(want, 4096), deadline_ns)
if not d:
if total is not None:
raise ConnectionError("frame truncated (%d/%d bytes)" % (len(buf), total))
continue # header still incomplete: wait until deadline
t = now_ns()
if first_ns is None:
first_ns = t
buf.extend(d)
batches.append([t, len(d)])
if total is None and len(buf) >= 4:
total = 4 + (((buf[2] << 8) | buf[3])) + 1
if ack_probe and total is not None and len(buf) < total:
try:
sock.sendall(b"\x00") # parser-safe: discarded while waiting for TAG 0xAA
except OSError:
pass
data = bytes(buf[4:total - 1])
crc = buf[total - 1]
return {"tag": buf[0], "cmd": buf[1], "data": data, "total": total,
"crc_ok": crc == xor_bytes(data), "first_ns": first_ns, "batches": batches}
def enable_immediate_ack(sock):
"""Windows: disable delayed ACK on this socket (SIO_TCP_SET_ACK_FREQUENCY=1).
Root-cause fix for the ~50ms tail: the CH395F (MSS=1024) holds the final
sub-MSS segment of a 3209B frame per Nagle until the peer ACKs; Windows'
delayed ACK defers that ACK ~50ms. Setting ACK frequency=1 makes every
segment ACKed immediately -> tail 52ms -> ~4ms (measured). Returns True on
success. Other platforms: no-op (Linux uses TCP_QUICKACK per recv)."""
if sys.platform != "win32":
return False
try:
import ctypes
ws2 = ctypes.WinDLL("ws2_32", use_last_error=True)
WSAIoctl = ws2.WSAIoctl
WSAIoctl.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p, ctypes.c_uint32,
ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(ctypes.c_uint32),
ctypes.c_void_p, ctypes.c_void_p]
WSAIoctl.restype = ctypes.c_int
val = ctypes.c_uint32(1) # 1 = ACK every segment
ret = ctypes.c_uint32(0)
rc = WSAIoctl(ctypes.c_void_p(sock.fileno()), 0x98000017,
ctypes.byref(val), 4, None, 0, ctypes.byref(ret), None, None)
return rc == 0
except (OSError, AttributeError):
return False
def remo_bitmap_nontrivial(payload):
"""Same 'non-trivial frame' criterion as simulator/tests/check_remo_sink.py:
the 1bpp bitmap must not be a single repeated byte (blank/broken frame)."""
bmp = payload[4:4 + REMO_MEMSIZE]
return len(set(bmp)) > 2
class RemoPeer:
"""Production-path peer (M3): drives L8/L9 against :7003 (device) or the
simulator, measuring the full client-observed frame cycle."""
def __init__(self, host, port, wait_s, json_path, samples, cadence_ms,
stress_from, nodelay, reconnect_rounds=1, ack_probe=False, ack_fast=False):
self.host = host
self.port = port
self.wait_s = wait_s
self.json_path = json_path
self.samples = samples
self.cadence_s = cadence_ms / 1000.0
self.stress_from = stress_from # cycle index where FTP stress starts
self.nodelay = nodelay
self.reconnect_rounds = max(1, reconnect_rounds)
self.ack_probe = ack_probe # in-band ACK-release experiment (tail diagnosis)
self.ack_fast = ack_fast # Windows immediate-ACK socket option
self.ack_fast_ok = None
self.sock = None
self.t0_wall = None
self.t0_ns = 0
self.connect_ms = None
self.reconnect_ms = None
self.reconnect_list = []
self.init_rtt_ms = None
self.geom = {}
self.frames = []
# --- connection ---
def connect_once(self):
t = now_ns()
s = socket.create_connection((self.host, self.port), timeout=5.0)
if self.nodelay:
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
if self.ack_fast:
self.ack_fast_ok = enable_immediate_ack(s)
print("[peer] immediate-ACK (SIO_TCP_SET_ACK_FREQUENCY=1): %s"
% ("applied" if self.ack_fast_ok else "not available"))
self.sock = s
return (now_ns() - t) / 1e6
def connect_with_retry(self):
t_end = time.time() + self.wait_s
last = None
while time.time() < t_end:
try:
return self.connect_once()
except OSError as e:
last = e
time.sleep(1.0)
raise ConnectionError("cannot connect to %s:%d within %ds (%s)" %
(self.host, self.port, self.wait_s, last))
def init_roundtrip(self, deadline_s=5.0):
"""INIT request -> geometry reply; returns round-trip ms."""
t = now_ns()
self.sock.sendall(remo_request(REMO_CMD_INIT))
f = read_remo_frame(self.sock, t + int(deadline_s * 1e9))
ms = (now_ns() - t) / 1e6
if f["cmd"] == REMO_CMD_INIT and len(f["data"]) >= 6:
# 8B (fixed): [W u16][H u16][MEMSIZE u32 BE]; 6B = 旧固件(u32 被截半 → 0)
self.geom = {"w": (f["data"][0] << 8) | f["data"][1],
"h": (f["data"][2] << 8) | f["data"][3],
"framebytes": int.from_bytes(f["data"][4:8], "big")
if len(f["data"]) >= 8
else ((f["data"][4] << 8) | f["data"][5]),
"payload_len": len(f["data"])}
return ms
# --- FTP head-of-line stress (E-02) ---
def ftp_stress_worker(self, start_evt, stop_evt, hold_s):
"""Wait for the stress window to open, then hold an idle FTP login:
lftpd read_line -> blocking net_recv(0) inside netTask (NET_RECV_TIMEOUT_MS)
-> every queued message (incl. the LCDMEM response) waits behind it (E-02)."""
if not start_evt.wait(120.0):
sys.stdout.write("[peer] ftp stress: window never opened\n")
return
try:
s = socket.create_connection((self.host, 21), timeout=10.0)
s.settimeout(10.0)
s.sendall(b"USER latency\r\n")
reply = b""
while b"\r\n" not in reply:
d = s.recv(64)
if not d:
break
reply += d
sys.stdout.write("[peer] ftp stress armed (331: %r)\n" % reply[:40])
stop_evt.wait(hold_s)
try:
s.sendall(b"QUIT\r\n")
except OSError:
pass
s.close()
sys.stdout.write("[peer] ftp stress released\n")
except OSError as e:
sys.stdout.write("[peer] ftp stress FAILED: %s\n" % e)
# --- session ---
def one_session(self):
self.t0_wall = iso_now()
self.t0_ns = now_ns()
self.connect_ms = self.connect_with_retry()
print("[peer] connected %s:%d in %.1f ms" % (self.host, self.port, self.connect_ms))
self.init_rtt_ms = self.init_roundtrip()
print("[peer] INIT rtt=%.1f ms geom=%s" % (self.init_rtt_ms, self.geom))
stress_start = threading.Event()
stress_stop = threading.Event()
stress_thread = None
if self.stress_from is not None:
# hold long enough to cover the remaining cycles (+15s margin)
hold_s = self.cadence_s * max(1, self.samples - self.stress_from) + 15.0
stress_thread = threading.Thread(
target=self.ftp_stress_worker,
args=(stress_start, stress_stop, hold_s),
daemon=True)
stress_thread.start()
for i in range(self.samples):
if self.stress_from is not None and i == self.stress_from:
print("[peer] === FTP stress ON (E-02 head-of-line) at cycle %d ===" % i)
stress_start.set()
time.sleep(0.5) # let the FTP half-login block netTask
stressed = (self.stress_from is not None and i >= self.stress_from)
t = now_ns()
self.sock.sendall(remo_lcdmem_request())
f = read_remo_frame(self.sock, t + int(REMO_CYCLE_DEADLINE_S * 1e9),
ack_probe=self.ack_probe)
t_end = now_ns()
first_ms = (f["first_ns"] - t) / 1e6
total_ms = (t_end - t) / 1e6
tail_ms = (f["batches"][-1][0] - f["batches"][0][0]) / 1e6 if len(f["batches"]) > 1 else 0.0
rec = {
"cycle": i,
"stress": stressed,
"first_ms": round(first_ms, 3),
"total_ms": round(total_ms, 3),
"tail_lag_ms": round(tail_ms, 3),
"batches": [[round((b[0] - t) / 1e6, 3), b[1]] for b in f["batches"]],
"bytes": f["total"],
"tag": f["tag"],
"cmd": f["cmd"],
"crc_ok": bool(f["crc_ok"]),
"nontrivial": bool(f["cmd"] == REMO_CMD_LCDMEM
and len(f["data"]) == 4 + REMO_MEMSIZE
and remo_bitmap_nontrivial(f["data"])),
}
self.frames.append(rec)
if (i % 10) == 0 or stressed:
print("[peer] cycle %3d %-7s first=%7.1f total=%8.1f ms batches=%d %s"
% (i, "STRESS" if stressed else "quiet", first_ms, total_ms,
len(f["batches"]),
"OK" if (rec["crc_ok"] and rec["nontrivial"]) else "BAD"))
if i + 1 < self.samples:
time.sleep(self.cadence_s)
if stress_thread is not None:
stress_stop.set()
self.dump_json()
# --- L9: close + reconnect rounds (each close drives the device auto-relisten
# probe; each reconnect measures connect + INIT round trip) ---
for r in range(self.reconnect_rounds):
self.sock.close()
print("[peer] round %d/%d: closed; reconnect in 1.5s (device relisten window) ..."
% (r + 1, self.reconnect_rounds))
time.sleep(1.5)
t = now_ns()
self.connect_once()
self.init_rtt_ms = self.init_roundtrip()
ms = (now_ns() - t) / 1e6
self.reconnect_list.append(round(ms, 3))
self.reconnect_ms = ms
print("[peer] round %d: reconnect + INIT = %.1f ms" % (r + 1, ms))
rep = self.dump_json()
self.print_summary(rep)
self.sock.close()
# --- reporting ---
def _split(self, key, stressed):
return [f[key] for f in self.frames if f["stress"] is stressed]
def _spikes(self, vals):
return sum(1 for x in vals if x >= SPIKE_MS)
def dump_json(self, error=None):
all_total = [f["total_ms"] for f in self.frames]
all_first = [f["first_ms"] for f in self.frames]
all_tail = [f["tail_lag_ms"] for f in self.frames]
quiet_total = self._split("total_ms", False)
stress_total = self._split("total_ms", True)
rep = {
"mode": "remo",
"host": self.host,
"port": self.port,
"ts_start": self.t0_wall,
"ts_end": iso_now(),
"duration_s": round((now_ns() - self.t0_ns) / 1e9, 2),
"samples": self.samples,
"cadence_ms": round(self.cadence_s * 1000.0, 1),
"tcp_nodelay": bool(self.nodelay),
"immediate_ack": (None if not self.ack_fast else bool(self.ack_fast_ok)),
"in_band_ack_probe": bool(self.ack_probe),
"stress_from": self.stress_from,
"connect_ms": None if self.connect_ms is None else round(self.connect_ms, 3),
"reconnect_ms": None if self.reconnect_ms is None else round(self.reconnect_ms, 3),
"reconnect_list_ms": self.reconnect_list,
"reconnect_stat": stat_block(self.reconnect_list),
"init": {"rtt_ms": None if self.init_rtt_ms is None else round(self.init_rtt_ms, 3),
"geom": self.geom},
"cycle_all": stat_block(all_total, self._spikes(all_total)),
"cycle_quiet": stat_block(quiet_total, self._spikes(quiet_total)),
"cycle_stress": stat_block(stress_total, self._spikes(stress_total)),
"first_byte_all": stat_block(all_first, self._spikes(all_first)),
"tail_lag_all": stat_block(all_tail, self._spikes(all_tail)),
"crc": {"ok": sum(1 for f in self.frames if f["crc_ok"]),
"fail": sum(1 for f in self.frames if not f["crc_ok"])},
"bitmap": {"nontrivial_ok": sum(1 for f in self.frames if f["nontrivial"]),
"nontrivial_fail": sum(1 for f in self.frames if not f["nontrivial"])},
"frames": self.frames,
}
if error:
rep["error"] = error
if self.json_path:
with open(self.json_path, "w", encoding="utf-8") as fh:
json.dump(rep, fh, ensure_ascii=False, indent=1)
print("[peer] JSON report -> %s%s" % (self.json_path,
" (PARTIAL - error)" if error else ""))
return rep
def print_summary(self, rep):
print("=== latency_peer summary (M3 / Phase 14, production RemoDispBus) ===")
print("host=%s:%d samples=%d cadence=%.0fms nodelay=%d duration=%.1fs"
% (self.host, self.port, rep["samples"], rep["cadence_ms"],
int(rep["tcp_nodelay"]), rep["duration_s"]))
print("connect=%s ms reconnect+INIT=%s ms geom=%s"
% (rep["connect_ms"], rep["reconnect_ms"], rep["init"]["geom"]))
for key in ("cycle_all", "cycle_quiet", "cycle_stress",
"first_byte_all", "tail_lag_all"):
b = rep[key]
if b.get("n"):
print("%-15s n=%-4d min=%9.1f med=%9.1f p95=%9.1f max=%9.1f ms spikes=%d"
% (key, b["n"], b["min_ms"], b["med_ms"], b["p95_ms"], b["max_ms"],
b.get("spikes500", 0)))
else:
print("%-15s (no samples)" % key)
print("crc ok/fail = %d/%d bitmap nontrivial ok/fail = %d/%d"
% (rep["crc"]["ok"], rep["crc"]["fail"],
rep["bitmap"]["nontrivial_ok"], rep["bitmap"]["nontrivial_fail"]))
def run(self):
try:
self.one_session()
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
self.dump_json(error="%s: %s" % (type(e).__name__, e))
raise
def percentile(sorted_vals, pct):
if not sorted_vals:
return None
idx = (len(sorted_vals) * pct) // 100
if idx >= len(sorted_vals):
idx = len(sorted_vals) - 1
return sorted_vals[idx]
def stat_block(ms_vals, spikes=0):
if not ms_vals:
return {"n": 0}
s = sorted(ms_vals)
return {
"n": len(s),
"min_ms": round(s[0], 3),
"med_ms": round(s[len(s) // 2], 3),
"p95_ms": round(percentile(s, 95), 3),
"max_ms": round(s[-1], 3),
"spikes500": spikes,
}
def fresh_reports():
"""Per-session measurement containers (reset on each retry attempt)."""
return {
"l6": {"samples": [], "echo_ok": 0, "echo_fail": 0},
"l7": {"frames": [], "first_ms": [], "total_ms": [], "spikes500": 0,
"crc_ok": 0, "crc_fail": 0, "batches_per_frame": []},
"l5_recv": {"bytes": 0, "window_ms": None},
"l5_send": {"bytes": 0, "window_ms": None},
"l3": {"bytes": 0, "window_ms": None},
}
class Peer:
def __init__(self, host, port, wait_s, json_path, ftp_stress):
self.host = host
self.port = port
self.wait_s = wait_s
self.connect_wait_s = wait_s # per-attempt connect budget (retries shrink it)
self.json_path = json_path
self.ftp_stress = ftp_stress
self.sock = None
self.t0_wall = None
self.t0_ns = 0
self.n_ops = 0
self.reports = fresh_reports()
def connect_with_retry(self):
t_end = time.time() + self.connect_wait_s
while time.time() < t_end:
try:
s = socket.create_connection((self.host, self.port), timeout=5.0)
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
self.sock = s
return
except OSError:
time.sleep(1.0)
raise ConnectionError("cannot connect to %s:%d within %ds" %
(self.host, self.port, self.wait_s))
def ftp_stress_worker(self, start_evt, stop_evt):
"""Hold an idle FTP login: lftpd read_line -> blocking net_recv(0) in
netTask (30s timeout) -> all queued messages wait behind it (E-02)."""
try:
s = socket.create_connection((self.host, 21), timeout=10.0)
s.settimeout(10.0)
s.sendall(b"USER latency\r\n")
reply = b""
while b"\r\n" not in reply:
d = s.recv(64)
if not d:
break
reply += d
start_evt.set() # L5 window starts under stress
sys.stdout.write("[peer] ftp stress armed (331: %r)\n" % reply[:40])
stop_evt.wait(35.0) # > 30s: netTask read times out (full freeze)
try:
s.sendall(b"QUIT\r\n")
except OSError:
pass
s.close()
sys.stdout.write("[peer] ftp stress released\n")
except OSError as e:
sys.stdout.write("[peer] ftp stress FAILED: %s\n" % e)
start_evt.set()
def dump_json(self, error=None):
"""Build the report dict and write it (idempotent; error => partial)."""
rep = {
"mode": "net",
"host": self.host,
"port": self.port,
"ts_start": self.t0_wall,
"ts_end": iso_now(),
"duration_s": round((now_ns() - self.t0_ns) / 1e9, 2),
"ops_seen": self.n_ops,
"ftp_stress": self.ftp_stress,
"seg_l6": stat_block(self.reports["l6"]["samples"], 0)
| {"echo_ok": self.reports["l6"]["echo_ok"], "echo_fail": self.reports["l6"]["echo_fail"]},
"seg_l7_first_byte": stat_block(self.reports["l7"]["first_ms"]),
"seg_l7_total": stat_block(self.reports["l7"]["total_ms"], self.reports["l7"]["spikes500"]),
"l7_crc": {"ok": self.reports["l7"]["crc_ok"], "fail": self.reports["l7"]["crc_fail"],
"batches_per_frame": sorted(set(self.reports["l7"]["batches_per_frame"]))},
"l5_recv": self.reports["l5_recv"],
"l5_send": self.reports["l5_send"],
"l3_drain": self.reports["l3"],
"frames_l7": self.reports["l7"]["frames"],
}
if error:
rep["error"] = error
if self.json_path:
with open(self.json_path, "w", encoding="utf-8") as f:
json.dump(rep, f, indent=1, ensure_ascii=True)
print("[peer] JSON report -> %s%s" % (self.json_path,
" (PARTIAL - error)" if error else ""))
return rep
def run(self, max_attempts=3):
"""整会话重试:芯片对空闲连接自行 RST(MCU 栈慢于芯片握手完成时,连接
建立后 15~30s 无流量即被芯片超时断开,2026-09-22 台架实证)→ 重连 +
MCU 端 re-HELLO 重启 = 闭环。重试时缩短 connect 预算(设备已在运行)。"""
last_err = None
for attempt in range(1, max_attempts + 1):
self.t0_wall = iso_now()
self.t0_ns = now_ns()
self.n_ops = 0
self.reports = fresh_reports()
self.connect_wait_s = self.wait_s if attempt == 1 else min(self.wait_s, 60)
try:
self.one_session()
return
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
last_err = e
self.close_quiet()
if attempt < max_attempts:
print("[peer] session attempt %d/%d failed: %s" % (attempt, max_attempts, e))
print("[peer] reconnect in 2s (MCU auto-relistens; re-HELLO restarts) ...")
time.sleep(2.0)
raise RuntimeError("all %d session attempts failed (last: %s)" % (max_attempts, last_err))
def close_quiet(self):
try:
if self.sock is not None:
self.sock.close()
except OSError:
pass
self.sock = None
def one_session(self):
t0_wall = self.t0_wall
t0_ns = self.t0_ns
print("[peer] connecting %s:%d (wait up to %ds) ..." % (self.host, self.port, self.connect_wait_s))
self.connect_with_retry()
print("[peer] connected at %s" % iso_now())
l5_start_evt = threading.Event()
l5_stop_evt = threading.Event()
if self.ftp_stress:
th = threading.Thread(target=self.ftp_stress_worker,
args=(l5_start_evt, l5_stop_evt), daemon=True)
th.start()
self.sock.sendall(HELLO)
print("[peer] HELLO sent, awaiting MCU ops ...")
l5_window_open = False
l5_t0 = 0
l3_t0 = 0
while True:
op = recv_exact(self.sock, 1, t0_ns + 300 * 1000 * 1000000)
op = op[0]
self.n_ops += 1
if op == OP_DONE:
print("[peer] op=done after %d ops" % self.n_ops)
break
if op == OP_L5_RECV:
if self.ftp_stress:
l5_start_evt.wait(60.0) # start window only under full stress
l5_window_open = True
l5_t0 = now_ns()
for i in range(L5_CHUNKS):
self.sock.sendall(pattern(L5_LEN, seed=i))
time.sleep(0.05) # 50ms: keep 1KB/s below MCU drain rate
self.reports["l5_recv"]["window_ms"] = (now_ns() - l5_t0) / 1e6
self.reports["l5_recv"]["bytes"] = L5_CHUNKS * L5_LEN
l5_stop_evt.set()
elif op == OP_L5_SEND:
t = now_ns()
data = recv_exact(self.sock, L5_CHUNKS * L5_LEN, t + 120 * 1000 * 1000000)
self.reports["l5_send"]["bytes"] = len(data)
self.reports["l5_send"]["window_ms"] = (now_ns() - t) / 1e6
elif op == OP_L6:
t0 = now_ns()
req = pattern(L6_SIZE, seed=self.n_ops)
self.sock.sendall(req)
resp = recv_exact(self.sock, L6_SIZE, t0 + 40 * 1000 * 1000000)
ms = (now_ns() - t0) / 1e6
self.reports["l6"]["samples"].append(ms)
if resp == req:
self.reports["l6"]["echo_ok"] += 1
else:
self.reports["l6"]["echo_fail"] += 1
elif op == OP_L7:
t_send = now_ns()
self.sock.sendall(l7_request(self.n_ops))
got = bytearray()
batches = []
first_ns = None
while len(got) < L7_FRAME_LEN:
# bounded read: never consume the next op byte (ASCII 0x04) that
# follows the frame in the stream (run10: unbounded recv ate it ->
# 3210-byte "frame" -> length check failed -> peer closed)
d = self.sock.recv(min(4096, L7_FRAME_LEN - len(got)))
if not d:
raise ConnectionError("L7 frame truncated (%d/%d)" %
(len(got), L7_FRAME_LEN))
if first_ns is None:
first_ns = now_ns()
got.extend(d)
batches.append([round((now_ns() - t_send) / 1e6, 3), len(d)])
t_end = now_ns()
first_ms = (first_ns - t_send) / 1e6 if first_ns else None
total_ms = (t_end - t_send) / 1e6
# inter-batch arrival gaps
spikes = 0
for k in range(1, len(batches)):
if batches[k][0] - batches[k - 1][0] >= SPIKE_MS:
spikes += 1
self.reports["l7"]["first_ms"].append(first_ms if first_ms is not None else 0.0)
self.reports["l7"]["total_ms"].append(total_ms)
self.reports["l7"]["spikes500"] += spikes
self.reports["l7"]["batches_per_frame"].append(len(batches))
if l7_frame_ok(bytes(got)):
self.reports["l7"]["crc_ok"] += 1
else:
self.reports["l7"]["crc_fail"] += 1
self.reports["l7"]["frames"].append(
{"first_ms": round(first_ms, 3) if first_ms is not None else None,
"total_ms": round(total_ms, 3), "batches": batches})
elif op == OP_L3:
l3_t0 = now_ns()
recv_exact(self.sock, L3_SENDS * L3_LEN, l3_t0 + 120 * 1000 * 1000000)
self.reports["l3"]["bytes"] = L3_SENDS * L3_LEN
self.reports["l3"]["window_ms"] = (now_ns() - l3_t0) / 1e6
else:
raise RuntimeError("unknown op byte 0x%02X (protocol desync)" % op)
# --- JSON report (also used on the error path: partial data kept) ---
rep = self.dump_json()
print("=== latency_peer summary (M2 / Phase 13) ===")
print("ops=%d duration=%.1fs ftp_stress=%d" % (self.n_ops, rep["duration_s"], int(self.ftp_stress)))
for key in ("seg_l6", "seg_l7_first_byte", "seg_l7_total"):
b = rep[key]
if b.get("n"):
print("%-18s n=%-4d min=%9.1f med=%9.1f p95=%9.1f max=%9.1f ms spikes=%d"
% (key, b["n"], b["min_ms"], b["med_ms"], b["p95_ms"], b["max_ms"],
b.get("spikes500", 0)))
else:
print("%-18s (no samples)" % key)
print("l7 crc ok/fail = %d/%d batches/frame = %s"
% (rep["l7_crc"]["ok"], rep["l7_crc"]["fail"], rep["l7_crc"]["batches_per_frame"]))
self.sock.close()
def main(argv=None):
ap = argparse.ArgumentParser(
description="link latency test peer - M2 Phase 13 (net) / M3 Phase 14 (remo)")
ap.add_argument("--mode", default="net", choices=["net", "remo"],
help="net = Phase 13 test protocol :8080; "
"remo = production RemoDispBus client :7003 (Phase 14 / simulator)")
ap.add_argument("--host", default="192.168.1.100", help="device IP")
ap.add_argument("--port", type=int, default=None,
help="listen port (default: 8080 for net, 7003 for remo)")
ap.add_argument("--wait", type=int, default=180,
help="max seconds to retry connect (device boots after flash)")
ap.add_argument("--json", default=None, help="write JSON report to this path")
ap.add_argument("--ftp-stress", action="store_true",
help="hold idle FTP login (E-02 head-of-line repro)")
ap.add_argument("--samples", type=int, default=50,
help="remo mode: LCDMEM frame cycles to measure (default 50)")
ap.add_argument("--cadence-ms", type=int, default=100,
help="remo mode: client poll cadence in ms (production client = 100)")
ap.add_argument("--stress-from", type=int, default=None,
help="remo mode: cycle index where FTP stress starts "
"(default with --ftp-stress: half of --samples)")
ap.add_argument("--nodelay", action="store_true",
help="remo mode: set TCP_NODELAY on the client socket")
ap.add_argument("--reconnect-rounds", type=int, default=1,
help="remo mode: close+reconnect rounds at the end (each one drives "
"the device auto-relisten probe; M3 uses 3)")
ap.add_argument("--ack-probe", action="store_true",
help="remo mode: when a frame is still incomplete, send one parser-safe "
"byte so the TCP segment piggybacks an ACK (diagnoses whether the "
"~50ms tail waits for an ACK; no packet capture needed)")
ap.add_argument("--ack-fast", action="store_true",
help="remo mode: Windows only - disable delayed ACK on this socket "
"(SIO_TCP_SET_ACK_FREQUENCY=1); removes the ~50ms frame tail")
args = ap.parse_args(argv)
if args.mode == "remo":
port = args.port if args.port else 7003
stress_from = args.stress_from
if args.ftp_stress and stress_from is None:
stress_from = max(1, args.samples // 2)
peer = RemoPeer(args.host, port, args.wait, args.json, args.samples,
args.cadence_ms, stress_from, args.nodelay,
args.reconnect_rounds, args.ack_probe, args.ack_fast)
else:
peer = Peer(args.host, args.port or 8080, args.wait, args.json, args.ftp_stress)
try:
peer.run()
return 0
except (ConnectionError, TimeoutError, RuntimeError, OSError) as e:
print("[peer] ERROR: %s" % e)
try:
peer.dump_json(error=str(e)) # partial report: keep measured data
except OSError as e2:
print("[peer] (JSON write failed: %s)" % e2)
return 1
if __name__ == "__main__":
sys.exit(main())
File diff suppressed because it is too large Load Diff
+228
View File
@@ -0,0 +1,228 @@
/******************************************************************************
* 模块名称:CH395F 链路延迟测试
* 模块功能:链路分段时延测量的微秒时基(DWT CYCCNT)、采样统计
* (min/med/p95/max + 尖峰计数)、机器可读 [LAT] 报告输出与判定。
* Phase 12(硬件层,M1):SPI 字节命令 / DMA 时延基线(L1/L2,无需 PC);
* Phase 13/14(NET 层,M2/M3 规划):芯片发送握手 / 队列往返 /
* 生产路径时延。分段定义、判定基线与执行流程见
* docs/测试说明/链路延迟测试说明.md。
* 适用平台:STM32F4 系列(CH395F)
* 作者:王建锋
* 创建日期:2026-09-22
* 修改记录:
* 2026-09-22 王建锋 初版:时基 + 统计/报告 + Phase 12(L1/L2 SPI 时延基线)
******************************************************************************/
#ifndef NET_LATENCY_TEST_H
#define NET_LATENCY_TEST_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
/*
* === 链路时延测试阶段一览(见 链路延迟测试说明.md §2.2) ===
* Phase 12 [硬件层] SPI 字节命令 / DMA 时延基线(L1/L2,无需 PC) —— M1(已完成)
* Phase 13 [NET 层] 芯片 + 队列时延(L3~L7/L10,需 PC peer) —— M2(已实现)
* Phase 14 [NET 层] 生产路径时延(L8~L11 + 模拟器 A/B,需 PC peer)—— M3(规划)
*
* 时基说明:DWT CYCCNT 为 32 位,168MHz 下回绕周期 25.17ms。
* M1 各段(<5ms)与 M2 调用方侧测量(单次 <25ms)用单次读数差即可;
* 跨 25ms 的段(L4/L8 整帧与重传尖峰)由 PC 端 peer 计时。
*
* Phase 13 协议(服务器驱动:MCU 发操作字节,peer 执行;详见说明文档 §2.5):
* HELLO = 5A A5 58 01(peer → MCU,4 字节)
* 操作字节(MCU → peer):0x01=L5 recv flood / 0x02=L5 send flood /
* 0x03=L6 单请求 / 0x04=L7 LCDMEM 全帧 / 0x05=L3 send flood / 0xFF=完成
* Phase 13 需**独立运行**(同 Phase 4 约定):与其他 Phase 共用 8080 端口
* 的 socket,混跑会污染帧测量。
*/
/* verdict 三态(lat_stat_report 输出,调用方据此汇总) */
#define LAT_VERDICT_OK 0
#define LAT_VERDICT_WARN 1
#define LAT_VERDICT_FAIL 2
/*
* 采样保存上限:静态数组容量(自研代码禁用 C 库堆,见 AGENTS.md)。
* n 可超过该值(min/max/spikes 仍对全量累计;med/p95 按已保存样本计算)。
*/
#define LAT_MAX_SAMPLES 128
/*
* 采样统计容器。
* n - 总样本数(可 > LAT_MAX_SAMPLES)
* mn/mx - 全量 min/max(us)
* med - 中位数(us,lat_stat_finish 后有效)
* p95 - 95 分位(us,lat_stat_finish 后有效)
* spikes - 达到尖峰阈值的样本数(spike_th_us=0 表示本段不统计)
*/
typedef struct {
uint16_t n;
uint32_t mn;
uint32_t med;
uint32_t p95;
uint32_t mx;
uint16_t spikes;
} lat_stats_t;
/*
* 函数功能:使能 DWT CYCCNT 周期计数器时基(幂等)
* 入口参数:无
* 返回值:无
* 限定条件:任何测量前调用一次即可;仅测试构建路径会执行(产品构建不调用)
* 函数说明:CoreDebug->DEMCR(TRCENA) + DWT->CYCCNT 清零 + CYCCNTENA 置位
*/
void lat_time_init(void);
/*
* 函数功能:重置统计容器(mn 置 0xFFFFFFFF,空集可辨识)
* 入口参数:p - 统计容器
*/
void lat_stat_reset(lat_stats_t *p);
/*
* 函数功能:追加一个时延样本(微秒)
* 入口参数:p - 统计容器
* us - 本样本测量时长(微秒)
* spike_th_us - 尖峰阈值(us);0 表示本段不统计尖峰
*/
void lat_stat_add(lat_stats_t *p, uint32_t us, uint32_t spike_th_us);
/*
* 函数功能:采样结束后排序计算 med/p95(内部静态工作区,零堆)
* 入口参数:p - 统计容器(lat_stat_add 全部追加完成后调用)
*/
void lat_stat_finish(lat_stats_t *p);
/*
* 函数功能:输出 [LAT] 报告行并给出本段 verdict
* 入口参数:p_seg - 段名(纯 ASCII 标识,如 "spi_dma_1024")
* p - 统计容器(须先 lat_stat_finish)
* warn_med_us - WARN 阈值:med 超过 → WARN(重锁阈值提示,计 PASS)
* fail_med_us - FAIL 阈值:med 超过 → FAIL
* max_spikes - 尖峰数上限:spikes 超过 → FAIL
* p_verdict - 输出:本段 verdict(LAT_VERDICT_*)
* 返回值:无
* 函数说明:报告行格式见 链路延迟测试说明.md §2.4;字符串字面量纯 ASCII
*/
void lat_stat_report(const char *p_seg, const lat_stats_t *p,
uint32_t warn_med_us, uint32_t fail_med_us,
uint16_t max_spikes, int *p_verdict);
/*
* 函数功能:Phase 12 入口(硬件层:SPI 字节命令 / DMA 时延基线,L1/L2,无需 PC)
* 入口参数:无
* 返回值:无
* 限定条件:由 ch395f_test_run() 硬件层分支调用(net_stack_bring_up 完成后
* testTask 独占 CH395F,无 SPI 并发);仅台架执行
* 函数说明:内部先做 check_exist 存活自检,失败即判 FAIL 并跳过;
* 套件未选中本 Phase 时函数体为空(产品构建可编译、不执行)
*/
void net_latency_phase12_run(void);
/*
* 函数功能:Phase 13 入口(NET 层:芯片 + 队列时延 L3~L7/L10,需 PC peer)
* 入口参数:sockfd - 测试监听 socket(连接后的数据通道 fd,8080 端口)
* 返回值:无
* 限定条件:由 net_layer_test_main() 监听循环每 ~10ms 调用一次(事件驱动
* 状态机);需 test/latency_peer.py --mode net 配合;须独立运行
* 函数说明:服务器驱动协议(见文件头协议注释);会话完成后 s_done 置位,
* 后续调用为空操作;套件未选中本 Phase 时函数体为空
*/
void net_latency_phase13_run(int32_t sockfd);
/*
* 函数功能:Phase 13 会话是否活动(HELLO 等待/进行中/abort 重连等待)
* 返回值:1 - 活动(监听循环应跳过 net_accept,由本模块轮询接管)
* 0 - 非活动(正常完成/终态,恢复常规 accept 等待)
* 限定条件:Phase 13 未启用时恒返回 0
*/
int net_latency_phase13_busy(void);
/*
* 函数功能:Phase 14 入口(NET 层:生产路径时延 L8~L11,真实 uiTask + :7003)
* 入口参数:无(本 Phase 不占用测试 socket,测量生产 socket 的服务端行为)
* 返回值:无
* 限定条件:由 net_layer_test_main() 循环每 ~10ms 调用一次;需
* test/latency_peer.py --mode remo --port 7003 配合;须单独使能
* (与 Phase 13 同启时两者都会跑,M3 台架建议只开 Phase 14)
* 函数说明:状态机顺序 = ui_loop(本地节拍窗口)→ prod_cycle_mcu(等 peer
* 发满 N 帧 LCDMEM)→ relisten(等 peer 断开重连)→ DONE;
* 各窗口通过 TEST_LATENCY_PROBE 采集(uiTask/netTask 侧打点);
* 未定义 TEST_LATENCY_PROBE 时只打印跳过说明;套件未选中时函数体为空
*/
void net_latency_phase14_run(void);
/*
* 函数功能:Phase 14 是否活动(活动期监听循环应让位,保证 10ms 轮询节拍)
* 返回值:1 - 活动;0 - 非活动/未启用
*/
int net_latency_phase14_busy(void);
/*
* === TEST_LATENCY_PROBE 探针接口(M2/M3)===
*
* 用途:netTask 上下文(net_socket.c 发送分块循环)向 testTask 报告的
* 段内打点。单写者(netTask)/单读者(testTask),32 位变量原子
* 读写,无锁。仅在 TEST_LATENCY_PROBE 定义(测试构建)时编译。
*/
/* 当前是否处于采样窗口(netTask 侧高频查询,须极廉价) */
uint8_t lat_probe_armed(void);
/* testTask 侧开一个采样窗口:seg 段名、max_n 样本上限、spike_th_us 尖峰阈值 */
void lat_probe_arm(const char *seg, uint32_t max_n, uint32_t spike_th_us);
/* netTask 侧追加一个样本(us);未武装或已满 → 无操作 */
void lat_probe_add(uint32_t us);
/*
* 函数功能:**按段名**追加样本(多打点站点共存时唯一安全的写法)
* 入口参数:seg - 本打点站点所属段名(须与 lat_probe_arm 的 seg 完全一致)
* us - 样本值(us)
* 返回值:无
* 函数说明:探针是**全局单窗口**:窗口武装成 A 段时,B 段站点的 lat_probe_add
* 会把 B 的样本灌进 A 的窗口(2026-09-22 run17 实证:Phase 14 三段
* 全被 uiTask 循环样本填满,prod/relisten 都读出 ~11ms 的 ui_loop 值)。
* 只有段名匹配才落样本 → 各站点互不污染。多站点构建**一律**用本接口。
*/
void lat_probe_add_seg(const char *seg, uint32_t us);
/* 已采集样本数(32 位原子读) */
uint32_t lat_probe_count(void);
/* 关闭采样窗口(此后的 lat_probe_add 无操作;在飞样本最多超 1 个) */
void lat_probe_disarm(void);
/* testTask 侧取窗口统计(disarm 后调用;内部 lat_stat_finish 已含 med/p95) */
const lat_stats_t *lat_probe_stats(void);
/*
* 函数功能:DWT CYCCNT 读数 / 差值换算(供 net_socket.c 探针使用)
* 入口参数:c_start - 起始读数(lat_cycles_now 返回值)
* 返回值:当前读数(us 计数域)/ 自 c_start 起的微秒数(<25.17ms 有效)
*/
uint32_t lat_cycles_now(void);
uint32_t lat_cycles_diff_us(uint32_t c_start);
/* ---- M3 探针计时标记(可跨 >25ms 区间的安全计时) ----
* DWT CYCCNT 为 32 位 @168MHz → 单次差值超过 **25.17ms 即回绕**,会把 30ms 读成
* 4.8ms 这类"静默错误小值"(M1 各段均 <25ms 时无此问题,M3 的压力档/重监听会超)。
* lat_mark/lat_mark_us 同时记 DWT 与 HAL tick,跨度 ≥20ms 时自动改用 tick 兜底
* (分辨率降为 1ms,量级正确);<20ms 用 DWT(亚微秒精度)。 */
typedef struct {
uint32_t cyc; /* DWT->CYCCNT 起始值 */
uint32_t tick; /* HAL_GetTick() 起始值 */
} lat_tmark_t;
void lat_mark(lat_tmark_t *p);
uint32_t lat_mark_us(const lat_tmark_t *p);
#ifdef __cplusplus
}
#endif
#endif /* NET_LATENCY_TEST_H */
+21 -1
View File
@@ -16,6 +16,7 @@
#include "ch395f_test.h"
#include "ch395f_test_task.h"
#include "net_test_task.h"
#include "net_latency_test.h"
#define DBG_TAG "[NET_TEST]"
#include "dbg_log.h"
@@ -867,7 +868,14 @@ void net_layer_test_main(void)
* 断开后自动重新 listen。
*/
for (;;) {
if (!phase_active) {
if (!phase_active
#ifdef ENABLE_PHASE13_TESTS
&& net_latency_phase13_busy() == 0
#endif
#ifdef ENABLE_PHASE14_TESTS
&& net_latency_phase14_busy() == 0
#endif
) {
int32_t acc = net_accept(sockfd, (struct net_sockaddr *)&client_addr, &addrlen);
if (acc < 0) {
if (accept_retry % 100 == 0) {
@@ -907,6 +915,18 @@ void net_layer_test_main(void)
#ifdef ENABLE_PHASE11_TESTS
phase11_run(sockfd);
#endif
#ifdef ENABLE_PHASE13_TESTS
/* Phase 13(链路时延 L3~L7/L10,M2):独立运行,勿与其他 Phase 混选;
* 注意此处**不**重置 phase_active —— 保持 10ms 节拍(时延测量对
* 循环周期敏感,Phase 8 式 accept 重入的 100ms 退避会污染样本) */
net_latency_phase13_run(sockfd);
#endif
#ifdef ENABLE_PHASE14_TESTS
/* Phase 14(生产路径时延 L8~L11,M3):不占测试 socket —— 测量真实
* uiTask + ui_net_sink(:7003) 生产路径,由 peer --mode remo 驱动;
* 本调用只是探针窗口状态机(arm/disarm/report),同样保持 10ms 节拍 */
net_latency_phase14_run();
#endif
/*
* 单连接模式:连接断开由 net 层自动重监听(auto_relisten),
+17 -2
View File
@@ -22,8 +22,13 @@ extern "C" {
#include <stdint.h>
/* === 选择当前构建的测试套件(三选一,或都注释 = 正常产品构建)=== */
//#define TEST_SUITE_CH395F
/* === 选择当前构建的测试套件(三选一,或都注释 = 正常产品构建)===
* 2026-09-22:M2(链路时延 Phase 13,NET 层,需 PC peer)交付 ——
* 当前启用 CH395F 套件 + Phase 13(独立运行,勿与其他 Phase 混选)。
* 执行:先起 test/latency_peer.py --mode net --port 8080,再 @test 烧录。
* 恢复产品构建 = 注释掉下面两行(TEST_SUITE_CH395F 与 ch395f_test.h 的
* ENABLE_PHASE13_TESTS)。详见 docs/测试说明/链路延迟测试说明.md §3.2。 */
#define TEST_SUITE_CH395F
//#define TEST_SUITE_STORAGE
//#define TEST_SUITE_GD5F
//#define TEST_SUITE_RTC
@@ -91,6 +96,16 @@ extern "C" {
#define TEST_SUITE_NAME "NONE"
#endif
/*
* 链路时延探针(M2/M3):在 net_socket.c 发送分块循环内定义 L3(chunk_wait)
* 测量钩子(netTask 上下文);产品构建未定义 → 钩子代码编译消失(零开销)。
* 依赖 ch395f_test.h 的 Phase 宏(上方 TEST_SUITE_CH395F 块内已包含)。
*/
#if defined(TEST_SUITE_CH395F) && \
(defined(ENABLE_PHASE13_TESTS) || defined(ENABLE_PHASE14_TESTS))
#define TEST_LATENCY_PROBE 1
#endif
#ifdef __cplusplus
}
#endif