Files
STM32F4-Base/test/host/screenlink_bench.py
T
wandering1 dab0d8ba17 feat(屏板): 帧 B 组帧 + 中断起发 TX DMA + 按键队列 + 离线判定(批 2)
批 2 五项全部落地(docs/任务规划/屏板链路.md §3):
- 2-1 sb_link_frame_update():uiTask 渲染后按需组帧,不留 3200 B 影子
  副本,直接拿活显存与帧内点阵段 memcmp;组完自校验,过了才置 ready。
  静态 4208 B 帧缓冲实测落 RW_IRAM1(0x20006d98),不在 CCM。
- 2-2 空闲中断校完 6 B 召唤帧直接点 TX DMA(sb_tx_kick),不排队不攒批;
  busy 期间再来的召唤丢帧并计数,绝不叠发(屏板收帧一次 IDLE 一包)。
- 2-3 按键走环形队列(中断入、任务出),uiTask 按 §4.3 表映射 UI_KEY_*
  后调 ui_port_queue_key;9/10(RESET/FACTORY)按决策四先丢弃并留痕。
- 2-4 HAL_UART_TxCpltCallback 在 rs485.c 加第三行转发(三个全局回调
  落点收一处,防日后重复定义);busy 冻结帧缓冲,错误中断按 gState
  收拾被打断的 TX,防残留 busy 把链路永久锁死。
- 2-5 连续 >=10 s 无有效召唤帧才判离线(不按 500 ms——屏板约 5000 s
  自复位有 1 s 空窗),恢复即清标志;CRC 错帧静默丢弃并计数。

实测:台架 40 s(PC 顶替屏板,--key-fixed 2 持续改画面)rx=143 ok=143
keys=143 crc=0 / tx=143 done=143 drop=0 abor=0 fb=144 bad=0;脚本一停
10 s 准点出 OFFLINE。组帧耗时 509 µs 典型 / 709 µs 峰值——原 0.3 ms
判据系估算,已按实测在规划文档改口为 1 ms 并给出压降路径(未做)。

附带:rs485_on_rx 补 CODE-RULE-09 文档块(本轮改 rs485.c 触发整文件
重扫被拦);CRC 循环计数改 32 位(消掉 ARMCC 每轮 UXTH);bench 脚本
加 --reply/--key-fixed/--dump,并写明 CH340 收不了 700 kbaud 连续流、
--reply 收不全 4208 B 不是固件问题。

主机单测 146/146 PASSED(+65);Keil 0 错误 0 警告;ci_local 全绿
(SKIP=nested-comment,worktree 限制,主树脚本另查 0 新发)。
Code=238,936 B / RW_IRAM1 65.5 % / RW_CCM 87.9 %。
2026-10-29 20:59:36 +08:00

