/* * 模块名称:UI Task * 模块功能:UI 主任务,等待网络就绪后初始化显示/菜单/推流并周期刷新 * 适用平台:STM32F407 系列(LVGL + CH395F 以太网) * 作者:王建锋 * 创建日期:2026-08-30 * 修改记录: * v1.1 2026-09-14 王建锋 新增待机显示页初始化(ui_standby_init,空闲 60s 切入) * v1.2 2026-10-10 王建锋 任务耗时测量埋点:主循环加常开探针 * (循环周期 / 单轮工作耗时 / ui_net_sink_poll 往返), * 用于把"心跳缺席"拆成 饿死 / 阻塞在往返 / 自身拖长 * 三种互斥机理(判读见 docs/子系统设计/看门狗设计说明.md §11.4) * v1.3 2026-10-10 王建锋 解耦心跳(docs/子系统设计/看门狗设计说明.md §11.3):把 task_wd_beat * (TASK_WD_UI) 提到 ui_net_sink_poll() **之前**。原写法心跳 * 在推流往返之后 → 心跳周期 = 渲染 + 一次 netTask 往返 + 10ms, * 实测 uiw≈uis≈50ms(心跳被 netTask 往返主导),心跳实际测的是 * "netTask 响应性"而非"UI 活着"。提到往返之前后,心跳测的是 * "UI 自己被调度到了"。注:若往返**永久**阻塞,本改仍不能救 * (心跳只发一次)→ 真要彻底去掉依赖须走事件驱动 accept * (NET_EVENT_CONNECTED 回调),待复现快照确认阻塞机理后再定。 * v1.4 2026-10-25 DTU 项目组 F3(大窗判读):向 ui_net_sink 注册心跳回调 * (ui_task_sink_beat)——推流段内**每个可等待 net 往返** * (accept/recv/send/close/重建)返回点即喂本任务心跳, * 风暴期「单轮多个慢往返之和」不再串成 800ms 缺席链 * (2026-10-25 实测 bit4 8 连击、uisn=154ms 仍在段内; * 按位连击判据 = docs/子系统设计/看门狗设计说明.md §5.3)。 * 单个往返自身仍无上界——该上界与彻底解耦(事件驱动 * accept)归登记簿「net 层跨上下文收口」(批 3),本票只做心跳续命。 * v1.5 2026-10-26 DTU 项目组 道F task-3 装置维护三页的执行侧:新增 * ui_data_maint_ops_t 六件适配(时钟=写+回读 RTC、 * 复归=prot_elm_request_reset、清除=syslog_request_clear * /clear_state 直通)并在 ui_menu_init 之前注入;记录 * reader 的 2 条 if 链改成 7 条映射表 s_log_map,并加 * 编译期守卫把 UI_W_REC_LOG_* == syslog_id_t 枚举序钉死 * v1.6 2026-11-02 DTU 项目组 修复「回放页段表枚举恒空」第一幕(登记簿同名 * 条目 + docs/测试说明/专项验证/故障录波板级到场轮执行手册.md §3): * ui_task_wave_scan 失败不再无条件钉 s_wave_scanned—— * opendir/readdir 失败本轮返 -1(卷忙语义),下一次进页/ * 按键自然重试;失败分支与「FR_OK 零条目」补带 FR 码留痕 * (正文「ui wave scan」前缀,tag 沿用本文件 [UI_TASK]) * 供板级远程定性根因。FR_NO_PATH(从未录过波)归 0 段 * 并钉表的设计原口径不动。 * v1.7 2026-11-02 DTU 项目组 根因修复:wave_store_seg_path 契约是「成功 = * 写入字符数(恒 16),失败为负码」,读侧两处(load_head * / seg_win)自建成起把返回值 != 0 当失败——每次成功拼 * 路径都被判死,回放页开档永远走不到 f_open,盘上有段 * 也恒显「无录波段」(本 boot FR 埋点钉死:scan done * cnt=7 + head path fail;写侧同函数用 <= 0 一直是对的; * 模拟器桩不走本文件,故黄金图从未暴露此病)。口径改 * <= 0;开档失败补带 FR 码的永久留痕(该失败态页面无 * 视觉面);第一幕临时诊断(序号/try 闸日志/无条件 * done 行)收敛删除。 */ #include "ui_task.h" #include #include "cmsis_os.h" #include "sys_events.h" #include "ui_port.h" #include "ui_menu.h" #include "ui_net_sink.h" #include "ui_standby.h" #include "ui_w_records.h" /* task-6 记录 reader 注入点(UI 页面不再 include sys_log.h) */ #include "ui_w_soe.h" /* task-15 SOE 记录 reader 注入点(同上:UI 不 include soe_store.h) */ #include /* task-15 snprintf:CP56 → UI 视图里的 char[20] 时标 */ #include "ui_data.h" /* task-14 快照存侧(BSP):本任务只负责先复位再喂帧 */ #include "ui_data_feed.h" /* task-14 数据装配节拍入口(App/app,UI 侧唯一发布者) */ #include "sys_log.h" #include "soe_store.h" /* task-15 SOE 只读遍历(count/read);写侧仍归 protectTask/defaultTask */ #include "ui_tokens.h" /* UI 设计令牌:循环周期 UI_TASK_TICK_MS / 待机阈值 UI_STANDBY_IDLE_MS */ #include "sys_clock.h" #include "prot_elm.h" /* 道F task-3 装置维护:强制复归请求 + 动作态元件位图 */ #include "adc_health.h" /* 屏板状态灯 ID 4:ADC 电平故障位(adc_health_flags) */ #include "sb_led_map.h" /* 屏板状态灯位映射(批 3:业务状态 → info[0..3] 掩码) */ #include "iec104_serv.h" /* 屏板状态灯 ID 3:104 链路 RUN 态(任意上下文可读) */ #include "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */ #include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */ #include "task_watchdog.h" /* 故障录波回放道(UI 道):录波注入表执行侧需要的四份头。 * wave_ring.h 给触发投递(request 任意上下文可调、非阻塞); * wave_store.h 给段格式解析与定点 mV 换算(唯一口径,页面不复述算式); * vol_lock.h + ff.h:/wave 只读遍历按 soe_store.c 读者同款纪律—— * try 锁非阻塞(卷忙本轮按「无段」处理,uiTask 绘制节拍绝不等待)、 * 锁跨完整文件操作序列、小窗口分次读、整段 5250B 绝不驻留 RAM */ #include "ff.h" #include "vol_lock.h" #include "wave_ring.h" #include "wave_store.h" #include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */ #include "sb_link.h" /* 屏板链路:帧 B 组帧 / 按键出队 / 统计与离线判定(批 2) */ /* 枚举留痕(v1.6,第一幕埋点):dbg_log.h 原在文件后段(s_wave_ops 之后)才 * 被包含,随本波把 DBG_TAG 定义 + 包含一并上收到包含区(原后段两处删除, * 同标签同宏,不构成行为改动);本波只在原先静默返回的黑洞处补锚点, * 不新增重复日志(DBG_* 每条调用点约 90B Flash + 行缓冲栈)。 * 留痕行以「ui wave scan」正文前缀区分来源。 */ #define DBG_TAG "[UI_TASK]" #include "dbg_log.h" /* * 函数功能:待机屏底部时钟提供回调(平台差异:MCU = sys_clock_get_str) * 入口参数:buf - 时间串输出缓冲 char * * len - buf 容量 size_t * 返回值:无 */ static void ui_task_time_cb(char *buf, size_t len) { if (sys_clock_get_str(buf, len) == NULL && len > 0u) { buf[0] = '\0'; /* 墙钟无效:空串(待机屏时钟行留白) */ } } /* * UI 记录源编号 → syslog_id_t 映射表(道F task-6 起 7 类,task-3 装置维护「清除记录」 * 要在 7 行上分行显示;表长 = UI_W_REC_LOG_N,两端映射表同源同长) * * ⚠ 顺序契约(Lead 裁定 2026-10-26):UI_W_REC_LOG_* 逐字等于 syslog_id_t 枚举序, * 只准尾部追加——枚举序就是 App/log 两端 s_paths[]/s_caps[] 的下标,中间插值会 * 安静地把事故读成告警。下面的编译期守卫就是这条契约在本端的机械防线(记录侧 * 已有 TC-SYSLOG-130/131/131b,UI 侧对应的一条在 test/host/ui_data TC-UIDATA-024, * 两端各钉一次,谁中间插号谁编译不过 / 单测红)。 * 即便如此,适配层**始终显式逐项映射**、从不把 UI 编号强转成 syslog_id_t: * 等值是今天的事实,不是页面可以依赖的契约(UI_W_REC_LOG_* 对页面仍是不透明号)。 */ static const syslog_id_t s_log_map[UI_W_REC_LOG_N] = { SYSLOG_RUN, SYSLOG_SELFTEST, SYSLOG_ACCIDENT, SYSLOG_ALARM, SYSLOG_START, SYSLOG_OPER, SYSLOG_YK }; #if defined(__STDC_VERSION__) && (__STDC_VERSION__ >= 199901L) typedef char ui_task_log_order_check_t[ ((UI_W_REC_LOG_RUN == (uint8_t)SYSLOG_RUN) && (UI_W_REC_LOG_SELFTEST == (uint8_t)SYSLOG_SELFTEST) && (UI_W_REC_LOG_ACCIDENT == (uint8_t)SYSLOG_ACCIDENT) && (UI_W_REC_LOG_ALARM == (uint8_t)SYSLOG_ALARM) && (UI_W_REC_LOG_START == (uint8_t)SYSLOG_START) && (UI_W_REC_LOG_OPER == (uint8_t)SYSLOG_OPER) && (UI_W_REC_LOG_YK == (uint8_t)SYSLOG_YK) && (UI_W_REC_LOG_N == (uint8_t)SYSLOG_COUNT)) ? 1 : -1]; #endif /* * 函数功能:记录 reader 适配——UI 不透明编号 → syslog_id_t 后取条数 * 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t * 返回值:记录条数;未知编号返回 0(页面退化为空态,与合并前直调同形) uint16_t * 限定条件:uiTask 上下文同步调用(记录页与清除记录页的绘制路径,无 I/O 之外开销: * syslog_count 读的是文件长度/内存计数,不逐条读盘) */ static uint16_t ui_task_rec_count(uint8_t log_id) { if (log_id >= (uint8_t)UI_W_REC_LOG_N) { return 0u; } return (uint16_t)syslog_count(s_log_map[log_id]); } /* * 函数功能:记录 reader 适配——读记录字段直通 sys_log(错误码不改写) * 入口参数:log_id/idx/ts/ts_len/desc/desc_len - 契约见 ui_w_records.h ui_rec_reader_t * 返回值:0 成功;-1 编号/序号越界(未知编号同 -1);-2 读失败 int * 限定条件:同上 */ static int ui_task_rec_get(uint8_t log_id, uint32_t idx, char *ts, uint32_t ts_len, char *desc, uint32_t desc_len) { if (log_id >= (uint8_t)UI_W_REC_LOG_N) { return -1; } return syslog_get_record(s_log_map[log_id], idx, ts, (size_t)ts_len, desc, (size_t)desc_len); } /* * ============================ 道F task-3:装置维护写通道(UI 注入执行侧) * * 为什么在这一层实现而不是在页面里:页面属 Drivers/BSP/UI,反调 App 侧的 * sys_clock / prot_elm / sys_log 是分层方向的反例(layer-deps 拦),更关键的是 * **模拟器根本不链 sys_clock**(simulator/dbg_log.h:4「模拟器无 sys_clock/RTC」)—— * 页面里用一次就是"MCU 编得过、模拟器链接期爆"。写动作一律走 ui_data_maint_ops_t, * 两端各给实现(口径 docs/UI设计/UI_数据供给设计说明.md §6.2/§7.2)。 * * uiTask 阻塞上界(IWDG 800ms 窗、本任务在 required_mask 内):本通道里只有 * 时钟设置会碰硬件——sys_clock_set_unix 内含 1 次 SD2506 写,再叠加本适配层刻意 * 加的回读复核 1 次读,各 SD2506_I2C_TIMEOUT_MS=100ms ⇒ 最坏 ≈200ms 落在一次 * "保存"按键上(心跳在循环尾,仍远小于 800ms;对时任务同样在本机上做过这件事)。 * 复归/清除两条只写内存标志或投递给别的任务,无 I/O。 */ /* * 函数功能:设置墙钟并**回读复核**(真机 = 写 RAM 基准 + 写 SD2506 RTC) * 入口参数:a_epoch - Unix 秒(本地时间域,与 sys_clock 同一口径,无时区偏移) uint32_t * 返回值:0 - 已生效且 RTC 回读一致;-1 - 越界 / 未生效 / RTC 回读不符 int * 限定条件:uiTask 上下文(页面"保存"按键直达,同步返回终态) * 函数说明:⚠ **sys_clock_set() 恒返 0**(App/util/sys_clock.c:462 起:RTC 写失败 * 只打 DBG_ERROR,不改返回值),所以本函数不能拿它的返回值当结论—— * 回读是唯一真判据;而只回读 sys_clock_get() 证明的仅是"RAM 基准换了", * 面板对时的用户意图是**掉电后还要是对的**,故必须回读 RTC 本身。 * RTC 坏了但墙钟已改时本函数返 -1(屏上"保存行=ERR + 值列跟着新时间", * 两个事实各说各的,比笼统报 OK 诚实);秒位允许 ±3 与"分/时/日回绕"两种 * 形态,因为两次读之间墙钟与 RTC 都在走。 * 上限 UI_DATA_CLOCK_EPOCH_MAX(2038) 在 UI 层已挡,这里再挡一次: * 执行侧不信任调用侧(0 还兼作"未建钟"哨兵,面板永远不该发 0) */ static int ui_task_maint_clock_set(uint32_t a_epoch) { sd2506_time_t want; sd2506_time_t got; uint32_t now; int ds; if ((a_epoch == 0u) || (a_epoch > (uint32_t)UI_DATA_CLOCK_EPOCH_MAX)) { return -1; } if (sys_clock_set_unix(a_epoch) != 0) { return -1; /* 今天到不了这条腿(sys_clock_set 恒 0),留着是因为它是契约 */ } now = sys_clock_get(); if ((now == UINT32_MAX) || ((now + 2u) < a_epoch) || (now > (a_epoch + 2u))) { return -1; /* RAM 基准没跟上:连"现在就对"都没成立 */ } if (sys_clock_get_time(&want) != 0) { return -1; } if (sd2506_get_time(&got) != BSP_OK) { return -1; /* RTC 读不动:掉电保持这件事无法证明 */ } if ((got.year != want.year) || (got.month != want.month) || (got.day != want.day) || (got.hour != want.hour) || (got.minute != want.minute)) { return -1; } ds = (int)got.second - (int)want.second; if (ds < 0) { ds = -ds; } if ((ds > 3) && !(((want.second >= 58u) && (got.second <= 3u)) || ((got.second >= 58u) && (want.second <= 3u)))) { return -1; } return 0; } /* * 函数功能:请求强制复归(跨上下文:protectTask 下一拍消费) * 入口参数:无 * 返回值:0 - 已投递(prot_elm_request_reset 无拒绝分支) int * 限定条件:uiTask 上下文;不等待执行结果 * 函数说明:只投递不阻塞(契约:UI 页面二次确认后调,页面立刻给"已请求复归")。 * **不在这里投操作记录**:远端 104 YK11 那条已经在 * App/protocol/iec104/iec104_pts.c 投过,在这里再投一次就会给每一次 * 主站复归多记一条"面板复归",审计面直接失真。本地这条由页面投 * (只有页面知道"这是人按的"),措辞也不同串:见 Drivers/BSP/UI/ui_page_reset.c */ static int ui_task_maint_reset_req(void) { prot_elm_request_reset(); return 0; } /* * 函数功能:取当前处于动作态的保护元件个数(复归页观测行 "保护动作 N" 的 N) * 入口参数:无 * 返回值:位图中置 1 的位数(纯 RAM 读,无锁) uint32_t * 限定条件:uiTask 绘制路径每帧可直调 * 函数说明:给个数而不是位图,是为了让 160x160 上一行放得下、也让"复归前 N>0 → * 复归后 N=0"这个台架判据肉眼可读;判据前提(产品构建处于未标定闭锁—— * App/task/protect_task.c:268 判停,判定本体 = adc_cal_prot_blocked(), * 闭锁期 N 恒 0)已写进页面注释 */ static uint32_t ui_task_maint_active_count(void) { uint32_t mask = prot_elm_active_mask(); uint32_t n = 0u; while (mask != 0u) { n += (mask & 0x1u); mask >>= 1; } return n; } /* * 函数功能:投一条本地操作记录(面板动作留痕) * 入口参数:a_desc - 描述串(ASCII,长度须 <= SYSLOG_DESC_MAX=28) const char * * 返回值:无 * 限定条件:uiTask 上下文;只在动作真正被受理之后由页面调用 * 函数说明:用 quiet 口(与 iec104_serv.c 对时留痕同一选择):uiTask 不属于深链 * 任务,但 quiet 口的收/丢判据与 syslog_post 一致且不在调用链上摊开 * 行缓冲,留痕这件事没理由选更贵的那个口。 * 丢条不回报页面:syslog 侧有累计丢弃计数(syslog_post_quiet_dropped()) * 可查;页面上"动作做了没"与"这条审计写没写"是两件事,不该混在一个 * 态标里——把后者做成 ERR 会让人以为复归没做而再按一次 */ static void ui_task_maint_oper_note(const char *a_desc) { if (a_desc == NULL) { return; } syslog_post_quiet(SYSLOG_OPER, a_desc); } /* * 函数功能:请求清除某一类记录(只投递,落地在 defaultTask 的 syslog_drain 尾部) * 入口参数:a_log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t * 返回值:0 - 已受理(MCU 端随后通常是 SYSLOG_CLEAR_WAIT);-1 - 越界编号 int * 限定条件:uiTask 上下文 * 函数说明:本层是 UI 编号 → syslog_id_t 的**唯一**映射点之一(与 s_log_map 同表), * 绝不在 UI 侧做 f_unlink / 也不调 MCU 专属的 syslog_clear_apply(): * 记录卷的属主是 defaultTask,uiTask 去碰 FAT 既违反唯一写者,又给 * IWDG required_mask 内的任务添一条无上界的存储路径(口径 App/log/README.md) */ static int ui_task_maint_clear_req(uint8_t a_log_id) { if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { return -1; } return (syslog_request_clear(s_log_map[a_log_id]) == 0) ? 0 : -1; } /* * 函数功能:取上条清除请求的状态(值直通,不改写、不翻译) * 入口参数:a_log_id - UI 记录源编号 uint8_t * 返回值:UI_DATA_CLEAR_ST_NONE/_OK/_WAIT/_FAIL;越界编号按 NONE int * 限定条件:uiTask 绘制路径每帧可直调(纯内存状态读) * 函数说明:SYSLOG_CLEAR_* 与 UI_DATA_CLEAR_ST_* 逐值相等(0/1/-1/-2),由 * test/host/ui_data TC-UIDATA-024 钉住;这里保持直通,是为了让页面 * 显示的就是日志侧的事实,而不是"适配层以为的事实" */ static int ui_task_maint_clear_state(uint8_t a_log_id) { if (a_log_id >= (uint8_t)UI_W_REC_LOG_N) { return UI_DATA_CLEAR_ST_NONE; } return syslog_clear_state(s_log_map[a_log_id]); } /* 维护通道函数表(六件齐全;ui_data_maint_bind 拒绝残表,缺件就是装配错) */ static const ui_data_maint_ops_t s_maint_ops = { ui_task_maint_clock_set, ui_task_maint_reset_req, ui_task_maint_active_count, ui_task_maint_oper_note, ui_task_maint_clear_req, ui_task_maint_clear_state }; static const ui_rec_reader_t s_rec_reader = { ui_task_rec_count, ui_task_rec_get }; /* * 函数功能:SOE 记录条数适配(道E task-15 reader 注入)——读落盘文件的完整槽数 * 入口参数:无 * 返回值:条数;超过 uint16 上限时钳到 65535(组件契约是 uint16_t) uint16_t * 限定条件:uiTask 上下文同步调用(SOE 记录页绘制路径) */ static uint16_t ui_task_soe_count(void) { uint32_t n = soe_store_count(); return (n > 65535u) ? 65535u : (uint16_t)n; } /* * 函数功能:读第 idx 条 SOE 记录并翻成 UI 视图(二进制 → 不透明显示字段) * 入口参数:idx - 记录序号 uint32_t * p_out - UI 侧记录视图(契约见 Drivers/BSP/UI/ui_w_soe.h) ui_w_soe_rec_t * * 返回值:0 - 成功;-1 - 读失败 / 卷忙 / 校验和不过(页面这行退化显示,不猜) int * 限定条件:uiTask 绘制路径;soe_store_read() 是 try 锁非阻塞,绝不拖住 UI 节拍 * 函数说明:时标**取记录内的 CP56**(事件自己的时间),不用"现在"换算。两条口径 * 叠起来正好与文本记录页一致,别"顺手补 8 小时": * ① sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的 * sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)"); * ② App/log/soe_rec.c 的 epoch→CP56 分解不加任何时区偏移。 * 所以这里打出来的墙上时钟 == 运行/自检记录页里 sys_clock_get_str() 的口径; * 哪天真要引入显式时区,两侧必须一起改(跨模块陷阱,注释即防线)。 * 时钟无效(IV)不写在这里:它是显示语义,由 UI 侧按 SOE_FLG_IV 决定 * (组件把那行名字打"--"、值打"时钟无效"),App 只交代原始记录。 */ static int ui_task_soe_get(uint32_t idx, ui_w_soe_rec_t *p_out) { soe_rec_t rec; uint32_t sec; if ((p_out == NULL) || (soe_store_read(idx, &rec) != 0)) { return -1; } /* CP56 字节表见 App/log/soe_rec.c 的 s_soe_epoch_to_cp56: * [0..1]=分内毫秒(秒域时基 → 恒为秒×1000,亚秒不造假)[2]=分 [3]=时 * [4]=日 [5]=月 [6]=年-2000 */ sec = (((uint32_t)rec.cp56[1] << 8) | (uint32_t)rec.cp56[0]) / 1000u; (void)snprintf(p_out->ts, sizeof(p_out->ts), "%04u-%02u-%02u %02u:%02u:%02u", (unsigned)(2000u + ((uint32_t)rec.cp56[6] & 0x7Fu)), (unsigned)((uint32_t)rec.cp56[5] & 0x0Fu), (unsigned)((uint32_t)rec.cp56[4] & 0x1Fu), (unsigned)((uint32_t)rec.cp56[3] & 0x1Fu), (unsigned)((uint32_t)rec.cp56[2] & 0x3Fu), (unsigned)sec); p_out->type = rec.type; p_out->elm = rec.elm_id; p_out->flags = rec.flags; p_out->seq = rec.seq; p_out->value = rec.value; return 0; } static const ui_soe_reader_t s_soe_reader = { ui_task_soe_count, ui_task_soe_get }; /* ═══════════════ 故障录波注入表执行侧(UI 道,2026-10-29)═══════════════ * 纪律逐条对齐 soe_store.c 读者(vol_lock try / 锁跨完整文件序列 / 小窗口): * - 卷忙(vol_lock_wait(0) 返 0)一律按「本轮无段」处理——uiTask 是 * LVGL + 推流节拍的所有者,等锁 = 停摆,宁可空一帧也不阻塞(lead 裁定); * - 段完整性判定只信 wave_store 自己的判据(尺寸等式 / magic+ver / 双 CRC / * sem==index),本胶水不读 avail/flags 做二次裁断(§6.3 与 2026-10-29 * 「VALID+CLIPPED 非矛盾」裁定同源:可用性不是完整性); * - 整段 5250B 不驻留:256B 静态窗口分次读,头区一次、数据区流式过 CRC。 * uiTask 阻塞上界(IWDG 800ms 窗、uiTask 在 required_mask 内;写法对齐上方 * 维护通道那段上界,同文件先例): * - 第零项是「本块不等待」:vol_lock_wait(0) 拿不到锁即按「本轮无段」 * 返回——不等锁 = 零阻塞,它本身就是上界的一项,不是只保心跳判读; * - 一次渲染实际发生的 I/O(进回放页首绘、或按键后重绘那帧)三件叠加: * ① 表重扫 = 1 次 f_opendir + 至多 WAVE_ST_SEG_MAX(100)次 f_readdir, * 收尾是 100 元插入排序(纯内存); * ② 段头装载 = 仅未缓存段才发生:1 次 f_open + 头区 1 次 128B 读 + * head_parse 内的头 CRC + 数据区 20 次 256B 流式读 + 尾 2B 一次 * = 最坏 22 次小读;5120B 逐字节 CRC 与 sem 表校验是纯计算, * 168MHz 下微秒到毫秒量级;缓存命中零卷操作; * ③ 窗口装载 = 1 次 f_open + 1 次 f_lseek + 1 次 80B 读(40 点一窗), * 解码 40 点 + 逐点 code_to_milli 峰值扫描 40 次(int64 乘除, * 微秒级)——不是全段 8×320 扫描,回放页一屏只碰一个通道一窗; * - 纯内存不碰卷的路径(评审据此看最坏的落点):manual_req 只投一个 * 请求字节;已缓存段的上下切道、左右切段的段号表环游;空态与坏态 * 直接返回。所以最坏只落在「第一次进回放页 / 第一次切到未缓存段」 * 这类一次性动作上,同段内连续按键不重复付 ②。 * 没有实测数,上面是与落盘道同一把尺的**推算**:落盘道的告警线是 * 50 ms / 21 次写,读路径无簇分配、不写 FAT 元数据,只会更便宜,故按 * 同量级不高于它估——一次渲染最坏一窗 ≈ 段表 + 一段全验 + 一窗,仍远 * 小于 800ms。实测归台架,仪器就用本文件已经在跑的 task_prof 单轮 * 工作耗时探针,不必新加埋点。 * 段序口径(V0.1 已知限制,终报点名):目录重建后按段号数值排序环游, * 盘上写满 100 段翻转后「数值序 = 时间序」不再严格成立——每屏顶行都带 * 段号自证身份,查事故先看号再看序;翻转窗口排序升级留给台架结论。 */ static uint8_t s_wave_idx[WAVE_ST_SEG_MAX]; /* 盘上现存段号升序表 */ static uint8_t s_wave_cnt; /* 表内条数 */ static uint8_t s_wave_scanned; /* 表是否有效(manual_req 后作废) */ static uint8_t s_wave_cached_idx; /* 段头缓存属于哪一段 */ static uint8_t s_wave_cache_ok; /* 该段已验完整(头+数据 CRC) */ static wave_seg_meta_t s_wave_meta; /* 已验段的头(172B,唯一驻留) */ static uint8_t s_wave_buf[256u]; /* 读窗:数据区流式 256B/次(lead 裁定 * 2026-10-29 第 1 条·第一条:128B 抬 256B, * f_read 次数对半砍,RAM 只多 128B; * 不选 100 位「已验」位图——位图的作废纪律 * 一旦漏做,「CRC 不符必须拒收」这条台架 * 判据对旧段号会静默失效,多 128B 没这个面)*/ /* * 函数功能:重扫 /wave 目录,把现存段号收集成升序表 * 入口参数:无 * 返回值:0 - 扫描完成(条数见 s_wave_cnt);-1 - 本轮无完整事实(卷忙,或 * f_opendir/f_readdir 失败),不钉表,下一次取数自然重试 int * 限定条件:uiTask;一次 f_opendir + 有界 f_readdir,全程锁内 * 函数说明:目录不存在(FR_NO_PATH,从未录过波)与目录空都归「0 段」并钉表, * 不是错误(设计原口径);其余 f_opendir/f_readdir 失败一律不钉表 * (v1.6:原实现失败也无条件置 s_wave_scanned=1,卷忙 / FR_DENIED * 一类失败被钉成「0 段」假空态直到下一次 manual_req,登记簿 * 「回放页段表枚举恒空」处置方向 = 失败不钉表、下键重试,与 * soe_store 读者同纪律)。失败留痕带 FR 码,板级判读表(FR_DENIED * → 锁纪律;FR_NOT_READY → 挂载;FR_OK 但零条目 → readdir 与卷状态 * 交互)见 docs/测试说明/专项验证/故障录波板级到场轮执行手册.md §3。 * 名字不合形态的文件(E_NAME)静默跳过——那是别的道写进来的 * 东西,回放页不替它背书也不报警 */ static int ui_task_wave_scan(void) { static DIR dir; /* static:同 FIL 理由,压栈深 */ FILINFO finfo; FRESULT fr; FRESULT rr = FR_OK; uint8_t idx; uint8_t i; uint8_t full; /* 1 = 本轮拿到完整事实,允许钉表 */ if (vol_lock_wait(0u) == 0u) { /* 卷忙本轮跳过 = 静默(看门狗设计说明 §4.3 口径:消费方在 try 失败 * 分支不留日志,锁层竞争看 vol_lock_get_stats 计数);下一次进页/ * 按键自然重试——本轮没拿到事实,绝不钉表 */ return -1; } s_wave_cnt = 0u; full = 0u; fr = f_opendir(&dir, WAVE_ST_DIR); if (fr == FR_OK) { full = 1u; for (;;) { rr = f_readdir(&dir, &finfo); if (rr != FR_OK) { full = 0u; /* 半截表不钉:本轮作废重来 */ break; } if (finfo.fname[0] == '\0') { break; /* 目录正常走完 */ } if (finfo.fattrib & AM_DIR) { continue; } if (wave_store_idx_from_name(finfo.fname, &idx) == 0) { if (s_wave_cnt < WAVE_ST_SEG_MAX) { s_wave_idx[s_wave_cnt++] = idx; } } } (void)f_closedir(&dir); if (full == 0u) { DBG_ERROR("ui wave scan readdir fail fr=%d got=%u", (int)rr, (unsigned)s_wave_cnt); } } else if (fr == FR_NO_PATH) { full = 1u; /* 从未录过波 = 0 段,完整事实 */ } else { DBG_ERROR("ui wave scan opendir fail fr=%d", (int)fr); } vol_lock_release(); if (full == 0u) { s_wave_cnt = 0u; /* 半截计数不外泄:本轮按「无段」显示 */ return -1; } if (s_wave_cnt == 0u) { /* 判读表第三条(FR_OK 但零条目):钉盘级证据;有段不重复刷 * (DBG_* 每条调用点约 90B Flash,开页成功态由画面自证) */ DBG_INFO("ui wave scan done: dir ok, 0 seg entries"); } /* 插入排序:条数上限 100,不引 qsort 的 stdlib 依赖 */ for (i = 1u; i < s_wave_cnt; i++) { uint8_t j; uint8_t tmp; tmp = s_wave_idx[i]; j = i; while ((j > 0u) && (s_wave_idx[j - 1u] > tmp)) { s_wave_idx[j] = s_wave_idx[j - 1u]; j--; } s_wave_idx[j] = tmp; } s_wave_scanned = 1u; return 0; } /* * 函数功能:确保段表可用(必要时重扫) * 入口参数:无 * 返回值:0 - 表可用;-1 - 卷忙 int * 限定条件:uiTask 绘制/按键链 */ static int ui_task_wave_table(void) { if (s_wave_scanned == 0u) { return ui_task_wave_scan(); } return 0; } /* * 函数功能:表内查段号下标 * 入口参数:a_idx - 段号;a_pos - 出参下标 * 返回值:1 - 命中;0 - 不在表内 uint8_t * 限定条件:表已可用(先过 ui_task_wave_table) */ static uint8_t ui_task_wave_find(uint8_t a_idx, uint8_t *a_pos) { uint8_t i; for (i = 0u; i < s_wave_cnt; i++) { if (s_wave_idx[i] == a_idx) { *a_pos = i; return 1u; } } return 0u; } /* * 函数功能:读段并做完整性判定(头 CRC 由 head_parse 判,数据 CRC 流式判), * 成功则把段头留在 s_wave_meta 供 seg_win 免重读 * 入口参数:a_idx - 段号 * 返回值:UI_WAVE_OK / UI_WAVE_NONE(卷忙/文件没了)/ UI_WAVE_BAD(判废) int * 限定条件:uiTask;锁跨 open→close 完整序列;头 128B 一次、数据区 256B * 分窗流式(共约 1 + 20 次 f_read),整段不驻留 * 耗时账(lead 裁定 2026-10-29 第 1 条·第三条并存):未缓存段的完整性 * 判定最坏约 21 次小读 + 5120B 逐位 CRC,全部在卷锁内、uiTask 上; * IWDG 800ms 窗内余量几何**实测毫秒归台架**(终报点名待测项)。 * 缓存只留一段:左右切段 A→B→A 会把 B 的校验再来一遍——接受该 * 成本的代价换「CRC 不符必须拒收」永远现算,不做跨键存的位图 * 函数说明:缓存命中(同段且已验)直接返 OK——左右切段回到上一段、上下切 * 通道都靠它把重复的 5250B 全扫压成一次;文件读中途消失(被别的 * 写者轮转掉)按 NONE 处理,页面下轮自然重扫 */ static int ui_task_wave_load_head(uint8_t a_idx) { static FIL f; /* static:FIL = 528B,理由同 soe_store_read */ char path[WAVE_ST_PATH_MAX]; /* 容量 = WAVE_ST_PATH_MAX(17 字节口径, * 自己声明路径缓冲不得写死 16) */ FRESULT fr; FRESULT ofr; /* 开档 FR 码:开档失败留痕用 */ UINT br = 0u; int rc; if ((s_wave_cache_ok != 0u) && (s_wave_cached_idx == a_idx)) { return UI_WAVE_OK; } if (wave_store_seg_path(path, (uint32_t)sizeof(path), a_idx) <= 0) { /* 契约(wave_store.h:458):成功 = 写入字符数(恒 16),失败才是负码。 * 建成功能起这里写成 != 0,把每次成功都当失败 → 回放页开档永远走不到 * f_open,盘上明明有段也恒显「无录波段」(2026-11-02 板级 FR 埋点 * 钉死:scan done cnt=7 + head path fail;写侧 default_task.c 同函数 * 用 <= 0,一直是对的)。口径取和写侧一致的 <= 0。 */ return UI_WAVE_NONE; } if (vol_lock_wait(0u) == 0u) { return UI_WAVE_NONE; /* 卷忙 = 本轮按无段处理(不阻塞 uiTask) */ } rc = UI_WAVE_NONE; ofr = f_open(&f, path, FA_READ); if (ofr == FR_OK) { uint32_t size; uint32_t total; uint32_t n; size = (uint32_t)f_size(&f); total = 0u; rc = UI_WAVE_BAD; /* 第一步:头区(首窗 128B = 当前形态整头);head_parse 自带判废 */ n = (size < (uint32_t)WAVE_ST_HEAD_SZ_CUR) ? size : (uint32_t)WAVE_ST_HEAD_SZ_CUR; fr = f_read(&f, s_wave_buf, n, &br); if ((fr == FR_OK) && (br == n)) { if (wave_store_head_parse(s_wave_buf, n, &s_wave_meta) == 0) { total = wave_store_seg_size(&s_wave_meta); } } /* 第二步:尺寸等式(零长/长度与头部字段矛盾 = 整段判废,§6.3) */ if ((total != 0u) && (size == total)) { uint32_t off; uint16_t crc; uint8_t tail[2]; /* 第三步:数据区流式 CRC(128B 窗;CRC 覆盖不含尾两字节自身) */ crc = WAVE_ST_CRC_INIT; off = wave_store_head_bytes(s_wave_meta.ch_count); rc = UI_WAVE_BAD; while ((off + 2u) < size) { n = (size - 2u - off < (uint32_t)sizeof(s_wave_buf)) ? (size - 2u - off) : (uint32_t)sizeof(s_wave_buf); fr = f_read(&f, s_wave_buf, n, &br); if ((fr != FR_OK) || (br != n)) { rc = UI_WAVE_BAD; break; } crc = wave_store_crc_update(crc, s_wave_buf, n); off += n; } /* 第四步:读回存储值比对(低字节在前) */ if ((rc == UI_WAVE_BAD) && (f_lseek(&f, size - 2u) == FR_OK) && (f_read(&f, tail, 2u, &br) == FR_OK) && (br == 2u)) { if (crc == (uint16_t)(tail[0] | ((uint16_t)tail[1] << 8))) { rc = UI_WAVE_OK; } } } else if (total == 0u) { rc = UI_WAVE_BAD; /* seg_size 返 0 = 头字段自相矛盾,判废 */ } else { rc = UI_WAVE_BAD; /* 长度与头部字段不符,判废 */ } (void)f_close(&f); } vol_lock_release(); if (rc == UI_WAVE_OK) { s_wave_cached_idx = a_idx; s_wave_cache_ok = 1u; } else { s_wave_cache_ok = 0u; if (ofr != FR_OK) { /* 表上明明列着这个段、开档却失败:页面只会显示「无录波段」, * 没有任何视觉面——必须留痕(10-31 半轮栽就栽在这类静默面上) */ DBG_ERROR("ui wave head idx=%u open fail fr=%d", (unsigned)a_idx, (int)ofr); } } return rc; } /* * 函数功能:录波触发页唯一写出口——投一次手动触发请求 * 入口参数:无 * 返回值:0 - 已受理;非零 - 拒收(未 IDLE/冷却中/参数),页面显示 ERR int * 限定条件:uiTask(request 本身任意上下文可调,这里只是转发不等待) * 函数说明:受理 = ring 进冻结窗,成段最快也在约一秒后由默认任务落盘; * 顺手作废段表缓存,页面下次取数会看到新段 */ static int ui_task_wave_manual_req(void) { int r = wave_ring_request((uint8_t)WAVE_TRIG_MANUAL, 0u); if (r == 0) { s_wave_scanned = 0u; s_wave_cache_ok = 0u; /* lead 裁定 2026-10-29 第 3 条:段表与段头缓存 * 一起作废——同名文件会被轮转重写,旧头字段 * (pts/pre/k/b/逐路 avail)不作废就会顶着新段 * 的号用旧尺子。单槽缓存本身 = 已知限制: * 只挡「本段验过不再重验」,多段来回切会重验 */ } return r; } /* * 函数功能:取盘上「数值最大段号」(回放页开屏落点) * 入口参数:a_idx - 出参段号 * 返回值:UI_WAVE_OK / NONE(无段或卷忙本轮)/ BAD 本件不产(表只含现存文件) * 函数说明:口径 = 数值最大,**不是时间最新**(lead 裁定 2026-10-29): * 回绕后数值序不等于时间序,页顶显示的段号即自证;真做时间序要 * 逐段开文件读 frame_seq/t0,一百次开档不在回放页的账上,本波不做 */ static int ui_task_wave_seg_first(uint8_t *a_idx) { if (ui_task_wave_table() != 0) { return UI_WAVE_NONE; /* 卷忙 = 本轮无段(同一条不阻塞纪律) */ } if (s_wave_cnt == 0u) { return UI_WAVE_NONE; } *a_idx = s_wave_idx[s_wave_cnt - 1u]; return UI_WAVE_OK; } /* * 函数功能:环游取邻段(a_dir:-1 更早 / +1 更新;段号数值序环回) * 入口参数:a_cur - 当前段号;a_out - 出参邻段段号 * 返回值:UI_WAVE_OK / NONE(表不可用或已无邻段可去) int * 限定条件:uiTask;当前段已被轮转覆盖时先重扫一次再定位 */ static int ui_task_wave_seg_around(uint8_t a_cur, int8_t a_dir, uint8_t *a_out) { uint8_t pos = 0u; if (ui_task_wave_table() != 0) { return UI_WAVE_NONE; } if (ui_task_wave_find(a_cur, &pos) == 0u) { if ((ui_task_wave_scan() != 0) || (s_wave_cnt == 0u) || (ui_task_wave_find(a_cur, &pos) == 0u)) { /* 当前段没了(翻转覆盖):退回首段,画面由调用侧重载 */ if (s_wave_cnt == 0u) { return UI_WAVE_NONE; } *a_out = s_wave_idx[0u]; return UI_WAVE_OK; } } if (s_wave_cnt == 1u) { *a_out = s_wave_idx[0u]; /* 单段:原地(用户看到的仍是这段) */ return UI_WAVE_OK; } if (a_dir > 0) { *a_out = s_wave_idx[(uint8_t)((pos + 1u) % s_wave_cnt)]; } else { *a_out = s_wave_idx[(uint8_t)((pos + s_wave_cnt - 1u) % s_wave_cnt)]; } return UI_WAVE_OK; } /* * 函数功能:切到段号表里的前一段(LR 左键的落点,数值升序表 → 「前」= 号更小) * 入口参数:a_cur 当前段号;a_out 输出切换后的段号(越界/空表不写) * 返 回 值:UI_WAVE_OK 已给出;UI_WAVE_NONE 盘上一段都没有 int * 限定条件:仅 uiTask 上下文(段号表是本任务私有缓存) * 函数说明:只是 ui_task_wave_seg_around 的方向包装,存在的理由是 ops 表要两个 * 独立函数指针,不写第二份查找逻辑。 */ static int ui_task_wave_seg_prev(uint8_t a_cur, uint8_t *a_out) { return ui_task_wave_seg_around(a_cur, -1, a_out); } /* * 函数功能:切到段号表里的后一段(LR 右键;数值升序表 → 「后」= 号更大) * 入口参数:a_cur 当前段号;a_out 输出切换后的段号(越界/空表不写) * 返 回 值:UI_WAVE_OK 已给出;UI_WAVE_NONE 盘上一段都没有 int * 限定条件:仅 uiTask 上下文(同上) * 函数说明:与 seg_prev 共用 ui_task_wave_seg_around,切段即触发一次懒加载; * 未缓存段的读盘 + 流式 CRC 也发生在这条调用链上(单轮上界见本 * 文件「故障录波注入表执行侧」块首注释的 uiTask 阻塞上界段)。 */ static int ui_task_wave_seg_next(uint8_t a_cur, uint8_t *a_out) { return ui_task_wave_seg_around(a_cur, 1, a_out); } /* * 函数功能:读段头(含双 CRC 完整性判定——判定发生在 ui_task_wave_load_head) * 入口参数:a_idx - 段号;a_eng/a_trig/a_clip - 段头三字段原值 * 返回值:UI_WAVE_OK / NONE / BAD int * 限定条件:uiTask;命中缓存零卷操作 */ static int ui_task_wave_seg_head(uint8_t a_idx, uint8_t *a_eng, uint8_t *a_trig, uint8_t *a_clip) { int rc = ui_task_wave_load_head(a_idx); if (rc != UI_WAVE_OK) { return rc; } *a_eng = s_wave_meta.eng; *a_trig = s_wave_meta.trig_src; *a_clip = (uint8_t)(s_wave_meta.clip_mask & 0xFFu); return UI_WAVE_OK; } /* * 函数功能:读固定显示窗(触发点前后各一周波)的码值与峰值 * 入口参数:a_idx - 段号;a_ch - 通道号;a_codes - UI_WAVE_WIN_PTS 个码值 * (已并入段头 b_codes);a_peak_milli - 窗内绝对值最大点的 mV * 返回值:UI_WAVE_OK / NONE(段头未验过/卷忙)/ BAD(数据区读不齐) int * 限定条件:uiTask;先段头后窗口(未过完整性判定的段不供数据,页面显示 * 「该段不可读」而不是半截波形——判废优先于展示) * 函数说明:窗口起点用段头 pre_pts 现算(§8.2「数据不是编译常量」: * 起点 = pre_pts - 窗长一半,与光标居中是同一个 20 的一半, * 改窗宽只改 ui_data.h 的 UI_WAVE_WIN_PTS 一处);峰值的 mV * 换算逐点走 wave_store_code_to_milli —— 全仓唯一口径, * 这里不重抄公式(§6.3;PC 道同一条款) */ static int ui_task_wave_seg_win(uint8_t a_idx, uint8_t a_ch, int16_t *a_codes, int32_t *a_peak_milli) { static FIL f; char path[WAVE_ST_PATH_MAX]; FRESULT fr; /* 开档 FR 码:开档失败留痕用 */ UINT br = 0u; uint32_t off; uint32_t want; uint32_t start; uint32_t got_pts = 0u; int16_t raw[UI_WAVE_WIN_PTS]; int32_t milli; int rc; if (ui_task_wave_load_head(a_idx) != UI_WAVE_OK) { return UI_WAVE_BAD; /* 段头坏了/读不到:窗口无从谈起 */ } if (a_ch >= s_wave_meta.ch_count) { return UI_WAVE_BAD; } if ((s_wave_meta.pts_per_ch < UI_WAVE_WIN_PTS) || (s_wave_meta.pre_pts < (UI_WAVE_WIN_PTS / 2u))) { return UI_WAVE_BAD; } start = (uint32_t)s_wave_meta.pre_pts - (UI_WAVE_WIN_PTS / 2u); off = wave_store_data_off(s_wave_meta.ch_count, s_wave_meta.pts_per_ch, a_ch, start); if (off == 0xFFFFFFFFu) { return UI_WAVE_BAD; } want = wave_store_data_bytes_avail(s_wave_meta.pts_per_ch, (uint16_t)start, UI_WAVE_WIN_PTS); if (want != UI_WAVE_WIN_PTS * 2u) { return UI_WAVE_BAD; /* 窗口贴不出完整 40 点:显式不可读 */ } if (wave_store_seg_path(path, (uint32_t)sizeof(path), a_idx) <= 0) { /* 同 ui_task_wave_load_head:契约成功 = 16,失败是负码;!= 0 的旧写法 * 把成功当失败,窗口永远贴不出(2026-11-02 根因修复,详见那里注释) */ return UI_WAVE_NONE; } if (vol_lock_wait(0u) == 0u) { return UI_WAVE_NONE; } rc = UI_WAVE_BAD; fr = f_open(&f, path, FA_READ); if (fr == FR_OK) { if ((f_lseek(&f, off) == FR_OK) && (f_read(&f, s_wave_buf, want, &br) == FR_OK) && (br == want)) { if ((wave_store_le_decode(s_wave_buf, want, raw, UI_WAVE_WIN_PTS, &got_pts) == 0) && (got_pts == UI_WAVE_WIN_PTS)) { rc = UI_WAVE_OK; } } (void)f_close(&f); } vol_lock_release(); if (rc == UI_WAVE_OK) { uint8_t i; int32_t peak = 0; for (i = 0u; i < UI_WAVE_WIN_PTS; i++) { int32_t v; v = (int32_t)raw[i] + s_wave_meta.ch[a_ch].b_codes; if (v > 32767L) { v = 32767L; } else if (v < (-32768L)) { v = -32768L; } a_codes[i] = (int16_t)v; /* 显示与度量同一把尺(「谁被显示谁被度量」,§6.3): * a_codes 画的是 raw+b,而 wave_store_code_to_milli 的公式是 * (a_code + b_codes) × k_q16 / 65536000 取整(App/algo/ * wave_store.c:567 实测口径)——传 raw 进去,b 已由唯一口径 * 自己加过,PK 数与屏上波形天然同尺,这里不再加第二遍 * (加两遍才是错);clamp 只护 int16 画布,b 的 V0.1 恒 0 */ if (wave_store_code_to_milli(&s_wave_meta.ch[a_ch], raw[i], &milli) == 0) { if ((milli < 0 ? -milli : milli) > (peak < 0 ? -peak : peak)) { peak = milli; } } } *a_peak_milli = peak; } else if (fr != FR_OK) { /* 理由同 load_head 的开档留痕:表列着的段开不出,页面无视觉面 */ DBG_ERROR("ui wave win idx=%u ch=%u open fail fr=%d", (unsigned)a_idx, (unsigned)a_ch, (int)fr); } return rc; } /* 录波通道函数表(六件齐全;ui_data_wave_bind 拒绝残表,缺件就是装配错) */ static const ui_data_wave_ops_t s_wave_ops = { ui_task_wave_manual_req, ui_task_wave_seg_first, ui_task_wave_seg_prev, ui_task_wave_seg_next, ui_task_wave_seg_head, ui_task_wave_seg_win, }; /* * 函数功能:推流往返边界的心跳续命钩(F3,注册给 ui_net_sink) * 入口参数:无 * 返回值:无 * 限定条件:uiTask 轮询上下文(sink 在往返返回处调用),只做置位、不阻塞 * 函数说明:为何经回调注入而非 sink 直调 task_wd_beat()——分层边界(BSP 不得 * include App 头,layer-deps 门禁);心跳属主仍是本任务。 */ static void ui_task_sink_beat(void) { task_wd_beat(TASK_WD_UI); } /* * 函数功能:等待网络就绪事件 * 入口参数:无 * 返回值:无 * 限定条件:sys_evtHandle 为 NULL 时直接返回 * 函数说明:无限期等待 SYS_EVT_NET_READY 标志 */ static void ui_task_wait_net_ready(void) { if (sys_evtHandle != NULL) { /* osFlagsNoClear:就绪位是状态,不能被本任务消费掉(否则其它等待者永不唤醒)。 * 2026-10-02 台架修复:等待改 100ms 分片 + 每片喂心跳(与 timeSync/netTask/ * ftpTask 入口同模式)——IWDG 必需掩码含 UI 位,原 osWaitForever 无心跳: * IWDG 启动(存储就绪)时网络未必就绪 → 掩码 800ms 不可达 → 停喂 → 复位循环。 * 100ms 分片 = 抽样窗,纯任务睡眠零队列开销。 */ for (;;) { uint32_t r = osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny | osFlagsNoClear, 100u); if ((r & osFlagsError) == 0u) { break; /* NET_READY 已置位(状态位,未被消费) */ } task_wd_beat(TASK_WD_UI); /* 看门狗:分片喂心跳(屏障等待态) */ } } } /* * 函数功能:把屏板键号映射为 UI 键码 * 入口参数:board_key - 屏板帧 A 的 key 字节(1~10,语义见 docs/子系统设计/屏板链路设计说明.md §5) * 返回值:UI_KEY_* 之一;0 = 不映射(无键 / RESET / FACTORY / 屏板无物理入口的 5、6) * 限定条件:只在 uiTask 上下文调用(ui_port_queue_key 的契约是输入子系统已初始化) * 函数说明:1. 屏板 key_configs[] 只有 **7 项**,可达值仅 1/2/3/4/7/8/9——别按屏板 readme * 的 8 键表实现(5/6 = ADD/DEC 在实物上没有入口,永远收不到)。 * 2. 9(RESET)/10(FACTORY)语义待产品裁定:**先丢弃并留痕**,不要擅自 * 映射成参考机的 F1 / F1+ENT——否则现场按一下"复位"会把菜单弹到未知页面, * 而且那种误映射极难从现象反推回映射表。 */ static uint8_t ui_task_board_key_map(uint8_t board_key) { switch (board_key) { case 1u: return UI_KEY_ENTER; case 2u: return UI_KEY_UP; case 3u: return UI_KEY_DOWN; case 4u: return UI_KEY_ESC; case 7u: return UI_KEY_LEFT; case 8u: return UI_KEY_RIGHT; default: return 0u; } } /* 组帧失败只留痕一次:组帧每 10 ms 一次,每拍打一行会把控制台冲爆(120 s 就 1.2 万行), * 而 BSP_ERR_PARAM/BSP_ERR_STATE 是配置类错误,出现一次就是一直出现,一行足够定位。 */ static uint8_t s_sb_frame_err_logged; /* * 函数功能:UI 主任务入口,初始化显示/菜单/推流并进入刷新主循环 * 入口参数:argument - FreeRTOS 任务参数(未使用) void * * 返回值:无 * 限定条件:FreeRTOS 已启动 * 函数说明:1. 等待 SYS_EVT_NET_READY 后依次初始化 ui_port/ui_menu/ui_net_sink * 2. 主循环周期调用 ui_port_step 与 ui_net_sink_poll 刷新界面 */ void ui_task_func(void *argument) { (void)argument; task_wd_bind(TASK_WD_UI); /* FTL 心跳路由:登记本任务句柄(入口一次) */ /* 等待网络就绪期间不可能喂狗:本任务此时尚未进入主循环,而就绪位由 netTask 置位。 * 该段若长期不成立,看门狗会复位——这是期望行为(网络 bring-up 卡死须暴露)。 * 注意本任务在 required_mask 内,主循环跑起来后须保证每 100ms 窗口内至少喂一次。 */ DBG_INFO("UI task: started, wait net ready"); ui_task_wait_net_ready(); DBG_INFO("UI task: net ready, init port"); DBG_INFO("UI task: ui_port_init enter"); if (ui_port_init() != 0) { DBG_ERROR("UI port init: FAIL"); for (;;) { osDelay(1000); } } DBG_INFO("UI port init: OK"); /* 记录 reader 注入:必须在 ui_menu_init 之前(记录页在此前不可能被打开, * 但契约把"启动期先绑定"写死;未绑定窗口组件按空态防御——见 ui_w_records.h) */ ui_w_records_bind(&s_rec_reader); ui_w_soe_bind(&s_soe_reader); /* task-15:SOE 记录页数据入口(未绑定按"无记录"空态) */ /* 装置维护写通道注入(道F task-3:时钟设置 / 强制复归 / 清除记录):同样必须先于 * ui_menu_init。未注入的窗口里六个入口一律安全返回(页面显 ERR 或空态), * 既不崩也不假成功;但残表是真故障,必须能在启动日志里看见,不能静默 */ if (ui_data_maint_bind(&s_maint_ops) != 0) { DBG_ERROR("UI maint bind: FAIL"); } /* 录波通道注入(故障录波道 UI 侧,2026-10-29):手动录波触发页与回放页 * 的唯一数据入口,同样先于 ui_menu_init。未注入窗口里六件按「无录波段」 * 空态渲染(不画线、不崩);残表 = 装配错,启动日志必须可见 */ if (ui_data_wave_bind(&s_wave_ops) != 0) { DBG_ERROR("UI wave bind: FAIL"); } /* 快照层复位:必须先于 ui_menu_init(首帧之前任何页面被打开都按"无数据" * 渲染 "--",不会读到上电残留)——UI_数据供给设计说明.md §5.3 */ ui_data_init(); /* 首帧立即发布:待机屏/菜单屏在启动早期就可能被远端推流打开,等 200 ms * 会闪一下 "--"(同文档 §5.3);本调用在 ui_menu_init 之前,页面尚未建立, * 发布路径不触 LVGL,安全 */ ui_data_feed_tick(); DBG_INFO("UI task: ui_menu_init enter"); if (ui_menu_init() != 0) { DBG_ERROR("UI menu init: FAIL"); for (;;) { osDelay(1000); } } DBG_INFO("UI menu init: OK"); if (ui_standby_init(UI_STANDBY_IDLE_MS, ui_task_time_cb) != 0) { DBG_ERROR("UI standby init: FAIL"); } else { DBG_INFO("UI standby: ready (idle=60s)"); } if (ui_net_sink_start(UI_NET_SINK_PORT) != 0) { DBG_ERROR("UI net sink: start FAIL"); } else { DBG_INFO("UI net sink: started (port %u)", (unsigned)UI_NET_SINK_PORT); } /* F3:往返边界心跳钩(契约见 ui_net_sink.h;须在首轮 poll 之前注册)。 * start 失败也要注:失败路径的 close/重建重试同样经往返 */ ui_net_sink_set_beat_cb(ui_task_sink_beat); #if defined(TEST_LATENCY_PROBE) /* M3 探针 ui_loop:主循环实测周期(= LVGL 渲染 + 推流轮询 + osDelay(10))。 * 每轮开头先结算"上一轮到此轮"的间隔,再重置标记 → 得到真实循环周期。 * 单写者(本任务)/单读者(testTask),样本经 lat_probe_add 落静态缓冲。 */ lat_tmark_t tm_loop; uint8_t tm_loop_valid = 0U; #endif /* 常开探针:TEST_LATENCY_PROBE 只在 CH395F 套件构建下打开, * 且样本消费者是 testTask(产品构建里不跑)→ 产品构建拿不到任何 UI 周期数据。 * 故此处独立埋点,恒定生效、只写本模块的静态计数,由 defaultTask 周期上报。 */ while (1) { /* 循环周期用 enter/exit 测:exit 结算"上轮起点 → 本轮起点",enter 记本轮起点。 * 首轮 exit 时尚未 enter 过 → 内部直接返回,不会拿 0 当起点。 * 选 enter/exit 而非"自己存一个 prev"的额外收益:两次循环之间段内标志保持为 1 * → 报告可读出"本轮已进行多久"。**这正是'uiTask 是否被饿住'的直接证据**: * 若该值在两次报告间持续增长而 uic 不动,就是被调度饿死/阻塞,而非渲染拖长。 */ task_prof_exit(TASK_PROF_UI_PERIOD); task_prof_enter(TASK_PROF_UI_PERIOD); const uint32_t prof_t0 = task_prof_cyc(); /* 单轮工作耗时基准 */ #if defined(TEST_LATENCY_PROBE) if (tm_loop_valid != 0U) { lat_probe_add_seg("ui_loop", lat_mark_us(&tm_loop)); } lat_mark(&tm_loop); tm_loop_valid = 1U; #endif /* 数据装配节拍(200 ms 内部自分频,未到期时只比一次 tick): * 排在渲染之前,本轮绘制的就是本轮刚发布的那一帧 * (UI_数据供给设计说明.md §5.1 单写者同上下文的前提就在这儿) */ ui_data_feed_tick(); ui_port_step(UI_TASK_TICK_MS); /* ---- 屏板链路(批 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 折算——四个源 * 都是"任意上下文可读"的观测口(不取锁不阻塞,见各自头文件契约), * 折算本身纯计算;心跳灯相位跟着这一拍的 tick 走(uiTask 10 ms 节拍 * 远高于 500 ms 半周期,闪烁边沿误差 ≤1 拍)。 */ { sb_led_in_t led_in; 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(); led_in.adc_flags = adc_health_flags(); 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 的发送窗口里),不必留痕; * 其余失败打一行即可(见 s_sb_frame_err_logged 的说明)。 */ if ((sb_ret != BSP_OK) && (sb_ret != BSP_ERR_BUSY) && (s_sb_frame_err_logged == 0u)) { s_sb_frame_err_logged = 1u; DBG_ERROR("screenlink frame update ret=%d", sb_ret); } /* 给 sb_link 交"键已画进画面、且帧内容与活显存一致"的凭据 * (延迟应答的放行钥匙):**只在组帧返回 BSP_OK 时交**——BUSY 意味着这一拍没能 * 把新画面组进帧缓冲,凭据必须留给组成功的那一拍;扣住的召唤自会 * 等下一个 OK 拍或按 140 ms 上限发旧帧保底。 */ if ((key_injected != 0u) && (sb_ret == BSP_OK)) { sb_link_key_rendered(); } } /* 心跳**先于**推流往返发出(判读口径 docs/子系统设计/看门狗设计说明.md §11.3):UI 心跳测的是 * "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后, * 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后, * 即使往返偶发变慢,心跳也不被其拖垮。 * ⚠ 若往返**永久**阻塞(xTaskNotifyWait portMAX_DELAY 不返回),本改只能发 * 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头修改记录 v1.3。 * v1.4 补充:sink 内每个往返**返回点**已各自补喂(set_beat_cb 注入), * 「单轮多个慢往返之和」不再构成缺席链;剩单往返 >800ms 才报——那是 * netTask 自身停摆,按位判据抓对了人(上界归「net 层跨上下文收口」)。 */ task_wd_beat(TASK_WD_UI); /* 推流轮询段单独 enter/exit:它内部经 net_accept → net_wait_result 走到 * xTaskNotifyWait(portMAX_DELAY) 的**无限期阻塞**。若 uiTask 永久卡在这里, * 样本永不结算,只有 task_prof 的"仍在段内已 X us"能证明它卡在何处 */ task_prof_enter(TASK_PROF_UI_SINK); ui_net_sink_poll(); task_prof_exit(TASK_PROF_UI_SINK); /* 屏板链路统计上报与离线判定(内部自分频,5 s 一次、只在计数有变化时打一行)。 * 放在推流段之后:它只是读一份快照 + 偶尔一行日志,不进推流往返的耗时统计。 * 组帧与注键在渲染之后就做完了(见上),这里只剩"看一眼"——离线判定要求每拍都看, * 所以它不跟着 5 s 的上报周期走。 */ sb_link_poll(osKernelGetTickCount()); /* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */ task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0); osDelay(UI_TASK_TICK_MS); } }