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
+23 -1
View File
@@ -264,6 +264,21 @@ void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName)
uint16_t len = 0u;
(void)xTask;
/* ⚠ 刻意**不**用 dbg_console_tx_begin()/tx_end() 包这三条裸发(道G task-26 定案,
* 2026-10-26;早先的版本包过,这一笔摘掉,三条理由都要看):
* ① 语义上空转:异常上下文 IPSR != 0 时 begin 是**放行、不持有凭证**("绝不等待"
* 这条约束的必然结果),未持有时 end 又是幂等空操作 —— 包上得不到任何整行保护。
* 本行的整行性来自"Fault 优先级 -1、外设中断 ≥5、任务全冻结 ⇒ 没有第二个写者
* 能跑进来",与 begin 无关。
* ② 代价却是全局的:本钩子挂在 configASSERT 子树上(vPortEnterCritical /
* vPortExitCritical 都可能进断言),挂在那上面的每一句都会被 armlink ×9 条任务链
* 记账。实测包上后 con_tx_release 子树 24→152 B、发送环单趟贡献 128→208 B,
* default/net/iec104 三条链各 +80 B,把 iec104Task 的 margin 顶到 1,016 < 1,024
* (红线数字见 App/log/console_log.c 的 CON_CRIT_ENTER 注释)。
* ③ 归还所有权在这里没有下游读者:这一行发完就 NVIC_SystemReset()。
* 对照:**任务态**多段裸发(App/util/task_watchdog.c 的 STALL 行)仍然包 begin/end
* —— 那里既不空转(同任务后面还要接着发),也不在断言子树上。这条区分写进
* docs/子系统设计/控制台单写者日志设计说明.md §4.4.1,别让下一个人以为是我们漏了。 */
dbg_console_write_raw(msg_head, (unsigned)(sizeof(msg_head) - 1u));
if ((pcTaskName != NULL) && (hook_ptr_ok(pcTaskName) != 0)) {
@@ -281,7 +296,10 @@ void vApplicationStackOverflowHook(xTaskHandle xTask, signed char *pcTaskName)
dbg_console_write_raw(name, (unsigned)len);
}
dbg_console_write_raw("\r\n", 2u);
HAL_Delay(200);
/* 这里不需要任何延时:唯一理由点写在 Src/stm32f4xx_it.c 的 hard_fault_report 函数头
* 第 ② 条(发送口自身已等到 TC;多留一条 HAL_Delay 就多一条 TIM7 tick 依赖,tick
* 一停摆就卡在复位之前,而启动段 IWDG 尚未启动、没有兜底),此处只引用不复述。
* 顺序不变:三条裸发全部发完 → 立即 NVIC_SystemReset()。 */
NVIC_SystemReset();
}
/* USER CODE END 4 */
@@ -547,6 +565,10 @@ void vApplicationAssertFailed(const char *file, long line)
len++;
}
name[len] = '\0';
/* ⚠ 与栈溢出钩子同理,刻意不包 begin/end(三条完整理由写在同文件
* vApplicationStackOverflowHook 上方那一处,这里不复述以免长成第二真源)。
* 另外修正先前版本自身的一处不完整:那对 begin/end 只包住了"有文件名"
* 这一支,else 分支(无文件名)根本不在窗口里 —— 半套独比亚没有更误导人。 */
dbg_console_write_raw(msg_head, (unsigned)(sizeof(msg_head) - 1u));
dbg_console_write_raw(name, (unsigned)len);
}
+61 -4
View File
@@ -23,6 +23,13 @@
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "usart.h"
/* 道G task-26:崩溃现场上报改走控制台单写者的**有界**发送口(dbg_console_write_raw)。
* 原来这里直接调 HAL_UART_Transmit,于是崩溃路径成了绕过 console_log 行级独占的
* **第三个写者**,并且带两条本仓已实证的失效:① 超时判据是 HAL_GetTick()(TIM7 停摆
* 时永不成立,第 8a/8b 轮台架实证);② 僵尸通道(ORE 锁存)下 HAL 恒快速失败
* (docs/子系统设计/控制台单写者日志设计说明.md §12)—— 而 [ERR] 崩溃行正是台架
* 「出现即不通过」的判据来源,快速失败 = 关键证据整条蒸发。 */
#include "console_log.h"
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
@@ -60,8 +67,14 @@
* 从此重生成不会丢失。
*
* 说明:本函数由 __asm void HardFault_Handler 用 B 直接跳入(见下方),
* 不经内核、不 printf、不遍历指针,只用 HAL_UART_Transmit 轮询 + SCB 寄存器读取,
* 因此在内核状态已不可信时仍可用。输出后 HAL_Delay(200) 排空 UART 再复位。
* 不经内核、不 printf、不遍历指针,只用寄存器级有界轮询(dbg_console_write_raw)+
* SCB 寄存器读取,因此在内核状态已不可信时仍可用。
* 2026-10-26(道G task-26)两处改动:
* ① 发送口由 HAL_UART_Transmit 换成 dbg_console_write_raw(理由见 hard_fault_write);
* ② 去掉输出后的 HAL_Delay(200):dbg_console_write_raw 自身等到 TC(移位寄存器排空)
* 才返回,延时本就冗余;去掉它是为了**少一条 tick 依赖**——HAL_Delay 也走
* TIM7 时基,tick 停摆时会卡死在复位之前(启动段 IWDG 尚未启动,没有兜底)。
* 同文件断言钩子早有同款理由的注释("无需 HAL_Delay:con_tx_bounded 已等到 TC")。
*/
static void hard_fault_write(const char *s, uint16_t len)
{
@@ -73,7 +86,21 @@ static void hard_fault_write(const char *s, uint16_t len)
if (len > 64u) {
len = 64u;
}
HAL_UART_Transmit(&huart1, (const uint8_t *)s, len, 0xFFFFu);
/* 道G task-26:改走 dbg_console_write_raw(原来是 HAL_UART_Transmit(&huart1, …,
* 0xFFFF))。三条换它的理由,全部有仓内实证或既有判据背书:
* ① **不依赖 tick**:本口的上界是 DWT CYCCNT(CON_TX_TIMEOUT_CYC = 100ms 对应
* 周期数),DWT 使能不上时退回指令计数上界(CON_TX_GUARD_MAX)——两条都与
* TIM7/uwTick 无关。HAL 那条是 UART_WaitOnFlagUntilTimeout(HAL_GetTick(),
* Timeout=0xFFFF):正常上限约 65 秒,且 tick 一停摆就**永不超时**。
* ② **不受 HAL 状态机与 ORE 影响**:本口只轮询 TXE/DR/TC,不读 gState,僵尸通道
* 下不会"整条不发"(§12);也不再占用 huart1 的 HAL lock——崩溃现场不再可能
* 与被冻结的 defaultTask 所持 BUSY 互绊。USE_FULL_ASSERT 打开时也不再走
* assert_param(IS_UART_INSTANCE(NULL)) 那条二次进断言钩子的潜在递归。
* ③ **未初始化有短路**:huart1.Instance == NULL 时立即返回 HAL_ERROR,绝不解引用
* 地址 0(机器证据 = 主机单测 TC-CONLOG-100;极早期路径仍能走完全行不挂死
* = TC-CONLOG-102)。残余风险照实写在下方 hard_fault_report 函数头。
* 本口自身等到 TC 才返回,所以调用点不需要(也不该)再补 HAL_Delay。 */
dbg_console_write_raw(s, (unsigned)len);
}
/* 字面量输出宏:长度由 sizeof 定,编译期即可确定 */
@@ -105,9 +132,37 @@ static void hard_fault_puthex(uint32_t v)
* (实测:`ADR r1, xxx_tag` 报 `A1516E: Bad symbol`,去掉 static 也无效),
* 而 `__asm` 里也无法给 C 函数传结构体/字符串参数。故把"带 tag"这一步
* 放在普通 C 函数里做。
*
* 关于"14 段裸发要不要包成一次整行独占"(道G task-26 定案,2026-10-26:**不包**):
* 本函数确实是 14 段裸发(tag / PC / LR / xPSR / R0~R3 / CFSR / HFSR / 可选 BFAR /
* 可选 MMFAR),段与段之间也确实可插字节;但 dbg_console_tx_begin()/tx_end() 在这里
* **空转**:异常上下文 IPSR != 0 时 begin 放行且**不持有**凭证("绝不等待"的必然结果),
* end 又只对持有者有效。而包上的代价是 ×9 条任务链的栈记账(本函数经断言/内核钩子
* 挂在 configASSERT 子树上;实测 default/net/iec104 三条链各 +80 B,把 iec104Task 的
* margin 顶到 1,016 < 1,024 ISR 底线)。真正救这条崩溃行的是另外两件事:**发送口归一**
* (hard_fault_write 改走 dbg_console_write_raw,三条理由见其函数头)与**去掉
* HAL_Delay**(少一条 tick 依赖)。整行性的真实依据与残余见
* docs/子系统设计/控制台单写者日志设计说明.md §4.4.1:
* 故障上下文 IPSR != 0,begin() 是**放行**(返回 1 但**不持有**凭证——异常上下文
* 绝不允许等待或失败),所以这里的原子性**不是**靠凭证,而是靠"Fault 优先级 -1、
* 外设中断 ≥5、任务全冻结 ⇒ 没有第二个写者能跑进来"。唯一残留:被抢占任务**已经
* 在移位寄存器里的那 ≤1 个字节**,它会出现在本行**之前**(不影响 [ERR] 前缀可 grep),
* 不是段间插入。已明确否决"发送循环逐字节检查让位标志"那种修法——那会把正在发的
* 启动日志行截断,换不来什么。
*
* 残余风险(照实登记,别当成已消除):
* - **极早期崩溃仍然静默**:若在 MX_USART1_UART_Init() 之前进 Fault,本口的
* `huart1.Instance == NULL` 短路会直接返回、一个字节都不发(换来的是"绝不解引用
* 地址 0",见 hard_fault_write ③;该短路有机器判据 TC-CONLOG-100/102)。旧写法在
* 同样场景下也不发(gState==RESET 不等于 READY ⇒ HAL_BUSY),**并没有因为本次改动
* 变差**,但也别写成"早期崩溃现在能看到"。
* - **ORE/NE/FE 脏标志本身没被消除**:本笔只是不再经由 HAL 状态机,从而消除了
* "崩溃日志整条蒸发"这一类;"控制台输出可能被 RX 悬空噪声污染"那一条仍在
* §12 既有观察项名下(RX 配置是另一个风险面,本笔不动)。
*/
void hard_fault_report(const char *tag, uint32_t *frame)
{
/* 刻意不包整行独占:三条理由见上方"要不要包成一次整行独占"那一段。 */
HF_PUTS("\r\n[ERR] ");
hard_fault_write(tag, 16u);
HF_PUTS(" PC=");
@@ -139,7 +194,9 @@ void hard_fault_report(const char *tag, uint32_t *frame)
hard_fault_puthex(SCB->MMFAR);
}
HAL_Delay(200);
/* 此处无需归还所有权:本函数从未取过(见函数开头说明)。 */
/* HAL_Delay(200) 已删:最后一个 write_raw 内部等到 TC 才返回,串口已排空;
* 留着它反而多一条 TIM7 tick 依赖(tick 停摆 = 卡在复位之前)。 */
NVIC_SystemReset();
}