diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index 0bddc59f..43ba5d48 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -24,7 +24,7 @@ "adc1-hal-enabled": "1a8ed6cff34f7ba6", "adc1-calibration-call": "8d5f4b40fb218913", "adc1-calibration-impl": "25723dd0449ac701", - "adc-tick-tim3-branch": "39c3548c37f6c4ae", + "adc-tick-tim3-branch": "17d43a565f50c6f2", "ioc-adc-trig-t3trgo": "a9939de260ee46f8", "ioc-adc-dma-continuous": "189126670d85d80a", "adc1-dma-link": "8a57c70611db1a02", diff --git a/App/task/ui_task.c b/App/task/ui_task.c index ed4062a4..31934983 100644 --- a/App/task/ui_task.c +++ b/App/task/ui_task.c @@ -998,9 +998,15 @@ void ui_task_func(void *argument) { ui_data_feed_tick(); ui_port_step(UI_TASK_TICK_MS); - /* ---- 屏板链路(批 2):帧 B 组帧 + 按键上行,全在任务上下文 ---- - * 组帧紧跟渲染:本轮画出来的就是本轮组进去的那一帧。 - * 排在推流往返**之前**,是因为那个往返最坏能等 ≈4 s(LCDMEM 应答 3204 B), + /* ---- 屏板链路(批 2 + 2026-10-30 按键延迟整治):帧 B 组帧 + 按键上行,全在任务上下文 ---- + * 本段顺序是**先注键、后组帧**(原先反过来):按键召唤由 sb_link 扣住不发 + * (延迟应答,判据见 sb_link.h 的 SB_KEY_RESP_MAX_MS),这一拍必须完成 + * "出键→即派即绘→组帧",sb_link_ms_tick() 在 ≤1 ms 内放行的那一答带的才是 + * **按键之后**的新内容;旧顺序下按键召唤答的必然是按键前的旧帧,新内容 + * 要等下一次召唤才带得出(平均 ~140 ms、最坏 280 ms,账本见 + * docs/子系统设计/屏板链路设计说明.md §7.1)。 + * "本轮画出来的就是本轮组进去的那一帧"的承诺因此覆盖到按键。 + * 排在推流往返**之前**不变:那个往返最坏能等 ≈4 s(LCDMEM 应答 3204 B), * 排在它后面会让屏板画面跟着网络一起卡(同一份显存、两个出口,出口不能互相拖)。 * 显存直接取活帧缓冲:不留 3200 B 影子副本,sb_link 内部与帧里的点阵段 memcmp。 * LED 掩码(批 3):每拍现采四路状态源经 sb_led_map_eval 折算——四个源 @@ -1012,6 +1018,7 @@ void ui_task_func(void *argument) { uint32_t led_mask; bsp_err_t sb_ret; uint8_t board_key; + uint8_t key_injected = 0u; led_in.prot_active = prot_elm_active_mask(); led_in.prot_pick = prot_elm_pick_mask(); @@ -1019,6 +1026,28 @@ void ui_task_func(void *argument) { led_in.iec104_run = iec104_serv_link_run(); led_mask = sb_led_map_eval(&led_in, osKernelGetTickCount()); + /* 逐键必到:一次取一个,直到队列空。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); + key_injected = 1u; + } else { + DBG_INFO("board key %u unmapped (RESET/FACTORY pending decision)", + (unsigned)board_key); + } + } + + /* 有键的这一拍:立刻派发 + 立刻重绘(省 indev 轮询 ≤20 ms + + * 刷新定时器 ≤20 ms 两段干等),随后组帧才能把新画面装进本拍的答案。 + * 没键的拍 service_keys 是空操作,一拍不多花。 */ + if (key_injected != 0u) { + ui_port_service_keys(); + } + sb_ret = sb_link_frame_update(ui_port_get_framebuffer(), led_mask); /* BSP_ERR_BUSY 是常态(上一帧还在 59.6 ms 的发送窗口里),不必留痕; @@ -1029,18 +1058,12 @@ void ui_task_func(void *argument) { 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); - } + /* 给 sb_link 交"键已画进画面、且帧内容与活显存一致"的凭据 + * (延迟应答的放行钥匙):**只在组帧返回 BSP_OK 时交**——BUSY 意味着这一拍没能 + * 把新画面组进帧缓冲,凭据必须留给组成功的那一拍;扣住的召唤自会 + * 等下一个 OK 拍或按 140 ms 上限发旧帧保底。 */ + if ((key_injected != 0u) && (sb_ret == BSP_OK)) { + sb_link_key_rendered(); } } diff --git a/Drivers/BSP/ScreenLink/sb_link.c b/Drivers/BSP/ScreenLink/sb_link.c index f401d30b..10e5620f 100644 --- a/Drivers/BSP/ScreenLink/sb_link.c +++ b/Drivers/BSP/ScreenLink/sb_link.c @@ -65,6 +65,22 @@ 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。 * 两个下标都只由各自的写者推进,所以不需要临界区(单生产者单消费者环形队列)。 @@ -83,6 +99,7 @@ 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; /* 离线判定:上次看到"有效召唤帧"的时刻与当时的计数基线。 @@ -152,23 +169,41 @@ static void sb_key_push(uint8_t key) } /* - * 函数功能:点一次 TX DMA 把当前帧发出去 + * 函数功能:点一次 TX DMA 把当前帧发出去(带原子闸,起发的唯一通道) * 入口参数:无 - * 返回值:无 - * 限定条件:**中断上下文**;调用前须已确认 s_frame_ready 且 !s_tx_busy + * 返回值:1 = 已起发;0 = 上一帧还在发 / 还没有帧,什么都没做 + * 限定条件:**中断上下文**(IDLE 直答与 TIM3 放行两个来源都走这里) * 函数说明:起发失败只计数不清帧——下一轮召唤还会再点一次。busy 由 TxCplt 清, - * 不由本函数清(清了就等于允许叠发,屏板会把两帧粘成一帧撕裂)。 + * 不由本函数清(清了就等于允许叠发,屏板会把两帧粘成一帧撕裂); + * 唯一的例外是 HAL 明确拒绝的本次调用(根本没上线,占位必须退回)。 + * ⚠ "判空闲→占位"必须在关中断里连着做完:本函数从 2026-10-30 起有 + * 两个中断来源(USART1 的 IDLE 直答、TIM3 节拍的延迟应答放行),两者 + * 抢占关系不由本模块定;各让一步的交错就是"双双看到 !busy → 各自 + * Transmit_DMA"——第二次的失败路径会把第一次的占位误清成空闲,下一拍 + * 召唤即可在线上中段叠帧。临界区只圈两个标志位(亚 µs),HAL 不进。 */ -static void sb_tx_kick(void) +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; + return 0U; } s_stats.tx_kick++; + return 1U; } /* ======================== 公开函数实现 ======================== */ @@ -197,11 +232,15 @@ bsp_err_t sb_link_init(UART_HandleTypeDef *p_huart) 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; @@ -301,19 +340,31 @@ void sb_link_on_rx_event(UART_HandleTypeDef *p_huart, uint16_t size) /* 起发就在这儿,不在任务里轮询。一召唤 → 一答 4208 B,绝不叠发。 * 上一帧还没发完(屏板自己超时重问过)就丢这一轮:多发一帧会让屏板把 - * 两帧粘成一帧撕裂,而且它接收溢出后会永久收不到东西(见 §4.4 的警示)。 */ - if (s_frame_ready == 0U) + * 两帧粘成一帧撕裂,而且它接收溢出后会永久收不到东西(见 §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)) { - s_stats.tx_busy_drop++; /* 帧还没组出来(刚上电)——与"上一帧未发完"同类记 */ + 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 (s_tx_busy != 0U) + else if (sb_tx_kick() == 0U) { + /* 周期召唤(及 key==0 的空键召唤):照旧立刻答,闸门拒了就丢单 */ s_stats.tx_busy_drop++; } - else - { - sb_tx_kick(); - } } else if (ret == BSP_ERR_CRC) { @@ -451,7 +502,8 @@ void sb_link_poll(uint32_t now_ms) (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.frame_build != s_report_frame_build) || + ((st.defer_fresh + st.defer_old) != s_report_defer)); if ((rx_changed == 0U) && (tx_changed == 0U)) { return; @@ -475,13 +527,15 @@ void sb_link_poll(uint32_t now_ms) { 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", + 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)st.offline, + (unsigned long)st.defer_fresh, (unsigned long)st.defer_old); } } @@ -593,6 +647,77 @@ void sb_link_on_tx_cplt(UART_HandleTypeDef *p_huart) 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 - 输出缓冲(非空) @@ -631,5 +756,7 @@ void sb_link_get_stats(sb_link_stats_t *p_out) 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; } diff --git a/Drivers/BSP/ScreenLink/sb_link.h b/Drivers/BSP/ScreenLink/sb_link.h index 10348e5b..cb1105df 100644 --- a/Drivers/BSP/ScreenLink/sb_link.h +++ b/Drivers/BSP/ScreenLink/sb_link.h @@ -4,6 +4,10 @@ * - 收: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,本层只做搬运与时序。 @@ -13,6 +17,11 @@ * 修改记录: * 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 跑单测; @@ -86,6 +95,23 @@ extern "C" { * 不必知道协议层的命名;两者恒等由下面一行保证。 */ #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) + /* ============================================================================ * 二、链路统计 * ==========================================================================*/ @@ -126,6 +152,10 @@ typedef struct /* ---- 按键上行与链路监视 ---- */ 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; @@ -179,9 +209,13 @@ bsp_err_t sb_link_receive_start(void); * size - 本次断出来的字节数 * 返回值:无 * 限定条件:**中断上下文**,只做 CRC 与计数(4 B 校验约 1 µs),不得打日志、不得阻塞。 - * 函数说明:长度不是 6 就直接记 rx_len_err 并丢弃;是 6 才解析。解析通过后**直接点 TX DMA** - * 把已经组好的帧 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() 取走后做。 * 末尾无条件重挂接收。 */ @@ -245,6 +279,26 @@ bsp_err_t sb_link_frame_update(const uint8_t *p_live_bmp, uint32_t led_mask); */ 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 - 触发回调的句柄(非本链路的会被直接忽略) diff --git a/Drivers/BSP/UI/ui_menu_view.c b/Drivers/BSP/UI/ui_menu_view.c index 59b89a8e..cc3fdf2f 100644 --- a/Drivers/BSP/UI/ui_menu_view.c +++ b/Drivers/BSP/UI/ui_menu_view.c @@ -20,6 +20,9 @@ * s_depth 决定,键回调 user_data=depth 冗余);根框装饰 * (浅 V)+状态行改容器自绘(删 3 lv_line + 1 label 对象) * 2026-09-09 王建锋 "浅 V"装饰几何提取至共享 ui_deco 模块(不再本地重复定义) + * 2026-10-30 王建锋 按键提速:view_apply_sel 选中移动且未滚动时 + * 只失效新旧两行竖带(lv_obj_invalidate_area),绘制量 + * 从整框降到 ~2/7;滚动/首建仍整框兜底 */ /* 头文件包含区 */ @@ -320,14 +323,50 @@ static void view_scroll_to_sel(lv_obj_t *box, const menu_frame_t *f) { * 限定条件:该层框体已建 * 函数说明:1. 行不是对象:选中只是数据,自绘回调绘制时定反色带/文字色 * 2. 选中行滚入可见区(手动计算,无行对象可供 scroll_to_view) - * 3. 失效框体:下一帧重绘 + * 3. 失效范围按改动面收敛(2026-10-30 按键提速 C): + * · 选中变了且**没发生滚动** → 只有新旧两行外观变化, + * 只脏这两行组成的竖带(根框 107 px 高、行高 15 px,绘制量 + * 从整框降到 ~2/7;自绘回调本就有 clip 剔除,带外行直接跳过) + * · 发生滚动 / 首次应用(新旧同号)→ 整框失效兜底 + * 4. 失效区用绝对坐标,与自绘回调同一坐标框架 + * (content_coords 起算、减滚动偏移) */ static void view_apply_sel(uint8_t depth, uint8_t sel) { - menu_frame_t *f = &s_frame[depth]; + menu_frame_t *f = &s_frame[depth]; + lv_obj_t *box = s_frame_box[depth]; + uint8_t sel_old = f->sel; + int32_t sy_before = lv_obj_get_scroll_y(box); f->sel = sel; - view_scroll_to_sel(s_frame_box[depth], f); /* 选中行必在可见区内 */ - lv_obj_invalidate(s_frame_box[depth]); /* 重绘框体(反色带/文字色绘制时决定) */ + view_scroll_to_sel(box, f); /* 选中行必在可见区内 */ + + if ((sel_old != sel) && (lv_obj_get_scroll_y(box) == sy_before)) { + lv_area_t ca; + lv_area_t band; + int32_t y_old; + int32_t y_new; + + lv_obj_get_content_coords(box, &ca); + y_old = ca.y1 + (int32_t)sel_old * MENU_ROW_H - sy_before; + y_new = ca.y1 + (int32_t)sel * MENU_ROW_H - sy_before; + + band.x1 = ca.x1; + band.x2 = ca.x2; + band.y1 = (y_old < y_new) ? y_old : y_new; + band.y2 = (y_old > y_new) ? y_old : y_new; + band.y2 += (MENU_ROW_H - 1); + if (band.y1 < ca.y1) { + band.y1 = ca.y1; /* 旧选中行可能被框顶裁掉(级联框贴屏上沿时) */ + } + if (band.y2 > ca.y2) { + band.y2 = ca.y2; /* 同理框底 */ + } + if (band.y1 <= band.y2) { + lv_obj_invalidate_area(box, &band); + return; + } + } + lv_obj_invalidate(box); /* 兜底:有滚动或首次建立,整框重绘 */ } /* diff --git a/Drivers/BSP/UI/ui_port.c b/Drivers/BSP/UI/ui_port.c index a69318d0..2ea27d17 100644 --- a/Drivers/BSP/UI/ui_port.c +++ b/Drivers/BSP/UI/ui_port.c @@ -6,6 +6,9 @@ * 创建日期:2026-08-29 * 修改记录: * 2026-08-29 王建锋 创建初始版本 + * 2026-10-30 王建锋 按键提速:单槽键缓存改环形队列(连按不再 + * 互相覆盖丢键);新增 ui_port_service_keys()——注键后 + * 立即派发并重绘,不等下一个 20 ms 输入轮询/刷新定时器 */ #include "ui_port.h" @@ -22,8 +25,20 @@ static uint8_t g_ui_fbuf[UI_FB_SIZE]; * ui_port.h 中 UI_DRAW_BUF_PALETTE / UI_DRAW_BUF_SIZE 注释(含越界与整帧错位的历史成因)。 */ static uint8_t g_ui_draw_buf[UI_DRAW_BUF_SIZE]; -/* 单键缓冲:菜单无需多键队列,仅保存最近一次待处理键 */ -static volatile uint8_t g_pending_key = 0; +/* 待处理键环形队列(2026-10-30 按键提速 A):旧版是单槽 g_pending_key, + * 一拍的注入窗口里来两个键(屏板连按 + 7003 远端注键同拍到达)后者就静默 + * 覆盖前者——用户侧的表象是"按了没反应/要按两下"。环形后深度 = LEN-1 = 7。 + * 生产与消费都在 UI 主循环任务上下文(uiTask / 模拟器主线程),同上下文 + * 单写者轮转,volatile 只防编译器重排,不需要临界区。满时**挤掉最旧**: + * 用户最新一次按键的意图必须保住(旧键没被看见已是损失,再丢新的等于连点不回)。 */ +#define UI_KEY_RING_LEN 8u +static volatile uint8_t g_key_ring[UI_KEY_RING_LEN]; +static volatile uint8_t g_key_head; /* 生产者(queue_key)推进 */ +static volatile uint8_t g_key_tail; /* 消费者(keypad read)推进 */ +static volatile uint8_t g_key_drop; /* 满挤旧计数(排障用,不上报) */ + +/* keypad 设备句柄(ui_port_service_keys 要直接驱动一次输入读取) */ +static lv_indev_t *g_indev; /* 远端键 -> LVGL 键 */ /* @@ -52,15 +67,16 @@ static uint8_t ui_map_key(uint8_t remo_key) { * data - 读取结果 lv_indev_data_t * 由本函数填充 * 返回值:无 * 限定条件:无 - * 函数说明:1. 若有缓存键则上报 PRESSED 并清除缓存 - * 2. 否则上报 RELEASED,等待下次按键 + * 函数说明:1. 环形队列非空 → 弹出一个键上报 PRESSED(一次读一个键, + * 连按键由后续轮询逐个派发,与 LVGL keypad 的按次派发语义一致) + * 2. 队列空则上报 RELEASED,等待下次按键 */ static void ui_keypad_read(lv_indev_t * indev, lv_indev_data_t * data) { (void)indev; - if (g_pending_key != 0) { + if (g_key_head != g_key_tail) { data->state = LV_INDEV_STATE_PRESSED; - data->key = g_pending_key; - g_pending_key = 0; + data->key = g_key_ring[g_key_tail]; + g_key_tail = (uint8_t)((g_key_tail + 1u) % UI_KEY_RING_LEN); } else { data->state = LV_INDEV_STATE_RELEASED; data->key = 0; @@ -131,6 +147,7 @@ int ui_port_init(void) { indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_KEYPAD); lv_indev_set_read_cb(indev, ui_keypad_read); + g_indev = indev; /* ui_port_service_keys 直接驱动一次读取用 */ lv_group_t *g = lv_group_create(); if (g == NULL) { @@ -157,14 +174,24 @@ uint8_t *ui_port_get_framebuffer(void) { * 函数功能:向输入队列喂入一个远端按键值 * 入口参数:remo_key - 远端键码 uint8_t UI_KEY_* 取值之一 * 返回值:无 - * 限定条件:无 - * 函数说明:映射后缓存,由 keypad 回调在下次轮询上报 + * 限定条件:与 keypad 读取同一任务上下文(uiTask / 模拟器主线程) + * 函数说明:映射后入环形队列;队列满挤掉**最旧**一个并计数——最新按键的 + * 意图必须保住(旧版单槽直接覆盖丢键)。由 keypad 回调逐个上报。 */ void ui_port_queue_key(uint8_t remo_key) { uint8_t k = ui_map_key(remo_key); - if (k != 0) { - g_pending_key = k; + uint8_t next; + + if (k == 0) { + return; } + next = (uint8_t)((g_key_head + 1u) % UI_KEY_RING_LEN); + if (next == g_key_tail) { + g_key_tail = (uint8_t)((g_key_tail + 1u) % UI_KEY_RING_LEN); + g_key_drop++; + } + g_key_ring[g_key_head] = k; + g_key_head = next; } /* @@ -178,3 +205,28 @@ void ui_port_step(uint32_t tick_ms) { lv_tick_inc(tick_ms); lv_timer_handler(); } + +/* + * 函数功能:立即消费队列中的待处理按键并让改动落屏(按键提速,2026-10-30) + * 入口参数:无 + * 返回值:无 + * 限定条件:ui_port_init() 已成功调用;与 ui_port_step 同一任务上下文 + * 函数说明:1. 队列空 = 直接返回(常态:绝大多数拍没有键,不触发输入读取) + * 2. lv_indev_read 把队头键以 PRESSED 派发(键事件直达 menu_key_cb + * 一类的处理器,处理器只 invalidate 脏区),lv_refr_now 立刻把 + * 脏区绘进帧缓冲——"注键→派发→重绘"在同一拍完成 + * 3. 省掉的两段等待:indev 轮询定时器 ≤20 ms + 刷新定时器 ≤20 ms; + * 真机上这两段原本压在 sb_link 延迟应答的截止预算里 + * 4. 只服务队头一个键:LVGL keypad 的按次派发语义(PRESSED 一次一键), + * 连按剩余的键由本函数下一次调用或周期轮询继续消化 + */ +void ui_port_service_keys(void) { + if (g_indev == NULL) { + return; + } + if (g_key_head == g_key_tail) { + return; + } + lv_indev_read(g_indev); + lv_refr_now(NULL); +} diff --git a/Drivers/BSP/UI/ui_port.h b/Drivers/BSP/UI/ui_port.h index 64fff365..cf928165 100644 --- a/Drivers/BSP/UI/ui_port.h +++ b/Drivers/BSP/UI/ui_port.h @@ -91,6 +91,19 @@ void ui_port_queue_key(uint8_t key_val); */ void ui_port_step(uint32_t tick_ms); +/* + * 函数功能:立即消费队列中的待处理按键并让改动落屏(按键提速,2026-10-30) + * 入口参数:无 + * 返回值:无 + * 限定条件:ui_port_init() 已成功调用;与 ui_port_step 同一任务上下文 + * 函数说明:1. 队列空 = 空操作(不触发输入读取) + * 2. 非空则立刻 lv_indev_read 派发键事件 + lv_refr_now 把脏区绘进 + * 帧缓冲——调用方据此可在**同一拍**完成"注键→派发→重绘", + * 不等下一个 20 ms 输入轮询 / 刷新定时器(按键反馈提速 A 项) + * 3. 只消费队列头部一个键;连按时其余按键仍由周期轮询逐个派发 + */ +void ui_port_service_keys(void); + #ifdef __cplusplus } #endif diff --git a/Src/main.c b/Src/main.c index 6a73ff77..27713d4d 100644 --- a/Src/main.c +++ b/Src/main.c @@ -43,6 +43,7 @@ #include "sys_clock.h" #include "task_watchdog.h" /* task_wd_tick_probe():第 8b 轮时间源存活性取证 */ #include "adc_task.h" /* adc_tick_irq_notify():TIM3 采样节拍 → 唤醒 adcTask */ +#include "sb_link.h" /* sb_link_ms_tick():TIM3 1 kHz 顺手做按键召唤延迟应答的毫秒时钟 */ /* USER CODE END Includes */ /* Private typedef -----------------------------------------------------------*/ @@ -269,6 +270,12 @@ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { tpafe5160_start_conv_irq(); adc_tick_irq_notify(); + /* 按键召唤延迟应答的毫秒时钟(2026-10-30 按键延迟整治):只读写几个标志位 + + * 必要时点一次已经在 IDLE 分支验证过的 DMA 起发,µs 级、无内核 API; + * sb_link_init 之前 s_huart 为空直接返回——TIM3 比调度器早 ≈100 ms 就开跑。 + * ⚠ 别把它挪去任务里:扣件的放行截止(140 ms)依赖 1 kHz 硬时钟, + * uiTask 自己被饿的时候恰恰最需要这个时钟还在走。 */ + sb_link_ms_tick(); } /* USER CODE END Callback 1 */ } diff --git a/docs/子系统设计/屏板链路设计说明.md b/docs/子系统设计/屏板链路设计说明.md index e8c4c27f..f43bce12 100644 --- a/docs/子系统设计/屏板链路设计说明.md +++ b/docs/子系统设计/屏板链路设计说明.md @@ -13,7 +13,8 @@ 串口重启被注释掉),只能断电重上或等 ≈83 min 自复位——多发是唯一风险源,busy 冻结在主机侧挡死。 - **一份显存、两个出口**:串口(本链路)为主,TCP 7003 网络为镜像;两出口读**同一份活显存** (`ui_port_get_framebuffer()`),无第二份账可漂。 -- **宿主 = `uiTask`(组帧 + 注键),唯一起发者 = USART1 空闲中断(µs 级)**。不新建任务、不占堆、 +- **宿主 = `uiTask`(组帧 + 注键),起发全部在中断 = USART1 空闲中断(周期召唤直答)+ TIM3 1 kHz 节拍 + (按键召唤的延迟应答放行,§7.1)**。不新建任务、不占堆、 不加看门狗位(产品 `required_mask` 维持 `0x1F7`,起发在中断、与任务心跳无关)。 ```mermaid @@ -24,14 +25,17 @@ flowchart TB F -->|TX busy → BSP_ERR_BUSY| R F -->|变了 → 组帧 + 自校验| S["s_frame 就绪"] K["sb_link_key_take 出队"] --> M["ui_task_board_key_map 映射"] --> Q["ui_port_queue_key 注键"] + Q --> SV["ui_port_service_keys 同拍立刻重绘"] --> F P["sb_link_poll 统计 + 离线判定"] end - subgraph ISRCTX["USART1 空闲中断(屏板召唤到达)"] - A["sb_link_on_rx_event:校 6 B 帧 A"] -->|"key ≠ 0"| KQ["按键环形队列(入队)"] - A -->|帧有效 且 s_frame 就绪| D["sb_tx_kick 点 TX DMA"] + subgraph ISRCTX["中断上下文(两个起发源)"] + A["sb_link_on_rx_event:校 6 B 帧 A"] -->|"key ≠ 0"| KQ["按键入队 + 扣住本答(不起发)"] + A -->|周期召唤 且 s_frame 就绪| D["sb_tx_kick 点 TX DMA"] + T["sb_link_ms_tick(TIM3 1 kHz)"] -->|"凭据→放新帧 / 扣满 140 ms→发旧帧"| D A -->|无条件| A2["重挂 ReceiveToIdle_IT"] end S -.-> D + S -.|"组帧 OK 且本拍注过键 → 交凭据"| T KQ -.-> K D --> TC["DMA2_Stream7 传完中断"] --> C["清 busy(攒了 dirty → 下一拍补组)"] ``` @@ -120,6 +124,13 @@ flowchart TB 对时 relisten 突发实测单轮 800 ms 以上缺席),屏板只给 205/280 ms,任务态起发救不了。 - **按键上行**:中断校验帧 A 后 `key ≠ 0` 入环形队列(可存 3 个,满了丢**最**新的一帧并计数——旧键已在路上), `uiTask` 出队 → 映射 → 注键;**注键不在中断里**。 +- **按键召唤延迟应答**(2026-10-30,`SB_KEY_RESP_MAX_MS = 140`):`key ≠ 0` 的帧 A 不再在 IDLE 中断里直接点 + DMA 起发——召唤到达那一刻 `uiTask` 还没消化这个键,此刻发出必然是"按键前"的画面,白发一帧把反馈推给下一轮 + 召唤。现在中断只入队**并扣住本答**;`uiTask` 同拍排空注键 + 同步重绘(`ui_port_service_keys()`)+ 组帧返回 + `BSP_OK` 后交 `sb_link_key_rendered()` 凭据;TIM3 1 kHz 的 `sb_link_ms_tick()` 见凭据即起发**带键的新帧**。 + 扣满 140 ms 保底发旧帧(140 + 59.6 线上 ≈ 199.6 ms < 205 ms 屏板放弃线,「一召一答」不破);线忙则同旧丢单、 + **不留扣件**(扣一个没人应答过的召唤 = 之后凭空多发一帧)。起发从此**两个中断源**(IDLE 直答 + TIM3 放行), + `sb_tx_kick()` 的"判忙→占位"在 PRIMASK 临界区原子完成——交错起发 = 线上中段叠帧。完整延迟账本见 §7.1。 ## 5. 键值映射(帧 A `key` → `UI_KEY_*`) @@ -167,19 +178,57 @@ flowchart TB | 组帧耗时 | **509 µs 典型 / 812 µs 峰值,预算 ≤1 ms**;没变只付 memcmp ≈20 µs | 台架 `us=` / `max=` 字段 | | 耗时结构 | ≈480 µs 全在 CRC:每帧算满 4206 B,168 MHz 查表 ≈19~20 周期/字节,512 B 表驻 Flash(5 等待周期);清零+拷贝只 ≈20 µs。**占空比无约束**:一帧 280 ms ≈0.2 %,3.5 帧/s ≈0.17 % CPU | 压降两条路已评估**未实施**:表搬 SRAM(≈ −125 µs,+512 B SRAM)、998 B 零段 GF(2) 推进变换(≈ −89 µs,需长期维护矩阵代码)——这个占空比换不来收益;另循环变量用 `uint32_t` 防 ARMCC 每轮多一条 `UXTH`(`sb_crc16`) | +### 7.1 按键反馈延迟:根因账本与 2026-10-30 修复(延迟应答) + +**背景**:用户报告真机 LCD 与模拟器按键反馈都慢(按下→看到画面变化)。模拟器插桩实测 +(SDL 事件→键回调→REFR_READY,40 键)内部路径 **avg 0.68 ms / max 1.2 ms**——窗口自身的 +代码路径不是量级来源;若现场 SDL 窗口仍感觉慢,剩余候选是旧构建 / vsync 呈现 / +远端查看器(7003 路径),另行取证。**真机的慢是结构性的**,账本如下。 + +**修复前(2026-10-29 基线)的账**:键 → 屏板扫描(外部,ms 级)→ 帧 A 立刻发出(屏板按键不等 +周期轮)→ 主机 IDLE 中断入队并**立刻起发当前帧**——可此刻 `uiTask` 还没消化这个键(10 ms +节拍 + 渲染地板 61.3 ms 占住,见 §7 表)→ **这一答发出去的是按键之前的画面(白发)**; +新内容只能随下一次召唤带出。三个结构性主因:①键召唤答旧帧;②`uiTask` FULL 整屏渲染地板 +61.3 ms;③召唤周期 ~275-280 ms(带键最早 205 ms)。放大器④:远端查看器在连时 +`ui_net_sink_poll()` 可阻塞到秒级(登记簿「远端推流阻塞 uiTask」专项,本轮只裁案不做)。 +典型 **~250-450 ms**、最坏 **~500 ms**;④ 发作时秒级。 + +**修复后(2026-10-30,用户裁定三处全做)**: + +| 面 | 改动 | 省掉 | +|---|---|---| +| **延迟应答(核心)** | IDLE 中断收按键召唤 → 只入队、**扣住本答不发**;`uiTask` 本拍排空注键 + `ui_port_service_keys()`(立刻 `lv_indev_read`+`lv_refr_now`,各省一个 ≤20 ms 的 LVGL 定时轮)+ 组帧 OK 后交 `sb_link_key_rendered()` 凭据;TIM3 1 kHz 的 `sb_link_ms_tick()` 见凭据即起发**带键的新帧** | 整轮"答旧帧"≈140-280 ms | +| 闸门 | 起发从此**两个中断源**(IDLE 直答 + TIM3 放行)→ `sb_tx_kick()` 内 PRIMASK 临界区原子占位;交错起发 = 叠发毁帧,闸必须有 | — | +| 截止 | `SB_KEY_RESP_MAX_MS = 140`:uiTask 饿死(对时 reconcile 突发那类)扣满即发旧帧保底——140 + 59.6 线上 ≈ 199.6 ms < 205 ms 屏板放弃线,「一召一答」不破;线忙则同旧丢单不留扣件 | 协议安全边界不动 | +| 注键环 | `ui_port` 注键从单槽改 **8 深环**(满丢最旧+计数)——连按不覆盖 | 连按丢键 | +| 渲染收窄 | `view_apply_sel()` 选中移动且滚动未变时只 invalidate **两行带**(旧选中行+新选中行),整框 invalidate 退为滚动/首刷兜底 | 选中移动类的整屏重绘(61.3 ms 地板页里的大头) | +| 模拟器 | 主循环 sleep 扣掉本轮实际耗时(旧代码在 `lv_refr_now` 前算 sleep_ms,每轮多睡一段工作量) | 每轮一段过冲 | + +**修复后的账**:召唤 → 下一拍 ≤10 ms → 注键+重绘(收窄后选中移动为几 ms 级;页面地板仍 +61.3 ms)→ 组帧 ~0.5 ms → 放行 ≤1 ms → 线上 59.6 ms → **典型 ~70-135 ms**(修复前 +250-450 ms);uiTask 饿死场景 **~200 ms 保底发旧帧**(修复前同场景秒级且白发一帧)。 +统计口径:TX 上报行新增 `dfok=`(新帧放行次数)与 `dfold=`(扣满 140 ms 发旧帧次数—— +**dfold 在涨 = uiTask 被饿 ≥140 ms 的直接证据**,与登记簿「reconcile 突发饿死 uiTask」对拍)。 + +**没动的**:屏板固件(外部工程 DTU-LCD,用户裁定不改)、召唤节奏(280 ms 是屏板的,主机 +单方面把"下一次召唤"的期望值提前不了)、7003 解耦(只留方案在登记簿)。主机单测 +`TC-SBLINK-030~033` 钉住扣件/放行/截止/四边界(套件 164/164)。 + ## 8. 对外接口(单一出处在各头文件,这里只列形状) | 接口 | 上下文 | 作用 | |---|---|---| | `sb_link_init(&huart1)` | 启动,`app_main.c` 步骤 2b | 交叉核对波特率(对不上 `BSP_ERR_STATE`)+ 挂首轮接收 + 使能 DWT | | `sb_link_receive_start()` | 中断/任务 | 重挂 IDLE 接收(一次性 API,漏一次永久静默) | -| `sb_link_on_rx_event(huart,size)` | 中断(rs485.c 转发) | 帧 A 校验、计数、按键入队、**点 TX DMA 应答**、重挂 | +| `sb_link_on_rx_event(huart,size)` | 中断(rs485.c 转发) | 帧 A 校验、计数、按键入队、**周期召唤点 TX DMA 直答;按键召唤扣件延迟应答(§7.1)**、重挂 | | `sb_link_on_error(huart)` | 中断(rs485.c 转发) | 按 SR→DR 清错、按 `RxState` 决定重挂 RX、按 `gState` 清被打断 TX 的残留 busy | | `sb_link_on_tx_cplt(huart)` | 中断(rs485.c 转发) | 清 busy、计数;**不在中断里组帧** | | `sb_link_frame_update(bmp,mask)` | uiTask | 按需组帧(memcmp 判变化),busy 返 `BSP_ERR_BUSY`,自校验不过不发 | | `sb_link_key_take()` | uiTask | 出队屏板原始键号(0 = 空);映射归 uiTask | +| `sb_link_key_rendered()` | uiTask | 交"键已画进画面、帧==活显存"凭据(组帧 `BSP_OK` 拍才准交);延迟应答的放行钥匙 | +| `sb_link_ms_tick()` | 中断(TIM3 1 kHz,`Src/main.c` USER CODE) | 扣件的毫秒时钟:见凭据放新帧(`dfok`)、扣满 140 ms 发旧帧保底(`dfold`) | | `sb_link_poll(now_ms)` | uiTask | 离线判定(每拍)+ 5 s 变化才打的统计行(RX/TX 两行自分频) | -| `sb_link_get_stats(&st)` | 任意上下文 | 计数快照(rx/ok/crc/len/fmt/keys/tx/done/drop/fb/bad/us/off…) | +| `sb_link_get_stats(&st)` | 任意上下文 | 计数快照(rx/ok/crc/len/fmt/keys/tx/done/drop/fb/bad/us/off/dfok/dfold…) | | `sb_crc16` / `sb_frame_a_parse` / `sb_frame_b_build_image` / `sb_frame_b_verify` | 纯逻辑 | 协议层四件套,主机单测可回归 | | `sb_frame_b_build_logo` | 纯逻辑 | **待删除**(LOGO 出局,勿新增调用,§9.2) | @@ -189,7 +238,7 @@ flowchart TB | 层级 | 证据(计数名 = 台架/真机日志 `[SBLINK]` 行字段) | |---|---| -| 主机单测 | `test/host/sb_proto/` **59/59**(屏板实测向量、CRC 表与屏板查表逐字节交叉验证);`test/host/sb_link/` **146/146**(ORE 的 SR→DR 顺序在桩里真执行、非阻塞错误不得重挂、busy 期间帧缓冲逐字节冻结、帧 B 逐字段、周期→µs 换算、离线 9.999 s/10 s 边界与恢复、按键 FIFO 与溢出丢新);`test/host/sb_led_map/` **17/17** | +| 主机单测 | `test/host/sb_proto/` **59/59**(屏板实测向量、CRC 表与屏板查表逐字节交叉验证);`test/host/sb_link/` **164/164**(ORE 的 SR→DR 顺序在桩里真执行、非阻塞错误不得重挂、busy 期间帧缓冲逐字节冻结、帧 B 逐字段、周期→µs 换算、离线 9.999 s/10 s 边界与恢复、按键 FIFO 与溢出丢新;2026-10-30 延迟应答 +18:不立刻起发/凭据放行新帧/140 ms 截止保底旧帧/忙时不留扣件/陈旧凭据不放行/首帧未备同旧丢单);`test/host/sb_led_map/` **17/17** | | 台架(PC + USB 转 TTL 顶替屏板) | `rx == 发送帧数`、`ok == rx`、`crc=0 len=0 size=6`;错波特率灌 64 B 垃圾后**自动恢复**(`rearm=0`,一次都没真失败);杜邦线噪声形态 `bits=0x02`(NE)每帧 ~1.3 次但数据零错;激励停止 10 s 准点 OFFLINE | | 真屏板冒烟(2026-10-29) | 5 s ≈18 帧 → 278 ms/帧,与屏板 280 ms 节拍吻合;`tx==done`、一召一答无双发;**屏板出图(用户目视确认)**——屏板收帧只有"CRC 过 → 刷屏 / 不过 → 静默丢弃"两种结局且无报错通道,**出图 = 屏板亲手端到端验收了帧长 4208、CRC 覆盖 `[0,4205]`、低字节在前、1 bpp 20 B/行 MSB 在前的布局**,这是任何主机侧计数都给不出的一条证据 | | 真屏板 10 min 连续(2026-10-29) | `rx=4258 ok=4258 crc=0 len=0 fmt=0 err=0 bits=0x00 rearm=0`(**连错误中断都没进过**);`tx=4249 done=4248`(`drop=9` 全在开机头两秒帧未就绪期,窗口内零新增);`keys=53 kq=0`(逐键必到、零丢弃);`fb=902 bad=0`(每帧自校验通过);组帧峰值 `us=687 max=812` < 1 ms 预算 | @@ -224,3 +273,6 @@ flowchart TB 8. **`info[0..3]` 双解释**:先判 `info[4..5]` 魔数再解释这 4 字节(普通图 = LED 掩码、LOGO = 宽高), 顺序反了会把 LED 掩码当宽高。 9. **纯注释改动也会让体积凭证报"不同码"**:先重建、再 `ram_baseline.py --update`。 +10. **按键召唤"立刻应答"发出去的必然是按键前的帧**(2026-10-30 前的默认行为):屏板按键召唤来得比 + `uiTask` 消化键快一到两个数量级,"有答"不等于"有新画面"——排查"按了键屏不变化"先对 `dfok=` + 计数,别顺着协议层找;延迟应答机制与账本见 §4 末条与 §7.1。 diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 8351d611..0091875a 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -13,4 +13,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 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 +2026-10-30 246720 29836 2052 149708 276556 26.4 94336 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=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 diff --git a/simulator/main.c b/simulator/main.c index b870d878..5eb95baf 100644 --- a/simulator/main.c +++ b/simulator/main.c @@ -1759,6 +1759,12 @@ int main(int argc, char **argv) { * 本循环不得再调 SDL_PollEvent,否则会先消费事件队列, * 导致键盘输入永远到不了 LVGL */ while (1) { + /* 本轮工作量起点(模拟器侧按键提速):lv_timer_handler() 返回的 sleep_ms 是 + * "距下一个定时器到期",而本循环随后的活(sink_poll / cfg 轮询 / 强制整屏 + * refr)都发生在这笔账**之后**——照单全睡就等于每轮多睡一段工作量, + * 按键后的下一次 SDL 事件轮询(5 ms 拍)也被顺延。扣掉已耗时再睡。 */ + const uint32_t loop_t0 = (uint32_t)SDL_GetTicks(); + /* 轮询 RemoDispBus 协议(非阻塞收发) */ ui_net_sink_poll(); @@ -1788,6 +1794,12 @@ int main(int argc, char **argv) { /* 强制整屏刷新(1bpp SDL PARTIAL 模式下保证首帧输出) */ lv_refr_now(NULL); + /* 扣掉本轮已消耗(见 loop_t0),下限 1 ms——Sleep(0) 会让循环空转烧核 */ + { + const uint32_t spent_ms = (uint32_t)SDL_GetTicks() - loop_t0; + sleep_ms = (sleep_ms > spent_ms) ? (sleep_ms - spent_ms) : 1u; + } + #ifdef _MSC_VER Sleep(sleep_ms); #else diff --git a/test/host/sb_link/sb_link_test.c b/test/host/sb_link/sb_link_test.c index 15dd7d33..b6dbf309 100644 --- a/test/host/sb_link/sb_link_test.c +++ b/test/host/sb_link/sb_link_test.c @@ -48,6 +48,16 @@ * TC-SBLINK-025 组帧耗时换算:周期数 → µs 的除数(SystemCoreClock/1e6) * TC-SBLINK-026 上报行:fb/tx 列可见;离线行排在周期行之前且不重复 * + * 覆盖点(2026-10-30 按键延迟整治:按键召唤延迟应答, + * docs/子系统设计/屏板链路设计说明.md §7.1): + * TC-SBLINK-030 按键召唤不立刻起发:扣住本答、键照常进队、不误记丢单 + * TC-SBLINK-031 放行链:画面更新 + 组帧 OK + key_rendered 凭据 → 下一拍发一帧、 + * dfok 计数、放行后不再复发的 + * TC-SBLINK-032 截止保底:uiTask 饿死时满 SB_KEY_RESP_MAX_MS 发旧帧、dfold 计数、 + * 窗口随即关闭 + * TC-SBLINK-033 边界四条:周期召唤不受影响 / 线忙丢单不留扣件 / + * 新召唤清陈旧凭据 / 首帧未备时同旧丢单 + * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 * @@ -803,6 +813,137 @@ static void tc_tx_report(void) CHECK(sb_log_has(0U, "OFFLINE") == 1, "TC-SBLINK-026e ... and it is the offline one"); } +/* ===================== 按键召唤延迟应答(2026-10-30) ===================== */ + +/* 模拟"TIM3 1 kHz 分支跑了 n 拍"(真机每拍必调 sb_link_ms_tick,见 Src/main.c) */ +static void run_ms_tick(uint32_t n) +{ + uint32_t i; + + for (i = 0U; i < n; i++) { + sb_link_ms_tick(); + } +} + +static void tc_key_defer_fresh(void) +{ + uint8_t f[SB_FRAME_A_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0xA1U); + (void)sb_link_frame_update(s_bmp, 0U); /* 帧备好、线空闲 */ + + make_frame(f, 0x01U, 0x03U, SB_CMD_KEY, 3U); /* 屏板键 3 = DOWN */ + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + + /* 延迟应答的核心断言:按键召唤**不立刻起发**——立刻发的是按键**之前**的画面, + * 这一答等于白发(用户看到变化还要再等一整轮召唤)。 */ + sb_link_get_stats(&st); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-030 key summon does not kick immediately"); + CHECK((st.tx_kick == 0U) && (st.tx_busy_drop == 0U), + "TC-SBLINK-030b ... nothing sent and nothing dropped"); + CHECK(sb_link_key_take() == 3U, "TC-SBLINK-030c ... the key still enters the queue"); + + /* 任务侧的等效动作:画面变新 → 组帧 OK → 交凭据 → 下一拍放行 */ + s_bmp[7] ^= 0xFFU; + (void)sb_link_frame_update(s_bmp, 0U); + sb_link_key_rendered(); + sb_link_ms_tick(); + + sb_link_get_stats(&st); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-031a release sends exactly one frame"); + CHECK(st.defer_fresh == 1U, "TC-SBLINK-031b ... counted as a fresh release"); + CHECK(st.tx_kick == 1U, "TC-SBLINK-031c ... and counted as a normal kick"); + + /* 放行即销扣件:之后的拍不得再发("一召一答"不许变连发) */ + tx_complete(); + sb_link_ms_tick(); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-031d ... and it does not fire again once released"); +} + +static void tc_key_defer_timeout(void) +{ + uint8_t f[SB_FRAME_A_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0xB2U); + (void)sb_link_frame_update(s_bmp, 0U); + + make_frame(f, 0x01U, 0x03U, SB_CMD_KEY, 2U); + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + + /* uiTask 被饿(对时突发那类事故):一直不交凭据。到上限必须发旧帧保底—— + * 欠发会让屏板白等一整个 205 ms 重问窗口,而"答一帧旧画面"至少守住协议。 */ + run_ms_tick(SB_KEY_RESP_MAX_MS - 1U); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-032a still held one ms before the deadline"); + sb_link_ms_tick(); + sb_link_get_stats(&st); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-032b the deadline sends the old frame"); + CHECK(st.defer_old == 1U, "TC-SBLINK-032c ... counted as an old-frame release"); + + tx_complete(); + run_ms_tick(300U); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-032d ... and the deferred window is closed"); +} + +static void tc_key_defer_rules(void) +{ + uint8_t f[SB_FRAME_A_LEN]; + sb_link_stats_t st; + + (void)setup_link(); + fill_bmp(0xC3U); + (void)sb_link_frame_update(s_bmp, 0U); + + /* ① 周期召唤不受影响:照旧立刻答(改动只收紧按键那一支) */ + make_frame(f, 0x01U, 0x03U, SB_CMD_RT, 0U); + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + CHECK(sb_tx_calls == 1U, "TC-SBLINK-033a periodic summon still kicks at once"); + + /* ② 线忙时的按键召唤:同旧丢单,且**不留扣件**——扣一个没人应答过的召唤, + * 等于下一拍凭空多发一帧(那轮召唤屏板早改口重问了)。 */ + tx_complete(); + feed(f, SB_FRAME_A_LEN); /* 再占上线 */ + make_frame(f, 0x01U, 0x03U, SB_CMD_KEY, 4U); + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-033b key summon while TX busy sends nothing"); + CHECK((st.defer_fresh == 0U) && (st.defer_old == 0U), + "TC-SBLINK-033c ... and a busy drop leaves nothing deferred"); + tx_complete(); + run_ms_tick(3U); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-033d ... and it never invents a response later"); + + /* ③ 新按键召唤清掉上一拍的凭据:不得拿按键之前的重绘冒充"新帧已备好"秒放行 */ + (void)setup_link(); + fill_bmp(0xD4U); + (void)sb_link_frame_update(s_bmp, 0U); + sb_link_key_rendered(); /* 按键**之前**交的陈旧凭据 */ + make_frame(f, 0x01U, 0x03U, SB_CMD_KEY, 8U); + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + sb_link_ms_tick(); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-033e stale evidence does not release a new hold"); + + /* ④ 首帧还没组出来(刚上电):按键召唤同旧丢单、不扣件(没帧可扣) */ + (void)setup_link(); + make_frame(f, 0x01U, 0x03U, SB_CMD_KEY, 1U); + sb_tx_calls = 0U; + feed(f, SB_FRAME_A_LEN); + sb_link_get_stats(&st); + CHECK((sb_tx_calls == 0U) && (st.tx_busy_drop == 1U), + "TC-SBLINK-033f key summon before the first frame still drops"); + tx_complete(); + run_ms_tick(3U); + CHECK(sb_tx_calls == 0U, "TC-SBLINK-033g ... and never sends a frame that never existed"); +} + int main(void) { printf("=== ScreenLink Link Tests ===\n"); @@ -863,6 +1004,12 @@ int main(void) tc_frame_timing(); printf("-- batch 2: TX report line --\n"); tc_tx_report(); + printf("-- batch 3: key summon deferred answer --\n"); + tc_key_defer_fresh(); + printf("-- batch 3: deferred answer deadline --\n"); + tc_key_defer_timeout(); + printf("-- batch 3: deferred answer rules --\n"); + tc_key_defer_rules(); printf("\n=== ScreenLink Link Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); diff --git a/test/host/sb_link/stub/main.h b/test/host/sb_link/stub/main.h index a18b045d..e368e944 100644 --- a/test/host/sb_link/stub/main.h +++ b/test/host/sb_link/stub/main.h @@ -108,6 +108,13 @@ typedef struct { volatile uint32_t ErrorCode; } UART_HandleTypeDef; +/* 关中断三件套(2026-10-30 sb_tx_kick 的原子占位闸用)。宿主单线程、没有真中断, + * 空实现即可——临界区的"不交错"性质本来就不是本套件能测的面,逻辑面(busy/ + * frame_ready 闸门、扣件状态机、超时保底)全部照测。 */ +#define __get_PRIMASK() (0U) +#define __disable_irq() ((void)0) +#define __set_PRIMASK(x) ((void)(x)) + /* ===== ③ 假 ReceiveToIdle_IT ===== */ #define SB_FAKE_ARM_OK (0U) #define SB_FAKE_ARM_BUSY (1U)