diff --git a/App/app/app_main.c b/App/app/app_main.c index 111985bc..ce4d702c 100644 --- a/App/app/app_main.c +++ b/App/app/app_main.c @@ -19,6 +19,7 @@ #include "tim.h" /* htim3:1 kHz 采样节拍(TIM3 更新中断触发 CONVST + TRGO 触发 ADC1) */ #include "adc.h" /* adc1_disable():挂 DMA 前把 ADC 关掉,让 Start_DMA 自己使能 */ #include "rs485.h" +#include "sb_link.h" /* 屏板链路传输层:sb_link_init()(USART1 收中断,批 1 的 1-3) */ #include "sd2506.h" #include "boot_stats.h" /* 开机账本:上电次数(RTC 用户 SRAM)+ 本次复位原因快照(道G task-12) */ #include "sys_clock.h" @@ -197,6 +198,22 @@ int app_main_init(void) { rs485_init(&g_rs485, &huart4, ST_DIR4_GPIO_Port, ST_DIR4_Pin); DBG_INFO("RS485 init: OK (UART4, DE=PA15)"); + /* 2b) 屏板链路初始化(USART1,700 kbaud 8N1,IDLE 中断接收)。 + * 放在 NAND 全片扫描之前:这一步只是挂接收(µs 级),挂上之后屏板的周期召唤 + * 就已经能被收下来并计数,台架不必等启动跑完才能看到链路状态。 + * 波特率不在本调用里设——在 Src/usart.c 的 USER CODE 区(CubeMX 重生成只保护那里), + * 本调用只做一次交叉核对,对不上会报 BSP_ERR_STATE 并打 ERR 行。 */ + { + bsp_err_t sb_ret = sb_link_init(&huart1); + /* BRR 直接读出来打:1-2 的判据是"实测位宽误差 < 1 %",而 BRR 是位宽的唯一决定量—— + * 84 MHz / (16 x 700 kHz) = 7.5 → 期望 0x0078(整数 7、小数 0.5x16 = 8),误差 0 %。 + * 有这一行就不必为了核波特率去架示波器:BRR 对不上就说明 USER CODE 覆盖没生效。 */ + DBG_INFO("ScreenLink init: %s (ret=%d, USART1 %lu baud, BRR=0x%04lX)", + (sb_ret == BSP_OK) ? "OK" : "FAILED", + sb_ret, (unsigned long)SB_LINK_BAUD, + (unsigned long)huart1.Instance->BRR); + } + /* 3) GD5F 初始化测试(BBT 全片扫描,启动段最耗时的一步) */ int ret = gd5f2gq5ue_init(); DBG_INFO("NAND init: %s", diff --git a/App/task/ui_task.c b/App/task/ui_task.c index 4ac1ca29..28d9c83d 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -58,6 +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 在此组帧) */ /* * 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str) @@ -510,6 +511,11 @@ void ui_task_func(void *argument) { ui_net_sink_poll(); task_prof_exit(TASK_PROF_UI_SINK); + /* 屏板链路统计上报(内部自分频,5 s 一次、只在计数有变化时打一行)。 + * 放在推流段之后:它只是读一份快照 + 偶尔一行日志,不进推流往返的耗时统计。 + * 批 2 的 2-1 会把帧 B 组帧接到这里(显存/LED 变化时组帧),届时仍在 `uiTask` 上下文。 */ + sb_link_poll(osKernelGetTickCount()); + /* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */ task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0); diff --git a/Drivers/BSP/RS485/rs485.c b/Drivers/BSP/RS485/rs485.c index ca2ff235..3a2096ad 100644 --- a/Drivers/BSP/RS485/rs485.c +++ b/Drivers/BSP/RS485/rs485.c @@ -16,6 +16,8 @@ #include "rs485.h" +#include "sb_link.h" /* 屏板链路 USART1:本文件是全局 UART 回调的唯一实现处,见下方回调段 */ + /* * 函数功能:把 HAL 的状态码映射成项目统一的 bsp_err_t(边界映射,2026-10-22 试点) * 入口参数:status - HAL_* 返回的状态 @@ -170,7 +172,16 @@ uint16_t rs485_rx_get_size(const rs485_handle_t *p_handle) { return p_handle->rx_size; } -/* ======================== HAL 回调(UART5 / RS-485 专属) ======================== */ +/* ==================== HAL 回调(本工程唯一实现处) ==================== + * + * ⚠ 这两个回调是 HAL 的**全局单例**:一个工程只能有一份定义 + * (Inc/stm32f4xx_hal_conf.h 的 USE_HAL_UART_REGISTER_CALLBACKS = 0, + * 没法按句柄注册各自的回调)。所以新增通道时**在这里加一行转发**, + * 不要在别处再定义一份 —— 重复定义是链接期的 multiple definition,编不过。 + * + * 按句柄过滤放在各通道自己的模块里(如 sb_link_on_rx_event 内部判 p_huart), + * 本文件不必知道别的通道用的是哪个 huart,也就不会因为它们换口而改动。 + */ /* * 应用层接收钩子默认实现(弱符号,可被应用重定义)。 @@ -196,6 +207,9 @@ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { /* 重新开启接收 */ rs485_receive_start(&g_rs485); } + + /* 屏板链路(USART1):非本链路的句柄在 sb_link 内部被忽略 */ + sb_link_on_rx_event(huart, Size); } /* @@ -210,4 +224,7 @@ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { /* 重新开启接收 */ rs485_receive_start(&g_rs485); } + + /* 屏板链路(USART1):清 ORE 并重挂接收(不清 ORE 会永久静默),见 sb_link.c */ + sb_link_on_error(huart); } diff --git a/Drivers/BSP/ScreenLink/sb_link.c b/Drivers/BSP/ScreenLink/sb_link.c new file mode 100644 index 00000000..d7bc14a8 --- /dev/null +++ b/Drivers/BSP/ScreenLink/sb_link.c @@ -0,0 +1,316 @@ +/* + * 模块名称:屏板链路传输层(USART1 中断接收) + * 模块功能:IDLE 空闲中断断帧收 6 B 帧 A、帧 A 校验、错误中断里清 ORE 并重挂接收、 + * 累计统计与周期上报。发送侧(帧 B 组帧 + TX DMA 起发)属批 2,不在本文件。 + * 适用平台:STM32F4 系列 + * 作者:王建锋 + * 创建日期:2026-10-29 + * 修改记录: + * 2026-10-29 王建锋 创建初始版本(批 1 的 1-2 / 1-3) + * + * 三个容易踩的点(改之前先读,都已经在台架上被别的东西咬过一次): + * + * ① **每个回调末尾都必须重挂接收**。HAL_UARTEx_ReceiveToIdle_IT 是一次性的: + * 一次 IDLE 事件处理完,HAL 就把 RxState 打回 READY 并关掉 RXNEIE/IDLEIE + * (stm32f4xx_hal_uart.c 的 UART_IRQHandler 里 IDLE 那一支)。漏挂一次链路就 + * 永久静默,而现象与"屏板没上电"完全一样——没有报错、没有计数。 + * + * ② **错误回调里必须自己清 ORE**。HAL 在阻塞型错误里已经把接收停掉并打回 READY, + * 但它不清 ORE(RM0090 §30.6.1:先读 SR 再读 DR)。ORE 一旦锁存,RXNE 不再置位 + * → 接收永远进不来。这就是"拔插一次 TX 线之后屏板再也收不到"的真因。 + * + * ③ **本文件只按句柄过滤,不做业务动作**。全局回调(HAL_UARTEx_RxEventCallback / + * HAL_UART_ErrorCallback)的唯一实现处在 Drivers/BSP/RS485/rs485.c,那里只做一行 + * 转发;过滤在这里做,于是新增通道不必去改 rs485.c 的判据。业务动作(注键、 + * 刷新画面)全部留给任务侧——注键不能在中断里做(docs/任务规划/屏板链路.md 决策三)。 + */ + +#define DBG_TAG "[SBLINK]" +#include "dbg_log.h" + +#include + +#include "sb_link.h" + +/* ======================== 内部状态 ======================== */ + +/* 绑定的 USART1 句柄;NULL = 未初始化(所有入口据此直接返回) */ +static UART_HandleTypeDef *s_huart; + +/* 接收缓冲:IDLE 中断的落地目标,长度 SB_LINK_RX_BUF_SIZE(16 B) */ +static uint8_t s_rx_buf[SB_LINK_RX_BUF_SIZE]; + +/* 累计统计:中断侧自增、任务侧只读,故 volatile */ +static volatile sb_link_stats_t s_stats; + +/* 上报用:上次上报时刻与上次上报时的计数(用来判"有没有变化") */ +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 uint8_t s_first_frame_logged; + +/* ======================== 内部函数 ======================== */ + +/* + * 函数功能:把 HAL 的返回状态映射成项目统一的 bsp_err_t + * 入口参数:status - HAL_* 返回的状态 + * 返回值:对应的 bsp_err_t;HAL_BUSY 归 BSP_ERR_BUSY(可重试),HAL_OK 归 BSP_OK + * 限定条件:仅本文件内部使用 + * 函数说明:与 rs485.c 里那个同名映射同构——HAL 的码**不在本模块之外出现** + * (bsp_err.h 的边界纪律),所以映射写成 static 而不是往公共头里塞。 + */ +static bsp_err_t sb_err_from_hal(HAL_StatusTypeDef status) +{ + switch (status) + { + case HAL_OK: + return BSP_OK; + case HAL_BUSY: + return BSP_ERR_BUSY; + case HAL_TIMEOUT: + return BSP_ERR_TIMEOUT; + default: + return BSP_ERR_IO; + } +} + +/* ======================== 公开函数实现 ======================== */ + +/* + * 函数功能:初始化屏板链路并挂上第一轮接收 + * 入口参数:p_huart - 已由 CubeMX 初始化完成的 USART1 句柄(非空) + * 返回值:BSP_OK / BSP_ERR_PARAM / BSP_ERR_STATE / BSP_ERR_BUSY / BSP_ERR_IO(见头文件) + * 限定条件:须在 osKernelStart 之前或任一任务上下文调用 + * 函数说明:波特率由 Src/usart.c 的 USER CODE 区设置,本函数只做一次交叉核对—— + * 对不上就报 BSP_ERR_STATE 并打一行 ERR,免得"115200 收 700 kbaud"这种 + * 静默错配被误判成接线或协议问题(那种错配的表象是 100 % CRC 错帧)。 + */ +bsp_err_t sb_link_init(UART_HandleTypeDef *p_huart) +{ + if (p_huart == NULL) + { + return BSP_ERR_PARAM; + } + + s_huart = p_huart; + (void)memset((void *)&s_stats, 0, sizeof(s_stats)); + s_report_ms = 0U; + s_report_events = 0U; + s_report_err_irq = 0U; + s_report_rearm_fail = 0U; + s_first_frame_logged = 0U; + + if (p_huart->Init.BaudRate != SB_LINK_BAUD) + { + /* 串口波特率被打回 CubeMX 的默认值 —— 即 Src/usart.c 的 USER CODE 覆盖丢了。 + * ⚠ 日志字面量只写 ASCII:ARMCC 按本地代码页解多字节,中文字面量会报 + * #870-D invalid multibyte character sequence(2026-10-29 实测)。 */ + DBG_ERROR("baud=%lu expect=%lu (USER CODE override in usart.c lost)", + (unsigned long)p_huart->Init.BaudRate, (unsigned long)SB_LINK_BAUD); + return BSP_ERR_STATE; + } + + return sb_link_receive_start(); +} + +/* + * 函数功能:重新挂上一轮 IDLE 中断接收 + * 入口参数:无 + * 返回值:BSP_OK / BSP_ERR_STATE / BSP_ERR_BUSY / BSP_ERR_IO(见头文件) + * 限定条件:可在中断上下文调用;不可阻塞 + * 函数说明:失败只累计 rearm_fail 计数、不重试——在中断里重试等于把一次总线抖动 + * 放大成中断风暴。真失败了靠任务侧的统计行暴露(rearm 列非 0 就是链路已死)。 + */ +bsp_err_t sb_link_receive_start(void) +{ + bsp_err_t ret; + + if (s_huart == NULL) + { + return BSP_ERR_STATE; + } + + ret = sb_err_from_hal(HAL_UARTEx_ReceiveToIdle_IT(s_huart, s_rx_buf, SB_LINK_RX_BUF_SIZE)); + if (ret != BSP_OK) + { + s_stats.rearm_fail++; + } + + return ret; +} + +/* + * 函数功能:IDLE 事件入口(由 HAL_UARTEx_RxEventCallback 转发进来) + * 入口参数:p_huart - 触发事件的句柄(非本链路的直接忽略) + * size - 本次断出来的字节数 + * 返回值:无 + * 限定条件:中断上下文;只做 CRC 与计数,不得打日志、不得阻塞 + * 函数说明:先判长度再解析——长度不是 6 时连 CRC 都不必算(屏板只发 6 B 帧 A)。 + * 解析结论只落统计,业务动作由任务侧取走统计后做(注键不能在中断里做)。 + */ +void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size) +{ + sb_frame_a_t frame; + + if ((s_huart == NULL) || (p_huart != s_huart)) + { + return; + } + + s_stats.rx_events++; + s_stats.last_size = size; + + if (size != SB_FRAME_A_LEN) + { + s_stats.rx_len_err++; + } + else + { + bsp_err_t ret; + + (void)memset((void *)&frame, 0, sizeof(frame)); + ret = sb_frame_a_parse(s_rx_buf, size, &frame); + if (ret == BSP_OK) + { + s_stats.rx_ok++; + s_stats.last_cmd = frame.cmd; + s_stats.last_key = frame.key; + if (frame.cmd == SB_CMD_KEY) + { + s_stats.key_events++; + } + } + else if (ret == BSP_ERR_CRC) + { + s_stats.rx_crc_err++; + } + else + { + s_stats.rx_fmt_err++; + } + } + + (void)sb_link_receive_start(); +} + +/* + * 函数功能:错误事件入口(由 HAL_UART_ErrorCallback 转发进来) + * 入口参数:p_huart - 触发事件的句柄(非本链路的直接忽略) + * 返回值:无 + * 限定条件:中断上下文;不得打日志、不得阻塞 + * 函数说明:先读 SR 再读 DR 清 ORE/NE/FE/PE,再按需重挂接收。两个细节都不能省: + * 1. **清除顺序**:RM0090 §30.6.1 规定的序列就是"读 SR 后读 DR",只读 DR 清不掉 ORE。 + * 2. **只有 HAL 真把接收停了才重挂**:HAL 把错误分成两类(stm32f4xx_hal_uart.c 的 + * UART_IRQHandler)—— + * · ORE 是**阻塞型**:HAL 先 UART_EndRxTransfer(RxState 打回 READY、关接收中断) + * 再回调 → 必须重挂,否则永久静默; + * · NE/FE/PE 是**非阻塞型**:HAL 不动接收,RxState 仍是 BUSY_RX → 此时再调 + * ReceiveToIdle_IT 只会拿到 HAL_BUSY,白记一次"重挂失败"。 + * 2026-10-29 台架实测踩的正是这一条:每帧都伴随 NE/FE,于是 rearm_fail 跟着 + * err_irq 一起涨(160/160),看着像链路坏了,其实一次都没真失败。 + * 判据用 RxState 而不是 ErrorCode 的位:它直接回答"现在挂着接收没有", + * 不依赖 HAL 怎么分组错误位。 + */ +void sb_link_on_error(UART_HandleTypeDef *p_huart) +{ + if ((s_huart == NULL) || (p_huart != s_huart)) + { + return; + } + + s_stats.err_irq++; + s_stats.err_bits |= p_huart->ErrorCode; + + /* STM32F4 通过读 SR + DR 清除 ORE/NE/FE/PE(与 rs485.c 的错误回调同一手法) */ + (void)p_huart->Instance->SR; + (void)p_huart->Instance->DR; + + /* 只有"接收已经停了"才需要重挂(见上面第 2 条) */ + if (p_huart->RxState == HAL_UART_STATE_READY) + { + (void)sb_link_receive_start(); + } +} + +/* + * 函数功能:链路统计上报(任务侧周期调用) + * 入口参数:now_ms - 当前毫秒时基 + * 返回值:无 + * 限定条件:任务上下文调用(内部会打日志) + * 函数说明:每 SB_LINK_REPORT_MS 检查一次,计数有变化才打一行;第一帧成功收到时 + * 立刻单独打一行(不等周期),台架刚上电就能确认"链路通了、长度对、CRC 过"。 + */ +void sb_link_poll(uint32_t now_ms) +{ + sb_link_stats_t st; + + if (s_huart == NULL) + { + return; + } + + sb_link_get_stats(&st); + + /* 首帧单独报一次:台架刚上电时不必等一个上报周期才知道链路通没通 */ + if ((s_first_frame_logged == 0U) && (st.rx_ok > 0U)) + { + s_first_frame_logged = 1U; + DBG_INFO("first frame: size=%u cmd=%u key=%u", + (unsigned)st.last_size, (unsigned)st.last_cmd, (unsigned)st.last_key); + } + + if ((uint32_t)(now_ms - s_report_ms) < SB_LINK_REPORT_MS) + { + return; + } + 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)) + { + 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); +} + +/* + * 函数功能:取一份链路统计快照 + * 入口参数:p_out - 输出缓冲(非空) + * 返回值:无 + * 限定条件:任一上下文可调;不保证同一时刻一致(中断侧仍在自增) + * 函数说明:逐字段拷出。内部结构是 volatile,整体赋值会把限定词丢掉(ARMCC 报错), + * 故显式逐字段写。 + */ +void sb_link_get_stats(sb_link_stats_t *p_out) +{ + if (p_out == NULL) + { + return; + } + + p_out->rx_events = s_stats.rx_events; + p_out->rx_ok = s_stats.rx_ok; + p_out->rx_crc_err = s_stats.rx_crc_err; + p_out->rx_len_err = s_stats.rx_len_err; + p_out->rx_fmt_err = s_stats.rx_fmt_err; + p_out->key_events = s_stats.key_events; + p_out->err_irq = s_stats.err_irq; + p_out->err_bits = s_stats.err_bits; + p_out->rearm_fail = s_stats.rearm_fail; + p_out->last_size = s_stats.last_size; + p_out->last_cmd = s_stats.last_cmd; + p_out->last_key = s_stats.last_key; +} diff --git a/Drivers/BSP/ScreenLink/sb_link.h b/Drivers/BSP/ScreenLink/sb_link.h new file mode 100644 index 00000000..639a218a --- /dev/null +++ b/Drivers/BSP/ScreenLink/sb_link.h @@ -0,0 +1,183 @@ +/* + * 模块名称:屏板链路传输层(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) + * 作者:王建锋 + * 创建日期:2026-10-29 + * 修改记录: + * 2026-10-29 王建锋 创建初始版本(批 1 的 1-2 / 1-3:700 kbaud + 收中断) + * + * 为什么单独一层、不并进 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,新增通道也不用改它的判据。 + */ + +#ifndef SB_LINK_H +#define SB_LINK_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include "main.h" /* UART_HandleTypeDef / USART_TypeDef / HAL_StatusTypeDef */ +#include "bsp_err.h" /* bsp_err_t / BSP_OK / BSP_ERR_*:对外只报统一错误码 */ +#include "sb_proto.h" /* SB_FRAME_A_LEN / sb_frame_a_t / sb_frame_a_parse */ + +/* ============================================================================ + * 一、链路参数 + * ==========================================================================*/ + +/* + * 串口参数:700000 8N1、16 倍过采样。 + * 实际写入发生在 Src/usart.c 的 `USER CODE BEGIN USART1_Init 2`(CubeMX 的 .ioc + * **不保存任何 UART 的波特率**,GUI 里改也不落盘,所以只能在 USER CODE 区覆盖)。 + * 理论值:APB2 84 MHz / (16 x 700 kHz) = 7.5 → BRR = 0x0078,误差 0 %。 + */ +#define SB_LINK_BAUD (700000U) + +/* + * 接收缓冲:16 B 足够。 + * 屏板只发 6 B 的帧 A(周期召唤 / 按键召唤),16 B 留出一次噪声冲击的余量。 + * ⚠ 不要照抄屏板侧的 4208 B——那是帧 B 的长度,方向相反(主机发、屏板收), + * 放进接收侧白占 SRAM(判据见 docs/任务规划/屏板链路.md §1.3)。 + */ +#define SB_LINK_RX_BUF_SIZE (16U) + +/* + * 统计上报周期(ms)。只在计数有变化时才打一行,链路空闲不刷屏; + * 台架验收靠的就是这一行里的累计值(判据见 docs/任务规划/屏板链路.md §3 的 1-3)。 + */ +#define SB_LINK_REPORT_MS (5000U) + +/* ============================================================================ + * 二、链路统计 + * ==========================================================================*/ + +/* + * 全部字段都是**累计值**,由中断侧自增、任务侧只读(读出来的是一份快照)。 + * 分门别类记而不只记"错了多少次",是因为台架上这几类错指向完全不同的原因: + * · rx_crc_err —— 波特率/接线问题(帧收到了但字节错位),最常见的真故障; + * · rx_len_err —— 断帧判据或噪声冲击(收到的不是 6 B),也可能是一次噪声把两帧粘在一起; + * · rx_fmt_err —— 地址/功能码/命令字不符(帧完整但内容不是给本机的); + * · err_irq —— ORE/NE/FE/PE 触发过错误中断,即线上真的有噪声或溢出。 + */ +typedef struct +{ + uint32_t rx_events; /* IDLE 事件总数(每来一包 +1,含错帧) */ + uint32_t rx_ok; /* 帧 A 校验通过(含 cmd = 按键帧) */ + uint32_t rx_crc_err; /* CRC 不符 */ + uint32_t rx_len_err; /* 长度不是 SB_FRAME_A_LEN */ + uint32_t rx_fmt_err; /* 地址/功能码/命令字不符 */ + uint32_t key_events; /* 其中 cmd == SB_CMD_KEY 的帧数(按键召唤,2-3 的消费源) */ + uint32_t err_irq; /* 错误中断次数(ORE/NE/FE/PE) */ + uint32_t err_bits; /* 错误中断里见过哪些位(HAL_UART_ERROR_* 的并集,见下) */ + uint32_t rearm_fail; /* 重挂接收失败次数(正常应恒 0,非 0 说明链路已静默) */ + uint16_t last_size; /* 最近一次 IDLE 事件的字节数 */ + uint8_t last_cmd; /* 最近一帧有效帧的命令类型(SB_CMD_*) */ + uint8_t last_key; /* 最近一帧有效帧的键值(无键为 0) */ +} sb_link_stats_t; + +/* + * err_bits 的位定义 = HAL 的 HAL_UART_ERROR_* 原值(PE 0x01 / NE 0x02 / FE 0x04 / ORE 0x08)。 + * 直接照抄 HAL 的值而不是自己另编一套:这一列的唯一用途是**判断物理层哪里不对**, + * 与 HAL 手册对照着看最省事。经验判读: + * · 只有 ORE(0x08) → 处理不过来(本链路 6 B/280 ms 不该出现,出现了就是重挂漏了); + * · NE(0x02)为主 → 线上有毛刺(线太长、无屏蔽、共地不良); + * · FE(0x04)为主 → 波特率对不上(对端不是 700000,或分频误差过大); + * · 全是 NE/FE 但 rx_ok 照样涨 → 数据没坏、只是采样点被判噪声,属"能用的告警",别当故障。 + */ + +/* ============================================================================ + * 三、公开接口 + * ==========================================================================*/ + +/* + * 函数功能:初始化屏板链路并挂上第一轮接收 + * 入口参数:p_huart - 已由 CubeMX 初始化完成的 USART1 句柄(非空) + * 返回值:BSP_OK 已挂上接收,后续每包都会进 sb_link_on_rx_event + * BSP_ERR_PARAM 句柄为空 + * BSP_ERR_STATE 句柄的波特率不是 SB_LINK_BAUD(Src/usart.c 的 USER CODE 覆盖丢了) + * BSP_ERR_BUSY 首次挂接收失败(HAL 侧忙,通常是上一轮没收完) + * BSP_ERR_IO 首次挂接收失败(HAL 侧状态错) + * 限定条件:须在 osKernelStart 之前或任一任务上下文调用;本函数不清任何外设。 + * 函数说明:波特率**不由本函数设置**——那一步在 Src/usart.c 的 USER CODE 区 + * (CubeMX 重生成只保护那里)。这里只做一次交叉核对:波特率对不上就报 + * BSP_ERR_STATE 并打一行 ERR,免得"115200 收 700 kbaud"这种静默错配 + * 被误判成接线或协议问题。 + */ +bsp_err_t sb_link_init(UART_HandleTypeDef *p_huart); + +/* + * 函数功能:重新挂上一轮 IDLE 中断接收 + * 入口参数:无 + * 返回值:BSP_OK 挂上了 + * BSP_ERR_STATE 尚未 sb_link_init + * BSP_ERR_BUSY HAL 侧忙(上一轮接收未结束) + * BSP_ERR_IO HAL 侧状态错 + * 限定条件:可在中断上下文调用(rx / error 两个回调都靠它续命);不可阻塞。 + * 函数说明:HAL 的 ReceiveToIdle_IT 是**一次性**的:一次 IDLE 事件处理完就把 + * RxState 打回 READY 并关掉 RXNEIE/IDLEIE。所以每个回调末尾都必须重挂, + * 漏一次链路就永久静默(表象与"屏板没上电"一模一样)。 + */ +bsp_err_t sb_link_receive_start(void); + +/* + * 函数功能:IDLE 事件入口(由 HAL_UARTEx_RxEventCallback 转发进来) + * 入口参数:p_huart - 触发事件的句柄(非本链路的会被直接忽略) + * size - 本次断出来的字节数 + * 返回值:无 + * 限定条件:**中断上下文**,只做 CRC 与计数(4 B 校验约 1 µs),不得打日志、不得阻塞。 + * 函数说明:长度不是 6 就直接记 rx_len_err 并丢弃;是 6 才解析。解析结论只累计计数 + * 与"最近一帧",**不做任何业务动作**——注键、刷新都由任务侧取走统计后再做 + * (批 2 的 2-3:注键不能在中断里做)。末尾无条件重挂接收。 + */ +void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size); + +/* + * 函数功能:错误事件入口(由 HAL_UART_ErrorCallback 转发进来) + * 入口参数:p_huart - 触发事件的句柄(非本链路的会被直接忽略) + * 返回值:无 + * 限定条件:**中断上下文**;不得打日志、不得阻塞。 + * 函数说明:先读 SR 再读 DR 清掉 ORE/NE/FE/PE(RM0090 §30.6.1 的清除序列),再重挂接收。 + * ⚠ 这一步不是可选的:HAL 在阻塞型错误里已经把 RxState 打回 READY 并关了 + * 接收中断,**而它自己不清 ORE**。ORE 一旦锁存,接收就再也进不来 → + * 一次噪声就把链路永久打死,而现象与"屏板没上电"完全一样。 + */ +void sb_link_on_error(UART_HandleTypeDef *p_huart); + +/* + * 函数功能:链路统计上报(任务侧周期调用) + * 入口参数:now_ms - 当前毫秒时基(调用方给,便于主机单测;MCU 侧传 HAL_GetTick()) + * 返回值:无 + * 限定条件:任务上下文调用,**不可在中断里调**(内部会打日志)。 + * 函数说明:每 SB_LINK_REPORT_MS 检查一次,只有计数有变化才打一行累计值。 + * 第一帧成功收到时立刻打一行(不等周期),台架刚上电就能看到链路通了。 + * 离线判定(连续 >=10 s 无召唤帧)属批 2 的 2-5,这里只上报不判定。 + */ +void sb_link_poll(uint32_t now_ms); + +/* + * 函数功能:取一份链路统计快照 + * 入口参数:p_out - 输出缓冲(非空) + * 返回值:无(p_out 为空时直接返回,不改任何东西) + * 限定条件:任一上下文可调;中断侧在自增,快照不保证同一时刻一致(只用于观测与计数)。 + * 函数说明:逐字段拷出,避免把 volatile 的内部结构整体赋给调用方。 + */ +void sb_link_get_stats(sb_link_stats_t *p_out); + +#ifdef __cplusplus +} +#endif + +#endif /* SB_LINK_H */ diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx index 3c0a6c72..e1f92f36 100644 --- a/MDK-ARM/STM32F407-Demo.uvprojx +++ b/MDK-ARM/STM32F407-Demo.uvprojx @@ -1628,6 +1628,11 @@ 1 ..\Drivers\BSP\ScreenLink\sb_proto.c + + sb_link.c + 1 + ..\Drivers\BSP\ScreenLink\sb_link.c + net_select.c 1 diff --git a/Src/main.c b/Src/main.c index ad56045b..bf5f8a40 100644 --- a/Src/main.c +++ b/Src/main.c @@ -217,6 +217,8 @@ void SystemClock_Config(void) * 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—— + * 那两个回调是全局单例,全工程只能有一份定义,**不要在本文件再写一份**。 */ /* USER CODE END 4 */ diff --git a/Src/usart.c b/Src/usart.c index c7be51da..16176078 100644 --- a/Src/usart.c +++ b/Src/usart.c @@ -21,7 +21,7 @@ #include "usart.h" /* USER CODE BEGIN 0 */ - +#include "sb_link.h" /* SB_LINK_BAUD:USART1 的屏板链路波特率(见 USART1_Init 2 区) */ /* USER CODE END 0 */ UART_HandleTypeDef huart4; @@ -113,7 +113,24 @@ void MX_USART1_UART_Init(void) Error_Handler(); } /* USER CODE BEGIN USART1_Init 2 */ - + /* 屏板链路:USART1 固定 700000 8N1、16 倍过采样(其余字段生成值即 8N1/16 倍,不用改)。 + * + * 为什么写在这里而不是上面生成区:那几行 `huart1.Init.*` 在 USER CODE 之外, + * CubeMX 重生成会把 BaudRate 打回 115200,且**不会报任何编译错**。 + * 为什么不能改 .ioc 解决:.ioc **不保存任何 UART 的波特率** + * (只有 `USART1.IPParameters=VirtualMode`),GUI 里改波特率也不落盘 —— 这条与 + * IWDG/看门狗那些"写进 .ioc 才不怕重生成"的情形不同,没有 .ioc 这条路。 + * + * 重新 HAL_UART_Init 是安全的:第二次调用时 gState 已非 RESET,HAL 只重算 BRR、 + * 不再跑 HAL_UART_MspInit(GPIO / DMA / NVIC 不会被重配,见 + * stm32f4xx_hal_uart.c 的 HAL_UART_Init 开头那段 if (gState == RESET))。 + * 理论值:APB2 = 84 MHz(APB2CLKDivider = HCLK/2),84 000 000 / (16 x 700 000) = 7.5 + * → 整数部分 7、小数部分 0.5x16 = 8 → BRR = 0x0078,误差 0 %。 */ + huart1.Init.BaudRate = SB_LINK_BAUD; + if (HAL_UART_Init(&huart1) != HAL_OK) + { + Error_Handler(); + } /* USER CODE END USART1_Init 2 */ } diff --git a/docs/内存管理/RAM使用分析与优化方案.md b/docs/内存管理/RAM使用分析与优化方案.md index 0e987c7a..442aad63 100644 --- a/docs/内存管理/RAM使用分析与优化方案.md +++ b/docs/内存管理/RAM使用分析与优化方案.md @@ -47,10 +47,10 @@ dhara 失败类 +84;RO 另 +208,全是该失败类名表)→ `ER_IROM1` | 区域 | 用量 | 容量 | 占比 | |---|---|---|---| -| ER_IROM1 | 264,584 B (258.38 KB) | 1,048,576 B (1024 KB) | 25.2% | -| RW_IRAM1 | 81,536 B (79.62 KB) | 131,072 B (128 KB) | 62.2% | +| 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% | | RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% | -| **Flash (ROM 合计)** | **266,460 B (260.2 KB)** | 1,048,576 B (1024 KB) | **25.4%** | +| **Flash (ROM 合计)** | **268,260 B (262.0 KB)** | 1,048,576 B (1024 KB) | **25.6%** | 各区的容量与常量来源: diff --git a/docs/内存管理/RAM预算台账.md b/docs/内存管理/RAM预算台账.md index e7d38b57..67cc6748 100644 --- a/docs/内存管理/RAM预算台账.md +++ b/docs/内存管理/RAM预算台账.md @@ -15,10 +15,10 @@ | 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) | |---|---|---|---|---| -| ER_IROM1 (Flash) | 264,584 B | 1,048,576 B | 25.2% | 参考 | +| ER_IROM1 (Flash) | 266,360 B | 1,048,576 B | 25.4% | 参考 | | RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS | | UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 | -| RW_IRAM1 | 81,536 B | 131,072 B | 62.2% | PASS | +| RW_IRAM1 | 81,616 B | 131,072 B | 62.3% | PASS | | 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 | |---|---|---|---| @@ -27,7 +27,7 @@ | UI | 34,656 B | 16 KB | ⚠ 超 17.8 KB | | RTOS 堆 | 57,376 B | 56 KB | 堆=配额 | | 应用 | 15,552 B | 8 KB | ⚠ 超 7.2 KB | -| 其它 | 10,553 B | — | 参考 | +| 其它 | 10,633 B | — | 参考 | - **门禁**:pre-commit `mem-budget` 钩子(`scripts/ram_baseline.py --gate`)—— diff --git a/scripts/check_registration.py b/scripts/check_registration.py index 6460f15e..222149dd 100644 --- a/scripts/check_registration.py +++ b/scripts/check_registration.py @@ -136,6 +136,7 @@ SIM_EXEMPT = [ ("Drivers/BSP/RS485/rs485.c", "MCU RS-485 驱动"), ("Drivers/BSP/SD2506/sd2506.c", "MCU RTC 驱动"), ("Drivers/BSP/ScreenLink/sb_proto.c", "屏板链路协议层纯逻辑(帧 A 解析 / 帧 B 组帧 / CRC-16),消费者 = MCU 侧 USART1 收发起发(批 1/2),模拟器无消费路径;主机侧纯逻辑单测覆盖于 test/host/sb_proto"), + ("Drivers/BSP/ScreenLink/sb_link.c", "屏板链路传输层(USART1 IDLE 中断收 + ORE 清除 + 统计),依赖 HAL 句柄与中断上下文,MCU 专属;主机侧单测以桩覆盖于 test/host/sb_link"), ("Drivers/BSP/TPAFE5160/tpafe5160.c", "MCU 计量芯片驱动"), ("Drivers/BSP/UI/ui_port.c", "MCU UI 端口层(显示/触摸硬件),模拟器另有 sim/sim_port.c"), ] diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 18056dc7..82e2b623 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 236460 28124 1876 137084 264584 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 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 336aecb5..0a071053 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,25 +1,25 @@ { - "built_at": "2026-10-29T19:17:06+08:00", - "fingerprint": "df1c01abda2bd6778c11976f346e1a9024386ae2809f94c720da9f95ef59becc", - "fingerprint_files": 1454, + "built_at": "2026-10-29T20:05:10+08:00", + "fingerprint": "aff51072f981be06701568ea469dd4baa0278299ac530446feb299ac0cba8abd", + "fingerprint_files": 1456, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "71b6a5fa14db85ed9c95bba5b3b6106d1f20d223", + "git_head": "2c69a6ca076d201a2f412318bdfaa91f20632c48", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1793272621, + "map_mtime": 1793275502, "pct": { - "ER_IROM1": 25.23, + "ER_IROM1": 25.4, "RW_CCM": 87.89, - "RW_IRAM1": 62.21, + "RW_IRAM1": 62.27, "UNINIT_CCM": 18.75 }, "regions": { "ER_IROM1": { "max": 1048576, - "size": 264584 + "size": 266360 }, "RW_CCM": { "max": 65280, @@ -27,7 +27,7 @@ }, "RW_IRAM1": { "max": 131072, - "size": 81536 + "size": 81616 }, "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": 236460, - "ro_data": 28124, - "rw_data": 1876, - "zi_data": 137084 + "code": 237464, + "ro_data": 28896, + "rw_data": 1900, + "zi_data": 137140 }, "threshold_pct": 92.0, "worktree_dirt_at_stamp": 0, diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index 799f1b45..ee0d4c91 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -344,6 +344,14 @@ "lines_covered": 108, "lines_valid": 112 }, + "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 + }, "Drivers/BSP/ScreenLink/sb_proto.c": { "func_rate": 1.0, "funcs_covered": 6, @@ -615,6 +623,14 @@ "lines_total": 961, "ok": true }, + "sb_link": { + "files": { + "Drivers/BSP/ScreenLink/sb_link.c": 96, + "Drivers/BSP/ScreenLink/sb_proto.c": 74 + }, + "lines_total": 170, + "ok": true + }, "sb_proto": { "files": { "Drivers/BSP/ScreenLink/sb_proto.c": 74 @@ -756,6 +772,7 @@ "Drivers/BSP/NET/net_socket.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c", + "Drivers/BSP/ScreenLink/sb_link.c", "Drivers/BSP/ScreenLink/sb_proto.c", "Drivers/BSP/UI/ui_data.c", "Drivers/BSP/UI/ui_net_sink.c", @@ -763,16 +780,16 @@ ], "totals": { "files_generated_excluded": 5, - "files_tested": 47, - "files_untested": 164, - "func_rate": 0.9324, - "funcs_covered": 538, - "funcs_valid": 577, - "line_rate": 0.8993, - "lines_covered": 7132, - "lines_valid": 7931, - "modules_ok": 42, - "modules_total": 42 + "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, + "modules_ok": 43, + "modules_total": 43 }, "untested_sources": [ { @@ -821,11 +838,11 @@ }, { "code_lines": 215, - "path": "Drivers/BSP/UI/ui_w_setlist.c" + "path": "App/task/ui_task.c" }, { - "code_lines": 214, - "path": "App/task/ui_task.c" + "code_lines": 215, + "path": "Drivers/BSP/UI/ui_w_setlist.c" }, { "code_lines": 206, @@ -864,7 +881,7 @@ "path": "Drivers/BSP/UI/ui_page_device_info.c" }, { - "code_lines": 135, + "code_lines": 142, "path": "App/app/app_main.c" }, { @@ -916,7 +933,7 @@ "path": "Drivers/BSP/NET/net_socket.h" }, { - "code_lines": 79, + "code_lines": 81, "path": "Drivers/BSP/RS485/rs485.c" }, { @@ -1043,6 +1060,10 @@ "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/run_unit_test.py b/test/host/sb_link/run_unit_test.py new file mode 100644 index 00000000..897ce192 --- /dev/null +++ b/test/host/sb_link/run_unit_test.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""屏板链路传输层(sb_link)的主机侧单元测试运行器(纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/sb_link/sb_link_test.c(它以 #include 纳入**生产源文件** + Drivers/BSP/ScreenLink/sb_link.c 与 sb_proto.c),在 PC 上离线验证 USART1 接收这一层: + 挂接收 / IDLE 事件分派 / 坏帧分类 / **错误中断里清 ORE 并重挂** / 重挂失败的码映射 / + 周期统计上报(含时基回绕)。零硬件、零烧录、毫秒级。 + + 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness + `test/host/common/unit_test_harness.py`(本文件只做声明)。 + +为什么必须主机侧测(而不是只用台架): + 这一层最要命的两条在台架上都"看不见": + ① **漏一次重挂 = 链路永久静默**,现象与"屏板没上电 / 线接反"完全同形,没有报错; + ② **ORE 没清干净 = 一次噪声永久打死接收**,台架上要拔插 TX 线才复现,且复现后 + 只能靠断电重来。主机侧用桩把 RM0090 的清除语义(先读 SR 再读 DR)实现出来, + 于是"顺序写反"会直接让下一次挂接收失败、用例立刻红。 + +桩目录(`stub/`,必须排在 include 路径最前,覆盖真实头): + main.h 假 USART 寄存器(读序列可记账、ORE 可注入)+ 假 ReceiveToIdle_IT(返回值可脚本) + dbg_log.h 日志换成行缓冲,于是能断言"打了几行、说了什么" + +用法(仓库根目录): + python test/host/sb_link/run_unit_test.py + 或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/sb_link/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。 + +设计依据:docs/任务规划/屏板链路.md §3 批 1(1-3 的判据)、§1.3(接收只要 16 B); + docs/产品需求/产品需求设计文档.md §5.16.2(帧 A 结构)。 +创建日期:2026-10-29 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +# harness 位于 test/host/common/ +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 桩目录必须排在最前:它覆盖 main.h(真实那份会拉进 core_cm4.h)与 dbg_log.h(会拉进 App 层) +BUILD = TestBuild( + name="ScreenLink Link Tests", + src=os.path.join(HERE, "sb_link_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "ScreenLink"), + os.path.join(ROOT, "Drivers", "BSP"), + HERE, + ], + summary_marker="=== ScreenLink Link Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) diff --git a/test/host/sb_link/sb_link_test.c b/test/host/sb_link/sb_link_test.c new file mode 100644 index 00000000..192eb199 --- /dev/null +++ b/test/host/sb_link/sb_link_test.c @@ -0,0 +1,482 @@ +/* + * 模块名称:屏板链路传输层(sb_link)主机侧单元测试 + * 模块功能:在 PC 上对 Drivers/BSP/ScreenLink/sb_link.c 做离线验证。 + * + * 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本): + * ../../../Drivers/BSP/ScreenLink/sb_proto.c (sb_link 依赖它的帧 A 解析) + * ../../../Drivers/BSP/ScreenLink/sb_link.c + * 依赖面用桩替换:main.h(假 USART 寄存器 + 假 ReceiveToIdle_IT)、dbg_log.h(日志行缓冲)。 + * + * 为什么要主机侧单测(而不是只用台架): + * 1. **ORE 清除序列**在台架上只能靠"拔插 TX 线"制造,且失败现象是"链路永久静默"—— + * 与屏板没上电、线接反完全同形。主机侧能把"必须按 SR→DR 顺序读"这条钉成断言: + * 桩按 RM0090 的语义实现(先读 SR 再读 DR 才清得掉 ORE),顺序反了清不掉 → + * 下一次挂接收必然失败 → 用例立刻红。 + * 2. **重挂接收漏一次 = 链路永久静默**:台架上要等 280 ms 才看得出来,且没有报错。 + * 主机侧数 `sb_arm_calls` 即可。 + * 3. 坏帧分支(长度不对 / 地址不对 / 功能码不对 / CRC 错)在台架上要造噪声才出现, + * 一出现就被复位冲掉;主机侧是几行断言。 + * + * 覆盖点(对应 docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据): + * TC-SBLINK-001 init 参数守卫:空句柄 / 波特率不是 SB_LINK_BAUD(后者必须不挂接收) + * TC-SBLINK-002 init 正常:挂接收一次、缓冲 = SB_LINK_RX_BUF_SIZE(16 B,不是 4208) + * TC-SBLINK-003 未初始化就重挂接收:BSP_ERR_STATE,且不碰外设 + * TC-SBLINK-004 非本链路的句柄:rx / error 两个入口都必须直接忽略 + * TC-SBLINK-005 周期召唤帧(屏板实测向量 01 03 01 00 F0 48):计数、cmd、key、重挂 + * TC-SBLINK-006 按键召唤帧(屏板实测向量 01 03 00 09 31 DE):key = 9 + * TC-SBLINK-007 CRC 错帧:只累加 rx_crc_err,不污染 last_cmd / last_key + * TC-SBLINK-008 长度不对:5 B / 16 B(缓冲满)/ 0 B 都记 rx_len_err + * TC-SBLINK-009 地址 / 功能码 / 命令类型不符:记 rx_fmt_err + * TC-SBLINK-010 错误中断(ORE,阻塞型):先读 SR 再读 DR 才清得掉,且必须重挂 + * TC-SBLINK-015 错误中断(NE/FE,非阻塞型):接收还在跑,**不得**重挂(否则白记一次失败) + * TC-SBLINK-011 重挂失败:HAL_BUSY / HAL_TIMEOUT / HAL_ERROR 三个映射 + rearm_fail 累计 + * TC-SBLINK-012 统计上报:未到周期不出行;有变化才出;无变化不刷屏;首帧立刻单独报一次 + * TC-SBLINK-013 统计上报的时基回绕:now_ms 跨 0xFFFFFFFF 仍按差值法工作 + * TC-SBLINK-014 统计快照:空指针守卫、字段逐项对得上 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-29 + */ + +#include +#include +#include + +/* ===================== 断言 ===================== */ +static int s_total = 0, s_passed = 0, s_failed = 0; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \ + else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== 被测代码 ===================== */ +#include "sb_proto.c" +#include "sb_link.c" + +/* ===================== 夹具 ===================== */ + +/* 本链路的句柄(地址唯一,用来区分"非本链路的句柄") */ +static UART_HandleTypeDef s_uart; + +/* 另一个句柄:只当参数传进去,验证过滤 */ +static UART_HandleTypeDef s_other; + +/* 屏板实测向量(出处:屏板固件工程 DTU-LCD 的实机抓包,同 test/host/sb_proto) */ +static const uint8_t k_rt[SB_FRAME_A_LEN] = { 0x01U, 0x03U, 0x01U, 0x00U, 0xF0U, 0x48U }; +static const uint8_t k_key[SB_FRAME_A_LEN] = { 0x01U, 0x03U, 0x00U, 0x09U, 0x31U, 0xDEU }; + +/* 复位桩 + 日志,然后以 700000 正常初始化一次;返回 init 的返回码 */ +static bsp_err_t setup_link(void) +{ + sb_fake_reset(); + sb_log_reset(); + + s_uart.Instance = &g_sb_uart; + s_uart.Init.BaudRate = SB_LINK_BAUD; + s_uart.Init.WordLength = 8U; + s_uart.Init.StopBits = 1U; + s_uart.Init.Parity = 0U; + s_uart.Init.Mode = 3U; + s_uart.Init.OverSampling = 16U; + s_uart.RxState = HAL_UART_STATE_READY; + s_uart.ErrorCode = 0U; + + s_other.Instance = &g_sb_uart; + s_other.Init.BaudRate = SB_LINK_BAUD; + + return sb_link_init(&s_uart); +} + +/* 模拟"HAL 把 n 个字节搬进它拿到的那块缓冲,然后报一次 IDLE 事件" */ +static void feed(const uint8_t *p_data, uint16_t len) +{ + if ((sb_arm_buf != NULL) && (len > 0U)) { + (void)memcpy(sb_arm_buf, p_data, len); + } + sb_link_on_rx_event(&s_uart, len); +} + +/* 按 4 字节负载组一帧(CRC 低字节在前,与 sb_proto 口径一致),用来造反例帧 */ +static void make_frame(uint8_t *p_frame, uint8_t addr, uint8_t func, uint8_t cmd, uint8_t key) +{ + uint16_t crc; + + p_frame[0] = addr; + p_frame[1] = func; + p_frame[2] = cmd; + p_frame[3] = key; + crc = sb_crc16(p_frame, 4U); + p_frame[4] = (uint8_t)(crc & 0xFFU); + p_frame[5] = (uint8_t)((crc >> 8) & 0xFFU); +} + +/* ===================== 用例 ===================== */ + +static void tc_init_guards(void) +{ + bsp_err_t ret; + + sb_fake_reset(); + sb_log_reset(); + + ret = sb_link_init(NULL); + CHECK(ret == BSP_ERR_PARAM, "TC-SBLINK-001 a NULL handle is rejected"); + CHECK(sb_arm_calls == 0U, "TC-SBLINK-001b nothing is armed for a NULL handle"); + + /* 波特率不是 700000:说明 Src/usart.c 的 USER CODE 覆盖丢了。 + * 必须**不挂接收**并打一行 ERR —— 否则就是"115200 收 700 kbaud"的静默错配 */ + s_uart.Instance = &g_sb_uart; + s_uart.Init.BaudRate = 115200U; + ret = sb_link_init(&s_uart); + CHECK(ret == BSP_ERR_STATE, "TC-SBLINK-001c a wrong baud rate is rejected (BSP_ERR_STATE)"); + CHECK(sb_arm_calls == 0U, "TC-SBLINK-001d nothing is armed when the baud rate is wrong"); + CHECK(sb_log_n == 1U, "TC-SBLINK-001e exactly one ERR line is emitted"); + CHECK(sb_log_has(0U, "baud=115200") == 1, "TC-SBLINK-001f the ERR line names the actual baud rate"); +} + +static void tc_init_ok(void) +{ + bsp_err_t ret = setup_link(); + + CHECK(ret == BSP_OK, "TC-SBLINK-002 init with 700000 returns BSP_OK"); + CHECK(sb_arm_calls == 1U, "TC-SBLINK-002b reception is armed exactly once"); + CHECK(sb_arm_size == SB_LINK_RX_BUF_SIZE, "TC-SBLINK-002c the receive buffer is SB_LINK_RX_BUF_SIZE"); + CHECK(sb_arm_size == 16U, "TC-SBLINK-002d ... which is 16 B, not the board's 4208 B frame length"); + CHECK(sb_arm_buf != NULL, "TC-SBLINK-002e a receive buffer is handed to HAL"); + CHECK(sb_log_n == 0U, "TC-SBLINK-002f a clean init logs nothing"); +} + +static void tc_not_initialised(void) +{ + /* 先把模块打回未初始化态:sb_link_init 之前 s_huart 为 NULL。 + * 本 TU 直接可见该静态变量(原样 include 生产源),这里显式还原一次。 */ + UART_HandleTypeDef *saved = s_huart; + + sb_fake_reset(); + sb_log_reset(); + s_huart = NULL; + + CHECK(sb_link_receive_start() == BSP_ERR_STATE, + "TC-SBLINK-003 re-arming before init returns BSP_ERR_STATE"); + CHECK(sb_arm_calls == 0U, "TC-SBLINK-003b ... and never touches the peripheral"); + + s_huart = saved; +} + +static void tc_foreign_handle(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + + sb_link_on_rx_event(&s_other, SB_FRAME_A_LEN); + sb_link_on_error(&s_other); + + sb_link_get_stats(&st); + CHECK(st.rx_events == 0U, "TC-SBLINK-004 an rx event on another handle is ignored"); + CHECK(st.err_irq == 0U, "TC-SBLINK-004b an error event on another handle is ignored"); + CHECK(sb_arm_calls == 1U, "TC-SBLINK-004c ... and does not re-arm (no extra arm call)"); + CHECK(sb_reg_n == 0U, "TC-SBLINK-004d ... and does not touch the registers"); +} + +static void tc_rt_frame(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + feed(k_rt, SB_FRAME_A_LEN); + + sb_link_get_stats(&st); + CHECK(st.rx_events == 1U, "TC-SBLINK-005 one IDLE event is counted"); + CHECK(st.rx_ok == 1U, "TC-SBLINK-005b the board's real RT summon frame parses OK"); + CHECK(st.last_size == SB_FRAME_A_LEN, "TC-SBLINK-005c last_size == 6"); + CHECK(st.last_cmd == SB_CMD_RT, "TC-SBLINK-005d last_cmd == SB_CMD_RT"); + CHECK(st.last_key == 0U, "TC-SBLINK-005e last_key == 0 for a periodic summon"); + CHECK(sb_arm_calls == 2U, "TC-SBLINK-005f reception is re-armed after the event"); + CHECK(st.rx_crc_err == 0U, "TC-SBLINK-005g no CRC error is counted"); + CHECK(st.key_events == 0U, "TC-SBLINK-005h a periodic summon is not a key event"); +} + +static void tc_key_frame(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + feed(k_key, SB_FRAME_A_LEN); + + sb_link_get_stats(&st); + CHECK(st.rx_ok == 1U, "TC-SBLINK-006 the board's real key summon frame parses OK"); + CHECK(st.last_cmd == SB_CMD_KEY, "TC-SBLINK-006b last_cmd == SB_CMD_KEY"); + CHECK(st.last_key == 9U, "TC-SBLINK-006c last_key == 9 (KEY_RESET on the board)"); + CHECK(st.key_events == 1U, "TC-SBLINK-006d the key event counter advances (2-3 consumes this)"); +} + +static void tc_crc_error(void) +{ + uint8_t bad[SB_FRAME_A_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + feed(k_key, SB_FRAME_A_LEN); /* 先来一帧好的,把 last_* 写成 9 */ + (void)memcpy(bad, k_rt, SB_FRAME_A_LEN); + bad[SB_FRAME_A_LEN - 1U] ^= 0xFFU; /* 打坏 CRC */ + feed(bad, SB_FRAME_A_LEN); + + sb_link_get_stats(&st); + CHECK(st.rx_events == 2U, "TC-SBLINK-007 both events are counted"); + CHECK(st.rx_ok == 1U, "TC-SBLINK-007b only the good frame counts as OK"); + CHECK(st.rx_crc_err == 1U, "TC-SBLINK-007c the corrupted frame is counted as a CRC error"); + CHECK(st.last_key == 9U, "TC-SBLINK-007d a bad frame does not overwrite last_key"); + CHECK(sb_arm_calls == 3U, "TC-SBLINK-007e reception is re-armed even after a bad frame"); +} + +static void tc_length_error(void) +{ + uint8_t five[5] = { 0x01U, 0x03U, 0x01U, 0x00U, 0xF0U }; + uint8_t full[SB_LINK_RX_BUF_SIZE]; + sb_link_stats_t st; + + (void)setup_link(); + feed(five, 5U); /* 少一个字节 */ + (void)memset(full, 0x00, sizeof(full)); + feed(full, SB_LINK_RX_BUF_SIZE); /* 缓冲被塞满(噪声冲击的典型形态) */ + sb_link_on_rx_event(&s_uart, 0U); /* 空事件(防御性) */ + + sb_link_get_stats(&st); + CHECK(st.rx_events == 3U, "TC-SBLINK-008 every event is counted"); + CHECK(st.rx_len_err == 3U, "TC-SBLINK-008b 5 B / 16 B / 0 B all count as length errors"); + CHECK(st.rx_ok == 0U, "TC-SBLINK-008c none of them is treated as a valid frame"); + CHECK(st.rx_crc_err == 0U, "TC-SBLINK-008d a wrong length is not reported as a CRC error"); + CHECK(sb_arm_calls == 4U, "TC-SBLINK-008e reception is re-armed every time"); +} + +static void tc_format_error(void) +{ + uint8_t frame[SB_FRAME_A_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + + make_frame(frame, 0x02U, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U); /* 地址不对 */ + feed(frame, SB_FRAME_A_LEN); + make_frame(frame, SB_ADDR, 0x04U, SB_CMD_RT, 0U); /* 功能码不对 */ + feed(frame, SB_FRAME_A_LEN); + make_frame(frame, SB_ADDR, SB_FUNC_READ_HOLDING, 0x02U, 0U); /* 命令类型不对 */ + feed(frame, SB_FRAME_A_LEN); + + sb_link_get_stats(&st); + CHECK(st.rx_fmt_err == 3U, "TC-SBLINK-009 wrong address / function / command all count as format errors"); + CHECK(st.rx_crc_err == 0U, "TC-SBLINK-009b ... and none of them is misreported as a CRC error"); + CHECK(st.rx_ok == 0U, "TC-SBLINK-009c none of them is accepted"); +} + +static void tc_error_clears_ore(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + + /* 造一次 ORE。ORE 是**阻塞型**错误:HAL 先把接收停掉(RxState 打回 READY)再回调, + * 所以桩也先把 RxState 打回 READY —— 否则测的就不是 ORE 那条路 */ + sb_ore_latched = 1U; + s_uart.RxState = HAL_UART_STATE_READY; + s_uart.ErrorCode = HAL_UART_ERROR_ORE; + sb_link_on_error(&s_uart); + + CHECK(sb_fake_trace_is("SD") == 1, "TC-SBLINK-010 the error path reads SR then DR (RM0090 clear sequence)"); + CHECK(sb_ore_latched == 0U, "TC-SBLINK-010b ORE is actually cleared"); + CHECK(sb_arm_calls == 2U, "TC-SBLINK-010c a blocking error (ORE) re-arms reception"); + CHECK(sb_arm_force == SB_FAKE_ARM_OK, "TC-SBLINK-010d (sanity) no forced failure is in play"); + + sb_link_get_stats(&st); + CHECK(st.err_irq == 1U, "TC-SBLINK-010e the error interrupt is counted"); + CHECK(st.err_bits == HAL_UART_ERROR_ORE, "TC-SBLINK-010f the error bits are recorded"); + CHECK(st.rearm_fail == 0U, "TC-SBLINK-010g re-arming succeeded, so rearm_fail stays 0"); + + /* 反例:如果哪天有人把清除序列写成 DR→SR,ORE 就清不掉,下一次挂接收必失败。 + * 这里直接模拟"ORE 没清掉"的后果,把判据钉住 */ + sb_ore_latched = 1U; + sb_sr_seen = 0U; + CHECK(sb_link_receive_start() == BSP_ERR_IO, + "TC-SBLINK-010h an uncleared ORE makes the next arm fail (this is why the clear matters)"); +} + +/* + * 非阻塞型错误(NE/FE/PE):HAL 不动接收(RxState 仍是 BUSY_RX),**不该**去重挂。 + * 2026-10-29 台架实测踩的就是这条:每帧都伴随 NE/FE,旧写法每次都想重挂、每次拿到 + * HAL_BUSY,于是 rearm_fail 跟着 err_irq 一起涨(160/160),看着像链路坏了。 + */ +static void tc_error_nonblocking(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + + s_uart.RxState = HAL_UART_STATE_BUSY_RX; /* 接收还在跑 */ + s_uart.ErrorCode = HAL_UART_ERROR_NE; + sb_link_on_error(&s_uart); + + CHECK(sb_arm_calls == 1U, "TC-SBLINK-015 a non-blocking error (NE) does not re-arm reception"); + CHECK(sb_fake_trace_is("SD") == 1, "TC-SBLINK-015b ... but still clears the flags (SR then DR)"); + + sb_link_get_stats(&st); + CHECK(st.err_irq == 1U, "TC-SBLINK-015c the error interrupt is counted"); + CHECK(st.err_bits == HAL_UART_ERROR_NE, "TC-SBLINK-015d the NE bit is recorded"); + CHECK(st.rearm_fail == 0U, "TC-SBLINK-015e ... and no phantom 're-arm failure' is booked"); + + /* 再来一次 FE:错误位按并集累计 */ + s_uart.ErrorCode = HAL_UART_ERROR_FE; + sb_link_on_error(&s_uart); + sb_link_get_stats(&st); + CHECK(st.err_bits == (HAL_UART_ERROR_NE | HAL_UART_ERROR_FE), + "TC-SBLINK-015f error bits accumulate as a union"); +} + +static void tc_rearm_failure(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + + sb_arm_force = SB_FAKE_ARM_BUSY; + CHECK(sb_link_receive_start() == BSP_ERR_BUSY, "TC-SBLINK-011 HAL_BUSY maps to BSP_ERR_BUSY"); + sb_arm_force = SB_FAKE_ARM_TIMEOUT; + CHECK(sb_link_receive_start() == BSP_ERR_TIMEOUT, "TC-SBLINK-011b HAL_TIMEOUT maps to BSP_ERR_TIMEOUT"); + sb_arm_force = SB_FAKE_ARM_ERROR; + CHECK(sb_link_receive_start() == BSP_ERR_IO, "TC-SBLINK-011c HAL_ERROR maps to BSP_ERR_IO"); + + sb_link_get_stats(&st); + CHECK(st.rearm_fail == 3U, "TC-SBLINK-011d every failed arm is counted in rearm_fail"); + + /* 中断里也要如实累计(不是只有任务侧那条路) */ + sb_arm_force = SB_FAKE_ARM_BUSY; + sb_link_on_rx_event(&s_uart, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(st.rearm_fail == 4U, "TC-SBLINK-011e a failed re-arm inside the rx callback is counted too"); +} + +static void tc_poll_reporting(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + + /* 未到周期:一行都不出(哪怕计数已经变了) */ + feed(k_rt, SB_FRAME_A_LEN); + sb_link_poll(0U); + CHECK(sb_log_n == 1U, "TC-SBLINK-012 the first valid frame is reported immediately"); + CHECK(sb_log_has(0U, "first frame") == 1, "TC-SBLINK-012b ... by a dedicated 'first frame' line"); + CHECK(sb_log_has(0U, "size=6") == 1, "TC-SBLINK-012c ... which carries size=6 (the 1-3 criterion)"); + + /* 到周期且计数有变化:出一行累计值 */ + feed(k_rt, SB_FRAME_A_LEN); + sb_link_poll(SB_LINK_REPORT_MS); + CHECK(sb_log_n == 2U, "TC-SBLINK-012d a periodic report is emitted once the period elapses"); + CHECK(sb_log_has(1U, "rx=2") == 1, "TC-SBLINK-012e the report carries the cumulative event count"); + CHECK(sb_log_has(1U, "ok=2") == 1, "TC-SBLINK-012f ... and the OK count"); + + /* 到周期但计数没变:不出行(链路安静时不刷屏) */ + 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 列可见 */ + { + 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); + } + 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"); +} + +static void tc_poll_wraparound(void) +{ + uint32_t base = 0xFFFFFFFFU - (SB_LINK_REPORT_MS / 2U); + + (void)setup_link(); + + /* 基准:距上次上报(0)已经远超一个周期 → 首帧行 + 周期行各一行 */ + feed(k_rt, SB_FRAME_A_LEN); + sb_link_poll(base); + CHECK(sb_log_n == 2U, "TC-SBLINK-013 the baseline poll emits both the first-frame line and a report"); + + feed(k_rt, SB_FRAME_A_LEN); + sb_link_poll(base + (SB_LINK_REPORT_MS / 2U)); /* 还没到周期 */ + CHECK(sb_log_n == 2U, "TC-SBLINK-013b the period is not up yet"); + + /* 跨过 0xFFFFFFFF:差值法(now - last)必须仍然成立,且真的绕回了 */ + CHECK((base + SB_LINK_REPORT_MS + 1U) < base, "TC-SBLINK-013c (sanity) this poll really wraps around"); + sb_link_poll(base + SB_LINK_REPORT_MS + 1U); + CHECK(sb_log_n == 3U, "TC-SBLINK-013d the report still fires across the tick wrap-around"); +} + +static void tc_stats_snapshot(void) +{ + sb_link_stats_t st; + + (void)setup_link(); + feed(k_key, SB_FRAME_A_LEN); + + (void)memset(&st, 0, sizeof(st)); + sb_link_get_stats(NULL); /* 空指针守卫:不得崩 */ + sb_link_get_stats(&st); + + CHECK(st.rx_events == 1U, "TC-SBLINK-014 the snapshot carries rx_events"); + CHECK(st.last_size == SB_FRAME_A_LEN, "TC-SBLINK-014b ... last_size"); + CHECK(st.last_cmd == SB_CMD_KEY, "TC-SBLINK-014c ... last_cmd"); + CHECK(st.last_key == 9U, "TC-SBLINK-014d ... last_key"); + CHECK(st.rearm_fail == 0U, "TC-SBLINK-014e ... rearm_fail"); +} + +int main(void) +{ + printf("=== ScreenLink Link Tests ===\n"); + + printf("-- init: guards --\n"); + tc_init_guards(); + printf("-- init: normal --\n"); + tc_init_ok(); + printf("-- re-arm before init --\n"); + tc_not_initialised(); + + printf("-- callback dispatch: foreign handle --\n"); + tc_foreign_handle(); + printf("-- rx event: periodic summon --\n"); + tc_rt_frame(); + printf("-- rx event: key summon --\n"); + tc_key_frame(); + printf("-- rx event: bad CRC --\n"); + tc_crc_error(); + printf("-- rx event: wrong length --\n"); + tc_length_error(); + printf("-- rx event: wrong address / function / command --\n"); + tc_format_error(); + + printf("-- error event: ORE clearing --\n"); + tc_error_clears_ore(); + printf("-- error event: non-blocking (NE/FE) must not re-arm --\n"); + tc_error_nonblocking(); + printf("-- re-arm failure mapping --\n"); + tc_rearm_failure(); + + printf("-- periodic reporting --\n"); + tc_poll_reporting(); + printf("-- periodic reporting: tick wrap-around --\n"); + tc_poll_wraparound(); + printf("-- stats snapshot --\n"); + tc_stats_snapshot(); + + 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/dbg_log.h b/test/host/sb_link/stub/dbg_log.h new file mode 100644 index 00000000..ecfa0b88 --- /dev/null +++ b/test/host/sb_link/stub/dbg_log.h @@ -0,0 +1,58 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(sb_link 模块专用) + * 模块功能:把 DBG_INFO / DBG_ERROR 换成"写进一个可读的行缓冲",于是用例能断言 + * 「打了几行、第一行说了什么」,而不是只能靠人看台架串口。 + * ⚠ 仅供 test/host/sb_link/ 使用,不参与 MCU 构建。 + * 真实 dbg_log.h 会拉进 console_log.h / sys_clock.h(App 层)并带时间戳, + * 本模块的用例只关心"有没有上报",故整份替换掉,不搬那条链。 + * 创建日期:2026-10-29 + */ +#ifndef DBG_LOG_H +#define DBG_LOG_H + +#include +#include +#include +#include + +#define SB_LOG_MAX (32U) +#define SB_LOG_LINE_MAX (192U) + +static char sb_log[SB_LOG_MAX][SB_LOG_LINE_MAX]; +static uint32_t sb_log_n; + +static void sb_log_add(const char *p_lvl, const char *p_fmt, ...) +{ + va_list ap; + size_t used; + + if (sb_log_n >= SB_LOG_MAX) { + return; + } + (void)snprintf(sb_log[sb_log_n], SB_LOG_LINE_MAX, "%s ", p_lvl); + used = strlen(sb_log[sb_log_n]); + va_start(ap, p_fmt); + (void)vsnprintf(sb_log[sb_log_n] + used, (size_t)(SB_LOG_LINE_MAX - used), p_fmt, ap); + va_end(ap); + sb_log_n++; +} + +static void sb_log_reset(void) +{ + sb_log_n = 0U; + sb_log[0][0] = '\0'; +} + +/* 第 idx 行是否包含 needle(idx 越界返回 0) */ +static int sb_log_has(uint32_t idx, const char *p_needle) +{ + if ((idx >= sb_log_n) || (idx >= SB_LOG_MAX)) { + return 0; + } + return (strstr(sb_log[idx], p_needle) != NULL) ? 1 : 0; +} + +#define DBG_INFO(...) sb_log_add("INFO", __VA_ARGS__) +#define DBG_ERROR(...) sb_log_add("ERR", __VA_ARGS__) + +#endif /* DBG_LOG_H */ diff --git a/test/host/sb_link/stub/main.h b/test/host/sb_link/stub/main.h new file mode 100644 index 00000000..da0e3b9d --- /dev/null +++ b/test/host/sb_link/stub/main.h @@ -0,0 +1,186 @@ +/* + * 模块名称:主机侧单测桩 - main.h(sb_link 模块专用) + * 模块功能:给 Drivers/BSP/ScreenLink/sb_link.c 提供"可脚本控制"的 HAL 面: + * ① 假 USART 寄存器:SR / DR 的**读取顺序与次数**可记账,ORE 锁存可注入; + * ② 假 ReceiveToIdle_IT:记录挂接收的缓冲/长度,返回值可脚本指定(OK/BUSY/TIMEOUT/ERROR)。 + * ⚠ 仅供 test/host/sb_link/ 使用,不参与 MCU 构建(MCU 走真实 main.h -> stm32f4xx_hal.h)。 + * + * 为什么必须桩:真实链 main.h -> stm32f4xx_hal.h -> stm32f4xx.h -> core_cm4.h 里是 + * __IO 寄存器绝对地址与 ARMCC 内联汇编,宿主 MSVC 既编不动也访问不了。 + * + * ★ 本桩唯一的技巧点:`->SR` / `->DR` 的读没法用普通结构体成员钩住(成员访问不能替换)。 + * 于是把 USART_TypeDef 的两个成员做成**函数指针**,再用宏把成员名改写成「成员名()」: + * 生产代码 p_huart->Instance->SR + * 预处理后 p_huart->Instance->sr_cell() + * 即一次真实的函数调用 —— 测试因此能记账"读了什么、什么顺序",并让 ORE 的清除 + * 严格按 RM0090 §30.6.1 的序列生效(**先读 SR 再读 DR 才清得掉**,顺序反了清不掉)。 + * 改成员名之前先确认生产代码只用 `->SR` / `->DR` 这一种写法(sb_link.c 里就两处)。 + * + * 创建日期:2026-10-29 + */ +#ifndef MAIN_H +#define MAIN_H + +#include +#include + +/* ===== 与真实头同值的枚举(stm32f4xx_hal_def.h / stm32f4xx_hal_uart.h) ===== */ +typedef enum { + HAL_OK = 0x00U, + HAL_ERROR = 0x01U, + HAL_BUSY = 0x02U, + HAL_TIMEOUT = 0x03U +} HAL_StatusTypeDef; + +/* RxState 的两个取值:错误回调里"要不要重挂"就靠它判(见 sb_link.c 的 on_error 说明) */ +#define HAL_UART_STATE_READY (0x20U) +#define HAL_UART_STATE_BUSY_RX (0x22U) + +/* HAL 的错误位(ErrorCode):ORE 是阻塞型,NE/FE/PE 是非阻塞型 */ +#define HAL_UART_ERROR_PE (0x01U) +#define HAL_UART_ERROR_NE (0x02U) +#define HAL_UART_ERROR_FE (0x04U) +#define HAL_UART_ERROR_ORE (0x08U) + +/* ===== ① 假 USART 寄存器(读法见文件头的★) ===== */ +typedef struct { + uint32_t (*sr_cell)(void); + uint32_t (*dr_cell)(void); +} USART_TypeDef; + +#define SR sr_cell() +#define DR dr_cell() + +#define SB_FAKE_SR_ORE (0x0008U) /* USART_SR_ORE,bit3 */ +#define SB_FAKE_SR_CLEAN (0x00C0U) /* TXE|TC 置位,无错误位 */ +#define SB_FAKE_DR_VALUE (0x00A5U) + +#define SB_FAKE_TRACE_MAX (8U) + +static uint32_t sb_reg_trace[SB_FAKE_TRACE_MAX]; /* 'S' = 读了 SR,'D' = 读了 DR */ +static uint32_t sb_reg_n; +static uint32_t sb_ore_latched; /* 1 = ORE 处于锁存态 */ +static uint32_t sb_sr_seen; /* 1 = 刚读过 SR(DR 才清得掉 ORE) */ + +static uint32_t sb_sr_cell(void) +{ + if (sb_reg_n < SB_FAKE_TRACE_MAX) { + sb_reg_trace[sb_reg_n] = (uint32_t)'S'; + } + sb_reg_n++; + sb_sr_seen = 1U; + return (sb_ore_latched != 0U) ? SB_FAKE_SR_ORE : SB_FAKE_SR_CLEAN; +} + +static uint32_t sb_dr_cell(void) +{ + if (sb_reg_n < SB_FAKE_TRACE_MAX) { + sb_reg_trace[sb_reg_n] = (uint32_t)'D'; + } + sb_reg_n++; + /* RM0090 §30.6.1:读 SR 之后再读 DR 才清 ORE;顺序反了清不掉(真机也这样) */ + if (sb_sr_seen != 0U) { + sb_ore_latched = 0U; + } + sb_sr_seen = 0U; + return SB_FAKE_DR_VALUE; +} + +static USART_TypeDef g_sb_uart = { sb_sr_cell, sb_dr_cell }; + +/* ===== ② 假 UART 句柄 ===== */ +typedef struct { + uint32_t BaudRate; + uint32_t WordLength; + uint32_t StopBits; + uint32_t Parity; + uint32_t Mode; + uint32_t HwFlowCtl; + uint32_t OverSampling; +} UART_InitTypeDef; + +typedef struct { + USART_TypeDef *Instance; + UART_InitTypeDef Init; + volatile uint32_t gState; + volatile uint32_t RxState; + volatile uint32_t ErrorCode; +} UART_HandleTypeDef; + +/* ===== ③ 假 ReceiveToIdle_IT ===== */ +#define SB_FAKE_ARM_OK (0U) +#define SB_FAKE_ARM_BUSY (1U) +#define SB_FAKE_ARM_TIMEOUT (2U) +#define SB_FAKE_ARM_ERROR (3U) + +static uint32_t sb_arm_calls; +static uint32_t sb_arm_force; /* SB_FAKE_ARM_* */ +static uint8_t *sb_arm_buf; +static uint16_t sb_arm_size; + +static HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_IT(UART_HandleTypeDef *p_huart, + uint8_t *p_buf, + uint16_t size) +{ + sb_arm_calls++; + sb_arm_buf = p_buf; + sb_arm_size = size; + + if (sb_arm_force == SB_FAKE_ARM_BUSY) { + return HAL_BUSY; + } + if (sb_arm_force == SB_FAKE_ARM_TIMEOUT) { + return HAL_TIMEOUT; + } + if (sb_arm_force == SB_FAKE_ARM_ERROR) { + return HAL_ERROR; + } + /* ORE 锁存时真机是收不了的(RXNE 不再置位)——桩照这个语义走, + * 于是"ORE 没清干净"必然表现为挂接收失败,用例能抓住 */ + if (sb_ore_latched != 0U) { + return HAL_ERROR; + } + /* 挂上了:RxState 进 BUSY_RX。错误回调据此判断"接收是不是还在跑" + * (真机也是这样:非阻塞型错误不动 RxState,阻塞型错误会把它打回 READY) */ + p_huart->RxState = HAL_UART_STATE_BUSY_RX; + return HAL_OK; +} + +/* ===== ④ 测试用的复位/查询把手 ===== */ +static void sb_fake_reset(void) +{ + uint32_t i; + + for (i = 0U; i < SB_FAKE_TRACE_MAX; i++) { + sb_reg_trace[i] = 0U; + } + sb_reg_n = 0U; + sb_ore_latched = 0U; + sb_sr_seen = 0U; + sb_arm_calls = 0U; + sb_arm_force = SB_FAKE_ARM_OK; + sb_arm_buf = NULL; + sb_arm_size = 0U; +} + +/* 寄存器读序列是否为期望的字符串(如 "SD"):长度不等或内容不符都返回 0 */ +static int sb_fake_trace_is(const char *p_expect) +{ + uint32_t i; + uint32_t len = (uint32_t)0U; + + while (p_expect[len] != '\0') { + len++; + } + if (sb_reg_n != len) { + return 0; + } + for (i = 0U; i < len; i++) { + if (sb_reg_trace[i] != (uint32_t)(unsigned char)p_expect[i]) { + return 0; + } + } + return 1; +} + +#endif /* MAIN_H */ diff --git a/test/host/screenlink_bench.py b/test/host/screenlink_bench.py new file mode 100644 index 00000000..c69870dc --- /dev/null +++ b/test/host/screenlink_bench.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。 + +做什么: + 在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤; + 可穿插按键帧(屏板有键时最早 205 ms 就会再来一帧,本脚本只在周期点替换内容, + 不额外插帧——批 1 只验"收得到、长度对、CRC 过",不验应答时序)。 + 主机侧的统计由固件自己打(`[SBLINK] rx=... ok=... crc=... len=...`), + 本脚本只负责"有东西可收",不读被测板——**收侧观测面是串口日志,不是本脚本**。 + +为什么不用串口助手 GUI: + 1-3 的判据含"连续 10 min 无丢帧",手点是做不到的;而且帧内容必须逐字节对 + (CRC 低字节在前,写错一位就是 100 % CRC 错帧,与"波特率错"同形)。 + +接法(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 判据 + --key N 每 N 个周期把召唤帧换成按键帧(键值在 1/2/3/4/7/8/9 里轮换;0 = 不发按键帧) + +退出码:0 = 按时发完;1 = 串口打不开 / 发送失败;2 = 用法错误。 + +设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、§5 台架联调步骤第 2 步。 +创建日期: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 ,CRC 只覆盖前 4 字节。""" + body = bytes((ADDR, FUNC, cmd, key)) + crc = crc16_modbus(body) + return body + bytes((crc & 0xFF, (crc >> 8) & 0xFF)) + + +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("--glitch-baud", type=int, default=0, + help="先按这个(错的)波特率灌一串垃圾再正常发,用来验 ORE/NE/FE 之后能否自愈" + "(0 = 不灌;建议 115200)") + ap.add_argument("--glitch-bytes", type=int, default=32, + help="灌多少字节垃圾(默认 32)") + args = ap.parse_args() + + if args.period_ms <= 0 or args.seconds <= 0: + print("[ERR] --period-ms / --seconds 必须为正") + 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: + ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1, timeout=0) + except Exception as exc: # noqa: BLE001 - 打开失败原因要原样给人看 + print("[ERR] 打开 %s @%d 失败:%s" % (args.port, args.baud, exc)) + return 1 + + sent = 0 + keys = 0 + 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 > 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" + + ser.write(frame) + ser.flush() + sent += 1 + if sent <= 3 or tag != "RT": + print(" #%d %s -> %s" % (sent, tag, frame.hex(" ").upper())) + + 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)) + print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0" % sent) + return 0 + + +if __name__ == "__main__": + sys.exit(main())