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 %。
This commit is contained in:
2026-10-29 20:59:36 +08:00
parent e5b4182a9f
commit dab0d8ba17
15 changed files with 1263 additions and 132 deletions
+170 -16
View File
@@ -3,15 +3,22 @@
"""屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。
做什么:
在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤;
可穿插按键帧(屏板有键时最早 205 ms 就会再来一帧,本脚本只在周期点替换内容,
不额外插帧——批 1 只验"收得到、长度对、CRC 过",不验应答时序)。
主机侧的统计由固件自己打(`[SBLINK] rx=... ok=... crc=... len=...`),
本脚本只负责"有东西可收",不读被测板——**收侧观测面是串口日志,不是本脚本**。
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)
@@ -26,11 +33,15 @@
用法(仓库根目录):
& '.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 = 按时发完;1 = 串口打不开 / 发送失败;2 = 用法错误。
退出码:0 = 按时发完且(`--reply` 时)所有应答都对;1 = 串口打不开 / 发送失败;
2 = 用法错误;3 = 有应答不合格。
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、§5 台架联调步骤第 2 步。
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、批 2 的 2-2/2-4、§5 台架联调步骤第 2、3 步。
创建日期:2026-10-29
"""
import argparse
@@ -72,6 +83,76 @@ def build_frame(cmd, key=0):
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")
@@ -81,16 +162,27 @@ def main():
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())
@@ -112,13 +204,24 @@ def main():
print("[WARN] 噪声注入失败(继续正常发):%s" % exc)
try:
ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1, timeout=0)
# 收应答时要给 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:
@@ -130,7 +233,11 @@ def main():
time.sleep(min(next_at - now, 0.02))
continue
if args.key > 0 and (sent % args.key) == (args.key - 1):
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
@@ -139,12 +246,40 @@ def main():
frame = rt_frame
tag = "RT"
ser.write(frame)
ser.flush()
sent += 1
if sent <= 3 or tag != "RT":
print(" #%d %s -> %s" % (sent, tag, frame.hex(" ").upper()))
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:
@@ -157,8 +292,27 @@ def main():
elapsed = time.monotonic() - t0
print("[DONE] 发送 %d 帧(其中按键帧 %d)用时 %.1f s,平均周期 %.1f ms"
% (sent, keys, elapsed, (elapsed * 1000.0 / sent) if sent else 0.0))
print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0" % sent)
return 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__":