320 lines
15 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 -*-
"""屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。
做什么:
1. **发**:在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤;
可穿插按键帧(`--key N`)。
2. **收**(`--reply`,批 2 起):每发一帧就收主机回的 4208 B 帧 B,校验长度 / CRC /
998 B 填充,量"召唤末位 → 应答首位"的间隔与整帧耗时,可选把点阵 dump 成 PBM。
主机侧的统计由固件自己打(`[SBLINK] rx=... tx=...`),**收侧观测面是串口日志**;
本脚本的 `--reply` 是另一路观测(应答本身对不对),两者互补,不能互相替代。
为什么不用串口助手 GUI:
1-3 的判据含"连续 10 min 无丢帧",手点是做不到的;而且帧内容必须逐字节对
(CRC 低字节在前,写错一位就是 100 % CRC 错帧,与"波特率错"同形)。
2-2 的判据含"整帧 59.6 ms ±0.5 ms、≤200 ms 内结束",更是只能靠脚本量。
⚠ **USB 转 TTL 适配器量不到"帧内无间隙"**:USB 帧是 1 ms 量级,适配器把字节成批交上来,
所以 `--reply` 报的 `span` 只能证明"整帧在 60 ms 量级内发完",不能证明"没有 ≥28 µs
的间隙"(那是屏板断帧的硬约束,只能上逻辑分析仪或接真屏板时看)。别把 span 通过
当成那条判据通过。
接法(docs/任务规划/屏板链路.md §2 决策一):
适配器 TXD -> U5.B10(网络 ST_RX0,PA10) 适配器 RXD -> U5.A10(网络 ST_TX0,PA9)
适配器 GND -> 板地
⚠ 必须交叉接(TX 对 RX);USART1 的 TTL 只从 U5 出去,CN1 上没有 USART1 端子。
串口参数 700000 8N1,与固件 `SB_LINK_BAUD` 一致。
与日志口的关系:
日志走 RS-232 侧(默认 COM3,由 serial-assistant daemon 占用),本脚本占用的是
**另一路 TTL 口**(台架上通常是 COM6)——两者互不相干,可同时跑。
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 30
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 600 # 10 min 判据
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --reply --dump lcd.pbm
--key N 每 N 个周期把召唤帧换成按键帧(键值在 1/2/3/4/7/8/9 里轮换;0 = 不发按键帧)
--reply 每帧收应答并校验(批 2 的 2-2 / 2-4)
--dump P 把最后一帧应答的点阵存成 PBM(P4,160x160,可直接看)
退出码:0 = 按时发完且(`--reply` 时)所有应答都对;1 = 串口打不开 / 发送失败;
2 = 用法错误;3 = 有应答不合格。
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、批 2 的 2-2/2-4、§5 台架联调步骤第 2、3 步。
创建日期:2026-10-29
"""
import argparse
import sys
import time
try:
import serial
except ImportError: # pragma: no cover - 环境缺失时给人话
print("[ERR] 缺少 pyserial:请用项目 .venv 的 python 运行本脚本")
sys.exit(2)
# 屏板固件里的 KEY_TYPE:7 个物理键可达 1/2/3/4/7/8/9(无 5/6,9 = RESET)
KEY_VALUES = (1, 2, 3, 4, 7, 8, 9)
CMD_KEY = 0x00
CMD_RT = 0x01
ADDR = 0x01
FUNC = 0x03
def crc16_modbus(data):
"""标准 CRC-16/MODBUS(与 sb_proto.c 同算法、同口径:写进帧里低字节在前)。"""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
def build_frame(cmd, key=0):
"""组一帧帧 A:01 03 <cmd> <key> <CRC_L> <CRC_H>,CRC 只覆盖前 4 字节。"""
body = bytes((ADDR, FUNC, cmd, key))
crc = crc16_modbus(body)
return body + bytes((crc & 0xFF, (crc >> 8) & 0xFF))
# ---- 帧 B(主机 -> 屏板,4208 B):批 2 的应答校验 ----
FRAME_B_LEN = 4208
FRAME_B_BMP = 3200
FRAME_B_INFO = 3200
FRAME_B_PAD_OFF = 3208
FRAME_B_CRC_OFF = 4206
SCREEN_W = 160
SCREEN_H = 160
def verify_frame_b(data):
"""校验一帧帧 B;返回 (是否合格, 说明)。
只验"结构对不对":长度、CRC(覆盖 [0, 4205],低字节在前)、998 B 填充全 0、
info[4..5] 不是 LOGO 魔数。**不验点阵内容**——那要么 dump 出来看(--dump),
要么拿主机侧显存逐字节比(需要主机侧配合,台架这一级做不到)。
"""
if len(data) != FRAME_B_LEN:
return False, "len=%d(应 %d)" % (len(data), FRAME_B_LEN)
crc = crc16_modbus(data[:FRAME_B_CRC_OFF])
if (data[FRAME_B_CRC_OFF] != (crc & 0xFF)) or (data[FRAME_B_CRC_OFF + 1] != ((crc >> 8) & 0xFF)):
return False, "CRC 不符(帧尾 %02X %02X,算得 %04X 低字节在前)" % (
data[FRAME_B_CRC_OFF], data[FRAME_B_CRC_OFF + 1], crc)
pad = data[FRAME_B_PAD_OFF:FRAME_B_CRC_OFF]
if any(pad):
bad = next(i for i, b in enumerate(pad) if b)
return False, "填充段非 0(偏移 %d)" % (FRAME_B_PAD_OFF + bad)
if (data[FRAME_B_INFO + 4] == 0x89) and (data[FRAME_B_INFO + 5] == 0x45):
return False, "info[4..5] 是 LOGO 魔数(本应是普通图帧)"
return True, "OK"
def dump_pbm(path, data):
"""把点阵段存成 PBM(P4,160x160)。显存本身就是 1 bpp / 20 B 一行 / MSB 在前,
与 PBM 的二进制布局逐字节同构,所以直接拼头 + 原字节即可。"""
with open(path, "wb") as fp:
fp.write(b"P4\n%d %d\n" % (SCREEN_W, SCREEN_H))
fp.write(data[:FRAME_B_BMP])
def capture_reply(ser, frame, budget_s=0.6):
"""发一帧召唤,收主机回的 4208 B;返回 (数据, 写时刻, 首字节时刻, 末字节时刻)。
⚠ **CH340 收不了 700 kbaud 的连续流**(实测 2026-10-29:4208 B 只收到 ~2373 B,
等效约 9.5 kB/s)。这不是固件问题——同一次运行里固件侧 `tx=94 done=93` 说明 4208 B
每次都发完了;CH340 的 RX FIFO 只有几十字节、USB 每 1 ms 才搬一次,70 kB/s 必然溢出。
要真正核对帧 B 的内容,换 FTDI / CP210x 之类的适配器,或等批 4 接真屏板。
"""
ser.reset_input_buffer()
t_write = time.monotonic()
ser.write(frame)
ser.flush()
buf = bytearray()
t_first = None
deadline = t_write + budget_s
while len(buf) < FRAME_B_LEN and time.monotonic() < deadline:
# 大块读:read(n) 会等到收满 n 或超时,比"按 in_waiting 一个字节一个字节读"快得多
chunk = ser.read(min(1024, FRAME_B_LEN - len(buf)))
if chunk:
if t_first is None:
t_first = time.monotonic()
buf += chunk
return bytes(buf), t_write, t_first, time.monotonic()
def main():
ap = argparse.ArgumentParser(description="屏板链路台架激励(PC 顶替屏板)")
ap.add_argument("--port", required=True, help="USB 转 TTL 适配器的串口,如 COM6")
ap.add_argument("--baud", type=int, default=700000, help="波特率(默认 700000,与固件一致)")
ap.add_argument("--seconds", type=float, default=30.0, help="发多久(秒),默认 30")
ap.add_argument("--period-ms", type=float, default=280.0,
help="召唤周期(ms),默认 280(屏板是 RT_count>55 -> 第 56 拍 x 5 ms)")
ap.add_argument("--key", type=int, default=0,
help="每 N 个周期把帧换成按键帧(0 = 只发周期召唤)")
ap.add_argument("--key-fixed", type=int, default=0,
help="固定发这个键值(1/2/3/4/7/8/9),与 --key 二选一。"
"用来「持续改画面」以便拿到多次组帧样本;2=UP / 3=DOWN 只移动高亮,"
"不会确认任何设置(1=ENTER 可能改配置,别用来做长跑)")
ap.add_argument("--glitch-baud", type=int, default=0,
help="先按这个(错的)波特率灌一串垃圾再正常发,用来验 ORE/NE/FE 之后能否自愈"
"(0 = 不灌;建议 115200)")
ap.add_argument("--glitch-bytes", type=int, default=32,
help="灌多少字节垃圾(默认 32)")
ap.add_argument("--reply", action="store_true",
help="每帧收主机回的 4208 B 应答并校验(批 2 的 2-2 / 2-4)")
ap.add_argument("--dump", default="",
help="把最后一帧应答的点阵存成 PBM(P4 160x160,可直接看)")
args = ap.parse_args()
if args.period_ms <= 0 or args.seconds <= 0:
print("[ERR] --period-ms / --seconds 必须为正")
return 2
if args.dump and not args.reply:
print("[ERR] --dump 需要同时给 --reply(没有应答就没有点阵可存)")
return 2
rt_frame = build_frame(CMD_RT)
print("RT frame : %s" % rt_frame.hex(" ").upper())
# ---- 噪声注入(可选):用错波特率灌垃圾,制造 NE/FE/ORE ----
# 真实现场对应的是"拔插一次 TX 线";错波特率产生的错误更密集,是更强的考验。
# 关键判据不是"有没有报错",而是**之后还能不能正常收**——ORE 一旦清不掉,
# 接收就永久进不来,现象与"屏板没上电"完全一样(见 sb_link.c 文件头第 ② 条)。
if args.glitch_baud > 0 and args.glitch_bytes > 0:
try:
gser = serial.Serial(args.port, args.glitch_baud, bytesize=8, parity="N",
stopbits=1, timeout=0)
junk = bytes((0xA5, 0x5A, 0x00, 0xFF, 0x3C, 0xC3)) * (args.glitch_bytes // 6 + 1)
gser.write(junk[:args.glitch_bytes])
gser.flush()
gser.close()
print("[GLITCH] %d B @%d(错波特率灌噪声)" % (args.glitch_bytes, args.glitch_baud))
except Exception as exc: # noqa: BLE001
print("[WARN] 噪声注入失败(继续正常发):%s" % exc)
try:
# 收应答时要给 read() 一个超时,否则 timeout=0 会让 read(n) 立刻返回空、空转
ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1,
timeout=(0.1 if args.reply else 0))
except Exception as exc: # noqa: BLE001 - 打开失败原因要原样给人看
print("[ERR] 打开 %s @%d 失败:%s" % (args.port, args.baud, exc))
return 1
sent = 0
keys = 0
reply_ok = 0
reply_bad = 0
lat_min = None
lat_max = 0.0
lat_sum = 0.0
span_min = None
span_max = 0.0
span_sum = 0.0
last_bmp = None
t0 = time.monotonic()
next_at = t0
try:
while True:
now = time.monotonic()
if now - t0 >= args.seconds:
break
if now < next_at:
time.sleep(min(next_at - now, 0.02))
continue
if args.key_fixed > 0:
frame = build_frame(CMD_KEY, args.key_fixed)
keys += 1
tag = "KEY %d" % args.key_fixed
elif args.key > 0 and (sent % args.key) == (args.key - 1):
key = KEY_VALUES[keys % len(KEY_VALUES)]
frame = build_frame(CMD_KEY, key)
keys += 1
tag = "KEY %d" % key
else:
frame = rt_frame
tag = "RT"
if args.reply:
data, t_write, t_first, t_last = capture_reply(ser, frame)
good, why = verify_frame_b(data)
if t_first is None:
lat = span = None
else:
lat = (t_first - t_write) * 1000.0
span = (t_last - t_first) * 1000.0
lat_sum += lat
lat_min = lat if lat_min is None else min(lat_min, lat)
lat_max = max(lat_max, lat)
span_sum += span
span_min = span if span_min is None else min(span_min, span)
span_max = max(span_max, span)
if good:
reply_ok += 1
last_bmp = data
else:
reply_bad += 1
if sent < 3 or tag != "RT" or not good:
if t_first is None:
print(" #%d %s -> 无应答(%.0f ms 内没收到首字节)" % (sent + 1, tag, 600.0))
else:
print(" #%d %s -> %d B %s lat=%.2f ms span=%.2f ms"
% (sent + 1, tag, len(data), ("OK" if good else "BAD: " + why),
lat, span))
else:
ser.write(frame)
ser.flush()
if sent <= 3 or tag != "RT":
print(" #%d %s -> %s" % (sent + 1, tag, frame.hex(" ").upper()))
sent += 1
next_at += args.period_ms / 1000.0
# 落后太多就别追了(PC 侧卡顿不该变成突发流量)
if next_at < time.monotonic() - 1.0:
next_at = time.monotonic()
except KeyboardInterrupt:
print("\n[INFO] 用户中断")
finally:
ser.close()
elapsed = time.monotonic() - t0
print("[DONE] 发送 %d 帧(其中按键帧 %d)用时 %.1f s,平均周期 %.1f ms"
% (sent, keys, elapsed, (elapsed * 1000.0 / sent) if sent else 0.0))
if args.reply:
print("[REPLY] 合格 %d / 不合格 %d" % (reply_ok, reply_bad))
if reply_bad > 0 and reply_ok == 0:
print("[HINT] 一个都没收全:先怀疑适配器。CH340 的 RX 顶不住 700 kbaud 连续流"
"(4208 B 只到 ~2400 B 就是它的典型表现),换 FTDI / CP210x 再试。")
print("[HINT] 固件侧是否真发了:看日志口 grep '[SBLINK]' 的 tx / done / fb 三列。")
if reply_ok > 0:
print("[REPLY] 召唤末位→应答首位:min %.2f / avg %.2f / max %.2f ms"
"(判据 30 µs ~ 5 ms;USB 适配器自身有 1~2 ms 量级延迟,只能证上界)"
% (lat_min, lat_sum / reply_ok, lat_max))
print("[REPLY] 整帧耗时(首字节→末字节):min %.2f / avg %.2f / max %.2f ms"
"(线上 59.6 ms;**不能**据此判「帧内无间隙」,见文件头)"
% (span_min, span_sum / reply_ok, span_max))
print("[REPLY] 整帧 ≤200 ms 判据:%s" % ("PASS" if lat_max + span_max < 200.0 else "FAIL"))
if args.dump and last_bmp is not None:
dump_pbm(args.dump, last_bmp)
print("[DUMP] 点阵已存 %s(P4 160x160,可直接打开看)" % args.dump)
print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0%s"
% (sent, "、tx == ok、done == tx、drop=0、fail=0、abort=0" if args.reply else ""))
return 3 if reply_bad > 0 else 0
if __name__ == "__main__":
sys.exit(main())