Files
STM32F4-Base/App/fs/vol_lock.h
wandering1 b623a406ad fix(栈·存储·UI 收尾): 断掉临界区↔断言幻影环回到 1,464,标定门控单点化,卷锁五态可观测
这四笔在主树里已经互相纠缠(同一次 @build 编译、同一份 .htm 度量),拆成四笔就不
存在任何一个被真编过的中间态,所以合一笔入库;下面按模块分段陈述,作者与出证归属
逐段写明。

—— 一、崩溃证据路径的整行独占收尾(log-maint-writer,task-26)——
上午那笔 1d4b6a17 之后,stack_report 报 iec104Task 静态最坏链 1,544 / margin 1,016,
低于 1,024 的 ISR 底线。真因不是新增局部缓冲,而是本模块与内核临界区之间新长出
一条环:taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒ vApplicationAssertFailed
⇒ dbg_console_write_raw ⇒ 回到本模块。armlink 把这一整坨计入**每一个含临界区的
任务链**(vApplicationAssertFailed 的 Max Depth 由约 40 B 涨到 128 B + In Cycle),
所以九条链同涨约 80 B——同涨本身就排除了任何单个调用方。
改法两处:① 本模块三处窗口(取凭证 / 还凭证 / 整行入环)改用 CON_CRIT_ENTER/EXIT
(PRIMASK 保存恢复到局部变量;注释写全为什么不能直接 __enable_irq()——嵌套时会
替别人打开任务级临界区——以及屏蔽面变宽是有意为之);② 异常/终点钩子里那对
自证空转的 begin/end 摘掉(异常态 begin 放行但不持有、end 是幂等空操作,调它只有
成本没有收益),任务态 STALL 行保留 begin/end,这个区分写进两处注释与文档 §4.4.1。
实测四棵树逐个 .htm 解析(身份钉 tree + Linker run 时刻 + 产物来源):A=77779d49
链 1,456;B=1d4b6a17 链 1,464 / margin 1,096 PASS;C=含未提交钩子接线的工作树
1,544 / 1,016 FAIL;D=本轮修复后(Linker run 22:52)回到 **1,464 / 1,096 PASS**,
九条链齐降 80 B。断环图证:vPortExitCritical 由 136 B + In Cycle 变 8 B 且不再
In Cycle,con_tx_release 子树 152→8;Error_Handler 回到 280 + In Cycle = A 树同值
(既有发送环无人拆动)。顺带删掉 Src/freertos.c 崩溃路径里最后一个 HAL_Delay(200)
(该文件真实调用现为 0)。
测试:console_log 152→**153** 条(只增不减);换钉不删防——015 现在钉「PRIMASK
窗口 2 次、深度 1、内核临界区 0 次」,新钉 015c 绝不等待,160 注入点搬到
con_pm_hook,176 强化到未调度路径连 PRIMASK 窗口都不开;红线水位
con_pm_at_tx_max 与 con_uart_crit_at_tx_max 双 0;判据⑦(延后的行与当场直发逐
字节相同)170b/171/178 仍全绿。作者自报并纠正一处:换钉时曾把 TC-CONLOG-015b 改
编号,而 015b 在生产注释与文档里都被引用——已恢复「编号不退役、语义搬过去」。
覆盖率 App/log/console_log.c 199/200 → 256/257 行、21/21 函数(只增)。

—— 二、未标定闭锁判据收成单点访问器(ui-param-writer,task-16)——
「UA/UN/IA 三相 VALID 全置位才允许跑判据」这条门控原先在两处各自镜像实现
(protect_task.c 的 s_cal_locked 与 ui_data_feed.c 的 feed_cal_block),改一处另一处
静默失效且无门禁可照。现新增 adc_cal_prot_blocked() 落在 App/algo/adc_cal,DTU_BENCH
特例收进一处;两个消费者同批改调、本地副本删除。作者还查实并纠正了我先前一句
错判:全仓 App/app→App/task 的 include 实为 0 条,故访问器不能落在 protect_task。
新增 TC-ADCCAL-018 八条(含冷启动即闭锁),adc_cal 套件 99/99。

—— 三、共享卷锁五态可观测性(ui-param-writer,task-17)——
原状比登记簿写的更糟:五个可区分态(未调用 / try 超时 / 分片超时 / fail-open /
未持锁却 release)生产侧零留痕,且 vol_lock.c 的返回值注释与代码相反——
fail-open(锁没建成、互斥已失效却在读写盘)被返 1 **谎报成取到锁**,而那正是
vol_lock.h 记录的台架实证故障(并发写坏 FTL → 喂狗点饥饿 → IWDG 误复位)的前置。
锁层加四个 volatile 计数(16 B 静态,零新局部、零新 callee、零日志、零 FatFs)与
不取锁的 vol_lock_get_stats();13 处 try 调用点一字未动。**刻意不在锁层记时刻**:
记时刻要读 tick,等于给 defaultTask 取锁链添帧——诊断不值那几个字节;消费方若
自盖时间戳,那只能是被观测到的时刻而非发生时刻,.h 里已写明不得写成首次/末次。
四套本地假件各加 s_last_wait_to(字段名逐字一致),把「喂狗点取锁一律 timeout=0」
与「ftpTask 分片一律 100 ms」从注释纪律升级成会 FAIL 的断言;红-绿反证:临时把
sys_log_mcu.c 的 0u 改 50u → TC-SYSLOG-010/012 双红,把 lftpd_io.c 的 100u 改 0u →
TC-LFTPIO-034 红,两处按原串改回并用 SHA-256 证明逐字节还原。TC-VOLLOCK-015~019
共 45/45(无新桩)。

