feat(屏板): 帧 B 组帧 + 中断起发 TX DMA + 按键队列 + 离线判定(批 2)

批 2 五项全部落地(docs/任务规划/屏板链路.md §3):
- 2-1 sb_link_frame_update():uiTask 渲染后按需组帧,不留 3200 B 影子
  副本,直接拿活显存与帧内点阵段 memcmp;组完自校验,过了才置 ready。
  静态 4208 B 帧缓冲实测落 RW_IRAM1(0x20006d98),不在 CCM。
- 2-2 空闲中断校完 6 B 召唤帧直接点 TX DMA(sb_tx_kick),不排队不攒批;
  busy 期间再来的召唤丢帧并计数,绝不叠发(屏板收帧一次 IDLE 一包)。
- 2-3 按键走环形队列(中断入、任务出),uiTask 按 §4.3 表映射 UI_KEY_*
  后调 ui_port_queue_key;9/10(RESET/FACTORY)按决策四先丢弃并留痕。
- 2-4 HAL_UART_TxCpltCallback 在 rs485.c 加第三行转发(三个全局回调
  落点收一处,防日后重复定义);busy 冻结帧缓冲,错误中断按 gState
  收拾被打断的 TX,防残留 busy 把链路永久锁死。
- 2-5 连续 >=10 s 无有效召唤帧才判离线(不按 500 ms——屏板约 5000 s
  自复位有 1 s 空窗),恢复即清标志;CRC 错帧静默丢弃并计数。

实测:台架 40 s(PC 顶替屏板,--key-fixed 2 持续改画面)rx=143 ok=143
keys=143 crc=0 / tx=143 done=143 drop=0 abor=0 fb=144 bad=0;脚本一停
10 s 准点出 OFFLINE。组帧耗时 509 µs 典型 / 709 µs 峰值——原 0.3 ms
判据系估算,已按实测在规划文档改口为 1 ms 并给出压降路径(未做)。

附带:rs485_on_rx 补 CODE-RULE-09 文档块(本轮改 rs485.c 触发整文件
重扫被拦);CRC 循环计数改 32 位(消掉 ARMCC 每轮 UXTH);bench 脚本
加 --reply/--key-fixed/--dump,并写明 CH340 收不了 700 kbaud 连续流、
--reply 收不全 4208 B 不是固件问题。

