/* * 模块名称:屏板链路传输层(USART1 中断收发) * 模块功能:IDLE 空闲中断断帧收 6 B 帧 A、帧 A 校验、错误中断里清 ORE 并重挂接收; * 任务侧组帧 B(4208 B)进静态缓冲、中断侧收到召唤帧直接点 TX DMA、 * TxCplt 里收尾;按键入队/出队;离线判定与统计上报。 * 适用平台:STM32F4 系列 * 作者:王建锋 * 创建日期:2026-10-29 * 修改记录: * 2026-10-29 王建锋 创建初始版本(收中断 + 帧 A 校验) * 2026-10-29 王建锋 增加:组帧入口 + 中断起发 + 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 #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; /* * 按键召唤延迟应答(2026-10-30 按键延迟整治)。三个标志跨三个上下文: * s_key_defer 1 = 按键召唤被扣住待发(IDLE 中断挂上,TIM3 节拍放行时清) * s_key_defer_ms 已扣毫秒数(TIM3 节拍独占读写,单写单读) * s_key_rendered 任务侧凭据(sb_link_key_rendered 置 1、TIM3 节拍放行时清): * ui_task 在本拍出过键、重绘过、且 frame_update 返回了 OK—— * 此刻帧缓冲的内容就是"按键之后"的画面,值得为它扣着不发。 * Cortex-M4 的字节访问原子,标志竞态的后果核过全部是良性的(最坏晚 1 ms 放行, * 或多扣到超时发旧帧),不存在撕裂帧或叠发的分支。起发本身另有原子闸(见 * sb_tx_kick:扣件放行与 IDLE 直答从此是**两个中断源**,判 busy→占位必须连续)。 * 窗口上限的账在 sb_link.h 的 SB_KEY_RESP_MAX_MS(140 + 60 < 205 屏板放弃线)。 */ static volatile uint8_t s_key_defer; static volatile uint8_t s_key_defer_ms; static volatile uint8_t s_key_rendered; /* * 按键交接:中断侧单写 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 uint32_t s_report_defer; /* dfok+dfold 合并基线:扣件放行也触发上报 */ 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 把当前帧发出去(带原子闸,起发的唯一通道) * 入口参数:无 * 返回值:1 = 已起发;0 = 上一帧还在发 / 还没有帧,什么都没做 * 限定条件:**中断上下文**(IDLE 直答与 TIM3 放行两个来源都走这里) * 函数说明:起发失败只计数不清帧——下一轮召唤还会再点一次。busy 由 TxCplt 清, * 不由本函数清(清了就等于允许叠发,屏板会把两帧粘成一帧撕裂); * 唯一的例外是 HAL 明确拒绝的本次调用(根本没上线,占位必须退回)。 * ⚠ "判空闲→占位"必须在关中断里连着做完:本函数从 2026-10-30 起有 * 两个中断来源(USART1 的 IDLE 直答、TIM3 节拍的延迟应答放行),两者 * 抢占关系不由本模块定;各让一步的交错就是"双双看到 !busy → 各自 * Transmit_DMA"——第二次的失败路径会把第一次的占位误清成空闲,下一拍 * 召唤即可在线上中段叠帧。临界区只圈两个标志位(亚 µs),HAL 不进。 */ static uint8_t sb_tx_kick(void) { uint32_t primask; primask = __get_PRIMASK(); __disable_irq(); if ((s_tx_busy != 0U) || (s_frame_ready == 0U)) { __set_PRIMASK(primask); return 0U; } s_tx_busy = 1U; __set_PRIMASK(primask); 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 0U; } s_stats.tx_kick++; return 1U; } /* ======================== 公开函数实现 ======================== */ /* * 函数功能:初始化屏板链路并挂上第一轮接收 * 入口参数: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_report_defer = 0U; s_first_frame_logged = 0U; s_frame_ready = 0U; s_led_mask = 0U; s_tx_busy = 0U; s_key_defer = 0U; s_key_defer_ms = 0U; s_key_rendered = 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 的警示)。 * 2026-10-30 按键延迟整治:按键召唤不再立刻起发——此刻帧里还是按键**之前**的 * 画面,发出去等于把这次按键的反馈推给下一次召唤(平均多等 ~140 ms)。 * 改为扣住本答,等任务侧把键画进画面并组好帧(sb_link_key_rendered 交 * 凭据)后由 TIM3 节拍放行;最迟 SB_KEY_RESP_MAX_MS 发旧帧保底, * "一召一答"不破(欠发对屏板是安全的,它只会溢出超发,重问即恢复)。 */ if ((frame.cmd == SB_CMD_KEY) && (frame.key != 0U)) { if ((s_frame_ready == 0U) || (s_tx_busy != 0U)) { /* 没帧可扣、线又被占:同旧丢单,屏板 ≥205 ms 后带键重问 */ s_stats.tx_busy_drop++; } else { s_key_defer_ms = 0U; s_key_rendered = 0U; /* 上一拍的重绘凭据不算数,本次重新交 */ s_key_defer = 1U; } } else if (sb_tx_kick() == 0U) { /* 周期召唤(及 key==0 的空键召唤):照旧立刻答,闸门拒了就丢单 */ s_stats.tx_busy_drop++; } } 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**:`/serial-boot-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) || ((st.defer_fresh + st.defer_old) != s_report_defer)); 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; s_report_defer = st.defer_fresh + st.defer_old; DBG_INFO("tx=%lu done=%lu drop=%lu kerr=%lu abor=%lu fb=%lu bad=%lu us=%lu max=%lu kq=%lu off=%u dfok=%lu dfold=%lu", (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, (unsigned long)st.defer_fresh, (unsigned long)st.defer_old); } } /* * 函数功能:组一帧帧 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++; } /* * 函数功能:任务侧向延迟应答交"键已画进画面、帧内容 == 活显存"的凭据 * 入口参数:无 * 返回值:无 * 限定条件:任务上下文(uiTask);仅当本拍 sb_link_frame_update() 返回过 BSP_OK * 之后才准调 * 函数说明:这是延迟应答的放行钥匙。调用方须保证本拍已经走完"注键 → LVGL 重绘 → * 组帧",且组帧没有 BUSY——此刻帧缓冲里的就是按键之后的画面, * 凭这个放行不会发出"键没画上去"的旧帧。BUSY 的拍次不许交凭据 * (画面没进帧),扣住的召唤自然等下一个 OK 拍或按上限发旧帧。 * 凭据只对当前这次扣件有效:新的按键召唤挂起时会先清掉旧凭据重交 * (见 sb_link_on_rx_event),连续按键不会用上一次的重绘冒充。 */ void sb_link_key_rendered(void) { s_key_rendered = 1U; } /* * 函数功能:按键召唤延迟应答的毫秒时钟(放行 / 保底 / 计时) * 入口参数:无 * 返回值:无 * 限定条件:中断上下文(TIM3 1 kHz 分支调用,见 Src/main.c 的 USER CODE Callback 1); * 不得调内核 API、不得打日志 * 函数说明:有扣件时每拍按优先级判两件事—— * 1. 有凭据 && 线空闲 → 起发新帧(dfok +1);若线反而忙(极小竞态: * IDLE 中断抢先把上一轮发了)则不算放行,落去第 2 步继续计时、 * 下一拍重试——线忙窗口 ≤59.6 ms,重试必然追得上; * 2. 扣件时长到 SB_KEY_RESP_MAX_MS 上限 → 不论新旧把当前帧保底发出 * (dfold +1;闸门再拒就按丢单记,屏板重问即恢复)。 * dfold 在涨 = uiTask 被饿到 ≥140 ms 的直接证据(对时突发那类问题)。 * 初始化之前调用安全:s_huart 为空直接返回——TIM3 比 sb_link_init 早 * ≈100 ms 就在跑(内核启动前节拍,见 docs/架构设计/项目启动顺序管理.md)。 */ void sb_link_ms_tick(void) { if ((s_huart == NULL) || (s_key_defer == 0U)) { return; } if ((s_key_rendered != 0U) && (s_tx_busy == 0U)) { if (sb_tx_kick() != 0U) { s_key_defer = 0U; s_key_rendered = 0U; s_stats.defer_fresh++; return; } } if (s_key_defer_ms < 255U) { s_key_defer_ms++; } if (s_key_defer_ms >= SB_KEY_RESP_MAX_MS) { s_key_defer = 0U; s_key_rendered = 0U; if (sb_tx_kick() != 0U) { s_stats.defer_old++; } else { s_stats.tx_busy_drop++; } } } /* * 函数功能:取一份链路统计快照 * 入口参数: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->defer_fresh = s_stats.defer_fresh; p_out->defer_old = s_stats.defer_old; p_out->offline = s_stats.offline; }