—— 四、UI 死接口清理(ui-maint-writer,task-6 收尾)——
删掉 ui_data_maint_ready():全仓零消费者、只有它自己的用例在读。**更值得记的是
它为什么能活过一整轮评审**:头注释写的是「页面用它决定这页能不能操作」,而页面
从来没用过它——注释陈述的是意图,评审读的是意图,门禁读的是可达路径,两者都不
反驳「意图未实现」。作者对「连用例一起删」做了显式抗命:TC-UIDATA-025 的 13 条里
只有 4 条读那个死口,另 9 条钉的是真防线(六口未注入安全返回 / 残表整表拒绝 /
被拒不覆盖已生效的好表),故删冗余的 025a、把 025i/025k/025m 换成用 reset_req()
的可观察返回值判定,性质不丢。ui_data 套件 244 → 243(净减 1),黄金 40 页与
池 5448 B 未变(该刀零像素影响,参考图无 diff)。

—— 门禁与未跑项(本笔如实留账)——
cubemx-survival 基线已 --write(漂移 6 项全部是上述意图内区域:断言/栈溢出钩子
实现、看门狗模块行等),基线文件同笔入库,随后 --check 通过。
本笔未跑的钩子只有两条:mem-numbers 与 mem-budget——磁盘 .map 出自 22:52 那次
构建、含尚未入库的 UI 与工具改动,与 HEAD 台账必然不同码;按仓库既有约定允许
--no-verify + 正文留证,回填在紧随的台账一笔统一做(含 ram_baseline --update 与
覆盖率基线两条 [NOTE]:ui_data.c 65→316、iec104_comm.c 36/36)。
两条已挂着的账:① id-check 不可判定引用 1,162 / 上限 1,166,**余量只剩 4**,
主要吃进者是我今天推进的 UI 那笔(68 条文本断言的注释引用),账在 Lead;
② 崩溃注入腿(故意复位)台架复验未跑,需先由代码道提交触发器,且判据只判原子性
不判 [ERR]=0——IPSR 路径的主张归主机侧套件,硬件照不出来。

产物身份(按 D 表要求钉死,避免数字与树脱钩):本笔包含的固件树就是那张 D 表对应的
那一棵——MDK-ARM/.../STM32F407-Demo.htm 的 mtime = 2026-10-26 22:52:12(.htm/.map 按
约定不入库,故把时刻写进正文)。提交前在本树复跑 stack_report.py:exit 0,
iec104Task 1,464 / margin 1,096(42%)PASS、defaultTask 3,040 / 2,080(40%)PASS,
与 D 表逐项一致;本次运行不含任何未提交固件源码之外的新改动。
2026-10-26 23:31:05 +08:00