主机单测 146/146 PASSED(+65);Keil 0 错误 0 警告;ci_local 全绿
(SKIP=nested-comment,worktree 限制,主树脚本另查 0 新发)。
Code=238,936 B / RW_IRAM1 65.5 % / RW_CCM 87.9 %。
This commit is contained in:
2026-10-29 20:59:36 +08:00
parent e5b4182a9f
commit dab0d8ba17
15 changed files with 1263 additions and 132 deletions
+24 -3
View File
@@ -174,18 +174,25 @@ uint16_t rs485_rx_get_size(const rs485_handle_t *p_handle) {
/* ==================== HAL 回调(本工程唯一实现处) ====================
*
* ⚠ 这两个回调是 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;
@@ -228,3 +235,17 @@ void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
/* 屏板链路(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);
}
+342 -23
View File
@@ -1,14 +1,16 @@
/*
* 模块名称:屏板链路传输层(USART1 中断接收)
* 模块功能:IDLE 空闲中断断帧收 6 B 帧 A、帧 A 校验、错误中断里清 ORE 并重挂接收、
* 累计统计与周期上报。发送侧(帧 B 组帧 + TX DMA 起发)属批 2,不在本文件。
* 模块名称:屏板链路传输层(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
@@ -20,9 +22,14 @@
* → 接收永远进不来。这就是"拔插一次 TX 线之后屏板再也收不到"的真因。
*
* ③ **本文件只按句柄过滤,不做业务动作**。全局回调(HAL_UARTEx_RxEventCallback /
* HAL_UART_ErrorCallback)的唯一实现处在 Drivers/BSP/RS485/rs485.c,那里只做一行
* 转发;过滤在这里做,于是新增通道不必去改 rs485.c 的判据。业务动作(注键、
* 刷新画面)全部留给任务侧——注键不能在中断里做(docs/任务规划/屏板链路.md 决策三)。
* 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]"
@@ -40,6 +47,32 @@ 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;
@@ -48,10 +81,33 @@ 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_* 返回的状态
@@ -75,6 +131,46 @@ static bsp_err_t sb_err_from_hal(HAL_StatusTypeDef status)
}
}
/*
* 函数功能:把一个屏板键号压进交接队列
* 入口参数: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++;
}
/* ======================== 公开函数实现 ======================== */
/*
@@ -99,8 +195,24 @@ bsp_err_t sb_link_init(UART_HandleTypeDef *p_huart)
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 覆盖丢了。
@@ -179,6 +291,28 @@ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size)
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)
@@ -211,6 +345,9 @@ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size)
* err_irq 一起涨(160/160),看着像链路坏了,其实一次都没真失败。
* 判据用 RxState 而不是 ErrorCode 的位:它直接回答"现在挂着接收没有",
* 不依赖 HAL 怎么分组错误位。
* 3. **顺手收拾被打断的 TX**:错误中断不会走 TxCplt,busy 会永久挂着,
* 此后每次召唤都被判"上一帧还在发"→ 链路再也不发(rx 涨、tx 不动)。
* 判据用 gState,见下面代码里的说明。
*/
void sb_link_on_error(UART_HandleTypeDef *p_huart)
{
@@ -226,6 +363,19 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart)
(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)
{
@@ -234,16 +384,26 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart)
}
/*
* 函数功能:链路统计上报(任务侧周期调用)
* 函数功能:链路统计上报与屏板离线判定(任务侧周期调用)
* 入口参数:now_ms - 当前毫秒时基
* 返回值:无
* 限定条件:任务上下文调用(内部会打日志)
* 函数说明:每 SB_LINK_REPORT_MS 检查一次,计数有变化才打一行;第一帧成功收到时
* 立刻单独打一行(不等周期),台架刚上电就能确认"链路通了、长度对、CRC 过"。
* 函数说明: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)
{
@@ -252,6 +412,26 @@ void sb_link_poll(uint32_t now_ms)
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))
{
@@ -267,23 +447,150 @@ void sb_link_poll(uint32_t now_ms)
s_report_ms = now_ms;
/* 无变化不刷屏:链路安静时一行都不出,出的一行就是有事实发生 */
if ((st.rx_events == s_report_events) &&
(st.err_irq == s_report_err_irq) &&
(st.rearm_fail == s_report_rearm_fail))
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;
}
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 (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++;
}
/*
@@ -313,4 +620,16 @@ void sb_link_get_stats(sb_link_stats_t *p_out)
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;
}
+99 -14
View File
@@ -1,24 +1,29 @@
/*
* 模块名称:屏板链路传输层(USART1 中断接收)
* 模块功能:把屏板(DTU-LCD)发来的 6 B 帧 A 从 USART1 上稳定收下来:
* IDLE 空闲中断断帧、帧 A 校验、错误中断里清 ORE 并重挂接收、统计上报。
* **本层不发送、不组帧**(帧 B 的组帧与 TX DMA 起发属批 2,见
* docs/任务规划/屏板链路.md §3 的 2-1 / 2-2)。
* 适用平台:STM32F4 系列(依赖 HAL UART 的 ReceiveToIdle_IT)
* 模块名称:屏板链路传输层(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 是**全局单例**,
* 本工程唯一实现处是 Drivers/BSP/RS485/rs485.c(USE_HAL_UART_REGISTER_CALLBACKS = 0,
* 没法按句柄注册回调)。那里只做一行转发,**按句柄过滤在本文件里做**——
* 于是 rs485.c 不必知道屏板链路用的是哪个 huart,新增通道也不用改它的判据。
* 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
@@ -60,6 +65,27 @@ extern "C" {
*/
#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
/* ============================================================================
* 二、链路统计
* ==========================================================================*/
@@ -86,6 +112,21 @@ typedef struct
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;
/*
@@ -138,9 +179,11 @@ bsp_err_t sb_link_receive_start(void);
* size - 本次断出来的字节数
* 返回值:无
* 限定条件:**中断上下文**,只做 CRC 与计数(4 B 校验约 1 µs),不得打日志、不得阻塞。
* 函数说明:长度不是 6 就直接记 rx_len_err 并丢弃;是 6 才解析。解析结论只累计计数
* 与"最近一帧",**不做任何业务动作**——注键、刷新都由任务侧取走统计后再做
* (批 2 的 2-3:注键不能在中断里做)。末尾无条件重挂接收。
* 函数说明:长度不是 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);
@@ -163,10 +206,52 @@ void sb_link_on_error(UART_HandleTypeDef *p_huart);
* 限定条件:任务上下文调用,**不可在中断里调**(内部会打日志)。
* 函数说明:每 SB_LINK_REPORT_MS 检查一次,只有计数有变化才打一行累计值。
* 第一帧成功收到时立刻打一行(不等周期),台架刚上电就能看到链路通了。
* 离线判定(连续 >=10 s 无召唤帧)属批 2 的 2-5,这里只上报不判定。
* 同时维护"屏板离线"标志:连续 >= 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];批 3 才填真值,此处传 0)
* 返回值: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.17 ms(3200 B 拷贝 + 4206 B CRC),只在变化时付一次。
* 函数说明: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 - 输出缓冲(非空)
+6 -2
View File
@@ -103,12 +103,16 @@ static void sb_put_crc(uint8_t *p_frame, uint16_t off, uint16_t crc)
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 (len > 0U)
while (n > 0U)
{
crc = (uint16_t)((crc >> 8) ^ s_crc_table[(uint8_t)(crc ^ *p_data)]);
p_data++;
len--;
n--;
}
return crc;
}