diff --git a/.cubemx_survival_baseline.json b/.cubemx_survival_baseline.json index 1e577ebe..a77596aa 100644 --- a/.cubemx_survival_baseline.json +++ b/.cubemx_survival_baseline.json @@ -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", diff --git a/App/algo/adc_cal.c b/App/algo/adc_cal.c index 7810cf29..7cf76a58 100644 --- a/App/algo/adc_cal.c +++ b/App/algo/adc_cal.c @@ -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 diff --git a/App/algo/adc_cal.h b/App/algo/adc_cal.h index 31427080..8e43958f 100644 --- a/App/algo/adc_cal.h +++ b/App/algo/adc_cal.h @@ -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 diff --git a/App/app/ui_data_feed.c b/App/app/ui_data_feed.c index cec6ef47..0fbf6063 100644 --- a/App/app/ui_data_feed.c +++ b/App/app/ui_data_feed.c @@ -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)) { diff --git a/App/fs/vol_lock.c b/App/fs/vol_lock.c index 4c990ae2..712eaa68 100644 --- a/App/fs/vol_lock.c +++ b/App/fs/vol_lock.c @@ -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; +} diff --git a/App/fs/vol_lock.h b/App/fs/vol_lock.h index c2502a61..47b3971b 100644 --- a/App/fs/vol_lock.h +++ b/App/fs/vol_lock.h @@ -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); + /* * 函数功能:释放卷锁(获取方任务释放) * 入口参数:无 diff --git a/App/log/console_log.c b/App/log/console_log.c index f9d65af5..a5bd1cc6 100644 --- a/App/log/console_log.c +++ b/App/log/console_log.c @@ -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; } diff --git a/App/task/protect_task.c b/App/task/protect_task.c index e4155780..162f1f5d 100644 --- a/App/task/protect_task.c +++ b/App/task/protect_task.c @@ -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, diff --git a/App/util/task_watchdog.c b/App/util/task_watchdog.c index 44ed6c3f..48184d15 100644 --- a/App/util/task_watchdog.c +++ b/App/util/task_watchdog.c @@ -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); } } diff --git a/Drivers/BSP/UI/ui_data.c b/Drivers/BSP/UI/ui_data.c index 60f33686..60bd2644 100644 --- a/Drivers/BSP/UI/ui_data.c +++ b/Drivers/BSP/UI/ui_data.c @@ -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 diff --git a/Drivers/BSP/UI/ui_data.h b/Drivers/BSP/UI/ui_data.h index df0ad921..45afd248 100644 --- a/Drivers/BSP/UI/ui_data.h +++ b/Drivers/BSP/UI/ui_data.h @@ -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 diff --git a/Src/freertos.c b/Src/freertos.c index c8cd063f..cefd1985 100644 --- a/Src/freertos.c +++ b/Src/freertos.c @@ -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); } diff --git a/Src/stm32f4xx_it.c b/Src/stm32f4xx_it.c index f898764c..35baf5f5 100644 --- a/Src/stm32f4xx_it.c +++ b/Src/stm32f4xx_it.c @@ -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(); } diff --git a/docs/UI设计/UI_数据供给设计说明.md b/docs/UI设计/UI_数据供给设计说明.md index 556c0957..29bf86c2 100644 --- a/docs/UI设计/UI_数据供给设计说明.md +++ b/docs/UI设计/UI_数据供给设计说明.md @@ -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` diff --git a/docs/子系统设计/控制台单写者日志设计说明.md b/docs/子系统设计/控制台单写者日志设计说明.md index dbccb6fc..194f7624 100644 --- a/docs/子系统设计/控制台单写者日志设计说明.md +++ b/docs/子系统设计/控制台单写者日志设计说明.md @@ -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] 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 纪律示例更新 | diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index 208c37e5..f7e365e6 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -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 逐字节相同) | diff --git a/docs/测试说明/卷锁单元测试说明.md b/docs/测试说明/卷锁单元测试说明.md index 727bafcd..2eeb8cce 100644 --- a/docs/测试说明/卷锁单元测试说明.md +++ b/docs/测试说明/卷锁单元测试说明.md @@ -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 是本套件存在的主要理由**:真机上需堆耗尽才可能触发,板端无法构造。 diff --git a/test/host/adc_cal/adc_cal_test.c b/test/host/adc_cal/adc_cal_test.c index aaf19799..dea17125 100644 --- a/test/host/adc_cal/adc_cal_test.c +++ b/test/host/adc_cal/adc_cal_test.c @@ -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); diff --git a/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c index b9ebe2a9..dceac963 100644 --- a/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c +++ b/test/host/cfg_file_store_mcu/cfg_file_store_mcu_test.c @@ -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"); } diff --git a/test/host/console_log/console_log_test.c b/test/host/console_log/console_log_test.c index 867948b4..01a992b0 100644 --- a/test/host/console_log/console_log_test.c +++ b/test/host/console_log/console_log_test.c @@ -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(); diff --git a/test/host/console_log/run_unit_test.py b/test/host/console_log/run_unit_test.py index f864dc96..20bdd352 100644 --- a/test/host/console_log/run_unit_test.py +++ b/test/host/console_log/run_unit_test.py @@ -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 僵尸通道)。 diff --git a/test/host/console_log/stub/stm32f4_fake.h b/test/host/console_log/stub/stm32f4_fake.h index ab46bc50..9a26a272 100644 --- a/test/host/console_log/stub/stm32f4_fake.h +++ b/test/host/console_log/stub/stm32f4_fake.h @@ -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++; /* 生产者绕过临界区碰环 = 竞态回来了 */ } } diff --git a/test/host/lftpd_io/lftpd_io_test.c b/test/host/lftpd_io/lftpd_io_test.c index 9e24ae0f..05ffbe90 100644 --- a/test/host/lftpd_io/lftpd_io_test.c +++ b/test/host/lftpd_io/lftpd_io_test.c @@ -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"); diff --git a/test/host/soe_store/soe_store_test.c b/test/host/soe_store/soe_store_test.c index 48bf858a..83e9db0f 100644 --- a/test/host/soe_store/soe_store_test.c +++ b/test/host/soe_store/soe_store_test.c @@ -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(); diff --git a/test/host/sys_log_mcu/sys_log_mcu_test.c b/test/host/sys_log_mcu/sys_log_mcu_test.c index f0906fc1..2aa76f01 100644 --- a/test/host/sys_log_mcu/sys_log_mcu_test.c +++ b/test/host/sys_log_mcu/sys_log_mcu_test.c @@ -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(); diff --git a/test/host/ui_data/ui_data_test.c b/test/host/ui_data/ui_data_test.c index 6f2cb806..aec2699c 100644 --- a/test/host/ui_data/ui_data_test.c +++ b/test/host/ui_data/ui_data_test.c @@ -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"); } /* diff --git a/test/host/vol_lock/vol_lock_test.c b/test/host/vol_lock/vol_lock_test.c index 436d3d8b..215f8af4 100644 --- a/test/host/vol_lock/vol_lock_test.c +++ b/test/host/vol_lock/vol_lock_test.c @@ -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);