Files
STM32F4-Base/App/task/ui_task.c
T
wandering1 67c469fa40 feat(ui-pages): SOE 记录页接 soe_store 只读遍历(类型 7/8 首次上屏)
ui_w_soe 组件(task-14 落)此前两端都没绑 reader,只能显示"无记录"空态。本批补齐三件事:
App 侧只读遍历、两端 reader 绑定、SOE 记录页与菜单注册。

App/log/soe_store 只读 API(Lead 已放行,本批独占这两个文件)
- soe_store_count() / soe_store_read(idx, soe_rec_t*):与 syslog_count /
  syslog_get_record 同构——8 条一窗(512 B)整块读、命中窗口零 I/O、卷锁 try(0 超时)
  非阻塞;定长记录逐条 open 等于同一扇区开 4 次,SPI NAND 上这个差距是肉眼可见的卡顿
- 读者不得看到半成品记录,三道保障缺一不可(规则写进 soe_store.h 头注,不只是提交信息):
  ① 条数只数整条 64 B 槽(写者留下的半条尾槽天然不计入);② 窗口代数:写者每次成功落盘 /
  容量重建在**卷锁区间内**自增代数,读者比对填窗时记下的代数,不符即重填(对齐 32 位读写
  在 ARM 上原子,跨任务无需临界区,最坏多读一轮);③ 单条硬门槛:soe_rec_parse() 校版本 +
  校验和,撕裂/半写一律拒收
- 容忍的竞态只有一个:count 与 read 之间新落一条 → 本轮少看一条,下轮重绘(200 ms)自然
  追上;所以边界一律用锁内重取的条数,不信调用方传进来的旧值
- API **不带类型掩码**(Lead 裁定 2026-10-25):5 个记录页怎么分组是界面语义,不进日志存储
  层;环容量小、页渲染全扫无成本,真变大了再加带 mask 的优化 API(YAGNI)

⚠ 顺带修掉一颗定时炸弹:菜单生成器与生成物漂移
- 提交里的 Drivers/BSP/UI/ui_menu_tab.h 带着 5 个表查询 API 的规范注释块,而
  tools/menu_gen/gen_menu.py 只会输出"声明 + 行尾短注释"——说明历史上有人**手改生成物**
  补注释、没回灌生成器。我这次加 #page=soe 后重新生成,等于把那些注释抹掉,bsp-err 立刻
  报 5 条 return-doc
- 修法不是把生成物改回去(下次重新生成还会再抹一次),而是把注释块**回灌进生成器**;
  回灌后重跑 gen_menu.py 产出的 ui_menu_tab.h 与 HEAD **逐字节一致**(git diff 为空),
  漂移关闭。生成物里的手工优化 = 一定会被覆盖的优化,要么进生成器要么别改生成物

时标口径(跨模块陷阱,注释即防线)
- 页面时标取记录内的 CP56(事件自己的时标,不是"现在")。它与运行/自检记录页同口径,靠两条
  事实叠出来:sys_clock 的 epoch 域本身就是本地时间(App/util/sys_clock.h 的
  sys_clock_set_unix 注明"Unix epoch time (秒,本地时间)"),而 soe_rec.c 的 epoch→CP56
  分解不加任何时区偏移。**谁"顺手补 8 小时",SOE 页就会和文本记录页差 8 小时**;要引显式
  时区必须两侧一起改

显示名回正:类型 7 用源词"未标定闭锁"
- 组件名字表里原写作"标定闭锁"——缩掉一个"未"字后意思可以读成"把标定闭锁了",与"因未标定
  而闭锁"几乎相反;源词见 App/log/soe_rec.h 与"数据可信"卡 5,UI 表现层无权改写事实词
- 模拟器的 3 条假记录刻意含一条 type 7 + ELM_NONE(非元件支路):这是 7/8 两个新类型
  **真的接上屏**的第一个像素级证据——只有主机断言时,名字表写错也照样一片绿
- 主机测试的宽度护栏随之按"画到哪一列"分列:动作/返回进值列(4 汉字 = 12 字节),其余类型
  名进名列(7 汉字 = 21 字节);原来一刀切 12 字节会把正常名字判成违规

页面与注册
- 新建 Drivers/BSP/UI/ui_page_soe.{c,h}:薄跳板,一条记录两行共用组件内单条读盘缓存,
  实例独立(两页共用一份缓存会互相命中对方记录号,task-6 的教训)
- 菜单树 SOE记录 加 #page=soe;重生成 4 个生成物,uvprojx / CMakeLists 两处注册由生成器
  自动写入(本批 uvprojx 只含这一条新增,无 UV4 抖动)
- reader 绑定在两端适配层:真机 App/task/ui_task.c(CP56 → UI 视图 + 容量钳位),
  模拟器 simulator/main.c(固定 3 条、时标固定串,保证黄金参考可复现)

两处被门禁当场抓住的记录(都是有效拦截,不是噪音)
- id-check 拦下注释里裸引"P2-10":编号引用必须可判定归属,代码注释也一样 → 改为指向
  App/log/sys_log_mcu.c 与事实本身,不留裸编号
- 又一次 BOM 铁律现场复现:一次编辑剥掉 ui_w_soe.c 的 BOM,ARMCC 立刻报一串 #8/#7/#20
  假语法错(missing closing quote 之类),而 MSVC 侧 0 警告完全照不出来;补回 EF BB BF
  即 0/0。本批提交前逐文件比 HEAD 做了 BOM 漂移审计(临时脚本,不入库)——建议把这条
  审计固化进 ui-verify skill 或门禁(不在本道写域,已回报 Lead)

