From d9303e05a4512defe29c0e434c20ba5e243bcc1b Mon Sep 17 00:00:00 2001 From: super_agent_bot Date: Thu, 5 Nov 2026 12:35:46 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(cfg):=20=E7=83=AD=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E8=AF=BB=E4=BE=A7=E4=B8=89=E7=82=B9=E4=B8=80=E8=87=B4=E6=80=A7?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E2=80=94=E2=80=94=E6=8E=90=E6=96=AD=E6=89=93?= =?UTF-8?q?=E5=9B=9E=E5=87=BA=E5=8E=82=E4=B8=8E=E4=BF=9D=E5=AD=98=E5=90=9E?= =?UTF-8?q?=E6=AE=B5=EF=BC=88#73=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机 #73:运行中改配置触发热重载,卷层(dhara)偶发返回旧/短 视图时,引擎把该视图当成文件全文:逐段按缺省解析后整盘打回 出厂默认且不自愈;此后任何一次保存又按缩小的视图做手术式 重写,文件越写越小(台架 1066B→986B,[sntp] 段整体丢失), 即真机对时静默 10.5 小时的元凶。 处置(引擎侧根因无关防御;卷层回滚归 #79/#81 已合并的 FTL 修复域): - poll:读完加三点校验(读长==stat.size、读前后 re-stat 的 size/mtime 一致),判为旧/短视图则 RAM 不动、基线不刷新、 每 2 拍自动重试自愈;首次拒绝留 1 条 DBG_ERROR+syslog, 之后每 30 拍一条 WARN 提示重启收敛。 - save:同型三点校验,视图与 stat 不符返回 -1 且文件不动, 调用方保留 dirty 下拍重试,吞段链在第一次保存即被掐断。 - 边界:「一致但陈旧」的整卷回滚引擎层无法区分,靠重启 收敛,已写入设计说明风险表与文档口径。 测试:新增 TC-CONFIG-058/059/060(旧视图保持 RAM、保存短 视图拒写、读中改写拒绝+日志限速)。cfg_file 239/239; iec104 179、run_par 67、adc_cal 117、sntp 98、 cfg_file_store_mcu 34、net_cfg 48、ftp_cfg 16、prot_set 90、 prot_dim 38 全绿;/keil-build 0 Error 0 Warning。 文档:配置文件设计说明 §(3)/§5.3/风险表/变更记录 v1.17; 单元测试说明 058–060 行;网络配置说明与产品需求 FR-CFG-3 同步口径;台账随新鲜 .map 由 ram_baseline --update 回填。 --- App/cfg/cfg_file.c | 71 ++++++++ App/cfg/cfg_file.h | 18 ++- docs/产品需求/产品需求设计文档.md | 2 +- docs/子系统设计/网络配置说明.md | 2 +- docs/子系统设计/配置文件设计说明.md | 5 +- .../测试说明/单元测试/配置文件单元测试说明.md | 7 +- scripts/ram_baseline.tsv | 1 + scripts/size_attestation.json | 16 +- test/host/cfg_file/cfg_file_test.c | 153 +++++++++++++++++- 9 files changed, 258 insertions(+), 17 deletions(-) diff --git a/App/cfg/cfg_file.c b/App/cfg/cfg_file.c index 9d75ee70..cbcab12c 100644 --- a/App/cfg/cfg_file.c +++ b/App/cfg/cfg_file.c @@ -29,6 +29,13 @@ * len 1/2/4 小端补码)——s_decode_int() 用 strtol + ERANGE + * 宽度上下界校验,s_encode() 带负号输出;负零点偏移从此可 * 干净落盘(动因见 docs/任务规划/数据可信.md §4.4 与 §7 决策 3) + * v1.6 2026-11-05 DTU 项目组 热重载/保存视图一致性守卫(issue #73 SNTP 补验揪出)—— + * poll stat→read→复 stat 三点不一致(空读/截断旧视图或读中改写) + * 拒绝应用:RAM 与基线不动、2s 重试自愈、首次自检留痕 + 每 30 趟 + * WARN 限速;save 读视图与 stat 基准不符拒写(断"短视图→整档 + * 外科重写→非默认段被删"吞段链,台架实测 1066B→986B)。 + * 台架根因为卷层 dhara 旧视图回滚(≥8/8 复现),引擎侧无法 + * 分辨"一致但陈旧"的整卷回滚——该形态重启必收敛,见设计说明 * 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save; * 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证 * (见《配置文件设计说明》D15 A+ 方案) @@ -102,6 +109,8 @@ static cfg_file_stat_t s_last; /* 上次 poll 的 stat */ static uint8_t s_stable; /* 连续相同轮询计数(沉降去抖) */ static uint8_t s_base_valid; /* 基线是否已建立 */ static uint8_t s_snap[CFG_SNAP_MAX]; +static uint16_t s_reject_streak; /* 连续被拒重载计数(视图不一致;0 = 无进行中 + 拒绝;饱和不回绕,仅用于告警限速节奏) */ static int s_sec_hdr[CFG_FILE_MAX_SECTIONS]; /* 段头行号(0xFFFF = 未见) */ static uint16_t s_key_line[CFG_FILE_MAX_SECTIONS][CFG_FILE_MAX_KEYS]; /* 受管键行号(0xFFFF = 未见) */ @@ -1240,6 +1249,26 @@ int cfg_file_save(void) { return -1; } + /* 2026-11-05 视图一致性守卫(真机台架实证,issue #73 SNTP 补验揪出): + * stat 与 read 之间卷层可给出旧视图(dhara 缓存回滚/FTL 视图滞后)。旧实现 + * 直接按"读到的视图"外科重写整档,视图偏短时非默认段随重写一并被静默删掉 + * (实测 1066B → 986B,[sntp] 段整段蒸发)。读长与 stat 报长不符、或读期间 + * 文件被改写 → 拒存:原文件不动、返回 -1;属主任务脏位保留 = 下一趟自然 + * 重试(defaultTask 50ms 循环 / iec104 下轮 poll 投递,均自带重试语义) */ + { + cfg_file_stat_t st2; + + if (in_len != (size_t)st.size || + cfg_store_stat(cfg_store_config_path(), &st2) != 0 || + st2.size != st.size || st2.mtime != st.mtime) { + cfg_store_free(p_in); + DBG_ERROR("save: view %luB != stat %luB, aborted (file untouched)", + (unsigned long)in_len, (unsigned long)st.size); + syslog_post(SYSLOG_SELFTEST, "cfg save view stale"); + return -1; + } + } + /* Pass 1 在独立副本上切分(避免破坏原文的行结束符); * 副本在 p_out 分配前释放,堆峰值维持 p_in + p_out(≤ CFG_HEAP_BUDGET, * 编译期已校验,见文件常量区) */ @@ -1371,6 +1400,46 @@ void cfg_file_poll(void) { if (s_load_file(&p_buf, &len) != 0) { return; /* 读失败:保留,下次 poll 重试 */ } + + /* 2026-11-05 视图一致性守卫(与 save 侧同族,真机台架实证 issue #73): + * stat → read → 再 stat 三点必须一致——读长 ≠ st.size(空读/截断 = + * 卷层旧视图;台架 8/8 复现的"热重载打回出厂默认"即空读形态:全段 + * 静默 memcpy 默认、零解析错误行)或读期间文件被改写 = 读到的内容不 + * 对应变更令牌。不一致 → 拒绝应用:RAM 副本原样保留、基线不刷新, + * 下一趟(2s)重试,视图恢复一致即正常应用(自愈);若持续到重启, + * boot 路径全新挂载必然收敛。首次拒绝自检留痕 + ERROR 一行,此后每 + * 30 趟(约 60s)一条 WARN 计数行(限速,防日志风暴) */ + { + cfg_file_stat_t st2; + int stale; + + if (len != (size_t)st.size) { + stale = 1; /* 空读/截断/旧视图 */ + } + else if (cfg_store_stat(cfg_store_config_path(), &st2) != 0 || + st2.size != st.size || st2.mtime != st.mtime) { + stale = 1; /* 读期间文件消失或被改写 */ + } + else { + stale = 0; + } + if (stale != 0) { + cfg_store_free(p_buf); + if (s_reject_streak < 0xFFFFu) { + s_reject_streak++; + } + if (s_reject_streak == 1u) { + DBG_ERROR("poll: view %luB != stat %luB, HELD (RAM kept)", + (unsigned long)len, (unsigned long)st.size); + syslog_post(SYSLOG_SELFTEST, "cfg poll view stale"); + } + else if ((s_reject_streak % 30u) == 0u) { + DBG_INFO("WARN: cfg reload held %u rounds, reboot to converge", + (unsigned)s_reject_streak); + } + return; /* 基线不刷新 = 同一变更下趟仍触发重试;RAM 不动 */ + } + } for (i = 0; i < (int)CFG_FILE_MAX_SECTIONS; i++) { const cfg_sec_t *p_sec = &s_sections[i]; @@ -1395,6 +1464,8 @@ void cfg_file_poll(void) { DBG_INFO("poll: config file reloaded"); } + s_reject_streak = 0u; /* 应用成功(含超长重建)= 拒绝期结束 */ + /* 重载后刷新基线(回调触发的写盘不引发二次重载) */ { cfg_file_stat_t st2; diff --git a/App/cfg/cfg_file.h b/App/cfg/cfg_file.h index b9cbf377..772be178 100644 --- a/App/cfg/cfg_file.h +++ b/App/cfg/cfg_file.h @@ -18,6 +18,10 @@ * v1.4 2026-10-25 DTU 项目组 「数据可信」有符号零点类型:新增 CFG_VAL_INT 有符号十进制值类型 * (len = 1/2/4,小端补码)——标定零点偏移等负值需有符号 * 落盘,动因与取舍见 docs/任务规划/数据可信.md §4.4 + * v1.5 2026-11-05 DTU 项目组 契约同步视图一致性守卫(issue #73 SNTP 补验揪出): + * poll 读视图与 stat 令牌不符 = 拒绝应用(RAM/基线不动、2s + * 重试自愈);save 读视图不符 = 拒存返回 -1(原文件不动)。 + * 细节见 cfg_file.c v1.6 与《配置文件设计说明》变更节 */ #ifndef CFG_FILE_H @@ -116,7 +120,10 @@ int cfg_file_load(void); * 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建 * 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超 * CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报, - * 引擎写盘闸口同拒) + * 引擎写盘闸口同拒);读视图与 stat 基准不符(空读/截断/读中 + * 被改写)同样拒存返回 -1——防按短视图外科重写把文件写小、 + * 非默认段被静默删除(2026-11-05 视图一致性守卫,调用方脏位 + * 保留 = 下一趟自然重试) */ int cfg_file_save(void); @@ -129,9 +136,12 @@ int cfg_file_save(void); * 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT * 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认 * + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启 - * 收敛);否则重载全部段(各段复位工厂默认后解析);挂回调 - * 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线 - * (回调触发的写盘不引发二次重载) + * 收敛);否则过视图一致性守卫(读长 = st.size 且读前后 (size, + * mtime) 不变;不符 = 卷层旧视图/读中被改写 → 拒绝应用:RAM 与 + * 基线不动,2s 重试自愈,持续到重启则由 load 全新挂载收敛;首次 + * 拒绝自检留痕,其后每 30 趟一条 WARN)后重载全部段(各段复位 + * 工厂默认后解析);挂回调段有差异 → 调用其回调(如触发网络重建); + * 重载后刷新基线(回调触发的写盘不引发二次重载) */ void cfg_file_poll(void); diff --git a/docs/产品需求/产品需求设计文档.md b/docs/产品需求/产品需求设计文档.md index 2d618954..719ed134 100644 --- a/docs/产品需求/产品需求设计文档.md +++ b/docs/产品需求/产品需求设计文档.md @@ -349,7 +349,7 @@ flowchart LR **FR-CFG-3 变更检测与立即应用** - 需求:FTP 上传配置后无需重启即生效;网络段变化触发网络重建。 -- 实现:`defaultTask` 每 2 s `f_stat` 比对 (size, mtime),连续 2 次稳定才重载(沉降去抖);`[network]` 段变化 → `SYS_EVT_NET_RECONFIG` → 网络重建(代次 +1,服务自愈)。 +- 实现:`defaultTask` 每 2 s `f_stat` 比对 (size, mtime),连续 2 次稳定才重载(沉降去抖);重载/保存前过视图一致性守卫(2026-11-05)——读到的视图与 stat 不符(卷层旧/短视图)时拒绝应用(RAM 保持当前配置、2s 重试)并拒绝按坏视图落盘,杜绝坏视图打回出厂默认与吞段(issue #73);`[network]` 段变化 → `SYS_EVT_NET_RECONFIG` → 网络重建(代次 +1,服务自愈)。 - 判据:FTP 改 IP 后 2~5 s 生效(网络重建含等链路 ≤3 s)。 **FR-CFG-4 旧档迁移**(已取消 · 2026-10-10) diff --git a/docs/子系统设计/网络配置说明.md b/docs/子系统设计/网络配置说明.md index 54f92bf0..ae70cfa5 100644 --- a/docs/子系统设计/网络配置说明.md +++ b/docs/子系统设计/网络配置说明.md @@ -81,7 +81,7 @@ mac=00:00:00:00:00:00 - **加载校验(逐键容错)**:单个键值非法(越界/格式错)不影响其他键——非法键回工厂默认并留痕(`parse: [network] <键> value invalid`);文件缺失 → 自动生成本模板;文件 > 4KB = 损坏 → 工厂默认 + 重建模板(坏配置不带崩网络)。 - **保存(外科手术式)**:注释 / 未知段 / 未知键逐行保留,受管键按 RAM 副本原位改写、缺失键补写;写盘 = 临时文件 `/config.TMP` + `f_rename` 原子替换(掉电不损坏目标文件)。 -- **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。 +- **外部变更自动重载**(P4 阶段接入):2s 轮询检测 (size, mtime) 变化、4s 沉降(连续 2 次轮询稳定)后重载 `[network]` 段并重建网络——FTP 上传编辑后的文件无需重启即生效。重载/保存均过**视图一致性守卫**(2026-11-05):卷层给回与 stat 不符的旧/短视图时拒绝应用/拒绝落盘(RAM 配置不会被坏视图打回出厂默认,文件也不会被短视图重写吞段),2s 重试自愈;若串口持续出现 `[CFG] poll ... HELD` / `WARN ... held` 告警,重启一次即收敛。机制详见 `docs/子系统设计/配置文件设计说明.md` §5.3。 完整设计(引擎 / 双端 / 并发模型 D15)见 `docs/子系统设计/配置文件设计说明.md`。 diff --git a/docs/子系统设计/配置文件设计说明.md b/docs/子系统设计/配置文件设计说明.md index 6fdbc4b7..85d86199 100644 --- a/docs/子系统设计/配置文件设计说明.md +++ b/docs/子系统设计/配置文件设计说明.md @@ -215,7 +215,7 @@ flowchart TD - **超长守卫(v1.12)**:外科手术输出超 `CFG_FILE_MAX_LEN`(4096B)→ **拒绝保存返回 -1**(原文件不动 + 自检上报 `cfg save overflow`);`s_write_doc`(模板/外科同闸的唯一落盘出口)硬性拒写 >4096B —— "引擎永不产出超长 config.ini"是结构性质。超长文件一律按损坏处置(load/poll 同判,见 §5.3) -**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载 → 段变化则触发该段回调 → 刷新基线。 +**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载先过**视图一致性守卫**(v1.17,§5.3):stat → read → 再 stat 三点必须一致——读长 ≠ `st.size` 或读期间 (size, mtime) 变化 = 卷层给了旧/短视图或读中被改写 → 拒绝应用(RAM 不动、基线不刷新,2s 重试自愈);一致后重载 → 段变化则触发该段回调 → 刷新基线。 **(4) 段变更回调** —— 未来模块的接入点: @@ -300,6 +300,7 @@ sequenceDiagram - 检测粒度 2s(轮询周期);网络重建含等链路 ≤3s,总生效时间约 2~5s - 下载天然支持(RETR 即文件读) - **沉降去抖(D15-②)**:(size, mtime) 相对基线变化且**连续 2 次轮询(4s)稳定**才重载——防 FTP STOR 进行中的中间态触发多次重载(STOR 先写 `.TMP`,目标文件 (size, mtime) 在 rename 前不变,rename 单次原子更新 → 至多一次重载) +- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速)。**边界**:内容一致但陈旧的整卷视图((size,mtime) 恰好相同)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径 - **单写者(D15-③)**:`/config.ini` 的直接写者收敛为 defaultTask(UI 保存 = 请求投递至 defaultTask,由其落盘(tmp+rename)+ 应用(触发网络重建),复用既有 `SYS_EVT_NET_RECONFIG` 路径);唯一例外 = FTP STOR 路径(lftpd,tmp+rename,与 defaultTask 写路径经卷锁互斥) ### 5.4 线程安全(FatFS 可重入) @@ -554,6 +555,7 @@ void adc_cfg_init(void) | FTP STOR 半截写(掉电 / 断链) | 中 | 逐键容错 + 工厂回退;0 字节 STOR 自动删文件(lftpd 现状)→ 下次上电重新生成模板;下次保存自愈 | | 墙钟无效(开机 RTC 读取失败且默认校时失败) | 低 | mtime 退化为固定值(检测退化为 size);既有开机告警链 + 默认校时(RTC_DEFAULT_INIT)兜底,实际极罕见 | | mtime 秒边界碰撞(同秒两次写) | 低 | (size, mtime) 双比对;2s 轮询通常跨秒;误报 = 多一次重载(无害) | +| 卷层给回旧/短视图(dhara FTL 缓存 / 整卷回滚,台架 issue #73 实证) | 高 | 引擎三点视图一致性守卫(§4-(3) / §5.3):stat 与 read 不符 → poll 拒绝应用 / save 拒绝落盘,坏视图既不碰 RAM 也不重写文件;"一致但陈旧"的整卷视图引擎不可检出,属卷层课题,重启收敛兜底 | | save 堆峰值与 LVGL / lftpd 争用 | 低 | 事件触发(罕见);FR_TIMEOUT 兜底;实施后 map 复核堆空闲 | | 8.3 文件名约束 | 无 | `config.ini` 合规;段名 / 键名属文件内容,不受限 | | 卷重新格式化丢配置 | 中(现状一致) | 与现状相同(f_mkfs 清空卷);文档提示 | @@ -591,3 +593,4 @@ void adc_cfg_init(void) | 2026-10-10 | v1.14:**删除旧档 NCFG 迁移(D10 撤销)**——`Drivers/BSP/NETCFG/net_cfg_legacy.{c,h}` 整体移除、`cfg_store_legacy_path()` 撤销(MCU / 模拟器后端与主机单测 `test/host/cfg_file/cfg_file_test.c` 同步删除),`net_cfg_load` 不再调用 `net_cfg_legacy_migrate()`;旧档 `/cfg/netcfg.bin` 若存在则被**忽略**(不读、不删、不迁移),配置回落工厂默认。理由:该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏栈链 **+600 B**(该链整体 5312 → 2776 B,另三个杠杆见 `docs/内存管理/RAM使用分析与优化方案.md`),而本仓库不存在携带旧档的设备。连带影响:`App/util` 的 CRC-16/MODBUS 失去唯一调用者(现为零调用者);模拟器 `--cfgtest` 不再覆盖迁移场景;§2 D10 / §3.5 / §4.2 / §5.1 / §6.2 / §6.4 / §7 / §9 / §11.1 同步 | | 2026-10-26 | v1.15:**新增 `[comm]` 段(道G task-8,「UI 补齐」波次里的非 UI 前置项)**——`App/protocol/iec104/iec104_comm.h/.c` 配对模块(104 公共地址 / 监听端口 2 键,getter 合法带自愈,adc_cal 先例);内核前 `iec104_comm_init()` 注册([adc_cal] / [run_par] 同「先注册后 load」契约);生效 = **改后重启**(不挂回调、不新增事件位,区别于 [network] 免重启重建);段占位 5→6 / 8,键表与主站地址不做理由 → `docs/子系统设计/网络配置说明.md` §3.6;机器钉 = `test/host/cfg_file` TC-CONFIG-056/057(段/键上限)+ `test/host/iec104` TC-IEC104-031~033(模板/重载/带外/ca 端到端) | 2026-11-04 | v1.16:**新增 `[sntp]` 段(板端 SNTP 客户端接入,#57 调研结论落地)**——`Drivers/BSP/NETCFG/sntp_cfg.h/.c`(enable / server1 / server2 / poll_min 4 键;出厂默认**关闭 + 服务器留空 + 60 分钟** = "未配置零网络足迹"口径;`poll_min` 带外自愈 1..1440 → 60 不写回 RAM);数据本体 = `Drivers/BSP/NETCFG/sntp_cfg.h/.c`(不依赖引擎头,分层门禁 layer-deps 口径),段描述符由 `net_cfg.c` 的 `s_sntp_register()` 组装,注册编排 = `net_cfg_load()`(与 [ftp] 同编排,先注册后 load);消费方 = `App/sync/time_sntp.c`(挂 timeSyncTask 既有循环,vendor `Lib/coreSNTP` serializer 做 48B 报文编解码/防欺骗校验);保存立即生效 = 每轮直读 RAM 副本 + 网络代次重建弃 UDP socket 重散布,**无段回调、不新增事件位**;段占位 6→7 / 8;键表与生效细则 → `docs/子系统设计/网络配置说明.md` §3.7;机器钉 = `test/host/net_cfg` TC-NETCFG-005/006b..d/007(段注册契约)+ `test/host/sntp`(RFC 4330 §10 节奏 / §5.3 防欺骗 / §5.5 步进策略 / poll 钳位) +| 2026-11-05 | v1.17:**热重载/保存路径视图一致性守卫(issue #73 台架实证根治)**——真机 8/8 复现"运行中改配置触发热重载打回出厂默认"且此后任何保存吞段(1066B→986B):卷层在 stat→read 窗口给回旧/短视图,引擎照单应用(全段 memcpy 默认、零解析错误行)或照短视图外科重写文件。① `cfg_file_poll` 沉降读取后三点校验(读长 == `st.size` + 读后复 stat (size, mtime) 不变):不符 → **拒绝应用**(RAM 原样 / 基线不刷新 / 2s 重试自愈),首次拒绝 `cfg poll view stale` 自检 + ERROR 一行,此后每 30 趟(≈60s)一条 WARN 计数;② `cfg_file_save` 同族守卫:视图与 stat 不符 → **拒存返回 -1**(调用方保留 dirty 重试)原文件一字节不动 + `cfg save view stale` 自检——"短视图 → 外科重写 → 非默认段被删"链就此斩断;③ 边界如实披露:一致但陈旧的整卷视图引擎层无法检出(dhara/FTL 属卷层),重启必然收敛,卷层根治前"改过配置后重启一次"仍是最稳口径。机器钉 = TC-CONFIG-058(poll HELD + 自愈)/ 059(save 拒存文件不动)/ 060(读中改写检出 + 日志限速);`cfg_file.c` v1.6 / `cfg_file.h` v1.5 契约同步 diff --git a/docs/测试说明/单元测试/配置文件单元测试说明.md b/docs/测试说明/单元测试/配置文件单元测试说明.md index b5fe3290..7730c5f9 100644 --- a/docs/测试说明/单元测试/配置文件单元测试说明.md +++ b/docs/测试说明/单元测试/配置文件单元测试说明.md @@ -162,8 +162,11 @@ test/host/cfg_file/cfg_file_test.c | **TC-CONFIG-054** | poll 检出超长 → 工厂默认 + 重建模板 + 留痕;**不触发回调** | 在线修复通道保留 | | **TC-CONFIG-056** | `[comm]` 段容量钉:段数上限 8、键数上限 16 写进断言(改上限必须同批改本行与用例) | 段/键上限是配置引擎的公开契约,不能靠"跑起来看看" | | **TC-CONFIG-057** | 段/键写满后再新增 → 拒绝保存且原文件不动(满则拒,不是静默截断) | 溢出语义与 TC-CONFIG-043 同源,覆盖 `[comm]` 新增段 | +| **TC-CONFIG-058** | 卷层旧视图(读长 ≠ stat 长)→ poll 拒绝应用:RAM 保旧值、基线保留重试、视图追平即自愈应用 + 回调;拒绝期间零回调、全程 1 ERROR + 1 留痕 | 2026-11-05 视图一致性守卫(issue #73 热重载打回出厂默认根治) | +| **TC-CONFIG-059** | save 侧同族守卫:视图 ≠ stat → 拒存 -1、原文件一字节不动 + 自检留痕;视图追平后可再存 | 斩断"短视图 → 外科重写 → 非默认段被吞"链(实测 1066B→986B 形态) | +| **TC-CONFIG-060** | 读中改写(读后复 stat 令牌变)→ HELD 后重新沉降自愈;持续不一致:全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条 | 拒绝分支日志限速(防 2s 节拍日志风暴) | -**当前结果**:139/139 PASSED(`failed=0`)。 +**当前结果**:239/239 PASSED(`failed=0`)。 --- @@ -175,6 +178,8 @@ test/host/cfg_file/cfg_file_test.c 需"内容变但令牌不变"的边界用 `vfs_put_notoken(text)`; 4. 用 `vfs_get()` 取当前文件文本做检索断言(注释/未知段是否保留等); 5. 用故障注入开关 `s_fail_read / s_fail_write / s_fail_alloc / s_fail_stat` 构造故障路径; + 视图不一致用 `vfs_mark_stale()`(read 冻结在旧快照、stat 报真实盘)/ + `vfs_clear_stale()`(视图追平 = 自愈),读中改写用一次性开关 `s_touch_on_read`; 6. `CHECK(cond, "描述")` 断言;在 `main()` 里注册。 > ⚠ **`vfs_put` 与 `vfs_put_notoken` 的区别很关键**:poll 依 `(size, mtime)` 判变更, diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index c5d2e03c..926654d5 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -17,3 +17,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-11-02 247548 29836 2052 149740 277384 26.5 94368 72.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 2026-11-03 249112 29836 2052 149996 278948 26.6 94624 72.2 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 2026-11-04 254332 30824 2072 150104 285156 27.2 94752 72.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6236;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 +2026-11-05 254784 30896 2072 150104 285680 27.2 94752 72.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6236;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 4e22dbb1..15f261a4 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,17 +1,17 @@ { - "built_at": "2026-11-04T18:58:01+08:00", - "fingerprint": "e144163b2fe1843cedb07864b926cab203f608524605a75fec38dc660b661d29", + "built_at": "2026-11-05T12:35:40+08:00", + "fingerprint": "8c8ba70adaaa6d8de16a3f80efc6caf859f032eb8dc29dd129351afc4362137d", "fingerprint_files": 1479, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "1f94015434ba809e7e3645316ad3fb4f331824c9", + "git_head": "0d5cd1c8d12ff40e225e3e8223958e1ab2a6f74d", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1793789873, + "map_mtime": 1793853338, "pct": { - "ER_IROM1": 27.19, + "ER_IROM1": 27.24, "RW_CCM": 87.89, "RW_IRAM1": 72.29, "UNINIT_CCM": 18.75 @@ -19,7 +19,7 @@ "regions": { "ER_IROM1": { "max": 1048576, - "size": 285156 + "size": 285680 }, "RW_CCM": { "max": 65280, @@ -37,8 +37,8 @@ "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { - "code": 254332, - "ro_data": 30824, + "code": 254784, + "ro_data": 30896, "rw_data": 2072, "zi_data": 150104 }, diff --git a/test/host/cfg_file/cfg_file_test.c b/test/host/cfg_file/cfg_file_test.c index cedebd61..18bfffab 100644 --- a/test/host/cfg_file/cfg_file_test.c +++ b/test/host/cfg_file/cfg_file_test.c @@ -53,12 +53,19 @@ * TC-CONFIG-043 输出超长 -> 拒绝保存(-1) 且原文件不动 * 受管键行超 CFG_LINE_TEST_MAX -> 拒绝保存(-1) 原文件不动; * 非受管键的超长行照旧放过(原样保留) + * TC-CONFIG-059 读视图与 stat 基准不符(卷层旧/短视图)-> 拒绝保存(-1) + * 原文件一字节不动 + 自检留痕;视图追平后可再存——2026-11-05 视图一致性 + * 守卫:断"短视图 → 外科重写 → 文件缩小非默认段被删"吞段链(issue #73) * —— poll 契约(cfg_file_poll)—— * TC-CONFIG-050 首轮建立基线(不重载) * TC-CONFIG-051 沉降去抖:连续 2 次稳定才重载 * 与基线一致不重载 * TC-CONFIG-053 变更后重载 + 挂回调段差异触发回调 * TC-CONFIG-054 溢出到文件超长 -> 工厂默认 + 重建模板 + 自检留痕(不触发回调) + * TC-CONFIG-058 卷层旧视图(读长 != stat 长)-> 拒绝应用:RAM 保旧值、 + * 基线保留 2s 重试、视图追平即自愈应用 + 回调;拒绝期间无回调无二次 ERROR + * TC-CONFIG-060 读中改写(读后复 stat 令牌变)-> HELD 后重新沉降自愈; + * 持续不一致:全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条(日志限速) * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -98,6 +105,19 @@ static int s_fail_write = 0; /* cfg_store_write_atomic 返回 -1 */ static int s_fail_alloc = 0; /* cfg_store_alloc 返回 NULL */ static int s_fail_stat = 0; /* cfg_store_stat 返回 -1 */ +/* 视图不一致注入(2026-11-05 视图一致性守卫):s_view_stale 置位时 read 返回 + * s_vfs_stale 快照,stat 仍报真实盘的 (size, mtime)——等效真机 dhara/FTL 在 + * stat→read 窗口给出旧视图(台架 8/8 复现形态:读到旧/短内容、解析错误行 + * 一条不出)。vfs_mark_stale() 冻结当前盘为旧视图并开启注入; + * vfs_clear_stale() 视图追平 = 自愈 */ +static uint8_t s_vfs_stale[VFS_CAP]; +static size_t s_vfs_stale_len = 0u; +static int s_view_stale = 0; + +/* 读期间文件被改写注入:read 完成后 mtime 前进 = 读到的内容不再对应变更令牌 + * (一次性,复现"读中改写",由读后复 stat 检出) */ +static int s_touch_on_read = 0; + /* 观测计数 */ static int s_write_count = 0; static int s_read_count = 0; @@ -135,8 +155,21 @@ int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nre if (n > cap) { n = cap; } - (void)memcpy(p_buf, s_vfs, n); + if (s_view_stale) { + n = s_vfs_stale_len; /* 旧视图:盘上真身已变,读仍返回冻结快照 */ + if (n > cap) { + n = cap; + } + (void)memcpy(p_buf, s_vfs_stale, n); + } + else { + (void)memcpy(p_buf, s_vfs, n); + } *p_nread = n; + if (s_touch_on_read) { + s_touch_on_read = 0; /* 一次性:读后令牌变 = 读中改写 */ + s_vfs_mtime++; + } return 0; } @@ -263,6 +296,10 @@ static void vfs_reset(void) { s_vfs_mtime = 100u; s_vfs_exists = 0; s_fail_read = s_fail_write = s_fail_alloc = s_fail_stat = 0; + s_view_stale = 0; + s_vfs_stale_len = 0u; + memset(s_vfs_stale, 0, sizeof(s_vfs_stale)); + s_touch_on_read = 0; s_write_count = s_read_count = 0; s_syslog_count = 0; g_cfgtest_err_count = 0; @@ -279,6 +316,7 @@ static void vfs_reset(void) { memset(s_snap, 0, sizeof(s_snap)); memset(s_sec_hdr, 0, sizeof(s_sec_hdr)); memset(s_key_line, 0, sizeof(s_key_line)); + s_reject_streak = 0u; /* 段 RAM 副本复位工厂默认 */ s_net_data = s_net_def; s_sys_data = s_sys_def; @@ -309,6 +347,20 @@ static void vfs_put_notoken(const char *text) { s_vfs_exists = 1; } +/* 冻结当前盘内容为"旧视图"并开启注入:此后 read 一直返回这份快照, + * stat/盘上真身照常变化 = 复现卷层 stat→read 窗口的旧视图滞后 */ +static void vfs_mark_stale(void) { + s_vfs_stale_len = s_vfs_len; + memcpy(s_vfs_stale, s_vfs, s_vfs_len); + s_view_stale = 1; +} + +/* 视图追平盘上真身 = 卷层视图恢复(守卫自愈路径) */ +static void vfs_clear_stale(void) { + s_view_stale = 0; + s_vfs_stale_len = 0u; +} + /* 取虚拟盘当前内容为 NUL 结尾字符串(供检索断言);返回静态缓冲 */ static const char *vfs_get(void) { static char buf[VFS_CAP + 1]; @@ -974,6 +1026,34 @@ static void tc_save(void) { CHECK(strstr(vfs_get(), "u8=9") != NULL, "managed key still rewritten"); CHECK(strstr(vfs_get(), "vendor = kkk") != NULL, "unknown long line preserved"); } + + printf("[TC-CONFIG-059] save view != stat token -> reject, file untouched\n"); + { + size_t before_len; + int before_writes; + + /* 台架实证吞段链:卷层在 stat→read 窗口给旧/短视图 → 旧实现照视图外科 + * 重写整档 → 文件变小、非默认段被静默删除(实测 1066B→986B)。 + * 守卫后:读视图与 stat 基准不符 = 拒存,原文件一字节不动 */ + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=7\r\n[sys]\r\nbaud=115200\r\n"); + s_net_data.u8 = 9u; + vfs_mark_stale(); /* 视图冻结在当前完整文件 */ + vfs_put("[net]\r\nu8=7\r\n"); /* 盘上真身变短(旧视图回滚形态) */ + before_len = s_vfs_len; + before_writes = s_write_count; + s_syslog_count = 0; + g_cfgtest_err_count = 0; + CHECK(cfg_file_save() == -1, "mismatched view rejected with -1"); + CHECK(s_vfs_len == before_len, "file bytes untouched"); + CHECK(s_write_count == before_writes, "no atomic write happened"); + CHECK(s_syslog_count >= 1, "view-stale self-test logged"); + CHECK(g_cfgtest_err_count == 1, "one ERROR line logged"); + vfs_clear_stale(); /* 视图追平盘上真身 */ + CHECK(cfg_file_save() == 0, "save succeeds again after view converges"); + CHECK(strstr(vfs_get(), "u8=9") != NULL, "RAM value written after convergence"); + } } /* TC-CONFIG-050~054:poll 契约 */ @@ -1057,6 +1137,77 @@ static void tc_poll(void) { CHECK(s_vfs_len <= CFG_FILE_MAX_LEN, "file regenerated within limit"); CHECK(s_net_cb_count == 0, "no callback on oversized (online-repair channel kept)"); } + + printf("[TC-CONFIG-058] poll stale view -> reload HELD, RAM kept, self-heals\n"); + { + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=5\r\n"); + s_net_data.u8 = 5u; /* RAM = 旧值:既非默认(7)也非新值(88) */ + cfg_file_poll(); /* 基线 = 旧令牌(台架时序:先有基线再有变更) */ + vfs_mark_stale(); /* 视图冻结在 u8=5(13B) */ + vfs_put("[net]\r\nu8=88\r\n"); /* 盘上真实变更(14B,读长必不符) */ + cfg_file_poll(); /* st 变:s_last 记录,stable=1 */ + s_syslog_count = 0; + g_cfgtest_err_count = 0; + s_net_cb_count = 0; + cfg_file_poll(); /* stable=2 -> 读视图 13B != stat 14B -> HELD */ + CHECK(s_net_data.u8 == 5u, "held: RAM keeps old value (not 88, not default)"); + CHECK(g_cfgtest_err_count == 1, "first reject logs one ERROR"); + CHECK(s_syslog_count == 1, "first reject self-test logged once"); + CHECK(s_net_cb_count == 0, "no callback while held"); + CHECK(s_base_valid == 1u, "baseline kept (retry not re-baseline)"); + cfg_file_poll(); /* 视图仍旧 -> 继续 HELD,静默不刷屏 */ + CHECK(g_cfgtest_err_count == 1, "repeat rejects stay silent"); + CHECK(s_syslog_count == 1, "self-test not spammed"); + vfs_clear_stale(); /* 卷层视图恢复 */ + cfg_file_poll(); /* 令牌未变 stable 饱和 -> 重试应用 */ + CHECK(s_net_data.u8 == 88u, "self-heal: value applied once view converges"); + CHECK(s_net_cb_count >= 1, "callback fires on healed apply"); + } + + printf("[TC-CONFIG-060] mid-read rewrite -> HELD; WARN cadence rate-bounded\n"); + { + int i; + + /* 段一:读完成后令牌被推进(读中改写)→ 读后复 stat 检出 → 拒绝且自愈 */ + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=5\r\n"); + s_net_data.u8 = 5u; + cfg_file_poll(); /* baseline */ + vfs_put("[net]\r\nu8=88\r\n"); /* 外部变更 */ + cfg_file_poll(); /* stable=1 */ + s_touch_on_read = 1; /* 本次 read 结束即"被改写" */ + g_cfgtest_err_count = 0; + s_syslog_count = 0; + cfg_file_poll(); /* stable=2 -> 读中改写 -> HELD */ + CHECK(s_net_data.u8 == 5u, "mid-read rewrite held: RAM kept"); + CHECK(g_cfgtest_err_count == 1, "mid-read reject logged one ERROR"); + CHECK(s_syslog_count == 1, "mid-read reject self-test logged"); + cfg_file_poll(); /* 令牌已变:重新 debounce */ + cfg_file_poll(); /* 再沉降 -> 视图已一致 -> 应用 */ + CHECK(s_net_data.u8 == 88u, "applies after view settles again"); + + /* 段二:持续旧视图 -> 全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条(≈60s) */ + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=5\r\n"); + s_net_data.u8 = 5u; + cfg_file_poll(); /* 基线 = 旧令牌 */ + vfs_mark_stale(); + vfs_put("[net]\r\nu8=88888\r\n"); /* 与视图长度不同 -> 持续不一致 */ + g_cfgtest_err_count = 0; + g_cfgtest_info_count = 0; + s_syslog_count = 0; + for (i = 0; i < 35; i++) { + cfg_file_poll(); /* 沉降 1 + HELD 34(streak 1..34 含 30) */ + } + CHECK(s_net_data.u8 == 5u, "persistent stale view never touches RAM"); + CHECK(g_cfgtest_err_count == 1, "episode logs exactly one ERROR"); + CHECK(s_syslog_count == 1, "episode logs exactly one self-test"); + CHECK(g_cfgtest_info_count == 1, "WARN cadence: one line per 30 rounds"); + } } int main(void) { From 1a2ca0467dbb8e86b29f8ace287948f5f3f2e8c2 Mon Sep 17 00:00:00 2001 From: super_agent_bot Date: Thu, 5 Nov 2026 17:08:25 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(cfg):=20=E8=A7=86=E5=9B=BE=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E8=A1=A5=E5=86=85=E5=AE=B9=E5=88=A4=E6=8D=AE=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=90=8C=E5=B0=BA=E5=AF=B8=E5=85=A8=200=20=E6=97=A7?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E5=9D=97=E4=B9=9F=E8=83=BD=E6=8B=A6=EF=BC=88?= =?UTF-8?q?#73=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台架(PR #82 head d9303e05,常授权烧写验证)在三点校验版守卫上继续复现 出打回形态:卷层给出的旧映射块与目标文件恰好同长(1064B)且整块全 0, 读长 == st.size 与读前后复 stat 全部通过,解析零错误、RAM 照样打回默认 (15:21 决定性轮:盘文件 RETR 校验七段完好,重载窗口内 s_sntp 三字节段 全 0)。纯令牌层校验对"内容坏"是盲的,必须补内容判据。 处置(引擎侧,与三点校验同族): - 新增 s_view_bad:重载/保存读到的视图若为空读、或 [0,len) 内任意偏移 含 NUL 字节即判坏——INI UTF-8 文本约定下任何偏移都不得含 0x00;解析 器按行切分遇 NUL 即断行,零块之后的内容本就不可达(静默截断同源故障), 拒之不误伤任何可解析文档。不要求首字节 ';'(手写文档可直接段名起始)。 - poll:坏内容 = 与长度/令牌不符同处置——拒绝应用,RAM 与基线不动,2s 重试自愈,首次 ERROR + 自检留痕,每 30 趟一条 WARN(限速)。 - save:坏内容同族拒存返回 -1,原文件一字节不动,调用方 dirty 保留重试。 - 边界如实保留:内容合法但陈旧的整卷视图(长度/令牌/内容三者皆一致滞后) 引擎层无法检出,属卷层(dhara/FTL)课题,重启必然收敛;"改过配置后 重启一次"在卷层根治前仍是最稳口径。 测试: - TC-CONFIG-061(poll 同尺寸全 0 块 HELD + 视图刷新自愈 + 零回调)、 TC-CONFIG-062(save 遇全 0 块拒存、文件字节/写次数不动、追平后重试 成功);桩新增 vfs_mark_stale_zeros() 注入同长全 0 旧视图。 - cfg_file 套件 259/259;iec104 179/179、run_par 67/67、adc_cal 117/117、 sntp 98/98、cfg_file_store_mcu 34/34 复跑全绿。 - /keil-build 0 错 0 警:Code 254,784 -> 254,820 B(+36 B);台账与同码 凭证已同步。台架实录见 PR #82 评论:NUL 注入块实机 HELD(16:55:46) + WARN 节拍 30/60 趟命中(16:56:42/16:57:41)+ 视图转新鲜后重载自动 追平;残余打回事件均为"内容合法但陈旧"整卷视图(15:19:30/16:34:59/ 16:58:38),属已声明守卫边界,盘文件全程未吞段,重启收敛实机确认 (17:04 RAM 回文件值)。 文档: - 《配置文件设计说明》§4-(3)/§5.3 守卫描述补内容判据与 15:21 实证形态、 风险表行更新、变更记录 v1.17 行同步; - 《配置文件单元测试说明》TC-CONFIG-061/062 行 + 当前结果 259/259 + 扩展指南补 vfs_mark_stale_zeros()。 --- App/cfg/cfg_file.c | 64 +++++++++++++---- App/cfg/cfg_file.h | 14 ++-- docs/子系统设计/配置文件设计说明.md | 8 +-- .../测试说明/单元测试/配置文件单元测试说明.md | 7 +- scripts/ram_baseline.tsv | 2 +- scripts/size_attestation.json | 14 ++-- test/host/cfg_file/cfg_file_test.c | 70 +++++++++++++++++++ 7 files changed, 146 insertions(+), 33 deletions(-) diff --git a/App/cfg/cfg_file.c b/App/cfg/cfg_file.c index cbcab12c..e68955d6 100644 --- a/App/cfg/cfg_file.c +++ b/App/cfg/cfg_file.c @@ -30,12 +30,16 @@ * 宽度上下界校验,s_encode() 带负号输出;负零点偏移从此可 * 干净落盘(动因见 docs/任务规划/数据可信.md §4.4 与 §7 决策 3) * v1.6 2026-11-05 DTU 项目组 热重载/保存视图一致性守卫(issue #73 SNTP 补验揪出)—— - * poll stat→read→复 stat 三点不一致(空读/截断旧视图或读中改写) - * 拒绝应用:RAM 与基线不动、2s 重试自愈、首次自检留痕 + 每 30 趟 - * WARN 限速;save 读视图与 stat 基准不符拒写(断"短视图→整档 + * poll stat→read→复 stat 三点校验 + 内容合法性 s_view_bad + * (空读或任意偏移含 NUL = 坏视图;台架 15:21 实证同尺寸 + * 全 0 旧映射块可骗过纯三点校验);命中拒绝应用:RAM 与 + * 基线不动、2s 重试自愈、首次自检留痕 + 每 30 趟 + * WARN 限速;save 读视图与 stat 基准不符或内容坏 + * 拒写(断"短/坏视图→整档 * 外科重写→非默认段被删"吞段链,台架实测 1066B→986B)。 - * 台架根因为卷层 dhara 旧视图回滚(≥8/8 复现),引擎侧无法 - * 分辨"一致但陈旧"的整卷回滚——该形态重启必收敛,见设计说明 + * 台架根因为卷层 dhara 旧视图回滚(复现多形态:空读、 + * 短读、同尺寸全 0 块),引擎侧无法分辨"一致但陈旧"的 + * 整卷回滚——该形态重启必收敛,见设计说明 * 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save; * 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证 * (见《配置文件设计说明》D15 A+ 方案) @@ -622,6 +626,34 @@ static int s_load_file(uint8_t **p_pbuf, size_t *p_len) { return 0; } +/* + * 函数功能:读视图内容合法性判定(stat/mtime 三点校验抓不住的内容层破视图) + * 入口参数:p_buf - s_load_file 成功缓冲 const uint8_t * + * len - 实际长度 size_t + * 返回值:1 - 视图非法(空读,或 [0,len) 内出现 NUL 字节);0 - 基本通过 + * (配置文件按 INI UTF-8 文本约定生成与编辑,任何偏移都不得含 0x00: + * 引擎保存只写可打印文本,手写/FTP 文档亦然;即便出现合法前缀 + + * 尾部零填充的"部分旧块",解析器按行切分遇 NUL 即断行,零块后内容 + * 本就不可达 = 静默截断同源故障,一并拒绝不误伤任何可解析文档) + * 限定条件:仅可用于"重载/保存"的既有文档上下文(boot load 首读不经此路: + * 空文件 = 合法待建形态)。真机台架 2026-11-05 实证:同尺寸整块全 0 + * 旧视图可通过长度+再 stat 双检(dhara 旧映射块恰与目标同尺寸), + * 解析零错误、RAM 打回默认——首字节即 NUL,本判定 100% 拦截 + */ +static int s_view_bad(const uint8_t *p_buf, size_t len) { + size_t i; + + if (len == 0u) { + return 1; /* 空读:解析空文档 = 全体打回默认 */ + } + for (i = 0u; i < len; i++) { + if (p_buf[i] == 0u) { + return 1; /* 全 0 旧映射块 / 含 NUL 坏块 */ + } + } + return 0; +} + /* * 函数功能:经后端原子写配置文档(写盘唯一闸口:模板/外科手术同闸) * 入口参数:p_buf - 文档数据 const uint8_t * @@ -1260,7 +1292,8 @@ int cfg_file_save(void) { if (in_len != (size_t)st.size || cfg_store_stat(cfg_store_config_path(), &st2) != 0 || - st2.size != st.size || st2.mtime != st.mtime) { + st2.size != st.size || st2.mtime != st.mtime || + s_view_bad(p_in, in_len) != 0) { cfg_store_free(p_in); DBG_ERROR("save: view %luB != stat %luB, aborted (file untouched)", (unsigned long)in_len, (unsigned long)st.size); @@ -1402,13 +1435,15 @@ void cfg_file_poll(void) { } /* 2026-11-05 视图一致性守卫(与 save 侧同族,真机台架实证 issue #73): - * stat → read → 再 stat 三点必须一致——读长 ≠ st.size(空读/截断 = - * 卷层旧视图;台架 8/8 复现的"热重载打回出厂默认"即空读形态:全段 - * 静默 memcpy 默认、零解析错误行)或读期间文件被改写 = 读到的内容不 - * 对应变更令牌。不一致 → 拒绝应用:RAM 副本原样保留、基线不刷新, - * 下一趟(2s)重试,视图恢复一致即正常应用(自愈);若持续到重启, - * boot 路径全新挂载必然收敛。首次拒绝自检留痕 + ERROR 一行,此后每 - * 30 趟(约 60s)一条 WARN 计数行(限速,防日志风暴) */ + * 三点校验 stat → read → 再 stat + 内容合法性 s_view_bad。台架后续 + * 实证(15:21):同尺寸"整块全 0 旧映射块"可骗过纯长度/再 stat 双检 + * (dhara 旧映射块恰与目标同尺寸 1064B),解析零错误、RAM 照打回 + * ——故必须验内容:INI UTF-8 文本任何偏移不得含 NUL(见 s_view_bad)。 + * 命中形态:空读/截断/全 0 块/含 NUL 坏块/读期间改写;不一致 → + * 拒绝应用:RAM 副本原样保留、基线不刷新,下一趟(2s)重试,视图 + * 恢复一致即正常应用(自愈);若持续到重启,boot 路径全新挂载必然 + * 收敛。首次拒绝自检留痕 + ERROR 一行,此后每 30 趟(约 60s)一条 + * WARN 计数行(限速,防日志风暴) */ { cfg_file_stat_t st2; int stale; @@ -1420,6 +1455,9 @@ void cfg_file_poll(void) { st2.size != st.size || st2.mtime != st.mtime) { stale = 1; /* 读期间文件消失或被改写 */ } + else if (s_view_bad(p_buf, len) != 0) { + stale = 1; /* 内容层坏视图:全 0 旧映射块/含 NUL */ + } else { stale = 0; } diff --git a/App/cfg/cfg_file.h b/App/cfg/cfg_file.h index 772be178..71d12ff6 100644 --- a/App/cfg/cfg_file.h +++ b/App/cfg/cfg_file.h @@ -19,7 +19,7 @@ * (len = 1/2/4,小端补码)——标定零点偏移等负值需有符号 * 落盘,动因与取舍见 docs/任务规划/数据可信.md §4.4 * v1.5 2026-11-05 DTU 项目组 契约同步视图一致性守卫(issue #73 SNTP 补验揪出): - * poll 读视图与 stat 令牌不符 = 拒绝应用(RAM/基线不动、2s + * poll 读视图与 stat 令牌不符或内容坏 = 拒绝应用(RAM/基线不动、2s * 重试自愈);save 读视图不符 = 拒存返回 -1(原文件不动)。 * 细节见 cfg_file.c v1.6 与《配置文件设计说明》变更节 */ @@ -121,9 +121,10 @@ int cfg_file_load(void); * 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超 * CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报, * 引擎写盘闸口同拒);读视图与 stat 基准不符(空读/截断/读中 - * 被改写)同样拒存返回 -1——防按短视图外科重写把文件写小、 - * 非默认段被静默删除(2026-11-05 视图一致性守卫,调用方脏位 - * 保留 = 下一趟自然重试) + * 被改写)或内容坏(INI 文本任意偏移含 NUL——dhara 同尺寸全 0 + * 旧映射块 2026-11-05 台架实证)同样拒存返回 -1——防按短/坏视图 + * 外科重写把文件写小、非默认段被静默删除(视图一致性守卫, + * 调用方脏位保留 = 下一趟自然重试) */ int cfg_file_save(void); @@ -136,8 +137,9 @@ int cfg_file_save(void); * 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT * 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认 * + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启 - * 收敛);否则过视图一致性守卫(读长 = st.size 且读前后 (size, - * mtime) 不变;不符 = 卷层旧视图/读中被改写 → 拒绝应用:RAM 与 + * 收敛);否则过视图一致性守卫(读长 = st.size、内容无 NUL(dhara + * 同尺寸全 0 旧映射块 2026-11-05 台架实证)且读前后 (size, + * mtime) 不变;不符 = 卷层旧视图/坏块/读中被改写 → 拒绝应用:RAM 与 * 基线不动,2s 重试自愈,持续到重启则由 load 全新挂载收敛;首次 * 拒绝自检留痕,其后每 30 趟一条 WARN)后重载全部段(各段复位 * 工厂默认后解析);挂回调段有差异 → 调用其回调(如触发网络重建); diff --git a/docs/子系统设计/配置文件设计说明.md b/docs/子系统设计/配置文件设计说明.md index 85d86199..58974a2e 100644 --- a/docs/子系统设计/配置文件设计说明.md +++ b/docs/子系统设计/配置文件设计说明.md @@ -215,7 +215,7 @@ flowchart TD - **超长守卫(v1.12)**:外科手术输出超 `CFG_FILE_MAX_LEN`(4096B)→ **拒绝保存返回 -1**(原文件不动 + 自检上报 `cfg save overflow`);`s_write_doc`(模板/外科同闸的唯一落盘出口)硬性拒写 >4096B —— "引擎永不产出超长 config.ini"是结构性质。超长文件一律按损坏处置(load/poll 同判,见 §5.3) -**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载先过**视图一致性守卫**(v1.17,§5.3):stat → read → 再 stat 三点必须一致——读长 ≠ `st.size` 或读期间 (size, mtime) 变化 = 卷层给了旧/短视图或读中被改写 → 拒绝应用(RAM 不动、基线不刷新,2s 重试自愈);一致后重载 → 段变化则触发该段回调 → 刷新基线。 +**(3) 变更检测** —— `cfg_file_poll()`(defaultTask / 模拟器主循环,2s 节拍):`f_stat` 取 (size, mtime-epoch) 与基线比对 → 沉降后**先按真实 `st.size` 查超长**(v1.12):>4096B = 损坏 → 各段复位工厂默认 + 重建模板 + 自检留痕(`cfg poll oversized`),**不触发段回调**(保留 FTP/在线修复通道,下次保存或重启收敛);正常路径重载先过**视图一致性守卫**(v1.17,§5.3):stat → read → 再 stat 三点必须一致且**内容过 `s_view_bad`**——读长 ≠ `st.size`、读期间 (size, mtime) 变化、或视图含 NUL 字节(同尺寸全 0 旧映射块,台架实证形态)= 卷层给了旧/短/坏视图或读中被改写 → 拒绝应用(RAM 不动、基线不刷新,2s 重试自愈);一致后重载 → 段变化则触发该段回调 → 刷新基线。 **(4) 段变更回调** —— 未来模块的接入点: @@ -300,7 +300,7 @@ sequenceDiagram - 检测粒度 2s(轮询周期);网络重建含等链路 ≤3s,总生效时间约 2~5s - 下载天然支持(RETR 即文件读) - **沉降去抖(D15-②)**:(size, mtime) 相对基线变化且**连续 2 次轮询(4s)稳定**才重载——防 FTP STOR 进行中的中间态触发多次重载(STOR 先写 `.TMP`,目标文件 (size, mtime) 在 rename 前不变,rename 单次原子更新 → 至多一次重载) -- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速)。**边界**:内容一致但陈旧的整卷视图((size,mtime) 恰好相同)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径 +- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)+ **内容判据 `s_view_bad`**(INI UTF-8 文本约定下任何偏移都不得含 NUL 字节——空读、整块全 0、合法前缀+尾部零填充的"部分旧块"全部命中;解析器遇 NUL 断行,被零块吞掉的尾部内容本就不可达,拒之不误伤任何可解析文档)。台架 15:21 实证:**同尺寸整块全 0 旧映射块可骗过纯三点校验**(dhara 旧映射块恰与目标同长 1064B),解析零错误、RAM 照打回——内容判据是该形态的唯一引擎侧拦截。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速)。**边界**:内容合法但陈旧的整卷视图((size,mtime) 与内容三者皆一致滞后)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径 - **单写者(D15-③)**:`/config.ini` 的直接写者收敛为 defaultTask(UI 保存 = 请求投递至 defaultTask,由其落盘(tmp+rename)+ 应用(触发网络重建),复用既有 `SYS_EVT_NET_RECONFIG` 路径);唯一例外 = FTP STOR 路径(lftpd,tmp+rename,与 defaultTask 写路径经卷锁互斥) ### 5.4 线程安全(FatFS 可重入) @@ -555,7 +555,7 @@ void adc_cfg_init(void) | FTP STOR 半截写(掉电 / 断链) | 中 | 逐键容错 + 工厂回退;0 字节 STOR 自动删文件(lftpd 现状)→ 下次上电重新生成模板;下次保存自愈 | | 墙钟无效(开机 RTC 读取失败且默认校时失败) | 低 | mtime 退化为固定值(检测退化为 size);既有开机告警链 + 默认校时(RTC_DEFAULT_INIT)兜底,实际极罕见 | | mtime 秒边界碰撞(同秒两次写) | 低 | (size, mtime) 双比对;2s 轮询通常跨秒;误报 = 多一次重载(无害) | -| 卷层给回旧/短视图(dhara FTL 缓存 / 整卷回滚,台架 issue #73 实证) | 高 | 引擎三点视图一致性守卫(§4-(3) / §5.3):stat 与 read 不符 → poll 拒绝应用 / save 拒绝落盘,坏视图既不碰 RAM 也不重写文件;"一致但陈旧"的整卷视图引擎不可检出,属卷层课题,重启收敛兜底 | +| 卷层给回旧/短/坏视图(dhara FTL 缓存 / 整卷回滚 / 同尺寸全 0 旧映射块,台架 issue #73 实证) | 高 | 引擎视图一致性守卫 = 三点校验 + 内容判据 s_view_bad(§4-(3) / §5.3):stat 与 read 不符或视图含 NUL → poll 拒绝应用 / save 拒绝落盘,坏视图既不碰 RAM 也不重写文件;"一致但陈旧"的整卷视图引擎不可检出,属卷层课题,重启收敛兜底 | | save 堆峰值与 LVGL / lftpd 争用 | 低 | 事件触发(罕见);FR_TIMEOUT 兜底;实施后 map 复核堆空闲 | | 8.3 文件名约束 | 无 | `config.ini` 合规;段名 / 键名属文件内容,不受限 | | 卷重新格式化丢配置 | 中(现状一致) | 与现状相同(f_mkfs 清空卷);文档提示 | @@ -593,4 +593,4 @@ void adc_cfg_init(void) | 2026-10-10 | v1.14:**删除旧档 NCFG 迁移(D10 撤销)**——`Drivers/BSP/NETCFG/net_cfg_legacy.{c,h}` 整体移除、`cfg_store_legacy_path()` 撤销(MCU / 模拟器后端与主机单测 `test/host/cfg_file/cfg_file_test.c` 同步删除),`net_cfg_load` 不再调用 `net_cfg_legacy_migrate()`;旧档 `/cfg/netcfg.bin` 若存在则被**忽略**(不读、不删、不迁移),配置回落工厂默认。理由:该一次性路径把整条配置写链挂进启动期调用图,defaultTask 静态最坏栈链 **+600 B**(该链整体 5312 → 2776 B,另三个杠杆见 `docs/内存管理/RAM使用分析与优化方案.md`),而本仓库不存在携带旧档的设备。连带影响:`App/util` 的 CRC-16/MODBUS 失去唯一调用者(现为零调用者);模拟器 `--cfgtest` 不再覆盖迁移场景;§2 D10 / §3.5 / §4.2 / §5.1 / §6.2 / §6.4 / §7 / §9 / §11.1 同步 | | 2026-10-26 | v1.15:**新增 `[comm]` 段(道G task-8,「UI 补齐」波次里的非 UI 前置项)**——`App/protocol/iec104/iec104_comm.h/.c` 配对模块(104 公共地址 / 监听端口 2 键,getter 合法带自愈,adc_cal 先例);内核前 `iec104_comm_init()` 注册([adc_cal] / [run_par] 同「先注册后 load」契约);生效 = **改后重启**(不挂回调、不新增事件位,区别于 [network] 免重启重建);段占位 5→6 / 8,键表与主站地址不做理由 → `docs/子系统设计/网络配置说明.md` §3.6;机器钉 = `test/host/cfg_file` TC-CONFIG-056/057(段/键上限)+ `test/host/iec104` TC-IEC104-031~033(模板/重载/带外/ca 端到端) | 2026-11-04 | v1.16:**新增 `[sntp]` 段(板端 SNTP 客户端接入,#57 调研结论落地)**——`Drivers/BSP/NETCFG/sntp_cfg.h/.c`(enable / server1 / server2 / poll_min 4 键;出厂默认**关闭 + 服务器留空 + 60 分钟** = "未配置零网络足迹"口径;`poll_min` 带外自愈 1..1440 → 60 不写回 RAM);数据本体 = `Drivers/BSP/NETCFG/sntp_cfg.h/.c`(不依赖引擎头,分层门禁 layer-deps 口径),段描述符由 `net_cfg.c` 的 `s_sntp_register()` 组装,注册编排 = `net_cfg_load()`(与 [ftp] 同编排,先注册后 load);消费方 = `App/sync/time_sntp.c`(挂 timeSyncTask 既有循环,vendor `Lib/coreSNTP` serializer 做 48B 报文编解码/防欺骗校验);保存立即生效 = 每轮直读 RAM 副本 + 网络代次重建弃 UDP socket 重散布,**无段回调、不新增事件位**;段占位 6→7 / 8;键表与生效细则 → `docs/子系统设计/网络配置说明.md` §3.7;机器钉 = `test/host/net_cfg` TC-NETCFG-005/006b..d/007(段注册契约)+ `test/host/sntp`(RFC 4330 §10 节奏 / §5.3 防欺骗 / §5.5 步进策略 / poll 钳位) -| 2026-11-05 | v1.17:**热重载/保存路径视图一致性守卫(issue #73 台架实证根治)**——真机 8/8 复现"运行中改配置触发热重载打回出厂默认"且此后任何保存吞段(1066B→986B):卷层在 stat→read 窗口给回旧/短视图,引擎照单应用(全段 memcpy 默认、零解析错误行)或照短视图外科重写文件。① `cfg_file_poll` 沉降读取后三点校验(读长 == `st.size` + 读后复 stat (size, mtime) 不变):不符 → **拒绝应用**(RAM 原样 / 基线不刷新 / 2s 重试自愈),首次拒绝 `cfg poll view stale` 自检 + ERROR 一行,此后每 30 趟(≈60s)一条 WARN 计数;② `cfg_file_save` 同族守卫:视图与 stat 不符 → **拒存返回 -1**(调用方保留 dirty 重试)原文件一字节不动 + `cfg save view stale` 自检——"短视图 → 外科重写 → 非默认段被删"链就此斩断;③ 边界如实披露:一致但陈旧的整卷视图引擎层无法检出(dhara/FTL 属卷层),重启必然收敛,卷层根治前"改过配置后重启一次"仍是最稳口径。机器钉 = TC-CONFIG-058(poll HELD + 自愈)/ 059(save 拒存文件不动)/ 060(读中改写检出 + 日志限速);`cfg_file.c` v1.6 / `cfg_file.h` v1.5 契约同步 +| 2026-11-05 | v1.17:**热重载/保存路径视图一致性守卫(issue #73 台架实证根治)**——真机 8/8 复现"运行中改配置触发热重载打回出厂默认"且此后任何保存吞段(1066B→986B):卷层在 stat→read 窗口给回旧/短视图,引擎照单应用(全段 memcpy 默认、零解析错误行)或照短视图外科重写文件。① `cfg_file_poll` 沉降读取后三点校验 + 内容判据(读长 == `st.size` + 读后复 stat (size, mtime) 不变 + `s_view_bad`:INI 文本任意偏移含 NUL = 坏块——台架 15:21 再实证**同尺寸整块全 0 旧映射块**可骗过纯三点校验,解析零错误 RAM 照打回,内容判据是该形态唯一引擎侧拦截):不符 → **拒绝应用**(RAM 原样 / 基线不刷新 / 2s 重试自愈),首次拒绝 `cfg poll view stale` 自检 + ERROR 一行(该行打印"读长B != stat B",内容判据或复 stat 命中时两个数可相等,命中的是内容/读中改写项),此后每 30 趟(≈60s)一条 WARN 计数;② `cfg_file_save` 同族守卫:视图与 stat 不符或内容坏 → **拒存返回 -1**(调用方保留 dirty 重试)原文件一字节不动 + `cfg save view stale` 自检——"短/坏视图 → 外科重写 → 非默认段被删"链就此斩断;③ 边界如实披露:一致但陈旧的整卷视图引擎层无法检出(dhara/FTL 属卷层),重启必然收敛,卷层根治前"改过配置后重启一次"仍是最稳口径。机器钉 = TC-CONFIG-058(poll HELD + 自愈)/ 059(save 拒存文件不动)/ 060(读中改写检出 + 日志限速)/ 061(同尺寸全 0 块内容判据拦截 + 自愈)/ 062(save 遇全 0 块拒存不动盘);`cfg_file.c` v1.6 / `cfg_file.h` v1.5 契约同步 diff --git a/docs/测试说明/单元测试/配置文件单元测试说明.md b/docs/测试说明/单元测试/配置文件单元测试说明.md index 7730c5f9..6f4f6f51 100644 --- a/docs/测试说明/单元测试/配置文件单元测试说明.md +++ b/docs/测试说明/单元测试/配置文件单元测试说明.md @@ -165,8 +165,10 @@ test/host/cfg_file/cfg_file_test.c | **TC-CONFIG-058** | 卷层旧视图(读长 ≠ stat 长)→ poll 拒绝应用:RAM 保旧值、基线保留重试、视图追平即自愈应用 + 回调;拒绝期间零回调、全程 1 ERROR + 1 留痕 | 2026-11-05 视图一致性守卫(issue #73 热重载打回出厂默认根治) | | **TC-CONFIG-059** | save 侧同族守卫:视图 ≠ stat → 拒存 -1、原文件一字节不动 + 自检留痕;视图追平后可再存 | 斩断"短视图 → 外科重写 → 非默认段被吞"链(实测 1066B→986B 形态) | | **TC-CONFIG-060** | 读中改写(读后复 stat 令牌变)→ HELD 后重新沉降自愈;持续不一致:全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条 | 拒绝分支日志限速(防 2s 节拍日志风暴) | +| **TC-CONFIG-061** | 同尺寸全 0 旧映射块(长度/令牌双检全过)→ 内容判据 `s_view_bad` 拦下:HELD RAM 保旧值、零回调、1 ERROR + 1 留痕;视图刷新后自愈应用 + 回调 | 台架 15:21 实证形态:纯三点校验拦不住"恰好同长"的 dhara 旧映射块 | +| **TC-CONFIG-062** | 保存链遇同尺寸全 0 旧视图 → 拒存 -1,文件字节/写次数一字节不动 + 留痕;视图恢复后同一保存重试成功落盘 | 坏视图外科重写同样被拒(与 059 短视图互补:同长但内容坏) | -**当前结果**:239/239 PASSED(`failed=0`)。 +**当前结果**:259/259 PASSED(`failed=0`)。 --- @@ -179,7 +181,8 @@ test/host/cfg_file/cfg_file_test.c 4. 用 `vfs_get()` 取当前文件文本做检索断言(注释/未知段是否保留等); 5. 用故障注入开关 `s_fail_read / s_fail_write / s_fail_alloc / s_fail_stat` 构造故障路径; 视图不一致用 `vfs_mark_stale()`(read 冻结在旧快照、stat 报真实盘)/ - `vfs_clear_stale()`(视图追平 = 自愈),读中改写用一次性开关 `s_touch_on_read`; + `vfs_mark_stale_zeros()`(read 返回与真实盘同长的全 0 块 = 台架 dhara + 旧映射块形态)/ `vfs_clear_stale()`(视图追平 = 自愈),读中改写用一次性开关 `s_touch_on_read`; 6. `CHECK(cond, "描述")` 断言;在 `main()` 里注册。 > ⚠ **`vfs_put` 与 `vfs_put_notoken` 的区别很关键**:poll 依 `(size, mtime)` 判变更, diff --git a/scripts/ram_baseline.tsv b/scripts/ram_baseline.tsv index 926654d5..5b45cf50 100644 --- a/scripts/ram_baseline.tsv +++ b/scripts/ram_baseline.tsv @@ -17,4 +17,4 @@ date code ro rw zi er_irom1 er_pct iram1 iram1_pct ccm ccm_pct top_rwzi 2026-11-02 247548 29836 2052 149740 277384 26.5 94368 72.0 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 2026-11-03 249112 29836 2052 149996 278948 26.6 94624 72.2 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6216;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 2026-11-04 254332 30824 2072 150104 285156 27.2 94752 72.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6236;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 -2026-11-05 254784 30896 2072 150104 285680 27.2 94752 72.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6236;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 +2026-11-05 254820 30896 2072 150104 285716 27.2 94752 72.3 57376 87.9 heap_4.o=57376;lv_mem_core_builtin.o=16384;lftpd.o=7884;ui_net_sink.o=6802;ui_port.o=6432;nand_ftl.o=6236;wave_ring.o=5296;sb_link.o=4376;ch395f.o=4162;console_log.o=4132 diff --git a/scripts/size_attestation.json b/scripts/size_attestation.json index 15f261a4..03ee934c 100644 --- a/scripts/size_attestation.json +++ b/scripts/size_attestation.json @@ -1,17 +1,17 @@ { - "built_at": "2026-11-05T12:35:40+08:00", - "fingerprint": "8c8ba70adaaa6d8de16a3f80efc6caf859f032eb8dc29dd129351afc4362137d", + "built_at": "2026-11-05T16:35:16+08:00", + "fingerprint": "36fcf475ac2d01551cf21b31af98a3aa51e1dca1e3a74ae79570e7cee253b5b5", "fingerprint_files": 1479, "fingerprint_source": "git-index", "gated_regions": [ "RW_IRAM1", "RW_CCM" ], - "git_head": "0d5cd1c8d12ff40e225e3e8223958e1ab2a6f74d", + "git_head": "d9303e05a4512defe29c0e434c20ba5e243bcc1b", "map": "MDK-ARM/STM32F407-Demo/STM32F407-Demo.map", - "map_mtime": 1793853338, + "map_mtime": 1793867423, "pct": { - "ER_IROM1": 27.24, + "ER_IROM1": 27.25, "RW_CCM": 87.89, "RW_IRAM1": 72.29, "UNINIT_CCM": 18.75 @@ -19,7 +19,7 @@ "regions": { "ER_IROM1": { "max": 1048576, - "size": 285680 + "size": 285716 }, "RW_CCM": { "max": 65280, @@ -37,7 +37,7 @@ "schema": 2, "scope_note": "\u540c\u7801\u51ed\u8bc1\uff1a\u6307\u7eb9\u8986\u76d6\u56fa\u4ef6\u4f53\u79ef\u76f8\u5173\u6e90\u7801\uff0c\u53e3\u5f84\u89c1\u672c\u811a\u672c docstring", "sizes": { - "code": 254784, + "code": 254820, "ro_data": 30896, "rw_data": 2072, "zi_data": 150104 diff --git a/test/host/cfg_file/cfg_file_test.c b/test/host/cfg_file/cfg_file_test.c index 18bfffab..903ec523 100644 --- a/test/host/cfg_file/cfg_file_test.c +++ b/test/host/cfg_file/cfg_file_test.c @@ -66,6 +66,10 @@ * 基线保留 2s 重试、视图追平即自愈应用 + 回调;拒绝期间无回调无二次 ERROR * TC-CONFIG-060 读中改写(读后复 stat 令牌变)-> HELD 后重新沉降自愈; * 持续不一致:全程仅 1 ERROR + 1 留痕,WARN 每 30 趟一条(日志限速) + * TC-CONFIG-061 同尺寸全 0 旧映射块(长度/令牌双检全过)-> 内容判据拦下: + * HELD RAM 保旧、视图刷新后自愈应用 + 回调 + * TC-CONFIG-062 保存链遇同尺寸全 0 旧视图 -> 拒存 -1,文件字节/写次数不动, + * 视图恢复后同一保存重试成功落盘 * * 通过:全部 TC PASS(main 返回 0)。 * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 @@ -355,6 +359,15 @@ static void vfs_mark_stale(void) { s_view_stale = 1; } +/* 同尺寸全 0 旧视图(2026-11-05 台架 15:21 实证形态:dhara 旧映射块恰与 + * 目标同长,长度/再 stat 双检全过,只有内容判据能拦):read 返回与真实 + * 盘同长的全 0 块 */ +static void vfs_mark_stale_zeros(void) { + s_vfs_stale_len = s_vfs_len; + memset(s_vfs_stale, 0, s_vfs_stale_len); + s_view_stale = 1; +} + /* 视图追平盘上真身 = 卷层视图恢复(守卫自愈路径) */ static void vfs_clear_stale(void) { s_view_stale = 0; @@ -1210,6 +1223,62 @@ static void tc_poll(void) { } } +/* 内容层坏视图(2026-11-05 台架 15:21 实证形态:dhara 同尺寸全 0 旧映射块, + * 长度与再 stat 双检全过,仅内容判据可拦) */ +static void tc_view_content(void) { + printf("[TC-CONFIG-061] poll zero-block same-size stale view -> HELD + self-heal\n"); + { + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=42\r\n"); + s_net_data.u8 = 5u; /* RAM = 旧值:非默认(7)非新值(88) */ + cfg_file_poll(); /* 基线(台架时序:先基线后变更) */ + vfs_put("[net]\r\nu8=88\r\n"); /* 盘上真实变更(与旧视图同尺寸) */ + vfs_mark_stale_zeros(); /* 视图 = 与 stat 同长的全 0 块 */ + cfg_file_poll(); /* st 变:stable=1 */ + s_syslog_count = 0; + g_cfgtest_err_count = 0; + s_net_cb_count = 0; + cfg_file_poll(); /* stable=2 -> 长度/令牌双检过,内容判据拦下 */ + CHECK(s_net_data.u8 == 5u, "zero-block view held: RAM keeps old value"); + CHECK(g_cfgtest_err_count == 1, "zero-block first reject logs one ERROR"); + CHECK(s_syslog_count == 1, "zero-block first reject self-test logged"); + CHECK(s_net_cb_count == 0, "zero-block view never reaches callback"); + cfg_file_poll(); /* 视图仍旧 -> 持续 HELD 静默 */ + CHECK(g_cfgtest_err_count == 1, "zero-block repeats stay silent"); + CHECK(s_net_data.u8 == 5u, "zero-block view still never touches RAM"); + vfs_clear_stale(); /* 卷层视图恢复 */ + cfg_file_poll(); /* 重试 -> 一致视图 -> 应用 */ + CHECK(s_net_data.u8 == 88u, "self-heal: applied once zero view refreshed"); + CHECK(s_net_cb_count >= 1, "callback fires on healed apply"); + } + + printf("[TC-CONFIG-062] save on zero-block view -> abort, file untouched\n"); + { + int w_before; + size_t l_before; + + vfs_reset(); + register_std(); + vfs_put("[net]\r\nu8=88\r\n"); + s_net_data.u8 = 9u; /* 待保存的脏值 */ + vfs_mark_stale_zeros(); /* 保存链读到同长全 0 旧视图 */ + w_before = s_write_count; + l_before = s_vfs_len; + s_syslog_count = 0; + g_cfgtest_err_count = 0; + CHECK(cfg_file_save() == -1, "save aborts on zero-block view"); + CHECK(s_write_count == w_before, "no atomic write happened"); + CHECK(s_vfs_len == l_before, "file size untouched (no shrink)"); + CHECK(strcmp(vfs_get(), "[net]\r\nu8=88\r\n") == 0, "file bytes untouched"); + CHECK(s_syslog_count == 1, "save reject self-test logged"); + CHECK(g_cfgtest_err_count == 1, "save reject logs one ERROR"); + vfs_clear_stale(); /* 视图恢复 -> 同一保存链重试成功 */ + CHECK(cfg_file_save() == 0, "save succeeds once view converges"); + CHECK(strstr(vfs_get(), "u8=9") != NULL, "dirty value persisted on retry"); + } +} + int main(void) { printf("=== Config File Tests ===\n"); @@ -1225,6 +1294,7 @@ int main(void) { tc_load(); tc_save(); tc_poll(); + tc_view_content(); printf("\n=== Config File Tests: %d/%d PASSED (failed=%d) ===\n", s_passed, s_total, s_failed); From b66fcc4d87c3637116fb438bcf9f3c5fc6fe1aed Mon Sep 17 00:00:00 2001 From: super_agent_bot Date: Thu, 5 Nov 2026 17:27:34 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(cfg):=20HELD=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=A1=8C=E2=80=9C=E4=B8=A4=E6=95=B0=E7=9B=B8=E7=AD=89=E2=80=9D?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=B3=A8=E8=AE=B0=EF=BC=88=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E5=91=BD=E4=B8=AD=EF=BC=89=EF=BC=88#73?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 台架实录中出现 `poll: view 1064B != stat 1064B, HELD`:两数相等是 设计使然——同尺寸旧映射块命中内容判据(s_view_bad),并非空读/ 短读。首读该日志易误判为格式串错误,在 §5.3 守卫条目补一句语义 注记。纯文档改动,不影响代码指纹与体积门禁。 --- docs/子系统设计/配置文件设计说明.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/子系统设计/配置文件设计说明.md b/docs/子系统设计/配置文件设计说明.md index 58974a2e..b85eca17 100644 --- a/docs/子系统设计/配置文件设计说明.md +++ b/docs/子系统设计/配置文件设计说明.md @@ -300,7 +300,7 @@ sequenceDiagram - 检测粒度 2s(轮询周期);网络重建含等链路 ≤3s,总生效时间约 2~5s - 下载天然支持(RETR 即文件读) - **沉降去抖(D15-②)**:(size, mtime) 相对基线变化且**连续 2 次轮询(4s)稳定**才重载——防 FTP STOR 进行中的中间态触发多次重载(STOR 先写 `.TMP`,目标文件 (size, mtime) 在 rename 前不变,rename 单次原子更新 → 至多一次重载) -- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)+ **内容判据 `s_view_bad`**(INI UTF-8 文本约定下任何偏移都不得含 NUL 字节——空读、整块全 0、合法前缀+尾部零填充的"部分旧块"全部命中;解析器遇 NUL 断行,被零块吞掉的尾部内容本就不可达,拒之不误伤任何可解析文档)。台架 15:21 实证:**同尺寸整块全 0 旧映射块可骗过纯三点校验**(dhara 旧映射块恰与目标同长 1064B),解析零错误、RAM 照打回——内容判据是该形态的唯一引擎侧拦截。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速)。**边界**:内容合法但陈旧的整卷视图((size,mtime) 与内容三者皆一致滞后)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径 +- **视图一致性守卫(D15-②延伸,v1.17,issue #73 台架实证)**:沉降触发重载后校验 stat → read → 再 stat 三点一致(读长 == `st.size` 且读期间 (size, mtime) 不变)+ **内容判据 `s_view_bad`**(INI UTF-8 文本约定下任何偏移都不得含 NUL 字节——空读、整块全 0、合法前缀+尾部零填充的"部分旧块"全部命中;解析器遇 NUL 断行,被零块吞掉的尾部内容本就不可达,拒之不误伤任何可解析文档)。台架 15:21 实证:**同尺寸整块全 0 旧映射块可骗过纯三点校验**(dhara 旧映射块恰与目标同长 1064B),解析零错误、RAM 照打回——内容判据是该形态的唯一引擎侧拦截。不一致视图若照单应用 = 全段静默打回工厂默认(真机 8/8 复现、10.5h 对时沉默元凶),若照短视图外科保存 = 重写整档吞段(实测 1066B→986B)。拒绝语义:poll **不动 RAM、不刷基线**(2s 重试,视图收敛即自愈;持续到重启则 boot 全新挂载必然收敛);save **返回 -1**(调用方保留 dirty 标志重试)原文件一字节不动。首次拒绝 ERROR + 自检留痕(`cfg poll view stale` / `cfg save view stale`),poll 此后每 30 趟(≈60s)一条 WARN 计数行(限速);HELD 行打印的是“读长B != stat B”,两数相等即内容判据命中(同尺寸旧映射块)而非空读。**边界**:内容合法但陈旧的整卷视图((size,mtime) 与内容三者皆一致滞后)引擎层无法检出——属卷层(dhara/FTL)领域,重启收敛是最后防线;运行期"改过配置后重启一次"在卷层根治前始终是最稳口径 - **单写者(D15-③)**:`/config.ini` 的直接写者收敛为 defaultTask(UI 保存 = 请求投递至 defaultTask,由其落盘(tmp+rename)+ 应用(触发网络重建),复用既有 `SYS_EVT_NET_RECONFIG` 路径);唯一例外 = FTP STOR 路径(lftpd,tmp+rename,与 defaultTask 写路径经卷锁互斥) ### 5.4 线程安全(FatFS 可重入)