Files
STM32F4-Base/docs/UI设计/UI_数据供给设计说明.md
T
wandering1 83011abb16 docs(编号): 测试验证层级文档面清号 + 修上一批改名留下的双写与错族疤(27 篇)
同族第三道(文档面)。上一批只改了 10 篇测试文档,本笔清掉余下 27 篇的层级号,并修
那批机械替换留下的两类疤。

- 层级号改语义名:各单元测试说明的「**门禁层级** | L0.5 层(介于 L0 静态门禁与 L1
  板端套件之间)」→「主机侧纯逻辑单测(介于静态门禁与板端测试套件之间)」;
  「板端(L1)」「L1/L2」「L3 人工」等表头与列值同批改写;测试文档模板规范的模板
  字段同步(它决定以后每篇测试说明怎么写这一栏)。
- 「模拟器回归」旧号(原文标 L3/L2)**不映射**成新语义名,就地写「模拟器回归」:
  它与「主机侧回归」是两回事,硬映射等于造一处新的口径错。
- 修双写疤 17 处(替换串套替换串的产物):文档道改完 Lead 点名的 8 处后中途失败,
  Lead 接手余下 9 处——测试开发标准流程 7、CI README 1、门禁说明/host-unit-tests 1、
  任务规划/测试与架构专项 1。
- 修**错族替换疤**(比双写更坏:字面改对、意思改错):ADC输入层单元测试说明与
  相量层单元测试说明的「介于 L0 静态门禁与 语义取数与有效性板端套件之间」——上一批
  把测试层的 L1 换成了采集链层名;本笔随该行改写一并修正。产品需求 §7.4 同类问题
  在上一笔(代码道)修。
- 变更记录历史行按「行内换号、日期与技术事实不动」处理(与「卡」族先例一致),
  换算表与撞族警示在 scripts/archive/id_gate/编号治理历史.md §10 / §10.1。
- 刻意未动:docs/芯片手册(外部手册原文)、UI 菜单层级残留 2 处与采集链旧层名残留
  (已登记 task-12)、test/board 的链路时延段号残留(task-11)。
- 门禁:check_doc_links --all --sections-strict 通过(195 篇、章节引用 3292 处);
  id-check-new --worktree rc=0(剩 2 处 WARN 是变更记录行里的已停发测试专项号,
  不拦提交,本笔不越族去动它);112 个改动文件 U+FFFD=0、无 CR、BOM 与 HEAD 一致;
  git diff --check 干净。
2026-10-28 17:56:21 +08:00

54 KiB
Raw Blame History

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 依赖方向

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

#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

#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 同此要求)。

  • 代价(本批 @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)。 @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() 判"快照是否变过",变过才整帧拷进页面私有静态缓存,其余行只读缓存。

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"改为"接受一个只读函数表":

/* 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 基础上加两个字段,或新增页面侧结构):

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 从每次 @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) 串口打印首帧(临时锚点);@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 态)——集合现已扩至 60 张,双次一致需按同口径重跑回填;轮询定时器不影响——无头模式不调 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 取值,页面零改动。