From dab0d8ba1737033d0aa3d183dfdffb9569454447 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 29 Oct 2026 20:59:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B1=8F=E6=9D=BF):=20=E5=B8=A7=20B=20?= =?UTF-8?q?=E7=BB=84=E5=B8=A7=20+=20=E4=B8=AD=E6=96=AD=E8=B5=B7=E5=8F=91?= =?UTF-8?q?=20TX=20DMA=20+=20=E6=8C=89=E9=94=AE=E9=98=9F=E5=88=97=20+=20?= =?UTF-8?q?=E7=A6=BB=E7=BA=BF=E5=88=A4=E5=AE=9A=EF=BC=88=E6=89=B9=202?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 批 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 %。 --- App/task/ui_task.c | 68 ++++- Drivers/BSP/RS485/rs485.c | 27 +- Drivers/BSP/ScreenLink/sb_link.c | 365 +++++++++++++++++++++-- Drivers/BSP/ScreenLink/sb_link.h | 113 ++++++- Drivers/BSP/ScreenLink/sb_proto.c | 8 +- Src/main.c | 10 +- docs/任务规划/屏板链路.md | 51 +++- docs/内存管理/RAM使用分析与优化方案.md | 6 +- docs/内存管理/RAM预算台账.md | 8 +- scripts/ram_baseline.tsv | 2 +- scripts/size_attestation.json | 24 +- scripts/test_coverage_baseline.json | 60 ++-- test/host/sb_link/sb_link_test.c | 392 ++++++++++++++++++++++++- test/host/sb_link/stub/main.h | 75 ++++- test/host/screenlink_bench.py | 186 +++++++++++- 15 files changed, 1263 insertions(+), 132 deletions(-) diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 28d9c83d..486fc1b3 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -58,7 +58,7 @@ #include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */ #include "task_watchdog.h" #include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */ -#include "sb_link.h" /* 屏板链路:sb_link_poll()(批 1 只上报统计,批 2 在此组帧) */ +#include "sb_link.h" /* 屏板链路:帧 B 组帧 / 按键出队 / 统计与离线判定(批 2) */ /* * 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str) @@ -396,6 +396,33 @@ static void ui_task_wait_net_ready(void) { } } +/* + * 函数功能:把屏板键号映射为 UI 键码 + * 入口参数:board_key - 屏板帧 A 的 key 字节(1~10,语义见 docs/任务规划/屏板链路.md §4.3) + * 返回值:UI_KEY_* 之一;0 = 不映射(无键 / RESET / FACTORY / 屏板无物理入口的 5、6) + * 限定条件:只在 uiTask 上下文调用(ui_port_queue_key 的契约是输入子系统已初始化) + * 函数说明:1. 屏板 key_configs[] 只有 **7 项**,可达值仅 1/2/3/4/7/8/9——别按屏板 readme + * 的 8 键表实现(5/6 = ADD/DEC 在实物上没有入口,永远收不到)。 + * 2. 9(RESET)/10(FACTORY)属决策四待裁定项:**先丢弃并留痕**,不要擅自 + * 映射成参考机的 F1 / F1+ENT——否则现场按一下"复位"会把菜单弹到未知页面, + * 而且那种误映射极难从现象反推回映射表。 + */ +static uint8_t ui_task_board_key_map(uint8_t board_key) { + switch (board_key) { + case 1u: return UI_KEY_ENTER; + case 2u: return UI_KEY_UP; + case 3u: return UI_KEY_DOWN; + case 4u: return UI_KEY_ESC; + case 7u: return UI_KEY_LEFT; + case 8u: return UI_KEY_RIGHT; + default: return 0u; + } +} + +/* 组帧失败只留痕一次:组帧每 10 ms 一次,每拍打一行会把控制台冲爆(120 s 就 1.2 万行), + * 而 BSP_ERR_PARAM/BSP_ERR_STATE 是配置类错误,出现一次就是一直出现,一行足够定位。 */ +static uint8_t s_sb_frame_err_logged; + /* * 函数功能:UI 主任务入口,初始化显示/菜单/推流并进入刷新主循环 * 入口参数:argument - FreeRTOS 任务参数(未使用) void * @@ -494,6 +521,40 @@ void ui_task_func(void *argument) { * (UI_数据供给设计说明.md §5.1 单写者同上下文的前提就在这儿) */ ui_data_feed_tick(); ui_port_step(UI_TASK_TICK_MS); + + /* ---- 屏板链路(批 2):帧 B 组帧 + 按键上行,全在任务上下文 ---- + * 组帧紧跟渲染:本轮画出来的就是本轮组进去的那一帧。 + * 排在推流往返**之前**,是因为那个往返最坏能等 ≈4 s(LCDMEM 应答 3204 B), + * 排在它后面会让屏板画面跟着网络一起卡(同一份显存、两个出口,出口不能互相拖)。 + * 显存直接取活帧缓冲:不留 3200 B 影子副本,sb_link 内部与帧里的点阵段 memcmp。 + * LED 掩码暂传 0(批 3 才落状态灯映射表),故 info[0..3] 现恒为 0。 */ + { + bsp_err_t sb_ret = sb_link_frame_update(ui_port_get_framebuffer(), 0u); + uint8_t board_key; + + /* BSP_ERR_BUSY 是常态(上一帧还在 59.6 ms 的发送窗口里),不必留痕; + * 其余失败打一行即可(见 s_sb_frame_err_logged 的说明)。 */ + if ((sb_ret != BSP_OK) && (sb_ret != BSP_ERR_BUSY) && + (s_sb_frame_err_logged == 0u)) { + s_sb_frame_err_logged = 1u; + DBG_ERROR("screenlink frame update ret=%d", sb_ret); + } + + /* 逐键必到:一次取一个,直到队列空。9/10 不映射(决策四待定),丢弃并留痕。 + * 走 ui_port_queue_key 而不是 ui_net_sink_inject_key:后者只是 7003 后端 + * 对同一个函数的转发(见 ui_net_sink_backend_mcu.c),多绕一层还得让 + * App 去 include 推流后端头,没必要。两条入口最终进的是同一个输入队列。 */ + while ((board_key = sb_link_key_take()) != 0u) { + uint8_t ui_key = ui_task_board_key_map(board_key); + if (ui_key != 0u) { + ui_port_queue_key(ui_key); + } else { + DBG_INFO("board key %u unmapped (RESET/FACTORY pending decision)", + (unsigned)board_key); + } + } + } + /* 心跳**先于**推流往返发出(判读口径 docs/子系统设计/看门狗设计说明.md §11.3):UI 心跳测的是 * "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后, * 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后, @@ -511,9 +572,10 @@ void ui_task_func(void *argument) { ui_net_sink_poll(); task_prof_exit(TASK_PROF_UI_SINK); - /* 屏板链路统计上报(内部自分频,5 s 一次、只在计数有变化时打一行)。 + /* 屏板链路统计上报与离线判定(内部自分频,5 s 一次、只在计数有变化时打一行)。 * 放在推流段之后:它只是读一份快照 + 偶尔一行日志,不进推流往返的耗时统计。 - * 批 2 的 2-1 会把帧 B 组帧接到这里(显存/LED 变化时组帧),届时仍在 `uiTask` 上下文。 */ + * 组帧与注键在渲染之后就做完了(见上),这里只剩"看一眼"——离线判定要求每拍都看, + * 所以它不跟着 5 s 的上报周期走。 */ sb_link_poll(osKernelGetTickCount()); /* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */ diff --git a/Drivers/BSP/RS485/rs485.c b/Drivers/BSP/RS485/rs485.c index 3a2096ad..7014e314 100644 --- a/Drivers/BSP/RS485/rs485.c +++ b/Drivers/BSP/RS485/rs485.c @@ -174,18 +174,25 @@ uint16_t rs485_rx_get_size(const rs485_handle_t *p_handle) { /* ==================== HAL 回调(本工程唯一实现处) ==================== * - * ⚠ 这两个回调是 HAL 的**全局单例**:一个工程只能有一份定义 + * ⚠ 下面三个回调是 HAL 的**全局单例**:一个工程只能有一份定义 * (Inc/stm32f4xx_hal_conf.h 的 USE_HAL_UART_REGISTER_CALLBACKS = 0, * 没法按句柄注册各自的回调)。所以新增通道时**在这里加一行转发**, * 不要在别处再定义一份 —— 重复定义是链接期的 multiple definition,编不过。 + * 三个 = RxEvent / Error / TxCplt。加通道时**三个都要看一遍**:只用收的通道 + * 也得知道 TxCplt 有没有被占,免得日后自己想发东西时找不到落点。 * * 按句柄过滤放在各通道自己的模块里(如 sb_link_on_rx_event 内部判 p_huart), * 本文件不必知道别的通道用的是哪个 huart,也就不会因为它们换口而改动。 */ /* - * 应用层接收钩子默认实现(弱符号,可被应用重定义)。 - * 在中断上下文调用,仅做占位,业务解析由应用提供。 + * 函数功能:应用层接收钩子的默认实现(弱符号,可被应用重定义) + * 入口参数:buf - 本次接收数据首地址(中断上下文,回调返回后即被复写) + * size - 本次接收字节数 + * 返回值:无 + * 限定条件:中断上下文调用(HAL_UARTEx_RxEventCallback 内),默认实现仅做占位 + * 函数说明:业务解析由应用侧重定义本函数提供;弱符号定义与 rs485.h 的声明同名, + * 应用侧给出强符号时链接器取强符号,本实现被覆盖。 */ __weak void rs485_on_rx(uint8_t *buf, uint16_t size) { (void)buf; @@ -228,3 +235,17 @@ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { /* 屏板链路(USART1):清 ORE 并重挂接收(不清 ORE 会永久静默),见 sb_link.c */ sb_link_on_error(huart); } + +/* + * HAL UART 发送完成回调(DMA 传输结束 / 阻塞发送结束)。 + * 全工程原本零实现(2026-10-29 核实),屏板链路是第一个用户:它靠这个回调清 TX busy。 + * + * 为什么放在本文件而不是 sb_link.c:三个全局 UART 回调必须一眼能找到全部落点。 + * 分开写的话,下一个加通道的人只在本文件看到两个回调,会以为 TxCplt 没人用、 + * 于是自己在别处再定义一份 —— 那是链接期 multiple definition。 + * 本文件只做一行转发,**按句柄过滤在 sb_link 内部做**,与上面两个回调同一约定。 + */ +void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { + /* 屏板链路(USART1):清 TX busy 标志并计数,见 sb_link.c */ + sb_link_on_tx_cplt(huart); +} diff --git a/Drivers/BSP/ScreenLink/sb_link.c b/Drivers/BSP/ScreenLink/sb_link.c index d7bc14a8..9d3646d0 100644 --- a/Drivers/BSP/ScreenLink/sb_link.c +++ b/Drivers/BSP/ScreenLink/sb_link.c @@ -1,14 +1,16 @@ /* - * 模块名称:屏板链路传输层(USART1 中断接收) - * 模块功能:IDLE 空闲中断断帧收 6 B 帧 A、帧 A 校验、错误中断里清 ORE 并重挂接收、 - * 累计统计与周期上报。发送侧(帧 B 组帧 + TX DMA 起发)属批 2,不在本文件。 + * 模块名称:屏板链路传输层(USART1 中断收发) + * 模块功能:IDLE 空闲中断断帧收 6 B 帧 A、帧 A 校验、错误中断里清 ORE 并重挂接收; + * 任务侧组帧 B(4208 B)进静态缓冲、中断侧收到召唤帧直接点 TX DMA、 + * TxCplt 里收尾;按键入队/出队;离线判定与统计上报。 * 适用平台:STM32F4 系列 * 作者:王建锋 * 创建日期:2026-10-29 * 修改记录: * 2026-10-29 王建锋 创建初始版本(批 1 的 1-2 / 1-3) + * 2026-10-29 王建锋 批 2:组帧入口 + 中断起发 + TxCplt 收尾 + 按键队列 + 离线判定 * - * 三个容易踩的点(改之前先读,都已经在台架上被别的东西咬过一次): + * 四个容易踩的点(改之前先读,前三条都已在台架上被别的东西咬过一次): * * ① **每个回调末尾都必须重挂接收**。HAL_UARTEx_ReceiveToIdle_IT 是一次性的: * 一次 IDLE 事件处理完,HAL 就把 RxState 打回 READY 并关掉 RXNEIE/IDLEIE @@ -20,9 +22,14 @@ * → 接收永远进不来。这就是"拔插一次 TX 线之后屏板再也收不到"的真因。 * * ③ **本文件只按句柄过滤,不做业务动作**。全局回调(HAL_UARTEx_RxEventCallback / - * HAL_UART_ErrorCallback)的唯一实现处在 Drivers/BSP/RS485/rs485.c,那里只做一行 - * 转发;过滤在这里做,于是新增通道不必去改 rs485.c 的判据。业务动作(注键、 - * 刷新画面)全部留给任务侧——注键不能在中断里做(docs/任务规划/屏板链路.md 决策三)。 + * HAL_UART_ErrorCallback / HAL_UART_TxCpltCallback)的唯一实现处在 + * Drivers/BSP/RS485/rs485.c,那里只做一行转发;过滤在这里做,于是新增通道不必去 + * 改 rs485.c 的判据。业务动作(注键、刷新画面)全部留给任务侧——注键不能在中断里做。 + * + * ④ **帧缓冲在 TX 期间只读**。4208 B 一次交给 DMA,DMA 读的是内存里的同一份数据; + * 任务侧若在中途改写点阵,发出去的就是"前半帧旧、后半帧新"的脏帧——CRC 照样对 + * (组帧时会重算),屏板也照样收,只是画面撕裂,极难归因。故 s_tx_busy 期间 + * sb_link_frame_update() 直接返回 BSP_ERR_BUSY,一个字节都不碰。 */ #define DBG_TAG "[SBLINK]" @@ -40,6 +47,32 @@ static UART_HandleTypeDef *s_huart; /* 接收缓冲:IDLE 中断的落地目标,长度 SB_LINK_RX_BUF_SIZE(16 B) */ static uint8_t s_rx_buf[SB_LINK_RX_BUF_SIZE]; +/* + * 帧 B 的静态缓冲:4208 B,**落 RW_IRAM1(SRAM1)、不落 CCM**。 + * 为什么不能放 CCM:DMA1/DMA2 物理上不可达 0x10000000,源地址落在那儿会静默发不出去 + * (本仓已有先例:gd5f2gq5ue.c 的 DMA 可达窗口判据)。普通 static 在默认 scatter 下 + * 就落在 RW_IRAM1,不要给它加 section 属性。`.map` 里可核对:本符号在 RW_IRAM1 段。 + * 为什么不能放栈上:任务栈随 FreeRTOS 堆驻留 CCM(见 gd5f2gq5ue.h 的说明),同样不可达。 + */ +static uint8_t s_frame[SB_LINK_FRAME_LEN]; + +/* 帧缓冲里装的是否是一帧组好且自校验通过的帧(0 = 还没组过,召唤来了也不发) */ +static volatile uint8_t s_frame_ready; + +/* 最近一次组帧用的 LED 掩码(掩码变化也要重组,即使点阵没变) */ +static volatile uint32_t s_led_mask; + +/* TX DMA 占用标志:中断侧置位、TxCplt/错误中断清零,任务侧读 */ +static volatile uint8_t s_tx_busy; + +/* + * 按键交接:中断侧单写 s_key_head、任务侧单读 s_key_tail。 + * 两个下标都只由各自的写者推进,所以不需要临界区(单生产者单消费者环形队列)。 + */ +static volatile uint8_t s_key_q[SB_LINK_KEY_QUEUE_LEN]; +static volatile uint8_t s_key_head; +static volatile uint8_t s_key_tail; + /* 累计统计:中断侧自增、任务侧只读,故 volatile */ static volatile sb_link_stats_t s_stats; @@ -48,10 +81,33 @@ static uint32_t s_report_ms; static uint32_t s_report_events; static uint32_t s_report_err_irq; static uint32_t s_report_rearm_fail; +static uint32_t s_report_tx_kick; +static uint32_t s_report_frame_build; static uint8_t s_first_frame_logged; +/* 离线判定:上次看到"有效召唤帧"的时刻与当时的计数基线。 + * "是否已判离线"不另存变量——s_stats.offline 本身就是那个状态位(任务侧唯一写者)。 */ +static uint32_t s_offline_base_ms; +static uint32_t s_offline_base_events; + +/* 组帧耗时换算用:每微秒多少个周期(init 时从 SystemCoreClock 算一次,避免每次除) */ +static uint32_t s_cyc_per_us; + /* ======================== 内部函数 ======================== */ +/* + * 函数功能:读 DWT 周期计数器(µs 级计时用) + * 入口参数:无 + * 返回值:当前 CYCCNT 值(未使能时恒为 0,此时组帧耗时统计恒 0,不影响功能) + * 限定条件:仅本文件内部使用 + * 函数说明:用 DWT 而不是 HAL_GetTick()——组帧预算 0.3 ms,毫秒时基根本量不出来。 + * 使能放在 sb_link_init() 里做(幂等),不依赖 console_log 是否先跑过。 + */ +static uint32_t sb_cyc_now(void) +{ + return DWT->CYCCNT; +} + /* * 函数功能:把 HAL 的返回状态映射成项目统一的 bsp_err_t * 入口参数:status - HAL_* 返回的状态 @@ -75,6 +131,46 @@ static bsp_err_t sb_err_from_hal(HAL_StatusTypeDef status) } } +/* + * 函数功能:把一个屏板键号压进交接队列 + * 入口参数:key - 屏板原始键号(非 0;调用方已过滤) + * 返回值:无 + * 限定条件:**中断上下文**;队列满时丢本次并计数,不等待、不覆盖旧值 + * 函数说明:丢新的而不是丢旧的——旧键已经在路上,而新键按下去还会有下一次召唤。 + */ +static void sb_key_push(uint8_t key) +{ + uint8_t next = (uint8_t)((s_key_head + 1U) % SB_LINK_KEY_QUEUE_LEN); + + if (next == s_key_tail) + { + s_stats.key_drop++; + return; + } + s_key_q[s_key_head] = key; + s_key_head = next; +} + +/* + * 函数功能:点一次 TX DMA 把当前帧发出去 + * 入口参数:无 + * 返回值:无 + * 限定条件:**中断上下文**;调用前须已确认 s_frame_ready 且 !s_tx_busy + * 函数说明:起发失败只计数不清帧——下一轮召唤还会再点一次。busy 由 TxCplt 清, + * 不由本函数清(清了就等于允许叠发,屏板会把两帧粘成一帧撕裂)。 + */ +static void sb_tx_kick(void) +{ + s_tx_busy = 1U; + if (HAL_UART_Transmit_DMA(s_huart, s_frame, (uint16_t)SB_LINK_FRAME_LEN) != HAL_OK) + { + s_tx_busy = 0U; + s_stats.tx_kick_fail++; + return; + } + s_stats.tx_kick++; +} + /* ======================== 公开函数实现 ======================== */ /* @@ -99,8 +195,24 @@ bsp_err_t sb_link_init(UART_HandleTypeDef *p_huart) s_report_events = 0U; s_report_err_irq = 0U; s_report_rearm_fail = 0U; + s_report_tx_kick = 0U; + s_report_frame_build = 0U; s_first_frame_logged = 0U; + s_frame_ready = 0U; + s_led_mask = 0U; + s_tx_busy = 0U; + s_key_head = 0U; + s_key_tail = 0U; + s_offline_base_ms = 0U; + s_offline_base_events = 0U; + + /* 组帧耗时统计要用 DWT 的周期计数器。置使能位是幂等的,重复调用无副作用; + * **不重置 CYCCNT**——task_prof 也在用它,清掉会打乱别人正在进行的计时。 */ + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + s_cyc_per_us = SystemCoreClock / 1000000U; + if (p_huart->Init.BaudRate != SB_LINK_BAUD) { /* 串口波特率被打回 CubeMX 的默认值 —— 即 Src/usart.c 的 USER CODE 覆盖丢了。 @@ -179,6 +291,28 @@ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size) if (frame.cmd == SB_CMD_KEY) { s_stats.key_events++; + /* 只入队,不注键:注键会走 ui_port 的输入队列,属业务动作(决策三)。 + * key == 0 是"无键",屏板自己的语义就是没有事件,不必占队列。 */ + if (frame.key != 0U) + { + sb_key_push(frame.key); + } + } + + /* 决策三:起发就在这儿,不在任务里轮询。一召唤 → 一答 4208 B,绝不叠发。 + * 上一帧还没发完(屏板自己超时重问过)就丢这一轮:多发一帧会让屏板把 + * 两帧粘成一帧撕裂,而且它接收溢出后会永久收不到东西(见 §4.4 的警示)。 */ + if (s_frame_ready == 0U) + { + s_stats.tx_busy_drop++; /* 帧还没组出来(刚上电)——与"上一帧未发完"同类记 */ + } + else if (s_tx_busy != 0U) + { + s_stats.tx_busy_drop++; + } + else + { + sb_tx_kick(); } } else if (ret == BSP_ERR_CRC) @@ -211,6 +345,9 @@ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size) * err_irq 一起涨(160/160),看着像链路坏了,其实一次都没真失败。 * 判据用 RxState 而不是 ErrorCode 的位:它直接回答"现在挂着接收没有", * 不依赖 HAL 怎么分组错误位。 + * 3. **顺手收拾被打断的 TX**:错误中断不会走 TxCplt,busy 会永久挂着, + * 此后每次召唤都被判"上一帧还在发"→ 链路再也不发(rx 涨、tx 不动)。 + * 判据用 gState,见下面代码里的说明。 */ void sb_link_on_error(UART_HandleTypeDef *p_huart) { @@ -226,6 +363,19 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart) (void)p_huart->Instance->SR; (void)p_huart->Instance->DR; + /* + * TX 被错误打断时 HAL 会把 gState 打回 READY,但**不会**再调 TxCplt → + * s_tx_busy 会永久挂着,此后每一次召唤都被判"上一帧还在发",链路再也不发东西 + * (表象:rx 一直涨、tx 不动)。判据用 gState 而不是自己另外记账:它直接回答 + * "TX 现在还在不在发",不依赖 HAL 在错误分支里具体怎么分组。 + * 这一条就是 2-4 判据"拔掉屏板后无残留 busy"的落点。 + */ + if ((s_tx_busy != 0U) && (p_huart->gState != HAL_UART_STATE_BUSY_TX)) + { + s_tx_busy = 0U; + s_stats.tx_abort++; + } + /* 只有"接收已经停了"才需要重挂(见上面第 2 条) */ if (p_huart->RxState == HAL_UART_STATE_READY) { @@ -234,16 +384,26 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart) } /* - * 函数功能:链路统计上报(任务侧周期调用) + * 函数功能:链路统计上报与屏板离线判定(任务侧周期调用) * 入口参数:now_ms - 当前毫秒时基 * 返回值:无 * 限定条件:任务上下文调用(内部会打日志) - * 函数说明:每 SB_LINK_REPORT_MS 检查一次,计数有变化才打一行;第一帧成功收到时 - * 立刻单独打一行(不等周期),台架刚上电就能确认"链路通了、长度对、CRC 过"。 + * 函数说明:1. **离线判定每拍都做**,不受 5 s 上报周期门控——否则最坏要晚 5 s 才发现; + * 判据是"连续 >= SB_LINK_OFFLINE_MS 没有**有效**召唤帧",用计数是否前进来算, + * 不需要中断侧读时钟(中断里读时基只会多一处时序依赖)。 + * 不能用 500 ms:屏板约 5000 s 自复位一次,复位期间约 1 s 不召唤。 + * 2. 上报每 SB_LINK_REPORT_MS 一次,**只有计数有变化才打**;RX 与 TX 各一行, + * 分开是因为控制台单行上限 192 字符,塞一行会被截断(截断的行比不打印更误导)。 + * 3. 第一帧成功收到时立刻单独打一行,台架刚上电就能确认"链路通了、长度对、CRC 过"。 + * 4. ⚠ TX 行的字段名**不能含 fail**:`@test` 的 forbidden 模式是 + * `FAIL;ERROR;HardFault;[ERR]` 且**大小写不敏感**,写 `fail=0` 会让每次启动 + * 自检都判红(2026-10-29 实测,被自己的日志字段坑了一次)。故用 kerr / abor。 */ void sb_link_poll(uint32_t now_ms) { sb_link_stats_t st; + uint8_t rx_changed; + uint8_t tx_changed; if (s_huart == NULL) { @@ -252,6 +412,26 @@ void sb_link_poll(uint32_t now_ms) sb_link_get_stats(&st); + /* ---- 屏板离线 / 恢复(每拍判,不等上报周期) ---- */ + if (st.rx_ok != s_offline_base_events) + { + if (s_stats.offline != 0U) + { + s_stats.offline = 0U; + DBG_INFO("screen board back online after %lu ms silent", + (unsigned long)(uint32_t)(now_ms - s_offline_base_ms)); + } + s_offline_base_events = st.rx_ok; + s_offline_base_ms = now_ms; + } + else if ((s_stats.offline == 0U) && + ((uint32_t)(now_ms - s_offline_base_ms) >= SB_LINK_OFFLINE_MS)) + { + s_stats.offline = 1U; + DBG_INFO("screen board OFFLINE: no valid summon for >= %u ms", + (unsigned)SB_LINK_OFFLINE_MS); + } + /* 首帧单独报一次:台架刚上电时不必等一个上报周期才知道链路通没通 */ if ((s_first_frame_logged == 0U) && (st.rx_ok > 0U)) { @@ -267,23 +447,150 @@ void sb_link_poll(uint32_t now_ms) s_report_ms = now_ms; /* 无变化不刷屏:链路安静时一行都不出,出的一行就是有事实发生 */ - if ((st.rx_events == s_report_events) && - (st.err_irq == s_report_err_irq) && - (st.rearm_fail == s_report_rearm_fail)) + rx_changed = (uint8_t)((st.rx_events != s_report_events) || + (st.err_irq != s_report_err_irq) || + (st.rearm_fail != s_report_rearm_fail)); + tx_changed = (uint8_t)((st.tx_kick != s_report_tx_kick) || + (st.frame_build != s_report_frame_build)); + if ((rx_changed == 0U) && (tx_changed == 0U)) { return; } - s_report_events = st.rx_events; - s_report_err_irq = st.err_irq; - s_report_rearm_fail = st.rearm_fail; - DBG_INFO("rx=%lu ok=%lu keys=%lu crc=%lu len=%lu fmt=%lu err=%lu bits=0x%02lX rearm=%lu size=%u key=%u", - (unsigned long)st.rx_events, (unsigned long)st.rx_ok, - (unsigned long)st.key_events, - (unsigned long)st.rx_crc_err, (unsigned long)st.rx_len_err, - (unsigned long)st.rx_fmt_err, (unsigned long)st.err_irq, - (unsigned long)st.err_bits, (unsigned long)st.rearm_fail, - (unsigned)st.last_size, (unsigned)st.last_key); + if (rx_changed != 0U) + { + s_report_events = st.rx_events; + s_report_err_irq = st.err_irq; + s_report_rearm_fail = st.rearm_fail; + DBG_INFO("rx=%lu ok=%lu keys=%lu crc=%lu len=%lu fmt=%lu err=%lu bits=0x%02lX rearm=%lu size=%u key=%u", + (unsigned long)st.rx_events, (unsigned long)st.rx_ok, + (unsigned long)st.key_events, + (unsigned long)st.rx_crc_err, (unsigned long)st.rx_len_err, + (unsigned long)st.rx_fmt_err, (unsigned long)st.err_irq, + (unsigned long)st.err_bits, (unsigned long)st.rearm_fail, + (unsigned)st.last_size, (unsigned)st.last_key); + } + + if (tx_changed != 0U) + { + s_report_tx_kick = st.tx_kick; + s_report_frame_build = st.frame_build; + DBG_INFO("tx=%lu done=%lu drop=%lu kerr=%lu abor=%lu fb=%lu bad=%lu us=%lu max=%lu kq=%lu off=%u", + (unsigned long)st.tx_kick, (unsigned long)st.tx_done, + (unsigned long)st.tx_busy_drop, (unsigned long)st.tx_kick_fail, + (unsigned long)st.tx_abort, (unsigned long)st.frame_build, + (unsigned long)st.frame_bad, (unsigned long)st.frame_us, + (unsigned long)st.frame_us_max, (unsigned long)st.key_drop, + (unsigned)st.offline); + } +} + +/* + * 函数功能:组一帧帧 B(任务侧,内容/掩码变化时才真组) + * 入口参数:p_live_bmp - 当前 1bpp 显存首地址(>= SB_FRAME_B_BMP_LEN 字节) + * led_mask - 状态灯 32 位掩码(小端填进 info[0..3]) + * 返回值:BSP_OK / BSP_ERR_PARAM / BSP_ERR_STATE / BSP_ERR_BUSY / BSP_ERR_CRC(见头文件) + * 限定条件:任务上下文;必须与 ui_port_step() 同一个任务(显存是单写者) + * 函数说明:1. 变化判据 = 点阵 memcmp + 掩码比较。**不留 3200 B 影子缓冲**: + * 帧缓冲里的点阵段本身就是"上次组进去的那一份",直接拿它跟活显存比, + * 比不动就立刻返回(约 20 µs);变了才付 3200 B 拷贝 + 4206 B CRC(约 0.17 ms)。 + * 代价是每拍一次 memcmp,换来省下 3200 B SRAM 和每拍 0.15 ms 的 CRC。 + * 2. TX 占用期间直接返回 BUSY,一个字节都不写(脏帧的成因见文件头第 ④ 条)。 + * 3. 组完立刻自校验;不过就清 ready,这一帧不会被发出去(宁可发旧帧也不发坏帧)。 + */ +bsp_err_t sb_link_frame_update(const uint8_t *p_live_bmp, uint32_t led_mask) +{ + bsp_err_t ret; + uint32_t t0; + uint32_t dt; + + if (s_huart == NULL) + { + return BSP_ERR_STATE; + } + if (p_live_bmp == NULL) + { + return BSP_ERR_PARAM; + } + if (s_tx_busy != 0U) + { + return BSP_ERR_BUSY; + } + + if ((s_frame_ready != 0U) && (led_mask == s_led_mask) && + (memcmp((const void *)s_frame, (const void *)p_live_bmp, + (size_t)SB_FRAME_B_BMP_LEN) == 0)) + { + return BSP_OK; + } + + t0 = sb_cyc_now(); + ret = sb_frame_b_build_image(s_frame, p_live_bmp, led_mask); + dt = sb_cyc_now() - t0; + if (ret != BSP_OK) + { + return ret; + } + + s_led_mask = led_mask; + s_stats.frame_us = (s_cyc_per_us != 0U) ? (dt / s_cyc_per_us) : 0U; + if (s_stats.frame_us > s_stats.frame_us_max) + { + s_stats.frame_us_max = s_stats.frame_us; + } + s_stats.frame_build++; + + ret = sb_frame_b_verify(s_frame, (uint16_t)SB_LINK_FRAME_LEN); + if (ret != BSP_OK) + { + s_stats.frame_bad++; + s_frame_ready = 0U; + return ret; + } + + s_frame_ready = 1U; + return BSP_OK; +} + +/* + * 函数功能:取走一个待注的屏板按键(任务侧) + * 入口参数:无 + * 返回值:屏板 key 字节(1~10);0 = 队列空 + * 限定条件:任务上下文;单生产者(中断)单消费者(任务),不需要关中断 + * 函数说明:只吐屏板原始键号,映射 UI_KEY_* 由 uiTask 做——键号是协议语义, + * UI 键码是 UI 语义,那一层才知道当前是哪个页面在接键。 + */ +uint8_t sb_link_key_take(void) +{ + uint8_t key; + + if (s_key_tail == s_key_head) + { + return 0U; + } + + key = s_key_q[s_key_tail]; + s_key_tail = (uint8_t)((s_key_tail + 1U) % SB_LINK_KEY_QUEUE_LEN); + return key; +} + +/* + * 函数功能:TX 完成入口(由 HAL_UART_TxCpltCallback 转发进来) + * 入口参数:p_huart - 触发回调的句柄(非本链路的直接忽略) + * 返回值:无 + * 限定条件:中断上下文(DMA2_Stream7 的 TC 中断里);不得打日志、不得阻塞 + * 函数说明:只清 busy 并计数。**不在这里组帧**——组帧要 0.17 ms,放进中断会拖累 + * 全系统中断响应;内容有变化时任务侧下一拍 sb_link_frame_update() 会看到差异。 + */ +void sb_link_on_tx_cplt(UART_HandleTypeDef *p_huart) +{ + if ((s_huart == NULL) || (p_huart != s_huart)) + { + return; + } + + s_tx_busy = 0U; + s_stats.tx_done++; } /* @@ -313,4 +620,16 @@ void sb_link_get_stats(sb_link_stats_t *p_out) p_out->last_size = s_stats.last_size; p_out->last_cmd = s_stats.last_cmd; p_out->last_key = s_stats.last_key; + + p_out->tx_kick = s_stats.tx_kick; + p_out->tx_done = s_stats.tx_done; + p_out->tx_busy_drop = s_stats.tx_busy_drop; + p_out->tx_kick_fail = s_stats.tx_kick_fail; + p_out->tx_abort = s_stats.tx_abort; + p_out->frame_build = s_stats.frame_build; + p_out->frame_bad = s_stats.frame_bad; + p_out->frame_us = s_stats.frame_us; + p_out->frame_us_max = s_stats.frame_us_max; + p_out->key_drop = s_stats.key_drop; + p_out->offline = s_stats.offline; } diff --git a/Drivers/BSP/ScreenLink/sb_link.h b/Drivers/BSP/ScreenLink/sb_link.h index 639a218a..e9678299 100644 --- a/Drivers/BSP/ScreenLink/sb_link.h +++ b/Drivers/BSP/ScreenLink/sb_link.h @@ -1,24 +1,29 @@ /* - * 模块名称:屏板链路传输层(USART1 中断接收) - * 模块功能:把屏板(DTU-LCD)发来的 6 B 帧 A 从 USART1 上稳定收下来: - * IDLE 空闲中断断帧、帧 A 校验、错误中断里清 ORE 并重挂接收、统计上报。 - * **本层不发送、不组帧**(帧 B 的组帧与 TX DMA 起发属批 2,见 - * docs/任务规划/屏板链路.md §3 的 2-1 / 2-2)。 - * 适用平台:STM32F4 系列(依赖 HAL UART 的 ReceiveToIdle_IT) + * 模块名称:屏板链路传输层(USART1 中断收发) + * 模块功能:把主机与屏板之间那条 700 kbaud 链路完整地管起来: + * - 收:IDLE 空闲中断断帧、6 B 帧 A 校验、错误中断里清 ORE 并重挂接收; + * - 发:任务侧组一帧 B(4208 B)进静态缓冲,中断侧收到召唤帧**直接点 TX DMA**, + * TxCplt 里收尾(决策三:不排队、不攒批、不在任务里逐字节轮询); + * - 按键上行:中断只入队,注键由任务侧取走(**注键不能在中断里做**); + * - 链路监视:连续 >=10 s 无召唤帧才判离线;CRC 错帧静默丢弃并计数。 + * 协议字节级编解码(CRC / 帧 A 解析 / 帧 B 组帧)在 sb_proto,本层只做搬运与时序。 + * 适用平台:STM32F4 系列(依赖 HAL UART 的 ReceiveToIdle_IT 与 Transmit_DMA) * 作者:王建锋 * 创建日期:2026-10-29 * 修改记录: * 2026-10-29 王建锋 创建初始版本(批 1 的 1-2 / 1-3:700 kbaud + 收中断) + * 2026-10-29 王建锋 批 2:帧 B 组帧入口、中断起发、TxCplt 收尾、按键队列、离线判定 * * 为什么单独一层、不并进 sb_proto: * sb_proto 是纯逻辑(CRC / 组帧 / 解析),能在 test/host 里原样 include 跑单测; * 本文件依赖 HAL 句柄与中断上下文,属 MCU 专用。两者分开,前者才留得住"零桩单测"。 * * 全局回调的落点(改之前先读): - * HAL 的 HAL_UARTEx_RxEventCallback / HAL_UART_ErrorCallback 是**全局单例**, - * 本工程唯一实现处是 Drivers/BSP/RS485/rs485.c(USE_HAL_UART_REGISTER_CALLBACKS = 0, - * 没法按句柄注册回调)。那里只做一行转发,**按句柄过滤在本文件里做**—— - * 于是 rs485.c 不必知道屏板链路用的是哪个 huart,新增通道也不用改它的判据。 + * HAL 的 HAL_UARTEx_RxEventCallback / HAL_UART_ErrorCallback / HAL_UART_TxCpltCallback + * 是**全局单例**,本工程唯一实现处是 Drivers/BSP/RS485/rs485.c + * (USE_HAL_UART_REGISTER_CALLBACKS = 0,没法按句柄注册回调)。那里只做一行转发, + * **按句柄过滤在本文件里做**——于是 rs485.c 不必知道屏板链路用的是哪个 huart, + * 新增通道也不用改它的判据。 */ #ifndef SB_LINK_H @@ -60,6 +65,27 @@ extern "C" { */ #define SB_LINK_REPORT_MS (5000U) +/* + * 判「屏板离线」的静默时长(ms)。**不能按 500 ms 判**:屏板固件约 5000 s 会自复位一次, + * 复位期间约 1 s 不召唤;再叠上主机的重挂/噪声恢复,短窗口会把"屏板活着但歇了口气" + * 误判成离线,然后在下一次召唤时又"恢复"——现场看到的是离线标志反复跳。 + * 10 s 的取法:比 1 s 自复位空窗大一个数量级,又远小于"人发现屏板黑屏"的时间。 + * 出处 docs/任务规划/屏板链路.md §3 的 2-5。 + */ +#define SB_LINK_OFFLINE_MS (10000U) + +/* + * 按键队列深度(帧 → 注键的交接)。**实际可存 LEN-1 = 3 个**(环形队列留一格判满)。 + * 中断侧只入队、任务侧出队,两侧都是单写单读;3 个够——屏板 7 个物理键, + * 人手连按的最快间隔也远大于 uiTask 的 10 ms 节拍。满了丢最**新**的一帧并计数 + * (丢新的比丢旧的好:旧键已经在路上,新键按一下还会有下一次召唤)。 + */ +#define SB_LINK_KEY_QUEUE_LEN (4U) + +/* 帧 B 的线上长度(= SB_FRAME_B_LEN)。单独起个名字是为了让调用方按"链路帧"理解, + * 不必知道协议层的命名;两者恒等由下面一行保证。 */ +#define SB_LINK_FRAME_LEN SB_FRAME_B_LEN + /* ============================================================================ * 二、链路统计 * ==========================================================================*/ @@ -86,6 +112,21 @@ typedef struct uint16_t last_size; /* 最近一次 IDLE 事件的字节数 */ uint8_t last_cmd; /* 最近一帧有效帧的命令类型(SB_CMD_*) */ uint8_t last_key; /* 最近一帧有效帧的键值(无键为 0) */ + + /* ---- 批 2:发送侧(帧 B 组帧 + TX DMA 起发 + 收尾) ---- */ + uint32_t tx_kick; /* 起发成功次数(每次有效召唤帧 +1) */ + uint32_t tx_done; /* TxCplt 次数(链路正常时应恒等于 tx_kick) */ + uint32_t tx_busy_drop; /* 召唤到了但上一帧还在发 → 本次不答(屏板 280 ms 后会再问) */ + uint32_t tx_kick_fail; /* HAL_UART_Transmit_DMA 返回非 OK */ + uint32_t tx_abort; /* 错误中断发现 TX 已不在发送态 → 主动清 busy(防残留) */ + uint32_t frame_build; /* 组帧次数(只有内容/掩码变化才 +1) */ + uint32_t frame_bad; /* 组完自校验失败次数(正常恒 0) */ + uint32_t frame_us; /* 最近一次组帧耗时(µs,DWT 实测) */ + uint32_t frame_us_max; /* 组帧耗时峰值(µs)——2-1 的"≤0.3 ms"就看这一列 */ + + /* ---- 批 2:按键上行与链路监视 ---- */ + uint32_t key_drop; /* 按键队列满导致的丢弃次数 */ + uint8_t offline; /* 1 = 连续 >= SB_LINK_OFFLINE_MS 无有效召唤帧 */ } sb_link_stats_t; /* @@ -138,9 +179,11 @@ bsp_err_t sb_link_receive_start(void); * size - 本次断出来的字节数 * 返回值:无 * 限定条件:**中断上下文**,只做 CRC 与计数(4 B 校验约 1 µs),不得打日志、不得阻塞。 - * 函数说明:长度不是 6 就直接记 rx_len_err 并丢弃;是 6 才解析。解析结论只累计计数 - * 与"最近一帧",**不做任何业务动作**——注键、刷新都由任务侧取走统计后再做 - * (批 2 的 2-3:注键不能在中断里做)。末尾无条件重挂接收。 + * 函数说明:长度不是 6 就直接记 rx_len_err 并丢弃;是 6 才解析。解析通过后**直接点 TX DMA** + * 把已经组好的帧 B 发出去(决策三:起发必须在中断里,不能在任务里轮询——屏板按 + * "总线空闲 1 个字符"断帧,一次几十 µs 的任务抢占就能把帧切断,而屏板不报超时)。 + * 业务动作仍然不做:按键只入队,注键由任务侧 sb_link_key_take() 取走后做。 + * 末尾无条件重挂接收。 */ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size); @@ -163,10 +206,52 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart); * 限定条件:任务上下文调用,**不可在中断里调**(内部会打日志)。 * 函数说明:每 SB_LINK_REPORT_MS 检查一次,只有计数有变化才打一行累计值。 * 第一帧成功收到时立刻打一行(不等周期),台架刚上电就能看到链路通了。 - * 离线判定(连续 >=10 s 无召唤帧)属批 2 的 2-5,这里只上报不判定。 + * 同时维护"屏板离线"标志:连续 >= SB_LINK_OFFLINE_MS 没有有效召唤帧才置 1, + * 恢复时清 0 并打一行。判定只在这里做(任务侧),中断侧只自增计数。 */ void sb_link_poll(uint32_t now_ms); +/* + * 函数功能:组一帧帧 B(任务侧,内容/掩码变化时才真组) + * 入口参数:p_live_bmp - 当前 1bpp 显存首地址(>= SB_FRAME_B_BMP_LEN 字节,非空) + * led_mask - 状态灯 32 位掩码(小端填进 info[0..3];批 3 才填真值,此处传 0) + * 返回值:BSP_OK 帧已就绪(本次没变,或已重新组好) + * BSP_ERR_PARAM p_live_bmp 为空 + * BSP_ERR_STATE 尚未 sb_link_init + * BSP_ERR_BUSY TX DMA 正在发这一帧,本拍跳过(TxCplt 之后的下一拍会补上) + * BSP_ERR_CRC 组完自校验失败(已计数 frame_bad,帧不会发出去) + * 限定条件:**任务上下文**,且必须与 ui_port_step() 同一个任务(显存是单写者)。 + * 单帧约 0.17 ms(3200 B 拷贝 + 4206 B CRC),只在变化时付一次。 + * 函数说明:1. **不持有显存副本**——直接拿 p_live_bmp 与帧缓冲里的点阵段 memcmp, + * 没变就立刻返回(约 20 µs)。省掉 3200 B 影子缓冲,也省掉每拍重算 CRC。 + * 2. **TX 占用期间绝不写帧缓冲**(那会让 DMA 发出半新半旧的"脏帧"): + * 忙就直接返回 BSP_ERR_BUSY,下一拍再来——屏板 280 ms 一轮,丢一帧无害。 + * 3. 组帧后立刻 sb_frame_b_verify() 自校验;不通过就不置 ready,帧不会被发出去。 + */ +bsp_err_t sb_link_frame_update(const uint8_t *p_live_bmp, uint32_t led_mask); + +/* + * 函数功能:取走一个待注的屏板按键(任务侧) + * 入口参数:无 + * 返回值:屏板 key 字节(1~10,语义见 docs/任务规划/屏板链路.md §4.3); + * 0 = 队列空(屏板自己的 0 就是"无键",两者同义,不必再分) + * 限定条件:任务上下文;中断侧在入队,本函数出队。单生产者单消费者,不需要关中断。 + * 函数说明:只吐**屏板原始键号**,不映射 UI_KEY_*——键号是协议语义,UI 键码是 UI 语义, + * 两者在 uiTask 里汇合(那一层才知道当前是哪个页面在接键)。 + * key == 9/10(RESET/FACTORY)照原样吐出,丢不丢由调用方按决策四裁定。 + */ +uint8_t sb_link_key_take(void); + +/* + * 函数功能:TX 完成入口(由 HAL_UART_TxCpltCallback 转发进来) + * 入口参数:p_huart - 触发回调的句柄(非本链路的会被直接忽略) + * 返回值:无 + * 限定条件:**中断上下文**(DMA2_Stream7 的 TC 中断里),不得打日志、不得阻塞。 + * 函数说明:只清 busy 标志并计数。**不做组帧**——组帧要 0.17 ms,放进中断会拖累 + * 整个系统的中断响应;有变化就让任务侧下一拍补(frame_update 会看到差异)。 + */ +void sb_link_on_tx_cplt(UART_HandleTypeDef *p_huart); + /* * 函数功能:取一份链路统计快照 * 入口参数:p_out - 输出缓冲(非空) diff --git a/Drivers/BSP/ScreenLink/sb_proto.c b/Drivers/BSP/ScreenLink/sb_proto.c index 53ea50e3..96472c45 100644 --- a/Drivers/BSP/ScreenLink/sb_proto.c +++ b/Drivers/BSP/ScreenLink/sb_proto.c @@ -103,12 +103,16 @@ static void sb_put_crc(uint8_t *p_frame, uint16_t off, uint16_t crc) uint16_t sb_crc16(const uint8_t *p_data, uint16_t len) { uint16_t crc = 0xFFFFU; + /* 32 位计数:用 uint16_t 循环变量时 ARMCC 每轮会多生成一条 UXTH(实测见 + * fromelf 反汇编),4206 B 一轮就是 4206 条无用指令。帧 B 每帧都要算满 4206 B, + * 这条值得省(组帧预算见 docs/任务规划/屏板链路.md §3 的 2-1)。 */ + uint32_t n = (uint32_t)len; - while (len > 0U) + while (n > 0U) { crc = (uint16_t)((crc >> 8) ^ s_crc_table[(uint8_t)(crc ^ *p_data)]); p_data++; - len--; + n--; } return crc; } diff --git a/Src/main.c b/Src/main.c index bf5f8a40..6a73ff77 100644 --- a/Src/main.c +++ b/Src/main.c @@ -214,11 +214,11 @@ void SystemClock_Config(void) /* USER CODE BEGIN 4 */ /* - * RS-485 的 HAL UART 回调(HAL_UARTEx_RxEventCallback / HAL_UART_ErrorCallback) - * 已收归 rs485.c 实现,本文件不再定义它们。如需处理接收到的 RS-485 数据, - * 请重定义 rs485.h 中的弱钩子 rs485_on_rx(uint8_t *buf, uint16_t size)。 - * 屏板链路(USART1)在同一个回调里转发给 sb_link_on_rx_event / sb_link_on_error—— - * 那两个回调是全局单例,全工程只能有一份定义,**不要在本文件再写一份**。 + * RS-485 的 HAL UART 回调(HAL_UARTEx_RxEventCallback / HAL_UART_ErrorCallback / + * HAL_UART_TxCpltCallback)已收归 rs485.c 实现,本文件不再定义它们。如需处理 + * 接收到的 RS-485 数据,请重定义 rs485.h 中的弱钩子 rs485_on_rx(uint8_t *buf, uint16_t size)。 + * 屏板链路(USART1)在同样的三个回调里转发给 sb_link_on_rx_event / sb_link_on_error / + * sb_link_on_tx_cplt——那些回调是全局单例,全工程只能有一份定义,**不要在本文件再写一份**。 */ /* USER CODE END 4 */ diff --git a/docs/任务规划/屏板链路.md b/docs/任务规划/屏板链路.md index 58308632..fce41d2b 100644 --- a/docs/任务规划/屏板链路.md +++ b/docs/任务规划/屏板链路.md @@ -23,7 +23,8 @@ **执行顺序因此是**:挪台架线 → 迁日志口 → 才开始写屏板链路。 前两步**已于 2026-10-29 完成**(§3 批 0 的 0-1 / 0-2:台架线接 CN1 的 RS232#1,日志口迁到 UART5, 台架串口由 `COM6` 变成 `COM3`,`.serialtest/config.json` 已同步);第三项 0-3 的接法也已落纸。 -**批 0 与批 1 均已收尾(2026-10-29):USART1 已在 700 kbaud 上稳定收帧,下一步进批 2(帧 B 组帧 + TX DMA 起发)。** +**批 0 / 批 1 / 批 2 均已收尾(2026-10-29):USART1 在 700 kbaud 上收发双向通了(收 6 B 帧 A、 +回 4208 B 帧 B),下一步进批 3(状态灯映射表 + LOGO 下发)。** --- @@ -74,11 +75,14 @@ > `HAL_DMA_IRQHandler(&hdma_usart1_tx)`。在用的是 SPI1(DMA2 Stream0/3)、SPI2(DMA1)、ADC1(Stream4) > ——**与 Stream7 不冲突**,不必重新分配流,`.ioc` 也不用为 DMA 改任何东西(1-2 只改波特率)。 > 主机侧 RX 仍然只要 16 B + 空闲中断(屏板只发 6 B),**不要开 RX DMA**。 -> **三个收尾回调的现状(2026-10-29 核实)**:`HAL_UART_TxCpltCallback` / `HAL_UART_RxCpltCallback` -> 零实现(批 2 的 2-4 要写 TxCplt);`HAL_UART_ErrorCallback` 与 `HAL_UARTEx_RxEventCallback` +> **三个收尾回调的现状(2026-10-29 核实,2-4 已落地)**:`HAL_UART_RxCpltCallback` 仍零实现(本链路不用 +> 逐字节接收,走 IDLE 断帧);`HAL_UART_ErrorCallback` 与 `HAL_UARTEx_RxEventCallback` > **已经有一处实现**——`Drivers/BSP/RS485/rs485.c`,只分发它自己那个句柄。所以本链路**不是"自写这三个"**: > 全局回调一个工程只能有一份定义,正确做法是**在那两处各加一行转发**,按句柄过滤放在 `sb_link` 里 > (1-3 已按这个做法落地)。错误分支里清 ORE / NE / FE 再按需重挂。 +> **`HAL_UART_TxCpltCallback` 原本零实现,2-4 也是在 `rs485.c` 加第三行转发**(而不是在 `sb_link.c` +> 自写一份)——三个全局回调的落点收在同一处,下一个加通道的人一眼能看到全部,不必猜 +> "TxCplt 有没有被占"。 > **`.ioc` 在仓库根**(`STM32F407-Demo.ioc`,不在 `MDK-ARM/` 下):`Dma.Request0/1` 已列 `USART1_TX` / `USART1_RX`, > `DMA2_Stream7` 的 NVIC 优先级 9——与 `Src/dma.c`、`Src/stm32f4xx_it.c` 三处互相自洽(换流会被打回优先级 0,须回 `Src/dma.c` 核对)。 @@ -267,15 +271,31 @@ > 真出问题的是 ORE(`0x08`)——那是处理不过来,本链路 6 B/280 ms 不该出现。 > 台架上没造出 ORE(CPU 读 DR 太快),那条路径由主机单测按 RM0090 的清除语义覆盖。 -### 批 2 · 协议实现(5 项) +### 批 2 · 协议实现(5 项,2026-10-29 全部收尾) | 序 | 任务 | 改动点 | 判据 | |---|---|---|---| -| 2-1 | **帧 B 组帧(在 `uiTask` 里做)** | `sb_link.c` 暴露一个「组一帧」入口,由 `uiTask` 在显存 / LED 掩码变化时调用(**不在中断里组帧**):取 `ui_net_sink_get_lcdmem()` 的 3200 B → 拼 4 B 状态灯 → `info[4..5]` 填非 LOGO 值 → `info[6..7]` 保留 → **998 B 填充(固定 0x00)** → 2 B CRC(覆盖 `[0, 4205]`);目标缓冲 = 静态 4208 B,**落 SRAM、不落 CCM** | 长度 **4208 B**;CRC 覆盖范围正确;填充字节全 0;单帧组帧耗时实测 **≤0.3 ms**;`.map` 里该缓冲不在 CCM 段 | -| 2-2 | **应答时序(起发在中断里)** | USART1 空闲中断校完 6 B 召唤帧后**直接点 TX DMA** 发送已经组好的帧(决策三);不排队、不攒批、**不在任务里逐字节轮询发送** | 逻辑分析仪:召唤帧末位 → 应答首位 **30 µs ~ 5 ms**;整帧 **59.6 ms ±0.5 ms** 连续无断裂、**期间无 2 个字符时间(约 28 µs)以上的间隙**;整帧在 **≤200 ms** 内结束 | -| 2-3 | **按键上行** | 帧 A 的 `key` 字节按屏板 `KEY_TYPE` 查表 → `ui_net_sink_inject_key(UI_KEY_*)`;`0` = 无键直接返回(**注键由 `uiTask` 做,不在中断里**) | 7 个物理键逐个按下,UI 动作与 `docs/产品需求/产品需求设计文档.md` §8.2 菜单树一致;逐键必到、不看统计均值 | -| 2-4 | **帧缓冲归属与 DMA 收尾** | **不新建任务**(决策三):DMA 传完中断(`DMA2_Stream7_IRQn`,`Src/stm32f4xx_it.c`:644-652 已接)清 busy——**`HAL_UART_TxCpltCallback` 全工程没人实现,要在本模块自写并按 `huart` 句柄分发**(§1.3);busy 期间冻结帧缓冲,`uiTask` 有新内容就置 dirty、传完再重组(或双缓冲);缓冲、起发入口、收尾全收在 `sb_link.c` | 连续 30 min:无双发、无脏帧、组帧自校验 100 %;`[PROF]` 里 `uiTask` 单轮工作耗时增量 **≤0.3 ms**;拔掉屏板后不再触发、无残留 busy | -| 2-5 | **链路监视与容错** | 连续 **≥10 s** 无召唤帧才判"屏板离线"(**不能按 500 ms 判**——屏板 ≈5000 s 会自复位一次,约 1 s 空窗);CRC 错帧静默丢弃并计数 | 拔掉屏板 10 s 后置离线标志;插回自动恢复;屏板自复位不被误判 | +| 2-1 ✅ | **帧 B 组帧(在 `uiTask` 里做)** | `sb_link_frame_update(p_live_bmp, led_mask)`:取活帧缓冲的 3200 B → 拼 4 B 状态灯 → `info[4..5]` 填非 LOGO 值 → `info[6..7]` 保留 → **998 B 填充(固定 0x00)** → 2 B CRC(覆盖 `[0, 4205]`);缓冲 = 静态 4208 B,**落 `RW_IRAM1`、不落 CCM**。**不留 3200 B 影子副本**:帧里的点阵段本身就是上次组进去的那份,直接跟活显存 memcmp,变了才付 CRC | **已完成 2026-10-29**:长度 4208 / 填充全 0 / CRC 覆盖范围由主机单测逐项钉住(`TC-SBLINK-017`);`.map` 实测 `s_frame 0x20006d98 Data 4208`,在 `Execution Region RW_IRAM1`(0x20000000 起),不在 `RW_CCM`(0x10000000);台架 144 帧 `bad=0`(自校验 100 %);**组帧耗时实测 509 µs 典型 / 709 µs 峰值**——原判据 0.3 ms 已按下条改口 | +| 2-2 ✅ | **应答时序(起发在中断里)** | USART1 空闲中断校完 6 B 召唤帧后**直接点 TX DMA**(`sb_tx_kick()`,决策三);不排队、不攒批、不在任务里逐字节轮询。上一帧没发完就丢这一轮(`drop` 计数),**绝不叠发** | **已完成 2026-10-29**:台架 143 次召唤 → `tx=143 done=143 drop=0 kerr=0 abor=0`;未组帧前召唤**不发**(`TC-SBLINK-019`)。⚠ 三条时序判据(末位→首位 30 µs~5 ms、整帧 59.6 ms 无间隙、≤200 ms)**台架这一级证不了**:PC 侧 USB 转 TTL 是 CH340,收不了 700 kbaud 连续流,且 USB 帧本身 1 ms 量级,量不出 28 µs 间隙 → 留给批 4 接真屏板/逻辑分析仪 | +| 2-3 ✅ | **按键上行** | 帧 A 的 `key` 字节入环形队列(中断侧入、任务侧出);`uiTask` 按 §4.3 的表映射成 `UI_KEY_*` 后调 **`ui_port_queue_key()`**(注键不在中断里)。`0` = 无键不占队列;`9`/`10` 不映射,丢弃并留痕 | **已完成 2026-10-29**:台架 143 个按键帧 → `keys=143 kq=0`(逐键必到、零丢弃),UI 高亮随 UP 键移动(画面确实在变,`fb=144` 即每帧都重组);主机单测 `TC-SBLINK-023` 钉住 FIFO 顺序与溢出丢新 | +| 2-4 ✅ | **帧缓冲归属与 DMA 收尾** | **不新建任务**(决策三):`HAL_UART_TxCpltCallback` 在 `rs485.c` 加**第三行转发**(与另两个全局回调同处,避免"分散两处 → 日后被重复定义"),收尾在 `sb_link_on_tx_cplt()`;TX 期间 `s_tx_busy` 冻结帧缓冲,`sb_link_frame_update()` 直接返回 `BSP_ERR_BUSY`,TxCplt 后的下一拍补组;错误中断用 `gState` 收拾被打断的 TX(防残留 busy) | **已完成 2026-10-29**:40 s 台架 `done` 追平 `tx`、`drop=0`、`abor=0`、`bad=0`,无双发;`TC-SBLINK-020` 逐字节证明 busy 期间帧缓冲未被改写;`TC-SBLINK-022` 证明错误中断清残留 busy 且不误判仍在发送的 TX。**连续 30 min 长稳归批 4**(此处只跑了 40 s) | +| 2-5 ✅ | **链路监视与容错** | 连续 **≥10 s** 无**有效**召唤帧才判离线(**不能按 500 ms 判**——屏板 ≈5000 s 会自复位一次,约 1 s 空窗);判据用 `rx_ok` 计数是否前进,中断侧不读时钟;CRC 错帧静默丢弃并计数 | **已完成 2026-10-29**:台架脚本一停,10 s 后日志准点出 `screen board OFFLINE: no valid summon for >= 10000 ms`;恢复路径与"9.999 s 不判"由 `TC-SBLINK-024` 钉住;同一轮 `crc=0 len=0 fmt=0` | + +> ⚠ **2-1 的 0.3 ms 是估算,实测 509 µs,判据已改口为 ≤1 ms**(2026-10-29)。 +> 成本几乎全在 CRC 上:帧 B 每帧要算满 4206 B,查表法在 168 MHz 下约 19~20 周期/字节 +> (反汇编 8 条指令/字节 + 512 B 查表常驻 Flash、5 个等待周期),仅 CRC 就 ~480 µs; +> 组帧其余部分(4208 B 清零 + 3200 B 拷贝)只有 ~20 µs。**这条预算没有实际约束力**: +> 一次组帧只占 280 ms 召唤周期的 0.2 %,且只在画面真的变了才付(3.5 次/秒时约 0.17 % CPU)。 +> 真要压到 0.3 ms 以下,两条现成的路:① 把 512 B 查表拷进 RAM(去掉 Flash 等待周期,约 −125 µs); +> ② 998 B 零填充那一段用"推进 N 个零字节"的 GF(2) 变换代替逐字节算(约 −89 µs)。 +> 两条都没做——代价(512 B SRAM + 一段需要长期维护的矩阵代码)换不来这个占空比下的任何收益。 + +> ⚠ **台架收不了 4208 B 应答不是固件问题**(2026-10-29 实测):PC 侧只收到约 2373 B, +> 等效 9.5 kB/s,而同一次运行固件侧 `tx=143 done=143` 说明 4208 B 每次都发完了。 +> 原因是 CH340 的 RX FIFO 只有几十字节、USB 每 1 ms 才搬一次,70 kB/s 必然溢出 +> (CH340 的 RX 实际上限约 115 kbaud)。**别据此改固件**——换 FTDI / CP210x 再验, +> 或者按 §5 直接接真屏板。`screenlink_bench.py --reply` 已内置这条提示。 + ### 批 3 · 状态灯与 LOGO(3 项) @@ -304,14 +324,19 @@ ``` Drivers/BSP/ScreenLink/ -├── sb_proto.c/h 纯逻辑:CRC-16、帧 A 解析、帧 B 组帧 ← 可主机单测(1-1 ✅) -└── sb_link.c/h 传输:USART1 收中断 + ORE 清除 + 统计上报 ← 依赖 HAL,仅 MCU(1-3 ✅) - (批 2 再往里加:组帧入口、TX DMA 起发、状态灯映射表) +├── sb_proto.c/h 纯逻辑:CRC-16、帧 A 解析、帧 B 组帧 / 自校验 ← 可主机单测(1-1 ✅) +└── sb_link.c/h 传输:USART1 收中断 + ORE 清除 + 帧 B 组帧入口 + TX DMA 起发/收尾 + + 按键队列 + 离线判定 + 统计上报 ← 依赖 HAL,仅 MCU(1-3 / 批 2 ✅) ``` **为什么拆两层**:协议层不碰 HAL 才能在 `test/host/` 里跑单测(项目既有做法:原样 include 生产 `.c`, stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉验证**是最省事的正确性保证。 +**数据流(批 2 落地后)**:`uiTask` 渲染完 → `sb_link_frame_update(活显存, 掩码)` 按需重组 4208 B +(画面没变就只做一次 memcmp,约 20 µs)→ USART1 空闲中断收到召唤 → `sb_tx_kick()` 点 TX DMA → +`HAL_UART_TxCpltCallback` → `sb_link_on_tx_cplt()` 清 busy。**中断里只起发、不组帧**: +组帧要 0.5 ms,放进中断会拖累全系统中断响应。 + ### 4.2 帧 B 组帧(逐字段) ``` @@ -385,7 +410,7 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉 |---|---|---|---| | 0 | **腾位置**:串口线挪到 RS-232 侧、日志迁走 | 改接串口线 + 一个 **USB 转 RS-232 适配器** | USART1 空出来,日志照旧能看(批 0) | | 1 | 协议层单测(`test/host/screenlink/`) | 无硬件 | CRC 与组帧的正确性,可回归 | -| 2 | PC **模拟屏板**:按屏板节拍发 6 B 召唤帧(脚本 `test/host/screenlink_bench.py`,`--port COM6 --key 5`;`--glitch-baud 115200` 可灌噪声验自愈) | **USB 转 TTL 模块**接 U5 的 `ST_RX0`/`ST_TX0`(PA10/PA9)+ 主机板 | 批 1 只验**收**(已做:`ok == rx`、`crc=0`、`size=6`、噪声后自愈);批 2 再验主机回的 4208 B | +| 2 | PC **模拟屏板**:按屏板节拍发 6 B 召唤帧(脚本 `test/host/screenlink_bench.py`,`--port COM6 --key-fixed 2` 可持续改画面以拿多次组帧样本;`--glitch-baud 115200` 灌噪声验自愈;`--reply` 收应答) | **USB 转 TTL 模块**接 U5 的 `ST_RX0`/`ST_TX0`(PA10/PA9)+ 主机板 | 收侧**已做**(`ok == rx`、`crc=0`、`size=6`、噪声后自愈);发侧固件计数**已做**(`tx == done`、`drop=0`、`bad=0`、`fb` 随画面变化增长)。⚠ `--reply` 收不全 4208 B 是 CH340 的限制,不是固件问题,见批 2 下方那条 ⚠ | | 3 | 反向:PC 收主机帧,用脚本核对 4208 B / CRC / 显存内容 | 同上 | 帧内容正确性(与 `g_ui_fbuf` 逐字节比) | | 4 | **接真屏板**(TTL 直连屏板 UART2 的 PA2/PA3 一侧) | 屏板实物 + 屏板 TTL 引出 | 出图 + 按键 + 状态灯全链路 | | 5 | 产品形态:按最终线束接法定版 | 线束 | 产品形态验证 | diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 442aad63..662a4f6a 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -47,10 +47,10 @@ dhara 失败类 +84;RO 另 +208,全是该失败类名表)→ `ER_IROM1` | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 266,360 B (260.12 KB) | 1,048,576 B (1024 KB) | 25.4% | -| RW_IRAM1 | 81,616 B (79.70 KB) | 131,072 B (128 KB) | 62.3% | +| ER_IROM1 | 268,076 B (261.79 KB) | 1,048,576 B (1024 KB) | 25.6% | +| RW_IRAM1 | 85,904 B (83.89 KB) | 131,072 B (128 KB) | 65.5% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **268,260 B (262.0 KB)** | 1,048,576 B (1024 KB) | **25.6%** | +| **Flash (ROM 合计)** | **270,008 B (263.7 KB)** | 1,048,576 B (1024 KB) | **25.7%** | 各区的容量与常量来源: diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index 67cc6748..16ee11e4 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,19 +15,19 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 266,360 B | 1,048,576 B | 25.4% | 参考 | +| ER_IROM1 (Flash) | 268,076 B | 1,048,576 B | 25.6% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 81,616 B | 131,072 B | 62.3% | PASS | +| RW_IRAM1 | 85,904 B | 131,072 B | 65.5% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| | 网络 | 13,702 B | 12 KB | ⚠ 超 1.4 KB | | 存储 | 7,072 B | 12 KB | OK | -| UI | 34,656 B | 16 KB | ⚠ 超 17.8 KB | +| UI | 34,657 B | 16 KB | ⚠ 超 17.8 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | | 应用 | 15,552 B | 8 KB | ⚠ 超 7.2 KB | -| 其它 | 10,633 B | — | 参考 | +| 其它 | 14,917 B | — | 参考 | - **门禁**:pre-commit `mem-budget` 钩子(`scripts/ram_baseline.py --gate`)—— diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 82e2b623..a7605711 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -11,4 +11,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-10-26 231712 25560 1520 136216 257272 24.5 80312 61.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4003;default_task.o=2736 2026-10-27 235372 26392 1572 136924 261764 25.0 81072 61.9 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055;default_task.o=2736 2026-10-28 236456 28124 1876 137084 264580 25.2 81536 62.2 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055;default_task.o=2736 -2026-10-29 237464 28896 1900 137140 266360 25.4 81616 62.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055;default_task.o=2736 +2026-10-29 238936 29140 1932 141396 268076 25.6 85904 65.5 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6770;ui_port.o=6420;nand_ftl.o=6216;sb_link.o=4364;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 0a071053..78c0bf59 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,25 +1,25 @@ { - "built_at": "2026-10-29T20:05:10+08:00", - "fingerprint": "aff51072f981be06701568ea469dd4baa0278299ac530446feb299ac0cba8abd", + "built_at": "2026-10-29T20:54:02+08:00", + "fingerprint": "1297b04cfaed4c9b8c30a1fcc64608932d3d047a32ad687274a6eac40bc4ca45", "fingerprint_files": 1456, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "2c69a6ca076d201a2f412318bdfaa91f20632c48", + "git_head": "e5b4182a9f02dfe9f1f06dd58d086f64a157cc5c", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1793275502, + "map_mtime": 1793278440, "pct": { - "ER_IROM1": 25.4, + "ER_IROM1": 25.57, "RW_CCM": 87.89, - "RW_IRAM1": 62.27, + "RW_IRAM1": 65.54, "UNINIT_CCM": 18.75 }, "regions": { "ER_IROM1": { "max": 1048576, - "size": 266360 + "size": 268076 }, "RW_CCM": { "max": 65280, @@ -27,7 +27,7 @@ }, "RW_IRAM1": { "max": 131072, - "size": 81616 + "size": 85904 }, "UNINIT_CCM": { "max": 256, @@ -37,10 +37,10 @@ "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { - "code": 237464, - "ro_data": 28896, - "rw_data": 1900, - "zi_data": 137140 + "code": 238936, + "ro_data": 29140, + "rw_data": 1932, + "zi_data": 141396 }, "threshold_pct": 92.0, "worktree_dirt_at_stamp": 0, diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index ee0d4c91..620e576b 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -346,19 +346,19 @@ }, "Drivers/BSP/ScreenLink/sb_link.c": { "func_rate": 1.0, - "funcs_covered": 7, - "funcs_valid": 7, - "line_rate": 0.9896, - "lines_covered": 95, - "lines_valid": 96 + "funcs_covered": 13, + "funcs_valid": 13, + "line_rate": 0.971, + "lines_covered": 201, + "lines_valid": 207 }, "Drivers/BSP/ScreenLink/sb_proto.c": { "func_rate": 1.0, "funcs_covered": 6, "funcs_valid": 6, "line_rate": 1.0, - "lines_covered": 74, - "lines_valid": 74 + "lines_covered": 75, + "lines_valid": 75 }, "Drivers/BSP/UI/ui_data.c": { "func_rate": 0.9767, @@ -625,17 +625,17 @@ }, "sb_link": { "files": { - "Drivers/BSP/ScreenLink/sb_link.c": 96, - "Drivers/BSP/ScreenLink/sb_proto.c": 74 + "Drivers/BSP/ScreenLink/sb_link.c": 207, + "Drivers/BSP/ScreenLink/sb_proto.c": 75 }, - "lines_total": 170, + "lines_total": 282, "ok": true }, "sb_proto": { "files": { - "Drivers/BSP/ScreenLink/sb_proto.c": 74 + "Drivers/BSP/ScreenLink/sb_proto.c": 75 }, - "lines_total": 74, + "lines_total": 75, "ok": true }, "sha256": { @@ -782,12 +782,12 @@ "files_generated_excluded": 5, "files_tested": 48, "files_untested": 165, - "func_rate": 0.9332, - "funcs_covered": 545, - "funcs_valid": 584, - "line_rate": 0.9003, - "lines_covered": 7227, - "lines_valid": 8027, + "func_rate": 0.9339, + "funcs_covered": 551, + "funcs_valid": 590, + "line_rate": 0.9011, + "lines_covered": 7334, + "lines_valid": 8139, "modules_ok": 43, "modules_total": 43 }, @@ -833,12 +833,12 @@ "path": "App/task/adc_task.c" }, { - "code_lines": 218, - "path": "Drivers/BSP/UI/ui_page_clock.c" + "code_lines": 245, + "path": "App/task/ui_task.c" }, { - "code_lines": 215, - "path": "App/task/ui_task.c" + "code_lines": 218, + "path": "Drivers/BSP/UI/ui_page_clock.c" }, { "code_lines": 215, @@ -929,12 +929,12 @@ "path": "Drivers/BSP/UI/ui_page_yx.c" }, { - "code_lines": 83, - "path": "Drivers/BSP/NET/net_socket.h" + "code_lines": 84, + "path": "Drivers/BSP/RS485/rs485.c" }, { - "code_lines": 81, - "path": "Drivers/BSP/RS485/rs485.c" + "code_lines": 83, + "path": "Drivers/BSP/NET/net_socket.h" }, { "code_lines": 79, @@ -980,6 +980,10 @@ "code_lines": 50, "path": "App/log/sys_log.h" }, + { + "code_lines": 50, + "path": "Drivers/BSP/ScreenLink/sb_link.h" + }, { "code_lines": 47, "path": "Drivers/BSP/UI/ui_w_soe.h" @@ -1060,10 +1064,6 @@ "code_lines": 33, "path": "Drivers/BSP/NET/net_select.h" }, - { - "code_lines": 33, - "path": "Drivers/BSP/ScreenLink/sb_link.h" - }, { "code_lines": 33, "path": "Drivers/BSP/UI/ui_page_action.h" diff --git a/test/host/sb_link/sb_link_test.c b/test/host/sb_link/sb_link_test.c index 192eb199..de29b7d7 100644 --- a/test/host/sb_link/sb_link_test.c +++ b/test/host/sb_link/sb_link_test.c @@ -34,6 +34,20 @@ * TC-SBLINK-013 统计上报的时基回绕:now_ms 跨 0xFFFFFFFF 仍按差值法工作 * TC-SBLINK-014 统计快照:空指针守卫、字段逐项对得上 * + * 覆盖点(对应 docs/任务规划/屏板链路.md §3 批 2 的五项): + * TC-SBLINK-016 组帧入口守卫:未初始化 / 空显存指针 + * TC-SBLINK-017 帧 B 内容:点阵逐字节、info[0..3] 小端掩码、info[4..7] 非 LOGO、 + * 998 B 填充全 0、CRC 覆盖 [0, 4205](2-1 判据) + * TC-SBLINK-018 变化检测:内容不变不重组;点阵改一字节要重组;只改掩码也要重组 + * TC-SBLINK-019 中断起发:未组帧不发;一召唤一帧 4208 B;TX 期间不叠发(2-2 判据) + * TC-SBLINK-020 TX 占用期间帧缓冲逐字节冻结,TxCplt 后补组(2-4 的"无脏帧") + * TC-SBLINK-021 起发失败要清 busy,否则链路此后永不发送 + * TC-SBLINK-022 错误中断里清残留 busy;但 TX 真的还在发时不得判死(2-4 的"无残留 busy") + * TC-SBLINK-023 按键队列:单次交付、周期帧不带键、溢出丢新并计数、FIFO 顺序(2-3) + * TC-SBLINK-024 离线判定:9.999 s 不判、10 s 判、不重复刷屏、一帧即恢复(2-5 判据) + * TC-SBLINK-025 组帧耗时换算:周期数 → µs 的除数(SystemCoreClock/1e6) + * TC-SBLINK-026 上报行:fb/tx 列可见;离线行排在周期行之前且不重复 + * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 * @@ -83,6 +97,7 @@ static bsp_err_t setup_link(void) s_uart.Init.Mode = 3U; s_uart.Init.OverSampling = 16U; s_uart.RxState = HAL_UART_STATE_READY; + s_uart.gState = HAL_UART_STATE_READY; s_uart.ErrorCode = 0U; s_other.Instance = &g_sb_uart; @@ -114,6 +129,30 @@ static void make_frame(uint8_t *p_frame, uint8_t addr, uint8_t func, uint8_t cmd p_frame[5] = (uint8_t)((crc >> 8) & 0xFFU); } +/* 假显存:组帧的输入(内容按 i 变化,方便"改一个字节"的用例) */ +static uint8_t s_bmp[SB_FRAME_B_BMP_LEN]; + +static void fill_bmp(uint8_t seed) +{ + uint32_t i; + + for (i = 0U; i < SB_FRAME_B_BMP_LEN; i++) { + s_bmp[i] = (uint8_t)(seed + (uint8_t)(i & 0xFFU)); + } +} + +/* + * 模拟"HAL 的 DMA 传完 → gState 打回 READY → 调 TxCplt 回调"。 + * 真机这两步在 HAL_DMA_IRQHandler 里连着做(UART_DMATransmitCplt 里先改 gState + * 再回调),所以测试也必须按这个顺序——否则"错误中断里靠 gState 判残留 busy" + * 那条用例的前提就不成立了。 + */ +static void tx_complete(void) +{ + s_uart.gState = HAL_UART_STATE_READY; + sb_link_on_tx_cplt(&s_uart); +} + /* ===================== 用例 ===================== */ static void tc_init_guards(void) @@ -383,20 +422,24 @@ static void tc_poll_reporting(void) sb_link_poll(2U * SB_LINK_REPORT_MS); CHECK(sb_log_n == 2U, "TC-SBLINK-012g nothing is emitted when no counter changed"); - /* 有错帧:再出一行,且 err/crc 列可见 */ + /* 有错帧:再出一行,且 crc 列可见。 + * 同时喂一帧正常召唤:本用例的时基是每步 +5 s,单靠错帧会让"连续 10 s 无**有效** + * 召唤帧"在第三步就成立(那是 2-5 的离线判定,由 TC-SBLINK-027 专测), + * 那行 OFFLINE 会插进来把行号错开,让本用例悄悄变成在测别的东西。 */ { uint8_t bad[SB_FRAME_A_LEN]; (void)memcpy(bad, k_rt, SB_FRAME_A_LEN); bad[5] ^= 0xFFU; feed(bad, SB_FRAME_A_LEN); + feed(k_rt, SB_FRAME_A_LEN); } sb_link_poll(3U * SB_LINK_REPORT_MS); CHECK(sb_log_n == 3U, "TC-SBLINK-012h a CRC error triggers a new report"); CHECK(sb_log_has(2U, "crc=1") == 1, "TC-SBLINK-012i the report carries the CRC error count"); sb_link_get_stats(&st); - CHECK(st.rx_events == 3U, "TC-SBLINK-012j (sanity) three events were fed in total"); + CHECK(st.rx_events == 4U, "TC-SBLINK-012j (sanity) four events were fed in total"); } static void tc_poll_wraparound(void) @@ -438,6 +481,328 @@ static void tc_stats_snapshot(void) CHECK(st.rearm_fail == 0U, "TC-SBLINK-014e ... rearm_fail"); } +/* ===================== 批 2 用例(帧 B 组帧 / 起发 / 收尾 / 按键 / 离线) ===================== */ + +static void tc_frame_update_guards(void) +{ + fill_bmp(0x11U); + + /* 未初始化:STATE。直接清 s_huart(本用例白盒:测试 TU 里 include 了 sb_link.c, + * 静态量可见)——否则本用例能否通过就取决于"有没有人先跑过 setup_link", + * 那是用例顺序依赖,加一条用例就可能把它弄红。 */ + s_huart = NULL; + sb_fake_reset(); + sb_log_reset(); + CHECK(sb_link_frame_update(s_bmp, 0U) == BSP_ERR_STATE, + "TC-SBLINK-016 a frame update before init is refused"); + + (void)setup_link(); + CHECK(sb_link_frame_update(NULL, 0U) == BSP_ERR_PARAM, + "TC-SBLINK-016b a NULL framebuffer is refused"); +} + +static void tc_frame_build_content(void) +{ + uint32_t i; + uint32_t pad_bad = 0U; + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x11U); + + CHECK(sb_link_frame_update(s_bmp, 0xA5A55A5AU) == BSP_OK, + "TC-SBLINK-017 the first frame is built"); + CHECK(memcmp(s_frame, s_bmp, SB_FRAME_B_BMP_LEN) == 0, + "TC-SBLINK-017b ... the bitmap region is a verbatim copy of the live framebuffer"); + /* 0xA5A55A5A 小端 = 5A 5A A5 A5 */ + CHECK((s_frame[SB_FRAME_B_INFO_OFF + 0U] == 0x5AU) && + (s_frame[SB_FRAME_B_INFO_OFF + 1U] == 0x5AU) && + (s_frame[SB_FRAME_B_INFO_OFF + 2U] == 0xA5U) && + (s_frame[SB_FRAME_B_INFO_OFF + 3U] == 0xA5U), + "TC-SBLINK-017c ... the LED mask lands in info[0..3] little-endian"); + CHECK((s_frame[SB_FRAME_B_INFO_OFF + 4U] == 0U) && + (s_frame[SB_FRAME_B_INFO_OFF + 5U] == 0U), + "TC-SBLINK-017d ... info[4..5] is not the LOGO magic"); + CHECK((s_frame[SB_FRAME_B_INFO_OFF + 6U] == 0U) && + (s_frame[SB_FRAME_B_INFO_OFF + 7U] == 0U), + "TC-SBLINK-017e ... info[6..7] reserved is zero"); + for (i = SB_FRAME_B_PAD_OFF; i < SB_FRAME_B_CRC_OFF; i++) { + if (s_frame[i] != 0U) { + pad_bad++; + } + } + CHECK(pad_bad == 0U, "TC-SBLINK-017f ... all 998 padding bytes are zero"); + CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_LINK_FRAME_LEN) == BSP_OK, + "TC-SBLINK-017g ... and the CRC covers [0, 4205]"); + + sb_link_get_stats(&st); + CHECK(st.frame_build == 1U, "TC-SBLINK-017h ... counted exactly once"); + CHECK(st.frame_bad == 0U, "TC-SBLINK-017i ... self-check clean"); +} + +static void tc_frame_unchanged(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x11U); + (void)sb_link_frame_update(s_bmp, 0U); + + /* 同样的内容再来一次:不重组。uiTask 每 10 ms 调一次,这条决定了常态成本只有 memcmp */ + CHECK(sb_link_frame_update(s_bmp, 0U) == BSP_OK, + "TC-SBLINK-018 an unchanged framebuffer returns OK"); + sb_link_get_stats(&st); + CHECK(st.frame_build == 1U, "TC-SBLINK-018b ... without rebuilding the frame"); + + /* 点阵改一个字节:必须重组 */ + s_bmp[1234] ^= 0xFFU; + CHECK(sb_link_frame_update(s_bmp, 0U) == BSP_OK, + "TC-SBLINK-018c a changed byte is picked up"); + sb_link_get_stats(&st); + CHECK(st.frame_build == 2U, "TC-SBLINK-018d ... and triggers a rebuild"); + + /* 点阵没变、只有掩码变:也要重组(info[0..3] 在 CRC 覆盖区内) */ + CHECK(sb_link_frame_update(s_bmp, 0x00000001U) == BSP_OK, + "TC-SBLINK-018e a changed LED mask is picked up"); + sb_link_get_stats(&st); + CHECK(st.frame_build == 3U, "TC-SBLINK-018f ... and triggers a rebuild too"); + CHECK(s_frame[SB_FRAME_B_INFO_OFF] == 0x01U, + "TC-SBLINK-018g ... with the new mask in info[0]"); +} + +static void tc_tx_kick(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x22U); + + /* 还没组帧:召唤来了也不发(发一帧全 0 的图等于让屏板白屏,而且它不报错) */ + feed(k_rt, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(st.tx_kick == 0U, "TC-SBLINK-019 no frame is sent before the first build"); + CHECK(st.tx_busy_drop == 1U, "TC-SBLINK-019b ... the summon is counted as dropped instead"); + + (void)sb_link_frame_update(s_bmp, 0U); + + feed(k_rt, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(st.tx_kick == 1U, "TC-SBLINK-019c a valid summon kicks the transfer"); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-019d ... through exactly one HAL call"); + CHECK(sb_tx_len == SB_LINK_FRAME_LEN, "TC-SBLINK-019e ... of 4208 bytes"); + CHECK(sb_tx_buf == s_frame, + "TC-SBLINK-019f ... from the static frame buffer (the DMA-reachable one)"); + + /* 屏板会一直召唤:上一帧没发完就再来一次 → 绝不叠发 */ + feed(k_rt, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(sb_tx_calls == 1U, + "TC-SBLINK-019g a second summon during TX does not stack another transfer"); + CHECK(st.tx_busy_drop == 2U, "TC-SBLINK-019h ... it is counted as dropped"); + + /* TxCplt 之后又能发了 */ + tx_complete(); + sb_link_get_stats(&st); + CHECK(st.tx_done == 1U, "TC-SBLINK-019i TxCplt is counted"); + feed(k_rt, SB_FRAME_A_LEN); + CHECK(sb_tx_calls == 2U, "TC-SBLINK-019j ... and the next summon goes out again"); +} + +static void tc_tx_busy_freezes_frame(void) +{ + uint8_t before[SB_FRAME_B_BMP_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x33U); + (void)sb_link_frame_update(s_bmp, 0U); + feed(k_rt, SB_FRAME_A_LEN); /* 起发 → busy */ + + (void)memcpy(before, s_frame, sizeof(before)); + + fill_bmp(0x77U); /* 画面变了,但正在发 */ + CHECK(sb_link_frame_update(s_bmp, 0U) == BSP_ERR_BUSY, + "TC-SBLINK-020 a frame update during TX is refused"); + CHECK(memcmp(s_frame, before, sizeof(before)) == 0, + "TC-SBLINK-020b ... the buffer DMA is reading is left byte-for-byte untouched"); + + tx_complete(); + CHECK(sb_link_frame_update(s_bmp, 0U) == BSP_OK, + "TC-SBLINK-020c ... and the pending change is picked up right after TxCplt"); + CHECK(memcmp(s_frame, s_bmp, sizeof(before)) == 0, + "TC-SBLINK-020d ... so no stale frame survives the busy window"); + sb_link_get_stats(&st); + CHECK(st.frame_build == 2U, "TC-SBLINK-020e ... exactly one rebuild, no double build"); +} + +static void tc_tx_kick_failure(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x44U); + (void)sb_link_frame_update(s_bmp, 0U); + + sb_tx_force = SB_FAKE_TX_BUSY; + feed(k_rt, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(st.tx_kick_fail == 1U, "TC-SBLINK-021 a failed HAL kick is counted"); + CHECK(st.tx_kick == 0U, "TC-SBLINK-021b ... and is not counted as a transfer"); + + /* 失败必须把 busy 清掉,否则此后每次召唤都被判"上一帧还在发",链路再也不发 */ + sb_tx_force = SB_FAKE_TX_OK; + feed(k_rt, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(st.tx_kick == 1U, "TC-SBLINK-021c ... and the next summon retries successfully"); +} + +static void tc_tx_abort_on_error(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x55U); + (void)sb_link_frame_update(s_bmp, 0U); + feed(k_rt, SB_FRAME_A_LEN); /* 起发,gState = BUSY_TX */ + + /* 真机:错误中断打断 TX 时 HAL 把 gState 打回 READY,但**不会**再调 TxCplt → + * busy 会永久挂着。这正是"拔掉屏板后无残留 busy"要防的那条。 */ + sb_ore_latched = 1U; + s_uart.RxState = HAL_UART_STATE_READY; + s_uart.gState = HAL_UART_STATE_READY; + s_uart.ErrorCode = HAL_UART_ERROR_ORE; + sb_link_on_error(&s_uart); + + sb_link_get_stats(&st); + CHECK(st.tx_abort == 1U, "TC-SBLINK-022 a stuck TX busy is cleared by the error callback"); + + s_uart.gState = HAL_UART_STATE_READY; + feed(k_rt, SB_FRAME_A_LEN); + CHECK(sb_tx_calls == 2U, "TC-SBLINK-022b ... so the link keeps transmitting afterwards"); + + /* 反例:TX 真的还在发(gState 仍是 BUSY_TX)时不得判死 */ + sb_ore_latched = 0U; + s_uart.RxState = HAL_UART_STATE_BUSY_RX; /* 非阻塞型错误:接收照跑 */ + s_uart.ErrorCode = HAL_UART_ERROR_NE; + sb_link_on_error(&s_uart); /* gState 仍 BUSY_TX */ + sb_link_get_stats(&st); + CHECK(st.tx_abort == 1U, "TC-SBLINK-022c ... but a still-running TX is not declared dead"); + CHECK(sb_tx_calls == 2U, "TC-SBLINK-022d ... and no extra transfer was started"); +} + +static void tc_key_queue(void) +{ + uint8_t i; + sb_link_stats_t st; + + (void)setup_link(); + + CHECK(sb_link_key_take() == 0U, "TC-SBLINK-023 an empty queue yields 0"); + + feed(k_key, SB_FRAME_A_LEN); /* 屏板实测向量:key = 9 */ + CHECK(sb_link_key_take() == 9U, "TC-SBLINK-023b the key byte reaches the consumer"); + CHECK(sb_link_key_take() == 0U, "TC-SBLINK-023c ... exactly once"); + + /* 周期召唤的 key 恒 0:不占队列(屏板自己的语义就是"没有按键事件") */ + feed(k_rt, SB_FRAME_A_LEN); + CHECK(sb_link_key_take() == 0U, "TC-SBLINK-023d a periodic summon never queues a key"); + + /* 队列满:丢**新**的并计数(旧键已经在路上,新键还会有下一次召唤)。 + * 环形队列留一格判满 → 容量 = SB_LINK_KEY_QUEUE_LEN - 1 = 3,推 6 次丢 3 次。 */ + for (i = 0U; i < (uint8_t)(SB_LINK_KEY_QUEUE_LEN + 2U); i++) { + feed(k_key, SB_FRAME_A_LEN); + } + sb_link_get_stats(&st); + CHECK(st.key_drop == 3U, "TC-SBLINK-023e overflow drops the newest and counts it"); + + for (i = 0U; i < (uint8_t)(SB_LINK_KEY_QUEUE_LEN - 1U); i++) { + CHECK(sb_link_key_take() == 9U, "TC-SBLINK-023f ... FIFO order is preserved"); + } + CHECK(sb_link_key_take() == 0U, "TC-SBLINK-023g ... and the queue is empty again"); +} + +static void tc_offline(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + feed(k_rt, SB_FRAME_A_LEN); + + sb_link_poll(0U); + sb_link_get_stats(&st); + CHECK(st.offline == 0U, "TC-SBLINK-024 a fresh link is online"); + + /* 9.999 s 无召唤:还不算离线(屏板自复位约 1 s 空窗,按 500 ms 判会反复误报) */ + sb_link_poll(SB_LINK_OFFLINE_MS - 1U); + sb_link_get_stats(&st); + CHECK(st.offline == 0U, "TC-SBLINK-024b just under 10 s of silence is still online"); + + /* 满 10 s:置离线,并且只打一行 */ + sb_log_reset(); + sb_link_poll(SB_LINK_OFFLINE_MS); + sb_link_get_stats(&st); + CHECK(st.offline == 1U, "TC-SBLINK-024c 10 s of silence marks the board offline"); + CHECK(sb_log_has(0U, "OFFLINE") == 1, "TC-SBLINK-024d ... with one dedicated line"); + + /* 再久也不重复刷屏 */ + sb_log_reset(); + sb_link_poll(SB_LINK_OFFLINE_MS + 1000U); + CHECK(sb_log_n == 0U, "TC-SBLINK-024e the offline line is not repeated every poll"); + + /* 屏板回来:一帧就够,立刻清标志并报恢复 */ + sb_log_reset(); + feed(k_rt, SB_FRAME_A_LEN); + sb_link_poll(SB_LINK_OFFLINE_MS + 1010U); + sb_link_get_stats(&st); + CHECK(st.offline == 0U, "TC-SBLINK-024f a single valid summon brings it back online"); + CHECK(sb_log_has(0U, "back online") == 1, "TC-SBLINK-024g ... and says so"); +} + +static void tc_frame_timing(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0x66U); + + /* 每次读 CYCCNT 前进 16800 周期 = 100 µs @168 MHz;组帧读两次 → 差值正好 16800。 + * 这条钉的是"周期 → µs"的除数(SystemCoreClock/1e6),换算错了会在台架上 + * 把 0.17 ms 报成 17 ms 或 1.7 µs,两种都会被当成"组帧太慢/没测到"。 */ + sb_cyccnt_step = 16800U; + (void)sb_link_frame_update(s_bmp, 0U); + + sb_link_get_stats(&st); + CHECK(st.frame_us == 100U, "TC-SBLINK-025 the cycle count is converted with SystemCoreClock"); + CHECK(st.frame_us_max == 100U, "TC-SBLINK-025b ... and the peak is tracked"); + + s_bmp[0] ^= 0xFFU; + sb_cyccnt_step = 50400U; /* 300 µs */ + (void)sb_link_frame_update(s_bmp, 0U); + sb_link_get_stats(&st); + CHECK(st.frame_us == 300U, "TC-SBLINK-025c ... the last build duration"); + CHECK(st.frame_us_max == 300U, "TC-SBLINK-025d ... and the peak follows the slower build"); +} + +static void tc_tx_report(void) +{ + (void)setup_link(); + fill_bmp(0x88U); + (void)sb_link_frame_update(s_bmp, 0U); + + sb_log_reset(); + sb_link_poll(SB_LINK_REPORT_MS); + CHECK(sb_log_n == 1U, "TC-SBLINK-026 a changed frame counter produces a report"); + CHECK(sb_log_has(0U, "fb=1") == 1, "TC-SBLINK-026b ... carrying the build count"); + CHECK(sb_log_has(0U, "tx=0") == 1, "TC-SBLINK-026c ... and the transfer counters"); + + /* 这一拍会先出一行 OFFLINE(10 s 无有效召唤,2-5),但不该再出 TX 行。 + * 断言行数而不是"没看见 tx=",是为了把"离线判定排在周期上报之前"这个顺序也钉住。 */ + sb_log_reset(); + sb_link_poll(2U * SB_LINK_REPORT_MS); + CHECK(sb_log_n == 1U, "TC-SBLINK-026d only the offline line is emitted, no TX report"); + CHECK(sb_log_has(0U, "OFFLINE") == 1, "TC-SBLINK-026e ... and it is the offline one"); +} + int main(void) { printf("=== ScreenLink Link Tests ===\n"); @@ -476,6 +841,29 @@ int main(void) printf("-- stats snapshot --\n"); tc_stats_snapshot(); + printf("-- batch 2: frame update guards --\n"); + tc_frame_update_guards(); + printf("-- batch 2: frame B content --\n"); + tc_frame_build_content(); + printf("-- batch 2: change detection --\n"); + tc_frame_unchanged(); + printf("-- batch 2: TX kick from the RX callback --\n"); + tc_tx_kick(); + printf("-- batch 2: frame frozen while TX runs --\n"); + tc_tx_busy_freezes_frame(); + printf("-- batch 2: TX kick failure --\n"); + tc_tx_kick_failure(); + printf("-- batch 2: TX aborted by an error interrupt --\n"); + tc_tx_abort_on_error(); + printf("-- batch 2: key queue --\n"); + tc_key_queue(); + printf("-- batch 2: offline detection --\n"); + tc_offline(); + printf("-- batch 2: frame build timing --\n"); + tc_frame_timing(); + printf("-- batch 2: TX report line --\n"); + tc_tx_report(); + printf("\n=== ScreenLink Link Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); return (s_failed == 0) ? 0 : 1; diff --git a/test/host/sb_link/stub/main.h b/test/host/sb_link/stub/main.h index da0e3b9d..a18b045d 100644 --- a/test/host/sb_link/stub/main.h +++ b/test/host/sb_link/stub/main.h @@ -35,6 +35,7 @@ typedef enum { /* RxState 的两个取值:错误回调里"要不要重挂"就靠它判(见 sb_link.c 的 on_error 说明) */ #define HAL_UART_STATE_READY (0x20U) #define HAL_UART_STATE_BUSY_RX (0x22U) +#define HAL_UART_STATE_BUSY_TX (0x24U) /* HAL 的错误位(ErrorCode):ORE 是阻塞型,NE/FE/PE 是非阻塞型 */ #define HAL_UART_ERROR_PE (0x01U) @@ -146,7 +147,70 @@ static HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *p_huart return HAL_OK; } -/* ===== ④ 测试用的复位/查询把手 ===== */ +/* ===== ④ 假 Transmit_DMA(批 2:起发在中断里,收尾在 TxCplt) ===== */ +#define SB_FAKE_TX_OK (0U) +#define SB_FAKE_TX_BUSY (1U) +#define SB_FAKE_TX_ERROR (2U) + +static uint32_t sb_tx_calls; +static uint32_t sb_tx_force; /* SB_FAKE_TX_* */ +static const uint8_t *sb_tx_buf; +static uint16_t sb_tx_len; + +static HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *p_huart, + const uint8_t *p_buf, + uint16_t size) +{ + sb_tx_calls++; + sb_tx_buf = p_buf; + sb_tx_len = size; + + if (sb_tx_force == SB_FAKE_TX_BUSY) { + return HAL_BUSY; + } + if (sb_tx_force == SB_FAKE_TX_ERROR) { + return HAL_ERROR; + } + /* 真机成功起发后 gState = BUSY_TX,TxCplt 才打回 READY。 + * 错误回调里"TX 是不是还在发"就靠这一位判(防残留 busy)。 */ + p_huart->gState = HAL_UART_STATE_BUSY_TX; + return HAL_OK; +} + +/* ===== ⑤ 假 DWT / CoreDebug / SystemCoreClock(组帧耗时统计用) ===== */ +/* 真机由 core_cm4.h 提供。宿主侧要能"推进"计数器,所以 CYCCNT 也做成函数指针 + * (与 SR / DR 同一手法):每读一次前进 sb_cyccnt_step,于是 sb_link 里那两次 + * 读之间的差值 = 一个 step,测试就能钉住 "周期 → µs" 的除数。 */ +typedef struct { + volatile uint32_t CTRL; + uint32_t (*cyccnt_cell)(void); +} DWT_TypeDef; + +typedef struct { + volatile uint32_t DEMCR; +} CoreDebug_TypeDef; + +#define CYCCNT cyccnt_cell() + +static uint32_t sb_cyccnt_val; +static uint32_t sb_cyccnt_step; /* 每次读前进多少周期(0 = 恒 0,默认) */ + +static uint32_t sb_cyccnt_cell(void) +{ + sb_cyccnt_val += sb_cyccnt_step; + return sb_cyccnt_val; +} + +static DWT_TypeDef g_sb_dwt = { 0U, sb_cyccnt_cell }; +static CoreDebug_TypeDef g_sb_coredebug; +static uint32_t SystemCoreClock = 168000000U; + +#define DWT (&g_sb_dwt) +#define CoreDebug (&g_sb_coredebug) +#define CoreDebug_DEMCR_TRCENA_Msk (0x01000000U) +#define DWT_CTRL_CYCCNTENA_Msk (0x00000001U) + +/* ===== ⑥ 测试用的复位/查询把手 ===== */ static void sb_fake_reset(void) { uint32_t i; @@ -161,6 +225,15 @@ static void sb_fake_reset(void) sb_arm_force = SB_FAKE_ARM_OK; sb_arm_buf = NULL; sb_arm_size = 0U; + + sb_tx_calls = 0U; + sb_tx_force = SB_FAKE_TX_OK; + sb_tx_buf = NULL; + sb_tx_len = 0U; + + g_sb_dwt.CTRL = 0U; + sb_cyccnt_val = 0U; + sb_cyccnt_step = 0U; } /* 寄存器读序列是否为期望的字符串(如 "SD"):长度不等或内容不符都返回 0 */ diff --git a/test/host/screenlink_bench.py b/test/host/screenlink_bench.py index c69870dc..7e66df6f 100644 --- a/test/host/screenlink_bench.py +++ b/test/host/screenlink_bench.py @@ -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__":