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 表逐项一致;本次运行不含任何未提交固件源码之外的新改动。
This commit is contained in:
2026-10-26 23:31:05 +08:00
parent 52e28885cb
commit b623a406ad
27 changed files with 703 additions and 150 deletions
+6 -6
View File
@@ -1,19 +1,19 @@
{
"snapshot": {
"iwdg-defer-start": "ca839ffc1cda439f",
"hardfault-handler": "f3afab8d2be18794",
"fault-handlers-3": "f3afab8d2be18794",
"hardfault-handler": "a7f538eccdf1d88d",
"fault-handlers-3": "a7f538eccdf1d88d",
"fault-handler-enable": "be87fcd3a7926138",
"hardfault-report-impl": "f3afab8d2be18794",
"hardfault-report-impl": "a7f538eccdf1d88d",
"error-handler-degrade": "d02a92afda945c4b",
"stack-overflow-check": "d4735e3a265e16ee",
"malloc-failed-hook": "6b86b273ff34fce1",
"assert-not-deadloop": "d4a00718ab2bd071",
"assert-impl": "5d862ceb21c77a86",
"stack-overflow-hook-impl": "a006edbd30d2c4d7",
"assert-impl": "0ccf10a7fb115aa8",
"stack-overflow-hook-impl": "03a383dda578d12a",
"malloc-failed-hook-impl": "fcd7250e5cd6403b",
"stack-overflow-hook-helper": "cacd9fd028be9b5b",
"watchdog-module": "2bb051d572be4149",
"watchdog-module": "07808632996d66e8",
"net-msgq-adopt": "a0f913e3f34c8e9e",
"reset-cause-latch": "983927d587cb9f48",
"iwdg-hal-driver": "4b902875a84554e9",
+29
View File
@@ -22,6 +22,11 @@
* 读取口;set() 授权写者扩至 iec104Task 直通路径
* (run_par 0x8040~/0x8048~,契约同 §4.4)
* + 合法带哨兵回退 + 两点标定公式(台架注入待跑)
* v1.2 2026-10-26 DTU 项目组 道F task-16:新增 adc_cal_prot_blocked()——未标定
* 闭锁(UA/UN/IA 三路 VALID 全齐才放行)收成本层单点,
* DTU_BENCH 旁路同时收成全仓唯一一处;此前该判据在
* protect_task.c 与 ui_data_feed.c 各写一份,两处一旦
* 漂移,屏上「未标定」与判据实际停走就不是同一件事
*/
/* 头文件包含区 */
@@ -280,6 +285,30 @@ uint32_t adc_cal_flags(adc_semantic_t sem) {
return f;
}
/*
* 函数功能:未标定闭锁判定(判据所需的 UA/UN/IA 三路是否齐)
* 返 回 值:1 - 闭锁;0 - 放行 uint8_t(契约、三路集合的理由、判据停走的四路
* 来源、两个同族谓词的边界都写在 adc_cal.h,本处不重述——重述就是第二真源)
* 函数说明:**现查 flags(),本函数不留任何状态**。刻意不复用保护任务里那份
* 「上拍闭锁态」——那是给边沿日志用的,开机首拍前恒 0;拿它当判据等于在
* 「还没判过」的时候谎称「已标定」,而这正是本谓词最贵的失效(屏上
* 「未标定」徽标一消失,现场读到的就是「改了定值就会动作」)。
* DTU_BENCH 旁路留在这里、且全仓只此一处;用 #ifndef 把产品默认路径留在
* 主分支——#ifdef 双分支会让 cppcheck 对未定义宏取真分支、把静态分析引到
* 旁路上报 knownConditionTrueFalse 假信号(实测 2026-10-25,这条形态说明原先
* 写在 protect_task.c 的实现处,随判据一起搬到这里)
*/
uint8_t adc_cal_prot_blocked(void) {
#ifndef DTU_BENCH
if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) {
return 1u;
}
#endif
return 0u;
}
/*
* 函数功能:读通道当前生效系数(µV/码,回退后口径)
* 入口参数:sem - 语义通道 adc_semantic_t
+38
View File
@@ -9,6 +9,9 @@
* 三态可用性:ADC_CAL_F_VALID(已按变比标定 → 工程量为二次值)与
* ADC_CAL_F_CLIPPED(V0.1 硬件削顶)由 adc_cal_flags() 单点查询,
* 104 / UI / 保护闭锁(卡 5)共用(docs/任务规划/数据可信.md §4.2)。
* **未标定闭锁的判定本身也归本层**(`adc_cal_prot_blocked()`):它讲的是
* 「这几路标定齐了没」,属标定层事实,不是任务编排逻辑——保护任务与 UI
* 组装器都只是它的消费者(道F task-16 把此前的两份实现收成一份)。
* ⚠ 输出单位口径 = **ADC 引脚电压 V**(eng=0):ROM 默认系数按卡 1
* 定死的引脚侧 LSB(TPAFE 152.59 µV/码、STM32 805.66 µV/码,见
* docs/硬件平台/ADC通道映射说明.md §7.1);端子变比(卡 0b)与两点
@@ -24,6 +27,10 @@
* 修改记录:
* v1.0 2026-10-25 DTU 项目组 创建:Q16 系数/零点 + 折算/flags/集
* v1.1 2026-10-25 DTU 项目组 卡 10:+adc_cal_b_codes()(run_par 回显/RMW 用)
* v1.2 2026-10-26 DTU 项目组 道F task-16:+adc_cal_prot_blocked()——「UA/UN/IA
* 三路 VALID 是否齐」这份判据停走条件从
* protect_task.c 与 ui_data_feed.c 的两份重复实现收成本层
* 单点(DTU_BENCH 旁路同时收成一处)
*/
#ifndef ADC_CAL_H
@@ -91,6 +98,37 @@ float adc_cal_apply(adc_semantic_t sem, int32_t code);
*/
uint32_t adc_cal_flags(adc_semantic_t sem);
/*
* 函数功能:**未标定闭锁判定**——保护判据所需的三路标定是否齐(「数据可信」卡 5
* / §4.3;道F task-16 把它从两处重复实现收敛成这一份真源)
* 入口参数:无
* 返 回 值:1 - 闭锁(UA/UN/IA 中至少一路 `ADC_CAL_F_VALID` 未置位,判据链应停走);
* 0 - 三路全 VALID(标定侧允许判据跑) uint8_t
* 限定条件:任意上下文(纯读 `adc_cal_flags()`,与它同一条并发契约:int32 对齐
* 字读写原子、cfg poll 重载 µs 级混窗设计接受);**现查,本层不留任何状态**
* 函数说明:① **为什么恰好这三路**:V0.1 把 8 路电气量分给两片转换器(AGENTS
* 「双转换器无共同采样触发」),UA/UN/IA = TPAFE 侧这三路是**唯一有效**
* 的测量通道;UB/UC/IB/IC/IN 即使将来标定也另受 `CLIPPED` 品质约束
* (M5 快照层处理),不掺进本闭锁源。
* ② ⚠ **不得当「保护在跑」的总括用**:判据停走共四路来源,本函数只覆盖
* 第二路——(a) L5-4 采样故障闭锁(`adc_health_flags()`);(b) 本函数
* (未标定);(c) 定值校验和失配闭锁(`prot_set_locked()`);(d) 全局软压板
* 退出(`PS_GLOBAL_EN` = 0)。要判「这拍到底评估了没有」必须四路一起看
* (编排顺序见 `App/task/protect_task.c`)。
* ③ ⚠ **两个同族谓词与它不是一件事,不要顺手合并**(三种口径各有用途):
* · 电气 **8** 路全 VALID 才升 eng=二次值(量纲标签,判据在
* `App/app/ui_data_feed.c` 的 `feed_eng()`)——并进来会让部分标定的整帧
* 被误标二次值;反过来把本三路口径当 8 路用,V0.1 下 UB/UC/IB/IC/IN
* 永不置位 → 徽标与停走**永不解除**;
* · 逐通道打印 VALID/CLIPPED 位图是诊断输出(`App/task/adc_task.c`),
* 不是判定。
* ④ 台架调试旁路 = 编译期 `-DDTU_BENCH`(「数据可信」§4.3 决策 1 的例外通道,
* 仅本地台架自行定义;本仓任何工程档都不定义 = 产品恒强制)。旁路**只在
* 本函数实现内部这一处**,调用方不再各自写 `#ifndef`:否则有人只给单个
* TU 加宏,屏上「未标定」徽标与判据停走当场分叉,且 0 错误 0 警告。
*/
uint8_t adc_cal_prot_blocked(void);
/*
* 函数功能:读通道当前生效系数(µV/码,浮点)
* 入口参数:sem - 语义通道 adc_semantic_t
+17 -32
View File
@@ -24,6 +24,13 @@
* (定值写路径出队 → 闭锁/越界预检 → set_sealed,
* 见 s_param_ops);② 参数帧尾填 cal_block(未标定
* 闭锁徽标,三路口径同 protect_task.c,不用 eng)
* v1.2 2026-10-26 DTU 项目组 道F task-16:删除本地镜像 feed_cal_block(),
* 两个调用点(填帧 / 门控比较)改调
* adc_cal_prot_blocked()——同一份三路判定此前在
* 本文件与 protect_task.c 各写一遍,且各自带一份
* #ifndef DTU_BENCH(只给单个 TU 加宏就能让屏与判据
* 分叉);真源现落在 App/algo/adc_cal.c,旁路全仓一处。
* 行为不变(判定式逐字同构、调用时机不变)
*/
/* 头文件包含区 */
@@ -48,7 +55,7 @@
/* 同层(App)数据源 */
#include "adc_input.h" /* ADC_SEM_COUNT / adc_input_raw(TEMP 码值) */
#include "adc_cal.h" /* adc_cal_flags:VALID/CLIPPED 三态可用性 */
#include "adc_cal.h" /* adc_cal_flags:VALID/CLIPPED 三态可用性;adc_cal_prot_blocked:未标定闭锁单点 */
#include "phasor.h" /* 基波/谐波有效值(fAmSQ) */
#include "freq.h" /* 测频读数与新鲜度 */
#include "prot_elm.h" /* 元件动作态(遥信位图来源) */
@@ -469,33 +476,6 @@ static const ui_data_param_ops_t s_param_ops = {
feed_param_apply
};
/*
* 函数功能:未标定闭锁判定(整定页"未标定"徽标的数据源)
* 入口参数:无
* 返 回 值:1 - 未标定闭锁(UA/UN/IA 三路至少一路标定 VALID 未置位);0 - 三路全
* VALID(保护判据正在跑) uint8_t
* 限定条件:uiTask 上下文(adc_cal_flags 纯读,每 200 ms 一次,代价可忽略)
* 函数说明:判据与 App/task/protect_task.c 的 s_cal_locked() **逐字同构**——那是
* 停走判据的真源,本函数只是把它搬到显示通道上;两处的例外通道也必须
* 一起改(DTU_BENCH 台架旁路,仅本地台架自行定义、产品恒强制)。
* ⚠ 已知重复:本仓没有导出该判据的公开 API(protect_task.h 只有线程
* 入口),故这里镜像一份;将来 protect_task 导出访问器后应收敛成一处
* (Lead 补充 A 明确本任务按此实现,不改 protect_task.*)
* ⚠ 不得用 rt.eng 反推:eng 要求电气 8 路全 VALID,V0.1 双转换器下
* UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照它画徽标就永不解锁
*/
static uint8_t feed_cal_block(void) {
/* 形态与 protect_task.c 同:用 #ifndef 把产品默认路径留在主分支(而非 #ifdef
* 双分支)——cppcheck 对未定义宏取真分支,双分支会把它引到旁路上报假信号 */
#ifndef DTU_BENCH
if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) {
return 1u;
}
#endif
return 0u;
}
/*
* 函数功能:组装参数快照(40 项定值 Q10 + 校验和 + 封存态 + 越界位图 + 未标定闭锁)
@@ -517,9 +497,14 @@ static void feed_fill_par(ui_data_par_t *p_snap, uint32_t sum) {
/* 越界位图非 0 = 上一次整表装载/提交有越界项(prot_set_bad_mask 的既有口径),
* 页面据此反显;为 0 时无意义位图不参与显示 */
p_snap->bad_valid = (p_snap->bad_mask != 0u) ? 1u : 0u;
/* v2 尾追加:未标定闭锁徽标(三路口径,与判据停走同源;与上面的 locked 是
* 两件事——locked 禁编辑,cal_block 只说明"改了也不会动作") */
p_snap->cal_block = feed_cal_block();
/* v2 尾追加:未标定闭锁徽标(与判据停走**同一个判定**:App/algo/adc_cal.c 的
* adc_cal_prot_blocked(),道F task-16 起本文件不再自带一份三路判定——两份实现
* 可以各自漂移,屏上"未标定"就会与判据实际停不走同一件事)。
* ⚠ 不要改回用 rt.eng 反推:eng 要求电气 **8** 路全 VALID,V0.1 双转换器下
* UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照它画徽标就永不解锁;
* 两个谓词各有用途,边界写在 adc_cal.h 的函数头第③条。
* ⚠ 与上面的 locked 是两件事——locked 禁编辑,cal_block 只说明"改了也不会动作" */
p_snap->cal_block = adc_cal_prot_blocked();
for (i = 0u; i < (uint8_t)PS_ID_COUNT; i++) {
p_snap->q10[i] = prot_set_get((prot_set_id_t)i);
}
@@ -617,7 +602,7 @@ void ui_data_feed_tick(void) {
* 代价:每轮多两次字节比较(无变化时零拷贝),换来屏上状态与真值同拍 */
{
uint8_t locked_now = prot_set_locked();
uint8_t cal_now = feed_cal_block();
uint8_t cal_now = adc_cal_prot_blocked(); /* 标定层单点,与判据同源 */
if ((s_par_sum_valid == 0u) || (sum != s_par_sum)
|| (locked_now != s_par_locked) || (cal_now != s_par_cal_block)) {
+61 -2
View File
@@ -6,6 +6,13 @@
* 创建日期:2026-10-02
* 修改记录:
* v1.0 2026-10-02 王建锋 创建
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 新增 4 个观测计数(T-a/T2/T3/T4,
* 共 16 B 静态)与 vol_lock_get_stats() 只读口 —— 不取锁、
* 零外部调用、零局部变量,13 处调用点一字未动;
* ② 修 :38「返回值」注释与代码相反的错(fail-open 原被
* 归给 0,实际返 1);③ 刻意不记时刻:时刻要读 tick,
* 等于给 defaultTask 取锁链添帧,改由消费方观测并自盖
* (命名 obs_first_ms/obs_last_ms,见 vol_lock.h 告诫 ②)
*/
/* 头文件包含区 */
@@ -16,6 +23,22 @@
/* 静态变量定义区 */
static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */
/* 观测计数(道G task-17「卷锁 5 态可观测性」)—— 本模块唯一的新增状态,共 16 B。
* 为什么计数必须在锁层、而且只有计数没有别的:
* ① 13 处调用点各自 return、历史上零痕;在每个调用点各写一份 = 13 份真源。
* ② 不能在这里打日志或投记录:唯一能落盘的通道(/log/wdev.log、run.log)自己也要
* 取这把卷锁,而卷锁**非递归** —— 被卷忙挡住时既写不出东西,还会自取死锁。
* ③ 也不读 tick 记时刻:那会给 defaultTask 的取锁链添一个 callee 与几字节帧,而
* 这条链的栈余量正是要省着花的;"什么时候开始忙"交给消费方观测(口径见 vol_lock.h
* 的告诫 ②:观测时刻不等于发生时刻,别写成首次发生)。
* 并发:多个任务可同时自增同一个计数,竞态下少计不会多计(判据是"是否 > 0 / 是否还
* 在增长",不是精确值),所以不为此上临界区 —— 上临界区反倒把观测做成了故障本身。
* volatile:跨任务读写且无锁保护,禁止把读侧重复读优化掉。 */
static volatile uint32_t s_st_acquired; /* T-a 真取到互斥 */
static volatile uint32_t s_st_try_timeout; /* T2 try(timeout=0)失败:卷被别人占住 */
static volatile uint32_t s_st_blk_timeout; /* T3 分片等待超时:ftpTask 正常退让 */
static volatile uint32_t s_st_failopen; /* T4 锁不存在却返 1:谎报成功那一态 */
/* 函数定义区 */
/*
@@ -35,18 +58,35 @@ int vol_lock_init(void) {
/*
* 函数功能:获取卷锁(首次调用自动 vol_lock_init)
* 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t
* 返回值:1 - 已持锁;0 - 超时/未取得,或锁创建失败(fail-open) int
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(返 1 但此刻并未串行化)**;
* 0 - 未取得(timeout=0 的 try 失败,或 timeout>0 的分片等待超时) int
* 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证
* 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞
* 函数说明:⚠ 原 v1.0 这行「返回值」把 fail-open 归给 0,与本函数 :46 的代码和
* TC-VOLLOCK-008 都相反(道G task-17 修正)。"返 1"有两种成因,调用方
* 无需各自分辨(它们只关心能不能干活),但整机的判据必须看
* vol_lock_get_stats().failopen —— 累计 > 0 = 本 boot 至少发生过一次
* "无互斥读写卷"。本函数只自增计数:不打日志、不投记录、不读 tick、
* 不新增局部变量(理由见上面计数块)。
*/
int vol_lock_wait(uint32_t timeout_ms) {
if (s_lock == NULL) {
(void)vol_lock_init();
if (s_lock == NULL) {
s_st_failopen++; /* T4:返 1 但零互斥 —— 谎报成功那一态 */
return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */
}
}
return (osMutexWait(s_lock, timeout_ms) == osOK) ? 1 : 0;
if (osMutexWait(s_lock, timeout_ms) == osOK) {
s_st_acquired++; /* T-a:真持锁 */
return 1;
}
if (timeout_ms == 0u) {
s_st_try_timeout++; /* T2:压根没等(喂狗点/绘制路径只有这一态会发生) */
} else {
s_st_blk_timeout++; /* T3:等了一会儿没等到(分片退让),语义与 T2 不同 */
}
return 0;
}
/*
@@ -60,3 +100,22 @@ void vol_lock_release(void) {
(void)osMutexRelease(s_lock);
}
}
/*
* 函数功能:读卷锁观测计数(契约与消费方告诫见 vol_lock.h 的函数头)
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
* 返 回 值:无 void
* 限定条件:不取卷锁、不关中断、不阻塞;**零外部调用、零局部变量**,因此不改变
* 任何调用方的栈最坏链(这条是本卡选型的结论,不要"顺手优化"掉)
* 函数说明:逐字段拷而非整体赋值 —— 每个字段都是某个真实值,不会读到半更新的字;
* 四元组之间不保证同一瞬间(诊断够用,理由见 .h 的告诫 ①)
*/
void vol_lock_get_stats(vol_lock_stats_t *p_out) {
if (p_out == NULL) {
return;
}
p_out->acquired = s_st_acquired;
p_out->try_timeout = s_st_try_timeout;
p_out->blk_timeout = s_st_blk_timeout;
p_out->failopen = s_st_failopen;
}
+62 -1
View File
@@ -13,6 +13,13 @@
* 长耗时恢复旋转饥饿 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 /
@@ -23,6 +30,12 @@
* - 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
@@ -45,11 +58,59 @@ int vol_lock_init(void);
/*
* 函数功能:获取卷锁
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
* 返回值:1 - 已获取;0 - 超时未获取(卷被占用) int
* 返回值: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);
/*
* 函数功能:释放卷锁(获取方任务释放)
* 入口参数:无
+48 -6
View File
@@ -138,6 +138,41 @@ static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护
* ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见
* con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给
* 启动窗口塞进 7.8 ms 的中断延迟与最坏喂狗抖动,比交错本身更糟)。 */
/*
* 本模块"只有几条指令"的关中断窗口(道G task-26 收口时定,2026-10-26)
* ---------------------------------------------------------------------
* 刻意**不用** taskENTER_CRITICAL()/taskEXIT_CRITICAL(),改用 CMSIS 的 PRIMASK
* 保存 + 恢复。理由是一条只在**静态调用图**上看得见、真机上却很贵的坑:
* taskEXIT_CRITICAL() => vPortExitCritical() => configASSERT()
* => vApplicationAssertFailed() (本仓断言钩子自 2026-09-29 起就调
* dbg_console_write_raw 上报 file:line)
* => 又回到本模块 => 又进临界区
* armlink 因此在本模块周围长出三条互递归环(con_tx_try_claim => vPortExitCritical、
* dbg_console_write_raw => con_tx_release、con_ring_try => vPortExitCritical),并把
* 这坨算进**每一条含临界区的任务链**:实测 vApplicationAssertFailed 的 Max Depth 从
* 约 40 B 变 128 B + In Cycle,defaultTask 3,040->3,120、netTask 1,920->2,000、
* iec104Task 1,464->1,544 —— 三条同涨 80 B 就是"加深在共享帧上"的铁证,iec104 的
* margin 因此掉到 1,016 < 1,024 ISR 底线。
* 换成 PRIMASK 之后:
* ① 三个原语都是 core_cmFunc.h 里的静态内联 —— 零调用帧、零断言,那三条环不成立;
* ② **屏蔽面变宽是有意的**:本 port(RVDS/ARM_CM4F)的临界区走 BASEPRI,只挡
* 优先级数值 >= configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,PRIMASK 连更高
* 优先级的也一起挡(NMI 与 HardFault 除外)。这是**语义变强**,不是变弱;窗口
* 只有几条指令(一次 test-and-set / 一次清属主 / 一次整行入环拷贝,与换之前
* 同宽度同时长),代价是几微秒级的额外屏蔽,读代码的人必须知道这一点。
* ③ 不依赖调度器与 uxCriticalNesting,未注册期与中断里同样可用。
* ⚠ **为什么必须把 PRIMASK 存进局部变量、退出时原样写回,而不是直接 __enable_irq()**:
* 本模块的窗口可能嵌套在调用者已有的临界区里(断言/故障钩子、写者 drain 轮内的
* 留痕行都是)。无条件开中断 = **替别人把任务级临界区提前打开**,那是比交错严重
* 一个量级的竞态;保存/恢复则天然支持嵌套。保存量放局部而不是共享静态量,是因为
* 窗口可能被更高优先级上下文再次进入,共享量会被它改写、退出时就恢复错了。
* ⚠ 只允许包住"几条指令"的量级,**绝不允许**包住串口发送本身:一行 90 B @115200
* 约 7.8 ms,把它关进中断等于给启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动
* (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b/177)。
*/
#define CON_CRIT_ENTER(pm_var) do { (pm_var) = __get_PRIMASK(); __disable_irq(); } while (0)
#define CON_CRIT_EXIT(pm_var) do { __set_PRIMASK(pm_var); } while (0)
static volatile TaskHandle_t s_con_tx_owner;
static volatile uint8_t s_con_tx_depth; /* 同一任务重入深度(drain 轮内的裸发留痕行) */
@@ -277,6 +312,7 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
*/
static uint8_t con_tx_try_claim(void) {
TaskHandle_t me;
uint32_t pm;
uint8_t got = 0U;
if (__get_IPSR() != 0U) {
@@ -292,13 +328,13 @@ static uint8_t con_tx_try_claim(void) {
}
return (uint8_t)CON_TX_HELD;
}
taskENTER_CRITICAL();
CON_CRIT_ENTER(pm); /* 窗口选型与嵌套语义理由见 CON_CRIT_ENTER 定义处 */
if (s_con_tx_owner == NULL) {
s_con_tx_owner = me;
s_con_tx_depth = 1U;
got = 1U;
}
taskEXIT_CRITICAL();
CON_CRIT_EXIT(pm);
return (got != 0U) ? (uint8_t)CON_TX_HELD : (uint8_t)CON_TX_NO;
}
@@ -314,6 +350,7 @@ static uint8_t con_tx_try_claim(void) {
*/
static void con_tx_release(uint8_t held) {
TaskHandle_t me;
uint32_t pm;
if (held != (uint8_t)CON_TX_HELD) {
return;
@@ -326,12 +363,12 @@ static void con_tx_release(uint8_t held) {
s_con_tx_depth--;
return;
}
taskENTER_CRITICAL();
CON_CRIT_ENTER(pm);
if (s_con_tx_owner == me) {
s_con_tx_owner = NULL;
s_con_tx_depth = 0U;
}
taskEXIT_CRITICAL();
CON_CRIT_EXIT(pm);
}
/*
@@ -606,9 +643,14 @@ void dbg_console_drain(void) {
*/
static uint8_t con_ring_try(const char *p_buf, uint16_t len)
{
uint32_t pm;
uint8_t fitted = 0U;
taskENTER_CRITICAL();
/* 窗口用 PRIMASK 而不是 taskENTER_CRITICAL():后者会经 vPortExitCritical 里的
* configASSERT 接回断言钩子,再回到本模块,静态调用图上长出一条环,armlink 把它
* 整条记进**每一条含临界区的任务链**(实测三条链同涨 80 B)。理由全文见
* CON_CRIT_ENTER 定义处。屏蔽时长与换之前一致(就是这一次整行拷贝)。 */
CON_CRIT_ENTER(pm);
if ((uint16_t)((DBG_CON_RING_SIZE - 1U) - con_used()) >= len) {
uint16_t head = s_con_head;
uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head);
@@ -627,7 +669,7 @@ static uint8_t con_ring_try(const char *p_buf, uint16_t len)
s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U));
fitted = 1U;
}
taskEXIT_CRITICAL();
CON_CRIT_EXIT(pm);
return fitted;
}
+15 -30
View File
@@ -16,8 +16,11 @@
* [ERR](未标定是出厂常态、不是故障;每次开机都走这条沿,
* [ERR] 还会误触 @test 禁用标记);台架旁路只走编译期宏
* DTU_BENCH(「数据可信」§4.3:不进 cfg、不进 104,本仓任何
* 提交目标都不定义它)。判定优先级 = 采样可信在前、标定可用
* 在后(数据都旧了谈不上标定)。
* 提交目标都不定义它),且该宏**只作用于判定本体**
* adc_cal_prot_blocked()(App/algo/adc_cal.c,全仓唯一一处 #ifndef;
* 道F task-16 之前本文件另有一份判定,两处可被分别开关 = 屏上
* 「未标定」与判据停能量分叉,这就是收敛的原因)。判定优先级 =
* 采样可信在前、标定可用在后(数据都旧了谈不上标定)。
* 定值/元件初始化在 app_main_init(内核前,B5);本任务只消费。
* 线程约定:prot_elm_run / prot_set_selfcheck 仅本任务上下文(prot_elm.h);
* 跨上下文复归(104 YK11)经 prot_elm_request_reset 落请求。
@@ -35,6 +38,12 @@
* + SOE_T_CAL_BLOCK/RESUME(不用 [ERR]:未标定是
* 常态非故障,且 @test 禁用 [ERR]);DTU_BENCH
* 编译期旁路(本仓目标不定义 = 产品恒强制闭锁)
* v1.3 2026-10-26 DTU 项目组 道F task-16:本文件的三路判定 s_cal_locked()
* 删除,改调 App/algo/adc_cal.c 的 adc_cal_prot_blocked()
(同一事实的单一真源;DTU_BENCH 旁路随之收成一处)。
行为不变:判定式、调用位置(采样恢复分支内、巡检/压板
之前)、边沿日志与 SOE 分型全部照旧;s_prev_cal_lock
仍只作边沿日志用,**不是**判据缓存
*/
#include "protect_task.h"
@@ -46,7 +55,7 @@
#include "adc_input.h" /* adc_input_stm32_seq():DMA 完成计数(中断侧写) */
#include "adc_health.h"
#include "adc_cal.h" /* 卡 5:adc_cal_flags() VALID = 未标定闭锁源 */
#include "adc_cal.h" /* 卡 5:adc_cal_prot_blocked() = 未标定闭锁判定单点(道F task-16) */
#include "console_log.h" /* dbg_console_writer_ready():启动锚点行门控(B6) */
#include "prot_elm.h"
#include "prot_set.h"
@@ -80,32 +89,6 @@ static uint8_t s_prev_hf_lock; /* 上拍采样闭锁态(边沿日志用)
static uint8_t s_prev_plate_out; /* 上拍全局软压板退出态(边沿日志用,B7) */
static uint8_t s_prev_cal_lock; /* 上拍未标定闭锁态(边沿日志用,卡 5) */
/*
* 函数功能:未标定闭锁判定(「数据可信」卡 5 / §4.3)
* 入口参数:无
* 返 回 值:1 - 闭锁(判据停走);0 - 放行 uint8_t
* 限定条件:仅本任务上下文(adc_cal_flags 纯读,任意上下文可读)
* 函数说明:判据 = UA/UN/IA 三路 VALID **全**置位——恰好是 V0.1 唯一有效的
* 三个测量通道(双转换器无共同采样触发);UB/UC/IB/IC/IN 即使将来标定也另受 CLIPPED
* 品质约束(M5 快照层处理),不掺和进本闭锁源。VALID 随 cfg
* load/poll 与卡 12 标定自动翻转,本函数无自有状态(每拍现查,
* 无陈旧标志位)。台架调试旁路 = 编译期 -DDTU_BENCH(§4.3 决策 1
* 的例外通道,仅本地台架构建自行定义;产品恒强制)。
*/
/* 形态说明:用 #ifndef DTU_BENCH 把"产品默认路径"留在主分支(而非 #ifdef
* 双分支)——cppcheck 对未定义宏取真分支,双分支写法会把它引到旁路分支上
* 报 knownConditionTrueFalse 假信号(实测 2026-10-25)。 */
static uint8_t s_cal_locked(void) {
#ifndef DTU_BENCH
if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) ||
((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) {
return 1u;
}
#endif
return 0u;
}
/*
* 函数功能:动作位图变化沿日志 + SOE 投递(逐变化元件)
* 入口参数:active - 本拍 prot_elm_active_mask()
@@ -256,7 +239,9 @@ void protect_task(void *argument) {
/* 卡 5:未标定闭锁——夹在「采样可信」与「巡检/压板」之间:
* eng=0 域(引脚电压)拿去比二次值定值必然错判(UV 尤甚),
* 标定 VALID 未全置位前整条判据链停走(「数据可信」§4.3 决策 1) */
if (s_cal_locked() != 0u) {
/* 判定本体在 adc_cal_prot_blocked()(标定层单点,道F task-16):本任务
* 只是消费者,不再自带一份三路判定 */
if (adc_cal_prot_blocked() != 0u) {
if (s_prev_cal_lock == 0u) {
DBG_INFO("uncalibrated -> protection blocked (UA/UN/IA VALID not all set)");
(void)soe_store_post((uint8_t)SOE_T_CAL_BLOCK,
+5
View File
@@ -292,8 +292,13 @@ static void wd_stall_evidence(uint32_t snapshot) {
/* 裸 UART 直发(不经环形缓冲/不依赖 defaultTask 排空):停喂窗口里
* 环形缓冲的排空本身可能停摆,裸发是本行证据的可靠通道(与盘上
* 证据互补——卷锁被阻塞方持有时盘上通道会失败,见 §0.5.4 判据缺口) */
/* 整行独占(道G task-26):本行两条裸发(正文 + CRLF),段间原本是缝;这行是
* 停喂判据的核心证据(盘上通道在卷锁被阻塞方持有时会失败,串口是唯一保底)。
* begin 非阻塞:拿不到也照发,停喂现场绝不能因为抢锁而丢。 */
(void)dbg_console_tx_begin();
dbg_console_write_raw(line, (unsigned)strlen(line));
dbg_console_write_raw("\r\n", 2u);
dbg_console_tx_end();
(void)sys_log_evidence(line);
}
}
-12
View File
@@ -470,18 +470,6 @@ int ui_data_maint_bind(const ui_data_maint_ops_t *a_ops) {
return 0;
}
/*
* 函数功能:查询维护通道是否已注入
* 入口参数:无
* 返回值:1 - 已注入;0 - 未注入 uint8_t
* 限定条件:无(只读单字,任意上下文安全)
* 函数说明:页面用它把"没接线"和"操作被拒"分开显示——前者是装配缺陷,
* 现场再按一百次也不会好,必须看得见
*/
uint8_t ui_data_maint_ready(void) {
return (s_maint != NULL) ? 1u : 0u;
}
/*
* 函数功能:请求设置墙钟(时钟设置页"保存"行的唯一出口)
* 入口参数:unix_epoch - 已整组校验过的秒(见 ui_data_clock_to_epoch) uint32_t
-10
View File
@@ -530,16 +530,6 @@ typedef struct {
*/
int ui_data_maint_bind(const ui_data_maint_ops_t *a_ops);
/*
* 函数功能:查询维护通道是否已注入
* 入口参数:无
* 返回值:1 - 已注入;0 - 未注入 uint8_t
* 限定条件:无(只读单字)
* 函数说明:页面用它决定"这页能不能操作"——未注入时按钮一律拒并按 LOCK 显示,
* 比默默吞掉按键好(现场能立刻看出是装配缺了一步,而不是设备坏了)
*/
uint8_t ui_data_maint_ready(void);
/*
* 函数功能:请求设置墙钟(时钟设置页"保存"行的唯一出口)
* 入口参数:unix_epoch - 由 ui_data_clock_to_epoch() 合成、且已整组校验过的秒 uint32_t
+23 -1
View File
@@ -264,6 +264,21 @@ void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName)
uint16_t len = 0u;
(void)xTask;
/* ⚠ 刻意**不**用 dbg_console_tx_begin()/tx_end() 包这三条裸发(道G task-26 定案,
* 2026-10-26;早先的版本包过,这一笔摘掉,三条理由都要看):
* ① 语义上空转:异常上下文 IPSR != 0 时 begin 是**放行、不持有凭证**("绝不等待"
* 这条约束的必然结果),未持有时 end 又是幂等空操作 —— 包上得不到任何整行保护。
* 本行的整行性来自"Fault 优先级 -1、外设中断 ≥5、任务全冻结 ⇒ 没有第二个写者
* 能跑进来",与 begin 无关。
* ② 代价却是全局的:本钩子挂在 configASSERT 子树上(vPortEnterCritical /
* vPortExitCritical 都可能进断言),挂在那上面的每一句都会被 armlink ×9 条任务链
* 记账。实测包上后 con_tx_release 子树 24→152 B、发送环单趟贡献 128→208 B,
* default/net/iec104 三条链各 +80 B,把 iec104Task 的 margin 顶到 1,016 < 1,024
* (红线数字见 App/log/console_log.c 的 CON_CRIT_ENTER 注释)。
* ③ 归还所有权在这里没有下游读者:这一行发完就 NVIC_SystemReset()。
* 对照:**任务态**多段裸发(App/util/task_watchdog.c 的 STALL 行)仍然包 begin/end
* —— 那里既不空转(同任务后面还要接着发),也不在断言子树上。这条区分写进
* docs/子系统设计/控制台单写者日志设计说明.md §4.4.1,别让下一个人以为是我们漏了。 */
dbg_console_write_raw(msg_head, (unsigned)(sizeof(msg_head) - 1u));
if ((pcTaskName != NULL) && (hook_ptr_ok(pcTaskName) != 0)) {
@@ -281,7 +296,10 @@ void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName)
dbg_console_write_raw(name, (unsigned)len);
}
dbg_console_write_raw("\r\n", 2u);
HAL_Delay(200);
/* 这里不需要任何延时:唯一理由点写在 Src/stm32f4xx_it.c 的 hard_fault_report 函数头
* 第 ② 条(发送口自身已等到 TC;多留一条 HAL_Delay 就多一条 TIM7 tick 依赖,tick
* 一停摆就卡在复位之前,而启动段 IWDG 尚未启动、没有兜底),此处只引用不复述。
* 顺序不变:三条裸发全部发完 → 立即 NVIC_SystemReset()。 */
NVIC_SystemReset();
}
/* USER CODE END 4 */
@@ -547,6 +565,10 @@ void vApplicationAssertFailed(const char *file, long line)
len++;
}
name[len] = '\0';
/* ⚠ 与栈溢出钩子同理,刻意不包 begin/end(三条完整理由写在同文件
* vApplicationStackOverflowHook 上方那一处,这里不复述以免长成第二真源)。
* 另外修正先前版本自身的一处不完整:那对 begin/end 只包住了"有文件名"
* 这一支,else 分支(无文件名)根本不在窗口里 —— 半套独比亚没有更误导人。 */
dbg_console_write_raw(msg_head, (unsigned)(sizeof(msg_head) - 1u));
dbg_console_write_raw(name, (unsigned)len);
}
+61 -4
View File
@@ -23,6 +23,13 @@
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "usart.h"
/* 道G task-26:崩溃现场上报改走控制台单写者的**有界**发送口(dbg_console_write_raw)。
* 原来这里直接调 HAL_UART_Transmit,于是崩溃路径成了绕过 console_log 行级独占的
* **第三个写者**,并且带两条本仓已实证的失效:① 超时判据是 HAL_GetTick()(TIM7 停摆
* 时永不成立,第 8a/8b 轮台架实证);② 僵尸通道(ORE 锁存)下 HAL 恒快速失败
* (docs/子系统设计/控制台单写者日志设计说明.md §12)—— 而 [ERR] 崩溃行正是台架
* 「出现即不通过」的判据来源,快速失败 = 关键证据整条蒸发。 */
#include "console_log.h"
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
@@ -60,8 +67,14 @@
* 从此重生成不会丢失。
*
* 说明:本函数由 __asm void HardFault_Handler 用 B 直接跳入(见下方),
* 不经内核、不 printf、不遍历指针,只用 HAL_UART_Transmit 轮询 + SCB 寄存器读取,
* 因此在内核状态已不可信时仍可用。输出后 HAL_Delay(200) 排空 UART 再复位。
* 不经内核、不 printf、不遍历指针,只用寄存器级有界轮询(dbg_console_write_raw)+
* SCB 寄存器读取,因此在内核状态已不可信时仍可用。
* 2026-10-26(道G task-26)两处改动:
* ① 发送口由 HAL_UART_Transmit 换成 dbg_console_write_raw(理由见 hard_fault_write);
* ② 去掉输出后的 HAL_Delay(200):dbg_console_write_raw 自身等到 TC(移位寄存器排空)
* 才返回,延时本就冗余;去掉它是为了**少一条 tick 依赖**——HAL_Delay 也走
* TIM7 时基,tick 停摆时会卡死在复位之前(启动段 IWDG 尚未启动,没有兜底)。
* 同文件断言钩子早有同款理由的注释("无需 HAL_Delay:con_tx_bounded 已等到 TC")。
*/
static void hard_fault_write(const char *s, uint16_t len)
{
@@ -73,7 +86,21 @@ static void hard_fault_write(const char *s, uint16_t len)
if (len > 64u) {
len = 64u;
}
HAL_UART_Transmit(&huart1, (const uint8_t *)s, len, 0xFFFFu);
/* 道G task-26:改走 dbg_console_write_raw(原来是 HAL_UART_Transmit(&huart1, …,
* 0xFFFF))。三条换它的理由,全部有仓内实证或既有判据背书:
* ① **不依赖 tick**:本口的上界是 DWT CYCCNT(CON_TX_TIMEOUT_CYC = 100ms 对应
* 周期数),DWT 使能不上时退回指令计数上界(CON_TX_GUARD_MAX)——两条都与
* TIM7/uwTick 无关。HAL 那条是 UART_WaitOnFlagUntilTimeout(HAL_GetTick(),
* Timeout=0xFFFF):正常上限约 65 秒,且 tick 一停摆就**永不超时**。
* ② **不受 HAL 状态机与 ORE 影响**:本口只轮询 TXE/DR/TC,不读 gState,僵尸通道
* 下不会"整条不发"(§12);也不再占用 huart1 的 HAL lock——崩溃现场不再可能
* 与被冻结的 defaultTask 所持 BUSY 互绊。USE_FULL_ASSERT 打开时也不再走
* assert_param(IS_UART_INSTANCE(NULL)) 那条二次进断言钩子的潜在递归。
* ③ **未初始化有短路**:huart1.Instance == NULL 时立即返回 HAL_ERROR,绝不解引用
* 地址 0(机器证据 = 主机单测 TC-CONLOG-100;极早期路径仍能走完全行不挂死
* = TC-CONLOG-102)。残余风险照实写在下方 hard_fault_report 函数头。
* 本口自身等到 TC 才返回,所以调用点不需要(也不该)再补 HAL_Delay。 */
dbg_console_write_raw(s, (unsigned)len);
}
/* 字面量输出宏:长度由 sizeof 定,编译期即可确定 */
@@ -105,9 +132,37 @@ static void hard_fault_puthex(uint32_t v)
* (实测:`ADR r1, xxx_tag` 报 `A1516E: Bad symbol`,去掉 static 也无效),
* 而 `__asm` 里也无法给 C 函数传结构体/字符串参数。故把"带 tag"这一步
* 放在普通 C 函数里做。
*
* 关于"14 段裸发要不要包成一次整行独占"(道G task-26 定案,2026-10-26:**不包**):
* 本函数确实是 14 段裸发(tag / PC / LR / xPSR / R0~R3 / CFSR / HFSR / 可选 BFAR /
* 可选 MMFAR),段与段之间也确实可插字节;但 dbg_console_tx_begin()/tx_end() 在这里
* **空转**:异常上下文 IPSR != 0 时 begin 放行且**不持有**凭证("绝不等待"的必然结果),
* end 又只对持有者有效。而包上的代价是 ×9 条任务链的栈记账(本函数经断言/内核钩子
* 挂在 configASSERT 子树上;实测 default/net/iec104 三条链各 +80 B,把 iec104Task 的
* margin 顶到 1,016 < 1,024 ISR 底线)。真正救这条崩溃行的是另外两件事:**发送口归一**
* (hard_fault_write 改走 dbg_console_write_raw,三条理由见其函数头)与**去掉
* HAL_Delay**(少一条 tick 依赖)。整行性的真实依据与残余见
* docs/子系统设计/控制台单写者日志设计说明.md §4.4.1:
* 故障上下文 IPSR != 0,begin() 是**放行**(返回 1 但**不持有**凭证——异常上下文
* 绝不允许等待或失败),所以这里的原子性**不是**靠凭证,而是靠"Fault 优先级 -1、
* 外设中断 ≥5、任务全冻结 ⇒ 没有第二个写者能跑进来"。唯一残留:被抢占任务**已经
* 在移位寄存器里的那 ≤1 个字节**,它会出现在本行**之前**(不影响 [ERR] 前缀可 grep),
* 不是段间插入。已明确否决"发送循环逐字节检查让位标志"那种修法——那会把正在发的
* 启动日志行截断,换不来什么。
*
* 残余风险(照实登记,别当成已消除):
* - **极早期崩溃仍然静默**:若在 MX_USART1_UART_Init() 之前进 Fault,本口的
* `huart1.Instance == NULL` 短路会直接返回、一个字节都不发(换来的是"绝不解引用
* 地址 0",见 hard_fault_write ③;该短路有机器判据 TC-CONLOG-100/102)。旧写法在
* 同样场景下也不发(gState==RESET 不等于 READY ⇒ HAL_BUSY),**并没有因为本次改动
* 变差**,但也别写成"早期崩溃现在能看到"。
* - **ORE/NE/FE 脏标志本身没被消除**:本笔只是不再经由 HAL 状态机,从而消除了
* "崩溃日志整条蒸发"这一类;"控制台输出可能被 RX 悬空噪声污染"那一条仍在
* §12 既有观察项名下(RX 配置是另一个风险面,本笔不动)。
*/
void hard_fault_report(const char *tag, uint32_t *frame)
{
/* 刻意不包整行独占:三条理由见上方"要不要包成一次整行独占"那一段。 */
HF_PUTS("\r\n[ERR] ");
hard_fault_write(tag, 16u);
HF_PUTS(" PC=");
@@ -139,7 +194,9 @@ void hard_fault_report(const char *tag, uint32_t *frame)
hard_fault_puthex(SCB->MMFAR);
}
HAL_Delay(200);
/* 此处无需归还所有权:本函数从未取过(见函数开头说明)。 */
/* HAL_Delay(200) 已删:最后一个 write_raw 内部等到 TC 才返回,串口已排空;
* 留着它反而多一条 TIM7 tick 依赖(tick 停摆 = 卡在复位之前)。 */
NVIC_SystemReset();
}
+1 -1
View File
@@ -217,7 +217,7 @@ typedef struct {
| 字段 | 真源 | 说的是什么事 | 屏上落点 | 还能改定值吗 |
|---|---|---|---|---|
| `locked` | `prot_set_locked()`(L5-1 校验和自检失配) | 这台装置现在**写不进去** | 整定页保存行值列 `LOCK`,且 ENTER 一律不消费(禁编辑) | ❌ |
| `cal_block` | `adc_cal_flags()` 的 `ADC_CAL_F_VALID`(UA/UN/IA 三路,判据侧同源 `App/task/protect_task.c`) | 定值能改,但**改了也不会动作**(判据停走) | 整定页标题右侧「未标定」徽标 | ✅ |
| `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`
@@ -1,7 +1,7 @@
# 控制台单写者日志设计说明
> **版本**:v1.6(已实施;修订史见文末「修订记录」)
> **日期**:2026-10-26(v1.6:未注册期裸发加「行级单一所有者」,O1 第二个实例收口;v1.5 于 2026-10-24;初版 v1.0 于 2026-09-25)
> **版本**:v1.7(已实施;修订史见文末「修订记录」)
> **日期**:2026-10-26(v1.7:崩溃上报归一进单写者 + 多段裸发收口,道G task-26;v1.6:未注册期裸发加「行级单一所有者」,O1 第二个实例收口;v1.5 于 2026-10-24;初版 v1.0 于 2026-09-25)
> **状态**:已实施并验证——`@build` 0 错误 0 警告、`@test` 硬件闭环 PASS(2026-09-25,见 §10);设计 2026-09-24 加固实施(当时位于 `Src/usart.c` USER CODE 区),2026-09-25 实现迁出至 `App/log/`(HAL 生成文件不含应用代码);同日写者任务体自 `Src/freertos.c` 迁至 `App/task/default_task.c`(与其余 5 任务文件同址归位)
> **关联文档**:`项目架构总览.md` · `运行与自检记录设计说明.md`(姊妹模块:持久化日志,同一写者任务) · `测试说明/链路延迟测试说明.md`(D6 日志税 / 步骤 8 非阻塞日志,需求出处) · AGENTS.md「平台约束」
@@ -227,11 +227,31 @@ sequenceDiagram
- **让路 ≠ 丢弃**:抢不到所有权的那条行**整行原文**转入既有环形缓冲延后发。前缀与时间戳在 `DBG_*` **调用点**就已 `snprintf` 进调用方缓冲,本模块与 drain 都不补前缀、不重打时间戳 → **延后发出的行与它当场直发时逐字节相同**(`TC-CONLOG-171`/`TC-CONLOG-178`),取证时不会出现"某条行晚了几百毫秒、时间戳却写着晚的时间"这种证据说谎。
- **`[ERR]` 仍然是"控制台在丢东西"的唯一信号**:只有「既抢不到所有权**且**环也放不下」才计入既有 `s_con_drop`(真溢出)。按 3 次启动命中 2 次的频率,若把"让路"本身也计 `[ERR]`,门禁会从此常红——那是把静默缺陷换成假失败(`TC-CONLOG-172c`/`TC-CONLOG-172d`/`TC-CONLOG-172e` 分别钉住"真溢出仍红""让路不红""报成才销账")。
- **顺带堵掉第二个洞**:`dbg_console_drain()` 现在也必须先拿到所有权才开轮,拿不到就让位(`tail` 不推进 → 环里一条不少,≤50 ms 后重发)。旧写法下 attach 就发生在 `defaultTask` 里,完全可能撞上别任务**已发出半行**的裸发,两边逐字节交错。
- **新增 API**:`dbg_console_tx_begin()` / `dbg_console_tx_end()`(同样非阻塞:拿不到返回 0,调用方照发即可)——给"一条行分多段裸发"的调用方收口用。本模块只提供 API 与主机单测;**调用点改造另卡跟进**(`Src/freertos.c` 栈溢出/堆失败钩子把一条行拆成 head/name/":"/" (resetting)" 多次直发,段与段之间仍是可插入的缝 → task-26)。
- **已知残余(不在本卡修)**:`IPSR != 0` 的放行仍可能打断某任务已发出半行。判断依据:Fault 上报以 `[ERR]` 起头且整段在同一次 `con_tx_bounded` 内,标记校验按**字节** grep 仍能命中,不构成漏检;且该路径随后即 `NVIC_SystemReset()`。
- **新增 API**:`dbg_console_tx_begin()` / `dbg_console_tx_end()`(同样非阻塞:拿不到返回 0,调用方照发即可)——给"一条行分多段裸发"的调用方收口用。本模块只提供 API 与主机单测;调用点改造已随 **task-26(2026-10-26)** 落地,但**只落在任务态**:`App/util/task_watchdog.c` 的 STALL 行(正文 + CRLF 共 2 段)包进一次独占,调用点一律写 `(void)dbg_console_tx_begin();`、不拿返回值分支;`Src/freertos.c` 栈溢出/断言钩子与 `Src/stm32f4xx_it.c hard_fault_report` 三处**刻意不包**,理由见下面那条 bullet。
- **异常态的整行性不靠凭证**(task-26 语义,必须写清免得误读):`IPSR != 0` 时 `dbg_console_tx_begin()` **返回 1 但并不持有凭证**(异常上下文绝不允许等待或失败)。所以四个 Fault 与栈溢出钩子的整行性来自「**Fault 优先级 -1、外设中断 ≥5、任务全冻结** ⇒ 没有第二个写者能跑进来」。唯一残留 = **被抢占任务已在移位寄存器里的那 ≤1 个字节**,它出现在故障行**之前**,不影响 `[ERR]` 前缀可 grep。已明确否决「发送循环逐字节检查让位标志」那类修法——那会把正在发的启动日志行截断,换不来什么。
- **崩溃上报也归一(task-26)**:`Src/stm32f4xx_it.c` 的 `hard_fault_report`(四个 Fault 共用,原本 **14 段**裸发)**自己调 `HAL_UART_Transmit`**,是绕过本模块行级独占的**第三个写者**;现改为 `dbg_console_write_raw`(**但刻意不包 `tx_begin()/tx_end()`**,见下面两条 bullet)。三条理由(详见该函数 `hard_fault_write` 头):① HAL 超时判据是 `HAL_GetTick()`(TIM7 停摆则永不成立,第 8a/8b 轮实证;正常上限也是 0xFFFF ≈ 65 s),本口是 DWT/指令计数双硬上界;② HAL 状态机在僵尸通道(ORE 锁存)下恒快速失败 ⇒ **`[ERR]` 崩溃行整条蒸发**,而它正是台架「出现即不通过」的判据来源;③ `huart1.Instance == NULL` 时本口立即短路,绝不解引用地址 0(机器判据 = `TC-CONLOG-100`/`TC-CONLOG-102`)。同处删除输出后的 `HAL_Delay(200)`:本口等到 TC 才返回,延时冗余,且 `HAL_Delay` 也依赖 TIM7 tick——tick 停摆会**卡在复位之前**(启动段 IWDG 尚未启动,无兜底)。
- **⚠ 行为变化:让路会改变行序与时间戳顺序(不是回归)**。让路的整行落进环形缓冲,因此它在流里**位置后移**(最多一轮 drain,实测毫秒级)。台架 2026-10-26 复验 5 次(1 次烧录 + 4 次仅复位)稳定观察到 **`[NAND_FTL] Initializing NAND FTL...` 排在 `[PROT] uncalibrated -> protection blocked …` 前一行**;而缺陷形态下的旧一手记录正好相反(PROT 在前,NAND 缺 `[20` 行首,`docs/测试说明/boot_log_data/20261026_prod_console_interleave_evidence.txt`)。原因:谁先抢到整行所有权谁立即发完,另一方整行让路延后;常态下 `defaultTask` 的存储 bring-up 行先开火,`protectTask` 的未标定闭锁边沿要等 adcTask 首次给出标志才产生。**所以**:① **行序不是判据**——它是竞态结果,两台设备/两次启动都可能不同;② **时间戳先后也不是判据**——让路行带的是它**自己产生时刻**的时间戳(刻意不重打,见上面判据⑦),于是可能出现"后出现的那一行时间戳更早";③ 正确的判据写法是**按整行逐字节锚定**两行各自是否完整(本次台架就是这么判的,5/5 命中),不要锚定相对顺序、也不要锚定"某行必须在另一行之前"。
- **哪些调用点包整行独占、哪些刻意不包(task-26 定案,不是遗漏)**:**任务态**多段裸发才包(现网唯一实例 = `App/util/task_watchdog.c` 的 STALL 行:既不空转,也不挂在 configASSERT 子树上)。**异常态与终点路径一律不包**——`hard_fault_report`(14 段)、`vApplicationStackOverflowHook`(3 段)、`vApplicationAssertFailed`(5 段):① `IPSR != 0` 时 `tx_begin()` 放行且**不持有**凭证、`tx_end()` 又只对持有者有效 ⇒ 包上是**纯空转**,保护本来就来自"Fault 优先级 -1 + 外设 ≥5 + 任务全冻结";② 这三处都挂在 `configASSERT` 子树上(`vPortEnterCritical/vPortExitCritical` 会进断言钩子),在那上面每加一句,armlink 就把它**乘进每一条含临界区的任务链**——实测包上时 `con_tx_release` 子树 24→152 B、发送环单趟贡献 128→208 B,`defaultTask`/`netTask`/`iec104Task` 三条链各 **+80 B**,`iec104Task` margin 从 1,096 被顶到 **1,016 < 1,024** ISR 底线(栈记账 FAIL);③ 崩溃行发完就 `NVIC_SystemReset()`,"归还所有权"没有下游读者。断言钩子那对 begin/end 还曾只包住"有文件名"一支(else 分支不在窗口内)——半套独比亚没有更误导人。
- **⚠ 结构性失效模式(这条环是我们自己造的,随时可能复活)**:断言钩子为了留痕要写控制台,而写控制台若要进内核临界区,就形成 `taskEXIT_CRITICAL() ⇒ vPortExitCritical() ⇒ configASSERT() ⇒ vApplicationAssertFailed() ⇒ dbg_console_write_raw ⇒ …⇒ 再进临界区` 这条**静态互递归环**;armlink 不是按运行时报错,而是把它**记进每一条含临界区的任务链**——一次性污染 9 条链的栈记账,且没有任何编译期或运行期告警。为此本模块的裸发/入环/取还凭证一律用 PRIMASK 窗口(`CON_CRIT_ENTER/EXIT`,机器判据 = `TC-CONLOG-015` 的 `con_crit_enter == 0` + `TC-CONLOG-015b` 两种屏蔽水位恒 0)。**判据留给后来人**:任何人在 `con_tx_*` / `con_ring_try` / `dbg_console_write_raw` / `dbg_console_drain` 的窗口里再放一处 `taskENTER_CRITICAL()` 或任何含 `configASSERT` 的内核 API,这条环立刻复活;提交前必须跑 `scripts/stack_report.py`(且**注明数字出自哪棵工作树、哪次构建**——本仓今天两次错误归因都是拿错了树的数字)。反向风险同样要记(task-38):若哪天把 `configASSERT` 裁成空宏,每条链"回收"的 80~90 B 是**静态记账口径变化,不是固件变好**,不得写成优化收益。
- **本笔没有消除的(别混)**:ORE/NE/FE 脏标志仍在(成因 = USART1 RX 悬空噪声,§12;用户已裁定不改 RX 配置)。本笔消除的是「崩溃日志整条蒸发」这一类,**不是**「控制台输出可能被脏标志污染」那一类,后者仍挂 §12 既有观察项。**极早期崩溃仍然完全静默**:`MX_USART1_UART_Init()` 之前进 Fault 时本口的 NULL 短路一个字节都不发——旧写法在同场景也不发(`gState != READY` ⇒ `HAL_BUSY`),所以没有变差,但也不能写成「现在能看到早期崩溃」。
- **实机验证缺口**:崩溃路径不能只靠静态论证(本仓最贵的教训就是「0 警告不代表安全」)。可执行验证设计见下方「验证设计」,**本笔未跑**(台架在别的任务手里)。
**为什么不做成"整段进环"**(曾评估):那会让 `storage_bring_up()` 期间串口**一个字都不出**,而这段正是 §「启动看门狗延后」注明的"死机表现为无任何输出"窗口(2026-10-02 那几轮就靠 bring-up 期流式日志取证)——等于把交错换成死机全瞎。现方案保留**胜者实时直发**,只有真撞车才让路一条行。
**验证设计(崩溃路径,两腿分工,2026-10-26 台架更正后定稿)**:这条路径不能只靠静态论证(本仓最贵的教训就是「0 警告不代表安全」),但**两腿各证各的,别互相顶包**。
**腿 A — 主机侧套件承担全部关于 IPSR 与异常态整行性的主张**(已落地、可重复、确定性证据):
- 假 USART + `con_ipsr` + 「每个上线字节的临界区深度水位」+ 一次性注入把手(在第 N 个字节上线后跑第二个生产者),把「Fault 上下文照发、不等、不丢、不越权归还」做成必然失败的断言:`TC-CONLOG-175` / `TC-CONLOG-175b`(IPSR 直发且不动别人的凭证)、`TC-CONLOG-174` / `TC-CONLOG-174b`(一次独占内多段连发不自我丢弃、`tx_end` 后别人能接手)、`TC-CONLOG-015` / `TC-CONLOG-015b` / `TC-CONLOG-177`(发送全程临界区深度恒 0,即"绝不把整行包进关中断区"这条硬红线)、`TC-CONLOG-170..172`/`178`(两个生产者并发时行内不得出现别人的字节;`TC-CONLOG-178` 直接复演台架那行原文)。
- ⚠ **「硬件上多跑几次启动照不出 IPSR」**——产品构建正常启动根本不进异常路径,跑 100 次也只是重复证明"没崩溃"。任何人想推翻或确认本节关于异常态的说法,请改主机侧套件,不要去台架上碰运气。
**腿 B — 实机腿:本腿含故意复位(不是常规回归)**
1. **硬前提:触发器必须由代码道提交进仓**(测试宏下编入的一条非法写即可,例如 `*(volatile uint32_t *)0x00000000U = 0U;` + 前置裸发锚点 `[FAULTINJ] arm`);**台架不得自行制造触发条件**——现场不可复现、也无法归因,且会污染"这一轮到底测了什么"的记录。
2. 判据**只用原子性**,不碰 `[ERR]` 计数:① 崩溃行**一整行**逐字节完整(`[ERR] MEMFAULT PC=… LR=… xPSR=… R0..R3=… CFSR=… HFSR=…` 字段齐全、无邻居行字节插入);② 该行在本次启动日志里**恰好出现一次**(既不丢也不重);③ `CONSOLE dropped` = 0。首个字符应为 `[`;前面若出现 ≤1 个移位寄存器残留字节,记为已知残余,不判失败。
3. ⚠ **不得在同一条腿要求 `[ERR]` = 0**:崩溃上报本身就以 `[ERR]` 起头,在同一路径上要求「无 `[ERR]`」等于要求这条路径别工作(本次就是这么被台架纠正的)。`[ERR]` 零命中的判据只属于腿 B 之外的**常规回归腿**(3~5 次仅复位不重烧 + 两行整行锚定)。
4. **故意复位的注入腿不得并入「连续 N 次仅复位不重烧」的计数**:注入会主动 `NVIC_SystemReset()`,混进常规序列会把正常行为判成异常(栈溢出钩子同理)。两条腿分开跑、分开记台账。
5. `@test` 在注入腿**只在命令行运行时**放宽(故障注入的既有台架口径见 `docs/测试说明/实机测试实施指南.md` §4,其中已写明"故障注入用例会复位设备多次 → 确认设备非在用/非已部署"):放宽 `--forbidden` / `--max-boot-count`;**不改仓库配置**(改了就等于把"永不出崩溃日志"设成期望状态)。
### 4.5 `fputc` 重定向(MicroLIB `printf`)
`fputc(ch, f)` → `con_append(&b, 1)`:库内部 / 遗留 `printf` 调用同样收敛到单写者路径(注册期逐字节入环;一条完整 `printf` 行 = 环内连续字节,写者按块发出 → 仍行级原子)。
@@ -287,7 +307,8 @@ sequenceDiagram
| 缓冲满(日志突发 / 写者卡滞) | 生产者等 200ms 后丢行 | `s_con_drop` 累计 → 写者刷 `[ERR] CONSOLE dropped N lines`(@test FAIL,永不静默) | — |
| 写者未注册(内核前) | 单线程直发 | — | 无(无并发) |
| 未注册期其他任务直发 | **已消除**(2026-10-26):整行独占,抢不到就整行入环延后发(§4.4.1) | 让路不计数;只有"抢不到且环也满"才进 `s_con_drop` → `[ERR] CONSOLE dropped N lines` | 残余 = 让路行在流里位置后移(内容与直发逐字节相同);主机单测 `TC-CONLOG-170..179` 钉死 |
| ISR 直发撞写者发送中 | 与写者发送竞争 → 交错/丢失,不挂死(`IPSR != 0` 一律放行,§4.4.1 规则①) | 未计数 | Fault 行以 `[ERR]` 起头且整段在同一次 `con_tx_bounded` 内 → 按字节 grep 仍命中,不构成标记漏检;ISR 日志频率极低,best-effort 语义 |
| 崩溃上报(四个 Fault)原走独立 HAL 发送口 | **已归一**(2026-10-26 task-26):`hard_fault_report` 的 14 段裸发改走 `dbg_console_write_raw`(整行独占**不**用在这里——异常态空转,见 §4.4.1);ORE 快速失败(= 证据整条蒸发)与 `HAL_GetTick`/`HAL_Delay` 的 tick 依赖随之消失 | 崩溃行 `[ERR] <TAG> PC=… CFSR=…` 现在是单写者输出;实机验证设计见 §4.4.1 末(待台架窗口) | 极早期(`MX_USART1_UART_Init()` 之前)崩溃仍完全静默(NULL 短路);ORE 脏标志本身未消除(§12) |
| ISR 直发撞写者发送中 | 与写者发送竞争 → 交错/丢失,不挂死(`IPSR != 0` 一律放行,§4.4.1 规则①);异常态没有第二写者(Fault 优先级 -1 + 任务全冻结),残留 ≤1 个移位寄存器字节且落在行**前** | 未计数 | ISR 日志频率极低,best-effort 语义 |
| 内核状态损坏(栈溢出 / 堆耗尽) | 钩子裸发固定文本 → 复位(栈溢出)/ 仅告警(堆) | `[ERR] STACK OVERFLOW task=…` / `[ERR] MALLOC FAILED …`;任务名范围校验 + 本地拷贝,TCB 指针不入 UART | — |
| 调用方指针损坏(脏指针) | 无法进入 UART(缓冲恒为环 / 模块本地) | 2026-09-24 HardFault 路径结构性消除 | — |
| UART 物理失效(发送卡死 / 超时) | `con_tx_bounded` 在 DWT 上界内返回 `HAL_TIMEOUT` → 该块丢弃 + **UART 重初始化**(僵尸通道自愈,§12) | 2026-10-10 起:drain 内**只置标志** `s_con_tx_fault_log`,由 defaultTask 在 drain 之外 `syslog_post(SYSLOG_RUN, "console tx fault, uart reinit")` 补记 run.log(旧实现直接在 drain 里调 `syslog_post` → `write_line → con_append → drain → syslog_post → write_line` **调用图成环**,静态栈在每个任务上多叠一条日志尾巴) | 已知缺口:丢弃计数与故障计数不分离 |
@@ -339,6 +360,7 @@ sequenceDiagram
| 2026-10-02 | **round 8b 加固**:发送超时判据改用 DWT `CYCCNT`(时间源无关)。原 `HAL_UART_Transmit` 的 `HAL_GetTick() - Tickstart` 依赖 TIM7 的 `uwTick`,TIM7 一旦停摆超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 → IWDG 复位。裸发送与缓冲满等待两处均换成 DWT 周期计数硬上界(DWT 使能失败时退回旧 HAL 行为);另加 `huart1.Instance == NULL` 短路,防早期/断言路径解引用 NULL |
| 2026-10-10 | **新增 `dbg_console_writer_ready()` + 未注册期纪律固化**:裸发为寄存器级轮询(无 `gState`)→ 未注册期多任务并发**逐字节交错**(台架 `adcTask` 首帧日志与 `[NAND_FTL]` 行交错实证)。新增 `dbg_console_writer_ready()` 供周期任务门控;`adcTask` 首帧/心跳日志据此门控(§3.4 / §4.4) |
| 2026-10-25 | **N-2 × S8 合并评估收口(评估任务,零代码)**:S8 生产者侧判定已达成、TX IT/DMA 维持否决并绑定 S-3 复议条件、N-2 收敛到 V5 复跑双零判据——全文见文末附录 |
| 2026-10-26 | **崩溃上报归一 + 多段裸发收口(道G task-26,接 task-23 第二段)**:`Src/stm32f4xx_it.c hard_fault_report`(四个 Fault 共用,14 段裸发)原**自行调用 `HAL_UART_Transmit`** ⇒ 绕过本模块行级独占的**第三个写者**,并带两条已实证失效:HAL 超时依赖 `HAL_GetTick()`(TIM7 停摆永不成立)与僵尸通道下恒快速失败(**关键 `[ERR]` 证据整条蒸发**)。现改 `dbg_console_write_raw`(**不包** `tx_begin()/tx_end()`,理由见 §4.4.1);`hard_fault_report` 与栈溢出钩子输出后的 `HAL_Delay(200)` 一并删除(本口已等 TC;少一条 tick 依赖,避免卡在复位之前)。同类收口只落在任务态:`App/util/task_watchdog.c` STALL 行 2 段包进一次独占;`Src/freertos.c` 栈溢出钩子(3 段)与断言钩子(5 段)以及 `hard_fault_report`(14 段)**不包**(异常态空转 + 代价 ×9 条任务链,见 §4.4.1)。**语义**:异常上下文 `tx_begin()` 放行不持有,故障行整行性靠 Fault 优先级与任务冻结,残留 ≤1 个移位寄存器字节(落在行前,不影响 `[ERR]` grep)。**未消除**:ORE/NE/FE 脏标志、极早期(串口未初始化)崩溃静默。主机侧复用既有判据 `TC-CONLOG-100/102`(NULL 短路)与 `TC-CONLOG-174`(一次独占内多段连发不自我丢弃),未新增用例;实机验证设计见 §4.4.1 末。**行序口径**:让路行入环后位置后移,台架 5/5 观察到 `[NAND_FTL]` 稳定排在 `[PROT]` 前一行(旧缺陷形态相反)⇒ 行序与时间戳先后**都不再是判据**,只能按整行逐字节锚定(§4.4.1) |
| 2026-10-26 | **O1 第二个实例 → 未注册期裸发加「行级单一所有者」(道G task-23)**:台架同镜像两次"仅复位"启动,`[PROT]` 未标定闭锁边沿行内分别多出 `[20`(3 字节)与 `[`(1 字节),而紧随其后的 `[NAND_FTL] Initializing NAND FTL...` 行恰好缺同样那几个字节的**行首**(字节账与三次启动命中两次的频率见 §4.4.1)→ 判据"framing 错不会让行变长"锁定为**两个写者并发裸发**,即 O1 的第二个实例(第一个是 2026-10-12 B6)。修法 = 非阻塞行所有权(`con_tx_try_claim`/`con_tx_release`,只在几条指令的临界区里 test-and-set;发送全程**不关中断**)+ 让路整行原文入环延后发(不丢、不补前缀、不重打时间戳)+ drain 每轮先取凭证(堵掉"attach 撞上别人半行")+ `[ERR]` 只在"抢不到且环也满"时红。新增 `dbg_console_tx_begin/tx_end`(多段裸发收口,调用点改造 → task-26)。主机单测 127 → **152** 条(M 族 `TC-CONLOG-170..179`,含逐字节相同判据),四处生产码注入反证各自转红(取消独占 11 红 / 让路改静默丢 5 红 / 非属主可归还 1 红 / 把发送包进临界区 3 红)。§3.4 / §4.4 / §7 / §13-O1 同步 |
| 2026-10-12 | **纪律盲区台架实证(B6)**:`protectTask`(B5 接线,台架腿当时未跑)启动锚点行在写者注册前裸发 → 与 defaultTask 启动日志**逐字节交错**(`st[art: cycle=220ms elm=70`,两次开机同形复现 = 启动调度确定性的竞态,非 USB 噪声)→ `@test` 锚点 MISS。修复 = 启动行改 `dbg_console_writer_ready()` 首拍补打(adcTask 首帧同款),`@build` 0/0 + `@test` PASS 复验(锚点完好 +577 ms)。§3.4 纪律示例更新 |
@@ -215,7 +215,9 @@ static HAL_StatusTypeDef iwdg_deferred_start(const IWDG_HandleTypeDef *a_h) { (v
### 4.3 长耗时窗口的三类处置
统一原则:**启动段不启动狗;主循环段用"分片心跳 / 心跳路由 / 直接喂狗"三类手段覆盖**。
统一原则:**启动段不启动狗;主循环段用"分片心跳 / 心跳路由 / 直接喂狗"三类手段覆盖**;
另有一类**根本不等锁**的场景(卷锁 try 失败)不属于"等待怎么活下来",它只需要
**可见**,用表中 ④「只计数」那一条(道G task-17,它不喂位也不需要喂位)。
| 场景 | 位置 | 时长 | 处置手段 |
|---|---|---|---|
@@ -224,6 +226,7 @@ static HAL_StatusTypeDef iwdg_deferred_start(const IWDG_HandleTypeDef *a_h) { (v
| `time_sync_handle_conn` 的 30 s 总预算 | `App/sync/time_sync.c` | 单次可睡满 30 s | **② 切片**:`TIME_SYNC_WAIT_SLICE_MS=200` 夹住每次 `net_recv_to` 的 `rem`,每片先喂位(总预算语义不变) |
| 就绪屏障等待(NET/STORAGE READY) | net / ui / ftp / timesync 入口 | 不定 | **② 分片**:等待按 100 ms 分片并喂位 |
| 卷锁等待(FatFs 多任务) | `lftpd_io.c`、`App/fs/vol_lock.h` | 不定 | **② 分片**:100 ms 分片阻塞 + 每片 `lftpd_task_wd_beat()` |
| 卷锁 try 失败(defaultTask / uiTask **从不等锁**,取不到就跳过本轮) | `App/fs/vol_lock.c` 观测计数 | 不定 | **④ 只计数、不在失败分支留日志**:锁层记 5 态(`acquired` / `try_timeout` / `blk_timeout` / `failopen`,16 B 静态),读口 `vol_lock_get_stats()` **不取锁**、任意上下文可调。为什么不许在 try 失败处补一条日志——唯一能把事写下来的通道(`/log/wdev.log`、`run.log`)自己也要取这把锁,而卷锁**非递归**,被卷忙挡住时既写不出还会自取死锁;一次性留痕交给 syslog 唯一写者按差值 latch 补投(道G task-17,接口口径见 `App/fs/vol_lock.h`) |
| FTP 事件循环与传输分片 | `Lib`外的 `Drivers/BSP/NET/lftpd/*` | 秒级 | **② 分片**:事件循环每轮 + RETR/STOR 每分片喂位 |
| NAND 块擦除等待(最长 5 s)/ GC / 日志写 | `gd5f2gq5ue.c` `gd5f_wait_busy` | 秒级 | **② 心跳路由** + **③ 直接喂狗**(每 100 ms 双管) |
| 长 FTL 操作发生在 defaultTask 上下文(喂狗点自身被阻塞) | 同上 | ~2 s | **③ `nand_ftl_wd_feed()` 直接写 `IWDG->KR`** |
@@ -490,6 +493,8 @@ snapshot == required → miss_streak=0、证据门闩复位、喂狗
看门狗是本项目**第一次**把 CubeMX 生成流程引入可靠性关键路径,由此沉淀出与看门狗本身无关的通用方法论。`AGENTS.md`「平台约束」指向本节,改动 `.ioc`/`Src/`/`Inc/` 前请先读。
> **2026-10-26(道G task-26)一句话登记,机制不在本文复述**:四个 Fault 与栈溢出 / 断言钩子的**崩溃上报发送口已归一**进控制台单写者(`dbg_console_write_raw` + 一次整行独占,不再自行调用 `HAL_UART_Transmit`),机制正文与残余见 `docs/子系统设计/控制台单写者日志设计说明.md` §4.4.1。本节只关心一件事:这些实现仍须整体待在 `Src/freertos.c` 的 `USER CODE BEGIN 4/5` 区与 `Src/stm32f4xx_it.c` 的 `USER CODE BEGIN 0` 区(§10.2 的 L2),改完跑 `scripts/check_cubemx_survival.py`(§10.3)。
### 10.1 覆盖史(三次重生成)
| 次 | 触发 | 被覆盖的定制 | 结论 |
@@ -707,3 +712,4 @@ enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的
| 2026-10-11 | **新增 §10.4:ARMCC `__asm` 的三条语法约束** | AGENTS.md 瘦身:「ARMCC 内联汇编语法」条目的完整细节(GCC 语法报 `#18`、块内联把寄存器名当 C 标识符报 `#20`、`__asm` 取不到 C 数据符号 `A1516E`、可用写法 = 整函数 `__asm void f(void)` + `B` 到 C 入口、`#define f f_Gen` 让位机理)迁入本文,AGENTS.md 只留一行禁令与指针 |
| 2026-10-12 | **iec104Task / protectTask 纳入监视(B4/B5):必需掩码 `0x77`→`0xF7`→`0x1F7`** | 两行合并记录(B4 落地时漏补本文,B5 一并纠偏):**B4** 新增 `TASK_WD_IEC104 = 7`(iec104Task 100 ms 服务循环喂位,NET_READY 屏障等待按 100 ms 分片喂位),产品 `0x77`→`0xF7`、HW 层 `0x75`→`0xF5`;**B5** 新增 `TASK_WD_PROTECT = 8`(protectTask 20 ms 判据节拍喂位,见 `docs/算法/继电保护功能复现规划.md` §7),产品 `0xF7`→`0x1F7`、HW 层 `0xF5`→`0x1F5`。两任务均**不依赖存储/网络之外的屏障**(protectTask 无任何屏障)→ 任何构建都置位;均不做 NAND I/O → 不登记 FTL 心跳路由。主机侧单测 `beat_all()` 与 `tc_003`/`tc_005`/`tc_011` 掩码断言同步至 `0x1F7`,39 断言全绿。⚠ 注意 `0x1F7` 不含 bit3(testTask 任何构建不入掩码,口径不变) |
| 2026-10-25 | **停喂判定按位分账(v1.5,大窗判读 F2)**:stall/停喂唯一判据从「复合连续缺席窗数」改为「**任一必需位的连续缺席窗数**」——同一任务连续 8 窗(800 ms)没跑过才申报停摆并停喂;多任务交替缺席(本窗缺 A、下窗缺 B)各自清零、且缺席窗在容忍线下**照常喂狗**(旧实现自第一个缺席窗起就不喂,800ms 只是申报点)。动因 = 2026-10-25 台架大窗:54 个探针重建窗里 9 条 stall 行中 16:24/16:28 两起为复合误升级(mg=877/918,无任何单位到 800ms 单串),16:39 为真 UI 单串(~1.2 s,受害者面修复另立 F3 票);`MISS start` 降级为复合边沿**诊断行**(不再代表停喂,判读口径 §6.1/§11.1 同步);真停摆到复位的总时长最坏 +约 0.7 s(§2.2 链注)。§0/§2.2/§3.2/§5.3/§6.1/§11.1/§11.6 同步;主机单测 `test/host/watchdog/watchdog_test.c` 断言按新判据改写 + 新增 **TC-WATCHDOG-017**(20 窗交替缺席:零 stall、20 窗持续喂)/ **TC-WATCHDOG-018**(同位 8/9/10 连击必报停 + 停喂;恢复窗喂、账本重启,第二段亚容忍串不误报),47 断言全绿 |
| 2026-10-26 | **卷锁 try 失败纳入 ④「只计数」口径 + 修一处会骗人的契约(道G task-17)** | §4.3 场景表新增一行:卷锁 try 失败(defaultTask / uiTask 从不等锁,取不到就跳过本轮)既不需要分片也不需要心跳,缺的是**可观测**。落地形态:`App/fs/vol_lock.c` 记 5 态计数(`acquired` / `try_timeout` / `blk_timeout` / `failopen`,共 16 B 静态、一次自增),`vol_lock_get_stats()` **不取锁**、不关中断、任意上下文可调、NULL 无害。为什么不许在 try 失败处补一条日志:能把事写下来的通道(`/log/wdev.log`、`run.log`)自己也要取这把锁,而卷锁**非递归**,被卷忙挡住时既写不出东西还会自取死锁——所以留痕只能由 syslog 唯一写者按差值 latch 补投(另立 task-40 接在 `syslog_drain()` 的 `s_quiet_drop` 差值块之后)。同时修契约:`App/fs/vol_lock.h` 原写「0 - 超时未获取」、`vol_lock.c` 原 :38 把 fail-open 归给 0,而代码(:46)与 `TC-VOLLOCK-008` 都是**返 1**;现在两处都按代码写实并显式警告「返 1 也可能是零互斥」,`failopen` 计数就是它的证据。**观测时刻刻意不在锁层**:记时刻要读 tick,等于给 defaultTask 取锁链添 callee 与本帧(本档 §11 一贯按字节省的就是这条链),改由消费方观测并自盖 `obs_first_ms` / `obs_last_ms`——那是**被观测到的时刻**,不等于发生时刻,命名与文案都不许写成"首次发生"。机器证据:`test/host/vol_lock` 新增 TC-VOLLOCK-015~019(45 断言全绿:T2/T3 互不冒领、fail-open 计数可累加、读取不消耗证据);另把「喂狗点取锁一律 timeout=0」从注释纪律升级成会 FAIL 的断言——4 套假件加 `s_last_wait_to`,断言落在 TC-SYSLOG-010/012、TC-SOESTORE-016/018、TC-CFGFST-003/016/032/033、TC-LFTPIO-034,并做过红-绿反证(把 `sys_log_mcu.c:318` 的 `0u` 改 `50u` → 135/137 双红;把 `lftpd_io.c:48` 的 `100u` 改 `0u` → 32/33 红;随后按原串改回、SHA-256 逐字节相同) |
@@ -66,6 +66,18 @@ test/host/vol_lock/vol_lock_test.c
都不会被锁阻塞。`TC-VOLLOCK-008`(返回 1)与 `TC-VOLLOCK-008b`(`osMutexWait` 调用次数 == 0)
两条断言共同钉死该语义——只断言返回 1 是不够的,必须同时证明**没有进入任何等待路径**。
(道G task-17 补充)**"返 1 可能是零互斥"这件事现在可查了**:锁层为 5 态各记一个计数
(`acquired` / `try_timeout` / `blk_timeout` / `failopen`,共 16 B 静态,见 `App/fs/vol_lock.c`
的计数块),读口 `vol_lock_get_stats()` **不取卷锁**、不关中断、任意上下文可调、NULL 无害。
判 `failopen` 用**差值与边沿**:累计 `> 0` = 本 boot 内至少发生过一次"无互斥读写卷";
计数跨任务读-改-写,竞态下**少计不会多计**,所以别拿精确值当账。
一次性留痕由 syslog 唯一写者按差值补投(`syslog_post_dropped()` 同型先例),**锁层自身绝不
产日志**——能把事写下来的通道(`/log/wdev.log`、`run.log`)自己也要取这把锁,而卷锁
非递归:在 try 失败分支里补日志既写不出东西,还会自取死锁。
⚠ 计数**刻意不记时刻**(记时刻要读 tick,等于给 defaultTask 的取锁链添 callee 与本帧)。
因此消费方自行盖的时间戳是「**被观测到的时刻**」,不等于发生时刻——真实发生可以早到
一整个轮询周期之前;命名请用 `obs_first_ms` / `obs_last_ms`,文案里不得出现"首次/末次发生"。
> `vol_lock_init()` 的返回值与 `wait()` 的语义**无关**:`init` 失败返回 `-1`,但
> `wait()` 仍返回 `1`。调用方**不得**用 `init` 的返回值决定是否调 `wait`。
@@ -128,6 +140,11 @@ test/host/vol_lock/vol_lock_test.c
| **TC-VOLLOCK-008** ⭐ | **fail-open**:锁未创建且创建失败 → 返回 **`1`**;**`osMutexWait` 调用次数 == 0**(零阻塞);`s_lock` 仍为 `NULL` |
| **TC-VOLLOCK-009** | fail-open 之后 `release` 无害:`osMutexRelease` **未被调用**(判空静默返回) |
| **TC-VOLLOCK-010** | 已创建时多次 `wait` **不重复创建**(`osMutexNew` 恒 1 次);每次 `wait` 各等待一次 |
| **TC-VOLLOCK-015** | 成功路径只涨 `acquired`:`try_timeout` / `blk_timeout` / `failopen` 必须仍为 `0`(异常计数不得被正常路径污染) |
| **TC-VOLLOCK-016** | `timeout=0` 未取到 → 只进 `try_timeout`,且 `blk_timeout` 与 `acquired` 保持 `0`(T2:压根没等,卷被别人占住) |
| **TC-VOLLOCK-017** | `timeout=100` 未取到 → 只进 `blk_timeout`,且 `try_timeout` 保持 `0`(T3:等了没等到;**与 T2 互不冒领**是本卡唯一"语义合并"风险) |
| **TC-VOLLOCK-018** ⭐ | fail-open 仍返回 `1`(口径不变,同 TC-VOLLOCK-008),但 `failopen` 计数自增而 `acquired` 保持 `0`;连续两次 → `failopen == 2`(消费方按差值 latch,一个 episode 一条) |
| **TC-VOLLOCK-019** | `vol_lock_get_stats(NULL)` 无害;重复读取**不消耗证据**(计数不因读而归零) |
> **TC-VOLLOCK-008 是本套件存在的主要理由**:真机上需堆耗尽才可能触发,板端无法构造。
+41
View File
@@ -32,6 +32,10 @@
* 折算 = ROM、VALID 不报(位图置位也不报)、[ERR] 留痕
* TC-ADCCAL-012 段损坏(超长文件)→ 回退 ROM 且 flags 无 VALID(卡 4 DoD)
* TC-ADCCAL-013 段缺失(文件无本段)→ ROM 默认、VALID 全 0
* TC-ADCCAL-018 未标定闭锁单点 adc_cal_prot_blocked()(道F task-16):开机态必须
* 闭锁 / 两路齐第三路缺仍闭锁(全或无)/ 三路齐放行且 UB 仍无 VALID
* (证明它是"三路"而不是 feed_eng 那个"8 路")/ 复位后立刻回闭锁
* (现查、无自有状态可残留)
*
* 通过:全部 TC PASS(main 返回 0)。
* 创建日期:2026-10-25(道A 卡 2/卡 4)
@@ -484,6 +488,42 @@ static void tc_013_section_missing(void) {
CHECK(adc_cal_flags(ADC_SEM_IA) == 0u, "no VALID with missing sections");
}
/* TC-ADCCAL-018:未标定闭锁判定单点(「数据可信」卡 5 / 道F task-16)
* 为什么值得单独钉:这条谓词此前在 App/task/protect_task.c 与
* App/app/ui_data_feed.c 各写一份,**没有任何一份被主机测到**(ui_data 套件只编
* 快照层,不编组装器)。收敛到本层后它跟着被测源文件一起进来,第一次拿到
* 机器证据。
* 注:DTU_BENCH 旁路这一档在主机侧测不到(宿主构建不定义它,编译进来的就是产品
* 路径),它的正确性由"全仓只剩一处 #ifndef"+ 台架目检保证,不假装有覆盖。 */
static void tc_018_prot_blocked(void) {
printf("[TC-ADCCAL-018] adc_cal_prot_blocked: boot blocks / three required / stateless\n");
/* ① 开机首拍前(任何 set 之前)必须判闭锁。**这条最贵**:若实现偷懒回读了
* 某个"上拍态"变量,刚上电那拍就是 0 = 谎称已标定,屏上「未标定」徽标
* 不出现,现场读到的是"改了定值就会动作" */
reset_all();
adc_cal_init();
CHECK(adc_cal_prot_blocked() == 1u, "boot (no calibration at all) -> blocked");
/* ② 全或无:两路齐、第三路缺 → 仍闭锁(不是多数表决,也不是"任一路齐就放行") */
CHECK(adc_cal_set(ADC_SEM_UA, 10500000L, 0L) == 0, "UA in-band accepted");
CHECK(adc_cal_prot_blocked() == 1u, "UA only -> still blocked");
CHECK(adc_cal_set(ADC_SEM_UN, 10000000L, 0L) == 0, "UN in-band accepted");
CHECK(adc_cal_prot_blocked() == 1u, "UA+UN with IA missing -> still blocked");
/* ③ 三路齐 → 放行;同一时刻 UB 仍无 VALID,证明本判定用的是**三路**口径,
* 不是电气 8 路全 VALID(那是 eng 的量纲口径,两者不可互换) */
CHECK(adc_cal_set(ADC_SEM_IA, 10000000L, 0L) == 0, "IA in-band accepted");
CHECK(adc_cal_prot_blocked() == 0u, "UA/UN/IA all VALID -> released");
CHECK((adc_cal_flags(ADC_SEM_UB) & ADC_CAL_F_VALID) == 0u,
"UB still uncalibrated at release (3-channel rule, not the 8-channel eng rule)");
/* ④ 现查、无残留:init 复位(等价于配置被重载/文件损坏回退)后立刻回闭锁 */
adc_cal_init();
CHECK(adc_cal_prot_blocked() == 1u, "re-init -> blocked again (no cached state)");
CHECK(adc_cal_prot_blocked() == 1u, "repeat call stable (idempotent pure read)");
}
/* ===================== main ===================== */
int main(void) {
printf("=== ADC Cal Layer Tests ===\n");
@@ -503,6 +543,7 @@ int main(void) {
tc_011_file_bad();
tc_012_file_corrupt();
tc_013_section_missing();
tc_018_prot_blocked();
printf("\n=== ADC Cal Layer Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);
@@ -139,6 +139,9 @@ static int s_lock_waits;
static int s_lock_releases;
static int s_lock_held;
static int s_lock_seq_err;
static uint32_t s_last_wait_to; /* 最近一次 vol_lock_wait 的 timeout 入参(0 = try 语义)。
* 四套假件逐字同名,便于全仓 grep「喂狗点绝不阻塞」这条纪律;
* 第 6 套出现时再议共享假件 */
static void locks_clear(void)
{
@@ -147,6 +150,7 @@ static void locks_clear(void)
s_lock_releases = 0;
s_lock_held = 0;
s_lock_seq_err = 0;
s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=0 */
}
static void lock_busy_next(int n) { s_lock_busy_left = n; }
@@ -157,7 +161,7 @@ int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
s_last_wait_to = timeout_ms; /* 记录入参:调用方的 timeout 口径由此可断言 */
if (s_lock_waits > 20000) {
(void)fprintf(stderr, "[FATAL] vol_lock_wait guard tripped\n");
exit(2);
@@ -283,8 +287,8 @@ static void tc_read(void)
lock_busy_next(1);
ok = (cfg_store_read(P_CFG, buf, sizeof(buf), &n) == -1);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0) && (s_lock_releases == 0)
&& (s_lock_seq_err == 0),
"TC-CFGFST-003 volume busy -> -1: zero FatFs calls, no release (never release a lock we do not hold)");
&& (s_lock_seq_err == 0) && (s_last_wait_to == 0u),
"TC-CFGFST-003 volume busy -> -1: zero FatFs calls, no release (never release a lock we do not hold) + asked with timeout=0");
/* 004 文件缺失:必须已放锁 */
reset_all();
@@ -417,8 +421,9 @@ static void tc_write_atomic(void)
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_RENAME] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_UNLINK] == 0)
&& (ff_fake_size(P_CFG) == 4u) && (s_lock_releases == 0),
"TC-CFGFST-016 volume busy -> -1: no TMP created, target untouched, no release");
&& (ff_fake_size(P_CFG) == 4u) && (s_lock_releases == 0)
&& (s_last_wait_to == 0u), /* 喂狗点绝不阻塞:取锁一律传 0 */
"TC-CFGFST-016 volume busy -> -1: no TMP created, target untouched, no release + asked with timeout=0");
/* 017 p_buf NULL */
reset_all();
@@ -522,6 +527,7 @@ static void tc_stat_remove(void)
rel = s_lock_releases;
lock_busy_next(1);
ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel);
ok = ok && (s_last_wait_to == 0u); /* stat 也走 try:喂狗点绝不阻塞 */
rel = s_lock_releases;
ff_fake_inject_rc(P_CFG, FF_FAKE_OP_STAT, FR_DISK_ERR, 1);
ok = ok && (cfg_store_stat(P_CFG, &st) == -1) && (s_lock_releases == rel + 1)
@@ -538,7 +544,7 @@ static void tc_stat_remove(void)
&& (s_lock_releases == rel + 2);
lock_busy_next(1);
ok = ok && (cfg_store_remove(P_CFG) == -1) && (s_lock_releases == rel + 2)
&& (s_lock_seq_err == 0);
&& (s_lock_seq_err == 0) && (s_last_wait_to == 0u); /* remove 同口径 */
CHECK(ok, "TC-CFGFST-033 remove: success / -1 when already gone / -1 when busy, each with lock pairing");
}
+78 -11
View File
@@ -149,6 +149,14 @@ void (*con_delay_hook)(void);
uint32_t con_crit_enter;
uint32_t con_crit_exit;
uint32_t con_crit_max_depth;
/* PRIMASK 版关中断窗口的账(道G task-26:本模块自己的取还/入环窗口用它,不再走内核
* 临界区 —— 那会把 configASSERT 环接进每一条含临界区的任务链,栈记账 9 链各 +80 B) */
uint32_t con_pm_enter;
uint32_t con_pm_exit;
uint32_t con_pm_max_depth;
uint32_t con_pm_at_tx_max;
void (*con_pm_hook)(void);
static uint32_t s_pm_depth;
static uint32_t s_crit_depth;
/* 环形缓冲实体 + 探针(钩子本体在 stub/stm32f4_fake.h,判据说明也在那里) */
@@ -235,6 +243,36 @@ void con_crit_pop(void)
s_crit_depth--;
}
/* PRIMASK 窗口桩:与上面的内核临界区平行(生产侧 __get_PRIMASK/__disable_irq/
* __set_PRIMASK 展开到这里)。成对建模即可——生产侧把 PRIMASK 存在**局部**变量里
* 并成对写回,所以嵌套时深度自然回升;未配对就退出说明生产代码写错了窗口。 */
void con_pm_push(void)
{
con_pm_enter++;
s_pm_depth++;
if (s_pm_depth > con_pm_max_depth) {
con_pm_max_depth = s_pm_depth;
}
if (con_pm_hook != 0) {
con_pm_hook(); /* 深度已记好:注入体看到的是"已在区内" */
}
}
void con_pm_pop(void)
{
con_pm_exit++;
if (s_pm_depth == 0U) {
printf("[FATAL] PRIMASK window exit without enter (unbalanced CON_CRIT_*)\n");
exit(2);
}
s_pm_depth--;
}
uint32_t con_pm_depth(void)
{
return s_pm_depth;
}
osStatus_t osEventFlagsSet(osEventFlagsId_t ef_id, uint32_t flags)
{
(void)ef_id;
@@ -349,6 +387,14 @@ static void reset_all(void)
con_crit_max_depth = 0U;
s_crit_depth = 0U;
/* PRIMASK 窗口一样要清:漏一项就是"单独跑绿、连跑偶红"(上一轮的教训) */
con_pm_enter = 0U;
con_pm_exit = 0U;
con_pm_max_depth = 0U;
con_pm_at_tx_max = 0U;
con_pm_hook = 0;
s_pm_depth = 0U;
memset(con_ring_store, 0, sizeof(con_ring_store));
con_ring_probe_on = 0;
con_ring_probe_hits = 0U;
@@ -562,11 +608,31 @@ static void tc_unattached(void)
* 独占绝不允许把 @115200 约 7.8 ms 的发送包进关中断区,那等于往启动窗口塞
* 7.8 ms 中断延迟与最坏喂狗抖动,比交错本身更糟(Lead 红线);
* ② 全程零次 vTaskDelay:拿不到所有权就当场让路,不排队(见 TC-CONLOG-170 族)。
*
* ⚠ **第二次换钉**(道G task-26,2026-10-26):上面那次只是把数字从 0 挪到 2,
* 钉子仍然钉在**实现细节**(用的是哪一种关中断原语)而不是行为上。本笔把裸发与
* 入环路径的窗口从内核临界区换成 PRIMASK(原因与实测的"9 条任务链各 +80 B"写在
* App/log/console_log.c 的 CON_CRIT_ENTER 注释里:taskEXIT_CRITICAL ⇒
* vPortExitCritical ⇒ configASSERT ⇒ 断言钩子 ⇒ 回到本模块,armlink 把这条环算进
* 每一条含临界区的任务链)。判据随之搬成三条**等价且更强**的行为断言:
* ① 窗口次数与嵌套深度不变(取 + 还各一次、深度 1)=> 独占语义一点没丢;
* ② 整条裸发路径 **0 次内核临界区调用** => 这同时就是"那条 configASSERT 环不
* 成立"的机器证据,不靠任何人的口头保证;
* ③ 上线字节看到的**两种**屏蔽深度水位都为 0 => "发送绝不包进关中断区"这条红线
* 换了原语之后照旧有牙。断言条数:本段由 2 条变 3 条,只增不减。
*/
CHECK((con_crit_enter == 2U) && (con_crit_exit == 2U) && (con_crit_max_depth == 1U),
"TC-CONLOG-015 direct send takes exactly two few-instruction critical sections (claim + return) and never nests them");
CHECK((con_uart_crit_at_tx_max == 0U) && (con_delay_calls == 0U),
"TC-CONLOG-015b whole-line ownership puts every wire byte outside any critical section and never waits");
CHECK((con_pm_enter == 2U) && (con_pm_exit == 2U) && (con_pm_max_depth == 1U)
&& (con_crit_enter == 0U),
"TC-CONLOG-015 direct send takes exactly two few-instruction PRIMASK windows (claim + return), never nests them, and calls the kernel critical API zero times (that is what keeps the configASSERT cycle out of every task's worst-case stack)");
/* ⚠ 编号**不退役**:015b 这个号生产注释(App/log/console_log.c 的 CON_CRIT_ENTER
* 一处、Src/stm32f4xx_it.c 的 hard_fault_write 一处)与本文档都在引用,一旦把它换成
* 新号,那些引用立刻变成悬空号(今天已经被这类问题咬过两次)。所以这里保持
* 015b = "上线字节的屏蔽水位恒 0"这条红线(换了原语,语义照旧),新加的"绝不等待"
* 另起 015c。 */
CHECK((con_uart_crit_at_tx_max == 0U) && (con_pm_at_tx_max == 0U),
"TC-CONLOG-015b every byte reaches the wire with interrupts enabled under BOTH masking primitives (the ~7.8 ms transmit is never inside a critical window)");
CHECK(con_delay_calls == 0U,
"TC-CONLOG-015c whole-line ownership never waits: contention yields on the spot instead of queueing");
reset_all();
memset(big, 'B', sizeof(big));
@@ -598,8 +664,9 @@ static void tc_ring(void)
CHECK(used_now() == 12U, "TC-CONLOG-024 the whole line enters the ring at once (used = 12)");
CHECK(con_evt_sets == 1U && con_evt_flags == SYS_EVT_CONSOLE_TX,
"TC-CONLOG-025 after entering the ring the real event bit SYS_EVT_CONSOLE_TX (bit6) wakes the writer");
CHECK(con_crit_enter == 1U && con_crit_exit == 1U && con_crit_max_depth == 1U,
"TC-CONLOG-026 one line takes exactly one critical-section level (implementation basis of line atomicity)");
CHECK(con_pm_enter == 1U && con_pm_exit == 1U && con_pm_max_depth == 1U
&& con_crit_enter == 0U,
"TC-CONLOG-026 one line takes exactly one PRIMASK window at depth one and no kernel critical section (implementation basis of line atomicity)");
writer_drain();
CHECK(tx_is(0U, "netTask up\r\n"), "TC-CONLOG-027 writer drain: wire bytes are byte-identical to what entered the ring");
@@ -1172,15 +1239,15 @@ static void tc_check_then_act(void)
con_ring_probe_hits = 0U;
con_ring_zero_depth_hits = 0U;
con_ring_probe_on = 1; /* 探针只在本次调用期间计数 */
con_crit_hook = crit_second_producer;
con_pm_hook = crit_second_producer; /* 窗口换 PRIMASK 之后注入点跟着搬 */
con_dwt_step = SystemCoreClock / 4U; /* 复核不过就直接判上界,不空等 */
rc = dbg_console_write_line(first, 60U);
con_crit_hook = 0;
con_pm_hook = 0;
con_ring_probe_on = 0;
con_dwt_step = 0U;
used_after = used_now();
CHECK(s_inject_hits == 1U && con_crit_max_depth == 2U,
CHECK(s_inject_hits == 1U && con_pm_max_depth == 2U,
"TC-CONLOG-160 the second producer ran with the region already held (depth two nesting)");
CHECK(rc == -1 && dbg_console_dropped() == 1U,
"TC-CONLOG-161 the first producer re-checked inside the region, found space gone and dropped instead of overwriting");
@@ -1339,8 +1406,8 @@ static void tc_ownership(void)
(void)dbg_console_tx_begin();
dbg_console_write_raw("EARLY", 5U);
CHECK((con_uart_tx_n == 5U) && tx_is(0U, "EARLY") && (dbg_console_dropped() == 0U)
&& (s_con_tx_owner == 0) && (con_crit_enter == 0U),
"TC-CONLOG-176 before the scheduler starts, the single execution stream sends directly, taking no token and no critical section");
&& (s_con_tx_owner == 0) && (con_crit_enter == 0U) && (con_pm_enter == 0U),
"TC-CONLOG-176 before the scheduler starts, the single execution stream sends directly, taking no token and opening neither kind of critical window");
/* 177 硬红线:整行独占绝不能把 @115200 约 7.8 ms 的发送包进关中断区 */
reset_all();
+9 -3
View File
@@ -5,16 +5,22 @@
做什么:
用本机 MSVC 编译 test/host/console_log/console_log_test.c(以 #include 纳入生产源
App/log/console_log.c),在 PC 上离线验证单写者环形缓冲的全套契约:192B 行截断、
256B 分块、跨环尾两段拷贝、写者未注册/ISR 上下文的无锁直发、缓冲满两条上界
256B 分块、跨环尾两段拷贝、写者未注册/ISR 上下文的直发(未注册期自 2026-10-26 起
为**整行独占**:抢不到独占的整行原文入环延后发,M 族 TC-CONLOG-170..179 负责钉)、
缓冲满两条上界
(DWT 周期 / 200 tick)与丢弃计数、[ERR] 告警行不经环(不递归)、TX 僵尸通道软复位
自愈 + 故障即止 + 300ms 冷却(含无符号回绕)、DWT 使能失败退回 20000 次指令计数、
huart1.Instance==NULL 短路、fputc 重定向、drain 250ms 预算。
huart1.Instance==NULL 短路、fputc 重定向、drain 250ms 预算、行级单一所有者。
工具链与判定委托 test/host/common/unit_test_harness.py。
为什么要主机侧单测(而不是只用台架):
本模块的坑全是"异常时刻 + 节奏":ORE 僵尸通道在软复位后毫秒级重锁、TIM7 停摆让
HAL 的 tick 超时永不成立(第 8a 轮台架:唯一喂狗者死等 TXE -> IWDG 误复位)、
背靠背块超时把主循环拉到 450~500ms 饿死喂狗(台架实测的停喂误复位)。这些在台架上
背靠背块超时把主循环拉到 450~500ms 饿死喂狗(台架实测的停喂误复位);未注册期两个
任务同时裸发会把一行插成两条都读不懂的半行(2026-10-12 B6 与 2026-10-26 两次台架
实证,后者连"多出的字节是邻居行首"的字节账都留了底)——这类形态在台架上不可复现、
事后无法重建现场,只有把抢占塞进"第 N 个字节上线之后"才能做成必然失败的断言。
这些在台架上
要么一年一遇、要么现场已被复位冲掉;把 100/250/300ms 三条时间线做成显式量之后,
299ms 与 300ms 的差别就是一行断言。详见
docs/子系统设计/控制台单写者日志设计说明.md(G1~G5 / K1~K5 / §12 ORE 僵尸通道)。
+35 -1
View File
@@ -110,11 +110,15 @@ extern int con_uart_pending; /* 1 = 有个字节已进 D
/* 前置声明:con_uart_tx_put 要看当前临界区深度(定义在本文件下方的④内核入口段,
* 这里必须先声明,否则 C89 隐式声明在 ARMCC/MSVC 上都是错误或告警) */
uint32_t con_crit_depth(void);
uint32_t con_pm_depth(void); /* PRIMASK 版关中断窗口深度(前置声明理由同上) */
/* —— 道G task-23 新增的两个把手(判据说明见 con_uart_tx_put 内注释)—— */
extern uint32_t con_uart_tx_hook_at; /* 第 N 个字节上线后触发一次(0=不触发) */
extern void (*con_uart_tx_hook)(void); /* 触发时跑的函数(测试装填) */
extern uint32_t con_uart_crit_at_tx_max; /* 所有上线字节里见过的最大临界区深度 */
extern uint32_t con_pm_at_tx_max; /* 同上,但量的是 PRIMASK 关中断窗口深度
* (道G task-26 之后裸发路径用它,这里先
* 声明:con_uart_tx_put 在本文件上方) */
void con_uart_reset(void);
@@ -147,6 +151,12 @@ static void con_uart_tx_put(uint8_t b) {
if (depth > con_uart_crit_at_tx_max) {
con_uart_crit_at_tx_max = depth;
}
/* 换用 PRIMASK 窗口之后,"发送不许被关中断包住"这条红线由这个水位证明:
* 任一上线字节看到的屏蔽深度必须恒 0。内核临界区水位 + PRIMASK 水位**两个都
* 为 0**,才叫"整行独占没把 @115200 约 7.8 ms 的发送关进中断"。 */
if (con_pm_depth() > con_pm_at_tx_max) {
con_pm_at_tx_max = con_pm_depth();
}
if ((con_uart_tx_hook != 0) && (con_uart_tx_hook_at != 0U)
&& (con_uart_tx_n == con_uart_tx_hook_at)) {
void (*h)(void) = con_uart_tx_hook;
@@ -288,6 +298,27 @@ extern void (*con_crit_hook)(void);
#define taskENTER_CRITICAL() con_crit_push()
#define taskEXIT_CRITICAL() con_crit_pop()
/* —— PRIMASK 版关中断窗口(生产侧 CON_CRIT_ENTER/EXIT 展开后落到这三个原语)——
* "关没关中断"不许靠人嘴说、也不许靠我保证,这三个把手让用例能机器断言:
* ① 取环写环 / 清属主确实发生在关中断窗口内(环探针 + 深度水位);
* ② 整条裸发路径 0 次内核临界区调用(con_crit_enter == 0)——这条同时是
* "taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒ 断言钩子 ⇒ 回本模块"
* 那条环**不成立**的证据(那条环在静态调用图上会被算进每一条含临界区的任务链,
* 实测三条链各 +80 B,见 console_log.c 的 CON_CRIT_ENTER 注释);
* ③ 每个上线字节看到的屏蔽深度恒 0(发送绝不关中断)。
* 深度按"成对 push/pop"建模:生产侧保存的是**局部** PRIMASK 副本并成对写回,
* 所以嵌套时深度自然回升,注入钩子看到的也是"已在区内"。 */
extern uint32_t con_pm_enter; /* __disable_irq 次数 */
extern uint32_t con_pm_exit; /* __set_PRIMASK 次数 */
extern uint32_t con_pm_max_depth; /* 曾达到的最大嵌套深度 */
extern uint32_t con_pm_at_tx_max; /* 任一上线字节所见屏蔽深度水位(必须恒 0) */
extern void (*con_pm_hook)(void); /* 进窗回调:把"第二个生产者"塞进关中断窗口里 */
void con_pm_push(void);
void con_pm_pop(void);
#define __get_PRIMASK() ((uint32_t)(con_pm_depth() != 0U ? 1U : 0U))
#define __disable_irq() con_pm_push()
#define __set_PRIMASK(v) con_pm_pop()
/* 环形缓冲实体改由测试持有:生产代码里的 s_con_ring 被宏名成 (*con_ring_cell()),
* 于是**每一次对环的取址访问都进这个钩子** —— 这是本轮唯一能把"整行拷贝发生在
* 临界区内、而读环发送发生在临界区外"这件事直接断言出来的办法(生产侧的空位判据
@@ -307,7 +338,10 @@ extern int con_ring_draining;
static uint8_t (*con_ring_cell())[4096U] {
if (con_ring_probe_on != 0) {
con_ring_probe_hits++;
if ((con_crit_depth() == 0U) && (con_ring_draining == 0)) {
/* 两种关中断窗口都算"在区内":内核临界区(BASEPRI;写者 drain 推 tail 那一处
* 仍是它)与本模块自己的 PRIMASK 窗口(入环拷贝 / 取还所有权)。换原语之后
* 判据含义不变:**生产者绕过任何关中断窗口碰环**才算竞态回来了。 */
if ((con_crit_depth() == 0U) && (con_pm_depth() == 0U) && (con_ring_draining == 0)) {
con_ring_zero_depth_hits++; /* 生产者绕过临界区碰环 = 竞态回来了 */
}
}
+8 -3
View File
@@ -169,12 +169,15 @@ static int g_wait_seq[8]; /* 每次 vol_lock_wait 的返回值脚本(0=
static int g_wait_calls;
static int g_release_calls;
static int g_beat_calls;
static uint32_t s_last_wait_to; /* 最近一次 vol_lock_wait 的 timeout 入参。字段名与另三套假件
* 逐字一致(本文件其余假件用 g_ 前缀,这里让"喂狗点绝不阻塞"
* 这条纪律能一次 grep 到四套);第 6 套出现时再议共享假件 */
int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
s_last_wait_to = timeout_ms; /* 记录入参:本套的口径是 100 ms 分片,不是 try */
r = (g_wait_calls < (int)(sizeof(g_wait_seq) / sizeof(g_wait_seq[0])))
? g_wait_seq[g_wait_calls] : 1;
g_wait_calls++;
@@ -244,6 +247,7 @@ static void reset_all(void)
g_wait_calls = 0;
g_release_calls = 0;
g_beat_calls = 0;
s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=0 */
g_open_calls = 0;
g_open_rc = FR_OK;
g_open_path[0] = '\0';
@@ -353,8 +357,9 @@ static void tc_open(void)
g_wait_seq[1] = 0; /* 第二轮:仍未就绪 */
g_wait_seq[2] = 1; /* 第三轮:拿到 */
rc = lftpd_io_open_read("/x");
CHECK((rc == 0) && (g_beat_calls == 2) && (g_wait_calls == 3),
"TC-LFTPIO-034 the lock-wait loop feeds the watchdog every round (missing a feed resets the whole device)");
CHECK((rc == 0) && (g_beat_calls == 2) && (g_wait_calls == 3)
&& (s_last_wait_to == 100u),
"TC-LFTPIO-034 the lock-wait loop feeds the watchdog every round (missing a feed resets the whole device) + every slice asks timeout=100ms (never try, never forever)");
reset_all();
rc = lftpd_io_open_write("/up.bin");
+12 -6
View File
@@ -155,7 +155,10 @@ static int s_lock_busy_left; /* >0:接下来 N 次 vol_lock_wait 返回"
static int s_lock_waits;
static int s_lock_releases;
static int s_lock_held;
static int s_lock_seq_err; /* 未持锁却 release = 锁纪律被写反 */
static int s_lock_seq_err;
static uint32_t s_last_wait_to; /* 最近一次 vol_lock_wait 的 timeout 入参(0 = try 语义)。
* 四套假件逐字同名,便于全仓 grep「喂狗点绝不阻塞」这条纪律;
* 第 6 套出现时再议共享假件 */ /* 未持锁却 release = 锁纪律被写反 */
static void lock_busy_next(int n)
{
@@ -169,6 +172,7 @@ static void locks_clear(void)
s_lock_releases = 0;
s_lock_held = 0;
s_lock_seq_err = 0;
s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=0 */
}
int vol_lock_init(void) { return 0; }
@@ -177,7 +181,7 @@ int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
s_last_wait_to = timeout_ms; /* 记录入参:调用方的 timeout 口径由此可断言 */
/* 跑飞保险:调用数越界直接判死,不让死循环拖挂门禁 */
if (s_lock_waits > 20000) {
(void)fprintf(stderr, "[FATAL] vol_lock_wait call guard tripped\n");
@@ -553,8 +557,9 @@ static void tc_drain_paths(void)
ok = (s_lock_waits == 1) && (s_lock_releases == 0) && (s_lock_seq_err == 0);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0)
&& (s_ring_tail == 0u) && (g_soe_err_count == 0),
"TC-SOESTORE-016 volume busy means defer: zero FatFs, zero release, no fault counted (feed duty)");
&& (s_ring_tail == 0u) && (g_soe_err_count == 0)
&& (s_last_wait_to == 0u), /* 喂狗点绝不阻塞:取锁一律传 0 */
"TC-SOESTORE-016 volume busy means defer: zero FatFs, zero release, no fault counted (feed duty) + lock asked with timeout=0");
/* 017:写失败时环不推进 tail -> 下轮重试、既不丢也不重复 */
reset_all();
@@ -583,8 +588,9 @@ static void tc_drain_paths(void)
CHECK((s_q_cnt == 1u)
&& (g_ff_obs.calls[FF_FAKE_OP_OPEN] == opens_before)
&& (g_ff_obs.calls[FF_FAKE_OP_WRITE] == writes_before)
&& (s_lock_waits >= 1) && (s_lock_seq_err == 0) && (s_q_get_calls >= 2),
"TC-SOESTORE-018 queue path with busy volume: only the dequeued entry is lost, zero FatFs now");
&& (s_lock_waits >= 1) && (s_lock_seq_err == 0) && (s_q_get_calls >= 2)
&& (s_last_wait_to == 0u),
"TC-SOESTORE-018 queue path with busy volume: only the dequeued entry is lost, zero FatFs now + asked with timeout=0");
/* 019:容量满 -> unlink 重建 + INFO 留痕,新文件恰好 64 字节 */
reset_all();
+10 -4
View File
@@ -391,6 +391,9 @@ static int s_lock_waits;
static int s_lock_releases;
static int s_lock_held;
static int s_lock_seq_err;
static uint32_t s_last_wait_to; /* 最近一次 vol_lock_wait 的 timeout 入参(0 = try 语义)。
* 四套假件逐字同名,便于全仓 grep「喂狗点绝不阻塞」这条纪律;
* 第 6 套出现时再议共享假件 */
static void lock_busy_next(int n)
{
@@ -415,6 +418,7 @@ static void locks_clear(void)
s_lock_releases = 0;
s_lock_held = 0;
s_lock_seq_err = 0;
s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=0 */
}
int vol_lock_init(void) { return 0; }
@@ -423,7 +427,7 @@ int vol_lock_wait(uint32_t timeout_ms)
{
int r;
(void)timeout_ms;
s_last_wait_to = timeout_ms; /* 记录入参:调用方的 timeout 口径由此可断言 */
if (s_lock_waits > 20000) {
(void)fprintf(stderr, "[FATAL] vol_lock_wait guard tripped\n");
exit(2);
@@ -732,8 +736,9 @@ static void tc_write_single(void)
&& (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 1)
&& (g_ff_obs.calls[FF_FAKE_OP_CLOSE] == 1)
&& (s_lock_releases == 1) && (s_lock_seq_err == 0)
&& (s_last_wait_to == 0u) /* 喂狗点绝不阻塞:取锁一律传 0 */
&& (g_sys_err_count == 0);
CHECK(ok, "TC-SYSLOG-010 single write ok: mode carries the write bit + open/write/close once each + lock released");
CHECK(ok, "TC-SYSLOG-010 single write ok: mode carries the write bit + open/write/close once each + lock released + lock asked with timeout=0 (feed point never blocks)");
/* 011 陷阱一保真对照 */
reset_all();
@@ -757,8 +762,9 @@ static void tc_write_single(void)
ok = (s_append(SYSLOG_RUN, "x") == -2);
CHECK(ok && (g_ff_obs.calls[FF_FAKE_OP_OPEN] == 0)
&& (g_ff_obs.calls[FF_FAKE_OP_WRITE] == 0)
&& (s_lock_releases == 0) && (s_lock_seq_err == 0) && (g_sys_err_count == 0),
"TC-SYSLOG-012 volume busy means defer: no disk touch, no release, no fault counted (feed point never blocks)");
&& (s_lock_releases == 0) && (s_lock_seq_err == 0) && (g_sys_err_count == 0)
&& (s_last_wait_to == 0u),
"TC-SYSLOG-012 volume busy means defer: no disk touch, no release, no fault counted (feed point never blocks) + defer path also asked with timeout=0");
/* 013 短写 -> 自愈 */
reset_all();
+9 -5
View File
@@ -1216,15 +1216,19 @@ static void tc_record_codes(void)
}
/*
* TC-UIDATA-025 维护通道未注入时的防御:六个口一律安全返回、不崩、ready 返 0;
* TC-UIDATA-025 维护通道未注入时的防御:六个口一律安全返回、不崩;
* 残表(某成员 NULL)整表拒绝且不覆盖已生效的好表——半途接住会在"按下才崩"的位置崩。
* "通道此刻绑没绑"一律用**可观察行为**判(025i/025k/025m 走 reset_req 的返回值),
* 不再有 ui_data_maint_ready() 那种状态查询口:它写完之后全仓零消费者、只有本
* 用例在读,等于给下一个人留一个看起来能用的假接口(Lead 裁定 2026-10-26,
* 与 generated-fresh 今天抓的是同一类东西)。真需要装配自检时,让它带着消费者
* 一起回来。
*/
static void tc_maint_not_bound(void)
{
(void)ui_data_maint_bind(NULL);
stub_reset_all();
CHECK(ui_data_maint_ready() == 0u, "TC-UIDATA-025a not bound before bind");
CHECK(ui_data_maint_clock_set(1792915200u) == -1, "TC-UIDATA-025b clock_set not bound is -1");
CHECK(ui_data_maint_reset_req() == -1, "TC-UIDATA-025c reset_req not bound is -1");
CHECK(ui_data_maint_active_count() == 0u, "TC-UIDATA-025d active_count not bound is 0");
@@ -1233,11 +1237,11 @@ static void tc_maint_not_bound(void)
CHECK(ui_data_maint_clear_state(UI_W_REC_LOG_RUN) == UI_DATA_CLEAR_ST_NONE,
"TC-UIDATA-025g clear_state not bound is NONE");
CHECK(ui_data_maint_bind(&s_maint_broken) == -1, "TC-UIDATA-025h incomplete table refused");
CHECK(ui_data_maint_ready() == 0u, "TC-UIDATA-025i refused table left channel unbound");
CHECK(ui_data_maint_reset_req() == -1, "TC-UIDATA-025i refused table left channel unbound");
CHECK(ui_data_maint_bind(&s_maint_stub) == 0, "TC-UIDATA-025j complete table accepted");
CHECK(ui_data_maint_ready() == 1u, "TC-UIDATA-025k bound after accept");
CHECK(ui_data_maint_reset_req() == 0, "TC-UIDATA-025k bound table forwards");
CHECK(ui_data_maint_bind(&s_maint_broken) == -1, "TC-UIDATA-025l broken table refused again");
CHECK(ui_data_maint_ready() == 1u, "TC-UIDATA-025m refusal keeps the good table active");
CHECK(ui_data_maint_reset_req() == 0, "TC-UIDATA-025m refusal keeps the good table active");
}
/*
+72
View File
@@ -30,6 +30,14 @@
* TC-VOLLOCK-012 release 在锁未创建时静默返回(不崩溃、不调用)
* TC-VOLLOCK-013 完整周期 init -> wait -> release -> wait -> release
* TC-VOLLOCK-014 wait(0) 的 timeout 透传为 0(defaultTask 的 try 语义)
* —— 观测计数(道G task-17「卷锁 5 态可观测性」)——
* TC-VOLLOCK-015 成功路径只涨 acquired,三个异常计数必须为 0
* TC-VOLLOCK-016 timeout=0 未取到 -> 只进 try_timeout(T2,没等)
* TC-VOLLOCK-017 timeout=100 未取到 -> 只进 blk_timeout(T3,等了没等到),
* 与 T2 互不冒领
* TC-VOLLOCK-018 ⭐ fail-open 仍返 1(口径不变)但 failopen 计数自增并可累加
* —— 把"谎报成功"那一态变成事后可查的证据
* TC-VOLLOCK-019 观测口:NULL 无害、重复读取不消耗证据
*
* 通过:全部 TC PASS(main 返回 0)。
* 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
@@ -108,6 +116,12 @@ static void fakes_reset(void) {
s_last_timeout = 0xFFFFFFFFu;
s_fake_mutex = (void *)0x1234;
s_lock = NULL;
/* 观测计数同属被测模块的文件静态量(道G task-17):复位后每条 TC 才能读到
* "本条自己造出来的那几个事件",而不是前一条的残留 */
s_st_acquired = 0u;
s_st_try_timeout = 0u;
s_st_blk_timeout = 0u;
s_st_failopen = 0u;
}
/* ===================== TC-VOLLOCK-001..004:vol_lock_init ===================== */
@@ -206,6 +220,62 @@ static void tc_release(void) {
CHECK(s_last_timeout == 100U, "TC-VOLLOCK-013f cycle: second wait timeout passes through correctly");
}
/* ===================== TC-VOLLOCK-015..019:观测计数(道G task-17) =====================
* 这一组是"5 态可观测性"的机器证据:
* T-a acquired / T2 try_timeout / T3 blk_timeout / T4 failopen 各自独立自增,
* T2 与 T3 不许互相冒领(那是本卡唯一"语义合并"风险),T4 从"谎报成功"变成可查。
*/
static void tc_stats(void) {
vol_lock_stats_t st;
/* 015 成功路径:只有 acquired 动,其余三项必须仍是 0 */
fakes_reset();
(void)vol_lock_init();
CHECK(vol_lock_wait(0U) == 1, "TC-VOLLOCK-015 try acquire succeeds");
vol_lock_get_stats(&st);
CHECK((st.acquired == 1u) && (st.try_timeout == 0u) && (st.blk_timeout == 0u)
&& (st.failopen == 0u),
"TC-VOLLOCK-015b acquired only: a successful lock never inflates any anomaly counter");
/* 016 T2:timeout=0 的 try 失败 -> 只进 try_timeout */
fakes_reset();
(void)vol_lock_init();
s_wait_ok = 0;
CHECK(vol_lock_wait(0U) == 0, "TC-VOLLOCK-016 try on a held volume returns 0");
vol_lock_get_stats(&st);
CHECK((st.try_timeout == 1u) && (st.blk_timeout == 0u) && (st.acquired == 0u),
"TC-VOLLOCK-016b timeout=0 miss counts as try_timeout and NOT as blk_timeout (feed point never waited)");
/* 017 T3:timeout>0 的分片等待超时 -> 只进 blk_timeout(与 T2 分道) */
fakes_reset();
(void)vol_lock_init();
s_wait_ok = 0;
CHECK(vol_lock_wait(100U) == 0, "TC-VOLLOCK-017 100ms slice on a held volume returns 0");
vol_lock_get_stats(&st);
CHECK((st.blk_timeout == 1u) && (st.try_timeout == 0u),
"TC-VOLLOCK-017b slice timeout counts as blk_timeout and NOT as try_timeout (waited-but-missed is a different fact)");
/* 018 T4 核心:锁不存在时 wait 仍返 1(TC-VOLLOCK-008 的口径),但现在必须留下
* 可查证据 —— 这一态此前被谎报成"取到锁",整机没有任何地方能发现它 */
fakes_reset();
s_new_fail = 1;
CHECK(vol_lock_wait(0U) == 1, "TC-VOLLOCK-018 fail-open still returns 1 (unchanged contract, TC-VOLLOCK-008)");
vol_lock_get_stats(&st);
CHECK((st.failopen == 1u) && (st.acquired == 0u),
"TC-VOLLOCK-018b fail-open is now provable: failopen counts up while acquired stays 0 (zero mutual exclusion)");
CHECK(vol_lock_wait(0U) == 1, "TC-VOLLOCK-018c second fail-open call also returns 1");
vol_lock_get_stats(&st);
CHECK(st.failopen == 2u, "TC-VOLLOCK-018d fail-open accumulates (consumer latches on the delta, not on the event)");
/* 019 观测口自身:NULL 无害、读取不消耗证据、逐字段稳定 */
vol_lock_get_stats(NULL);
CHECK(1, "TC-VOLLOCK-019 get_stats(NULL) is harmless (a defensive caller must never crash the feed point)");
vol_lock_get_stats(&st);
CHECK((st.failopen == 2u) && (st.try_timeout == 0u) && (st.blk_timeout == 0u)
&& (st.acquired == 0u),
"TC-VOLLOCK-019b reading does not consume the evidence: counters survive repeated reads");
}
/* ===================== main ===================== */
int main(void) {
@@ -217,6 +287,8 @@ int main(void) {
tc_wait();
printf("-- vol_lock_release --\n");
tc_release();
printf("-- observability counters (task-17) --\n");
tc_stats();
printf("\n=== Volume Lock Tests: %d/%d PASSED (failed=%d) ===\n",
s_passed, s_total, s_failed);