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
+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);