Files
STM32F4-Base/docs/UI设计/UI_数据供给设计说明.md
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

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

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

725 lines
55 KiB
Markdown
Raw Permalink 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 数据供给设计说明(高内聚低耦合)
> **定位**:解决"内容页拿不到实时数据"的架构问题——给出数据从 App 层到 UI 层的**唯一通道**、数据契约、并发模型、写路径与测试策略。
> **问题来源**:`docs/UI设计/页面补齐规划.md` §1 与 §3.1(现状:交流量第 2/3 页、直流量全部为硬编码占位值)。
> **门禁口径**:`scripts/check_layer_deps.py` 的文件头已写明三种合法做法(注入 / 上层取好传进来 / 叶子工具进 ALLOW)。本文选择并细化前两种。
> **前置阅读**:`docs/UI设计/内容页架构.md`(页面描述符与 4 个钩子)、`docs/架构设计/项目架构总览.md`(六层单向依赖)。
---
## 1. 问题与约束
### 1.1 现象
| 页 | 应该显示 | 实际显示 |
|---|---|---|
| 交流量 | 8 路电气量 + 温度的有效值 | 第 1 页写死实测值,第 2/3 页写死占位值 |
| 直流量 | 直流通道 | 3 行写死(`3.01/3.15/3.288`) |
页面代码本身没问题——**问题在于页面没有任何合法途径拿到 App 层的数据**。
### 1.2 根因
六层单向依赖规定 `Drivers/BSP/**` 不得 include `App/**` 的头,且这条规则已经机械化(`scripts/check_layer_deps.py`,2026-10-22 起拦新增)。而 UI 需要的全部数据都在 `App/`:
| 数据 | 现居位置 | 层 |
|---|---|---|
| 相量(有效值/相角) | `App/algo/phasor.h` | 算法层 |
| 频率 | `App/algo/freq.h` | 算法层 |
| 定值(40 项 + 封存态) | `App/algo/prot_set.h` | 算法层 |
| 遥信位图 / 品质 | `App/protocol/iec104/iec104_pts.h` | 协议层 |
| 时钟 | `App/util/sys_clock.h` | 工具层(**已在 ALLOW 豁免内**) |
| 运行/自检记录 | `App/log/sys_log.h` | 服务层 |
**已有的 3 条基线违规**(`ui_page_runlog.c` / `ui_page_selftest.c` / `ui_w_records.h` → `sys_log.h`)就是这条通道缺失的化石证据——当时为了做记录页,选择了"直接 include 上层头",门禁注释里写着"记录读取应由上层递进来",但**递进来的机制一直没建**。
### 1.3 反模式:`extern` 全局变量
门禁只扫 `#include`。因此在 BSP 里写 `extern const float g_phasor_ua;` 就能**完全绕过检查**。
这是本设计要明确禁止的做法,理由不是"门禁会拦",而是它比 include 更糟:
| 维度 | include 头 | extern 全局 |
|---|---|---|
| 编译期契约 | 有(类型/签名) | 无(类型写错是未定义行为) |
| 所有权 | 明确 | 无(谁写谁读说不清) |
| 并发 | 至少能看到调用 | 完全隐式 |
| 可测性 | 可桩替 | 必须链接真实现 |
| 门禁可见性 | 会报警 | **静默通过** |
**结论**:数据通道必须是**显式的、有契约的、可测的**,不能靠"能编过"。
---
## 2. 方案选型
### 2.1 四个候选
| 方案 | 做法 | 依赖方向 | 绘制路径开销 | 可测性 | 并发 |
|---|---|---|---|---|---|
| **A 直连** | BSP include `App/algo/phasor.h` | BSP → App(**反向**) | 每行一次跨层调用 | 差(要链真实现) | 隐式 |
| **B extern** | BSP 声明 `extern` 全局 | 无(不可见) | 读全局 | 差 | 无保护 |
| **C 注入(pull)** | BSP 定义 `ui_data_src_t` 函数指针表,App 启动时注入;UI 主动调 `src->get_yc(i)` | App → BSP(**正确**) | 每行一次函数指针调用 | 好 | 调用时序耦合 |
| **D 快照推送(push)** | BSP 拥有快照存储 + 发布 API;App 周期组装并发布;页面只读本地快照 | App → BSP(**正确**) | 纯本地读 | 好(直接 publish 假帧) | 单写者 + 同上下文 |
### 2.2 评分
| 维度 | A | B | C | D |
|---|---|---|---|---|
| 依赖方向正确 | ✗ | ✗ | ✓ | ✓ |
| 页面与数据源解耦(换数据源不改页面) | ✗ | ✗ | 部分(接口在 BSP,实现可换) | ✓(页面只认快照结构) |
| 绘制路径无跨层调用 | ✗ | ✓ | ✗ | ✓ |
| 主机单测可驱动 | ✗ | ✗ | ✓ | ✓ |
| 并发模型简单 | ✗ | ✗ | ✓ | ✓(单写者同上下文) |
| 模拟器 golden 可稳定 | ✗ | ✗ | 需假实现 | ✓(publish 固定帧) |
| 新增字段的扩散面 | 大 | 大 | 中(改接口 + 实现) | 小(改结构体 + 填充处) |
**选 D 为主通道**,C(注入)用于两类不适合快照的场景:**写路径**(定值整定)与**记录分页读取**(数据量随记录增长,不能全量快照)。
### 2.3 为什么 D 能做到"高内聚"
- **内聚**:`ui_data` 只干一件事——存最新一帧并给出只读访问;`ui_data_feed` 只干一件事——把 App 的散装数据组装成一帧。两者都不含对方的知识。
- **耦合**:页面只依赖**一个结构体**,不依赖任何 App 模块;App 只依赖**一个发布函数**,不依赖任何 UI 概念(页面、行、几何一概不知)。
- **变化隔离**:将来遥测链(谐波方和根 + 平均 + 死区)落地,只改 `ui_data_feed` 里的取数来源,**页面零改动**;反过来页面改版式,**App 零改动**。
---
## 3. 总体架构
### 3.1 依赖方向
```mermaid
flowchart TB
subgraph APP["App 层(业务)"]
F["App/app/ui_data_feed.c<br/>组装一帧(唯一填充者)"]
SRC["phasor / freq / prot_set / iec104_pts / sys_clock"]
F --> SRC
end
subgraph BSP["Drivers/BSP/UI(表现层)"]
D["ui_data.c/h<br/>快照存储 + 只读访问器<br/>+ 定值写入队列与状态机"]
P["ui_page_ac / ui_page_yx / ui_w_setview+ui_w_setlist ...<br/>只读快照 + 格式化 + 布局(整定页另加写投递)"]
P --> D
P -- "整定保存:ui_data_param_request(&req)" --> D
end
T["App/task/ui_task.c<br/>调度:每轮调 feed_tick()"] --> F
T --> P
F -- "ui_data_publish_rt/par(&帧)" --> D
F -- "落定:ui_data_param_drain(&ops),执行侧由 App 注入" --> D
```
**include 方向**(全部合法):
| 文件 | include | 方向 |
|---|---|---|
| `App/app/ui_data_feed.c` | `ui_data.h`(BSP)+ 各 App 头 | App → BSP ✓ |
| `App/task/ui_task.c` | `ui_data_feed.h` + `ui_menu.h` | App → BSP ✓ |
| `Drivers/BSP/UI/ui_page_*.c` | `ui_data.h`(同层 BSP) | 同层 ✓ |
| `Drivers/BSP/UI/ui_data.c` | 仅 `stdint.h` / `string.h` | 零依赖 ✓ |
**`ui_data.c` 不 include 任何 App 头,也不 include 任何页面头**——它是叶子。
写路径没有给上表新增任何一行:它走的是**反向调用**(BSP 声明函数表、App 提供实现并按节拍
把表传进来,见 §6.2),所以 include 方向不变、`layer-deps` 基线不变。这类"下层定义抽象、
上层实现并注入"的第三条腿在 §7.2 的记录页 reader 上已经用过一次,两次都不新增 `ALLOW` 条目。
### 3.2 三个模块的职责边界
| 模块 | 位置 | 只做 | 绝不做 |
|---|---|---|---|
| `ui_data` | `Drivers/BSP/UI/` | 存最新一帧、给只读访问、记发布序号;**存定值写入请求队列、跑那台状态机**(越界与闭锁的判定按注入的 ops 问,自己一条业务规则都不持有) | 取数、格式化、判断业务含义、碰 `prot_set` 与文件系统 |
| `ui_data_feed` | `App/app/` | 按节拍从各 App 模块取数、组装、发布;变化检测;**每轮 drain 一条定值请求并注入执行侧实现** | 布局、字符串格式化、LVGL 调用 |
| 页面 `ui_page_*` | `Drivers/BSP/UI/` | 读快照、按行表格式化、交给框架绘制;整态把改动攒在**编辑叠层**里(改动位图 + 工作值,`ui_w_setview.c` 模块级一份——同屏只开一页,与各查看页的行缓存同一假设),只在按"保存"时投递一次 | 直接调 App、自己算业务量、自己判越界(行内上下限只是**镜像**,用于早拦一手;真源在 App,见 §9.4 一致性测试) |
---
## 4. 数据契约
分**两条独立通道**,因为更新频率与语义完全不同:
| 通道 | 内容 | 节拍 | 写者 |
|---|---|---|---|
| `rt`(实时量) | 遥测 9 路 + 品质、遥信位图、频率、时钟 | 200 ms | `ui_data_feed`(uiTask 上下文) |
| `par`(参数) | 定值 40 项(Q10)+ 校验和 + 封存态 + 越界位图 | 变化触发 | `ui_data_feed`(同上下文) |
### 4.1 实时快照 `ui_data_rt_t`
```c
#define UI_DATA_YC_N 9u /* 语义通道数:UA/UB/UC/UN/IA/IB/IC/IN/温度 */
#define UI_DATA_YX_N 33u /* 遥信点数(与 104 点表同序) */
/* 品质位(与 104 QDS 语义对齐,页面只消费前 3 位) */
#define UI_DATA_Q_IV 0x01u /* 无效:该通道无源/不可用 */
#define UI_DATA_Q_NT 0x02u /* 未就绪:尚未攒满一周波 */
#define UI_DATA_Q_OV 0x04u /* 越限:超过量程/削顶 */
#define UI_DATA_Q_SB 0x08u /* 取代值 */
#define UI_DATA_Q_BL 0x10u /* 闭锁 */
#define UI_DATA_Q_RAW 0x20u /* 原始码值域:val 不是工程量而是转换器码值(TEMP 用) */
typedef struct {
float val; /* 数值;口径由 ui_data_rt_t.eng 决定 */
uint8_t qds; /* 品质位组合 */
} ui_data_yc_t;
typedef struct {
uint32_t seq; /* 发布序号(单调递增;页面据此判"是否需重建行缓存") */
uint32_t tick_ms; /* 发布时刻(tick 域,差值法可比) */
uint8_t valid; /* 0 = 尚无首帧(页面显示 "--") */
uint8_t eng; /* 量纲口径:0 = ADC 引脚电压(现状)/ 1 = 二次值 /
* 2 = 一次值(预留)——真值语义见下方"要点"第 1 条 */
ui_data_yc_t yc[UI_DATA_YC_N];
uint64_t yx_bits; /* 遥信位图:bit i = 第 i 点"合/动作" */
uint64_t yx_valid; /* 遥信有效位图:bit i = 1 该点有硬件来源 */
uint64_t yx_dp; /* 遥信双点位图:bit i = 1 该点是双点型(M_DP) */
uint8_t freq_valid;
float freq_hz;
uint8_t clock_ok; /* 0 = 时钟无效(RTC 异常) */
char clock[20]; /* "YYYY-MM-DD HH:MM:SS" 固定长度,无指针 */
} ui_data_rt_t;
```
**要点**:
- **`eng` 字段是诚实性的关键**(【2026-10-25 落地注】本条原写"0 = 原始码值(未标定)、`val` 是 ADC 码值(千级)",那是标定落地前的旧口径,已按现场改真值语义):标定层 `adc_cal` 的 ROM 默认系数按**引脚侧 LSB**定死(TPAFE 152.59 µV/码、STM32 805.66 µV/码),采样环折算出来的 `val` 现在是 **ADC 引脚电压 V**——既不是千级码值,也不是端子二次值。承诺边界的依据是 `docs/任务规划/数据可信.md` §2.6:这一波只承诺到"引脚电压","端子二次值"要等变比确认与两点标定。所以页面文案是 `eng=0` → "未标定"、`eng=1` → "二次值",而不是"码值";升档条件写在 `App/app/ui_data_feed.c` 的 `feed_eng()`——**电气 8 路 `adc_cal_flags()` 的 VALID 全置位才升**(整帧共用一个 `eng`,只要还有一路是引脚电压就不能给整帧贴"二次值"标签,宁可整体保守)。TEMP 不参与线性标定 → 该路恒带 `UI_DATA_Q_RAW`,页面据此不挂单位、不与其他路互比。
- **字符串只用定长数组**:`clock[20]` 在结构体内,无指针、无生命周期问题(跨层传指针是经典事故源)。
- **`yx_valid` 与 `yx_bits` 分开**:无效点不参与显示、不参与变位判断(沿用 104 点表的既有做法,避免"IV 通道抖动成变位")。
- **`yx_dp`(2026-10-25 批 4 追加)**:单点还是双点是 **104 点表事实**(`iec104_pts.c` 的 `YX_DP_N`
/ `iec104_pts_yx_is_dp()`),由写侧 `ui_data_feed.c` 逐点问点表后下发位图;页面只按位图选词
(双点"合位/分位"、单点"动作/复归"、无效 "--")。把"前 4 点是双点"抄进表现层就是第二真相:
点表改成双点段挪位或加高起始下标,屏上不会报错,只会用错词形容同一个点。V0.1 这 4 点无开入
硬件 → 不在 `yx_valid` 内,值列仍打 "--"(本字段不参与无效判定)。点数一致性由写侧一条
一次性 `DBG_ERROR` 兜(`UI_DATA_YX_N` 与 `iec104_pts_yx_count()` 不等时留痕),因为该常量
在协议层是 .c 内的 `#define`,编译期拿不到。
### 4.2 参数快照 `ui_data_par_t`
```c
#define UI_DATA_SET_N 40u /* 定值项数(与 prot_set 枚举同序,见 §9.2 一致性测试) */
#define UI_DATA_VER 2u /* 契约版本;字段只尾追加、追加即升版(§4.3) */
typedef struct {
uint32_t seq;
uint32_t checksum; /* 与 prot_set_checksum() 同值:页面据此判"参数是否变过" */
uint8_t valid;
uint8_t locked; /* 定值闭锁(校验和自检失败等)—— 定值**写不进去** */
uint8_t bad_valid; /* 越界位图有效 */
uint8_t rsv;
uint64_t bad_mask; /* 40 位:置 1 = 该定值项越界 */
int32_t q10[UI_DATA_SET_N]; /* Q10 原值;页面按行表的小数位换算显示 */
uint8_t cal_block; /* v2 尾追加:未标定闭锁(判据停走)—— 定值**仍可改** */
} ui_data_par_t; /* 当前 192 B(v2 前 184 B;尾字节由 8 字节对齐补齐) */
```
**为什么存 Q10 而不是 float**:定值是**整表校验和覆盖的定点值**,快照存浮点会引入"显示值与校验和口径不一致"的二义性。Q10 → 显示值的小数位换算属表现层(§8)。
**`locked` 与 `cal_block` 是两个事实,屏上是两条通道、两个词,不得合并**(判据口径
`docs/任务规划/数据可信.md` §4.3):
| 字段 | 真源 | 说的是什么事 | 屏上落点 | 还能改定值吗 |
|---|---|---|---|---|
| `locked` | `prot_set_locked()`(定值校验和自检失配) | 这台装置现在**写不进去** | 整定页保存行值列 `LOCK`,且 ENTER 一律不消费(禁编辑) | ❌ |
| `cal_block` | `adc_cal_prot_blocked()`(`App/algo/adc_cal.c`:UA/UN/IA 三路 `ADC_CAL_F_VALID` 全齐才放行。**判据停走用的是同一个函数**——`App/task/protect_task.c` 也调它,道F task-16 起全仓一份判定、`DTU_BENCH` 旁路也只有这一处;机器证据 = `test/host/adc_cal` 的 TC-ADCCAL-018) | 定值能改,但**改了也不会动作**(判据停走) | 整定页标题右侧「未标定」徽标 | ✅ |
合成一个词就等于互相冒充:现场看到 `LOCK` 会以为"改了白改"而不去标定,看到「未标定」
会以为"这台装置写不进定值"而放弃正当的整定操作。两个字段的取数都在 `App/app/ui_data_feed.c`
完成,页面零 App include(分层方向由 `scripts/check_layer_deps.py` 守住)。
> 【v2 落地注(2026-10-26,道F task-1)】本次只尾追加 `cal_block`,帧长 184 → 192 B;
> 组装缓冲 `s_scr` 是 rt/par/info 的联合体,而最大者是 **rt(208 B)**,所以这笔追加
> 实际**没多花一字节 RAM**——这条"谁兜住了谁"的关系由主机单测的尺寸断言钉住
> (帧长一变,那处注释就是唯一会撒谎的地方)。升 `UI_DATA_VER` 是必须的:版本号是
> 页面与写侧唯一的对齐凭据,追加字段不升版 = 老页面按新帧长 memcpy 时静默错位。
### 4.3 扩展规则
- **只允许尾部追加字段**,结构体开头保留 `seq/valid`。
- 追加字段必须同时升 `UI_DATA_VER`(编译期常量),并在 `ui_data_feed` 里填默认值。
- **禁止删除或重排字段**(页面按名字取,重排是静默错位)。
### 4.4 通道三:装置信息 `ui_data_info_t`(2026-10-25 落地新增)
落地时比本文原稿多出一条同构通道,承载**既不属于 200 ms 实时量、也不属于校验和门控参数帧**的那批量:
| 字段 | 内容 | 为什么不塞进已有的两条通道 |
|---|---|---|
| `model` / `sw_ver` / `hw_ver` / `build_date` / `sw_sum` | 装置身份(型号、软硬件版本、程序日期、软件校验) | 编译期常量,一辈子不变;塞进 `rt` 等于每 200 ms 白拷约 60 B 字符串 |
| `uptime_s` | 开机运行时长 | 每秒都在变;塞进 `par` 会被"校验和没变就不重发"的门控冻住(页面显示一个不再增长的时长 = 假数据) |
| `port_104` / `port_disp` / `ftp_user` | 通讯参数(104 端口、远程显示端口、FTP 用户名) | 值必须在 App 侧取(页面不得自己 include 协议头,`docs/UI设计/页面扩充详细方案.md` §3.5),但它们是静态量,与遥测节拍无关 |
- 结构与两条主通道同构:`seq` + `valid` 打头、定长字符数组、零指针、由 `ui_data` 盖章序号、由 `App/app/ui_data_feed.c` 独家填充,随实时帧同节拍发布。
- **公共地址 CA 故意不在本帧**:固件里 CA 是逐帧从 ASDU 头解析的,没有集中常量可取;等 104 侧给出查询点再按 `docs/UI设计/UI_数据供给设计说明.md` §4.3 的扩展规则追加字段并升 `UI_DATA_VER`。
- 版本类字段目前是 `App/app/ui_data_feed.c` 里的宏(仓库内没有产品级版本登记表,值沿用页面既有显示值,有正式口径后只改那一处);**软件校验位在拿到构建期 CRC 生成步骤之前一律显示 `--`**,不抄参考项目遗留值。
**2026-10-28 追加(「运行报告」页落地)**:info 帧尾部追加 9 个字段,把运行报告要用的四个数从 App 侧搬下来——
`boot_count` / `boot_seq_state` / `boot_write_failed` / `rst_cause` / `boot_ready`(来自 `App/util/boot_stats.h` 的开机账本)与
`iec104_conn_up` / `iec104_apdu_tx` / `iec104_tx_fail` / `iec104_rx_bytes`(来自 `App/task/iec104_serv.h` 的四个只读观测计数)。
- **为什么不另开第二条注入通道**:这些量与装置身份同属`慢变、非节拍、零指针`的一批,info 帧已是它们的属主;
再开一个 reader 就是把 task-6 刚收紧的分层反向边接回来(记录页那套注入表是因为要读**盘**,本批不读盘)。
- **搬运不算第二份口径**:`feed_fill_info()` 只做逐字段拷贝,不换算、不在别处再算一份时长(`uptime_s` 仍由本函数按 tick 差算)。
- **屏上文案归 UI**:四个数各自的类别标签(「自电池周期」「本次开机」)、`boot_ready == 0` 显 `--` 而不是 0、
以及「禁写终身次数」「跨开机累计不做」这两条口径,落在 `Drivers/BSP/UI/ui_page_runreport.c` 文件头;口径出处只在
`App/util/boot_stats.h` 四数台账与本节各述一次(同一事实只在一处详述,`docs/文档规范.md` 同此要求)。
- 代价(本批
`/keil-build` 实测,0 错 0 警):
`Program Size` 由本批前
Code=235536 RO=26392 RW=1572 ZI=136924 变为 Code=236456 RO=28124 RW=1876 ZI=137084(ROM 合计 +2,652 B)
→ **RAM 合计 +464 B**(RW +304 / ZI +160)。逐对象(.map):5 个记录页各 60 B
`.data`(每页一份
`ui_w_records_t` 读盘缓存,
按实例存是记录页注入改造时定下的纪律,共享会串页);`ui_page_runreport.o` 116 B
`.bss`(信息帧本地缓存 + 发布序号);
info 帧字段增量落在 `ui_data.o`(1,192 B
`.bss`,同对象还装 rt / par 两通道的双区)与 `ui_data_feed.o`(217 B)里。
随帧发布不新增拷贝节拍。水位:
`RW_IRAM1` 62.2% / `RW_CCM` 87.9%(阈值 92%),
`mem-budget` PASS,
台账已由 `scripts/ram_baseline.py --update` / `--write` 回填(docs/内存管理/RAM预算台账.md)。 `/keil-build` 后回填),未触 `mem-budget` 92% 红线。
> 另一处落地细化:`ui_data_rt_t` 除 `yc[]`(语义通道序基波有效值)外还带一条 `ph[]` 相量实例槽带(`ph_n` 给实际槽数)。原因是谐波(`Ia` 的二次/五次)无法在"语义通道"下标空间里表达——同一通道的谐波会与基波撞位,而交流量页要显示它们(`docs/UI设计/页面扩充详细方案.md` §3.1.1 第 4/5 行)。两条带的元素同形(数值 + 品质),页面行表按需选一条。
---
## 5. 并发与时序
### 5.1 单写者 + 同上下文(核心简化)
**约定**:`rt` 与 `par` 的**唯一发布者**是 `ui_data_feed_tick()`,它在 **uiTask 上下文**运行;页面的读取也在 uiTask 上下文(绘制钩子与按键钩子都跑在 `ui_task_func` 主循环里,见 `docs/UI设计/内容页架构.md` §六)。
→ **读写同线程,不需要锁、不需要双缓冲、不需要 seqlock。**
这一点值得展开,因为它与"直觉上要加保护"相反:
| 顾虑 | 实际情况 |
|---|---|
| 发布时页面正在绘制? | 不可能:绘制与发布都在 uiTask,串行执行 |
| 104 主站写定值会改快照? | 不会:主站只改 `prot_set`,快照由 feed 下一拍重新拉取 |
| 其他任务要推数据? | 不允许——要加新数据,就在 feed 里加取数,仍由 uiTask 发布 |
**如果将来真出现第二个发布者**(例如 netTask 收到主站写定值后要"立刻"刷新 UI),扩展路径是:给 `ui_data` 内部加"双槽 + 每槽 seq"(写非活动槽 → 盖 seq → 翻转单字节活动下标,同 `phasor` 双区先例)。**页面代码不受影响**——这正是把并发策略藏在 `ui_data` 内部的价值。
### 5.2 发布节拍
| 通道 | 节拍 | 理由 |
|---|---|---|
| `rt` | **200 ms**(内部计数分频,UI tick 10 ms × 20) | 与遥测链目标刷新率一致;人眼在 160×160 单色屏上感知不到更快 |
| `par` | **变化触发**:每轮比三个各自独立的变化源——`prot_set_checksum()`(定值改动)、`prot_set_locked()`(自检闭锁)、未标定判定(标定完成),**任一翻转就重发一帧** | 定值变化是低频事件,只盯校验和能省下每拍的 192 B 拷贝;但 `locked` 与 `cal_block` 都**不改动校验和**,只盯校验和会让保存行的 `LOCK` 与标题的「未标定」永远停在旧值上(真机现象:标定做完了徽标还挂着,直到某次定值改动被顺带刷掉——那是把两件不相干的事当同步点) |
`ui_data_feed_tick()` 每轮调用是**廉价**的:一次 tick 差值判断 + 一次 uint32 比较 + 两次字节比较(`locked`/`cal_block` 上一帧值)。真正的组装只在到期或有源翻转时发生,无变化时零拷贝。
### 5.3 启动时序
```
内核启动前:ui_data_init()(清零 + valid=0)—— 纯内存操作,无 OS 依赖
↓
uiTask 起来(NET_READY 屏障后):ui_menu_init() → 进入主循环
↓
首轮循环:ui_data_feed_tick() 立即发布首帧(不等 200 ms)
↓
首帧之前任何页面被打开:valid=0 → 全部显示 "--"(不显示 0.000,避免误读为真实零值)
```
**为什么首帧要立即发**:待机屏与菜单屏在启动早期就可能被远端推流打开,若等 200 ms 会出现"一闪的 `--`"。首帧立即发布后,页面从第一帧就是真值(或明确的"未就绪")。
### 5.4 页面的行缓存(性能)
框架的 `refresh(row, name, value)` 是**逐行调用**的(每帧每行一次)。若每行都 `ui_data_rt(&snap)` 拷 130 B,一页 8 行 = 1 KB/帧的冗余拷贝。
**做法**:页面用 `ui_data_rt_seq()` 判"快照是否变过",变过才整帧拷进**页面私有静态缓存**,其余行只读缓存。
```c
static ui_data_rt_t s_rt; /* 页面私有缓存 */
static uint32_t s_seq; /* 上次拷贝时的序号 */
static void pg_refresh(uint16_t row, char *name, char *value) {
uint32_t seq = ui_data_rt_seq(); /* 轻量查询:只读一个 uint32 */
if (seq != s_seq) { /* 快照变过才整帧拷贝 */
(void)ui_data_rt(&s_rt);
s_seq = seq;
}
/* 以下只读 s_rt,按行表格式化 */
}
```
> 用 `ui_data_rt_seq()` 门控而不是"row==0 才刷新",是为了避免依赖"框架总从第 0 行开始绘制"这一隐式时序假设——框架换绘制顺序时页面不会静默出错。
---
## 6. 写路径(定值整定)
### 6.1 为什么 UI 不能直写
- `prot_set_set()` 之后**必须** `prot_set_seal()` 重算校验和;逐项写会把校验和打成中间态,被自检判为损坏。
- 落盘由 `defaultTask`(配置引擎属主)执行,UI 直接写盘会撞单写者纪律。
- 越界校验是 all-or-nothing:任一项越界整表不装载。
### 6.2 请求 / 状态交接(复用 `net_cfg` 范式)
落地形态(道F task-1 落本层队列与状态机,task-2 落页面侧消费者,2026-10-26):
```
ui_page_view_param / _set / _word / _plate(整定态:123 授权 → attach_edit 绑 set/commit/state)
│ 选中"保存"行按 ENTER(组件 ui_w_setlist_key 先过两道门:可写权限 + state()!=LOCK)
▼
sv_commit():req.mask = 改动位图;req.q10[] 只按 mask 置位的槽换算(显示值 → Q10)
▼
ui_data_param_request(&req) ← 本层只投递,不执行、不判越界、不碰文件系统
│ 静态环形队列,深度 4(UI_DATA_PARAM_QD);满 → -1(页面显 ERR + 页脚"忙")
│ 入队 = 168 B 整条拷入(.bss 4 × 168 = 672 B;零堆、零变长拷贝)
▼
ui_data_feed_tick()(App 侧,uiTask 上下文;先 drain 再比校验和,顺序即判据)
│ ui_data_param_drain(&s_param_ops):每轮最多落定一条
│ ② ops->locked() == 1 → ERR_LOCKED,**本条丢弃**(不排队等解锁)
│ ③ ops->check() 按 mask 逐项整批预检,任一项越界 → ERR_RANGE + err_mask,一字不写
│ ④ 预检全过再逐项 ops->apply() → prot_set_set_sealed()(只写 mask 内的 id)→ OK
│ 值真的变了 → 同轮重发 par 帧(页面按 seq 失效缓存)
▼
ui_data_param_state() / ui_data_param_err_mask()(保存行显词 + 越界行反显)
```
四个公开函数(声明 `Drivers/BSP/UI/ui_data.h`、实现 `ui_data.c`,全在本层,零 App include):
| 函数 | 作用 | 返回值口径 |
|---|---|---|
| `ui_data_param_request()` | 投递一条请求(页面"保存"的唯一出口) | `0` 已入队;**`mask=0` 的空请求也返 0**,但不占槽位、直接落终态 OK(一个都没改就按保存是正常操作,报 ERR 会让现场以为装置有问题);`-1` 队列满;`-2` 参数无效(NULL,或 `mask` 含 `UI_DATA_SET_N` 以上的位——调用方的编程错误不该落成屏上的"越界") |
| `ui_data_param_state()` | 回读写路径状态 | `UI_DATA_PARAM_ST_{IDLE,WAIT,OK,ERR_RANGE,ERR_LOCKED,ERR_BUSY}` |
| `ui_data_param_err_mask()` | 最近一条**被本次预检挡下**的项位图 | bit i = 1 → 第 i 项越界;最近终态不是 `ERR_RANGE` 时恒 0 |
| `ui_data_param_drain()` | 落定一条(App 装配节拍入口) | `1` 处理并提交了一条;`0` 队列空(状态不动);`-1` ops 或其函数指针为 NULL |
**"算不算越界"与"现在能不能写"都是 App 侧事实,本层一份都不复制**:执行能力经
`ui_data_param_ops_t`(`locked` / `check` / `apply` 三个函数指针)由 `App/app/ui_data_feed.c`
**每次调 drain 时注入**,本层不保存副本。原因有两条,都不含糊:① 分层方向硬约束——
`Drivers/BSP/**` 不得 include `App/**`(`scripts/check_layer_deps.py` 拦提交),而上下限真源
在 `App/algo/prot_set.c` 的 `g_prot_set_meta`;② 注入表**刻意不含任何范围数值**,多传一份
上下限就是第二真源、就会漂。同 §7.2 记录页的注入式 reader 一条路子,额外的好处是主机单测
可以把三个指针换成桩件,于是**队列与状态机的生产代码**(不是副本)能在 PC 上把"满队列 /
越界不改值 / 闭锁拒写 / 正常往返"跑全(`test/host/ui_data/`)。
**请求形状 = 「位图 + 定长 40 槽」(168 B)而不是「项数 + 变长 id 数组」**:表现层的编辑叠层
本就是一张改动位图 + 一张工作值表,本形状与它逐位同构 → 提交侧一次赋值、执行侧逐项按位取,
两侧都不必维护第三种表示;定长还让队列退化成 O(1) 静态数组。边界与代价:`mask` 只有 64 位
→ 定值项数硬上限 64(`ui_data.c` 有编译期守卫,破 64 是编译失败而不是静默丢项);队列 672 B
+ 控制字 16 B 是买"整批原子 + 零变长 + 零堆"的价钱(`docs/内存管理/RAM使用分析与优化方案.md`)。
> ⚠ **提交侧必须只提交 `mask` 内置位的 id,绝不"从显示值回提全表"**:Q10 存不下 0.95 这类
> 返回系数(实际存 972/1024),按显示域往返会把用户没动的项顺手改掉。判据"越界值不落盘"与
> "改哪条只落哪条"都建在这条上(`docs/任务规划/UI补齐.md` §2 第 2 行)。
> 换算的往返精度因此也**不需要**逐位可逆:没动的行根本不进换算函数。
**这套结构与 `net_cfg` 的 `net_cfg_save_request` / `net_cfg_save_state` 完全同构**
(`Drivers/BSP/NETCFG/net_cfg.h`),不引入新范式——这是"低耦合"的另一个含义:
**项目里同类问题只有一种解法**。
### 6.3 失败语义
落定结果只有 5 个值(`UI_DATA_PARAM_ST_*`),页面侧的**词与位置**由整定视图统一映射,
四张整定页共用一套(组件按 `state()` 回调给的值画保存行值列,页面不参与解释):
| 状态 | 含义 | 保存行值列 | 页面附带动作 | 产出方 |
|---|---|---|---|---|
| `IDLE` | 本次打开页面从未投递过 | `ENTER` | — | 本层初值 |
| `WAIT` | 已投递、队列里还有请求在路上 | `WAIT` | 编辑叠层**保留**(不清稿) | `request` 受理 / `drain` 未排空 |
| `OK` | 已逐项写入并置脏 | `OK` | 按 `commit` 位图清掉叠层,让位给新帧 | `drain` 第④步 |
| `ERR_RANGE` | 整批预检有任一项越界 → **一字未写** | `ERR` | 用 `ui_data_param_err_mask()` 反显越界行 | `drain` 第③步 |
| `ERR_LOCKED` | 投递时定值闭锁 → **本条丢弃** | `LOCK` | 反显同 `ERR_RANGE`;`drain` 不回滚(`prot_set` 无回滚接口,谎称"整批未写"比如实报更坏) | `drain` 第②步 |
| `ERR_BUSY` | 投递被拒:队列满 | `ERR` | 页脚"忙,请重试";**在队请求一条不动**,用户直接再按一次保存 | `request` 返 -1 |
三条实现侧口径,都是原稿没写死的:
1. **`LOCK` 还有一个与状态机无关的来源**:帧里 `par.locked == 1` 时保存行**直接显 `LOCK`
并禁 ENTER**(优先级高于历史结果)。闭锁是持续状态,不该只在"某次投递被拒"那一帧才说一次;
禁编辑必须落在键路上,只靠屏上词会让用户白改一轮。
2. **`ERR_TIMEOUT`(原稿的"3 s 未取走")本层不产出**——uiTask 每 10 ms 就 drain 一次,
真出现"永不落定"是 App 侧没接线的装配缺陷,不是运行期状态。页面若要兜底,可自己按投递
时刻计时(属页面本地可选项,当前没做:没有消费者就别先加一条无人走的分支)。
3. **状态是全局单值,页面必须锁存显示**:`ui_data_param_state()` 落定后一直停在那个值上,
直读会让用户移动一下光标就看到别的页留下的旧结果。整定视图把它锁存成"本页投过一次
才显示那次的答案、开页复位成 `ENTER`"(`ui_w_setview.c` 的 `s_disp`)。
> 词表刻意用 ASCII(`ENTER`/`WAIT`/`OK`/`ERR`/`LOCK`)与 `net_cfg` 保存行同排;
> 中文「未标定」徽标是**另一件事**(`cal_block`),见 §4.2 的两源两通道表,不要为了
> 省一个词把它并进 `LOCK`。
---
## 7. 记录页:另一种通道(注入式 reader)
### 7.1 为什么记录不走快照
记录是**分页读文件**:条数随运行增长,不可能全量快照;且读盘有 I/O 延迟,不能放在绘制路径里同步做。
### 7.2 注入式 reader
把 `ui_w_records` 从"直接 include `sys_log.h`"改为"接受一个只读函数表":
```c
/* Drivers/BSP/UI/ui_w_records.h —— 不含任何 App 头 */
typedef struct {
uint16_t (*count)(uint8_t log_id);
/* 返回 0 成功;-1 越界;-2 读失败 */
int (*get)(uint8_t log_id, uint32_t idx,
char *ts, uint32_t ts_len, char *desc, uint32_t desc_len);
} ui_rec_reader_t;
void ui_w_records_bind(const ui_rec_reader_t *reader); /* App 启动时注入 */
```
- `log_id` 用 `uint8_t`(**不透明编号**),BSP 不知道 `SYSLOG_RUN` 是什么,只知道"0 号日志"。
- 注入点在 `App/task/ui_task.c`(`ui_menu_init` 之前),实现体是 `sys_log` 的两个薄包装。
- 【2026-10-25 落地注】模拟器同形镜像:`simulator/main.c` 的 `sim_rec_count/sim_rec_get`,
无头与窗口两个初始化点都在 `ui_menu_init` 前调 `ui_w_records_bind`;UI 侧编号常量
`UI_W_REC_LOG_RUN/SELFTEST`(映射只在两端适配层,UI 不感知 `syslog_id_t`)。
`ui_rec_reader_t` 按本文契约**原样**实现(`int` 错误码 0/-1/-2 未改型)。
### 7.3 收益:可削减 3 条既有基线
改造完成后,`scripts/check_layer_deps.py` 的 `BASELINE` 可删掉:
```
("Drivers/BSP/UI/ui_page_runlog.c", "App/log/sys_log.h"),
("Drivers/BSP/UI/ui_page_selftest.c", "App/log/sys_log.h"),
("Drivers/BSP/UI/ui_w_records.h", "App/log/sys_log.h"),
```
基线"只减不增"——**本设计顺带把存量债还掉三条**,而不是新增。
> 另外两条(`net_cfg` / `ftp_cfg` → `cfg_file.h`)属配置引擎,与 UI 无关,不在本文范围。
### 7.4 SOE 记录:同构 reader + UI 侧显示名映射(2026-10-25 落地新增)
`/log/soe.log` 是**二进制**定长 64 B 记录(时标/类型/元件号/序号/值),不是"时间戳 + 文本描述",不能与 `ui_w_records` 混读,故另立同构组件 `Drivers/BSP/UI/ui_w_soe.c`:
- **注入式 reader** `ui_soe_reader_t`(`count()` + `get(idx, &view)`,错误码 0/-1/-2 与 `ui_rec_reader_t` 同族);`get` 返回的是 **UI 侧视图结构** `ui_w_soe_rec_t`(时标串 + 不透明类型号 + 不透明元件号 + 标志 + 序号/值),适配层负责解码与 CP56 → 文本换算,UI 不 include `App/log/soe_rec.h`。
- **显示名映射在 UI 侧**(`ui_w_soe_type_name()` / `ui_w_soe_elm_name()`):类型与元件都是**表现层文案**,不进快照也不回落到 App 层(同 `docs/UI设计/UI_数据供给设计说明.md` §8 的格式化归属结论)。UI 侧另立一套不透明编号常量(`UI_W_SOE_T_*` / `UI_W_SOE_ELM_*`),其**数值与 App 枚举逐项相等**——这条等价关系不靠文档转述,由主机单测在同一 TU 里 include 两侧头逐项核对(`test/host/ui_data/` 的用例 `TC-UIDATA-012`/`013`,2026-10-25 现场核对结果:类型 1..8 + 哨兵 9、`ELM_NONE=0xFF`、`FLG_IV=0x80`、元件 0..8 与 `PE_COUNT=9`)。
- **未标定闭锁/标定复归两类新事件已进映射表**(`UI_W_SOE_T_CAL_BLOCK=7` / `UI_W_SOE_T_CAL_RESUME=8`,缺字 采/样/恢/效 已走字体重生成 + 像素回归)。单测额外钉了两条容易悄悄发生的错:八类事件必须有名(忘配名只会在屏上显示 `--`,像素门禁照不出来)、"采样闭锁/恢复"与"标定闭锁/复归"四名必须互不相同(同名 = 现场把未标定闭锁读成采样故障)。
- **本批不绑定 reader**:落盘侧还没有只读 API(`App/log/soe_store.c` 只有 `post/drain/dropped`),需要加法式的 `soe_count()` / `soe_read(idx, &rec)`(FatFs `f_open` + `f_lseek`,与 `syslog_get_record` 同构);该文件属道F 的潜在写域,先后次序由 Lead 排。未注入期间组件按空态渲染("无记录"伪行),因此本批**不产生任何像素变化**。
---
## 8. 格式化归属:谁决定小数位
**结论:格式化属表现层,全部在页面行表里;快照只给数值与品质。**
| 信息 | 归属 | 理由 |
|---|---|---|
| 数值(float / Q10) | 快照 | 业务事实 |
| 品质(IV/NT/OV) | 快照 | 业务事实 |
| 通道名("Ua"/"Ia") | 页面行表 | 显示文案 |
| 小数位(2 位/3 位) | 页面行表 | 显示规格 |
| 单位(V/A/Hz) | 页面行表 | 显示规格 |
| 无效符号(`--`) | 页面行表 | 显示规格 |
| 排序/分组/分页 | 页面行表 | 版式 |
行表结构相应扩展(在现有 `cp_row_t` 基础上加两个字段,或新增页面侧结构):
```c
typedef struct {
const char *name; /* "Ua" */
uint8_t src; /* 快照内下标(0..UI_DATA_YC_N-1) */
uint8_t dot; /* 小数位:电压 2 / 电流 3 / 频率 3 */
const char *unit; /* "V" / "A" / "Hz" */
} ui_ac_row_t;
```
**为什么不在快照里放格式化后的字符串**:那会让 App 知道"电压要 2 位小数",把显示规格漏进业务层——这正是要避免的耦合。
---
## 9. 可测试性
### 9.1 主机侧纯逻辑单测
`ui_data` 是纯内存模块,天然可测:
| 用例 | 判据 |
|---|---|
| 首帧前读取 | `ui_data_rt()` 返回 -1,`valid=0` |
| 发布后读取 | 字段逐项一致;`seq` 递增 |
| 参数变化检测 | checksum 不变 → 不重发;变 → 重发 |
| 请求队列 | 深度 4;满则 -1;取走后可再投 |
| 状态回读 | OK/ERR_RANGE/ERR_LOCKED 正确映射 |
页面渲染可在模拟器侧断言(见 9.2),主机侧只测数据模块。
### 9.2 模拟器:固定帧保证 golden 稳定
`simulator/main.c` 不链接 `App/task/ui_task.c`,因此需要自己发布快照:
- **默认(golden 模式)**:发布一帧**固定数据**(写死的 9 路值 + 位图),保证像素参考可复现。
- **`--live` 模式**:用合成正弦逐帧更新,供人工观察动态刷新(不参与 golden)。
这是本设计对"可测性"的直接贡献:**页面数据来源可注入 → 像素回归不再依赖真实硬件**。
> 【2026-10-25 落地注】`simulator/main.c` 的实现形态与两条原稿未写死的细节:
> 1. 固定帧由 `sim_publish_data_frames()` 一次性发布,**无头与窗口两个初始化点都调**(与记录 reader 注入同址、同"先于 `ui_menu_init`"纪律);
> 2. 固定帧里的定值**不手抄 40 个数字**,而是把 `App/algo/prot_set.c`(零平台依赖的纯算法,双端共享核心)链进模拟器、`prot_set_init()` 后取出厂默认——抄表等于造第二真相(同 `scripts/check_font_charset.py` 的"单一规则源"口径);
> 3. `build_date` 用**固定串**而不是 `__DATE__`:否则跨天重建会把装置信息页的黄金参考刷掉,而黄金参考是判据本身。
> 4. 原稿设的 `--live` 合成正弦模式**尚未实现**(当前无消费者,等动态刷新真的需要人眼观察时再加)。
> 5. **定值写路径在模拟器侧有一份同构的执行实现**(`sim_param_locked/check/apply` +
> `sim_param_pump()`):真机那段在 `App/app/ui_data_feed.c` 的 tick 里,而模拟器不链接装配器,
> 所以在 `main.c` 里镜像一份——`check()` 用的仍是同一份 `g_prot_set_meta` 真源(链进来的
> `App/algo/prot_set.c`),因此"模拟器里越界被拒"与"真机上越界被拒"是同一个判定,不是两份规矩。
> 固定帧的 `cal_block` 显式填 1:**真机现在就是**未标定态(`rt.eng` 也按同一条口径钉在引脚档),
> 屏上的话必须与本机一致,模拟器才配当像素基准。
> 6. 落定与"页面自己发现数据变了"的分工:`sim_param_pump()` 在无头模式排在注键之后、截图之前
> 同步排空(真机由每 10 ms 的 tick 排);整定视图另有一条 100 ms 的 LVGL 轮询定时器,
> 负责把"主站写进来的新值 / 标定完成 / 落定但不改校验和的 ERR"推上屏。**这条定时器在无头
> 回归里一次都不会触发**——`run_headless()` 严禁调 `lv_timer_handler()`(I1+FULL 全屏刷新会
> 进渲染死循环),所以黄金像素与它无关;两次连跑逐字节一致即为证据(`simulator/README.md`
> 的同条注)。窗口模式才用它。
### 9.3 与"虚拟注入"的关系
`docs/UI设计/页面补齐规划.md` §7 提的厂家调试"虚拟注入",**天然落在 `ui_data_feed` 上**:注入值只要在 feed 组装时覆盖对应字段即可,**页面完全不知情**。
约束(必须写进代码注释):虚拟注入**只能在 feed 层覆盖快照字段**,绝不能改 `prot_set` / `phasor` 的输入——否则会污染保护判据路径。
### 9.4 一致性测试(防"魔法下标"漂移)
页面行表用**不透明下标**(`src` / `set_idx`),BSP 侧不知道它对应哪个 App 枚举——这是解耦的代价,必须用测试兜住:
| 断言 | 位置 | 内容 |
|---|---|---|
| 通道数一致 | `App/app/ui_data_feed.c` | `typedef char chk[(UI_DATA_YC_N == ADC_SEM_COUNT) ? 1 : -1];` |
| 定值项数一致 | 同上 | `typedef char chk2[(UI_DATA_SET_N == PS_ID_COUNT) ? 1 : -1];` |
| 下标语义一致 | `test/host/ui_data/` | 主机测试同时 include 两侧头,逐项断言"页面表的 src/set_idx 指向预期枚举" |
第 3 条是关键:**它把"跨层魔法数字"从运行期风险降级为编译/测试期错误**。
---
## 10. 内存与性能
| 项 | 量(`.map` 实测) | 说明 |
|---|---|---|
| `rt` 帧 | 208 B | 遥测 9 路 + 相量槽带 + 位图 + 时钟串;三帧串行组装用的 `s_scr` 是联合体,**取最大者 = rt** |
| `par` 帧 | 192 B | 40×4 Q10 + 头部 + `cal_block`(v2);追加这 8 B 由 rt 兜住,**净增 0**(尺寸关系由主机单测钉住,见 §4.2 落地注) |
| `info` 帧 | 随字符串字段定长 | 与 rt 同节拍发布,同样落在联合体里,不额外占 |
| `ui_data.o` 全部静态 | `.bss` 1,168 B | 含三帧存储、`s_scr` 联合体、**定值请求队列 4×168 = 672 B + 控制字 16 B** |
| `ui_w_setview.o` 静态 | `.bss` 352 B / `.data` 48 B | 四视图行缓存 + **整定编辑叠层 190 B**(`int32_t[40]` + 三张位图 + 锁存态) |
| 页面行缓存 | 每页 ≈ 52 B(`ui_page_view_*.o` 各 `.bss` 52 B) | 定值相关页共用一份视图缓存(同屏只开一页),所以四张整定页**没有**再各存一份 192 B |
| LVGL 池 | 快照 0 B;轮询定时器一只 | 快照不进池(不是 LVGL 对象);定时器是 LVGL 池对象,只建一次不删 |
| 每 200 ms 拷贝 | 一帧(≤208 B),且只在变化时 | 一次 memcpy,微秒级 |
| 每轮(10 ms) | 0 次拷贝 | 比 seq / 比三个变化源;见 §5.2、§5.4 |
栈侧(同一份 `.map` 的调用图,改前改后各跑一次 `scripts/stack_report.py`):uiTask 静态最坏链
**1336 B / 配置 16 KB(余 15,048 B,91%)在接入写路径前后逐字节不变**——新链路自身都不深
(`sv_commit` 帧 192 B 含 168 B 请求体、链深 208 B;「未标定」徽标帧 160 B、链深 536 B;按键链
`pg_key` 起算 480 B),最坏链仍是 LVGL 建树那条,**没有换人**。这是"页面 commit 会在 uiTask 栈上
放 168 B 局部量"这类担心的实测答案,也是本表值得写实测数而不写"约几百字节"的理由。
**结论**:代价 = 约 1.5 KB 静态 + 每 200 ms 一次 memcpy,换来整个 UI 层的数据可及性与一条
可测的写路径。区域水位(RW_IRAM1 / RW_CCM / Flash)**不在本文复制**——那是受管快照,
由 `scripts/ram_baseline.py` 从每次 `/keil-build` 的 `.map` 生成,第二处写死就是一份会过期的
真源(本仓已有一条门禁在钉这个,口径见 `docs/内存管理/RAM使用分析与优化方案.md` §2.1 与
`docs/内存管理/RAM预算台账.md`)。本次新增约 1.5 KB 静态 + 一条 LVGL 池定时器,量级无压力。
---
## 11. 落地步骤(每步可独立验证)
| 步 | 内容 | 验证 | 依赖 |
|---|---|---|---|
| **步骤1** | `ui_data.h/.c` + 主机单测(无任何消费者) | `test/host/ui_data` 全绿;`layer-deps` 无新增 | 无 |
| **步骤2** | `App/app/ui_data_feed.c` + `ui_task.c` 接线(每轮调 tick) | 串口打印首帧(临时锚点);`/keil-build` 0/0 | 步骤1 |
| **步骤3** | 交流量 / 直流量回炉(接快照 + 行表加 `src/dot/unit`) | 模拟器固定帧 golden 更新;台架看真实码值变化 | 步骤2 |
| **步骤4** | 遥信量 + 定值/控制字/软压板查看(4 页) | 主站改定值 → 页面 200 ms 内变 | 步骤3 |
| **步骤5** | 写路径(请求/状态)+ 整定 4 页 | 就地改定值 → 保存 → 掉电重启 → 值保持 | 步骤4。**已落**(道F task-1 落本层队列与状态机、task-2 落整定 4 页与模拟器侧,2026-10-26);「掉电重启值保持」只能上板收口,见 §12 判据 6 |
| **步骤6** | 记录 reader 注入 + 删 3 条 layer-deps 基线 | `check_layer_deps.py` 基线由 6 → 3 | 可与步骤3 并行 |
**步骤1~步骤2 是关键路径**:它们不产出任何用户可见效果,但决定后面所有页面是否可信。**不要跳过步骤1 直接改页面**——那会退化成"页面里塞假数据 2.0"。
> 【2026-10-25 落地进度】步骤1、步骤2 已落(道E task-14,分支 `task/ui-data-feed`):`ui_data` 三通道 + 主机单测 82 项全绿(`test/host/ui_data/`,已挂 `ui-data-unit-test` 钩子)、`ui_data_feed` 已在 `App/task/ui_task.c` 主循环接线并在建页前发首帧。**改动两处与原稿不同**:① 实时帧的 `eng` 与品质位按标定层真值语义落地(见 `docs/UI设计/UI_数据供给设计说明.md` §4.1 的落地注);② 新增第三条通道 `info`(同文档 §4.4)。**写路径(步骤5 的 `ui_data_param_request/state`)随整定 4 页一起落**,本批无消费者,先落等于落一段永不执行的代码。步骤3 起(页面接线)在道E 第二个工作项。
>
> 【2026-10-25 落地进度·二】步骤3 的第一批页面已接(道E task-15,分支 `task/pages-ui`):**装置信息**读 `info`
> 通道(型号/软件/硬件/校验/日期/运行时长)+ `net_cfg` RAM 副本(MAC/IP),**交流量**读 `rt`
> 的 `ph[]` 实例槽与 `freq_*`、第 7 行读 `eng`,**直流量**读 `rt.yc[TEMP]` 并透传 `Q_RAW`。
> 三条纪律在页面侧被验证:① 页面只读快照、零 App include(分层基线不变);② 帧缓存按 `seq`
> 门控整帧拷一次,逐行只读标量;③ 品质位判空一律 `--`,"有数但不可信"与"没数"同形。参数帧
> `par` 的首批消费者是定值/参数查看页(本批未接,`bad_mask`/`locked` 的屏上表达随那 4 页落)。
>
> 【2026-10-25 落地进度·三】`par` 的首批消费者已落(同分支批 3):**参数 / 定值 / 控制字 / 软压板**四个查看页
> (`ui_w_setview` 四张行表 + `ui_w_setlist` 组件,查看态 `commit=NULL` 无保存行)。Q10 → 显示值的换算落在**表现层**
> (本契约 §8 的分工:写侧只搬原值),换算式在 `ui_w_setview.c` 里做整数拆分而非浮点/64 位除(MicroLIB
> 软浮点与 `__aeabi_ldiv` 一进镜像几百字节,模拟器永远照不出这笔代价)。`bad_mask` 的屏上表达 =
> 该行反显高亮(且只在 `bad_valid=1` 时参与,把没数据画成有毛病是假告警);`locked` 的屏上表达是
> **禁编辑**,查看页本来就无编辑通路,它随整定 4 页一起显形。四个返回系数按 2 位小数显示的理由见
> `docs/UI设计/页面扩充详细方案.md` §3.2.2 落地注;行表与 `prot_set` 枚举序/上下限的一致性由
> `tools/setview_check/check_setview_limits.py` 核验(BSP 侧看不到 App 头,物理上无法自证,必须有人从外面钉)。
> 【2026-10-25 落地进度·四】遥信通道已被消费:**遥信量**页(`ui_page_yx.c`,33 行固定名表,
> 方案 A)按 `yx_valid` → `yx_dp` → `yx_bits` 的次序取值列(先判无效:无效位上的位值不代表任何
> 事实;再判单/双点选词;最后才看动作态)。起草时为此补了 `yx_dp` 一个字段(§4.1),由
> `ui_data_feed.c` 逐点问 `iec104_pts_yx_is_dp()` 得到——**点数与单双点性质都留在协议层**,页面
> 不复制这些事实;`UI_DATA_YX_N` 与 `iec104_pts_yx_count()` 一旦不等,写侧打一条一次性
> `DBG_ERROR`(点数在协议层是 .c 内的宏,编译期拿不到,只能运行期留痕)。模拟器侧同步发布
> `yx_dp = 0x0F` 但**不**把这 4 位置进 `yx_valid`:V0.1 没有开入硬件,屏上就是 `--`——刻意不用
> 「让它显示合位/分位」的假数据把画面填满,那等于让黄金参考声称本机有开关位置遥信。因此
> 「合位/分位」这条词映射当前**没有任何自动化证据覆盖**(像素恒 IV、页面依赖 LVGL 也进不了
> 主机单测),它已登记为开入落地后的台架判据,而不是假装测过。
> 【落地注·五(写路径判据的前置口径,指针级,不重述细节)】带 `#P` 注记的叶子**第一屏是密码
> 页、不是内容页**:真机实测里三帧「深页盲走」的落点都是密码输入页(墨量 512,与黄金参考
> 的两张密码态逐字对得上)。所以批 5 的写路径判据必须把「过密码」当**显式状态**处理——快照
> 层不提供「曾否进过某页」这种隐式状态,可写性只由 UI 侧权限态与 `par.locked` 共同决定,
> 「当前是否已过密码」既不真实也不该被推断。判据侧的过法只有一套(查看密码 = 出厂 000 /
> 修改密码 = 123,键序与黄金回归的网口设置编辑态完全一致),口径与纪律见
> `docs/UI设计/UI_自动化测试流程.md` §7.5;本节只登记这条事实,避免两份文档各说各话。
>
> 【落地注·六(写路径消费者已上屏,2026-10-26,道F task-2)】整定 4 页(参数 / 定值 /
> 控制字 / 软压板)绑到与查看列**同一批行表、同一个 `ui_w_setlist` 组件**:同一份页面
> 描述符按 `ui_content_page_is_editable()` 分派只读 / 可写两态,两态的差别只有绑不绑
> `set/commit/state` 三个回调。于是本节契约里原本只是示意图的那条链路第一次真的被按到:
> **越界值不落盘**(提交只带改动位图)、**闭锁禁编辑**(`state()` 回 LOCK 或帧里
> `locked=1` 就直接挡住 ENTER,而不是只换个屏上词)、**落定要自己上屏**(`ERR_RANGE` 与
> `ERR_LOCKED` 都不改校验和,页面收不到新帧,靠的是整定视图那条 100 ms 轮询,见 §9.2 注 6)。
>
> 这一批还暴露了两条「只有第一个消费者才会暴露」的缺陷,都已修(为什么必现写在
> `docs/UI设计/页面扩充详细方案.md` §3.2 落地注):二态行 ENTER 不回绕(写出 opts 之外的
> 下标:屏上看着没变、叠层里已是越界值,保存必被预检挡回);「参数帧已就位」原先只在
> **绘制路径**里被置位,而按键门读它 → 无头回归里开页后的第一个 ENTER 永远被挡(真机开页
> 10 ms 内就重绘,症状只在测试侧出现)。第二条的修法是把「数据就位」与「画过没有」解耦:
> 判就位之前先按 seq 做一次 sync(帧没变时只是一次 uint32 比较)。
>
> 另记一条同批暴露、与本契约无关的事实:菜单树重跑时 `ui_menu_tab.h` 有一行注释
> 跟着生成器的模板文字变了 —— 说明 HEAD 上「生成器模板」与「已提交生成物」本就不同步
> (改模板那笔没重跑生成器)。生成物以生成器输出为准即可,但这条滞后该补一条门禁,
> 已登记为待办「生成器幂等性检查」。
---
## 12. 验收判据
| # | 判据 | 方法 |
|---|---|---|
| 1 | `Drivers/BSP/**` 无新增 App include | `scripts/check_layer_deps.py` 退出码 0 |
| 2 | 记录页改造后基线由 6 条减到 3 条 | 同上 `--prune` |
| 3 | 交流量页显示真实相量(非写死值) | 台架:注入已知信号 → 页面值随信号变 |
| 4 | 未标定时页面明确标识(不把码值当伏特) | 目检 + 文本断言(`eng=0` 时带标记) |
| 5 | 首帧前显示 `--` 而非 `0.000` | 模拟器:不 publish 直接开页,断言文本 |
| 6 | 定值整定全链闭环 | 就地改 → 保存 → 重启 → 值保持 + 校验和自检通过 + 主站 C_RD 一致。**半收口(2026-10-26)**:模拟器侧「改 → 保存 → OK → 回首屏新值已在帧里」已由像素 + 文本断言覆盖(黄金用例 `sparam_save` / `sparam_landed`,越界侧 `sparam_oob`);**重启保持与主站 C_RD 一致只能上板**——模拟器没有 NAND,`apply()` 到 `prot_set_set_sealed()` 就是终点,这条差距已登记在 `docs/测试说明/专项验证/模拟器差距清单.md`;另注意 40 项里有 13 项 `comm_addr=0`(纯本地项),主站侧根本读不到,不能拿 C_RD 当它们的判据 |
| 7 | 像素回归稳定 | `ui-golden` 连续两次运行零差异。**已验(2026-10-26,当时下限为 27 页)**:27 页连跑两次逐字节一致(含整定 9 态)——**集合现已扩至 66 张**,**双次一致已验(2026-10-29:同一批 66 张连跑三次逐字节一致,池水位 5,448 B;菜单 8 状态连跑三次亦一致,5,368 B)**;轮询定时器不影响——无头模式不调 `lv_timer_handler()`(§9.2 注 6) |
| 9 | 「写不进去」与「改了不动作」两条事实互不冒充 | 反向文本断言(`check_content_pages.py` 的 `ABSENT`:查看页与只读态不得出现「未标定」徽标与「保存」行)+ 台架:未制造该态——要它必须改坏 /prot_set.bin(破坏产品配置存储,用户裁定 2026-10-26 不做),故 LOCK 态目前只有代码级证据与三重反证(ENTER 被正常消费、遥信 YX31=OFF、开机 CORRUPT=0),**没有像素证据** |
| 8 | 主机单测覆盖数据模块 | `test/host/ui_data` 全绿并挂 pre-commit 钩子 |
---
## 13. 反模式清单(禁止事项)
| # | 禁止 | 原因 |
|---|---|---|
| 1 | BSP 里 `extern` App 全局变量 | 绕过门禁且比 include 更危险(无契约/无所有权) |
| 2 | 页面直接 include `App/**` 头 | 门禁拦截;且页面被绑死在具体 App API |
| 3 | 快照里放指针或动态字符串 | 跨层生命周期事故;只用定长数组 |
| 4 | 每行 `refresh` 都整帧拷贝快照 | 8 行/帧冗余;用 seq 门控 |
| 5 | 在快照里放格式化后的字符串 | 显示规格漏进业务层 |
| 6 | 多个任务同时发布同一通道 | 破坏单写者约定;需要时在 `ui_data` 内部加保护,不改页面 |
| 7 | 虚拟注入改 `prot_set`/`phasor` 输入 | 会污染保护判据路径 |
| 8 | 定值逐项 `prot_set_set()` 不封存 | 校验和中间态 → 自检判损坏 |
| 9 | 为省事把 `sys_log.h` 加进 `ALLOW` | ALLOW 是"叶子工具"通道,`sys_log` 是有状态服务,加进去等于永久开后门 |
---
## 14. 与既有文档和门禁的关系
- **门禁**:`scripts/check_layer_deps.py` 的三种合法做法本文全部用上(快照推送=②,写路径与记录 reader=①),**不新增任何 `ALLOW` 或 `BASELINE` 条目**;计划的 3 条削减**已于 2026-10-25 兑现**(task/UI-rec-reader 落地 §7.2 契约,基线 6 → 3,详见 `docs/门禁说明/layer-deps.md` §1)。
- **页面框架**:`docs/UI设计/内容页架构.md` 的 `refresh` / `count_cb` 钩子**签名不变**,页面只是改为从 `ui_data` 取数。
- **页面清单**:`docs/UI设计/页面补齐规划.md` 的"批 1"第一项即本文 步骤1~步骤3。
- **数据真实性**:`docs/问题记录/采集与数据处理短板评测.md` 的标定(§4.1)与遥测链(§4.2)落地后,只需改 `ui_data_feed` 的取数来源与 `eng` 取值,页面零改动。