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
+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++; /* 生产者绕过临界区碰环 = 竞态回来了 */
}
}