merge: task/screenlink-bench-serial 合入 v2-on-ui

任务 screenlink(17 个提交)经 task-worktree 回收:
- merge: v2-on-ui into task/screenlink-bench-serial (wave + screenlink)
- docs(屏板): 批 3 改写(LOGO 出局)+ 4-1 双通道并发实测回填
- feat(屏板): 状态灯位映射 sb_led_map + 104 RUN 观测口(批 3);附 4-1 并发压测脚本
- docs(屏板): 回填真屏板 10 min 连续零错实测(§5.5),未测项清单刷新状态
- feat(屏板): 帧 B 组帧 + 中断起发 TX DMA + 按键队列 + 离线判定(批 2)
- docs(屏板): 批 1 收尾,1-2/1-3 判据回填并订正三处错口径
- feat(屏板): USART1 700 kbaud + 收中断传输层 sb_link(1-2/1-3)
- docs(屏板): 订正 CRC 字节序口径(低字节在前)
- chore(体积): 重盖同码凭证(sb_proto 进索引)
- feat(屏板): 协议层 sb_proto(帧 A 解析 / 帧 B 组帧 / CRC-16)
- docs(屏板): 批 0 的 0-3 接法落纸
- chore(台架): 台架串口默认口改 COM3
- test(日志): 主机单测桩跟到 UART5
- docs(屏板): 批 0 的 0-1 / 0-2 标记完成
- feat(日志): 控制台迁到 RS232#1/UART5,USART1 让位给屏板链路
- docs(屏板): 删掉「日志口改 UART_MODE_TX」的说法
- docs(屏板): 批 0 日志口定为 RS232#1(UART5),落定 CN1 端子与适配器要求
This commit is contained in:
2026-10-30 01:09:12 +08:00
42 changed files with 4883 additions and 180 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"portName": "COM6",
"portName": "COM3",
"baudRate": 115200,
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
+58
View File
@@ -0,0 +1,58 @@
/*
* 模块名称:屏板状态灯位映射(业务状态 → LED 位掩码)
* 模块功能:sb_led_map_eval()——状态快照进、32 位 LED 掩码出。位定义与理由全在
* sb_led_map.h(映射表唯一出处),本文件只做折算。
* 适用平台:平台无关(纯标准 C)
* 作者:王建锋
* 创建日期:2026-10-29
* 修改记录:
* 2026-10-29 王建锋 创建初始版本(批 3 的 3-1)
*/
#include <stddef.h> /* NULL:纯逻辑不经 main.h,NULL 要自己带来 */
#include "sb_led_map.h"
/* NULL 输入归一用的全零快照(static const:不占 RAM,语义就是"什么都没发生") */
static const sb_led_in_t s_in_zero = { 0UL, 0UL, 0UL, 0U, { 0U, 0U, 0U } };
/*
* 函数功能:把状态快照折算成屏板 LED 32 位掩码(契约见 sb_led_map.h)
* 入口参数:p_in - 状态快照,允许 NULL(= 全零输入,只跑心跳)
* now_ms - 毫秒时基(只用于运行灯相位)
* 返回值:32 位 LED 掩码(bit i = 灯 ID i;本函数不触碰 bit0 与 ID 6~19)
* 限定条件:任意上下文,纯函数
* 函数说明:心跳相位取 (now_ms / SB_LED_BLINK_HALF_MS) 的奇偶——用**除法取奇偶**
* 而不是"与上次比差值再翻转",因为后者要把相位存成静态量,纯函数就没了
* (单测要能不开机就验完整个闪烁序列)。回绕只截短一个半周期,无害。
*/
uint32_t sb_led_map_eval(const sb_led_in_t *p_in, uint32_t now_ms)
{
const sb_led_in_t *q = (p_in != NULL) ? p_in : &s_in_zero;
uint32_t mask = 0UL;
/* ID 1 运行心跳:偶数个 500 ms 段 = 亮 */
if (((now_ms / SB_LED_BLINK_HALF_MS) & 1UL) == 0UL)
{
mask |= SB_LED_BIT_RUN;
}
if (q->prot_active != 0UL)
{
mask |= SB_LED_BIT_ALARM;
}
if (q->iec104_run != 0U)
{
mask |= SB_LED_BIT_COMM;
}
if (q->adc_flags != 0UL)
{
mask |= SB_LED_BIT_ADC_FAULT;
}
if (q->prot_pick != 0UL)
{
mask |= SB_LED_BIT_PICKUP;
}
return mask;
}
+101
View File
@@ -0,0 +1,101 @@
/*
* 模块名称:屏板状态灯位映射(业务状态 → LED 位掩码)
* 模块功能:把装置的业务状态(保护动作 / 保护启动 / 采样故障 / 104 主站在线 /
* 运行心跳)折算成屏板帧 B `info[0..3]` 的 32 位 LED 掩码。
* ⚠ 本层**纯逻辑**:不碰 HAL、不读全局、不打日志——输入是调用方
* (uiTask)采好的状态快照,输出是一个 uint32_t → 主机侧可纯逻辑单测
* (test/host/sb_led_map/)。与 Drivers/BSP/ScreenLink/sb_proto 同一分层理由。
* 适用平台:平台无关(纯标准 C)
* 作者:王建锋
* 创建日期:2026-10-29
* 修改记录:
* 2026-10-29 王建锋 创建初始版本(批 3 的 3-1:映射表落纸,docs/任务规划/屏板链路.md §3)
*
* 这张表为什么必须"自定":屏板源码(DTU-LCD LED_Printf)只规定了"位 → 灯号"的
* 物理口径(bit1~bit19 → ID 1~19,ID 0 电源灯硬件常亮、不看位,bit20~31 未使用),
* **"哪路状态占哪一位"在参考主机源码里不存在**(参考的 LCD_LED_Update 实现在
* 未开源的屏板 BSP 库里)。出处 docs/产品需求/产品需求设计文档.md §5.16.3 ①。
*/
#ifndef SB_LED_MAP_H
#define SB_LED_MAP_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
/* ============================================================================
* 一、位定义(屏板物理口径:bit i = 灯 ID i;bit0 被屏板忽略,别指望用它)
* ==========================================================================*/
/* ID 1 = 运行灯:装置活着的心跳,500 ms 半周期闪烁(与主板 LED1~3 心跳节奏同源,
* docs/产品需求/产品需求设计文档.md §5.8)。它**不取决于任何输入状态**——只要这段
* 代码还在被调度,灯就在闪;灯不闪 = uiTask 停了,这正是操作员要看的信号。 */
#define SB_LED_BIT_RUN (1UL << 1)
/* ID 2 = 告警灯:任一保护元件处于**动作**态(prot_elm_active_mask != 0)。
* ⚠ 产品构建处于「未标定闭锁」(App/task/protect_task.c 的 VALID 门禁),
* 标定前恒灭——与屏上"保护动作 N"同一已知口径,不是本灯的缺陷。 */
#define SB_LED_BIT_ALARM (1UL << 2)
/* ID 3 = 通信灯:IEC 60870-5-104 链路处于 RUN(STARTDT 已确认 = 主站真的在线在
* 轮询)。选 RUN 而不选"TCP 已建连":连上不发 STARTDT 的扫描器不该点亮它。 */
#define SB_LED_BIT_COMM (1UL << 3)
/* ID 4 = 采样故障灯:ADC 采样有效性**电平**故障位非零(adc_health_flags,
* 静默/倒退等,当前活跃、可自愈熄灭)。选电平不选 sticky:这盏灯要能熄灭,
* "曾经故障过"的账在自检记录里,不该用一盏长明灯喊给操作员。 */
#define SB_LED_BIT_ADC_FAULT (1UL << 4)
/* ID 5 = 保护启动灯:任一保护元件处于**启动**(picked)态(prot_elm_pick_mask)。
* picked 早于 asserted,"越限启动了但没到动作点"这段过程只在这盏灯上可见
* (与 104「保护启动」记录同一动机,App/algo/prot_elm.h 的 pick_mask 注释)。
* 同样受未标定闭锁口径约束。 */
#define SB_LED_BIT_PICKUP (1UL << 5)
/* 已赋义位的并集(判据用:掩码里不得出现本表之外的置位)。
* ID 6~19 保留未用,恒 0;将来扩展从 ID 6 顺序取,跳号留白不回收语义。 */
#define SB_LED_BITS_DEFINED (SB_LED_BIT_RUN | SB_LED_BIT_ALARM | SB_LED_BIT_COMM | \
SB_LED_BIT_ADC_FAULT | SB_LED_BIT_PICKUP)
/* 运行灯闪烁半周期(ms):0 ~ SB_LED_BLINK_HALF_MS-1 亮,下一个 500 ms 灭,交替。
* 取 500 是为与主板 LED 心跳(defaultTask 500 ms 翻转)同拍,两套灯闪烁同相。 */
#define SB_LED_BLINK_HALF_MS (500U)
/* ============================================================================
* 二、状态输入快照(调用方每拍现采,本模块不缓存、不加锁)
* ==========================================================================*/
typedef struct
{
uint32_t prot_active; /* prot_elm_active_mask():bit i = 元件 i 动作 */
uint32_t prot_pick; /* prot_elm_pick_mask():bit i = 元件 i 启动 */
uint32_t adc_flags; /* adc_health_flags():ADC_HF_* 电平故障位 */
uint8_t iec104_run; /* 1 = IEC 104 链路 RUN(iec104_serv_link_run()) */
uint8_t pad[3]; /* 显式补齐:结构体大小固定 16 B,主机/目标端一致 */
} sb_led_in_t;
/* ============================================================================
* 三、求值入口
* ==========================================================================*/
/*
* 函数功能:把状态快照折算成屏板 LED 32 位掩码(纯函数,映射表唯一出处见本头 §一)
* 入口参数:p_in - 状态快照;**允许 NULL**:等价于全零输入(只跑运行灯心跳——
* 心跳证明的是"这段代码活着",与别的状态无关,不该被空输入连坐)
* now_ms - 当前毫秒时基(运行灯相位唯一依赖;osKernelGetTickCount 同域)
* 返回值:32 位 LED 掩码(bit i = 灯 ID i;bit0 与 ID 6~19 恒 0,直接进帧 B info[0..3])
* 限定条件:任意上下文(纯计算:无分支副作用、不读写任何静态量、时基回绕安全——
* 回绕瞬间至多把一个半周期截短或延长 1 拍,500 ms 的心跳灯看不出来)
* 函数说明:闪烁相位 = (now_ms / 500) 的奇偶,偶 = 亮。上电 first 500 ms 为亮,
* "刚上电灯就在闪"是最直观的自检信号。各状态位相互独立,任一非零即置位,
* 无优先级、无互斥(告警与通信可以同时亮,那是两件同时在真发生的事)。
*/
uint32_t sb_led_map_eval(const sb_led_in_t *p_in, uint32_t now_ms);
#ifdef __cplusplus
}
#endif
#endif /* SB_LED_MAP_H */
+17
View File
@@ -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",
+32 -20
View File
@@ -1,5 +1,5 @@
/*
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1)实现
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(RS232#1 / UART5)实现
* 模块功能:控制台单写者日志的环形缓冲 + 唯一写者发送实现(API 见 console_log.h);
* 兼载 MicroLIB printf 底层 fputc 重定向
* 适用平台:STM32F407ZGTx(仅 MCU 端)
@@ -99,6 +99,18 @@
* 与当场直发逐字节相同。ISR / 调度器未启动两种上下文放行(不许等)。
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
*/
/* ===== 控制台端口(单点收口)=====
* 换口只改这一段:句柄 / IRQn / 初始化函数。时钟宏随句柄走——UART5 在 APB1、
* USART1 在 APB2,别照抄;串口模式与波特率在 Src/usart.c 的 MX_*_Init 里,本模块不管。
* 2026-10-29 批 0:USART1 让位给屏板链路(700 kbaud 只有它 MCU 引脚直出),控制台迁
* RS232#1/UART5。缘由、端子与验收见 docs/任务规划/屏板链路.md 的批 0。 */
#define CON_UART huart5
#define CON_UART_IRQN UART5_IRQn
#define CON_UART_CLK_DISABLE() __HAL_RCC_UART5_CLK_DISABLE()
#define CON_UART_CLK_ENABLE() __HAL_RCC_UART5_CLK_ENABLE()
#define CON_UART_INIT() MX_UART5_Init()
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
#define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */
#define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */
@@ -140,7 +152,7 @@ static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保
static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */
static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */
static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */
/* 行级单一所有者(道G task-23,O1 第二实例):USART1 数据寄存器同一时刻只允许
/* 行级单一所有者(道G task-23,O1 第二实例):控制台串口数据寄存器同一时刻只允许
* 一个上下文在逐字节轮询写入。NULL = 空闲。
* ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见
* con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给
@@ -228,7 +240,7 @@ static void con_dwt_ensure(void) {
* 入口参数:u - USART 寄存器基址(调用方已判非 NULL) USART_TypeDef *
* p_buf - 待发数据;len - 字节数
* 返回值:HAL_OK - 已发出;HAL_TIMEOUT - 计数上界内未完成(本块放弃)
* 限定条件:USART1 已初始化
* 限定条件:控制台串口已初始化
* 函数说明:刻意不用 HAL_UART_Transmit——理由见 CON_TX_GUARD_MAX 处注释
* (tick 依赖不可信 + 其调用链污染所有任务的最坏栈链)
*/
@@ -258,12 +270,12 @@ static HAL_StatusTypeDef con_tx_counted(USART_TypeDef *u, const uint8_t *p_buf,
* 函数功能:有界裸发送(不依赖 uwTick / HAL 超时)
* 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX)
* 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成
* 限定条件:USART1 已初始化
* 限定条件:控制台串口已初始化
* 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC);
* DWT 未使能时走 con_tx_counted(指令计数上界,2026-10-10 取代
* HAL_UART_Transmit 回退)。本函数只做 TX,
* 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。
* ⚠ USART1 未初始化(huart1.Instance == NULL,如 HAL_Init 早期或
* ⚠ 控制台串口未初始化(CON_UART.Instance == NULL,如 HAL_Init 早期或
* 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写
* 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。
*/
@@ -273,14 +285,14 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
uint16_t i;
con_dwt_ensure();
if (huart1.Instance == NULL) {
return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位(不是超时,不记超时账) */
if (CON_UART.Instance == NULL) {
return HAL_ERROR; /* 控制台串口未初始化:无可用通道,安全让位(不是超时,不记超时账) */
}
if (s_con_dwt_up == 0U) {
return con_tx_counted(huart1.Instance, p_buf, len);
return con_tx_counted(CON_UART.Instance, p_buf, len);
}
u = huart1.Instance;
u = CON_UART.Instance;
t0 = DWT->CYCCNT;
for (i = 0U; i < len; i++) {
while ((u->SR & USART_SR_TXE) == 0U) {
@@ -299,7 +311,7 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
}
/*
* 函数功能:尝试取得"USART1 整行发送所有权"(**非阻塞**,道G task-23)
* 函数功能:尝试取得"控制台串口整行发送所有权"(**非阻塞**,道G task-23)
* 返回值:CON_TX_HELD 已持有(发完必须 con_tx_release)/ CON_TX_BYPASS 放行且不记账
* / CON_TX_NO 别人正持有整行,调用方须让路
* 限定条件:任意上下文(内部只有一次几条指令的临界区,绝不睡眠、绝不长期关中断)
@@ -473,7 +485,7 @@ uint8_t dbg_console_writer_ready(void) {
}
/*
* 函数功能:USART1 僵尸通道自愈(清错误锁存 + 整外设重初始化)
* 函数功能:控制台串口僵尸通道自愈(清错误锁存 + 整外设重初始化)
* 函数说明:**RX 使能了却从不读 DR** → 线上任意一个字节或一个噪声沿使 RXNE 置位后无人读,
* 下一个事件即锁存 ORE(常伴 NE/FE/PE);ORE 只能靠"先读 SR 再读 DR"清除 ⇒ 永不自愈。
* 锁存后 HAL 轮询 TX 恒快速失败(控制台静默、系统不复位);发送器卡死状态则使 TXE
@@ -484,13 +496,13 @@ uint8_t dbg_console_writer_ready(void) {
* (详见 docs/子系统设计/控制台单写者日志设计说明.md §12)。
*/
static void con_uart_reinit(void) {
__NVIC_ClearPendingIRQ(USART1_IRQn);
__HAL_RCC_USART1_CLK_DISABLE();
__HAL_RCC_USART1_CLK_ENABLE();
huart1.gState = HAL_UART_STATE_RESET;
huart1.RxState = HAL_UART_STATE_RESET;
huart1.ErrorCode = HAL_UART_ERROR_NONE;
(void)MX_USART1_UART_Init();
__NVIC_ClearPendingIRQ(CON_UART_IRQN);
CON_UART_CLK_DISABLE();
CON_UART_CLK_ENABLE();
CON_UART.gState = HAL_UART_STATE_RESET;
CON_UART.RxState = HAL_UART_STATE_RESET;
CON_UART.ErrorCode = HAL_UART_ERROR_NONE;
(void)CON_UART_INIT();
}
/*
@@ -512,7 +524,7 @@ void dbg_console_drain(void) {
return; /* 重入保护:写者自记录不触发嵌套 drain */
}
/* 整行所有权(道G task-23):本函数按块直写 USART1->DR,若此刻别的应用上下文
/* 整行所有权(道G task-23):本函数按块直写 CON_UART->DR,若此刻别的应用上下文
* 还在未注册期那条裸发里发到自己一半(attach 就发生在 defaultTask 里,与别的
* 任务裸发**可以**前后脚撞上),两边就会逐字节交错——那正是本卡要修的形态。
* 拿不到就**本轮让位**:tail 不推进 => 环里一条不少,主循环 ≤50 ms 后再试,
@@ -560,7 +572,7 @@ void dbg_console_drain(void) {
if (st != HAL_OK) {
if (st == HAL_ERROR) {
/* 2026-10-24 修复:USART1 **未初始化**(huart1.Instance == NULL,
/* 2026-10-24 修复:控制台串口 **未初始化**(CON_UART.Instance == NULL,
* 只可能出现在 HAL_Init 早期或断言/故障上报路径)与"僵尸通道"是两回事:
* 这里**绝不做外设重初始化**——那会在 HAL 尚未就绪时跑 MspInit 重配
* RCC/GPIO;也不计 tx_fault(那本账的语义是"软复位自愈次数")。
+1 -1
View File
@@ -1,5 +1,5 @@
/*
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1 调试串口)
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(RS232#1 / UART5)
* 模块功能:调试控制台单写者环形缓冲的公共 API:所有任务/上下文(含 printf)只把
* 已格式化整行文本入环形缓冲,写者任务(defaultTask)为唯一
* HAL_UART_Transmit 发送点(行级原子 + 指针可控)
+15
View File
@@ -282,6 +282,21 @@ void iec104_serv_get_stats(iec104_serv_stats_t *p_out)
*p_out = s;
}
/*
* 函数功能:读 104 链路是否处于 RUN 态(契约见 iec104_serv.h 的 iec104_serv_link_run)
* 入口参数:无
* 返 回 值:1 = RUN;0 = 其它 uint8_t
* 限定条件:任意上下文;单枚举字读,原子
* 函数说明:直接透传 iec104_link_state(),不另存镜像量——镜像量一旦和状态机
* 分家(比如 open/close 路径漏更),屏灯就成第二本假账。link_state
* 本身只读实例里的 state 字段(App/protocol/iec104/iec104_link.c),
* 无副作用,跨任务读安全。
*/
uint8_t iec104_serv_link_run(void)
{
return (iec104_link_state(&s_link) == IEC104_ST_RUN) ? 1U : 0U;
}
/*
* 函数功能:单次服务循环(代次自愈 → accept → 门控收 → 喂 link → 节拍)
* 返回值:无
+13
View File
@@ -52,4 +52,17 @@ typedef struct {
*/
void iec104_serv_get_stats(iec104_serv_stats_t *p_out);
/*
* 函数功能:读 104 链路是否处于 RUN 态(STARTDT 已确认 = 主站在线在轮询)
* 返 回 值:1 = RUN;0 = 其它(IDLE/OPEN/STOPPED 或未创建任务) uint8_t
* 限定条件:**任意上下文可读**——单枚举字读,不取锁、不碰 socket、不阻塞
* (与 iec104_serv_get_stats 同一形态与同一读者群)。
* 函数说明:写者 = iec104Task(link 状态机推进),读者按需采样;单字读写在
* Cortex-M4 上原子,最多读到"晚一拍"的状态,无撕裂风险。
* 消费点 = 屏板状态灯 ID 3(App/algo/sb_led_map.h 的通信灯)。
* ⚠ 别拿它换算 conn_up:RUN 是电平,conn_up 是累计边沿,语义不可互换
* (同文件统计注释里的"不得互相换算"纪律)。
*/
uint8_t iec104_serv_link_run(void);
#endif /* IEC104_SERV_H */
+84
View File
@@ -54,6 +54,9 @@
#include "ui_tokens.h" /* UI 设计令牌:循环周期 UI_TASK_TICK_MS / 待机阈值 UI_STANDBY_IDLE_MS */
#include "sys_clock.h"
#include "prot_elm.h" /* 道F task-3 装置维护:强制复归请求 + 动作态元件位图 */
#include "adc_health.h" /* 屏板状态灯 ID 4:ADC 电平故障位(adc_health_flags) */
#include "sb_led_map.h" /* 屏板状态灯位映射(批 3:业务状态 → info[0..3] 掩码) */
#include "iec104_serv.h" /* 屏板状态灯 ID 3:104 链路 RUN 态(任意上下文可读) */
#include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */
#include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */
#include "task_watchdog.h"
@@ -68,6 +71,7 @@
#include "wave_ring.h"
#include "wave_store.h"
#include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */
#include "sb_link.h" /* 屏板链路:帧 B 组帧 / 按键出队 / 统计与离线判定(批 2) */
/*
* 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str)
@@ -862,6 +866,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 *
@@ -966,6 +997,53 @@ 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 掩码(批 3):每拍现采四路状态源经 sb_led_map_eval 折算——四个源
* 都是"任意上下文可读"的观测口(不取锁不阻塞,见各自头文件契约),
* 折算本身纯计算;心跳灯相位跟着这一拍的 tick 走(uiTask 10 ms 节拍
* 远高于 500 ms 半周期,闪烁边沿误差 ≤1 拍)。 */
{
sb_led_in_t led_in;
uint32_t led_mask;
bsp_err_t sb_ret;
uint8_t board_key;
led_in.prot_active = prot_elm_active_mask();
led_in.prot_pick = prot_elm_pick_mask();
led_in.adc_flags = adc_health_flags();
led_in.iec104_run = iec104_serv_link_run();
led_mask = sb_led_map_eval(&led_in, osKernelGetTickCount());
sb_ret = sb_link_frame_update(ui_port_get_framebuffer(), led_mask);
/* 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 往返主导)。提到往返之前后,
@@ -983,6 +1061,12 @@ void ui_task_func(void *argument) {
ui_net_sink_poll();
task_prof_exit(TASK_PROF_UI_SINK);
/* 屏板链路统计上报与离线判定(内部自分频,5 s 一次、只在计数有变化时打一行)。
* 放在推流段之后:它只是读一份快照 + 偶尔一行日志,不进推流往返的耗时统计。
* 组帧与注键在渲染之后就做完了(见上),这里只剩"看一眼"——离线判定要求每拍都看,
* 所以它不跟着 5 s 的上报周期走。 */
sb_link_poll(osKernelGetTickCount());
/* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */
task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0);
+41 -3
View File
@@ -16,6 +16,8 @@
#include "rs485.h"
#include "sb_link.h" /* 屏板链路 USART1:本文件是全局 UART 回调的唯一实现处,见下方回调段 */
/*
* 函数功能:把 HAL 的状态码映射成项目统一的 bsp_err_t(边界映射,2026-10-22 试点)
* 入口参数:status - HAL_* 返回的状态
@@ -170,11 +172,27 @@ 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,编不过。
* 三个 = 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;
@@ -196,6 +214,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 +231,21 @@ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
/* 重新开启接收 */
rs485_receive_start(&g_rs485);
}
/* 屏板链路(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);
}
+635
View File
@@ -0,0 +1,635 @@
/*
* 模块名称:屏板链路传输层(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
* (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 / 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]"
#include "dbg_log.h"
#include <string.h>
#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];
/*
* 帧 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;
/* 上报用:上次上报时刻与上次上报时的计数(用来判"有没有变化") */
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_* 返回的状态
* 返回值:对应的 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;
}
}
/*
* 函数功能:把一个屏板键号压进交接队列
* 入口参数: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++;
}
/* ======================== 公开函数实现 ======================== */
/*
* 函数功能:初始化屏板链路并挂上第一轮接收
* 入口参数: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_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 覆盖丢了。
* ⚠ 日志字面量只写 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++;
/* 只入队,不注键:注键会走 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)
{
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 怎么分组错误位。
* 3. **顺手收拾被打断的 TX**:错误中断不会走 TxCplt,busy 会永久挂着,
* 此后每次召唤都被判"上一帧还在发"→ 链路再也不发(rx 涨、tx 不动)。
* 判据用 gState,见下面代码里的说明。
*/
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;
/*
* 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)
{
(void)sb_link_receive_start();
}
}
/*
* 函数功能:链路统计上报与屏板离线判定(任务侧周期调用)
* 入口参数:now_ms - 当前毫秒时基
* 返回值:无
* 限定条件:任务上下文调用(内部会打日志)
* 函数说明: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)
{
return;
}
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))
{
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;
/* 无变化不刷屏:链路安静时一行都不出,出的一行就是有事实发生 */
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;
}
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++;
}
/*
* 函数功能:取一份链路统计快照
* 入口参数: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;
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;
}
+270
View File
@@ -0,0 +1,270 @@
/*
* 模块名称:屏板链路传输层(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 / HAL_UART_TxCpltCallback
* 是**全局单例**,本工程唯一实现处是 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 <stdint.h>
#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)
/*
* 判「屏板离线」的静默时长(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
/* ============================================================================
* 二、链路统计
* ==========================================================================*/
/*
* 全部字段都是**累计值**,由中断侧自增、任务侧只读(读出来的是一份快照)。
* 分门别类记而不只记"错了多少次",是因为台架上这几类错指向完全不同的原因:
* · 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) */
/* ---- 批 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;
/*
* 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 才解析。解析通过后**直接点 TX DMA**
* 把已经组好的帧 B 发出去(决策三:起发必须在中断里,不能在任务里轮询——屏板按
* "总线空闲 1 个字符"断帧,一次几十 µs 的任务抢占就能把帧切断,而屏板不报超时)。
* 业务动作仍然不做:按键只入队,注键由任务侧 sb_link_key_take() 取走后做。
* 末尾无条件重挂接收。
*/
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 检查一次,只有计数有变化才打一行累计值。
* 第一帧成功收到时立刻打一行(不等周期),台架刚上电就能看到链路通了。
* 同时维护"屏板离线"标志:连续 >= 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];位的语义见
* App/algo/sb_led_map.h——位变了帧就重组,灯随下一轮召唤出去)
* 返回值: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.5 ms(典型)/ 0.8 ms(峰值),没变只付 memcmp(≈20 µs);
* 判据口径见 docs/任务规划/屏板链路.md §3 的 2-1。
* 函数说明: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 - 输出缓冲(非空)
* 返回值:无(p_out 为空时直接返回,不改任何东西)
* 限定条件:任一上下文可调;中断侧在自增,快照不保证同一时刻一致(只用于观测与计数)。
* 函数说明:逐字段拷出,避免把 volatile 的内部结构整体赋给调用方。
*/
void sb_link_get_stats(sb_link_stats_t *p_out);
#ifdef __cplusplus
}
#endif
#endif /* SB_LINK_H */
+277
View File
@@ -0,0 +1,277 @@
/*
* 模块名称:屏板链路协议层实现(帧 A 解析 / 帧 B 组帧 / CRC-16)
* 模块功能:见 sb_proto.h 的模块头。本文件只做字节级编解码,不碰寄存器、DMA、任务与时间。
* 适用平台:STM32F4 系列(ARMCC v5);仅依赖 stdint.h 与 string.h,主机侧可编译。
* 作者:王建锋
* 创建日期:2026-10-29
* 修改记录:
* 2026-10-29 王建锋 创建:按屏板固件 CRC16.c / MODBUS.c 对齐,并订正 CRC 字节序口径
*
* ============================================================================
* CRC 的字节序:这里有一个会让人白查一天的口径陷阱,先看这段
* ============================================================================
* 屏板固件 Middlewares/Modbus/CRC16.c 的 Calculate_CRC() 返回的**不是**教科书上那个
* CRC-16/MODBUS 值,而是它高低字节互换后的「线上字」。举实测值:
*
* 数据 "123456789" 屏板返回 0x374B CRC-16/MODBUS 标准值 0x4B37
* 数据 01 03 00 00 00 0A 屏板返回 0xC5CD 标准值 0xCDC5
*
* 于是屏板 RS485_RefreshScreenCMD() 里那句「cmdbuf[4] = crc >> 8」(注释写"CRC校验高字节")
* 实际写出去的是**标准 CRC 的低字节**——两处命名都是反的,但**线上字节是对的**:
* 上面第二帧的线上 CRC 就是 C5 CD(Modbus RTU 的低字节在前)。
*
* 结论(本模块采用的写法):
* · sb_crc16() 返回**标准 CRC-16/MODBUS 值**(可用教科书校验值 0x4B37 直接对);
* · 写帧时一律 **低字节在前**:帧 A 偏移 4 = 低、偏移 5 = 高;帧 B 偏移 4206 = 低、4207 = 高。
* 若照屏板注释的字面意思写成「高字节在前」,两侧算出的值差一次字节交换,屏板会把每一帧都
* 判成 CRC 错(它的超时判定只看"收没收到一整包"、不看 CRC,所以现场只表现为画面不刷新),
* 极易误判成波特率或时序问题。主机单测里钉了屏板算法的实测向量,改这两行会立刻红。
*
* 表:屏板用两张 8 位表(auchCRCHi / auchCRCLo),本模块用一张 16 位表(同样 512 B 常量)。
* 两者关系是可逐项验证的恒等式:s_crc_table[i] == (auchCRCLo[i] 左移 8) 或 auchCRCHi[i]
* ——即屏板把**低字节表叫 Hi、高字节表叫 Lo**,这也是上面那个反相的根源。
*/
#include <string.h>
#include "sb_proto.h"
/* ============================================================================
* CRC-16/MODBUS 查表(多项式 0xA001 反转式,初值 0xFFFF)
* 表的生成方式:对 i = 0..255,取 crc = i,做 8 次「最低位为 1 则右移后异或 0xA001,
* 否则只右移」,所得 crc 即表项。主机单测逐项复核前 8 项与末项,并复算屏板的实测向量。
* ==========================================================================*/
static const uint16_t s_crc_table[256] =
{
0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,
0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,
0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,
0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,
0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,
0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,
0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,
0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,
0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,
0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,
0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,
0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,
0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,
0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,
0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,
0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,
0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,
0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,
0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,
0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,
0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,
0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,
0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,
0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,
0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,
0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,
0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,
0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,
0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,
0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,
0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,
0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040
};
/* ============================================================================
* 私有工具
* ==========================================================================*/
/*
* 函数功能:把 CRC 写到帧尾(低字节在前,见文件头那段说明)
* 入口参数:p_frame - 帧首地址
* off - CRC 域起始偏移
* crc - sb_crc16() 返回的标准 CRC 值
* 返回值:无
* 限定条件:p_frame 至少长 off + 2。
* 函数说明:帧 A 与帧 B 共用,两处字节序必须一致——写反了屏板会把每一帧都判成 CRC 错。
*/
static void sb_put_crc(uint8_t *p_frame, uint16_t off, uint16_t crc)
{
p_frame[off] = (uint8_t)(crc & 0xFFU); /* 低字节在前 */
p_frame[off + 1U] = (uint8_t)((crc >> 8) & 0xFFU);
}
/* ============================================================================
* 公开接口实现
* ==========================================================================*/
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 (n > 0U)
{
crc = (uint16_t)((crc >> 8) ^ s_crc_table[(uint8_t)(crc ^ *p_data)]);
p_data++;
n--;
}
return crc;
}
/*
* 函数功能:解析一帧帧 A(屏板发来的 6 B 召唤 / 按键帧)
* 入口参数:p_buf - 收到的字节首地址
* len - 收到的字节数
* p_out - 解析结果输出(成功时写 cmd / key,失败时不改)
* 返回值:BSP_OK / BSP_ERR_CRC(帧被破坏,计数后丢弃)/ BSP_ERR_PARAM(不是合法召唤帧,直接丢)
* 限定条件:只认固定 6 B;先校 CRC 再看字段。
* 函数说明:细节与"为什么地址/功能码不符也报 PARAM"见 sb_proto.h 的同名声明。
*/
bsp_err_t sb_frame_a_parse(const uint8_t *p_buf, uint16_t len, sb_frame_a_t *p_out)
{
uint16_t crc;
uint8_t cmd;
if ((p_buf == 0) || (p_out == 0))
{
return BSP_ERR_PARAM;
}
if (len != SB_FRAME_A_LEN)
{
return BSP_ERR_PARAM;
}
/* 先校 CRC:字节已经乱了的话,再去解释地址/功能码没有意义 */
crc = sb_crc16(p_buf, (uint16_t)SB_FRAME_A_CRC_OFF);
if ((p_buf[SB_FRAME_A_CRC_OFF] != (uint8_t)(crc & 0xFFU)) ||
(p_buf[SB_FRAME_A_CRC_OFF + 1U] != (uint8_t)((crc >> 8) & 0xFFU)))
{
return BSP_ERR_CRC;
}
if (p_buf[0] != SB_ADDR)
{
return BSP_ERR_PARAM;
}
if (p_buf[1] != SB_FUNC_READ_HOLDING)
{
return BSP_ERR_PARAM;
}
cmd = p_buf[2];
if ((cmd != SB_CMD_KEY) && (cmd != SB_CMD_RT))
{
return BSP_ERR_PARAM;
}
p_out->cmd = cmd;
/* 周期召唤的 key 字段按协议恒为 0;真收到非 0 也按 0 处理(不为此丢一帧) */
p_out->key = (cmd == SB_CMD_KEY) ? p_buf[3] : (uint8_t)0U;
return BSP_OK;
}
/*
* 函数功能:组一帧帧 B「普通图」(点阵 + 32 位 LED 掩码)
* 入口参数:p_frame - 输出缓冲(>= SB_FRAME_B_LEN)
* p_bmp - 点阵源(>= SB_FRAME_B_BMP_LEN)
* led_mask - 状态灯位掩码,小端填进 info[0..3]
* 返回值:BSP_OK / BSP_ERR_PARAM(指针为空)
* 限定条件:整帧重写(含 998 B 填充与 CRC),调用方不必预先清零。
* 函数说明:掩码的位语义不归本模块管(见任务规划批 3)。
*/
bsp_err_t sb_frame_b_build_image(uint8_t *p_frame, const uint8_t *p_bmp, uint32_t led_mask)
{
if ((p_frame == 0) || (p_bmp == 0))
{
return BSP_ERR_PARAM;
}
/* 整帧重写:998 B 填充与 info[4..7] 一并归零,调用方不必预先清零 */
(void)memset(p_frame, 0, (size_t)SB_FRAME_B_LEN);
(void)memcpy(p_frame, p_bmp, (size_t)SB_FRAME_B_BMP_LEN);
/* info[0..3]:LED 位掩码,小端(info[0] = bit0..7) */
p_frame[SB_FRAME_B_INFO_OFF + 0U] = (uint8_t)(led_mask & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 1U] = (uint8_t)((led_mask >> 8) & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 2U] = (uint8_t)((led_mask >> 16) & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 3U] = (uint8_t)((led_mask >> 24) & 0xFFU);
sb_put_crc(p_frame, (uint16_t)SB_FRAME_B_CRC_OFF, sb_crc16(p_frame, (uint16_t)SB_FRAME_B_CRC_OFF));
return BSP_OK;
}
/*
* 函数功能:组一帧帧 B「LOGO」(点阵 + 宽高 + 魔数 0x89 0x45)
* 入口参数:p_frame - 输出缓冲(>= SB_FRAME_B_LEN)
* p_bmp - 点阵源(>= SB_FRAME_B_BMP_LEN)
* width - 图片宽度(像素),大端填进 info[0..1]
* height - 图片高度(像素),大端填进 info[2..3]
* 返回值:BSP_OK / BSP_ERR_PARAM(指针空 / 宽高为 0 / 点阵装不下)
* 限定条件:按字节数判容量(((width + 7) / 8) * height <= 3200),不是按像素数。
* 函数说明:本帧会覆盖屏板 Flash 扇区 126,发送时机由调用方决定。
*/
bsp_err_t sb_frame_b_build_logo(uint8_t *p_frame, const uint8_t *p_bmp, uint16_t width, uint16_t height)
{
uint32_t need_bytes;
if ((p_frame == 0) || (p_bmp == 0))
{
return BSP_ERR_PARAM;
}
if ((width == 0U) || (height == 0U))
{
return BSP_ERR_PARAM;
}
/* 按字节数判容量而不是像素数:161x159 只有 25599 像素,却要 21x159 = 3339 B 才装得下 */
need_bytes = ((uint32_t)width + 7U) / 8U;
need_bytes *= (uint32_t)height;
if (need_bytes > (uint32_t)SB_FRAME_B_BMP_LEN)
{
return BSP_ERR_PARAM;
}
(void)memset(p_frame, 0, (size_t)SB_FRAME_B_LEN);
(void)memcpy(p_frame, p_bmp, (size_t)SB_FRAME_B_BMP_LEN);
/* info[0..1] 宽、info[2..3] 高,均大端 */
p_frame[SB_FRAME_B_INFO_OFF + 0U] = (uint8_t)((width >> 8) & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 1U] = (uint8_t)(width & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 2U] = (uint8_t)((height >> 8) & 0xFFU);
p_frame[SB_FRAME_B_INFO_OFF + 3U] = (uint8_t)(height & 0xFFU);
/* info[4..5] LOGO 魔数;info[6..7] 保留(已归零) */
p_frame[SB_FRAME_B_INFO_OFF + 4U] = SB_LOGO_MAGIC_HI;
p_frame[SB_FRAME_B_INFO_OFF + 5U] = SB_LOGO_MAGIC_LO;
sb_put_crc(p_frame, (uint16_t)SB_FRAME_B_CRC_OFF, sb_crc16(p_frame, (uint16_t)SB_FRAME_B_CRC_OFF));
return BSP_OK;
}
/*
* 函数功能:自校验一帧帧 B(重算 CRC 并与帧尾比对)
* 入口参数:p_frame - 待校验帧首地址
* len - 帧长度,必须等于 SB_FRAME_B_LEN
* 返回值:BSP_OK / BSP_ERR_CRC(不一致)/ BSP_ERR_PARAM(指针空或长度不对)
* 限定条件:只验 CRC,不检查填充段是否全 0、也不检查掩码位语义。
* 函数说明:供组帧路径自查(2-4 的「组帧自校验 100%」接这里)。
*/
bsp_err_t sb_frame_b_verify(const uint8_t *p_frame, uint16_t len)
{
uint16_t crc;
if (p_frame == 0)
{
return BSP_ERR_PARAM;
}
if (len != SB_FRAME_B_LEN)
{
return BSP_ERR_PARAM;
}
crc = sb_crc16(p_frame, (uint16_t)SB_FRAME_B_CRC_OFF);
if ((p_frame[SB_FRAME_B_CRC_OFF] != (uint8_t)(crc & 0xFFU)) ||
(p_frame[SB_FRAME_B_CRC_OFF + 1U] != (uint8_t)((crc >> 8) & 0xFFU)))
{
return BSP_ERR_CRC;
}
return BSP_OK;
}
+166
View File
@@ -0,0 +1,166 @@
/*
* 模块名称:屏板链路协议层(帧 A 解析 / 帧 B 组帧 / CRC-16)
* 模块功能:把「主机与屏板之间那两种帧」的编解码收成一处纯逻辑:
* - CRC-16/MODBUS(表与屏板固件逐字节一致,见 Drivers/BSP/ScreenLink/sb_proto.c)
* - 帧 A(屏板 → 主机,固定 6 B)解析:校验后吐出「命令类型 + 按键值」
* - 帧 B(主机 → 屏板,固定 4208 B)组帧:普通图(LED 掩码)/ LOGO(宽高 + 魔数)
* - 帧 B 自校验(组完立刻能验,供组帧路径自查)
* 本模块**不碰寄存器、不碰 DMA、不碰任务**:串口怎么配、谁来发、什么时候发都不归它管
* (那是批 1 的 1-2 / 1-3 与批 2 的事)。
* 适用平台:STM32F4 系列(ARMCC v5);不依赖 HAL,主机侧可直接编译(故可主机单测)。
* 作者:王建锋
* 创建日期:2026-10-29
* 修改记录:
* 2026-10-29 王建锋 创建:按屏板固件 D:\Code\DTU Code\DTU-LCD 的 CRC16.c / MODBUS.c 逐字节对齐
*
* 协议事实源:屏板固件工程(不在本仓库)
* Middlewares/Modbus/CRC16.c —— 两张 256 字节查表 + Calculate_CRC() / Check_CRC()
* Middlewares/Modbus/MODBUS.c —— RS485_RefreshScreenCMD()(帧 A 组帧,6 B)
* RS485_Process()(帧 B 解析:3200 点阵 + 8 字节 info + CRC 在帧尾)
* Drivers/BSP/RS485/rs485.h —— UART_RX_LEN = 4208(帧 B 的线上长度,注释写 3208 是错的)
* 需求侧口径见 docs/产品需求/产品需求设计文档.md §5.16.2(帧结构)与 §5.16.3(按键枚举)。
*
* ⚠ 两个长度别混:屏板 `BMP_LEN` 的 3208 是**写 Flash 的记录长度**(点阵 3200 + 宽 4 + 高 4),
* 不是帧长;帧长以 `Check_CRC(..., UART_RX_LEN)` 的 **4208** 为准。本模块只认 4208。
*/
#ifndef SB_PROTO_H
#define SB_PROTO_H
#include <stdint.h>
#include "bsp_err.h" /* bsp_err_t / BSP_OK / BSP_ERR_*:本模块只报统一错误码 */
#ifdef __cplusplus
extern "C" {
#endif
/* ============================================================================
* 一、帧长与字段偏移(单一出处,别在调用方另抄一份)
* ==========================================================================*/
/* 帧 A:屏板 → 主机,固定 6 B。CRC 只覆盖前 4 B,高字节在前。 */
#define SB_FRAME_A_LEN (6U)
#define SB_FRAME_A_CRC_OFF (4U)
/* 帧 B:主机 → 屏板,固定 4208 B。CRC 覆盖 [0, 4205],高字节在前。 */
#define SB_FRAME_B_LEN (4208U)
#define SB_FRAME_B_BMP_LEN (3200U) /* 点阵:160 行 x 20 B/行,1 bpp,MSB 在前 */
#define SB_FRAME_B_INFO_LEN (8U) /* info[0..7] */
#define SB_FRAME_B_INFO_OFF (3200U)
#define SB_FRAME_B_PAD_OFF (3208U) /* 保留填充起点 */
#define SB_FRAME_B_PAD_LEN (998U) /* [3208, 4205]:屏板不读,但参与 CRC,须固定填 0 */
#define SB_FRAME_B_CRC_OFF (4206U)
/* 帧 A 的固定字段值 */
#define SB_ADDR (0x01U) /* 从站地址:**主机的地址**,不是屏板的 */
#define SB_FUNC_READ_HOLDING (0x03U) /* 功能码:读保持寄存器 */
#define SB_CMD_KEY (0x00U) /* 命令类型:按键事件(立即召唤) */
#define SB_CMD_RT (0x01U) /* 命令类型:周期刷新 */
/* 帧 B 的 LOGO 魔数(info[4] / info[5])
* ⚠ 待删除(用户裁定 2026-10-29):LOGO 功能出局,不下发、不接线;本组
* LOGO 常量与 sb_frame_b_build_logo() 保留仅为协议层完整性,**勿新增调用**。 */
#define SB_LOGO_MAGIC_HI (0x89U)
#define SB_LOGO_MAGIC_LO (0x45U)
/* 屏幕几何:LOGO 的宽高要在这一片点阵装得下 */
#define SB_SCREEN_W (160U)
#define SB_SCREEN_H (160U)
/* ============================================================================
* 二、帧 A 的解析结果
* ==========================================================================*/
/* 解析成功后的有效字段。addr / func 恒为 SB_ADDR / SB_FUNC_READ_HOLDING,
* 不再回吐(校验不过直接报错,能走到这里就一定是这两个值)。 */
typedef struct
{
uint8_t cmd; /* SB_CMD_KEY / SB_CMD_RT */
uint8_t key; /* cmd == SB_CMD_KEY 时有效;cmd == SB_CMD_RT 时恒为 0(见 sb_frame_a_parse 说明) */
} sb_frame_a_t;
/* ============================================================================
* 三、公开接口
* ==========================================================================*/
/*
* 函数功能:计算 CRC-16/MODBUS(多项式 0xA001 反转式、初值 0xFFFF、查表法)
* 入口参数:p_data - 待校验数据首地址
* len - 参与校验的字节数(不含 CRC 本身)
* 返回值:16 位**标准 CRC-16/MODBUS 值**(教科书校验值:数据 "123456789" → 0x4B37)
* 限定条件:p_data 必须非空且 len >= 1;本函数是纯算术,不做空指针检查(与相量系纯算法函数同口径)。
* 写进帧里一律**低字节在前**——屏板固件那个同名函数返回的是高低互换后的「线上字」,
* 两处命名都是反的,但线上字节是对的。陷阱与实测向量见 sb_proto.c 文件头,改前务必看。
* 函数说明:查表法而非逐位法。帧 B 一次要算 4206 B:逐位法约 13 万拍(毫秒量级),
* 查表法约 2.5 万拍(0.15 ms 量级,168 MHz 估算),后者才留得住 2-1 的 0.3 ms
* 组帧预算——而那份预算还要分给 3200 B 的点阵拷贝,2-1 实测时两者要一起算。
*/
uint16_t sb_crc16(const uint8_t *p_data, uint16_t len);
/*
* 函数功能:解析一帧帧 A(屏板发来的 6 B 召唤 / 按键帧)
* 入口参数:p_buf - 收到的字节首地址
* len - 收到的字节数(空闲中断断出来的实际长度)
* p_out - 解析结果输出(成功时写 cmd / key,失败时不改)
* 返回值:BSP_OK 解析成功,*p_out 有效
* BSP_ERR_CRC CRC 不符:帧被破坏,应计数后静默丢弃(不要回任何东西)
* BSP_ERR_PARAM 不是一个合法的召唤帧:指针空 / len != 6 / 地址不是 0x01 /
* 功能码不是 0x03 / 命令类型不是 0 或 1 —— 丢弃即可,不必计数
* 限定条件:只认固定 6 B 的帧 A;屏板的帧 B 应答方向相反,不经过本函数。
* 函数说明:
* 1. **先校 CRC 再看字段**:CRC 不过说明字节已经乱了,再去解释地址/功能码没有意义。
* 2. 地址与功能码不符也报 BSP_ERR_PARAM 而不是新造错误码——bsp_err.h 的码是跨模块的
* 「能不能重试」语义,不是协议细节;对调用方而言这几种都等于「这不是给本机的合法召唤帧」。
* 要区分细节就比对原始字节(屏板这两个字段是写死的,出现不符说明已经错位)。
* 3. cmd == SB_CMD_RT 时若 key 非 0 **不报错**,按 0 忽略:屏板的周期召唤本应带 0,
* 但为这一项丢一帧就等于丢一次刷新(280 ms 的画面不动),代价不对等。
*/
bsp_err_t sb_frame_a_parse(const uint8_t *p_buf, uint16_t len, sb_frame_a_t *p_out);
/*
* 函数功能:组一帧帧 B「普通图」(点阵 + 32 位 LED 掩码)
* 入口参数:p_frame - 输出缓冲,长度必须 >= SB_FRAME_B_LEN
* p_bmp - 点阵源,长度必须 >= SB_FRAME_B_BMP_LEN(160x160 @1bpp)
* led_mask - 状态灯位掩码,小端填进 info[0..3](info[0] = bit0..7)
* 返回值:BSP_OK 组帧完成,整帧(含 CRC)已就绪
* BSP_ERR_PARAM 指针为空
* 限定条件:p_frame 会被**整帧重写**(含 998 B 填充与 CRC),调用方不必预先清零;
* 组好的帧在下次调用前有效,本模块不持有任何缓冲。
* 函数说明:填充段固定填 0x00(屏板不读,但参与 CRC)。掩码的位语义不归本模块管
* (bit0 恒 0 / bit20~31 恒 0 这类业务约束属状态灯映射表,见任务规划批 3)。
*/
bsp_err_t sb_frame_b_build_image(uint8_t *p_frame, const uint8_t *p_bmp, uint32_t led_mask);
/*
* 函数功能:组一帧帧 B「LOGO」(点阵 + 宽高 + 魔数 0x89 0x45)
* 入口参数:p_frame - 输出缓冲,长度必须 >= SB_FRAME_B_LEN
* p_bmp - 点阵源,长度必须 >= SB_FRAME_B_BMP_LEN
* width - 图片宽度(像素),大端填进 info[0..1]
* height - 图片高度(像素),大端填进 info[2..3]
* 返回值:BSP_OK 组帧完成
* BSP_ERR_PARAM 指针为空 / 宽高为 0 / 超出 160x160 点阵容量
* 限定条件:宽高必须满足 ((width + 7) / 8) * height <= 3200 —— 屏板拿这两个数直接显示,
* 装不下的宽高会让它读越 3200 B 的点阵缓冲,故本函数按字节数而非像素数判容量。
* 函数说明:LOGO 帧会把屏板 Flash 0x0803F000(扇区 126)整块覆盖,且屏板不校验是否同一张图;
* 本模块只负责把帧组对,**什么时候发、能不能重复发由调用方决定**。
*/
bsp_err_t sb_frame_b_build_logo(uint8_t *p_frame, const uint8_t *p_bmp, uint16_t width, uint16_t height);
/*
* 函数功能:自校验一帧帧 B(重算 CRC 并与帧尾比对)
* 入口参数:p_frame - 待校验帧首地址
* len - 帧长度,必须等于 SB_FRAME_B_LEN
* 返回值:BSP_OK CRC 与帧尾一致
* BSP_ERR_CRC 不一致(帧被改写 / 组帧时漏算)
* BSP_ERR_PARAM 指针空或 len 不是 4208
* 限定条件:只验 CRC,不检查填充段是否全 0、也不检查掩码位语义。
* 函数说明:给组帧路径自查用(2-4 的「组帧自校验 100%」直接接这里)。
*/
bsp_err_t sb_frame_b_verify(const uint8_t *p_frame, uint16_t len);
#ifdef __cplusplus
}
#endif
#endif /* SB_PROTO_H */
+16 -1
View File
@@ -337,7 +337,7 @@
<MiscControls>--diag_suppress=1,68,111,188,191,546,1293</MiscControls>
<Define>USE_HAL_DRIVER,STM32F407xx,NDEBUG</Define>
<Undefine />
<IncludePath>../Inc;../Drivers/STM32F4xx_HAL_Driver/Inc;../Drivers/STM32F4xx_HAL_Driver/Inc/Legacy;../Drivers/CMSIS/Device/ST/STM32F4xx/Include;../Drivers/CMSIS/Include;../Drivers/BSP;../Drivers/BSP/CH395F;../Drivers/BSP/GD5F2GQ5UE;../Drivers/BSP/TPAFE5160;../Drivers/BSP/SD2506;../Drivers/BSP/RS485;../Drivers/BSP/NET;../Drivers/BSP/NET/lftpd;../Drivers/BSP/NETCFG;../App;../App/app;../App/algo;../App/cfg;../App/fs;../App/log;../App/sync;../App/task;../App/protocol/iec104;../App/util;../Middlewares/Third_Party/FreeRTOS/Source/include;../Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2;../Middlewares/Third_Party/FreeRTOS/Source/portable/RVDS/ARM_CM4F;../Lib/dhara;../Lib/FatFs;../test/board;../Lib/lvgl;../Drivers/BSP/UI;../Lib</IncludePath>
<IncludePath>../Inc;../Drivers/STM32F4xx_HAL_Driver/Inc;../Drivers/STM32F4xx_HAL_Driver/Inc/Legacy;../Drivers/CMSIS/Device/ST/STM32F4xx/Include;../Drivers/CMSIS/Include;../Drivers/BSP;../Drivers/BSP/CH395F;../Drivers/BSP/GD5F2GQ5UE;../Drivers/BSP/TPAFE5160;../Drivers/BSP/SD2506;../Drivers/BSP/RS485;../Drivers/BSP/ScreenLink;../Drivers/BSP/NET;../Drivers/BSP/NET/lftpd;../Drivers/BSP/NETCFG;../App;../App/app;../App/algo;../App/cfg;../App/fs;../App/log;../App/sync;../App/task;../App/protocol/iec104;../App/util;../Middlewares/Third_Party/FreeRTOS/Source/include;../Middlewares/Third_Party/FreeRTOS/Source/CMSIS_RTOS_V2;../Middlewares/Third_Party/FreeRTOS/Source/portable/RVDS/ARM_CM4F;../Lib/dhara;../Lib/FatFs;../test/board;../Lib/lvgl;../Drivers/BSP/UI;../Lib</IncludePath>
</VariousControls>
</Cads>
<Aads>
@@ -1052,6 +1052,11 @@
<FileType>1</FileType>
<FilePath>..\App\algo\freq.c</FilePath>
</File>
<File>
<FileName>sb_led_map.c</FileName>
<FileType>1</FileType>
<FilePath>..\App\algo\sb_led_map.c</FilePath>
</File>
<File>
<FileName>wave_ring.c</FileName>
<FileType>1</FileType>
@@ -1633,6 +1638,16 @@
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\RS485\rs485.c</FilePath>
</File>
<File>
<FileName>sb_proto.c</FileName>
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\ScreenLink\sb_proto.c</FilePath>
</File>
<File>
<FileName>sb_link.c</FileName>
<FileType>1</FileType>
<FilePath>..\Drivers\BSP\ScreenLink\sb_link.c</FilePath>
</File>
<File>
<FileName>net_select.c</FileName>
<FileType>1</FileType>
+5 -3
View File
@@ -214,9 +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)。
* 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 */
+19 -2
View File
@@ -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 */
}
+1
View File
@@ -93,6 +93,7 @@ docs/
| [`ADC采样节拍设计说明.md`](子系统设计/ADC采样节拍设计说明.md) | **ADC 采集链路采样节拍(现状说明)**:TIM3 1 kHz 更新事件同时触发 TPAFE CONVST 与(经 TRGO)STM32 ADC1 + DMA 乒乓缓冲;节拍源为什么是 TIM3、DMA 硬约束、事件驱动消费与就绪门、实现落点、观测计数、性能特征与设计边界 |
| [docs/子系统设计/故障录波设计说明.md](子系统设计/故障录波设计说明.md) | **故障录波设计与口径属主**:1 kHz 起步与 80 点口径的偏离登记、存原始码值的理由、四段所有权与四条并发陷阱、段文件字节布局、现场单通道回放显示契约、预算与 R6 前置 |
| [`IEC104上行设计说明.md`](子系统设计/IEC104上行设计说明.md) | **B4 上行规约**:IEC 60870-5-104 被控站(TCP 2404)范围/点表(V0.1 有效子集)/链路流程(k/w/t1~t3、总召、COV、遥控两段式、定值)/netTask 接入决策/预算与测试方案;协议核心纯逻辑 + 主机侧纯逻辑单测全栈单测 |
| [`屏板链路设计说明.md`](子系统设计/屏板链路设计说明.md) | **屏板串口链路(`Drivers/BSP/ScreenLink/`)实现记录**:一句话架构与数据流、`sb_proto`/`sb_link` 全部对外接口、八条关键实现决定(不留影子缓冲/busy 冻结/中断起发等)、资源占用实测(`s_frame` 落 RW_IRAM1 非 CCM)、单测与台架/真屏板冒烟证据链、接线教训(排针丝印视角陷阱)、坑账本、**尚未实测项清单**;任务分解与协议事实源仍在 `docs/任务规划/屏板链路.md` |
| [`网络/BSD_Socket_API_使用指南.md`](网络/BSD_Socket_API_使用指南.md) | **上层视图**:BSD Socket API 公开语义、示例、常见问题 |
| [`网络/BSD_Socket_API_函数调用关系.md`](网络/BSD_Socket_API_函数调用关系.md) | **内部视图**:netTask / 消息队列 / 保护判据挂起 / 重试对账的调用关系与设计意图 |
| [`网络/Net层阻塞模型架构设计.md`](网络/Net层阻塞模型架构设计.md) | **等待模型**:recv/send 挂起请求架构、数据结构与挂起槽状态机、recv 五路 + send 四路完成语义、模块接口契约(14 函数)、实现落点(20 集成点)、调用方契约、设计边界与性能特征 |
+27 -19
View File
@@ -1058,9 +1058,9 @@ sequenceDiagram
>
> ORE 锁存的充分条件与接线无关——**RX 使能了却从不读 `DR`**:线上只要出现
> 任意一个字节或一个噪声沿,`RXNE` 置位后没人读,下一个事件就把 **ORE 锁存**;而 ORE 只能用
> "先读 SR 再读 DR"清除,于是**永不自愈**。这条对本实现有两条硬要求:
> ① 屏板链路要用 RX,就必须真的消费 `DR`,并在 `HAL_UART_ErrorCallback` 里**清 ORE 后重挂接收**
> (否则一次噪声就永久锁死,症状与"屏板不回帧"一模一样);② 新日志口设 `UART_MODE_TX`,从机制上把 RX 摘掉。
> "先读 SR 再读 DR"清除,于是**永不自愈**。这条对本实现有一条硬要求:
> 屏板链路要用 RX,就必须真的消费 `DR`,并在 `HAL_UART_ErrorCallback` 里**清 ORE 后重挂接收**
> (否则一次噪声就永久锁死,症状与"屏板不回帧"一模一样)。
**帧 A:召唤 / 按键(屏板 → 主机,固定 6 B)**
@@ -1068,13 +1068,21 @@ sequenceDiagram
|---|---|---|---|
| 0 | 1 | 从站地址 | 固定 `0x01`(**主机的从站地址**,不是屏板地址) |
| 1 | 1 | 功能码 | 固定 `0x03`(读保持寄存器) |
| 2 | 1 | 命令类型 | `0x00` = `KEY_CMD`(按键事件,**立即**召唤)<br>`0x01` = `RT_CMD`(周期刷新,275 ms 一次) |
| 2 | 1 | 命令类型 | `0x00` = `KEY_CMD`(按键事件,**立即**召唤)<br>`0x01` = `RT_CMD`(周期刷新,**280 ms** 一次:`RT_count > 55`,第 56 拍 × 5 ms) |
| 3 | 1 | 按键值 | `KEY_CMD` 时有效(见 §5.16.3 键值表);`RT_CMD` 时**恒为 `0`**(按键发完即清 `key_bk`,见 `RS485_Process()`) |
| 4 | 1 | CRC-16 **高**字节 | CRC-16/MODBUS |
| 5 | 1 | CRC-16 **低**字节 | 同上 |
| 4 | 1 | CRC-16 **低**字节 | CRC-16/MODBUS |
| 5 | 1 | CRC-16 **高**字节 | 同上 |
CRC-16/MODBUS(多项式 `0xA001` 反转式、初值 `0xFFFF`、查表法 `auchCRCHi[]`/`auchCRCLo[]`),
**只覆盖前 4 字节**,**高字节在前**(`cmdbuf[4] = crc>>8`、`cmdbuf[5] = crc`)。
CRC-16/MODBUS(多项式 `0xA001` 反转式、初值 `0xFFFF`、查表法),
**只覆盖前 4 字节**,**低字节在前**——这是标准 Modbus RTU 的线上顺序(教科书校验值:数据 `123456789`
→ `0x4B37`,线上写 `37 4B`)。
> ⚠ **屏板源码里的字节序注释是反的,别照抄**:`Calculate_CRC()` 返回的**不是**标准 CRC 值,而是高低
> 互换后的「线上字」(`123456789` 它返回 `0x374B`)。于是 `RS485_RefreshScreenCMD()` 里那句
> `cmdbuf[4] = crc>>8`(注释写"CRC校验高字节")实际写出去的是**低字节**——两处命名都反,**线上字节是对的**。
> 主机侧按「标准值 + 低字节在前」实现,两侧字节逐一相同;若照字面写成"高字节在前",屏板会把每一帧
> 都判成 CRC 错,而它的超时判定只看"收没收到一整包"、不看 CRC,现场只表现为画面不刷新,
> 极易误判成波特率或时序问题。实测向量与实现见 `Drivers/BSP/ScreenLink/sb_proto.c` 文件头。
**帧 B:图像应答(主机 → 屏板,固定 4208 B)**
@@ -1086,7 +1094,7 @@ CRC-16/MODBUS(多项式 `0xA001` 反转式、初值 `0xFFFF`、查表法 `auch
| 3205 | 1 | `info[5]` | LOGO 魔数低字节 `0x45` |
| 3206 | 2 | `info[6..7]` | 保留,屏板不读 |
| 3208 | 998 | 保留填充 | 屏板不读,**但参与 CRC 计算**,须固定填充(建议 `0x00`) |
| 4206 | 2 | CRC-16 | 覆盖 `[0, 4205]`,**高字节在前** |
| 4206 | 2 | CRC-16 | 覆盖 `[0, 4205]`,**低字节在前**(与帧 A 同一口径,见上方的字节序说明) |
> ⚠ **帧长以代码为准:4208 B**(屏板 readme 与 `BMP_LEN` 写的"3208 字节"不作准):接收侧实参是
> `Check_CRC(DR, UART_RX_LEN)`,`UART_RX_LEN = 4208` → CRC 取 `DR[4206]/DR[4207]`、覆盖 `DR[0..4205]`。
@@ -1144,15 +1152,12 @@ ISR 抢占,一抢就撞第 1 条,而后果在屏板侧完全不可见。时
(`U15 = TP8485E-SR` 只有 250 kbps,`docs/芯片手册/TP8485E-SR.md`)。换到这条线上,速率问题从根上消失,
也不用改任何硬件。UART4 保持 RS-485 冻结簇不动——`rs485_init(&g_rs485, &huart4, …)` 与 V0.1 一致,**无需摘除**。
**这次重排带来两件事,必须在任务里体现:**
**这次重排带来一件事,必须在任务里体现:**
1. **日志口迁移不是一行改动**:`App/log/console_log.c` 里 `huart1` 出现 5 处(含
`huart1.Instance == NULL` 的空指针短路判据),自愈函数 `con_uart_reinit()` 还直接写
`USART1_IRQn`、`__HAL_RCC_USART1_CLK_*`、`MX_USART1_UART_Init()`。建议收口成**一处可配置端口宏**,
而不是逐处散改。
2. **新日志口必须设成只发不收**(`UART_MODE_TX`):USART1 控制台之所以要背一整套僵尸通道自愈,
根源就是"RX 悬空 + RX 使能 → ORE 锁存 → HAL 轮询 TX 恒快速失败"。日志口不需要收,
就别再开 RX,否则等于把同一场病搬到新口上。
> ⚠ **TTL 直连没有方向控制**:主机侧不再有半双工方向切换,建立时间、"等 TC 收尾"一类约束全部不适用。
@@ -1483,7 +1488,7 @@ for (i = 1; i < LED_COUNT; i++) /* LE
> 逐项差异、以及为什么会有差异,见 §5.16.9。**以参考为准**,差异项按 §9.13 排期收敛。
>
> 本节只讲**网络**通道(TCP 7003)。**串口屏板通道不走本协议**——它是 RS-485 上的
> **Modbus RTU 风格裸二进制帧**(6 B 召唤 + **4208 B** 图像应答,CRC-16/MODBUS 高字节在前),
> **Modbus RTU 风格裸二进制帧**(6 B 召唤 + **4208 B** 图像应答,CRC-16/MODBUS **低字节在前**),
> 帧格式已按屏板源码逐字节定版,见 §5.16.2。
> 两条通道**共用同一份显存**,但帧格式、校验、时序、主从方向全都不同,**不能共用组帧代码**。
@@ -2543,12 +2548,12 @@ docs/任务规划/UI补齐.md §5);原「当前执行 6 项」(记录 5
| 8 | **发送重试评估** | 对比参考"BUSY + 200 ms 放弃"与现有 `net_send` 阻塞模型,出结论并回填 §5.16.9 第 7 行 | 结论有实测数据支撑(实现 / 明确不实现) | 否 |
| 9 | **客户端一致性回归** | `DTU-RemoteLCD` 按 **16 B** 解析;把 §5.16.10 第 1~7 条做成自动化用例 | 七条判据全 PASS | **是** |
| 10 | **串口物理层落地** | 按 §5.16.2 定版:**700000 8N1、`BRR = 0x0078`**(USART1,APB2 84 MHz)、**无 DE**(TTL 直连,不走收发器)、RX 缓冲 16 B + 空闲中断断帧、发送走 **DMA 一次性发完**(禁逐块阻塞,`DMA2_Stream7` TX / `DMA2_Stream2` RX 已在 `.ioc` 配好但控制台未用) | 环回 + 示波器实测 700 kbaud 分频零误差;屏板能收到帧 A 并回帧 B | 否 |
| 11 | **帧 A 解析 / 帧 B 组帧** | 新增屏板通道模块:识别 6 B 请求(`01 03 type key CRC_H CRC_L`)、校验 CRC-16/MODBUS(**高字节在前**);组 **4208 B** 帧 B(3200 显存 + 4 B LED + 4 B info 保留 + 998 B 填充 + 2 B CRC) | 屏板出图与 `g_ui_fbuf` 逐字节一致;`info[0..3]` 与下发的 LED 掩码一致 | 否 |
| 11 | **帧 A 解析 / 帧 B 组帧** | 新增屏板通道模块:识别 6 B 请求(`01 03 type key CRC_L CRC_H`)、校验 CRC-16/MODBUS(**低字节在前**,口径见 §5.16.2);组 **4208 B** 帧 B(3200 显存 + 4 B LED + 4 B info 保留 + 998 B 填充 + 2 B CRC) | 屏板出图与 `g_ui_fbuf` 逐字节一致;`info[0..3]` 与下发的 LED 掩码一致 | 否 |
| 12 | **状态灯位映射表** | 定义"**业务状态 → 屏板 LED 位**"映射(参考源码**无此表**,须自定);`info[0..3]` 按 **32 位小端**填,bit0 恒 0、bit20~31 恒 0 | §5.16.10 第 11 条通过 | 否 |
| 13 | **串口上行按键** | 帧 A 的 `key` 字节按**屏板 `KEY_TYPE`** 解释——实际可达值只有 **`1/2/3/4/7/8/9`**(`5`/`6`/`10` 无物理入口);映射为逻辑键值后与 7003 注入**同一入口**合并 | §5.16.10 第 10 条通过;`9`/`10` 语义按 §5.16.3 ② 处置 | 否 |
| 14 | **双通道并发 + 自复位容忍** | 两出口各自独立发送上下文;串口 4208 B 阻塞不拖 7003;链路监视按"**连续 ≥10 s 无应答**"判离线,**忽略屏板 ≈5000 s 周期自复位** | §5.16.10 第 12、13、14 条通过 | 否 |
| 15 | **LOGO 下发流程** | 一次性下发 LOGO 帧(`info[4..5] = 0x89 0x45`,宽高大端);放在出厂 / 联调流程,**不进常规刷新循环** | §5.16.10 第 15 条通过;断电重启后 LOGO 仍在 | 否 |
| 16 | **日志口迁移(USART1 → RS-232 通道)** | ① `console_log.c` 的 `huart1`、`USART1_IRQn`、`__HAL_RCC_USART1_CLK_*`、`MX_USART1_UART_Init()` 收口成**一处端口宏**,指向 RS232#3(`huart2` / USART2,默认)或 RS232#1(`huart5` / UART5);② 该实例改 **`UART_MODE_TX`**(只发不收,避免重演 ORE 锁存);③ **不动** UART4 的 `g_rs485` 绑定 | 日志在 RS-232 侧(CN1)可读且内容与迁移前一致;USART1 **完全空出**;连续 10 分钟无 ORE 锁存、无 `[ERR]` | 否 |
| 16 | **日志口迁移(USART1 → RS-232 通道)** | ① `console_log.c` 的 `huart1`、`USART1_IRQn`、`__HAL_RCC_USART1_CLK_*`、`MX_USART1_UART_Init()` 收口成**一处端口宏**,指向 RS232#1(`huart5` / UART5);② **不动** UART4 的 `g_rs485` 绑定 | 日志在 RS-232 侧(CN1)可读且内容与迁移前一致;USART1 **完全空出**;连续 10 分钟无 `[ERR]` | 否 |
**批次划分**:
@@ -2574,9 +2579,12 @@ docs/任务规划/UI补齐.md §5);原「当前执行 6 项」(记录 5
> 第 11 条的帧长仍有一处待实测复核——代码实参是 **4208 B**,屏板 readme 写 **3208 B**(§5.16.2);
> 台架抓到第一帧真实报文后**立即固化**,在此之前以代码实参为准,不得按 readme 实现。
>
> **一条物理前置 + 一条实现纪律**:
> ① **台架串口线挪位**——现在的 CH340/`COM6` 接在 USART1 调试口上,日志迁到 RS-232 侧后
> 必须改接到 CN1(或 U14),否则 `@test` 与 `serial-assistant` 全部抓不到日志,等于把台架弄瞎。
> **一条物理前置(已完成 2026-10-29)+ 一条实现纪律**:
> ① **台架串口线已挪位**——日志口迁到 RS-232 侧后,台架那根线改接 CN1 的 RS232#1 端子
> (`CN1.10` 接适配器 TXD、`CN1.12` 接适配器 RXD、`CN1.7` 或 `CN1.8` 接 GND),台架日志口
> 因此由 `COM6` 变为 **`COM3`**,`.serialtest/config.json` 的 `portName` 已同步,`@test` 与
> `serial-assistant` 不必再手工指端口。这些端子是 **RS-232 电平**,TTL 的 CH340 直连不了,
> 用的是 USB 转 RS-232 适配器;接法与端子出处见 `docs/任务规划/屏板链路.md` §3 批 0。
> **这条要在第 16 条动代码之前完成。**
> ② **RX 要真的读、ORE 要真的清**——PA10 一直是接线的(§5.16.2),
> 但"RX 使能却不读 `DR`"本身就足以让 ORE 永久锁存。第 11 条实现时必须在
+142 -46
View File
@@ -12,15 +12,20 @@
## 0. 先看结论:裁定已定,三条流程前置顶上来
**2026-10-28 用户裁定:屏板走 USART1,日志口让位给 RS-232 通道。** 700 kbaud 只有 USART1 这一条 MCU
引脚直出的通道吃得到(其余串口都要过板上的收发器);代价是换成几条**流程**前置——都不是软件能解决的。
引脚直出的通道吃得到(其余串口都要过板上的收发器);代价是换成几条**流程**前置——都不是软件能解决的
(①②已于 2026-10-29 解除,见 §3 批 0)。
| # | 项 | 实测 / 出处 | 后果 |
|---|---|---|---|
| 1 | **日志口占着 USART1** | `console_log.c` 里 `huart1` 5 处;自愈函数直写 `USART1_IRQn` / `MX_USART1_UART_Init()` | 两者共用同一个 `USART1->DR`,同时用就是互相插字节 → **日志必须先迁到 RS-232 通道** |
| 2 | **台架串口线接在 USART1 上** | CH340 → `COM6`,`@test` / `serial-assistant` 都靠它抓日志 | 日志一迁,台架立刻瞎 → **先把线挪到 RS-232 侧(CN1)再动代码** |
| 2 | **台架串口线接在 USART1 上** | TTL 的 CH340 → `COM6`,`@test` / `serial-assistant` 都靠它抓日志 | 日志一迁,台架立刻瞎 → **先把线挪到 RS-232 侧(CN1)再动代码**;且这根 TTL 模块挪不过去,须换 **USB 转 RS-232 适配器**(见 §2 决策二) |
**执行顺序因此是**:挪台架线 → 迁日志口 → 才开始写屏板链路。
第一步是拧螺丝,第二步约 20 行代码(§3 批 0),第三步才是屏板链路的开发批次。
前两步**已于 2026-10-29 完成**(§3 批 0 的 0-1 / 0-2:台架线接 CN1 的 RS232#1,日志口迁到 UART5,
台架串口由 `COM6` 变成 `COM3`,`.serialtest/config.json` 已同步);第三项 0-3 的接法也已落纸。
**批 0 / 批 1 / 批 2 均已收尾(2026-10-29):USART1 在 700 kbaud 上收发双向通了(收 6 B 帧 A、
回 4208 B 帧 B)。批 3 按用户裁定(2026-10-29)缩为两项:状态灯映射表 + 下发;
**LOGO 功能出局**(不实现,`sb_proto` 里既有 LOGO 组帧函数列为待删除)。**
---
@@ -71,9 +76,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**。
> **三个收尾回调全工程都没人实现**:`HAL_UART_TxCpltCallback` / `HAL_UART_RxCpltCallback` 零实现,
> `HAL_UART_ErrorCallback` 只有一处、且只分发另一条通道的句柄(定位:全库搜这三个名字)→ 本链路要**自写这三个、
> 按 `huart` 句柄分发**,错误分支里顺手清掉 USART1 的 ORE / NE / FE 再重挂接收(与 1-3 同一手法)。
> **三个收尾回调的现状(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` 核对)。
@@ -88,22 +98,69 @@
| 项 | 结论 |
|---|---|
| 端口 | **USART1**——唯一一条 MCU 引脚直出 TTL、不经收发器的通道,同时引到 U5 子板连接器 |
| 波特率 | 700000 8N1,`BRR = 0x0078`(APB2 84 MHz → `84 000 000 / (16 × 700 000) = 7.5`,误差 0 %) |
| 波特率 | 700000 8N1,`BRR = 0x0078`(APB2 84 MHz → `84 000 000 / (16 × 700 000) = 7.5`,误差 0 %)。**2026-10-29 开机日志实测就是 `0x0078`**(1-2) |
| 方向控制 | **不需要**——TTL 直连是 TX / RX 两根独立线(全双工),没有方向切换 |
| 剩余子决策 | **USART1 那端怎么接**:TTL 直连屏板 UART2 侧(PA2/PA3,绕过屏板自己的收发器)。**台架按这条做**;产品形态的线束长度与隔离需求届时单独评估 |
**接法与验收(0-3 的交付物,2026-10-29 落纸)**
主机侧取 U5 子板连接器上的两个网络(出处 `docs/硬件平台/V0.1.tel`:`ST_TX0 ; U5.A10 U17.101`、
`ST_RX0 ; U5.B10 U17.102`;`U17` 就是 MCU 本体,101/102 = PA9/PA10):
| 主机侧(V0.1) | 屏板侧 | 方向 |
|---|---|---|
| `U5.A10`(网络 `ST_TX0`,PA9) | 屏板 `PA3`(屏板 USART2_RX) | 主机发 → 屏板收 |
| `U5.B10`(网络 `ST_RX0`,PA10) | 屏板 `PA2`(屏板 USART2_TX) | 屏板发 → 主机收 |
| 板地(`CGND`,或板上任一 GND 脚) | 屏板 `GND` | 共地,必须接 |
> ⚠ **本表的"屏板侧 PA 编号"与 2026-10-29 真屏板首通实测相反**:照表接 `rx` 彻底静默,
> **两根信号线对调后立即通**。表里"TX 对 RX"的方向逻辑没错,错的是屏板排针对 PA2/PA3
> 谁是发的假定(未经屏板方确认)。**实操以排针丝印 + 计数为准**:主机 TX 接丝印 **RX**、
> 主机 RX 接丝印 **TX**、GND 必接;接完看主机 `[SBLINK] rx` 是否涨(涨=方向对,与内容无关),
> 不涨当场对调。全证据与推论见 `docs/子系统设计/屏板链路设计说明.md` §5.3/§5.6——
> 对调后屏板出了正确画面,等于屏板按 CRC 验收了主机的发送线**就是**它标 RX 的那根,
> 即**屏板实际发送脚是表中标 `PA3` 的那根**(与屏板固件"USART2 TX=PA2"的常规映射相反,
> 除非它的 USART2 做了重映射)。产品线束定版前向屏板方核实。
三条容易接错的地方:
- **必须交叉**:TX 对 RX。同边对接(`U5.A10` 接屏板 `PA2`)两端口都静默,且没有任何报错可看。
- **USART1 的 TTL 只从 U5 出去**:`ST_TX0` / `ST_RX0` 在网表里只连 `U5.A10` / `U5.B10` 与 MCU 的
101/102 脚,**CN1 上没有任何 USART1 端子**——CN1 的 `RS232_*` 是 UART5 / USART2 经收发器之后的电平,
别去 CN1 找。同一个 `U5` 上还挂着 USART3 的 `ST_TX2` / `ST_RX2`(`U5.C7` / `U5.C6`),**别抓错针**。
- **不串电阻、没有方向控制**:3.3 V TTL 直连,全双工两根独立线,没有 DE 脚可接。
**验收方式**(判据落在 1-3,0-3 只把方法写死):
1. **无屏板实物时用 PC + USB 转 TTL 顶替屏板**(`docs/任务规划/README.md` 已注明"软件可先用 PC +
USB 转 TTL 台架验证,不必等屏板实物"):适配器 `TXD` → `U5.B10`、`RXD` → `U5.A10`、`GND` → 板地,
700000 8N1,按屏板的周期召唤节拍(约 275~280 ms,口径见 `docs/产品需求/产品需求设计文档.md` §5.16.2)
发 6 B 帧 `01 03 01 00 CRC_L CRC_H`(`RT_CMD`,按键时第 4 字节换成键值;CRC-16/MODBUS 只覆盖前 4 B、
**低字节在前**——屏板源码的注释把高低写反了,口径见 `docs/产品需求/产品需求设计文档.md` §5.16.2)。
2. **判据**:`Size == 6`、CRC 通过率 100 %、连续 10 min 无丢帧;拔插一次 TX 线制造噪声后能自动恢复
(`HAL_UART_ErrorCallback` 里清 ORE 并重挂接收,否则一次噪声就永久静默)。
3. **有实物后**重复第 2 步,并确认屏板画面按召唤节拍刷新。
### 决策二:用哪个 UART(**已裁定 2026-10-28**)
| 端口 | 引脚 | V0.1 板上通道 | 重排后用途 |
|---|---|---|---|
| **USART1** | PA9 / PA10 | 调试口 + U5 直连 TTL(ST_TX0/RX0) | ✅ **屏板链路** |
| **USART2** | PD5 / PD6 | **RS232#3**(U10 隔离 → U13 TPT3232E → CN1) | ✅ **日志**(默认) |
| UART5 | PC12 / PD2 | **RS232#1**(U9 隔离 → U13 → CN1) | 备选日志口 |
| **UART5** | PC12 / PD2 | **RS232#1**(ST_TX1/ST_RX1 → U9 隔离 → U13 TPT3232E → CN1.10/CN1.12) | ✅ **日志**(2026-10-29 已定) |
| USART2 | PD5 / PD6 | **RS232#3**(ST_TX3/ST_RX3 → U10 隔离 → U13 → CN1.9/CN1.11;另有网口座 `U14` 同信号) | 备选日志口 |
| USART3 | PB10 / PB11 | U5 直连 TTL(ST_TX2/RX2) | 空闲 |
**日志口选 USART2 还是 UART5**:两者都经隔离器 → `U13` TPT3232E → CN1,能力等价;
控制台是**只发不收、寄存器轮询**,DMA 也用不上,所以挑哪个只取决于**现场把 CH340 接到哪个端子**。
默认写 **USART2(RS232#3)**;若实际接的是 RS232#1,就换成 `huart5`——**迁移时收口成一处宏,别散改**。
**日志口已定为 UART5(RS232#1)**——2026-10-29 用户裁定,理由是**台架那根线落在哪个端子**,不是能力差异:
两条 RS232 通道都经隔离器 → `U13` TPT3232E → CN1,控制台又是**只发不收、寄存器轮询**,DMA 也用不上。
选定的 `ST_TX1` / `ST_RX1` 就是 RS232#1 在 MCU 侧的网络名(PC12 / PD2,UART5)。
> ⚠ **RS232#1 没有 TTL 出口**:`ST_TX1` / `ST_RX1` 在网表里只连 `U9` 的 3 脚 / 2 脚与 MCU 两个脚,
> **既无连接器也无测试点**;唯一外部出口是 CN1 的 RS-232 端子。所以台架**必须用 USB 转 RS-232 适配器**,
> 现有的 TTL CH340 接不上去(接上去轻则不通信,重则打坏 `U13` 或适配器)。
> 另:`U14` 是 RS232#3 的网口座(3 脚 `RS232_TX3` / 6 脚 `RS232_RX3`),**本通道用不上**——别把它当成备选端子。
**迁移时收口成一处端口宏,别散改**:`huart5` 已是 `115200 8N1`(`Src/usart.c:74-81`),
业务代码里没人用,只有 `Src/stm32f4xx_it.c:565` 挂着 IRQ 入口。
> ⚠ **USART1 交接前必须确认两件事**:① 控制台已不再写 `USART1->DR`(否则两条流互相插字节,
> 症状是"屏板偶尔花屏 + 日志偶尔乱码",很容易误判成 CRC 或时序问题);
@@ -185,9 +242,9 @@
| 序 | 任务 | 改动点 | 判据 |
|---|---|---|---|
| 0-1 | **台架串口线挪位** | 硬件:CH340 从 USART1 调试口改接 CN1 的 RS-232 端(或 U14) | `serial-assistant` 在 RS-232 侧能收到日志 |
| 0-2 | **日志口迁移(USART1 → RS-232)** | ① `console_log.c` 的 `huart1`、`USART1_IRQn`、`__HAL_RCC_USART1_CLK_*`、`MX_USART1_UART_Init()` 收口成**一处端口宏**,指向 `huart2`(RS232#3)或 `huart5`(RS232#1);② 该实例改 **`UART_MODE_TX`**(只发不收,把 RX 从机制上摘掉) | 日志在 RS-232 侧可读、内容与迁移前一致;连续 10 min 无 ORE 锁存、无 `[ERR]`;USART1 完全空出 |
| 0-3 | **落定 USART1 到屏板的接线** | 按决策一:TTL 直连屏板 UART2 侧(PA2/PA3);把线束接法与验收方式写清楚 | 接法落纸;照此接线后屏板发来的 6 B 召唤帧能被收到 |
| 0-1 ✅ | **台架串口线挪位** | 硬件:**换成 USB 转 RS-232 适配器**(TTL 的 CH340 直连不了),接 RS232#1 的三个端子——适配器 TXD → **CN1.10**(板子 `RS232_RX1`)、RXD → **CN1.12**(板子 `RS232_TX1`)、GND → **CN1.7 或 CN1.8**(`CGND`)。端子号出自 `docs/硬件平台/V0.1.tel`;`U14` 是 RS232#3 的网口座,本通道用不上 | **已完成 2026-10-29**:COM3 在 RS-232 侧可读可抓(`serial-assistant` 正常) |
| 0-2 ✅ | **日志口迁移(USART1 → RS-232)** | `console_log.c` 的 `huart1`、`USART1_IRQn`、`__HAL_RCC_USART1_CLK_*`、`MX_USART1_UART_Init()` 收口成**一处端口宏**,指向 **`huart5`(RS232#1,已定)** | **已完成 2026-10-29**(`f5161e15`):COM3 抓到完整开机日志、8 个锚点全中、启动到 `net init: OK` 661 ms;连续 10 min 1218 行零 `[ERR]`/零 dropped;USART1 已无控制台写入 |
| 0-3 ✅ | **落定 USART1 到屏板的接线** | 按决策一:TTL 直连屏板 UART2 侧(PA2/PA3);接法与验收方式见 §2 决策一下方的「接法与验收」 | **接法已落纸 2026-10-29**:`U5.A10` / `U5.B10` ↔ 屏板 TTL 排针 + 共地,交叉接。**真屏板实测(同日):照表对 PA 编号接不通、信号线对调后才通**——验收以"主机 `rx` 计数上涨"为准,屏板侧 PA2/PA3 的实际收发方向待向屏板方核实(见决策一表下 ⚠ 与 `docs/子系统设计/屏板链路设计说明.md` §5.6) |
> PA10 已接线(用户 2026-10-28 确认),屏板链路**不需要额外飞线**。
@@ -202,40 +259,67 @@
| 序 | 任务 | 改动点 | 判据 |
|---|---|---|---|
| 1-1 | **协议层模块(纯逻辑,可主机单测)** | 新建 `Drivers/BSP/ScreenLink/sb_proto.c/h`:CRC-16/MODBUS(表与屏板 `auchCRCHi/Lo` 逐字节一致)、帧 A 解析、帧 B 组帧 | 主机单测:给定 6 B 召唤帧能算出屏板同样的 CRC;组出的 4208 B 帧 CRC 自校验通过 |
| 1-2 | **波特率与格式落地** | `STM32F407-Demo.ioc` 里把 **USART1** 改 **700000 / 8N1 / 16 倍过采样**(改 `.ioc` 而不是只改 `Src/usart.c`,否则 CubeMX 重生成会丢);`Src/usart.c` 只写 USER CODE | 示波器/逻辑分析仪实测位宽误差 **< 1 %**;理论值 `USARTDIV = 7.5`、`BRR = 0x0078`(APB2 84 MHz),误差 0 % |
| 1-3 | **USART1 收中断与全局回调分支** | 开 IDLE 中断接收(缓冲 **16 B**);`HAL_UARTEx_RxEventCallback` 里**加 USART1 分支**(现有回调只把事件分发给另一条在用通道的句柄,不加分支会静默收不到);`HAL_UART_ErrorCallback` 同样要加,且**必须清 ORE/NE/FE 并重挂接收**(`__HAL_UART_CLEAR_OREFLAG` 或读 SR 再读 DR) | 屏板每 280 ms 发来的 6 B 能稳定收到,`Size == 6`,CRC 通过率 100 %;**拔插一次 TX 线制造噪声后能自动恢复**(不清 ORE 会永久锁死) |
| 1-1 ✅ | **协议层模块(纯逻辑,可主机单测)** | 新建 `Drivers/BSP/ScreenLink/sb_proto.c/h`:CRC-16/MODBUS(与屏板同一多项式,用 16 位合并表)、帧 A 解析、帧 B 组帧(普通图 / LOGO)、帧 B 自校验;双端注册:uvprojx 加 File + IncludePath,模拟器侧走 `scripts/check_registration.py` 的 MCU-only 豁免(纯逻辑,模拟器无消费路径) | **已完成 2026-10-29**:主机单测 **59/59 PASSED**(`test/host/sb_proto/`),覆盖率 74/74 行、6/6 函数;屏板实测向量 `01 03 01 00 F0 48` 与 `01 03 00 09 31 DE` 原样解析通过;组出的两种帧按屏板 `Check_CRC` 口径复核均被接受;Keil 构建 **0 错误 0 警告**(`Code` 不变——尚无调用者,链接器按未用节丢弃) |
| 1-2 ✅ | **波特率与格式落地** | **改 `Src/usart.c` 的 `USER CODE BEGIN USART1_Init 2` 区**(覆盖 `huart1.Init.BaudRate` 后重调一次 `HAL_UART_Init`)——**`.ioc` 这条路不存在**:`.ioc` 不保存任何 UART 的波特率(只有 `USART1.IPParameters=VirtualMode`),GUI 里改也不落盘,与 IWDG 那些"写进 `.ioc` 才不怕重生成"的情形不同 | **已完成 2026-10-29**:开机日志实测 `BRR=0x0078`(APB2 84 MHz / (16 × 700 kHz) = 7.5 → 整数 7、小数 0.5×16 = 8,误差 **0 %**),与理论值逐位相符,不必再架示波器;700 kbaud 下 10 分钟收帧零 CRC 错 |
| 1-3 ✅ | **USART1 收中断与全局回调分支** | 新建 `Drivers/BSP/ScreenLink/sb_link.c/h`:开 IDLE 中断接收(缓冲 **16 B**);`HAL_UARTEx_RxEventCallback` / `HAL_UART_ErrorCallback` 里**各加一行转发**(两个回调是全局单例,唯一实现在 `rs485.c`,按句柄过滤在 `sb_link` 内做);错误分支**清 ORE/NE/FE/PE 并按需重挂** | **已完成 2026-10-29**(PC + USB 转 TTL 顶替屏板,`COM6` @700000):`rx == 发送帧数`、`ok == rx`、`crc=0`、`len=0`、`size=6`,`keys` 随按键帧递增;先用错波特率灌 64 B 垃圾(`len=31`、`bits=0x06`)后**自动恢复**,`rearm=0`。主机单测 **81/81 PASSED**(`test/host/sb_link/`),Keil **0 错误 0 警告** |
> ⚠ **`HAL_UARTEx_RxEventCallback` / `HAL_UART_ErrorCallback` 是全局单例**——全工程只有一处实现,
> 现在只按它自己那个 `huart` 句柄分发(定位:全库搜这两个回调名就能找到实现处)。
> USART1 必须加自己的分支,否则中断进来了但没人处理——表象是"屏板一直超时",很容易误判成协议错。
> **分支只能"加一行转发",不能"自写一份"**:再定义一次就是链接期 multiple definition。
### 批 2 · 协议实现(5 项)
> ⚠ **错误回调里不能无条件重挂**(2026-10-29 台架实测踩到):HAL 把错误分成两类——
> **ORE 是阻塞型**(先 `UART_EndRxTransfer` 把 `RxState` 打回 READY,再回调,必须重挂);
> **NE/FE/PE 是非阻塞型**(接收照跑,`RxState` 仍是 `BUSY_RX`,此时再调 `ReceiveToIdle_IT`
> 只会拿到 `HAL_BUSY`)。旧写法无条件重挂,于是台架上出现 `err=160 / rearm=160` 完全同步增长——
> 看着像"每帧都重挂失败、链路快死了",其实一次都没真失败。判据用 `RxState == HAL_UART_STATE_READY`
> 而不是错误位:它直接回答"现在挂着接收没有"。主机单测 `TC-SBLINK-015` 钉住了这条。
> ⚠ **PC + 杜邦线顶替屏板时会有 NE(噪声错)**:实测 `bits=0x02`、每帧约 1.3 次,
> 但 `crc=0` 且 `ok == rx`——数据完好,只是采样点被判噪声(线材无屏蔽、无终端匹配)。
> 判读口径写进了 `sb_link.h` 的 `err_bits` 注释:**NE/FE 而 `rx_ok` 照涨 = 能用的告警,不是故障**;
> 真出问题的是 ORE(`0x08`)——那是处理不过来,本链路 6 B/280 ms 不该出现。
> 台架上没造出 ORE(CPU 读 DR 太快),那条路径由主机单测按 RM0090 的清除语义覆盖。
### 批 2 · 协议实现(5 项,2026-10-29 全部收尾;实现记录见 `docs/子系统设计/屏板链路设计说明.md`)
| 序 | 任务 | 改动点 | 判据 |
|---|---|---|---|
| 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` |
### 批 3 · 状态灯与 LOGO(3 项)
> ⚠ **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 · 状态灯(3-1 / 3-2 两项,2026-10-29 收尾;LOGO 出局)
| 序 | 任务 | 改动点 | 判据 |
|---|---|---|---|
| 3-1 | **状态灯位映射表** | 定义"业务状态 → 屏板 LED 位"的表(**参考源码里没有这张表,必须自定**)。建议:ID 1 = 运行、ID 2 = 告警、ID 3 = 通信、其余按 `docs/产品需求/产品需求设计文档.md` §5.8 的 6 路语义扩展;**bit0 恒 0**(ID 0 是硬件常亮的电源灯)、**bit20~31 恒 0** | 表落纸并进代码;每位点亮/熄灭可单独验证 |
| 3-2 | **状态灯下发** | 32 位小端填进 `info[0..3]`(`info[0]` = bit0~7) | 主机改掩码 → 屏板对应 LED 变化,**LED 0 不受掩码影响** |
| 3-3 | **LOGO 下发(一次性)** | 独立入口(不进常规刷新):`info[4..5] = 0x89 0x45`、`info[0..1]` = 宽、`info[2..3]` = 高(大端) | 下发后屏板显示 2 s 并写 Flash;**断电重启后 LOGO 仍在**;这帧不改 LED 状态 |
> ⚠ LOGO 帧会把屏板 Flash `0x0803F000`(扇区 126)**整块覆盖**,且屏板固件不校验是否同一张图。
> 建议只在出厂配置/联调流程里提供,**不要挂到菜单上**。
| 3-1 | **状态灯位映射表** | 落 `App/algo/sb_led_map.c/h`(纯逻辑,主机单测 17/17):**ID 1 = 运行心跳**(500 ms 半周期闪烁,与主板 LED1~3 同拍)、**ID 2 = 告警**(`prot_elm_active_mask != 0`)、**ID 3 = 通信**(104 链路 RUN,新增 `iec104_serv_link_run()`)、**ID 4 = 采样故障**(`adc_health_flags()` 电平位,不选 sticky——灯要能灭)、**ID 5 = 保护启动**(`prot_elm_pick_mask`);**bit0 恒 0**(ID 0 硬件常亮、屏板不看位)、**ID 6~19 恒 0**(保留,扩展从 ID 6 顺序取) | 表落纸并进代码 ✓;主机单测用「掩码 & ~定义集 == 0」钉住无表外位 ✓;每位点亮可单独验证(真屏板目视,见 §5) |
| 3-2 | **状态灯下发** | `ui_task.c` 帧组装块每拍现采四路状态源(全部是"任意上下文可读"观测口)→ `sb_led_map_eval()` → 掩码进 `sb_link_frame_update()` 的 `info[0..3]`(小端);**掩码变了帧就重组**,走批 2 已有的 busy/dirty 机制,无新通路 | 主机改掩码 → 屏板对应 LED 变化;**LED 0 不受掩码影响**(真屏板目视待验,见 §7 清单) |
| 3-3 ❌ | ~~LOGO 下发~~ **出局**(用户裁定 2026-10-29:LOGO 功能无用,不实现、不接线) | 不做下发入口;`sb_proto.c/h` 里既有的 `sb_frame_b_build_logo()` + `SB_LOGO_MAGIC_*` 常量与 `test/host/sb_proto` 的相应用例**列为待删除**(删除时连同 `docs/子系统设计/屏板链路设计说明.md` 的 LOGO 段落一并清)。帧 B 的 `info[4..5]` 常规图像路径恒填 0,屏板按普通图分支处理,与 LOGO 无冲突 | 无——本项不产生代码改动 |
### 批 4 · 并发与验收(3 项)
| 序 | 任务 | 改动点 | 判据 |
|---|---|---|---|
| 4-1 | **双通道并发回归** | 7003 客户端拉帧 + 屏板召唤同时进行 | 两侧画面一致;串口 4208 B 发送期间 7003 不超时;7003 拉帧期间屏板不超时 |
| 4-1 ✅ | **双通道并发回归** | 7003 客户端拉帧 + 屏板召唤同时进行 | **已完成 2026-10-30(真屏板 + 新脚本 `test/host/remolcd_pull_test.py`,7003 侧 INIT→LCDMEM 10 Hz 重压)**:网络侧两轮(150 s / 60 s 隔离窗口)请求应答 **1500/1500 + 700/700、CRC 错 0、超时 0**,往返 min 35 / avg ~63 / max 131 ms(远小于 LCDMEM 应答"最坏 ≈4 s"的口径上限)。屏板侧 60 s 隔离窗口(clear 后开窗,计数只读窗口内):**rx +233 帧全收、`drop` 零新增、`kerr=0 abor=0 bad=0`、`off=0`、无 OFFLINE 行**,`tx` 追平 `rx`(末帧 59.6 ms 采样窗差 1)→ **7003 拉帧期间屏板每召必答**(屏板的 205 ms 超时计数在屏侧看不到,但"每召必答"是其不超时的充分证据)。"两侧画面一致"由机制保证:两出口读**同一份活显存**(`ui_net_sink_get_lcdmem` 与 `sb_link_frame_update` 的入参同为 `ui_port_get_framebuffer()`),无第二份账可漂;目视对图为机会性复核项。⚠ 观察项(不属 4-1 判据):`len=7` 的出现窗口夹在"烧录后冒烟(23:49 时 len=0)"与"第一轮 150 s 重压结束(00:15:26 已 len=7)"之间,**不能排除与首轮重压相关**;但第二轮 60 s 隔离窗口零新增、全程 `crc=0 fmt=0`、有效帧一帧没少(ok 增速与 rx 同频),功能影响为零。挂设计说明观察,若再现再查 |
| 4-2 | **长稳与自复位** | 连续跑 ≥2 h(跨过屏板一次自复位) | 零帧错误、零误判离线、UI 无可见卡顿 |
| 4-3 | **文档收口** | 把实测结论回填 `docs/产品需求/产品需求设计文档.md` §5.16.2(尤其**帧长 4208 vs 3208 那条待复核项**)与 §9.13 | 待复核项清零;§9.13 对应行销项 |
@@ -247,13 +331,19 @@
```
Drivers/BSP/ScreenLink/
├── sb_proto.c/h 纯逻辑:CRC-16、帧 A 解析、帧 B 组帧 ← 可主机单测
└── sb_link.c/h 传输:USART1 中断起发 + TX DMA、组帧入口、状态灯映射表 ← 依赖 HAL,仅 MCU
├── 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 组帧(逐字段)
```
@@ -263,9 +353,17 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉
偏移 3205 1 B info[5]:普通图填 0x00(非 0x45)
偏移 3206 2 B info[6..7]:保留,填 0
偏移 3208 998 B 填充,填 0x00(屏板不读,但参与 CRC)
偏移 4206 2 B CRC-16/MODBUS,覆盖 [0, 4205],高字节在前
偏移 4206 2 B CRC-16/MODBUS,覆盖 [0, 4205],低字节在前(口径见 §4.2 下方的字节序说明)
```
> ⚠ **CRC 的字节序:屏板源码的注释是反的**。屏板 `Calculate_CRC()` 返回的不是标准 CRC 值,
> 而是高低互换后的「线上字」(数据 `123456789` 它返回 `0x374B`,标准值是 `0x4B37`),所以
> `RS485_RefreshScreenCMD()` 里 `cmdbuf[4] = crc>>8`(注释写"CRC校验高字节")写出去的其实是**低字节**。
> 两处命名都反,**线上字节是对的**:标准 Modbus RTU 的低字节在前。
> 主机侧一律按「标准 CRC 值 + 低字节在前」写(帧 A 偏移 4、帧 B 偏移 4206 都是低字节),
> 照字面写成"高字节在前"会让屏板把每一帧都判成 CRC 错,而它的超时判定只看"收没收到一整包",
> 现场只表现为画面不刷新。实测向量已钉在 `test/host/sb_proto/sb_proto_test.c`。
> ⚠ **`info[0..3]` 是双解释字段**:普通图当 LED 掩码、LOGO 图当宽高。区分只靠 `info[4..5]`。
> **先判魔数再解释这 4 字节**,顺序反了会把 LED 掩码当宽高。
@@ -317,11 +415,11 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉
| 步 | 做什么 | 需要什么 | 停下也能交付什么 |
|---|---|---|---|
| 0 | **腾位置**:CH340 挪到 RS-232 侧、日志迁走 | 改接串口线 | USART1 空出来,日志照旧能看(批 0) |
| 0 | **腾位置**:串口线挪到 RS-232 侧、日志迁走 | 改接串口线 + 一个 **USB 转 RS-232 适配器** | USART1 空出来,日志照旧能看(批 0) |
| 1 | 协议层单测(`test/host/screenlink/`) | 无硬件 | CRC 与组帧的正确性,可回归 |
| 2 | PC 串口助手**模拟屏板**:手发 6 B 召唤帧,看主机回的 4208 B | **USB 转 TTL 模块**接 USART1(PA9/PA10)+ 主机板 | 主机侧协议 100 % 可验证 |
| 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 引出 | 出图 + 按键 + 状态灯全链路 |
| 4 | **接真屏板**(TTL 直连屏板 UART2 的 PA2/PA3 一侧) | 屏板实物 + 屏板 TTL 引出 | **冒烟首通 + 10 min 零错 2026-10-29**:出图(用户目视确认)、`rx=4258 ok=4258 crc=0 err=0`、应答 `tx=4249 done=4248 drop=0(窗口内) abor=0`、按键 `keys=53 kq=0`、`off` 自动恢复。⚠ 接线实测教训:**按排针丝印同名直连收不到,必须"主机 TX 接丝印 RX"**(丝印是屏板视角),且两板各供电须接第三根 GND——详见 `docs/子系统设计/屏板链路设计说明.md` §5.6。状态灯已实现(批 3)待目视、双通道并发**已过(4-1,见 `docs/子系统设计/屏板链路设计说明.md` §5.7)**;长稳/时序仪器项仍未测 |
| 5 | 产品形态:按最终线束接法定版 | 线束 | 产品形态验证 |
> 第 2、3 步是**性价比最高的两步**:不用屏板、不用改硬件,就能把 90 % 的软件问题挖出来。
@@ -334,16 +432,16 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉
| 风险 | 触发条件 | 影响 | 回退/应对 |
|---|---|---|---|
| **日志迁移把台架弄瞎** | 先改代码、后挪串口线 | 什么都看不到,误以为固件挂了 | 顺序钉死:0-1 先挪线、0-2 再改代码;改完先跑一次 `@test` |
| **TTL 适配器直插 CN1** | 沿用那根 CH340 去接 RS-232 端子 | 不通信,且 RS-232 的 ±电平可能打坏 `U13` 或适配器 | 0-1 已写死「换 USB 转 RS-232 适配器」;RS232#1 无 TTL 出口,只能走电平转换后的 CN1 |
| **控制台与屏板链路抢 `USART1->DR`** | 日志没迁干净就接屏板 | 屏板花屏 + 日志乱码,像 CRC / 时序错 | 0-2 的判据明确要求"USART1 完全空出" |
| **一次噪声后链路永久静默** | 错误回调里不清 ORE | 屏板召唤再也收不到,症状与"协议不对"完全一样 | 1-3 判据含"拔插 TX 线制造噪声后自动恢复" |
| **日志口又染上 ORE 锁存** | 新日志口照抄 `UART_MODE_TX_RX` | 控制台静默、看门狗误复位(老病复发) | 新日志口设 `UART_MODE_TX`;判据含"连续 10 min 无 ORE" |
| 帧长按 3208 实现 | 照 readme 写 | CRC 全失败、屏板不亮 | 已写进 §4.2 警示;联调第一步就核 4208 |
| 中断回调漏加分支 | 只改自己模块,忘了那三个全局 UART 回调里唯一的一处实现只分发另一条通道的句柄(§1.3) | 收不到召唤帧,表象像协议错 | 批 1 的 1-3 已列为判据 |
| 屏板自复位被误判离线 | 链路监视窗口设得太小 | 每 83 分钟误报一次 | 判据定为"连续 ≥10 s 无帧" |
| 状态灯语义返工 | 映射表拍脑袋定的 | 后期改表要重测 | 把表做成一张 `const` 数组,改表不改逻辑 |
| 状态灯语义返工 | 映射表拍脑袋定的 | 后期改表要重测 | 表 = `App/algo/sb_led_map.h` 位宏 + 单点 `sb_led_map_eval()`(唯一出处),折算不散落调用点;主机单测钉「掩码 & ~定义集 == 0」,改表漏测会被套件拦住(3-1 落纸时的口径微调:5 路输入判据形态各异——电平/闪烁——const 数组统一不了谓词,纯函数分支更直白) |
| RAM 超门禁 | 误把 RX 缓冲也做成 4208 B | `RW_IRAM1` 逼近 92 % | RX 只要 16 B(§1.3) |
| **整帧被 ISR 切断** | 在任务里轮询发送,中途被采样 / 网口 / 系统节拍 ISR 抢走 ≥2 个字符时间 | 一帧拆两包、两半 CRC 全错;症状只是「画面偶尔不刷新」,而**屏板不报超时** | 决策三:整帧 TX DMA + 中断起发;2-2 判据含「无 28 µs 以上间隙」 |
| **屏板接收溢出后永久静默** | 主机提前发 / 超发 / 叠发,或 LOGO 帧撞上屏板自己的 Flash 写 | 屏板再也收不到,画面与状态灯一起冻死,**它自己毫无指示**,只能断电重上 | 严格一召唤一应答、正好 4208 B;LOGO 只走出厂 / 联调流程(3-3),下发前先确认能给屏板断电 |
| **屏板接收溢出后永久静默** | 主机提前发 / 超发 / 叠发 | 屏板再也收不到,画面与状态灯一起冻死,**它自己毫无指示**,只能断电重上 | 严格一召唤一应答、正好 4208 B(LOGO 出局后总线上不会再有其它帧形态,风险源只剩叠发/提前发,批 2 的 busy 冻结已挡) |
| **把 200 ms 当成「帧作废」线** | 误读屏板那个放弃计时 | 白加一层重试与加急逻辑;或反过来以为迟到无害,撞上 205 ms 的按键召唤 | 屏板真实规则:迟到的完整帧照样显示;**有按键待报时最早 205 ms 就再召唤** → 仍按 ≤200 ms 完成,理由换成「防撞 + 不滞后」(决策三) |
| **按键被当成偶发失灵** | 那一轮窗口被错过 | 屏板的键**只发一次不重试**(发完立刻清备份)→ 该次按键直接丢失 | 起发留在中断里(µs 级);2-3 按「逐键必到」测,不看统计均值 |
| **屏板背光 100 s 熄灭被当成掉线** | 现场长时间没人按键 | 误报链路故障 | 按任意键即复亮;判离线只看召唤帧有无(2-5 的 10 s 窗口) |
@@ -355,7 +453,7 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉
做完这一项,要能同时满足(逐条可测,对应 `docs/产品需求/产品需求设计文档.md` §5.16.10 第 8~15 条):
0. **腾位置**:日志在 RS-232 侧可读、内容与迁移前一致;USART1 已无任何控制台写入;连续 10 min 无 ORE 锁存。
0. **腾位置**:日志在 RS-232 侧可读、内容与迁移前一致;USART1 已无任何控制台写入。
1. 屏板按 **280 ms** 召唤(判据是 RT_count > 55 → 第 56 拍 × 5 ms;屏板侧常写的 275 ms 是 55×5 的算法),主机每帧在 **≤200 ms** 内**发完** **4208 B**,连续 10 min **主机侧**零超时、零 CRC 错帧。
2. 帧 B 前 3200 B 与 `g_ui_fbuf` **逐字节一致**。
3. 屏板 7 个物理键逐个按下 → UI 动作正确;无键时 `key = 0` 不产生事件。
@@ -363,16 +461,14 @@ stub 提供 HAL)。屏板侧 CRC 有查表实现,**用同一张表做交叉
5. **双通道并发**:7003 与串口同时跑,互不拖超时、画面一致。
6. **屏板自复位**(≈5000 s)不被误判离线,约 1 s 后自动续上。
7. 拔插屏板:7003 侧不受影响,插回自动恢复,不用复位主机。
8. LOGO 下发后断电重启仍在,且不改变 LED 状态。
8. ~~LOGO 下发后断电重启仍在,且不改变 LED 状态~~ **随 LOGO 出局划掉**(用户裁定 2026-10-29,见批 3 的 3-3)。
**另测三项**(属本方案的实现判据,不在 §5.16.10 编号内):
**另测两项**(属本方案的实现判据,不在 §5.16.10 编号内):
- 逻辑分析仪量整帧发送期间**不得出现 ≥2 个字符时间(约 28 µs)的间隙**——这是断帧安全性的直接证据,
也是必须用 DMA 的理由(决策三);同时记下召唤帧末位到应答首位的实测值,确认 30 µs 的切换留量够用。
- 跑一遍 LOGO 下发(3-3)之后,验证「给屏板断电重上」仍能恢复通信:屏板在写它自己那块 Flash 时若被打断,
- **7003 拥塞时串口侧照常应答**:挂着 7003 客户端不读、或制造对时 / relisten 突发把 `uiTask` 卡住,
屏板侧应**零超时**、画面停在最后一帧(这就是「起发不在任务态」的直接证据);`uiTask` 一醒画面立刻续上、总线无对撞。
可能进入它自己救不回来的状态(它的错误回调与重初始化都被注释掉了)。
---
+1 -1
View File
@@ -355,7 +355,7 @@ typedef struct {
## 5. 任务卡(完成即销项删行;标识列用语义短语,不配编号)
> 资源图例:🔴 台架(COM6 / ST-LINK,`bench.lock` 独占)· 🟡 外部(标准源 / 子板原理图)· 🟢 纯软件。
> 资源图例:🔴 台架(COM3 / ST-LINK,`bench.lock` 独占)· 🟡 外部(标准源 / 子板原理图)· 🟢 纯软件。
> 状态:⬜ 未开始 · 🟦 进行中。**已闭环 = 直接删行**。
> 本表**只写判据**;执行步骤在 `docs/测试说明/ADC前端注入验证流程.md`,实测结论在
> `docs/硬件平台/ADC通道映射说明.md` §8.1(动态实测结论开在其后)。
@@ -47,10 +47,10 @@ dhara 失败类 +84;RO 另 +208,全是该失败类名表)→ `ER_IROM1`
<!-- MEM_SNAPSHOT:BEGIN generated by scripts/check_mem_numbers.py -->
| 区域 | 用量 | 容量 | 占比 |
|---|---|---|---|
| ER_IROM1 | 272,336 B (265.95 KB) | 1,048,576 B (1024 KB) | 26.0% |
| RW_IRAM1 | 89,936 B (87.83 KB) | 131,072 B (128 KB) | 68.6% |
| ER_IROM1 | 275,976 B (269.51 KB) | 1,048,576 B (1024 KB) | 26.3% |
| RW_IRAM1 | 94,312 B (92.10 KB) | 131,072 B (128 KB) | 72.0% |
| RW_CCM | 57,376 B (56.03 KB) | 65,280 B (64 KB) | 87.9% |
| **Flash (ROM 合计)** | **274,308 B (267.9 KB)** | 1,048,576 B (1024 KB) | **26.2%** |
| **Flash (ROM 合计)** | **278,012 B (271.5 KB)** | 1,048,576 B (1024 KB) | **26.5%** |
<!-- MEM_SNAPSHOT:END -->
各区的容量与常量来源:
+4 -4
View File
@@ -15,19 +15,19 @@
<!-- MEM_BUDGET:BEGIN generated by scripts/ram_baseline.py -->
| 区域 | 用量 | 容量 | 占比 | 门禁(≤92%) |
|---|---|---|---|---|
| ER_IROM1 (Flash) | 272,336 B | 1,048,576 B | 26.0% | 参考 |
| ER_IROM1 (Flash) | 275,976 B | 1,048,576 B | 26.3% | 参考 |
| RW_CCM | 57,376 B | 65,280 B | 87.9% | PASS |
| UNINIT_CCM | 48 B | 256 B | 18.8% | 参考 |
| RW_IRAM1 | 89,936 B | 131,072 B | 68.6% | PASS |
| RW_IRAM1 | 94,312 B | 131,072 B | 72.0% | PASS |
| 子系统(静态 RW+ZI;除 RTOS 堆在 CCM 外均在 SRAM1) | 现值 | 配额 | 状态 |
|---|---|---|---|
| 网络 | 13,702 B | 12 KB | ⚠ 超 1.4 KB |
| 存储 | 7,072 B | 12 KB | OK |
| UI | 36,428 B | 16 KB | ⚠ 超 19.6 KB |
| UI | 36,429 B | 16 KB | ⚠ 超 19.6 KB |
| RTOS 堆 | 57,376 B | 56 KB | 堆=配额 |
| 应用 | 26,333 B | 8 KB | ⚠ 超 17.7 KB |
| 其它 | 6,404 B | — | 参考 |
| 其它 | 10,768 B | — | 参考 |
<!-- MEM_BUDGET:END -->
- **门禁**:pre-commit `mem-budget` 钩子(`scripts/ram_baseline.py --gate`)——
@@ -0,0 +1,201 @@
# 屏板链路设计说明(主机侧实现记录)
> 模块:`Drivers/BSP/ScreenLink/`(`sb_proto` + `sb_link`),任务 `screenlink`。
> 本文档记**已实现的内容与实测证据**;任务分解、协议事实源、四个决策的来龙去脉在
> `docs/任务规划/屏板链路.md`(下称规划文档),两边不重复维护同一张表。
> 创建:2026-10-29(真屏板首通当晚落笔)。
---
## 1. 一句话架构
屏板是 Modbus 主站、每 280 ms 发一帧 6 B 召唤(帧 A),主机是从站 `0x01`:
**任务侧组好一帧 4208 B(帧 B)挂在手上,空闲中断校完召唤直接点 TX DMA 回一帧**;
屏板按键骑在召唤帧里上来,主机排给 UI。串口为主出口,7003 网络是同一份显存的镜像。
```
uiTask(10 ms 节拍) USART1 空闲中断(屏板召唤到达)
ui_port_step() 渲染 sb_link_on_rx_event()
sb_link_frame_update(活显存, 掩码) ├─ sb_frame_a_parse 校验 6 B
├─ 点阵 memcmp 没变 → 立刻返回(~20 µs) ├─ 按键 ≠ 0 → 入环形队列
├─ TX busy → BSP_ERR_BUSY,下拍再来 ├─ 帧有效 → sb_tx_kick() 点 TX DMA
└─ 变了 → sb_frame_b_build_image 组整帧 └─ 无条件重挂 ReceiveToIdle_IT
+ sb_frame_b_verify 自校验
→ s_frame_ready = 1 DMA2_Stream7 TC 中断
sb_link_key_take() 出队 sb_link_on_tx_cplt() 清 busy
→ ui_task_board_key_map() 映射 UI_KEY_*
→ ui_port_queue_key()
sb_link_poll(tick) 统计上报 + ≥10 s 无召唤判离线
```
分层铁律:`sb_proto` 纯逻辑(不碰 HAL/寄存器/任务),原样 include 进 `test/host/` 跑单测;
`sb_link` 依赖 HAL 与中断上下文,仅 MCU。
## 2. 对外接口(单一出处在头文件,这里只列形状)
| 接口 | 上下文 | 作用 |
|---|---|---|
| `sb_link_init(&huart1)` | 启动,`app_main.c` 步骤 2b | 交叉核对波特率(对不上报 ERR_STATE)+ 挂首轮接收 + 使能 DWT |
| `sb_link_receive_start()` | 中断/任务 | 重挂 IDLE 接收(一次性 API,漏一次链路永久静默) |
| `sb_link_on_rx_event(huart,size)` | 中断(rs485.c 转发) | 帧 A 校验、计数、按键入队、**点 TX DMA 应答**、重挂 |
| `sb_link_on_error(huart)` | 中断(rs485.c 转发) | 按 SR→DR 顺序清错、按需重挂 RX、按 `gState` 清被打断 TX 的残留 busy |
| `sb_link_on_tx_cplt(huart)` | 中断(rs485.c 转发) | 清 busy、计数;**不在中断里组帧** |
| `sb_link_frame_update(bmp,mask)` | uiTask | 按需组帧(memcmp 判变化),busy 返 BUSY,自校验不过不发 |
| `sb_link_key_take()` | uiTask | 出队屏板原始键号(0=空);映射归 uiTask |
| `sb_link_poll(now_ms)` | uiTask | 离线判定(每拍)+ 5 s 变化才打的 RX/TX 两行统计 |
| `sb_link_get_stats(&st)` | 任意 | 计数快照(rx/ok/crc/keys/tx/done/drop/fb/us/offline…) |
| `sb_crc16` / `sb_frame_a_parse` / `sb_frame_b_build_image` / `sb_frame_b_build_logo`(⚠ 待删除,LOGO 出局见批 3 的 3-3)/ `sb_frame_b_verify` | 纯逻辑 | 协议层五件套,主机单测 59/59 |
全局回调落点:`HAL_UARTEx_RxEventCallback` / `HAL_UART_ErrorCallback` /
`HAL_UART_TxCpltCallback` 三个的**唯一实现处都在 `rs485.c`**,各含一行
`sb_link_on_*` 转发;按句柄过滤在 `sb_link` 内部做。三个必须收在一处见全——
分开写,下一个加通道的人会以为 TxCplt 没人用而再定义一份(multiple definition)。
## 3. 关键实现决定(为什么是现在这个样子)
1. **不留 3200 B 影子缓冲**:帧里的点阵段就是"上次组进去的那一份",直接拿它跟活显存
memcmp。省下 3200 B SRAM 与每拍 0.15 ms 的 CRC,代价只是每拍一次 memcmp(~20 µs)。
2. **busy 期间冻结帧缓冲**:DMA 读的就是 `s_frame` 本体,中途改写 = 半新半旧的脏帧
(CRC 仍对、屏板照收、画面撕裂,极难归因)。宁可 `BSP_ERR_BUSY` 下一拍再组。
双缓冲被否:一召唤一答的节奏下"下一拍补组"最坏慢 10 ms,屏板 280 ms 周期无感,
不值再花 4208 B。
3. **起发在中断里**(规划文档的决策三):4208 B 线上 59.6 ms,任务里轮询 DR 一次几十 µs
的 ISR 抢占就把帧切断(屏板按 1 字符时间断帧、且坏帧也清超时计数——现场只剩"画面不刷新")。
4. **注键走 `ui_port_queue_key`、不绕 `ui_net_sink_inject_key`**:后者只是 7003 后端对
同一函数的转发,绕过去要多一次 include 且语义不变。两条入口进同一个输入队列。
5. **错误中断顺手收拾 TX**:HAL 的 TX 被错误打断时不调 TxCplt,busy 会永久挂着,
之后每次召唤都被判"上一帧还在发"→ rx 涨 tx 冻结。判据用 `huart->gState != BUSY_TX`。
6. **离线判 10 s 不判 500 ms**:屏板 ≈5000 s 自复位有 ≈1 s 空窗,短窗会把"歇了口气"
判成离线再"恢复",标志反复跳。
7. **LED 掩码走独立纯逻辑模块折算**(批 3 落地):位定义 + 求值收在 `App/algo/sb_led_map.c/h`
(映射表唯一出处),`uiTask` 每拍现采四路状态源(`prot_elm_active_mask` / `prot_elm_pick_mask` /
`adc_health_flags` / `iec104_serv_link_run`)喂进去,掩码变了帧就重组——复用批 2 的 busy/dirty
机制,无新通路。折算不做成 const 表:5 路输入判据形态各异(电平 4 路 + 心跳 1 路),
纯函数分支比"表 + 谓词回调"直白,且主机单测能钉「掩码 & ~定义集 == 0」。
8. **键 9/10 丢弃留痕**:决策四未裁,先按 `docs/任务规划/屏板链路.md` §4.3 的表把
1/2/3/4/7/8 映射到 `UI_KEY_*`,9/10 出日志不注键。
9. **LOGO 出局**(用户裁定 2026-10-29):不做下发入口。`sb_proto` 的 `sb_frame_b_build_logo()`
与魔数常量**列为待删除**(头文件已挂 ⚠ 勿新增调用);常规图像路径 `info[4..5]` 恒 0,
屏板按普通图分支处理,总线上不会再出现 LOGO 形态——「屏板接收溢出」风险表随之只剩叠发一源。
## 4. 资源占用(实测,`.map` 可复核)
| 项 | 值 | 备注 |
|---|---|---|
| `s_frame` | 4208 B @ `0x20006d98` | `Execution Region RW_IRAM1`——**不在 CCM**(DMA1/2 物理不可达 CCM,放错段=静默发旧数据) |
| `s_rx_buf` / `s_stats` 等 | 16 + 84 B | 同段 |
| 整模块增量 | Code 约 +1.5 KB | 批 2 前后 237,464 → 238,936 B |
| 全镜像 | RW_IRAM1 85,904 B (65.5 %) / RW_CCM 57,376 B (87.9 %) | 凭证 `scripts/size_attestation.json` |
| TX DMA | `DMA2_Stream7` CH4,NVIC 优先级 9 | CubeMX 已配好、此前全工程没人用;无需动 `.ioc` |
| 波特率 | USART1 = 700000,BRR=0x0078(误差 0 %) | 写在 `Src/usart.c` 的 `USER CODE BEGIN USART1_Init 2`(`.ioc` 不保存波特率),开机日志自证 |
| RX | 16 B + IDLE 中断 | 不上 RX DMA(屏板只发 6 B) |
## 5. 实测记录
### 5.1 主机单测(可回归)
- `test/host/sb_proto/`:**59/59**,含屏板实测向量与 CRC 表逐字节交叉验证。
- `test/host/sb_link/`:**146/146**(批 2 新增 65 条)。桩把 `SR/DR/CYCCNT` 做成函数指针,
能在主机上钉住:ORE 清除的 SR→DR 顺序(RM0090 语义在桩里真执行)、非阻塞错误**不得**重挂、
busy 期间帧缓冲逐字节冻结、错误中断清残留 busy 但不误杀在发的 TX、帧 B 每个字段、
周期→µs 换算除数、离线 9.999 s/10 s 边界与恢复、按键 FIFO 与溢出丢新。
### 5.2 台架(PC + USB 转 TTL 顶替屏板,2026-10-29 白天)
40 s `--key-fixed 2`:`rx=143 ok=143 keys=143 crc=0` / `tx=143 done=143 drop=0 abor=0
fb=144 bad=0`;杜邦线噪声形态 `bits=0x02`(NE)每帧 ~1.3 次但数据零错——已定口径
"NE/FE 而 rx_ok 照涨 = 能用的告警";错波特率灌 64 B 垃圾后自动恢复;激励停止 10 s
准点 OFFLINE。组帧耗时 **509 µs 典型 / 709 µs 峰值**(成本全在 4206 B 查表 CRC、512 B
表驻 Flash)——原 0.3 ms 估算判据已改口 1 ms,压降路径记录未实施,见规划文档批 2 的 ⚠。
### 5.3 真屏板冒烟首通(2026-10-29 21:24,接 §2 决策一的 TTL 线)
| 证据 | 读数 | 结论 |
|---|---|---|
| `rx=125 ok=125 crc=0 len=0 fmt=0 err=0 bits=0x00 rearm=0` | 一字节没错、**连错误中断都没进过** | 真屏板线路质量优于台架杜邦线(台架每帧 ~1.3 次 NE) |
| 5 s ≈ 18 帧 → 278 ms | 与屏板固件 280 ms(第 56 拍 × 5 ms)吻合 | 对端确认是屏板本尊的周期召唤 |
| `tx=116 done=116 kerr=0 abor=0`,`drop=9` 集中在开机头两秒 | 一召一答、无双发、无失败 | 2-2/2-4 的时序骨架在真硬件上成立 |
| 错接期间 `off=1` → 对调线后连打三轮 `off=0` | 离线判定与自动恢复走通 | 2-5 判据闭环(全程未重启主机)。⚠ `back online` 那一行本身没取到证据——恢复发生在 21:24 抓日志的 `clear` 之前 ~10 s,行被抹掉了;但 `offline` 字段**只有**恢复分支会清,1→0 的转变即该分支执行过的证明 |
| **屏板出图,用户目视确认"非常完美"** | 见 §5.4 的推论 | 帧 B 的 4208/CRC 口径端到端正确 |
### 5.4 "出图"这两个字的证明力(别低估它)
屏板收帧**只有两种结局**:整包 CRC 过 → 刷屏;不过 → 静默丢弃,且它超时计数照清、
没有任何报错通道(`docs/任务规划/屏板链路.md` §4.4 预言过的"只表现为画面不刷新")。所以肉眼看到正确画面,等于
**屏板亲手验收了**:帧长 4208、CRC 覆盖 [0,4205]、**低字节在前**(两处命名相反的屏板
源码口径)、1 bpp 20 B/行 MSB 在前的显存布局、info 字段布局——全部端到端正确。
这是主机侧任何计数都给不出的一条证据,台架阶段(CH340 收不全)也补不了。
### 5.5 真屏板 10 min 连续零错(2026-10-29 21:30~21:44,验收清单第 1 条的主机侧半边)
挂机窗口内(日志清零后累计)末轮计数:
| 计数 | 读数 | 判据 |
|---|---|---|
| `rx=4258 ok=4258 crc=0 len=0 fmt=0 err=0 bits=0x00 rearm=0` | **连错误中断都没进过一次** | 10 min 零超时、零 CRC 错帧 ✓ |
| 末段 5 s 采样 4240→4258 = 18 帧 | 277.8 ms/帧 | 与屏板 280 ms 节拍吻合 ✓ |
| `tx=4249 done=4248 drop=9 kerr=0 abor=0` | `drop=9` 全部是开机头两秒(帧未就绪),窗口内零新增;`done` 差 1 是采样瞬间最后一帧还在发(59.6 ms 窗) | 一召一答、无双发、无失败 ✓ |
| `keys=53 kq=0` | 按压期间 53 个按键帧全部入队出队、**零丢弃** | 按键"逐键必到"的主机侧半边 ✓(动作语义合不合菜单树需目视,见 §7-1) |
| `fb=902 bad=0`,`us=687 max=812` | 10 min 组帧 902 次(待机时钟秒跳等 LVGL 自刷新驱动,≈1.5 次/s),自校验全程通过;峰值 812 µs < 1 ms 改口判据 | 常态组帧占空比对 10 ms 节拍 <7 % |
| 窗口内无 `OFFLINE`/`back online` 行 | 连接全程稳定 | — |
### 5.6 接线实测教训(决策一的接法表照接后**不通**,对调才通)
- 按规划文档决策一的表接"主机 A10(PA9)→屏板 PA3、B10(PA10)→屏板 PA2"**rx=0 彻底静默**;
**把两根信号线对调后立即通**。表本身的信号方向逻辑没错(TX 对 RX),错在**屏板侧引脚的
认定**——排针丝印 TX/RX 是"屏板自己发出/接收"的视角,与网表里 PA2=发送/PA3=接收的对应
关系未经屏板方确认。实操判据比丝印可靠:**接完看主机 `rx` 有没有计数**(rx 一动=方向对,
与帧内容无关),不动就当场对调。产品形态线束定版前,以"rx 计数"为唯一验收口径。
- 两块板各自供电必须接第三根线 GND;只连两根信号线时是浮地,形态同样是彻底收不到。
### 5.7 双通道并发回归(批 4 的 4-1,2026-10-30 00:10~00:18,真屏板在线)
激励脚本 `test/host/remolcd_pull_test.py`(新):INIT(8 B:宽/高/帧字节数,大端)→ LCDMEM
偏移 0 以 **10 Hz** 重压拉整帧 3200 B,逐帧 XOR 校验 + 长度核对。每轮 LCDMEM 在固件侧都是
「uiTask 入队 + 等 netTask 回执」的真往返——这就是"7003 在真实占用 uiTask"的压力形态。
| 侧 | 读数 | 判据 |
|---|---|---|
| 网络侧·第一轮 | 150 s:请求 1500 / 应答 1500、CRC 错 0、超时 0,往返 min 35 / avg 63 / max 88 ms | **串口发送期间 7003 不超时** ✓(4208 B 线上 59.6 ms 与拉帧并行,应答照样 100 % 到手) |
| 网络侧·第二轮 | 60 s 隔离窗口:700 / 700、零错零超时,max 131 ms | 同上,重复性 ✓ |
| 屏板侧·60 s 隔离窗口(日志 clear 后开窗,计数只读窗口内) | rx +233 帧(278 ms/帧节拍精确)、**`drop` 零新增、`kerr=0 abor=0 bad=0`、`off=0`、全程无 OFFLINE**,`tx` 追平 `rx`(末帧 59.6 ms 采样窗差 1) | **7003 拉帧期间屏板每召必答** ✓(屏板 205 ms 超时计数在主机侧看不到,每召必答是其不超时的充分证据) |
| 画面一致性 | 机制证明:两出口读**同一份活显存**(`ui_port_get_framebuffer()` 单点),无第二份账可漂;7003 末帧已 `--dump` 成 PBM/PNG 留档(`ci-artifacts/`,不入库) | 目视对图为机会性复核 |
**观察项(不属 4-1 判据,如实记)**:本轮上电累计 `len=7`(7 次 IDLE 断帧尺寸 ≠ 6)。
出现窗口夹在"烧录后冒烟 23:49(未截 len 字段)"与"首轮重压结束 00:15(已 len=7)"之间,
**不能排除与首轮 150 s 重压相关**;但上一上电周期 10 min 窗(§5.5)`len=0`,同一份固件,
说明非确定性、疑瞬态;第二轮 60 s 隔离窗口**零新增**,且全程 `crc=0 fmt=0`、`ok` 增速与
`rx` 同频——有效帧一帧没少,功能影响为零。若后续再现再查(首查方向:断帧尺寸 7 的那一包
是否伴随 `err` 位——若是 NE 噪声尾巴粘进下一包,属杜邦级线噪的正常形态)。
## 6. 坑账本(都已在代码注释/规划文档留痕,这里只汇总)
1. 中文字面量 → ARMCC `#870-D`(源码无 BOM 按本地代码页解)。日志字面量纯 ASCII。
2. `@test` 的 forbidden `FAIL;ERROR;HardFault;[ERR]` **大小写不敏感**——日志字段名带
fail/error/abort 会把启动自检判红(`fail=0` 实测中招,已改 `kerr=`/`abor=`)。
3. `ReceiveToIdle_IT` 一次性:漏重挂 = 链路永久静默;ORE 不自己清 = 一次噪声打死接收。
4. NE/FE 非阻塞、ORE 阻塞:重挂判据用 `RxState==READY`,不看错误位。
5. CH340 收不了 700 kbaud 连续流(4208 B 只到 ~2373 B)——**收不全 ≠ 固件没发**,
同轮 `tx==done` 即自证。
6. `.ioc` 不保存 UART 波特率;改波特率只能在 `USER CODE` 覆盖后重调 `HAL_UART_Init`。
7. 纯注释改动也会让体积凭证报"不同码":先重建、再 `ram_baseline --update`。
## 7. 尚未实测项(按优先级)
| # | 项 | 怎么测 | 状态(2026-10-29 夜) |
|---|---|---|---|
| 1 | **7 个物理键逐个按** | 真屏板已就位:每按一键,日志 `keys=` 应 +1、`kq` 恒 0,画面动作对照 `docs/产品需求/产品需求设计文档.md` §8.2 菜单树;键 9 应只出 `unmapped` 日志 | **半闭环**:10 min 窗口 `keys=53 kq=0`(必到、零丢弃)已实测(§5.5);剩"逐键动作合菜单树 + 键 9 出 unmapped 日志"需边按边看 |
| 2 | **10 min 连续零错**(屏板在线) | 挂机后 `grep SBLINK` 抽末行:`crc=len=fmt=err=rearm=bad=kerr=abor=0`、`tx≈rx`、`done=tx` | ✅ **2026-10-29 实测通过**(§5.5,rx=4258 零错) |
| 3 | **拔插回归**(正式版) | 拔屏板线 ≥10 s → 一条 OFFLINE;插回 ≤2 s 续图、一条 back online;期间 7003 与日志口不受影响 | 冒烟时被动验过一半(对调线前 off=1、对调后自动恢复);主动拔插 + 7003 并发面未测 |
| 4 | **uiTask 单轮增量**(判据"画面静止时 memcmp 成本 ~20 µs") | 画面静止 1 min,对比开屏板前后的 `[PROF] uiw`;按键刷屏时段观察 `uiw` 尖峰 ≈ 组帧 0.7 ms 以内 | 未测(§5.5 已给出组帧侧成本上限 `max=812 µs`,`[PROF]` 对照仍缺) |
| 5 | **2 h 长稳跨自复位** | 挂机 ≥5000 s + 余量:零误判离线(自复位 ≈1 s 空窗不该出 OFFLINE)、零 kerr/abort、恢复自动 | 无 |
| 6 | **召唤→应答延迟 30 µs~5 ms / 帧内无 ≥28 µs 间隙 / 59.6 ms** | 逻辑分析仪挂 `U5.A10/B10`;或 FTDI/CP210x 适配器 + `screenlink_bench.py --reply` 只能验整帧时长与内容(USB 1 ms 粒度量不了间隙) | 仪器 |
| 7 | **帧 B 与 `g_ui_fbuf` 逐字节比** | FTDI/CP210x 收满 4208 后 `--dump` 出 PBM 对 UI 目检;严格逐字节需 PC 收全帧脚本比对(CH340 做不了) | 仪器 |
| 8 | **双通道并发**(批 4 的 4-1) | 挂 DTU-RemoteLCD 持续拉帧 + 屏板同时召唤:两侧画面一致、串口发送期 7003 不超时、7003 拉帧期屏板不超时 | ✅ **2026-10-30 实测通过**(§5.7:7003 侧 2200 拉帧零错零超时;屏板侧隔离窗口每召必答、零新增丢弃;画面一致由"同一份活显存"机制保证) |
| 9 | **7003 拥塞时串口照常应答** | 制造 netTask 往返卡顿(对时风暴/客户端不读),屏板侧应零超时、画面停最后一帧——"起发不在任务态"的直接证据 | 无 |
| 10 | **状态灯逐位**(批 3 已实现,待目视) | ID 1 运行灯 1 Hz 闪(肉眼即见);ID 3 = 挂 104 主站(`test/host/iec104_master.py`)后亮、拆线灭;ID 4 = 拔采样前端后亮、插回灭;**ID 2 / ID 5 在产品构建恒灭**(未标定闭锁,`protect_task.c` 的 VALID 门禁——与屏上"保护动作 N"同一已知口径),要验证这两路得等标准源标定后或临时构建 | 真屏板已就位 |
| 11 | ~~LOGO 断电留存~~ | **随 LOGO 出局划掉**(用户裁定 2026-10-29):不下发 LOGO,`sb_proto` 的 LOGO 组帧函数列为待删除 | — |
| 12 | **键 9/10 语义裁定**(决策四) | 需要产品侧定 RESET/FACTORY 行为,代码已留 unmapped 分支 | 人 |
| 13 | 真屏板 ORE 路径 | 主机侧只能被动等:若某天 `bits` 出现 0x08,看 `rearm` 是否跟涨;台架造不出(CPU 读 DR 太快),已由主机单测按 RM0090 覆盖 | 机会性 |
第 2 项已代跑通过(§5.5);第 3、4 项接线不动就能跑;第 1 项剩下半边需要你手按。
+3
View File
@@ -98,6 +98,7 @@ SIM_EXEMPT = [
("App/algo/adc_cal.c", "标定折算纯算法 + cfg 段注册(消费者 = MCU 侧 adcTask 每拍;模拟器不跑采样任务),模拟器无消费路径"),
("App/algo/meas.c", "测频与遥测快照发布纯算法(唯一发布者 = MCU 侧 adcTask 200 ms 分频;104 改读快照与 UI 侧消费属另一波次另行接线,模拟器无消费路径;主机侧纯逻辑单测 test/host/meas 覆盖),模拟器无消费路径"),
("App/algo/run_par.c", "运行参数段纯算法 + [run_par] cfg 段注册 + 104 地址映射(写者 = iec104Task 直通、属主落盘 defaultTask;模拟器不跑 104 协议,模拟器无消费路径;主机侧纯逻辑单测 test/host/run_par 覆盖),模拟器无消费路径"),
("App/algo/sb_led_map.c", "屏板状态灯位映射纯算法(唯一消费者 = MCU 侧 uiTask 经 sb_link 组帧;模拟器无屏板串口,模拟器无消费路径;主机侧纯逻辑单测 test/host/sb_led_map 覆盖),模拟器无消费路径"),
("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"),
("App/app/ui_data_feed.c", "UI 快照装配器(依赖 phasor/prot_set/iec104 端口常量等固件侧数据源);模拟器不跑装配链,快照由 simulator/main.c 直接发布固定帧,模拟器无消费路径"),
("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"),
@@ -135,6 +136,8 @@ SIM_EXEMPT = [
("Drivers/BSP/NET/net_pending.c", "MCU 挂起 recv 模型(依赖 ch395f_* SPI 读出与 FreeRTOS 通知),模拟器另有 net_socket_sim.c 按外部语义模拟"),
("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"),
]
+2
View File
@@ -11,4 +11,6 @@ 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 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
2026-10-29 243552 28784 1972 145388 272336 26.0 89936 68.6 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;wave_ring.o=5296;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055
2026-10-30 246160 29816 2036 149700 275976 26.3 94312 72.0 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;wave_ring.o=5296;sb_link.o=4364;ch395f.o=4162;console_log.o=4132
1 date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi
11 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
12 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
13 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
14 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
15 2026-10-29 243552 28784 1972 145388 272336 26.0 89936 68.6 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;wave_ring.o=5296;ch395f.o=4162;console_log.o=4132;sys_log_mcu.o=4055
16 2026-10-30 246160 29816 2036 149700 275976 26.3 94312 72.0 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;wave_ring.o=5296;sb_link.o=4364;ch395f.o=4162;console_log.o=4132
+13 -13
View File
@@ -1,25 +1,25 @@
{
"built_at": "2026-10-29T19:31:06+08:00",
"fingerprint": "5cc78605efbb7dd87e23d66915406c8487ee3f286a28ca50c8932693e3a4c614",
"fingerprint_files": 1460,
"built_at": "2026-10-30T01:06:44+08:00",
"fingerprint": "1ed4267c77f65e357f631ef775aef15a784757ae2ffecf15cb3838b5e2ce1fb5",
"fingerprint_files": 1466,
"fingerprint_source": "git-index",
"gated_regions": [
"RW_IRAM1",
"RW_CCM"
],
"git_head": "efda416f427cea35465707cb26bf2f6517e732bc",
"git_head": "f405c8994fd0123b7b8ad1d449a372508a5276ac",
"map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map",
"map_mtime": 1793273428,
"map_mtime": 1793293592,
"pct": {
"ER_IROM1": 25.97,
"ER_IROM1": 26.32,
"RW_CCM": 87.89,
"RW_IRAM1": 68.62,
"RW_IRAM1": 71.95,
"UNINIT_CCM": 18.75
},
"regions": {
"ER_IROM1": {
"max": 1048576,
"size": 272336
"size": 275976
},
"RW_CCM": {
"max": 65280,
@@ -27,7 +27,7 @@
},
"RW_IRAM1": {
"max": 131072,
"size": 89936
"size": 94312
},
"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": 243552,
"ro_data": 28784,
"rw_data": 1972,
"zi_data": 145388
"code": 246160,
"ro_data": 29816,
"rw_data": 2036,
"zi_data": 149700
},
"threshold_pct": 92.0,
"worktree_dirt_at_stamp": 0,
+89 -28
View File
@@ -80,6 +80,14 @@
"lines_covered": 41,
"lines_valid": 41
},
"App/algo/sb_led_map.c": {
"func_rate": 1.0,
"funcs_covered": 1,
"funcs_valid": 1,
"line_rate": 1.0,
"lines_covered": 15,
"lines_valid": 15
},
"App/algo/wave_ring.c": {
"func_rate": 1.0,
"funcs_covered": 15,
@@ -209,12 +217,12 @@
"lines_valid": 100
},
"App/task/iec104_serv.c": {
"func_rate": 0.8182,
"func_rate": 0.75,
"funcs_covered": 9,
"funcs_valid": 11,
"line_rate": 0.7706,
"funcs_valid": 12,
"line_rate": 0.7572,
"lines_covered": 131,
"lines_valid": 170
"lines_valid": 173
},
"App/util/boot_stats.c": {
"func_rate": 1.0,
@@ -360,6 +368,22 @@
"lines_covered": 108,
"lines_valid": 112
},
"Drivers/BSP/ScreenLink/sb_link.c": {
"func_rate": 1.0,
"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": 75,
"lines_valid": 75
},
"Drivers/BSP/UI/ui_data.c": {
"func_rate": 0.8235,
"funcs_covered": 42,
@@ -482,9 +506,9 @@
},
"iec104_serv": {
"files": {
"App/task/iec104_serv.c": 170
"App/task/iec104_serv.c": 173
},
"lines_total": 170,
"lines_total": 173,
"ok": true
},
"lftpd": {
@@ -623,6 +647,28 @@
"lines_total": 967,
"ok": true
},
"sb_led_map": {
"files": {
"App/algo/sb_led_map.c": 15
},
"lines_total": 15,
"ok": true
},
"sb_link": {
"files": {
"Drivers/BSP/ScreenLink/sb_link.c": 207,
"Drivers/BSP/ScreenLink/sb_proto.c": 75
},
"lines_total": 282,
"ok": true
},
"sb_proto": {
"files": {
"Drivers/BSP/ScreenLink/sb_proto.c": 75
},
"lines_total": 75,
"ok": true
},
"sha256": {
"files": {
"App/util/sha256.c": 114
@@ -747,6 +793,7 @@
"App/algo/prot_set.c",
"App/algo/run_par.c",
"App/algo/sample_ring.c",
"App/algo/sb_led_map.c",
"App/algo/wave_ring.c",
"App/algo/wave_store.c",
"App/cfg/cfg_file.c",
@@ -782,22 +829,24 @@
"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",
"Drivers/BSP/UI/ui_w_soe.c"
],
"totals": {
"files_generated_excluded": 5,
"files_tested": 48,
"files_untested": 169,
"func_rate": 0.9231,
"funcs_covered": 576,
"funcs_valid": 624,
"line_rate": 0.8992,
"lines_covered": 7729,
"lines_valid": 8595,
"modules_ok": 44,
"modules_total": 44
"files_tested": 51,
"files_untested": 172,
"func_rate": 0.924,
"funcs_covered": 596,
"funcs_valid": 645,
"line_rate": 0.9016,
"lines_covered": 8020,
"lines_valid": 8895,
"modules_ok": 47,
"modules_total": 47
},
"untested_sources": [
{
@@ -809,7 +858,7 @@
"path": "Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.c"
},
{
"code_lines": 486,
"code_lines": 525,
"path": "App/task/ui_task.c"
},
{
@@ -898,11 +947,11 @@
},
{
"code_lines": 142,
"path": "App/task/protect_task.c"
"path": "App/app/app_main.c"
},
{
"code_lines": 135,
"path": "App/app/app_main.c"
"code_lines": 142,
"path": "App/task/protect_task.c"
},
{
"code_lines": 129,
@@ -945,12 +994,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": 79,
"path": "Drivers/BSP/RS485/rs485.c"
"code_lines": 83,
"path": "Drivers/BSP/NET/net_socket.h"
},
{
"code_lines": 79,
@@ -1000,6 +1049,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,6 +1113,10 @@
"code_lines": 38,
"path": "Drivers/BSP/UI/ui_page_wave_manual.c"
},
{
"code_lines": 36,
"path": "Drivers/BSP/ScreenLink/sb_proto.h"
},
{
"code_lines": 36,
"path": "Drivers/BSP/UI/ui_w_records.h"
@@ -1132,6 +1189,10 @@
"code_lines": 27,
"path": "Drivers/BSP/UI/ui_w_setview.h"
},
{
"code_lines": 26,
"path": "App/algo/sb_led_map.h"
},
{
"code_lines": 26,
"path": "App/util/task_prof.h"
@@ -1280,6 +1341,10 @@
"code_lines": 12,
"path": "App/app/app_main.h"
},
{
"code_lines": 12,
"path": "App/task/iec104_serv.h"
},
{
"code_lines": 12,
"path": "Drivers/BSP/GD5F2GQ5UE/nand_ftl.h"
@@ -1296,10 +1361,6 @@
"code_lines": 11,
"path": "App/sync/time_sync.h"
},
{
"code_lines": 11,
"path": "App/task/iec104_serv.h"
},
{
"code_lines": 11,
"path": "App/task/net_task.h"
+21 -21
View File
@@ -6,7 +6,7 @@
* 起是**整行独占**,不再是"无锁":M 族 TC-CONLOG-170 至 TC-CONLOG-179 钉"别的写者插不进
* 一行、让路行逐字节等同直发、让位不丢、真溢出才 [ERR]")、缓冲满有界等待与
* 丢弃可观测、TX 僵尸通道软复位自愈 + 300ms 冷却、DWT 与指令计数两种硬上界、
* huart1.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算、
* huart5.Instance 未初始化的 NULL 短路、fputc 重定向、drain 250ms 预算、
* 整行所有权(行级单一所有者,道G task-23)。
*
* 为什么要主机侧单测(而不是只用台架):
@@ -19,7 +19,7 @@
*
* 桩的边界(细节见 stub/stm32f4_fake.h 头注释):
* console_log.h / sys_events.h(含真实 SYS_EVT_CONSOLE_TX 位)用**真实头**;
* usart.h 用桩,但只暴露本模块访问的 huart1 + MX_USART1_UART_Init,寄存器位号 /
* usart.h 用桩,但只暴露本模块访问的 huart5 + MX_UART5_Init,寄存器位号 /
* 枚举值照抄真实头;USART SR 的 TXE/TC、DWT CYCCNT、HAL_GetTick、临界区、
* __get_IPSR、vTaskDelay、osEventFlagsSet 做成可控 + 可观测的量。
* ⚠ 本文件**刻意不定义**任何 syslog_* / DBG_* 符号:console_log.c 哪天把"TX 故障
@@ -38,7 +38,7 @@
* 读后清、300ms 冷却两端、tick==0 也进冷却(哨兵撞值修好后为正向断言)、
* 半块 / 已上线仍判失败
* 090..106 DWT 惰性使能三处寄存器写、使能失败退回 20000 次指令计数、超时判据不读
* HAL_GetTick(第 8b 轮台架动机)、huart1.Instance == NULL 短路两路 +
* HAL_GetTick(第 8b 轮台架动机)、huart5.Instance == NULL 短路两路 +
* "未初始化绝不 reinit"(误自愈修好后为正向断言)+ 证据不蒸发
* 110..117 fputc 重定向(未注册直发 / 注册逐字节入环 / ISR 上下文裸发 / 环满时 ISR
* 仍不等待)
@@ -112,7 +112,7 @@ uint32_t SystemCoreClock = 168000000U; /* 与 MCU 端一致(168 MHz) */
USART_TypeDef g_con_uart;
CoreDebug_Type g_con_core_debug;
DWT_Type g_con_dwt;
UART_HandleTypeDef huart1;
UART_HandleTypeDef huart5;
uint32_t con_uart_tx_n;
uint8_t con_uart_tx[CON_UART_TX_MAX];
@@ -282,12 +282,12 @@ osStatus_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags)
}
/* 真实版是 CubeMX 生成的 HAL_UART_Init 封装;桩按脚本决定"软复位到底治没治好" */
void MX_USART1_UART_Init(void)
void MX_UART5_Init(void)
{
s_mx_init_calls++;
huart1.Instance = &g_con_uart;
huart1.gState = HAL_UART_STATE_READY;
huart1.ErrorCode = HAL_UART_ERROR_NONE;
huart5.Instance = &g_con_uart;
huart5.gState = HAL_UART_STATE_READY;
huart5.ErrorCode = HAL_UART_ERROR_NONE;
if (s_mx_repair != 0) {
con_uart_txe_stuck = 0;
con_uart_txe_after = 0;
@@ -413,10 +413,10 @@ static void reset_all(void)
s_mx_init_calls = 0U;
s_mx_repair = 0;
huart1.Instance = &g_con_uart;
huart1.gState = HAL_UART_STATE_READY;
huart1.RxState = HAL_UART_STATE_READY;
huart1.ErrorCode = HAL_UART_ERROR_NONE;
huart5.Instance = &g_con_uart;
huart5.gState = HAL_UART_STATE_READY;
huart5.RxState = HAL_UART_STATE_READY;
huart5.ErrorCode = HAL_UART_ERROR_NONE;
}
/* ===================== 观察辅助 ===================== */
@@ -983,12 +983,12 @@ static void tc_dwt(void)
"TC-CONLOG-099 the DWT branch declares timeout on the first spin (the ceiling really works, never burns 20000)");
}
/* ============ TC-CONLOG-100..106 huart1.Instance == NULL 短路 + 未初始化分诊 ============ */
/* ============ TC-CONLOG-100..106 huart5.Instance == NULL 短路 + 未初始化分诊 ============ */
static void tc_null_uart(void)
{
printf("-- TC-CONLOG-100..106 USART1 not initialised: yield, never reinit, keep proof --\n");
printf("-- TC-CONLOG-100..106 UART5 not initialised: yield, never reinit, keep proof --\n");
reset_all();
huart1.Instance = 0;
huart5.Instance = 0;
dbg_console_write_raw("boom", 5U);
CHECK(con_uart_tx_n == 0U && con_uart_txe_reads == 0U,
"TC-CONLOG-100 with Instance==NULL not one register is touched (no dereference of address 0)");
@@ -996,7 +996,7 @@ static void tc_null_uart(void)
"TC-CONLOG-101 DWT is still lazily enabled before the short circuit (ensure stays ahead of the NULL check)");
reset_all();
huart1.Instance = 0;
huart5.Instance = 0;
CHECK(dbg_console_write_line("early", 5U) == 5,
"TC-CONLOG-102 the unattached path (pre-kernel) survives a missing UART and still returns the line length");
@@ -1004,7 +1004,7 @@ static void tc_null_uart(void)
reset_all();
attach_writer();
(void)dbg_console_write_line("abc", 3U);
huart1.Instance = 0; /* 写者起来了但串口还没初始化 */
huart5.Instance = 0; /* 写者起来了但串口还没初始化 */
s_mx_repair = 1;
writer_drain();
CHECK(s_mx_init_calls == 0U && con_rcc_disables == 0U && con_nvic_clears == 0U,
@@ -1014,7 +1014,7 @@ static void tc_null_uart(void)
CHECK(dbg_console_dropped() == 1U && used_now() == 0U,
"TC-CONLOG-105 the block that left the ring but never hit the wire counts as dropped");
huart1.Instance = &g_con_uart; /* 串口随后初始化好 */
huart5.Instance = &g_con_uart; /* 串口随后初始化好 */
con_uart_tx_n = 0U;
writer_drain();
CHECK(tx_contains("[ERR] CONSOLE dropped 1 lines") && dbg_console_dropped() == 0U,
@@ -1159,7 +1159,7 @@ static void tc_three_ledgers(void)
reset_all();
attach_writer();
(void)dbg_console_write_line("xyz", 3U);
huart1.Instance = 0;
huart5.Instance = 0;
writer_drain();
CHECK(dbg_console_dropped() == 1U && dbg_console_tx_timeout() == 0U
&& dbg_console_tx_fault() == 0U,
@@ -1193,11 +1193,11 @@ static void tc_three_ledgers(void)
con_dwt_step = SystemCoreClock / 4U;
(void)dbg_console_write_line("0123456789ABCDEFGHIJ", 20U); /* 缓冲满丢 1 行 */
con_dwt_step = 0U;
huart1.Instance = 0; /* 告警那一发也发不出去 */
huart5.Instance = 0; /* 告警那一发也发不出去 */
writer_drain();
CHECK(dbg_console_dropped() >= 1U,
"TC-CONLOG-148 drop count survives while the channel is unavailable (evidence no longer washed away)");
huart1.Instance = &g_con_uart;
huart5.Instance = &g_con_uart;
con_uart_tx_n = 0U;
writer_drain();
CHECK(tx_contains("[ERR] CONSOLE dropped") && dbg_console_dropped() == 0U,
+2 -2
View File
@@ -10,7 +10,7 @@
缓冲满两条上界
(DWT 周期 / 200 tick)与丢弃计数、[ERR] 告警行不经环(不递归)、TX 僵尸通道软复位
自愈 + 故障即止 + 300ms 冷却(含无符号回绕)、DWT 使能失败退回 20000 次指令计数、
huart1.Instance==NULL 短路、fputc 重定向、drain 250ms 预算、行级单一所有者。
huart5.Instance==NULL 短路、fputc 重定向、drain 250ms 预算、行级单一所有者。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
@@ -26,7 +26,7 @@
docs/子系统设计/控制台单写者日志设计说明.md(G1~G5 / K1~K5 / §12 ORE 僵尸通道)。
桩的边界:console_log.h 与 sys_events.h 用真实头(SYS_EVT_CONSOLE_TX 位定义一改即
编译期报错);usart.h 是桩但只暴露本模块访问的 huart1 + MX_USART1_UART_Init,
编译期报错);usart.h 是桩但只暴露本模块访问的 huart5 + MX_UART5_Init,
USART SR 位号、DWT/CoreDebug 位掩码、HAL_StatusTypeDef 枚举值均照抄真实头,
宿主编不动的那条 main.h -> stm32f4xx_hal.h -> core_cm4.h 链由 stub/stm32f4_fake.h
以普通结构体替代(理由写在该文件头注释里)。
+8 -8
View File
@@ -2,7 +2,7 @@
* 模块名称:主机侧单测桩 - 假 HAL/CMSIS 寄存器面 + 内核入口(console_log 模块专用)
* 模块功能:让 App/log/console_log.c 在 PC 上编起来,并把它依赖的硬件/内核面做成
* **可脚本控制**的模型:
* ① USART1:TXE/TC 可注入「就绪 / 永远不就绪(僵尸通道)/ 前 N 次不就绪」;
* ① 假串口外设:TXE/TC 可注入「就绪 / 永远不就绪(僵尸通道)/ 前 N 次不就绪」;
* 写进 DR 的字节被收进一条上线字节日志,并按「一次 TC 就绪 = 一块」记块长。
* ② DWT CYCCNT:每次访问推进 con_dwt_step 个周期(= 给时间定价),
* CTRL 的 CYCCNTENA 可脚本成「写不进去」以走 con_tx_counted 支路。
@@ -19,7 +19,7 @@
* DWT_Type.CTRL/CYCCNT + DWT_CTRL_CYCCNTENA_Msk=bit0、CoreDebug_Type.DEMCR +
* CoreDebug_DEMCR_TRCENA_Msk=bit24(core_cm4.h)
* HAL_StatusTypeDef{OK=0,ERROR=1,BUSY=2,TIMEOUT=3}(stm32f4xx_hal_def.h)
* HAL_UART_STATE_RESET=0x00、HAL_UART_ERROR_NONE=0x00、USART1_IRQn=7
* HAL_UART_STATE_RESET=0x00、HAL_UART_ERROR_NONE=0x00、UART5_IRQn=7
* UART_HandleTypeDef 只搬本模块访问的 4 个字段(Instance/gState/RxState/ErrorCode),
* 余下(Init/Lock/...)刻意不搬:抄半份 HAL 结构只会更快与真实头漂移。
*
@@ -52,11 +52,11 @@
#include <stdlib.h>
/* ===== 与真实头同值的位 / 枚举(来源见文件头) ===== */
#define USART_SR_TXE_Msk (0x0080U) /* USART1->SR bit7 发送寄存器空 */
#define USART_SR_TC_Msk (0x0040U) /* USART1->SR bit6 发送完成 */
#define USART_SR_TXE_Msk (0x0080U) /* SR bit7 发送寄存器空 */
#define USART_SR_TC_Msk (0x0040U) /* SR bit6 发送完成 */
#define DWT_CTRL_CYCCNTENA_Msk (0x00000001U)
#define CoreDebug_DEMCR_TRCENA_Msk (0x01000000U)
#define USART1_IRQn 7
#define UART5_IRQn 7
#define HAL_UART_STATE_RESET 0x00U
#define HAL_UART_STATE_READY 0x20U
@@ -75,7 +75,7 @@ typedef enum {
extern uint32_t SystemCoreClock;
/* ------------------------------------------------------------------ */
/* ① 假 USART1 */
/* ① 假串口外设 */
/* ------------------------------------------------------------------ */
typedef struct {
volatile uint32_t SR;
@@ -368,7 +368,7 @@ extern uint32_t con_nvic_clears;
extern uint32_t con_rcc_disables;
extern uint32_t con_rcc_enables;
#define __NVIC_ClearPendingIRQ(irqn) do { (void)(irqn); con_nvic_clears++; } while (0)
#define __HAL_RCC_USART1_CLK_DISABLE() do { con_rcc_disables++; } while (0)
#define __HAL_RCC_USART1_CLK_ENABLE() do { con_rcc_enables++; } while (0)
#define __HAL_RCC_UART5_CLK_DISABLE() do { con_rcc_disables++; } while (0)
#define __HAL_RCC_UART5_CLK_ENABLE() do { con_rcc_enables++; } while (0)
#endif /* STM32F4_FAKE_H */
+6 -4
View File
@@ -1,8 +1,10 @@
/*
* 模块名称:主机侧单测桩 - usart.h(console_log 模块专用)
* 模块功能:与真实 Inc/usart.h 对本模块可见的部分逐字一致:extern UART_HandleTypeDef
* huart1; + void MX_USART1_UART_Init(void);(真实那份还声明 huart2~5 与
* MX_USART2~5_UART_Init,本模块只碰 USART1,搬多出来的声明没有意义)。
* huart5; + void MX_UART5_Init(void);(真实那份还声明 huart1~4 与
* MX_USART1~3_UART_Init / MX_UART4_Init,本模块只碰 UART5,搬多出来的声明
* 没有意义)。
* 2026-10-29 批 0:控制台从 USART1 迁到 RS232#1/UART5,本桩随之改。
* ⚠ 仅供 test/host/console_log/ 使用,不参与 MCU 构建。
* 真实 Inc/usart.h 的 include 链(main.h -> stm32f4xx_hal.h -> stm32f4xx.h ->
* core_cm4.h)在宿主上编不动,寄存器面由 stm32f4_fake.h 提供(理由见该文件头)。
@@ -14,7 +16,7 @@
#include <stdint.h>
#include "stm32f4_fake.h"
extern UART_HandleTypeDef huart1;
void MX_USART1_UART_Init(void);
extern UART_HandleTypeDef huart5;
void MX_UART5_Init(void);
#endif /* USART_H */
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""RemoDispBus(7003) 拉帧压力客户端:批 4 的 4-1「双通道并发回归」的网络侧激励。
做什么:
连 192.168.1.100:7003,先发 INIT 查询屏幕参数(应得 8 B:宽u16/高u16/帧字节数u32,
大端),随后按固定节拍持续发 LCDMEM(偏移 0) 拉整帧 3200 B 显存,逐帧校验
XOR 校验和与长度,统计请求数 / 应答数 / CRC 错 / 超时 / 往返延迟 min-avg-max。
可 --dump last_frame.pbm 存最后一帧(1bpp 布局与屏板帧 B 点阵段逐字节同构)。
为什么它能当 4-1 的激励:
7003 的每次 LCDMEM 往返在固件里是「uiTask 入队 + 等 netTask 回执」
(osMessageQueuePut(osWaitForever) + xTaskNotifyWait(portMAX_DELAY),最坏 ≈4 s,
见 ui_net_sink.h 的 ⚠),即**每一拍拉帧都在真实占用 uiTask**——这正是
「7003 拉帧期间屏板不超时」要压的条件。屏板侧应答起在 USART1 空闲中断里,
理论上不被 uiTask 拖住;4-1 的判据就是拿两边的数交叉证明这件事:
网络侧(本脚本):超时 ≈0、延迟分布正常;
固件侧(日志口 grep SBLINK):tx 跟得上 rx(drop 无新增)、bad=0、kerr=0。
协议(Drivers/BSP/UI/ui_net_sink.c 实测口径):
帧 = <TAG 1B> <CMD 1B> <LEN u16 大端> <payload LEN B> <XOR(payload) 1B>
客户端 TAG=0xAA,设备 TAG=0xBB;CMD:0=KEEP 1=INIT 2=KEY 3=LCDMEM。
LCDMEM 请求 payload = 偏移 u32 大端(仅支持 0);应答 payload = 偏移 u32 + 3200 B 位图。
(设备对 CRC 只做异或累加——与屏板链路的 CRC-16 是两套,别混。)
用法(仓库根目录,先清代理再跑——本机有 HTTP 代理会劫持内网连接):
& '.venv\\Scripts\\python.exe' test/host/remolcd_pull_test.py --seconds 30
--hz 10 拉帧节拍(默认 10 = 每秒 10 次,重压;1 次/秒 ≈ DTU-RemoteLCD 观感)
--dump x.pbm 退出前把最后一帧写成 PBM(160x160 1bpp,可直接图片查看)
退出码:0 = 全程无超时且 CRC 全对;1 = 出现超时/CRC 错/断连;2 = 用法错误。
设计依据:docs/任务规划/屏板链路.md §3 批 4 的 4-1。
创建日期:2026-10-30
"""
import argparse
import socket
import struct
import sys
import time
DEFAULT_IP = "192.168.1.100"
DEFAULT_PORT = 7003
TAG_CLIENT = 0xAA
TAG_DEVICE = 0xBB
CMD_KEEP = 0
CMD_INIT = 1
CMD_LCDMEM = 3
FB_SIZE = 3200
SCREEN_W = 160
SCREEN_H = 160
def build_frame(cmd, payload=b""):
"""组一帧客户端请求:AA <cmd> <len16be> <payload> <xor>。"""
body = bytes([TAG_CLIENT, cmd, (len(payload) >> 8) & 0xFF, len(payload) & 0xFF])
xor = 0
for b in payload:
xor ^= b
return body + payload + bytes([xor])
class FrameReader:
"""设备应答流重组(逐字节状态机,TAG 定界——与固件侧同思路,噪声字节自动重同步)。"""
def __init__(self):
self.buf = bytearray()
def pop(self):
"""从缓冲里取一个完整 (cmd, payload);没有则 None。坏帧丢弃继续找下一个 TAG。"""
while True:
i = self.buf.find(bytes([TAG_DEVICE]))
if i < 0:
del self.buf[:] # 整段都不是帧头:全弃
return None
if i > 0:
del self.buf[:i] # 丢帧头前的噪声
if len(self.buf) < 4:
return None
cmd = self.buf[1]
ln = (self.buf[2] << 8) | self.buf[3]
total = 4 + ln + 1
if len(self.buf) < total:
return None
payload = bytes(self.buf[4:4 + ln])
crc = self.buf[4 + ln]
del self.buf[:total]
xor = 0
for b in payload:
xor ^= b
if crc != xor:
return ("CRCERR", payload)
return (cmd, payload)
def feed(self, data):
self.buf += data
def run(args):
try:
s = socket.create_connection((args.ip, args.port), timeout=5.0)
except OSError as exc:
print("[ERR] 连 %s:%d 失败:%s" % (args.ip, args.port, exc))
print(" (若在内网却报超时/奇怪拒绝:先清 HTTP 代理再跑,见文件头用法行)")
return 1
s.settimeout(5.0)
rd = FrameReader()
def wait_frame(want_cmd):
"""收一个 cmd==want_cmd 的应答;CRC 错返回 ('CRCERR', None);超时返回 None。"""
t0 = time.monotonic()
while time.monotonic() - t0 < 5.0:
f = rd.pop()
if f is not None:
cmd, payload = f
if cmd == "CRCERR":
return ("CRCERR", None)
if cmd == want_cmd:
return (cmd, payload)
# 其它命令的应答(如残留 KEEP 回显):跳过继续找
continue
try:
data = s.recv(4096)
except socket.timeout:
return None
if not data:
return "CLOSED"
rd.feed(data)
return None
# ---- INIT:查询屏幕参数,顺带把几何钉死 ----
s.sendall(build_frame(CMD_INIT))
r = wait_frame(CMD_INIT)
if r is None or r == "CLOSED" or r[0] == "CRCERR":
print("[ERR] INIT 无有效应答(%s)" % (r,))
s.close()
return 1
_, payload = r
if len(payload) >= 8:
w, h, nbytes = struct.unpack(">HHI", payload[:8])
print("[INIT] %dx%d frame_bytes=%u" % (w, h, nbytes))
if w != SCREEN_W or h != SCREEN_H or nbytes != FB_SIZE:
print("[WARN] INIT 几何与预期 160x160/3200 不符——继续,以设备为准")
else:
print("[WARN] INIT 应答长度 %d < 8,跳过几何核对" % len(payload))
# ---- LCDMEM 主循环 ----
n_req = n_resp = n_crc = n_to = 0
lat_sum = lat_min = lat_max = 0.0
last_bmp = None
period = 1.0 / args.hz
t_end = time.monotonic() + args.seconds
next_at = time.monotonic()
while time.monotonic() < t_end:
now = time.monotonic()
if now < next_at:
time.sleep(min(next_at - now, 0.005))
continue
t0 = time.monotonic()
s.sendall(build_frame(CMD_LCDMEM, struct.pack(">I", 0)))
n_req += 1
r = wait_frame(CMD_LCDMEM)
if r == "CLOSED":
print("[ERR] 第 %d 次拉帧时对端断开" % n_req)
s.close()
break
if r is None:
n_to += 1
print("[WARN] #%d LCDMEM 超时(5 s)" % n_req)
else:
cmd_got, payload = r
if cmd_got == "CRCERR":
n_crc += 1
print("[WARN] #%d CRC 错" % n_req)
elif len(payload) != 4 + FB_SIZE:
n_crc += 1
print("[WARN] #%d 应答长度 %d != %d" % (n_req, len(payload), 4 + FB_SIZE))
else:
dt = time.monotonic() - t0
n_resp += 1
lat_sum += dt
lat_min = dt if lat_min == 0 else min(lat_min, dt)
lat_max = max(lat_max, dt)
last_bmp = payload[4:]
next_at += period
if next_at < time.monotonic() - 1.0: # 落后太多不追(避免突发风暴)
next_at = time.monotonic()
s.close()
avg = (lat_sum / n_resp * 1000.0) if n_resp else 0.0
print("[STAT] 请求 %d / 应答 %d / CRC错 %d / 超时 %d" % (n_req, n_resp, n_crc, n_to))
print("[STAT] 往返延迟 min %.1f / avg %.1f / max %.1f ms"
% (lat_min * 1000.0, avg, lat_max * 1000.0))
if args.dump and last_bmp:
with open(args.dump, "wb") as f:
f.write(b"P4\n%d %d\n" % (SCREEN_W, SCREEN_H))
f.write(last_bmp)
print("[DUMP] 最后一帧已写 %s(P4 PBM,与屏板帧 B 点阵段同构)" % args.dump)
ok = (n_to == 0) and (n_crc == 0) and (n_resp > 0)
print("[VERDICT] %s" % ("PASS" if ok else "FAIL"))
return 0 if ok else 1
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--ip", default=DEFAULT_IP)
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
ap.add_argument("--seconds", type=float, default=30.0)
ap.add_argument("--hz", type=float, default=10.0, help="拉帧节拍(次/秒,默认 10)")
ap.add_argument("--dump", default="", help="退出前把最后一帧写成的 PBM 路径")
args = ap.parse_args()
if args.seconds <= 0 or args.hz <= 0:
print("[ERR] --seconds / --hz 必须为正")
return 2
return run(args)
if __name__ == "__main__":
sys.exit(main())
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""屏板状态灯位映射(sb_led_map)主机侧纯逻辑单元测试运行器(纯标准库)。
做什么:用本机 MSVC 离线编译 test/host/sb_led_map/sb_led_map_test.c
(#include 纳入生产源文件 App/algo/sb_led_map.c),验证位语义
(每路状态只点亮对应位、可叠加不遮蔽)、恒零位(bit0 / ID 6~19)、
心跳闪烁相位与 tick 回绕、NULL 输入契约。
为什么这套判据在真机上测不动:真机只能看到"哪盏灯亮了"(目视),看不到
"掩码里多没多出一位保留位"——保留位被顺带点亮时屏板毫无反应,现场表现为
"改了表没效果";主机侧用"掩码 & ~定义集 == 0"一条就把映射表钉死。
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' test/host/sb_led_map/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误。
设计依据:docs/任务规划/屏板链路.md §3 批 3 的 3-1。
创建日期: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)))
sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
BUILD = TestBuild(
name="SB LED Map Tests",
src=os.path.join(HERE, "sb_led_map_test.c"),
include_dirs=[
os.path.join(ROOT, "App", "algo"),
HERE,
],
summary_marker="=== SB LED Map Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+234
View File
@@ -0,0 +1,234 @@
/*
* 模块名称:屏板状态灯位映射(sb_led_map)主机侧单元测试
* 模块功能:在 PC 上离线验证 App/algo/sb_led_map.c 的位语义、恒零位、心跳闪烁
* 相位与时基回绕行为。
*
* 被测对象:真实源文件 App/algo/sb_led_map.c(#include 原样纳入)。
* 本层纯逻辑(仅 stdint.h),**无需任何桩**——与 adc_health 套件同形。
*
* 为什么值得单测(真机上验的是"灯亮没亮",这里验的是"亮的是不是那一位"):
* 1. bit0 恒 0——屏板 ID 0 是硬件常亮电源灯,掩码 bit0 根本不被读;但一旦
* 哪天有人往映射里塞 bit0,测试先拦住,而不是到现场发现"多一位没反应"。
* 2. ID 6~19 恒 0——映射表之外的位不许被顺带点亮(掩码里出现未定义位 = 表被改
* 而测试没跟上,这是这张"自定表"唯一的防漂移手段)。
* 3. 心跳相位在 tick 回绕点不得连灭两个 500 ms 段(操作员眼里的"装置停了")。
* 4. NULL 输入只跑心跳(心跳证明的是"代码活着",不该被空输入连坐熄灭)。
*
* 编号:TC-SBLEDMAP-001 起(小写子项 = 同一判据的另一面)。
* 创建日期:2026-10-29(批 3 · 3-1)
*/
#include <stdint.h>
#include <stdio.h>
#include "sb_led_map.h"
/* 被测生产源(include_dirs 里有 App/algo) */
#include "sb_led_map.c"
static int s_pass;
static int s_fail;
#define CHECK(cond, name) \
do { \
if (cond) { \
s_pass++; \
} else { \
s_fail++; \
printf("FAIL: %s (line %d)\n", (name), __LINE__); \
} \
} while (0)
/* 亮/灭心跳各取一个代表时刻(偶段 = 亮,见 sb_led_map.c) */
#define T_ON (0U) /* [0,500) 偶段 → 亮 */
#define T_OFF (500U) /* [500,1000) 奇段 → 灭 */
/*
* TC-SBLEDMAP-001 全零输入:只有心跳位,其余状态位全灭
*/
static void tc_zero_input(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 0U;
in.prot_pick = 0U;
in.adc_flags = 0U;
in.iec104_run = 0U;
m = sb_led_map_eval(&in, T_ON);
CHECK(m == SB_LED_BIT_RUN, "TC-SBLEDMAP-001 zero input lights only the run blink (on phase)");
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == 0U, "TC-SBLEDMAP-001b zero input in the dark half-cycle is all-zero");
}
/*
* TC-SBLEDMAP-002 每一路状态各自点亮且只点亮对应位(非零即真,不看具体值)
*/
static void tc_one_source_each(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 0U;
in.prot_pick = 0U;
in.adc_flags = 0U;
in.iec104_run = 0U;
in.prot_active = 0x00080000U; /* 随便哪个元件动作 */
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_ALARM, "TC-SBLEDMAP-002 prot_active -> ID 2 only");
in.prot_active = 0U;
in.prot_pick = 1U;
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_PICKUP, "TC-SBLEDMAP-002b prot_pick -> ID 5 only");
in.prot_pick = 0U;
in.adc_flags = 0x80000000U; /* 高位故障位同样算非零 */
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_ADC_FAULT, "TC-SBLEDMAP-002c adc_flags -> ID 4 only");
in.adc_flags = 0U;
in.iec104_run = 1U;
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == SB_LED_BIT_COMM, "TC-SBLEDMAP-002d iec104_run -> ID 3 only");
in.iec104_run = 0U;
}
/*
* TC-SBLEDMAP-003 多路同时:位可叠加、互不遮蔽(告警+通信同时真是合法工况)
*/
static void tc_stack(void)
{
sb_led_in_t in;
uint32_t m;
in.prot_active = 3U;
in.prot_pick = 3U;
in.adc_flags = 1U;
in.iec104_run = 1U;
m = sb_led_map_eval(&in, T_ON);
CHECK(m == (SB_LED_BITS_DEFINED), "TC-SBLEDMAP-003 all sources + blink = exactly the defined set");
m = sb_led_map_eval(&in, T_OFF);
CHECK(m == (SB_LED_BITS_DEFINED & ~SB_LED_BIT_RUN),
"TC-SBLEDMAP-003b dark half-cycle drops only the run bit");
}
/*
* TC-SBLEDMAP-004 恒零位:bit0 与 ID 6~19 在任何输入下不得出现
*(bit0 屏板根本不读;ID 6~19 是保留位——掩码出现表外位 = 表被改而没走测试)
*/
static void tc_reserved_bits(void)
{
sb_led_in_t in;
uint32_t t;
int clean = 1;
in.prot_active = 0xFFFFFFFFU;
in.prot_pick = 0xFFFFFFFFU;
in.adc_flags = 0xFFFFFFFFU;
in.iec104_run = 1U;
/* 扫 3 秒时基(6 个半周期)+ 边界时刻,全取定义集做差 */
for (t = 0U; t < 3000U; t += 499U) {
uint32_t m = sb_led_map_eval(&in, t);
if ((m & ~SB_LED_BITS_DEFINED) != 0U) {
clean = 0;
}
}
CHECK(clean == 1, "TC-SBLEDMAP-004 no bit outside the defined set ever appears");
CHECK((sb_led_map_eval(&in, T_ON) & 1U) == 0U, "TC-SBLEDMAP-004b bit0 stays 0 (screen board ignores it)");
}
/*
* TC-SBLEDMAP-005 心跳相位:连续 8 个半周期亮灭交替,边界含等号(500 整 = 暗段起点)
*/
static void tc_blink_sequence(void)
{
int i;
int prev = -1;
int alt = 1;
uint32_t m;
for (i = 0; i < 8; i++) {
uint32_t t = (uint32_t)i * SB_LED_BLINK_HALF_MS;
int on;
m = sb_led_map_eval(NULL, t);
on = ((m & SB_LED_BIT_RUN) != 0U) ? 1 : 0;
if ((prev >= 0) && (on == prev)) {
alt = 0;
}
prev = on;
}
CHECK(alt == 1, "TC-SBLEDMAP-005 run bit alternates every half-period");
CHECK((sb_led_map_eval(NULL, 0U) & SB_LED_BIT_RUN) != 0U,
"TC-SBLEDMAP-005b power-on first half-period is LIT (blink visible from t=0)");
CHECK((sb_led_map_eval(NULL, SB_LED_BLINK_HALF_MS) & SB_LED_BIT_RUN) == 0U,
"TC-SBLEDMAP-005c the boundary instant belongs to the dark half-period");
CHECK((sb_led_map_eval(NULL, SB_LED_BLINK_HALF_MS - 1U) & SB_LED_BIT_RUN) != 0U,
"TC-SBLEDMAP-005d last ms before the boundary still lit");
}
/*
* TC-SBLEDMAP-006 tick 回绕:跨 0xFFFFFFFF 连续熄灭时长不得超过一个半周期
*(相位由除法直接得出、不依赖"上次"状态。回绕把末段截短、下一段从"偶段=亮"
* 重新开始——最坏也只是某段被截短,不会出现操作员眼里的"连灭 1 s = 装置停了"。
* 1 ms 步进扫回绕前后各 1.5 个周期,量最长暗段。)
*/
static void tc_wraparound(void)
{
uint32_t t;
int run = 0; /* 当前连续暗 ms 数 */
int worst = 0;
/* now 从 0xFFFFFFFF-1500 走到 0xFFFFFFFF 再接 0..1500(模 2^32 连续) */
for (t = 0xFFFFFFFEU - 1500U; t != 0xFFFFFFFFU; t++) {
int dark = ((sb_led_map_eval(NULL, t) & SB_LED_BIT_RUN) == 0U) ? 1 : 0;
run = (dark != 0) ? (run + 1) : 0;
if (run > worst) {
worst = run;
}
}
for (t = 0U; t <= 1500U; t++) {
int dark = ((sb_led_map_eval(NULL, t) & SB_LED_BIT_RUN) == 0U) ? 1 : 0;
run = (dark != 0) ? (run + 1) : 0;
if (run > worst) {
worst = run;
}
}
/* 半周期 500 ms + 1 ms 扫描步进余量 */
CHECK(worst <= (int)SB_LED_BLINK_HALF_MS + 1,
"TC-SBLEDMAP-006 dark run never exceeds one half-period across tick wraparound");
}
/*
* TC-SBLEDMAP-007 NULL 输入 = 全零输入 + 心跳照常(注释契约:心跳不被连坐)
*/
static void tc_null_input(void)
{
CHECK(sb_led_map_eval(NULL, T_ON) == SB_LED_BIT_RUN,
"TC-SBLEDMAP-007 NULL input still blinks (liveness signal must not depend on inputs)");
CHECK(sb_led_map_eval(NULL, T_OFF) == 0U,
"TC-SBLEDMAP-007b NULL input lights nothing else");
}
int main(void)
{
printf("-- sb_led_map unit tests --\n");
tc_zero_input();
tc_one_source_each();
tc_stack();
tc_reserved_bits();
tc_blink_sequence();
tc_wraparound();
tc_null_input();
printf("=== SB LED Map Tests: %d/%d PASSED ===\n", s_pass, s_pass + s_fail);
return (s_fail == 0) ? 0 : 1;
}
+59
View File
@@ -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))
+870
View File
@@ -0,0 +1,870 @@
/*
* 模块名称:屏板链路传输层(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 统计快照:空指针守卫、字段逐项对得上
*
* 覆盖点(对应 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 脚本判读)。
*
* 创建日期:2026-10-29
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ===================== 断言 ===================== */
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.gState = 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);
}
/* 假显存:组帧的输入(内容按 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)
{
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");
/* 有错帧:再出一行,且 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 == 4U, "TC-SBLINK-012j (sanity) four 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");
}
/* ===================== 批 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");
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("-- 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;
}
+58
View File
@@ -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 <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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 */
+259
View File
@@ -0,0 +1,259 @@
/*
* 模块名称:主机侧单测桩 - 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 <stddef.h>
#include <stdint.h>
/* ===== 与真实头同值的枚举(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)
#define HAL_UART_STATE_BUSY_TX (0x24U)
/* 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;
}
/* ===== ④ 假 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;
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;
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 */
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 */
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""屏板链路协议层(sb_proto)的主机侧纯逻辑单元测试运行器(纯标准库)。
做什么:
用本机 MSVC 编译 test/host/sb_proto/sb_proto_test.c(它以 #include 纳入**生产源文件**
Drivers/BSP/ScreenLink/sb_proto.c),在 PC 上离线验证帧 A 解析 / 帧 B 组帧 /
CRC-16/MODBUS:教科书校验值、屏板实测的召唤帧字节、坏帧分支、4208 B 帧的
点阵-掩码-填充-CRC 落位、LOGO 宽高容量守卫、以及"按屏板 Check_CRC 的口径复核
本模块组的帧会不会被屏板接受"。零硬件、零烧录、毫秒级。
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness
`test/host/common/unit_test_harness.py`(本文件只做声明)。
为什么要主机侧单测(而不是只用台架):
屏板不认帧时,它的超时判定只看"收没收到一整包"、不看 CRC,现场只表现为**画面不刷新**——
这条现象与波特率错、时序错、线接反完全同形,台架上无法归因。把 CRC 与字节序钉在主机侧,
台架上剩下的就只可能是物理层问题。另一处只能在这里测:CRC 的**字节序**口径。
屏板固件 Calculate_CRC() 返回的是高低互换后的「线上字」,它自己的注释把低字节叫"高字节";
照字面实现会让每一帧都差一次字节交换,而现象又是"画面不刷新"——这里钉了两条屏板实测向量。
本层为**纯逻辑**:无寄存器、无 RTOS、无文件/外设依赖,故**不需要任何桩目录**。
用法(仓库根目录):
python test/host/sb_proto/run_unit_test.py
或统一用 .venv: & '.venv\\Scripts\\python.exe' test/host/sb_proto/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
设计依据:docs/产品需求/产品需求设计文档.md §5.16.2(帧结构);
docs/任务规划/屏板链路.md §3 批 1(1-1 的判据);
协议事实源 = 屏板固件工程 D:\\Code\\DTU Code\\DTU-LCD 的 CRC16.c / MODBUS.c。
创建日期: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
# 无桩目录:被测代码只依赖 stdint.h / string.h。
# 两个 include 目录各管一个头:ScreenLink 找 sb_proto.h,Drivers/BSP 找 bsp_err.h。
BUILD = TestBuild(
name="ScreenLink Proto Tests",
src=os.path.join(HERE, "sb_proto_test.c"),
include_dirs=[
os.path.join(ROOT, "Drivers", "BSP", "ScreenLink"),
os.path.join(ROOT, "Drivers", "BSP"),
HERE,
],
summary_marker="=== ScreenLink Proto Tests:",
)
if __name__ == "__main__":
sys.exit(run_host_unit_test(BUILD))
+490
View File
@@ -0,0 +1,490 @@
/*
* 模块名称:屏板链路协议层(sb_proto)主机侧单元测试
* 模块功能:在 PC 上对 Drivers/BSP/ScreenLink/sb_proto.c 做离线验证。
*
* 被测对象:真实源文件(以 #include 原样纳入,保证测的是生产代码而非副本):
* ../../../Drivers/BSP/ScreenLink/sb_proto.c
* 本层为**纯逻辑**(只依赖 stdint.h 与 string.h),故**无需任何桩**。
*
* 为什么要主机侧单测(而不是只用台架):
* 1. 协议层只有两种结局:屏板认 / 屏板不认。屏板不认时它的超时判定只看「收没收到一整包」、
* 不看 CRC,现场只表现为**画面不刷新**——在台架上这条现象与波特率错、时序错、线接反
* 完全同形,无法归因。主机侧把 CRC 与字节序钉死,才谈得上"发出去的就是对的"。
* 2. 帧 A 的坏帧分支(长度不对 / 地址不对 / 功能码不对 / CRC 错)在台架上要造噪声才可能出现,
* 且一出现就被复位冲掉;主机侧是几行断言。
* 3. 帧 B 4208 B 的边界(填充段参与 CRC、LOGO 宽高装不下点阵)不占 MCU 的 RAM,毫秒级。
*
* 覆盖点(对应 docs/产品需求/产品需求设计文档.md §5.16.2 帧结构与任务规划 1-1 判据):
* TC-SBPROTO-001 CRC-16/MODBUS 教科书校验值:"123456789" -> 0x4B37
* TC-SBPROTO-002 CRC 与 Modbus RTU 教科书帧一致:01 03 00 00 00 0A -> 0xCDC5(线上 C5 CD)
* TC-SBPROTO-003 查表法 == 逐位法(300 组伪随机长度/内容,钉住表被改错)
* TC-SBPROTO-004 查表法边界:len 个字节与逐字节累积等价(前 1 字节 / 全 0 / 全 FF)
* TC-SBPROTO-010 帧 A 实测向量:屏板发的周期召唤 01 03 01 00 F0 48 -> RT_CMD / key 0
* TC-SBPROTO-011 帧 A 实测向量:按键召唤 01 03 00 09 31 DE -> KEY_CMD / key 9
* TC-SBPROTO-012 帧 A 的 key:RT 命令下非 0 也按 0 收(不为此丢一帧)
* TC-SBPROTO-013 帧 A 坏帧:CRC 错 / 长度 5 与 7 / 地址 0x02 / 功能码 0x04 / 命令类型 0x02
* TC-SBPROTO-014 帧 A 参数守卫:空指针两路;失败时不动 *p_out
* TC-SBPROTO-020 帧 B 普通图:点阵逐字节、掩码小端、info[4..7] 为 0、填充段全 0、CRC 自校验
* TC-SBPROTO-021 帧 B 普通图:整帧重写(预填 0xAA 的缓冲被清干净)、重复组帧结果一致
* TC-SBPROTO-022 帧 B LOGO:宽高大端、魔数 0x89 0x45、info[6..7] 为 0、CRC 自校验
* TC-SBPROTO-023 帧 B LOGO 宽高守卫:161x159 与 160x161 被拒(按字节数判容量)、宽或高为 0 被拒
* TC-SBPROTO-024 帧 B 参数守卫:空指针三处
* TC-SBPROTO-030 帧 B 自校验:改点阵 / 改填充段 / 改 CRC 自身 / 长度不对
* TC-SBPROTO-040 屏板侧视角:用屏板 Check_CRC 的口径(高低互换后比对)复核本模块组的两种帧
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
*
* 创建日期:2026-10-29
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ===================== 断言 ===================== */
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"
/* ===================== 工具 ===================== */
/* 伪随机(自持实现,不依赖 rand() 的实现差异,换编译器也复现同一组向量) */
static uint32_t s_rng = 0x20261029U;
static uint32_t rng_next(void)
{
s_rng = (s_rng * 1103515245U) + 12345U;
return (s_rng >> 8) & 0x00FFFFFFU;
}
/* 逐位法 CRC-16/MODBUS:与被测查表法互为独立实现,用来抓表项被改错 */
static uint16_t crc16_bitwise(const uint8_t *p, uint16_t len)
{
uint16_t crc = 0xFFFFU;
uint16_t i;
uint8_t b;
for (i = 0U; i < len; i++)
{
crc ^= (uint16_t)p[i];
for (b = 0U; b < 8U; b++)
{
if ((crc & 1U) != 0U)
{
crc = (uint16_t)((crc >> 1) ^ 0xA001U);
}
else
{
crc = (uint16_t)(crc >> 1);
}
}
}
return crc;
}
/*
* 屏板 Check_CRC() 的等价物(照屏板固件 Middlewares/Modbus/CRC16.c 的口径):
* 屏板的 Calculate_CRC() 返回的是高低字节互换后的「线上字」,所以它把帧尾两字节按
* 「前一个是高」拼起来再比。本函数只用 sb_crc16 的标准值复刻这一口径,
* 用来回答"屏板会不会认这一帧"。
*/
static int screen_board_accepts(const uint8_t *buf, uint16_t len)
{
uint16_t wire;
uint16_t crc;
if (len < 2U)
{
return 0;
}
wire = (uint16_t)(((uint16_t)buf[len - 2U] << 8) | (uint16_t)buf[len - 1U]);
crc = sb_crc16(buf, (uint16_t)(len - 2U));
/* 标准值高低互换 == 屏板 Calculate_CRC() 的返回值 */
return ((uint16_t)((crc << 8) | (crc >> 8)) == wire) ? 1 : 0;
}
/* 按给定 4 字节组一帧帧 A(用生产代码自己的 CRC,再按线上顺序摆) */
static void make_frame_a(uint8_t *p_out, uint8_t addr, uint8_t func, uint8_t cmd, uint8_t key)
{
uint16_t crc;
p_out[0] = addr;
p_out[1] = func;
p_out[2] = cmd;
p_out[3] = key;
crc = sb_crc16(p_out, (uint16_t)SB_FRAME_A_CRC_OFF);
p_out[4] = (uint8_t)(crc & 0xFFU);
p_out[5] = (uint8_t)((crc >> 8) & 0xFFU);
}
static uint8_t s_frame[SB_FRAME_B_LEN]; /* 4208 B,帧 B 输出缓冲 */
static uint8_t s_bmp[SB_FRAME_B_BMP_LEN]; /* 3200 B,点阵源 */
static void fill_bmp_pattern(void)
{
uint16_t i;
for (i = 0U; i < SB_FRAME_B_BMP_LEN; i++)
{
s_bmp[i] = (uint8_t)((i * 37U) & 0xFFU);
}
}
/* ===================== 用例 ===================== */
static void tc_crc_vectors(void)
{
static const uint8_t k_check[] = { '1', '2', '3', '4', '5', '6', '7', '8', '9' };
static const uint8_t k_modbus[] = { 0x01, 0x03, 0x00, 0x00, 0x00, 0x0A };
uint16_t crc;
crc = sb_crc16(k_check, (uint16_t)sizeof(k_check));
CHECK(crc == 0x4B37U, "TC-SBPROTO-001 CRC-16/MODBUS check value of '123456789' is 0x4B37");
crc = sb_crc16(k_modbus, (uint16_t)sizeof(k_modbus));
CHECK(crc == 0xCDC5U,
"TC-SBPROTO-002 the textbook Modbus RTU frame yields 0xCDC5, so the wire bytes are C5 CD (low first)");
}
static void tc_crc_table(void)
{
uint8_t buf[64];
uint16_t n;
uint16_t i;
int mismatch = 0;
for (n = 0U; n < 300U; n++)
{
uint16_t len = (uint16_t)(rng_next() % 65U);
for (i = 0U; i < len; i++)
{
buf[i] = (uint8_t)(rng_next() & 0xFFU);
}
if (sb_crc16(buf, len) != crc16_bitwise(buf, len))
{
mismatch++;
}
}
CHECK(mismatch == 0,
"TC-SBPROTO-003 the table walk agrees with the bitwise reference on 300 pseudo-random buffers");
buf[0] = 0x00U;
CHECK(sb_crc16(buf, 1U) == crc16_bitwise(buf, 1U),
"TC-SBPROTO-004 a single 0x00 byte: table and bitwise agree (initial value is really 0xFFFF)");
for (i = 0U; i < 32U; i++)
{
buf[i] = 0xFFU;
}
CHECK(sb_crc16(buf, 32U) == crc16_bitwise(buf, 32U),
"TC-SBPROTO-004b 32 bytes of 0xFF: table and bitwise agree");
}
static void tc_frame_a_vectors(void)
{
/* 屏板 RS485_RefreshScreenCMD() 在 700000 8N1 上真实发出去的字节:
* 周期召唤(type=RT_CMD=1, key=0)与按键召唤(type=KEY_CMD=0, key=9)。
* 这两条是拿屏板自己的算法算出来的线上字节,**不是**本模块算的。 */
static const uint8_t k_rt[] = { 0x01, 0x03, 0x01, 0x00, 0xF0, 0x48 };
static const uint8_t k_key[] = { 0x01, 0x03, 0x00, 0x09, 0x31, 0xDE };
sb_frame_a_t out;
memset(&out, 0, sizeof(out));
CHECK(sb_frame_a_parse(k_rt, (uint16_t)sizeof(k_rt), &out) == BSP_OK,
"TC-SBPROTO-010 the screen board periodic summon 01 03 01 00 F0 48 parses");
CHECK((out.cmd == SB_CMD_RT) && (out.key == 0U),
"TC-SBPROTO-010b it reports RT_CMD with key 0");
memset(&out, 0, sizeof(out));
CHECK(sb_frame_a_parse(k_key, (uint16_t)sizeof(k_key), &out) == BSP_OK,
"TC-SBPROTO-011 the screen board key summon 01 03 00 09 31 DE parses");
CHECK((out.cmd == SB_CMD_KEY) && (out.key == 9U),
"TC-SBPROTO-011b it reports KEY_CMD with key 9");
/* 屏板会认本模块自己组出来的这两帧吗(同一口径反查) */
CHECK(screen_board_accepts(k_rt, (uint16_t)sizeof(k_rt)) == 1,
"TC-SBPROTO-011c the board side accepts the periodic summon frame");
CHECK(screen_board_accepts(k_key, (uint16_t)sizeof(k_key)) == 1,
"TC-SBPROTO-011d the board side accepts the key summon frame");
}
static void tc_frame_a_lenient_key(void)
{
uint8_t buf[SB_FRAME_A_LEN];
sb_frame_a_t out;
/* 屏板周期召唤本应带 key = 0;真带了非 0 也收下(丢一帧就等于丢一次刷新) */
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0x2AU);
memset(&out, 0, sizeof(out));
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_OK,
"TC-SBPROTO-012 an RT frame carrying a non-zero key is still accepted");
CHECK(out.key == 0U, "TC-SBPROTO-012b its key is normalised to 0 (the key field only means something for KEY_CMD)");
/* KEY_CMD 的 key 原样透出,包括 0 与 0xFF */
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_KEY, 0x00U);
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_OK && out.key == 0x00U,
"TC-SBPROTO-012c a KEY_CMD with key 0 is accepted and keeps 0");
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_KEY, 0xFFU);
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_OK && out.key == 0xFFU,
"TC-SBPROTO-012d a KEY_CMD with key 0xFF is accepted and keeps 0xFF");
}
static void tc_frame_a_reject(void)
{
uint8_t buf[SB_FRAME_A_LEN];
sb_frame_a_t out;
sb_frame_a_t sentinel;
/* CRC 错:改 CRC 低字节 / 改数据字节 */
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
buf[4] ^= 0x01U;
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_CRC,
"TC-SBPROTO-013 a flipped CRC byte is reported as CRC error");
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
buf[2] ^= 0x02U;
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_CRC,
"TC-SBPROTO-013b a flipped payload byte is reported as CRC error");
/* 长度不对 */
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
CHECK(sb_frame_a_parse(buf, (uint16_t)(SB_FRAME_A_LEN - 1U), &out) == BSP_ERR_PARAM,
"TC-SBPROTO-013c a 5-byte frame is rejected");
CHECK(sb_frame_a_parse(buf, (uint16_t)(SB_FRAME_A_LEN + 1U), &out) == BSP_ERR_PARAM,
"TC-SBPROTO-013d a 7-byte frame is rejected (the idle interrupt may hand over a longer burst)");
/* 地址 / 功能码 / 命令类型不符(CRC 都是对的,纯粹是内容不对) */
make_frame_a(buf, 0x02U, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_PARAM,
"TC-SBPROTO-013e slave address 0x02 is rejected even with a good CRC");
make_frame_a(buf, SB_ADDR, 0x04U, SB_CMD_RT, 0U);
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_PARAM,
"TC-SBPROTO-013f function code 0x04 is rejected even with a good CRC");
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, 0x02U, 0U);
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_PARAM,
"TC-SBPROTO-013g command type 0x02 is rejected even with a good CRC");
/* 失败时不动 *p_out */
sentinel.cmd = 0x5AU;
sentinel.key = 0xA5U;
out = sentinel;
make_frame_a(buf, 0x02U, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
(void)sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, &out);
CHECK((out.cmd == 0x5AU) && (out.key == 0xA5U),
"TC-SBPROTO-014 a failed parse leaves the caller result struct untouched");
}
static void tc_frame_a_guard(void)
{
uint8_t buf[SB_FRAME_A_LEN];
sb_frame_a_t out;
make_frame_a(buf, SB_ADDR, SB_FUNC_READ_HOLDING, SB_CMD_RT, 0U);
CHECK(sb_frame_a_parse(0, (uint16_t)SB_FRAME_A_LEN, &out) == BSP_ERR_PARAM,
"TC-SBPROTO-014b a null buffer is rejected");
CHECK(sb_frame_a_parse(buf, (uint16_t)SB_FRAME_A_LEN, 0) == BSP_ERR_PARAM,
"TC-SBPROTO-014c a null result pointer is rejected");
}
static void tc_frame_b_image(void)
{
uint16_t i;
uint16_t crc;
int bmp_ok = 1;
int pad_ok = 1;
fill_bmp_pattern();
CHECK(sb_frame_b_build_image(s_frame, s_bmp, 0xDEADBEEFU) == BSP_OK,
"TC-SBPROTO-020 building an image frame succeeds");
for (i = 0U; i < SB_FRAME_B_BMP_LEN; i++)
{
if (s_frame[i] != s_bmp[i])
{
bmp_ok = 0;
}
}
CHECK(bmp_ok == 1, "TC-SBPROTO-020b the 3200-byte bitmap lands at offset 0 unchanged");
CHECK((s_frame[SB_FRAME_B_INFO_OFF + 0U] == 0xEFU) &&
(s_frame[SB_FRAME_B_INFO_OFF + 1U] == 0xBEU) &&
(s_frame[SB_FRAME_B_INFO_OFF + 2U] == 0xADU) &&
(s_frame[SB_FRAME_B_INFO_OFF + 3U] == 0xDEU),
"TC-SBPROTO-020c the LED mask goes into info[0..3] little-endian (info[0] = bit0..7)");
CHECK((s_frame[SB_FRAME_B_INFO_OFF + 4U] == 0U) && (s_frame[SB_FRAME_B_INFO_OFF + 5U] == 0U) &&
(s_frame[SB_FRAME_B_INFO_OFF + 6U] == 0U) && (s_frame[SB_FRAME_B_INFO_OFF + 7U] == 0U),
"TC-SBPROTO-020d info[4..7] stay 0 for a normal picture (the LOGO magic must not leak in)");
for (i = SB_FRAME_B_PAD_OFF; i < SB_FRAME_B_CRC_OFF; i++)
{
if (s_frame[i] != 0U)
{
pad_ok = 0;
}
}
CHECK(pad_ok == 1, "TC-SBPROTO-020e the 998-byte padding block is all zero");
crc = sb_crc16(s_frame, (uint16_t)SB_FRAME_B_CRC_OFF);
CHECK((s_frame[SB_FRAME_B_CRC_OFF] == (uint8_t)(crc & 0xFFU)) &&
(s_frame[SB_FRAME_B_CRC_OFF + 1U] == (uint8_t)((crc >> 8) & 0xFFU)),
"TC-SBPROTO-020f the CRC sits at 4206/4207 low byte first");
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_OK,
"TC-SBPROTO-020g the built frame verifies");
CHECK(screen_board_accepts(s_frame, (uint16_t)SB_FRAME_B_LEN) == 1,
"TC-SBPROTO-040 the board side Check_CRC accepts the image frame");
}
static void tc_frame_b_rewrite(void)
{
static uint8_t first[SB_FRAME_B_LEN];
uint16_t i;
int pad_ok = 1;
/* 预填脏数据:整帧重写必须把填充段与 info 一起清干净 */
memset(s_frame, 0xAA, sizeof(s_frame));
fill_bmp_pattern();
CHECK(sb_frame_b_build_image(s_frame, s_bmp, 0x00000001U) == BSP_OK,
"TC-SBPROTO-021 building over a dirty buffer succeeds");
for (i = SB_FRAME_B_PAD_OFF; i < SB_FRAME_B_CRC_OFF; i++)
{
if (s_frame[i] != 0U)
{
pad_ok = 0;
}
}
CHECK(pad_ok == 1, "TC-SBPROTO-021b a 0xAA-pre-filled buffer is fully rewritten (padding zeroed)");
memcpy(first, s_frame, sizeof(first));
CHECK(sb_frame_b_build_image(s_frame, s_bmp, 0x00000001U) == BSP_OK,
"TC-SBPROTO-021c building the same content twice succeeds");
CHECK(memcmp(first, s_frame, sizeof(first)) == 0,
"TC-SBPROTO-021d the same inputs give a byte-identical frame (idempotent)");
}
static void tc_frame_b_logo(void)
{
fill_bmp_pattern();
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 160U, 160U) == BSP_OK,
"TC-SBPROTO-022 building a 160x160 LOGO frame succeeds");
CHECK((s_frame[SB_FRAME_B_INFO_OFF + 0U] == 0x00U) && (s_frame[SB_FRAME_B_INFO_OFF + 1U] == 0xA0U) &&
(s_frame[SB_FRAME_B_INFO_OFF + 2U] == 0x00U) && (s_frame[SB_FRAME_B_INFO_OFF + 3U] == 0xA0U),
"TC-SBPROTO-022b width and height go into info[0..3] big-endian");
CHECK((s_frame[SB_FRAME_B_INFO_OFF + 4U] == 0x89U) && (s_frame[SB_FRAME_B_INFO_OFF + 5U] == 0x45U),
"TC-SBPROTO-022c the LOGO magic 0x89 0x45 sits in info[4..5]");
CHECK((s_frame[SB_FRAME_B_INFO_OFF + 6U] == 0U) && (s_frame[SB_FRAME_B_INFO_OFF + 7U] == 0U),
"TC-SBPROTO-022d info[6..7] stay 0 (reserved)");
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_OK,
"TC-SBPROTO-022e the LOGO frame verifies");
CHECK(screen_board_accepts(s_frame, (uint16_t)SB_FRAME_B_LEN) == 1,
"TC-SBPROTO-040b the board side Check_CRC accepts the LOGO frame");
/* 小图也要能组:1x1 只需 1 字节点阵 */
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 1U, 1U) == BSP_OK,
"TC-SBPROTO-022f a 1x1 LOGO is accepted (the capacity check is about bytes, not pixels)");
}
static void tc_frame_b_logo_guard(void)
{
fill_bmp_pattern();
/* 161x159 = 25599 像素(看起来"装得下"),实际要 21x159 = 3339 B > 3200 B */
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 161U, 159U) == BSP_ERR_PARAM,
"TC-SBPROTO-023 161x159 is rejected: 3339 bytes of bitmap do not fit in 3200");
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 160U, 161U) == BSP_ERR_PARAM,
"TC-SBPROTO-023b 160x161 is rejected (3220 bytes)");
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 168U, 152U) == BSP_OK,
"TC-SBPROTO-023c 168x152 is accepted (exactly 3192 bytes, width not a multiple of 8 is fine)");
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 0U, 160U) == BSP_ERR_PARAM,
"TC-SBPROTO-023d a zero width is rejected");
CHECK(sb_frame_b_build_logo(s_frame, s_bmp, 160U, 0U) == BSP_ERR_PARAM,
"TC-SBPROTO-023e a zero height is rejected");
}
static void tc_frame_b_guard(void)
{
fill_bmp_pattern();
CHECK(sb_frame_b_build_image(0, s_bmp, 0U) == BSP_ERR_PARAM,
"TC-SBPROTO-024 a null frame buffer is rejected (image)");
CHECK(sb_frame_b_build_image(s_frame, 0, 0U) == BSP_ERR_PARAM,
"TC-SBPROTO-024b a null bitmap is rejected (image)");
CHECK(sb_frame_b_build_logo(0, s_bmp, 160U, 160U) == BSP_ERR_PARAM,
"TC-SBPROTO-024c a null frame buffer is rejected (LOGO)");
CHECK(sb_frame_b_build_logo(s_frame, 0, 160U, 160U) == BSP_ERR_PARAM,
"TC-SBPROTO-024d a null bitmap is rejected (LOGO)");
CHECK(sb_frame_b_verify(0, (uint16_t)SB_FRAME_B_LEN) == BSP_ERR_PARAM,
"TC-SBPROTO-024e a null frame is rejected by verify");
}
static void tc_frame_b_verify(void)
{
fill_bmp_pattern();
(void)sb_frame_b_build_image(s_frame, s_bmp, 0x00000000U);
s_frame[100] ^= 0x01U;
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_ERR_CRC,
"TC-SBPROTO-030 a flipped bitmap byte is caught");
s_frame[100] ^= 0x01U;
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_OK,
"TC-SBPROTO-030b flipping it back restores the verdict");
/* 填充段屏板不读,但它参与 CRC:改了必须报错,否则"填充随便填"会悄悄破坏帧 */
s_frame[SB_FRAME_B_PAD_OFF + 500U] = 0x01U;
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_ERR_CRC,
"TC-SBPROTO-030c a modified padding byte is caught (the padding really is inside the CRC range)");
s_frame[SB_FRAME_B_PAD_OFF + 500U] = 0x00U;
s_frame[SB_FRAME_B_CRC_OFF] ^= 0xFFU;
CHECK(sb_frame_b_verify(s_frame, (uint16_t)SB_FRAME_B_LEN) == BSP_ERR_CRC,
"TC-SBPROTO-030d a corrupted CRC byte is caught");
(void)sb_frame_b_build_image(s_frame, s_bmp, 0U);
CHECK(sb_frame_b_verify(s_frame, (uint16_t)(SB_FRAME_B_LEN - 1U)) == BSP_ERR_PARAM,
"TC-SBPROTO-030e a length other than 4208 is rejected");
}
int main(void)
{
printf("=== ScreenLink Proto Tests ===\n");
printf("-- CRC-16/MODBUS check values --\n");
tc_crc_vectors();
printf("-- CRC table vs bitwise reference --\n");
tc_crc_table();
printf("-- frame A: the board's real summon bytes --\n");
tc_frame_a_vectors();
printf("-- frame A: key handling --\n");
tc_frame_a_lenient_key();
printf("-- frame A: rejection paths --\n");
tc_frame_a_reject();
printf("-- frame A: argument guards --\n");
tc_frame_a_guard();
printf("-- frame B: image (bitmap + LED mask) --\n");
tc_frame_b_image();
printf("-- frame B: full-frame rewrite / idempotence --\n");
tc_frame_b_rewrite();
printf("-- frame B: LOGO (width/height + magic) --\n");
tc_frame_b_logo();
printf("-- frame B: LOGO capacity guard --\n");
tc_frame_b_logo_guard();
printf("-- frame B: argument guards --\n");
tc_frame_b_guard();
printf("-- frame B: self-verification --\n");
tc_frame_b_verify();
printf("\n=== ScreenLink Proto Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
return (s_failed == 0) ? 0 : 1;
}
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""屏板链路台架激励:用 PC + USB 转 TTL **顶替屏板**,按屏板的召唤节拍发 6 B 帧 A。
做什么:
1. **发**:在一个 TTL 串口上按屏板的节拍(默认 280 ms)发帧 A,模拟屏板的周期召唤;
可穿插按键帧(`--key N`)。
2. **收**(`--reply`,批 2 起):每发一帧就收主机回的 4208 B 帧 B,校验长度 / CRC /
998 B 填充,量"召唤末位 → 应答首位"的间隔与整帧耗时,可选把点阵 dump 成 PBM。
主机侧的统计由固件自己打(`[SBLINK] rx=... tx=...`),**收侧观测面是串口日志**;
本脚本的 `--reply` 是另一路观测(应答本身对不对),两者互补,不能互相替代。
为什么不用串口助手 GUI:
1-3 的判据含"连续 10 min 无丢帧",手点是做不到的;而且帧内容必须逐字节对
(CRC 低字节在前,写错一位就是 100 % CRC 错帧,与"波特率错"同形)。
2-2 的判据含"整帧 59.6 ms ±0.5 ms、≤200 ms 内结束",更是只能靠脚本量。
⚠ **USB 转 TTL 适配器量不到"帧内无间隙"**:USB 帧是 1 ms 量级,适配器把字节成批交上来,
所以 `--reply` 报的 `span` 只能证明"整帧在 60 ms 量级内发完",不能证明"没有 ≥28 µs
的间隙"(那是屏板断帧的硬约束,只能上逻辑分析仪或接真屏板时看)。别把 span 通过
当成那条判据通过。
接法(docs/任务规划/屏板链路.md §2 决策一):
适配器 TXD -> U5.B10(网络 ST_RX0,PA10) 适配器 RXD -> U5.A10(网络 ST_TX0,PA9)
适配器 GND -> 板地
⚠ 必须交叉接(TX 对 RX);USART1 的 TTL 只从 U5 出去,CN1 上没有 USART1 端子。
串口参数 700000 8N1,与固件 `SB_LINK_BAUD` 一致。
与日志口的关系:
日志走 RS-232 侧(默认 COM3,由 serial-assistant daemon 占用),本脚本占用的是
**另一路 TTL 口**(台架上通常是 COM6)——两者互不相干,可同时跑。
用法(仓库根目录):
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 30
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --seconds 600 # 10 min 判据
& '.venv\\Scripts\\python.exe' test/host/screenlink_bench.py --port COM6 --reply --dump lcd.pbm
--key N 每 N 个周期把召唤帧换成按键帧(键值在 1/2/3/4/7/8/9 里轮换;0 = 不发按键帧)
--reply 每帧收应答并校验(批 2 的 2-2 / 2-4)
--dump P 把最后一帧应答的点阵存成 PBM(P4,160x160,可直接看)
退出码:0 = 按时发完且(`--reply` 时)所有应答都对;1 = 串口打不开 / 发送失败;
2 = 用法错误;3 = 有应答不合格。
设计依据:docs/任务规划/屏板链路.md §3 批 1 的 1-3 判据、批 2 的 2-2/2-4、§5 台架联调步骤第 2、3 步。
创建日期:2026-10-29
"""
import argparse
import sys
import time
try:
import serial
except ImportError: # pragma: no cover - 环境缺失时给人话
print("[ERR] 缺少 pyserial:请用项目 .venv 的 python 运行本脚本")
sys.exit(2)
# 屏板固件里的 KEY_TYPE:7 个物理键可达 1/2/3/4/7/8/9(无 5/6,9 = RESET)
KEY_VALUES = (1, 2, 3, 4, 7, 8, 9)
CMD_KEY = 0x00
CMD_RT = 0x01
ADDR = 0x01
FUNC = 0x03
def crc16_modbus(data):
"""标准 CRC-16/MODBUS(与 sb_proto.c 同算法、同口径:写进帧里低字节在前)。"""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xA001
else:
crc >>= 1
return crc & 0xFFFF
def build_frame(cmd, key=0):
"""组一帧帧 A:01 03 <cmd> <key> <CRC_L> <CRC_H>,CRC 只覆盖前 4 字节。"""
body = bytes((ADDR, FUNC, cmd, key))
crc = crc16_modbus(body)
return body + bytes((crc & 0xFF, (crc >> 8) & 0xFF))
# ---- 帧 B(主机 -> 屏板,4208 B):批 2 的应答校验 ----
FRAME_B_LEN = 4208
FRAME_B_BMP = 3200
FRAME_B_INFO = 3200
FRAME_B_PAD_OFF = 3208
FRAME_B_CRC_OFF = 4206
SCREEN_W = 160
SCREEN_H = 160
def verify_frame_b(data):
"""校验一帧帧 B;返回 (是否合格, 说明)。
只验"结构对不对":长度、CRC(覆盖 [0, 4205],低字节在前)、998 B 填充全 0、
info[4..5] 不是 LOGO 魔数。**不验点阵内容**——那要么 dump 出来看(--dump),
要么拿主机侧显存逐字节比(需要主机侧配合,台架这一级做不到)。
"""
if len(data) != FRAME_B_LEN:
return False, "len=%d(应 %d)" % (len(data), FRAME_B_LEN)
crc = crc16_modbus(data[:FRAME_B_CRC_OFF])
if (data[FRAME_B_CRC_OFF] != (crc & 0xFF)) or (data[FRAME_B_CRC_OFF + 1] != ((crc >> 8) & 0xFF)):
return False, "CRC 不符(帧尾 %02X %02X,算得 %04X 低字节在前)" % (
data[FRAME_B_CRC_OFF], data[FRAME_B_CRC_OFF + 1], crc)
pad = data[FRAME_B_PAD_OFF:FRAME_B_CRC_OFF]
if any(pad):
bad = next(i for i, b in enumerate(pad) if b)
return False, "填充段非 0(偏移 %d)" % (FRAME_B_PAD_OFF + bad)
if (data[FRAME_B_INFO + 4] == 0x89) and (data[FRAME_B_INFO + 5] == 0x45):
return False, "info[4..5] 是 LOGO 魔数(本应是普通图帧)"
return True, "OK"
def dump_pbm(path, data):
"""把点阵段存成 PBM(P4,160x160)。显存本身就是 1 bpp / 20 B 一行 / MSB 在前,
与 PBM 的二进制布局逐字节同构,所以直接拼头 + 原字节即可。"""
with open(path, "wb") as fp:
fp.write(b"P4\n%d %d\n" % (SCREEN_W, SCREEN_H))
fp.write(data[:FRAME_B_BMP])
def capture_reply(ser, frame, budget_s=0.6):
"""发一帧召唤,收主机回的 4208 B;返回 (数据, 写时刻, 首字节时刻, 末字节时刻)。
⚠ **CH340 收不了 700 kbaud 的连续流**(实测 2026-10-29:4208 B 只收到 ~2373 B,
等效约 9.5 kB/s)。这不是固件问题——同一次运行里固件侧 `tx=94 done=93` 说明 4208 B
每次都发完了;CH340 的 RX FIFO 只有几十字节、USB 每 1 ms 才搬一次,70 kB/s 必然溢出。
要真正核对帧 B 的内容,换 FTDI / CP210x 之类的适配器,或等批 4 接真屏板。
"""
ser.reset_input_buffer()
t_write = time.monotonic()
ser.write(frame)
ser.flush()
buf = bytearray()
t_first = None
deadline = t_write + budget_s
while len(buf) < FRAME_B_LEN and time.monotonic() < deadline:
# 大块读:read(n) 会等到收满 n 或超时,比"按 in_waiting 一个字节一个字节读"快得多
chunk = ser.read(min(1024, FRAME_B_LEN - len(buf)))
if chunk:
if t_first is None:
t_first = time.monotonic()
buf += chunk
return bytes(buf), t_write, t_first, time.monotonic()
def main():
ap = argparse.ArgumentParser(description="屏板链路台架激励(PC 顶替屏板)")
ap.add_argument("--port", required=True, help="USB 转 TTL 适配器的串口,如 COM6")
ap.add_argument("--baud", type=int, default=700000, help="波特率(默认 700000,与固件一致)")
ap.add_argument("--seconds", type=float, default=30.0, help="发多久(秒),默认 30")
ap.add_argument("--period-ms", type=float, default=280.0,
help="召唤周期(ms),默认 280(屏板是 RT_count>55 -> 第 56 拍 x 5 ms)")
ap.add_argument("--key", type=int, default=0,
help="每 N 个周期把帧换成按键帧(0 = 只发周期召唤)")
ap.add_argument("--key-fixed", type=int, default=0,
help="固定发这个键值(1/2/3/4/7/8/9),与 --key 二选一。"
"用来「持续改画面」以便拿到多次组帧样本;2=UP / 3=DOWN 只移动高亮,"
"不会确认任何设置(1=ENTER 可能改配置,别用来做长跑)")
ap.add_argument("--glitch-baud", type=int, default=0,
help="先按这个(错的)波特率灌一串垃圾再正常发,用来验 ORE/NE/FE 之后能否自愈"
"(0 = 不灌;建议 115200)")
ap.add_argument("--glitch-bytes", type=int, default=32,
help="灌多少字节垃圾(默认 32)")
ap.add_argument("--reply", action="store_true",
help="每帧收主机回的 4208 B 应答并校验(批 2 的 2-2 / 2-4)")
ap.add_argument("--dump", default="",
help="把最后一帧应答的点阵存成 PBM(P4 160x160,可直接看)")
args = ap.parse_args()
if args.period_ms <= 0 or args.seconds <= 0:
print("[ERR] --period-ms / --seconds 必须为正")
return 2
if args.dump and not args.reply:
print("[ERR] --dump 需要同时给 --reply(没有应答就没有点阵可存)")
return 2
rt_frame = build_frame(CMD_RT)
print("RT frame : %s" % rt_frame.hex(" ").upper())
# ---- 噪声注入(可选):用错波特率灌垃圾,制造 NE/FE/ORE ----
# 真实现场对应的是"拔插一次 TX 线";错波特率产生的错误更密集,是更强的考验。
# 关键判据不是"有没有报错",而是**之后还能不能正常收**——ORE 一旦清不掉,
# 接收就永久进不来,现象与"屏板没上电"完全一样(见 sb_link.c 文件头第 ② 条)。
if args.glitch_baud > 0 and args.glitch_bytes > 0:
try:
gser = serial.Serial(args.port, args.glitch_baud, bytesize=8, parity="N",
stopbits=1, timeout=0)
junk = bytes((0xA5, 0x5A, 0x00, 0xFF, 0x3C, 0xC3)) * (args.glitch_bytes // 6 + 1)
gser.write(junk[:args.glitch_bytes])
gser.flush()
gser.close()
print("[GLITCH] %d B @%d(错波特率灌噪声)" % (args.glitch_bytes, args.glitch_baud))
except Exception as exc: # noqa: BLE001
print("[WARN] 噪声注入失败(继续正常发):%s" % exc)
try:
# 收应答时要给 read() 一个超时,否则 timeout=0 会让 read(n) 立刻返回空、空转
ser = serial.Serial(args.port, args.baud, bytesize=8, parity="N", stopbits=1,
timeout=(0.1 if args.reply else 0))
except Exception as exc: # noqa: BLE001 - 打开失败原因要原样给人看
print("[ERR] 打开 %s @%d 失败:%s" % (args.port, args.baud, exc))
return 1
sent = 0
keys = 0
reply_ok = 0
reply_bad = 0
lat_min = None
lat_max = 0.0
lat_sum = 0.0
span_min = None
span_max = 0.0
span_sum = 0.0
last_bmp = None
t0 = time.monotonic()
next_at = t0
try:
while True:
now = time.monotonic()
if now - t0 >= args.seconds:
break
if now < next_at:
time.sleep(min(next_at - now, 0.02))
continue
if args.key_fixed > 0:
frame = build_frame(CMD_KEY, args.key_fixed)
keys += 1
tag = "KEY %d" % args.key_fixed
elif args.key > 0 and (sent % args.key) == (args.key - 1):
key = KEY_VALUES[keys % len(KEY_VALUES)]
frame = build_frame(CMD_KEY, key)
keys += 1
tag = "KEY %d" % key
else:
frame = rt_frame
tag = "RT"
if args.reply:
data, t_write, t_first, t_last = capture_reply(ser, frame)
good, why = verify_frame_b(data)
if t_first is None:
lat = span = None
else:
lat = (t_first - t_write) * 1000.0
span = (t_last - t_first) * 1000.0
lat_sum += lat
lat_min = lat if lat_min is None else min(lat_min, lat)
lat_max = max(lat_max, lat)
span_sum += span
span_min = span if span_min is None else min(span_min, span)
span_max = max(span_max, span)
if good:
reply_ok += 1
last_bmp = data
else:
reply_bad += 1
if sent < 3 or tag != "RT" or not good:
if t_first is None:
print(" #%d %s -> 无应答(%.0f ms 内没收到首字节)" % (sent + 1, tag, 600.0))
else:
print(" #%d %s -> %d B %s lat=%.2f ms span=%.2f ms"
% (sent + 1, tag, len(data), ("OK" if good else "BAD: " + why),
lat, span))
else:
ser.write(frame)
ser.flush()
if sent <= 3 or tag != "RT":
print(" #%d %s -> %s" % (sent + 1, tag, frame.hex(" ").upper()))
sent += 1
next_at += args.period_ms / 1000.0
# 落后太多就别追了(PC 侧卡顿不该变成突发流量)
if next_at < time.monotonic() - 1.0:
next_at = time.monotonic()
except KeyboardInterrupt:
print("\n[INFO] 用户中断")
finally:
ser.close()
elapsed = time.monotonic() - t0
print("[DONE] 发送 %d 帧(其中按键帧 %d)用时 %.1f s,平均周期 %.1f ms"
% (sent, keys, elapsed, (elapsed * 1000.0 / sent) if sent else 0.0))
if args.reply:
print("[REPLY] 合格 %d / 不合格 %d" % (reply_ok, reply_bad))
if reply_bad > 0 and reply_ok == 0:
print("[HINT] 一个都没收全:先怀疑适配器。CH340 的 RX 顶不住 700 kbaud 连续流"
"(4208 B 只到 ~2400 B 就是它的典型表现),换 FTDI / CP210x 再试。")
print("[HINT] 固件侧是否真发了:看日志口 grep '[SBLINK]' 的 tx / done / fb 三列。")
if reply_ok > 0:
print("[REPLY] 召唤末位→应答首位:min %.2f / avg %.2f / max %.2f ms"
"(判据 30 µs ~ 5 ms;USB 适配器自身有 1~2 ms 量级延迟,只能证上界)"
% (lat_min, lat_sum / reply_ok, lat_max))
print("[REPLY] 整帧耗时(首字节→末字节):min %.2f / avg %.2f / max %.2f ms"
"(线上 59.6 ms;**不能**据此判「帧内无间隙」,见文件头)"
% (span_min, span_sum / reply_ok, span_max))
print("[REPLY] 整帧 ≤200 ms 判据:%s" % ("PASS" if lat_max + span_max < 200.0 else "FAIL"))
if args.dump and last_bmp is not None:
dump_pbm(args.dump, last_bmp)
print("[DUMP] 点阵已存 %s(P4 160x160,可直接打开看)" % args.dump)
print("[NEXT] 回看日志口:grep '[SBLINK]' —— 期望 rx == %d、ok == rx、crc=0、len=0%s"
% (sent, "、tx == ok、done == tx、drop=0、fail=0、abort=0" if args.reply else ""))
return 3 if reply_bad > 0 else 0
if __name__ == "__main__":
sys.exit(main())