Files
STM32F4-Base/Drivers/BSP/ScreenLink/sb_link.c
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

763 lines
32 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 模块名称:屏板链路传输层(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 <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;
/*
* 按键召唤延迟应答(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;
}