验证
- @build:0 错误 0 警告;模拟器:0 警告
- 内容页回归 15 项 PASS:新增 soe 页 9 条文本断言(两条时标、过流Ⅰ段+动作、+返回、
  未标定闭锁、页码),其余 11 页逐字节不变;菜单 8 状态 PASS、池 5368 B
- soe 页刷新黄金参考前出图肉眼确认:6 行两两成对、19 字符时标在 94 px 名列内不越屏、
  非元件行的类型名落在名列且值列为空
- test/host/ui_data 83/83、test/host/soe 32/32;13 个代码/文档/注册类钩子(含
  coding-standard、bsp-err、id-check、cppcheck、registration-check、layer-deps、
  font-charset、doc-links)全 Passed
- 全量钩子红项只有同族三条:mem-numbers / mem-budget / size-gate(L0c),root cause 一条:
  Flash/RAM 台账与同码凭证回填归单写者 → 本批 --no-verify

体积(相对 b746a1f 台账 Code 217204 / RO 21504 / RW 1256 / ZI 132680)
- 实测 Code 219712、RO 21388、RW 1288、ZI 134272 → Code +2508、RO −116、RW +32、ZI +1592
  → Flash 合计 +2424 B、RAM +1624 B(含本批之前的三页回炉)
- 本批新增里约 1064 B ZI 是 SOE 读窗口(512 B)+ static FIL(528 B),一次性投入、页面打开
  才用得到;RAM 再紧张可把窗口降到 4 条(256 B)拿回一半,代价是跨窗记录多一次 I/O

待台架实测:真机 /log/soe.log 有记录时的翻页与尾部行为、卷忙时该行退化显示、SOE 页与文本
记录页时标一致性(跨 8 小时口径的现场核对)、记录增长时窗口代数失效后的重读节拍。

gate: only mem-budget/mem-numbers/size-gate pending lead ledger backfill
(同族三条,root cause 一条:台账与同码凭证回填归单写者)
像素基线单独提交(下一笔新增 simulator/tests/ref/shot_soe.bin):本笔单独 checkout 时
ui-golden 会报"缺参考",属预期拆分;上述钩子结论取自"本笔 + 下一笔"的合并树。
自查:BOM 漂移审计 17 个改动文件全对齐 HEAD;CR = 0;U+FFFD = 0;字面量纯 ASCII。
2026-10-25 14:44:45 +08:00

287 lines
15 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 模块名称: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 回调),待复现快照确认阻塞机理后再定。
*/
#include "ui_task.h"
#include <string.h>
#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 <stdio.h> /* 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 "test_config.h" /* TEST_LATENCY_PROBE(链路时延探针编译开关) */
#include "net_latency_test.h" /* M3 探针:ui_loop(生产循环实测周期) */
#include "task_watchdog.h"
#include "task_prof.h" /* P2 常开探针:循环周期 / 工作耗时 / 推流往返 */
/*
* 函数功能:待机屏底部时钟提供回调(平台差异: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'; /* 墙钟无效:空串(待机屏时钟行留白) */
}
}
/*
* 函数功能:记录 reader 适配(task-6 注入)——UI 不透明编号 0/1 → syslog_id_t 映射
* 入口参数:log_id - UI 记录源编号(UI_W_REC_LOG_*) uint8_t
* 返回值:记录条数;未知编号返回 0(页面退化为空态) uint16_t
* 限定条件:uiTask 上下文同步调用(记录页绘制路径,与合并前直调同形)
*/
static uint16_t ui_task_rec_count(uint8_t log_id) {
if (log_id == UI_W_REC_LOG_RUN) {
return (uint16_t)syslog_count(SYSLOG_RUN);
}
if (log_id == UI_W_REC_LOG_SELFTEST) {
return (uint16_t)syslog_count(SYSLOG_SELFTEST);
}
return 0u;
}
/*
* 函数功能:记录 reader 适配(task-6 注入)——读记录字段直通 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 == UI_W_REC_LOG_RUN) {
return syslog_get_record(SYSLOG_RUN, idx, ts, (size_t)ts_len, desc, (size_t)desc_len);
}
if (log_id == UI_W_REC_LOG_SELFTEST) {
return syslog_get_record(SYSLOG_SELFTEST, idx, ts, (size_t)ts_len, desc, (size_t)desc_len);
}
return -1;
}
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 };
#define DBG_TAG "[UI_TASK]"
#include "dbg_log.h"
/*
* 函数功能:等待网络就绪事件
* 入口参数:无
* 返回值:无
* 限定条件: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 主任务入口,初始化显示/菜单/推流并进入刷新主循环
* 入口参数: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 记录页数据入口(未绑定按"无记录"空态) */
/* 快照层复位:必须先于 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);
}
#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);
/* 心跳**先于**推流往返发出(判读口径 docs/子系统设计/看门狗设计说明.md §11.3):UI 心跳测的是
* "UI 自身被调度到了",而非"netTask 回执快慢"。原写法心跳在 sink 之后,
* 致实测 uiw≈uis≈50ms(心跳被 netTask 往返主导)。提到往返之前后,
* 即使往返偶发变慢,心跳也不被其拖垮。
* ⚠ 若往返**永久**阻塞(xTaskNotifyWait portMAX_DELAY 不返回),本改只能发
* 一次心跳,救不了 → 彻底解耦须走事件驱动 accept,见文件头修改记录 v1.3。 */
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);
/* 单轮工作耗时(不含 osDelay):与循环周期对比即得"调度延迟" */
task_prof_sample(TASK_PROF_UI_WORK, task_prof_cyc() - prof_t0);
osDelay(UI_TASK_TICK_MS);
}
}