/* * 模块名称:屏板链路传输层(USART1 中断收发) * 模块功能:把主机与屏板之间那条 700 kbaud 链路完整地管起来: * - 收:IDLE 空闲中断断帧、6 B 帧 A 校验、错误中断里清 ORE 并重挂接收; * - 发:任务侧组一帧 B(4208 B)进静态缓冲,中断侧收到召唤帧**直接点 TX DMA**, * TxCplt 里收尾(不排队、不攒批、不在任务里逐字节轮询); * 例外(2026-10-30 按键延迟整治):**按键召唤延迟应答**——key≠0 的 * 召唤到达时当前帧必然还是"按键前"的内容(键还在中断队列里没被画), * 立刻发出去等于让屏板白等一轮;改为扣住这一答,等新帧组好(任务侧 * 同拍完成)再由 1 kHz 节拍钩子在中断里放行,超时则发旧帧保底 * - 按键上行:中断只入队,注键由任务侧取走(**注键不能在中断里做**); * - 链路监视:连续 >=10 s 无召唤帧才判离线;CRC 错帧静默丢弃并计数。 * 协议字节级编解码(CRC / 帧 A 解析 / 帧 B 组帧)在 sb_proto,本层只做搬运与时序。 * 适用平台:STM32F4 系列(依赖 HAL UART 的 ReceiveToIdle_IT 与 Transmit_DMA) * 作者:王建锋 * 创建日期:2026-10-29 * 修改记录: * 2026-10-29 王建锋 创建初始版本(700 kbaud + 收中断) * 2026-10-29 王建锋 增加:帧 B 组帧入口、中断起发、TxCplt 收尾、按键队列、离线判定 * 2026-10-30 王建锋 按键延迟整治:按键召唤**延迟应答**——收到 * key≠0 的帧不再立刻把"按键前的旧帧"发走,而是扣住等 * 任务侧把按键画进新帧(上限 SB_KEY_RESP_MAX_MS), * 一召唤一答与"起发必须在 IDLE 中断"两条约束都不变; * sb_link_ms_tick() 挂在 1 kHz 采样节拍上驱动扣件超时 * * 为什么单独一层、不并进 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 #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 §2)。 */ #define SB_LINK_RX_BUF_SIZE (16U) /* * 统计上报周期(ms)。只在计数有变化时才打一行,链路空闲不刷屏; * 台架验收靠的就是这一行里的累计值(判据见 docs/子系统设计/屏板链路设计说明.md §9)。 */ #define SB_LINK_REPORT_MS (5000U) /* * 判「屏板离线」的静默时长(ms)。**不能按 500 ms 判**:屏板固件约 5000 s 会自复位一次, * 复位期间约 1 s 不召唤;再叠上主机的重挂/噪声恢复,短窗口会把"屏板活着但歇了口气" * 误判成离线,然后在下一次召唤时又"恢复"——现场看到的是离线标志反复跳。 * 10 s 的取法:比 1 s 自复位空窗大一个数量级,又远小于"人发现屏板黑屏"的时间。 * 出处 docs/子系统设计/屏板链路设计说明.md §2 链路监视。 */ #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 /* * 按键召唤延迟应答的上限(ms,2026-10-30 按键延迟整治的核心参数)。 * * 为什么要有这个窗:按键召唤到达的那一刻,键还躺在中断队列里没被画——立刻应答 * 发出去的是**按键前**的旧帧,屏板白等一轮,新画面要等下一次召唤(平均 ~140 ms、 * 最坏 280 ms)才带得出来。现在把按键召唤扣住:任务侧同拍完成"出键→即派即绘→ * 组帧"后交回凭据(sb_link_key_rendered),1 kHz 节拍钩子 ≤1 ms 内放行新帧。 * 实测路径预算:召唤落点→下一拍 ≤10 + 渲染地板 ≤62 + 组帧 ≤1 ≈ **73 ms**, * 留了将近一倍余量给"召唤恰好落在数据拍/重绘中段"这类不利相位。 * * 为什么是 140 而不是更大:扣住的窗从召唤时刻起算,到点必须发(旧帧)保"一召唤 * 一答";140 + 线上 59.6 ≈ 199.6 < **205**(屏板放弃计时——它带键重问的最早时刻), * 超时答案也赶在下一次召唤之前发完,不与任何重问对撞。再大就要撞上 205 线。 * 出处:docs/子系统设计/屏板链路设计说明.md §7.1(205/280 两线)与本函数说明。 */ #define SB_KEY_RESP_MAX_MS (140U) /* ============================================================================ * 二、链路统计 * ==========================================================================*/ /* * 全部字段都是**累计值**,由中断侧自增、任务侧只读(读出来的是一份快照)。 * 分门别类记而不只记"错了多少次",是因为台架上这几类错指向完全不同的原因: * · 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 的帧数(按键召唤,按键队列的消费源) */ 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) */ /* ---- 发送侧(帧 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),预算 ≤1 ms(docs/子系统设计/屏板链路设计说明.md §7) */ /* ---- 按键上行与链路监视 ---- */ uint32_t key_drop; /* 按键队列满导致的丢弃次数 */ /* ---- 2026-10-30 按键延迟整治:延迟应答的两类放行 ---- */ uint32_t defer_fresh; /* 等到新画面后放行(理想路径;正常按键应几乎全落这里) */ uint32_t defer_old; /* 扣到 SB_KEY_RESP_MAX_MS 仍未见新画面 → 发旧帧保底 * (uiTask 被饿住的直接证据;持续非 0 要先查任务卡点) */ 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 的任务 * 抢占就能把帧切断,而屏板不报超时)。**按键召唤(cmd=KEY 且 key≠0) * 例外:只入队、扣住不应答**(延迟应答,见 SB_KEY_RESP_MAX_MS 的账), * 由 sb_link_ms_tick() 在新帧就绪或超时时放行——这一答必须走同一个 * 中断起发通道,绝不因为"等画面"而退化成任务态发送。 * 业务动作仍然不做:按键只入队,注键由任务侧 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 §7。 * 函数说明: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 §5); * 0 = 队列空(屏板自己的 0 就是"无键",两者同义,不必再分) * 限定条件:任务上下文;中断侧在入队,本函数出队。单生产者单消费者,不需要关中断。 * 函数说明:只吐**屏板原始键号**,不映射 UI_KEY_*——键号是协议语义,UI 键码是 UI 语义, * 两者在 uiTask 里汇合(那一层才知道当前是哪个页面在接键)。 * key == 9/10(RESET/FACTORY)照原样吐出,丢不丢由调用方定(当前口径见 * docs/子系统设计/屏板链路设计说明.md §5)。 */ uint8_t sb_link_key_take(void); /* * 函数功能:交"按键已画进画面、帧与活显存一致"的放行凭据(延迟应答用) * 入口参数:无 * 返回值:无 * 限定条件:任务上下文(uiTask);本拍 sb_link_frame_update() 返回 BSP_OK 之后才准调 * 函数说明:按键召唤不再立刻应答(见 sb_link_on_rx_event),TIM3 的 sb_link_ms_tick() * 看到本凭据才把"带新画面的那一答"放上线。BUSY 拍次不许交(画面还没进帧)。 */ void sb_link_key_rendered(void); /* * 函数功能:按键召唤延迟应答的毫秒时钟(1 kHz 调用) * 入口参数:无 * 返回值:无 * 限定条件:**中断上下文**(Src/main.c TIM3 分支),不得调内核 API / 打日志 * 函数说明:有扣件时——凭据已到就起发新帧(dfok+1),扣满 SB_KEY_RESP_MAX_MS * 则发当前帧保底(dfold+1)。初始化前调用安全(直接返回)。 */ void sb_link_ms_tick(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 */