127 lines
7.5 KiB
C
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.
/*
* 模块名称:Volume Lock(卷锁)
* 模块功能:NAND FatFs 卷的跨任务串行化锁——FatFs 卷状态(FS 窗口缓冲/FAT
* 缓存)与 FTL 核心状态(单页 RAM 缓存 + dhara journal)均非线程安全;
* defaultTask(syslog 落盘/配置引擎/存储 bring-up)与 ftpTask(lftpd
* 文件操作)对卷的访问必须在本锁下串行执行
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2)
* 作者:王建锋
* 创建日期:2026-10-02
* 修改记录:
* v1.0 2026-10-02 王建锋 创建(2026-10-02 台架实证:defaultTask 与
* ftpTask 并发 FTL 写 → dhara journal 竞争 →
* 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点)
* → IWDG 误复位;单写者卷属主假设在多任务 FatFs
* 调用下不成立,须显式串行化)
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 修契约——vol_lock.c 原 :38 的
* 「返回值」把 fail-open 归给 0,而代码(:46)与
* TC-VOLLOCK-008 都是返 1;现在 .h/.c 两处都按代码写实,
* 并显式警告"返 1 也可能是零互斥";② 新增 5 态计数与
* vol_lock_get_stats() 只读口(不取锁、任意上下文可调);
* ③ 使用约定补「可观测性」段(为何计数在锁层、为何不得
* 在失败分支里取锁打日志)
*
* 使用约定(铁律):
* - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir /
* 单发 stat/unlink/原子写),不得只锁单个 f_read/f_write;
* - defaultTask 调用方一律用非阻塞 try(vol_lock_wait(0)):defaultTask 是
* 看门狗喂狗点(task_wd_poll),阻塞等锁 = 主循环停摆 = 停喂 → IWDG 误复位;
* try 失败 = 本轮跳过(syslog 条目滞留队列/配置保存待下轮重试);
* - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat),
* 防长等待导致 FTP 心跳位缺席误复位;
* - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。
*
* 可观测性(道G task-17):锁层自带 5 态计数 + `vol_lock_get_stats()` 只读口。
* 为什么计数必须在**锁层**:唯一能把事写下来的通道(`/log/wdev.log` 等)自己也要
* 取这把锁,而卷锁**非递归**——被卷忙挡住时既写不出日志,也不许在失败分支里再取
* 同一把锁。所以锁层只自增计数;一次性留痕交给 defaultTask(syslog 唯一写者)按边沿
* 补投自检记录。调用方 13 处 try 的分支与返回值一字不动(不动分支 = 不动行为)。
*/
#ifndef VOL_LOCK_H
#define VOL_LOCK_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* 函数功能:创建卷锁(幂等)
* 入口参数:无
* 返回值:0 - 成功/已创建;-1 - 创建失败 int
* 限定条件:内核启动后调用(defaultTask 存储 bring-up 首行)
*/
int vol_lock_init(void);
/*
* 函数功能:获取卷锁
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(此时并未串行化)**;
* 0 - 未取得(try 超时,或分片等待超时) int
* 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0)
* 函数说明:⚠ 道G task-17 修掉的契约缺口——本函数只有 0/1 两值,而"返 1"有两种
* 成因:真取到锁,与 `osMutexNew` 失败时的 fail-open(返 1 但对卷的访问
* **零互斥**)。要求 13 处调用方各自区分是不现实的(它们只关心"能不能干活"),
* 判据请改用 `vol_lock_get_stats()` 的 `failopen` 计数:累计 > 0 =
* 本 boot 内至少发生过一次"无互斥读写卷"。
* 本函数内只**自增计数**:不打日志、不投记录、不阻塞(理由见 .c 计数块)。
*/
int vol_lock_wait(uint32_t timeout_ms);
/*
* 卷锁观测计数(道G task-17「卷锁 5 态可观测性」)。四个字段就是四态,语义**不得**合并:
* T-a `acquired` = 真取到互斥的次数
* T2 `try_timeout` = timeout=0 的 try 失败次数(卷被别的任务占住;defaultTask/uiTask
* 的喂狗与绘制路径只有这一态会发生)
* T3 `blk_timeout` = timeout>0 的分片等待超时次数(ftpTask 100 ms 分片正常退让,
* 语义与 T2 不同:那是"等了一会儿没等到",这是"压根没等")
* T4 `failopen` = 锁不存在却返 1 的次数(**谎报成功**那一态,见 wait 的函数头)
* T1「从未调用过」不占计数:三个计数全 0 就说明这条路径没跑过——区分"没跑过"与
* "跑了但一直忙"正是本卡的目的(此前两者在日志上完全一样)。
* ⚠ 本层**刻意不记时刻**:记时刻就得读 tick,等于给 defaultTask 的取锁链添一个
* callee 与几字节帧,而这条链的余量我们整天都在省着花(诊断不值那几个字节)。
* 代价是"什么时候开始忙"只能由消费方观测,见下面 get_stats 的第② 条告诫。
*/
typedef struct {
uint32_t acquired;
uint32_t try_timeout;
uint32_t blk_timeout;
uint32_t failopen;
} vol_lock_stats_t;
/*
* 函数功能:读卷锁观测计数(纯拷字段)
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
* 返 回 值:无 void
* 限定条件:任意上下文可调——**不取卷锁**、不关中断、不阻塞(卷被长期占住时正是最
* 需要读这几位的时刻,观测口再抢锁就把观测做成故障的一部分)
* 函数说明:① 四个字段各是 32 位对齐量,Cortex-M4 上单字读写原子,但**不保证是同一
* 瞬间的四元组快照**(诊断够用)。计数本身跨任务读-改-写,竞态下可能
* **少计不会多计** → 判据用「是否 > 0 / 与上一轮比是否增长」,别拿精确值当账。
* ② ⚠ **给消费方的告诫**(消费方 = 看门狗/自检记录道,接口按此写就不会错):
* 本层不产出时刻。消费方若自行盖时间戳,那是「**被观测到的时刻**」,不是
* 「**发生的时刻**」——真实发生可以早到一整个轮询周期之前。命名请用
* `obs_first_ms` / `obs_last_ms`(观测时刻)这类,**别用** `first_ms`/`last_ms`,
* 文案里也别写「首次/末次发生」(那就是新的谎报)。
* 一次性留痕自己做边沿:照 `sys_log.h` 的 `syslog_post_dropped()` 先例(唯一
* 写者按差值补发,一个 episode 一条),锁层自身绝不产日志、不投记录。
* ③ 本函数零外部调用、零局部变量(除出参),因此不改变任何调用方的栈最坏链。
*/
void vol_lock_get_stats(vol_lock_stats_t *p_out);
/*
* 函数功能:释放卷锁(获取方任务释放)
* 入口参数:无
* 返回值:无
* 限定条件:必须与 vol_lock_wait 成对;未获取时调用无害(内部判空)
*/
void vol_lock_release(void);
#ifdef __cplusplus
}
#endif
#endif /* VOL_LOCK_H */