Files
STM32F4-Base/docs/子系统设计/看门狗设计说明.md
T
wandering1 d286b69a2d chore(工具,文档): 台架 rc 语义与导航硬闸用例、门禁环境诊断、护栏还债算法、mem-budget 跨树口径
四条道的收尾件同笔(全是 python 工具与文档,零固件源;各段归属按道署明)。

· **台架(bench-runner)**:verify_log.py 新增 classify_measurement() 与 measureme
nt.* 字段,零行/零锚点
  判 exit 2 并打 [MEASUREMENT-BROKEN],**但同帧有 Forbidden 命中或自检故障仍判 1**(专门造了「零锚点 +
  [ERR] MEMFAULT」的陷阱日志实测——崩溃不许被降级成「没数据」);--selftest 16 → 22 条。board_ui_nav.py
  把两条执行页安全闸做成 7 条常驻用例接进提交钩子跑的快检档,作者把自己用例**证伪三轮**(测试强制上限、
  清单清空时是崩不是判定、出厂默认被翻允许也照样绿)后三条危险改动逐条能红。台架卫生清单落 §7:每圈复位
  前 open + 断言 ready + 看 rx 增长(今晚 daemon 死了 status 自愈把它遮住)、**Forbidden 令牌不得落在
  英文单词内部**(裸 Fault 命中 default 两次假红)、改判定语义前先 grep 消费者。
· **环境(ui-param-writer)**:证伪卡片前提两处——identify 那个版本根本没有 tags 模块(RECORD 与磁盘逐项
  一致,pre-commit 自己也从 identify.identify 取 ALL_TAGS,全仓零引用),真问题是 44 条 system 钩子里
  42 条 entry 用裸 python → PATH 首位那个零第三方包解释器,今天没炸纯靠两件事同时成立。交付是**新增 entry
  硬规则 + 修复死顺序与回滚并标注等静默窗口**,没有动共享环境(此刻重装会让别人的钩子在半途换解释器)。
· **门禁(gate-docs-fixer 已入库的 51 号钩子)**:本篇带上 host-unit-tests 的裁定句落点说明。
· **内存(comm-config-writer)**:mem-budget skill 补跨树测量身份口径(脚本按 __file__ 定位产物、什么才算
  一次新测量、多链同涨同一数值的共享帧/幻影环归因法)+ 看门狗说明的指针行。

实测:verify_log --selftest 22/22、board_ui_nav --selftest-quick rc=0(硬闸 7/7)、
doc-links --all exit 0、
check_ids --strict exit 0 且绝对数 1,156 **未被本笔吃掉**;全部文件 LF / 无 BOM / U+FFFD=0
;本笔零固件源,
随上一批权威构建验证过(0 错 0 警),故不再单独 @build。
2026-10-27 05:16:58 +08:00

716 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 看门狗设计说明
> **模块名称**:独立看门狗(IWDG)+ 任务心跳位图(`App/util/task_watchdog.{c,h}`)
> **文档定位**:**现状设计说明**——选型依据、当前实现、可观测性与判读方法、验证结论。
> **状态**:✅ 已落地并台架闭环(2026-10-03 V2/V4/V5);未办项集中在 §9。
> **日期**:2026-09-29 创建;**2026-10-10 按当前代码重写正文**(旧正文含大量立项前的
> 描述,已清理;实施史与逐次决策见 §10 与文末变更记录,未删)。
> **相关文档**:
> [`运行与自检记录设计说明.md`](运行与自检记录设计说明.md)(复位记录与 `selftest.log`)、
> [`../测试说明/看门狗单元测试说明.md`](../测试说明/看门狗单元测试说明.md)(L0.5 判定状态机单测)、
> [`../测试说明/实机测试实施指南.md`](../测试说明/实机测试实施指南.md)(故障注入台架流程)、
> [`../架构设计/项目启动顺序管理.md`](../架构设计/项目启动顺序管理.md)(启动时序与就绪屏障)、
> [`../测试说明/ST-LINK_CLI_复位使用说明.md`](../测试说明/ST-LINK_CLI_复位使用说明.md)(复位语义)、
> 《项目审查与优化建议报告》(本模块缺口的立项出处;该条已修复并按「修复即销项」从报告删除,**完整记录即本文**;报告本身 2026-10-25 已删除)
---
## 0. 一页速览
| 项 | 当前值 / 落点 | 事实来源 |
|---|---|---|
| 看门狗外设 | **IWDG**(独立 LSI ≈32 kHz),CubeMX 已勾选 | `.ioc`:`Mcu.IP4=IWDG`、`IWDG.Prescaler=IWDG_PRESCALER_64`、`IWDG.Reload=1249` |
| 超时 | 标称 **2498 ms**,最坏(LSI 47 kHz)**≈1701 ms** | `WD_IWDG_PRESCALER=4` / `WD_IWDG_RELOAD=1249`(`task_watchdog.c`) |
| 抽样周期 | **100 ms**(`WD_POLL_PERIOD_MS`) | 同上 |
| 缺失容忍 | **同一必需位**连续 8 窗缺席 = 800 ms 后停喂(`WD_MISS_TOLERANCE`;v1.5 起按位分账,多任务交替缺席各自清零、不再串成复合停喂) | 同上 |
| 必需掩码 | 产品构建 **`0x1F7`**(default/net/ftp/ui/timesync/adc/iec104/protect);`ENABLE_HW_LAYER_TESTS` 下 **`0x1F5`**(剔 net) | `wd_required_mask()` |
| 喂狗者 | **仅 defaultTask**(`task_wd_poll`,主循环每轮调用、内部节流) | `App/task/default_task.c` |
| 启动时刻 | **defaultTask 存储 bring-up 之后**(`task_wd_start()`);`MX_IWDG_Init()` 只填参数不启动 | `default_task.c:175`、`Src/iwdg.c` 的 `USER CODE BEGIN 0` |
| 复位归因 | 停喂 → IWDG 自然溢出 → `RCC_CSR.IWDGRSTF`(不用 `NVIC_SystemReset`) | `task_wd_poll()` |
| 复位原因上报 | `main.c` 锁存 `g_rcc_csr_latched` + 清 `RMVF` → `app_main.c` 打 `reset cause:` 并把看门狗复位记入 `selftest.log` | `app_main.c:117-130` |
| 主机侧单测 | 39 断言(L0.5,pre-commit `watchdog-unit-test`) | `test/host/watchdog/` |
| 重生成审计 | 21 项 / 1 警告 / 0 失败(含 iwdg 延后启动、看门狗挂载、复位锁存) | `scripts/check_cubemx_survival.py` |
---
## 1. 问题域与目标
### 1.1 要覆盖的故障谱
| 故障形态 | 现有兜底 | 无看门狗时的后果 |
|---|---|---|
| 单任务死锁 / 饿死(SPI 等待、卷锁、事件屏障) | 无 | 现场必须人工断电 |
| `configASSERT` 触发(内核态误用) | 现为 `vApplicationAssertFailed()` 打印后软复位 | 旧形态是关中断死循环 = 永久砖 |
| 4 个 Fault(Hard/Mem/Bus/Usage) | 采集现场后 `NVIC_SystemReset()` | 旧形态空 `while(1)` = 静默死机 |
| 初始化深链挂死 | **不覆盖**(启动段无狗,见 §4.1) | 表现为"无输出 + LED 不闪",人工辨识 |
| NAND 侧真实卡死 | FTL 长路径直接喂狗 → **设备存活但功能冻结**(刻意边界,见 §9) | — |
### 1.2 设计目标
1. **可恢复**:任务级死锁 / 饿死 / 断言挂死 → 硬件复位,无需人工到场。
2. **可诊断**:复位后能区分"上电 / 看门狗 / 软件 / 引脚 / 欠压",并留下证据。
3. **不误杀**:正常业务的长耗时窗口(NAND 擦写与 GC、FAT 挂载、PHY 链路等待、FTP 大文件传输、LVGL 重绘)**不得**触发复位。
4. **低代价**:RAM/Flash 增量可核算(实测见 §8),不破坏既有 `osDelay` 节拍与串口观测习惯。
### 1.3 立项前状态(历史,2026-09-29)
当时全库无 IWDG/WWDG 初始化、HAL IWDG 驱动源码不在仓库、`.ioc` 无看门狗条目、无复位原因读取——缺陷原登记于《项目审查与优化建议报告》(已修复并按销项删除正文)。**该节只作立项背景**:现状是 IWDG 已启用(`.ioc` + `HAL_IWDG_MODULE_ENABLED` + 驱动已挂载),复位原因每次开机都在上报。
---
## 2. 选型与参数
### 2.1 为什么是 IWDG 而不是 WWDG
| 维度 | **IWDG**(独立) | **WWDG**(窗口) |
|---|---|---|
| 时钟源 | **LSI ≈ 32 kHz**(独立 RC,启动后强制开启且不可关) | APB1(42 MHz,依赖 PLL) |
| 依赖 CPU 时钟 | ❌ | ✅(PLL 失锁/时钟切换期失效) |
| 依赖中断 | ❌ | ✅(早期唤醒中断 EWI) |
| 复位标志 | `IWDGRSTF` | `WWDGRSTF` |
| 能否停 | **启动后只能靠复位停** | 可关 |
| 喂狗窗口 | 只有下限 | 有上下限(过早喂也复位) |
**结论:IWDG。** 本项目的故障谱(死锁、饿死、Fault、断言挂死)可能伴随时钟异常或中断被关闭,WWDG 恰在这些场景失效;IWDG 走独立 LSI,是全场景兜底。台架 V4(§7)实测:`__disable_irq()` 后死循环仍每 ~4.9 s 稳定复位,证实独立于中断。
**代价**:LSI 精度差(可在 17–47 kHz 漂移,随温度/电压变化),`.ioc` 按 `RCC.LSI_VALUE=32000` 建模,**必须按最坏情况校验**(§2.2)。
### 2.2 超时计算
IWDG 为 12 位递减计数器(RM0090 §21.3):
```
T_IWDG = (4 × 2^PR) × RLR / F_LSI
```
| PR | 分频 | RLR | 标称(32 kHz) | **最坏(47 kHz)** |
|---|---|---|---|---|
| **4** | **64** | **1249** | **2498 ms** | **≈1701 ms** |
| 4 | 64 | 2499 | 4998 ms | ≈3403 ms |
| 5 | 128 | 1249 | 4996 ms | ≈3402 ms |
**LSI 偏快时超时变短**,故按 47 kHz 校验。三个时长必须严格包含:
```
T_任务循环 < 监视窗口(100ms) < 停喂判定(同一任务连续缺席 800ms) < T_IWDG最坏(1701ms)
```
即:停喂后仍有约 **900 ms** 余量才到复位;实测 V2 从声明 stall 到复位 ≤2.5 s。
⚠ v1.5(2026-10-25)起本链**字面成立**:旧实现的停喂从第一个缺席窗就开始(全掩码
精确匹配是喂狗前置),800ms 只是申报时点;现在停喂时点 = 同一位第 8 连击窗,此前
1..7 窗照常喂狗。代价 = 真停摆从挂死到复位总时长 +约 0.7 s(最坏 ≈2.5 s / 标称
≈3.3 s,V2 判据「声明后 ≤2.5 s」仍成立);收益 = 「本窗缺 A、下窗缺 B」式互不相干
抖动不再累计成停喂(2026-10-25 大窗 16:24/16:28 两起误升级实证,判据改写动因)。
### 2.3 配置落点
| 层 | 落点 | 内容 |
|---|---|---|
| **L1(`.ioc`)** | `IWDG.Prescaler=IWDG_PRESCALER_64` / `IWDG.Reload=1249` | CubeMX 生成 `Src/iwdg.c` 的 `hiwdg.Init.*`,重生成不丢 |
| **代码(判定侧)** | `WD_IWDG_PRESCALER=4` / `WD_IWDG_RELOAD=1249` | **真正写寄存器的是这两只宏**(见 §2.4) |
| **喂狗/启动** | 裸寄存器 `IWDG->KR`(不经 HAL 封装) | 100 ms 级高频路径,HAL 封装的 `State` 检查无收益 |
IWDG 是"写键值"式外设(KR 只写,读回恒 0):
| 键值 | 用途 |
|---|---|
| `0x00005555` | 解锁 `PR`/`RLR` 写保护 |
| `0x0000CCCC` | 启动(此后不可停,只能靠复位) |
| `0x0000AAAA` | 重装计数器(喂狗) |
⚠ `PR`/`RLR` 每只写完后须等 `SR.PVU`/`SR.RVU` 清零(同步到 LSI 域,≤5 个 LSI 周期),**否则后写的那只会被静默丢弃**(2026-10-02 SWD 实测)。
### 2.4 延后启动:`MX_IWDG_Init()` 只填参数
CubeMX 把 `MX_IWDG_Init()` 插在 `app_main_init()` **之前**,而 `HAL_IWDG_Init()` 第一句就是 `__HAL_IWDG_START()`(立即启动)。启动段(NAND/RTC/ADC)耗时可达数秒且无喂狗点 → 按最坏 1.7 s 必然复位 → **开机死循环**(2026-09-29 实测)。
**现做法(L2,2026-10-09 起)**:`Src/iwdg.c` 的 `USER CODE BEGIN 0` 里放空实现 + 宏改名,把**本编译单元内**那句生成调用变成空操作——生成区保持 CubeMX 原样,**重生成零 diff**,不必再手工删那一句:
```c
static HAL_StatusTypeDef iwdg_deferred_start(const IWDG_HandleTypeDef *a_h) { (void)a_h; return HAL_OK; }
#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)
```
`.map` 证实 `Removing stm32f4xx_hal_iwdg.o(i.HAL_IWDG_Init), (94 bytes)`。
⚠ **不能走"反初始化"**:IWDG 启动后无法停止(唯一手段是复位),只能在启动**之前**拦截。
> 🔴 **连带 P0 教训(2026-10-02)**:`HAL_IWDG_Init()` 是 HAL 里**唯一**把 `hiwdg.Init.Prescaler/Reload` 写进寄存器的路径。改名成空实现后若不在别处补写,`PR/RLR` 会停在**复位默认值**(`PR=0`→÷4、`RLR=4095` → 实测 349~512 ms,比设计短约 5 倍)→ 800 ms 容忍分支沦为死代码、任何 >400 ms 心跳缺口直接误复位。
> 现由 **`wd_platform_start()`** 完成"解锁 → 写 PR → 等 PVU → 写 RLR → 等 RVU → 写 0xCCCC",是全固件唯一写这两只寄存器的点。
> **通用教训:凡"把某个 HAL `Init()` 拆开/延后",必须逐一核对被拆掉的函数还有哪些副作用没搬到新位置。**
---
## 3. 架构:心跳位图 + 单一喂狗者
### 3.1 为什么不能"每个任务各自喂狗"
`HAL_IWDG_Refresh()` 塞进每个任务循环 → **只要还有任何一个任务在跑,狗就被喂**,单任务卡死永远检测不到,退化成"只检测全部任务同时挂死"(现实中几乎不发生)。
### 3.2 采用方案:任务置位 → 校验者判定 → 校验者喂狗
![看门狗心跳模型:任务置位 → 校验者判定 → 校验者喂狗](../images/看门狗心跳模型.svg)
> 图源:`tools/arch_diagram/gen_watchdog_svg.py`(改图改脚本重跑,见
> [`../架构设计/图形绘制流程.md`](../架构设计/图形绘制流程.md))。
**语义**:喂狗 = "所有被监视的任务都在窗口内动过"。
| 阶段 | 代码(`App/util/task_watchdog.c`) |
|---|---|
| ① 任务置位 | `task_wd_beat(id)`:`s_hb_bits \|= (1u << id)`(仅置位) |
| ② 校验者取走清零 | `snapshot = s_hb_bits; s_hb_bits = 0u;`(单字原子) |
| ③ 按位分账(v1.5) | 对掩码内每一位:出现→该位连击清零;缺席→+1(饱和封顶)。掩码外的位不入账(监视范围随构建变化时账本自动跟随) |
| ④ 全中窗 | `snapshot == wd_required_mask()`(**精确相等**,§3.5)→ `s_miss_streak=0; s_stall_evidenced=0;` 喂狗 |
| ⑤ 缺席窗(v1.5) | `s_miss_total++`;复合边沿(上一窗全中)打一条 `MISS start`(**纯诊断**,不再代表停喂);**任一必需位连击 ≥8** → `[WD] task stall` + **停喂**(每 episode 只落一次盘上证据);所有位连击 <8 → **照常喂狗** |
### 3.3 为什么用"取走并清零"
单字读-改-写在 Cortex-M4 上天然原子(无锁)。对比:
| 方案 | 问题 |
|---|---|
| 每任务独立计数器,比较"是否增长" | 需临界区,32 位回绕判定繁琐 |
| `osEventFlags` 做心跳 | 需 6 位;`osEventFlagsSet` 可能触发调度,任务循环内代价过高 |
| **单字位图 + 取走清零** | ✅ 零临界区、单指令、一个静态字 |
语义优势:校验者看到的位图 = "自上次抽样以来谁动过" → 缺席可连续累计 → 可判"连续 N 轮未达标"。
### 3.4 为什么喂狗者在 defaultTask
| 候选 | 评估 |
|---|---|
| **defaultTask** | ✅ 已是"系统管家"(配置引擎属主、syslog 唯一写者、控制台唯一发送者);50 ms 循环基准天然适配 100 ms 抽样 |
| netTask | ❌ 节拍最紧(`osDelay(1)`)且是最可能卡死的任务之一——让被监视者喂狗语义错乱 |
| 独立看门狗任务 | ❌ 新建任务(栈 + TCB)与 RAM 预算冲突;且"谁来监视监视者"无解 |
| 中断 / FreeRTOS timer | ❌ 其存活不代表业务健康 |
> ⚠ **对抗性风险即期望行为**:defaultTask 自身卡死 → 狗永不喂 → 必然复位(fail-safe)。
> 但"喂狗点被长 I/O 阻塞"这一形态需要单独处置,见 §4.3。
### 3.5 掩码与"精确匹配"语义
抽样判定是 **`snapshot == required`**,不是 `(snapshot & required) == required`。因此**掩码必须恰好等于"会置位的任务集合"**——多一个位同样判失败。
| 构建 | 掩码 | 会置位的任务 |
|---|---|---|
| 产品 | `0x77` = bit0,1,2,4,5,6 | default / net / **ftp** / ui / timesync / **adc** |
| `ENABLE_HW_LAYER_TESTS`(CH395F 硬件层测试,netTask 入口自挂起) | `0x75` = bit0,2,4,5,6 | default / ftp / ui / timesync / **adc**(屏障等待按 100 ms 分片喂位;adcTask 不依赖任何屏障,任何构建都置位) |
| 测试套件(`TEST_SUITE_*`,创建 testTask) | 同上 `0x77` | **testTask 任何构建都不入掩码、也不置位**(它可长阻塞;置位反而引入多余位误杀) |
> 2026-10-02 台架:旧版"仅剔 NET 位 + 屏障等待者不置位"会让测试构建陷入 IWDG 复位循环——**掩码与会置位集合必须成对维护**,改任一处都要同步。
---
## 4. 关键设计决策(现状)
### 4.1 启动时刻:defaultTask bring-up 完成后
`task_wd_start()` 位于 `default_task.c:175`——存储深链(`f_mount`/`f_mkfs`/卷探针/syslog 挂载/配置加载)之后,即全系统最长初始化链完成、系统进入稳态之时。启动前先喂一次,使首个抽样窗口从启动时刻起算。
**显式代价**:从复位到此处约数秒的窗口**无看门狗保护**;该段死机表现为"无任何输出 + LED 不闪",可人工辨识(§9 R1)。
### 4.2 停喂,而不是直接 `NVIC_SystemReset()`
软复位会把 `RCC_CSR` 置 `SFTRSTF`,丢失"是看门狗抓到的"这一权威归因。让 IWDG 自然溢出 → `IWDGRSTF`。代价是额外 ≤2.5 s 延迟(可接受)。
### 4.3 长耗时窗口的三类处置
统一原则:**启动段不启动狗;主循环段用"分片心跳 / 心跳路由 / 直接喂狗"三类手段覆盖**;
另有一类**根本不等锁**的场景(卷锁 try 失败)不属于"等待怎么活下来",它只需要
**可见**,用表中 ④「只计数」那一条(道G task-17,它不喂位也不需要喂位)。
| 场景 | 位置 | 时长 | 处置手段 |
|---|---|---|---|
| NAND 初始化 / `f_mkfs` / `dhara_map_resume` / `f_mount` | 启动段 | 数百 ms ~ 数秒 | **① 启动段不启动狗**(§4.1) |
| PHY 链路等待(60×50 ms) | `net_stack_bring_up` | 最长 3 s | **② 分片心跳**:循环内每轮 `task_wd_beat` |
| `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`** |
| 控制台排空(僵尸通道自愈路径) | `console_log.c` | — | `DBG_CON_DRAIN_BUDGET_MS=250` 单次 drain 上限,让位喂狗轮询 |
**② 心跳路由**(`task_wd_bind()` + `nand_ftl_wd_beat()`):FTL 长操作运行在**调用者任务**上下文,驱动只调弱钩子 `nand_ftl_wd_beat()`,本模块按 `task_wd_bind()` 登记的句柄把它路由到调用者任务的心跳位——驱动层不向上依赖应用层。`testTask` 刻意不登记(不在掩码内)。
**③ 直接喂狗**的语义边界(显式声明):NAND 真实卡死时该路径持续喂狗 = **设备存活但存储功能冻结**(与控制台 ORE 自愈同类边界,见 §9 R5)。
### 4.4 断言与崩溃钩子
`Inc/FreeRTOSConfig.h:165`(L2,`USER CODE BEGIN 1` 区):
```c
#define configASSERT( x ) if ((x) == 0) { vApplicationAssertFailed(__FILE__, __LINE__); }
```
`vApplicationAssertFailed()`(`Src/freertos.c` 的 `USER CODE Application` 区)经 `hook_ptr_ok()` 校验文件指针、取路径末段,裸 UART 打 `[ERR] ASSERT FAILED <file>:<line> (resetting)` 后 `NVIC_SystemReset()`。即使复位逻辑本身因内核态损坏而失效,**IWDG 仍会在最坏 1.7 s 后强制复位**(双保险)。
**2026-10-10 栈整改**:钩子里的 `name[49]`/`num[16]` 改**函数级 static**(终点路径、打印完即复位、无并发),行号转换改原地反转,去掉 `HAL_Delay(200)`(`con_tx_bounded` 已等 TC)。原因:`configASSERT` 在 FreeRTOS 内部可达 → 本钩子挂在**每个任务**的最坏调用链尾,放栈上的 81 B 会出现在所有任务的静态最坏链上。
4 个 Fault(Hard/Mem/Bus/Usage)的采集与使能(`fault_handler_enable()` 使能后三者,否则被升级成 HardFault 看不出根因)见 §10.2 与 `AGENTS.md`「平台约束」。
**`Error_Handler()` 统一降级(2026-10-12)**:CubeMX 弱定义的这个入口旧形态是 `__disable_irq(); while(1){}`——**单处外设初始化失败即整机挂死**,只剩 IWDG 兜底且不留任何自检日志。现改为打 1 条自检 + `NVIC_SystemReset()`,与 4 个 Fault 处理同款自恢复(IWDG 退回"最后兜底"而非唯一出路;复位归因 `SFTRSTF` → 开机日志 `software (NVIC_SystemReset)`,§4.5)。三条实现约束:
| 约束 | 原因 |
|---|---|
| **不得 `__disable_irq()`** | 日志必须能走串口,关中断 = 现场彻底消失 |
| **`__builtin_return_address(0)` 不可用** | AC5 前端接受但编成库函数调用,MicroLIB 无该符号 → `L6218E`(2026-10-12 台架实证,勿再尝试);ARMCC `__asm` 只有整函数形态且取不到 C 符号(§10.4)。→ **失败点靠"最后一条成功日志"与 `main()` 的 `MX_*_Init` 顺序之间定位**(GPIO→DMA→USART1→SPI2→SPI1→I2C1→UART4→UART5→USART2→USART3→IWDG→ADC1→TIM3→`app_main_init`) |
| **日志路径必须内核前安全** | 调用点全在 `osKernelStart` 之前:控制台写者未注册 → `con_append` 走 `dbg_console_write_raw`(DWT/计数有界轮询,`huart1` 未就绪即返回),`sys_clock_get_str` 未初始化打占位串——全程无内核 API(§4.7 与 `AGENTS.md`「平台约束」同源) |
成本 = 仅这 1 条日志(≈90 B Flash,见 `AGENTS.md` 日志成本)。重生成存活:函数体在 `USER CODE Error_Handler_Debug` 区(L2,`KeepUserCode` 逐字保留)+ `check_cubemx_survival.py` 审计项兜底(L3)。
### 4.5 复位原因锁存与优先级
`RCC_CSR` 的复位标志只由 POR 清除、读后不自动清 → 必须读并锁存后 `SET_BIT(RCC->CSR, RCC_CSR_RMVF)`,否则下次软复位会读回上次残留。
现状:`Src/main.c` 在 `HAL_Init()` 之后(`USER CODE BEGIN Init` 区)锁存到 `g_rcc_csr_latched` 并清标志;`app_main_init()` 早期翻译并打印。
**代码实际优先级**(`app_main.c reset_cause_str()`,按"最可信归因"而非因果强度):
| 顺序 | 标志 | 描述串 |
|---|---|---|
| 1 | `IWDGRSTF` | `IWDG (independent watchdog)` |
| 2 | `WWDGRSTF` | `WWDG (window watchdog)` |
| 3 | `SFTRSTF` | `software (NVIC_SystemReset)` |
| 4 | `PORRSTF` | `power-on (POR)` |
| 5 | `PINRSTF` | `external pin (NRST)` |
| 6 | `BORRSTF` | `brown-out (BOR)` |
| 7 | 全 0 | `unknown` |
看门狗排在软件复位**之前**:两者同时置位时(崩溃钩子软复位 + 期间也超时),报告看门狗更有价值。
### 4.6 与 `run.log` / `selftest.log` 的集成
| 通道 | 现状 |
|---|---|
| 串口 | 每次开机第一行区打 `[APP] reset cause: <类型> (CSR=0x…)`(`@test` 已把它当启动锚点并校验次数与期望值) |
| `selftest.log` | **看门狗/窗口看门狗复位 → `syslog_post(SYSLOG_SELFTEST, "watchdog reset")`**(只记故障,符合既有约定) |
| `run.log` | 仍是"上电 / 重启"二分类(靠末条 epoch 时间窗推断),**未接入硬件复位原因**——细粒度原因不上 `run.log`/UI(避免中文字符集重生成),见 §9 O2 |
| `/log/wdev.log` | 停喂现场证据(`sys_log_evidence()`,>8 KB 先 unlink 重建,防长跑无界增长) |
### 4.7 模拟器与主机侧
- **模拟器不编译本模块**(`simulator/` 无 `task_watchdog.c`,也无 `--watchdogtest`)——原计划的 S1 已降级,判定逻辑改由 **L0.5 主机侧单测**覆盖(39 断言,见 §7)。
- 不可建模的部分:真实复位、LSI 漂移、`RCC_CSR` 标志——必须真机(§7)。
---
## 5. 实现现状
### 5.1 文件与职责
| 文件 | 职责 |
|---|---|
| `App/util/task_watchdog.{c,h}` | 心跳位图、判定状态机、IWDG 配置与启动、FTL/FTP 心跳路由、取证旁路 |
| `Src/iwdg.c` | CubeMX 生成;`USER CODE BEGIN 0` 宏改名 → 只填参数不启动(§2.4) |
| `Src/main.c` | `HAL_Init()` 后锁存复位原因 + 清 `RMVF`;`HAL_Init()` **前** `task_wd_tick_snapshot()`;TIM7 回调内 `task_wd_tick_probe()` |
| `App/app/app_main.c` | `reset cause:` 上报 + `selftest.log` 记录 + `task_wd_seg_report()` |
| `App/task/default_task.c` | 唯一喂狗者:`task_wd_start()` + 每轮 `task_wd_poll()` / `task_wd_beat()` / `task_wd_seg()` |
| `App/task/{net,ui}_task.c`、`App/sync/time_sync.c`、`Src/freertos.c`(ftpTask) | 心跳埋点 + `task_wd_bind()` 登记 |
| `Drivers/BSP/NET/lftpd/*` | 弱钩子 `lftpd_task_wd_beat()` 的节拍点(事件循环、传输分片、连接等待、卷锁等待) |
| `Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue.{c,h}` | 弱钩子 `nand_ftl_wd_beat()` / `nand_ftl_wd_feed()` 的调用点(FTL 入口与 `wait_busy` 轮询) |
| `App/log/sys_log_mcu.c` | `sys_log_evidence()` → `/log/wdev.log` |
| `Inc/FreeRTOSConfig.h` | `configASSERT` 改造(L2) |
| `test/host/watchdog/` | 判定状态机主机侧单测(`#include` 生产源码 + 强符号桩) |
| `MDK-ARM/STM32F407-Demo.uvprojx` | 挂载 `task_watchdog.c` 与 `stm32f4xx_hal_iwdg.c` |
| `MDK-ARM/STM32F407-Demo.sct` | `UNINIT_CCM`(0x1000FF00,256 B)承载 `.noinit` 取证槽 |
### 5.2 对外接口
```c
void task_wd_beat(task_wd_id_t id); /* 各任务循环内置位(1~2 指令,任意上下文) */
void task_wd_bind(task_wd_id_t id); /* 任务入口一次:登记句柄供 FTL 心跳路由 */
void task_wd_start(void); /* 仅 defaultTask,bring-up 后一次:配置并启动 IWDG */
void task_wd_poll(void); /* 仅 defaultTask 主循环:抽样 + 判定 + 喂狗(内部 100ms 节流) */
uint32_t task_wd_last_mask(void); /* 观测:上次抽样位图(0 位 = 缺席任务) */
uint32_t task_wd_miss_count(void); /* 观测:累计未达标轮数 */
uint32_t task_wd_is_armed(void); /* 观测:1 = IWDG 已启动 */
/* 取证(仅 MCU 构建):进段打点与时间源存活性,见 §5.6 */
void task_wd_seg(uint32_t seg);
void task_wd_seg_report(void);
void task_wd_tick_snapshot(void); /* 必须在 HAL_Init() 之前 */
void task_wd_tick_probe(void); /* TIM7 ISR 内,每 1ms */
```
任务 ID(位序号即掩码位,**顺序不可插空**):`DEFAULT=0 / NET=1 / FTP=2 / TEST=3 / UI=4 / TIMESYNC=5`。
本模块**不创建任何 FreeRTOS/CMSIS-RTOS 对象**,可安全用于内核启动前后的上下文(唯一例外:`task_wd_bind()`/`nand_ftl_wd_beat()` 需内核句柄,内核前为空操作)。
### 5.3 判定状态机要点
```
未启动 → 直接返回
未到 100ms 边界 → 直接返回(节流)
取走并清零位图 → 记入近 8 次环形历史(取证)
按位分账:掩码内每位 出现→连击清零 / 缺席→+1(饱和);worst = 各位最大连击
snapshot == required → miss_streak=0、证据门闩复位、喂狗
否则 → miss_total++;复合边沿(miss_streak==0)→ 裸 UART `MISS start …`(诊断行)
worst >= 8 → DBG_ERROR task stall +(本 episode 首次)落盘证据 + 不喂狗
worst < 8 → **照常喂狗**(单任务亚容忍缺席不牵连全局停喂)
```
⚠ 2026-10-25 大窗判读前,后两行按复合 `miss_streak >= 8` 判定:互不相干的缺席
串成停喂链(变更记录同日行);按位分账后 `s_miss_streak` 退化为纯诊断边沿计数。
⚠ **证据门闩 `s_stall_evidenced`**:落盘走 NAND → FTL 路径会 `nand_ftl_wd_feed()` **直接喂狗**。若每 100 ms 都写,等于在"停喂"窗口里持续喂狗 → IWDG 永不溢出(2026-10-03 V2 首轮实测:stall 已声明、`miss` 累至 1157≈115 s 仍不复位)。每 episode 只落一次,恢复健康后复位门闩。注意 `s_miss_streak` 达上限后会**饱和**,不能用它判"首次"。
### 5.4 平台后端(可测性)
硬件访问收敛到三个 `__weak` 钩子(声明在 `task_watchdog.h` 末段,**非对外接口**):
| 钩子 | MCU 默认实现 | 主机侧 |
|---|---|---|
| `wd_platform_feed()` | `IWDG->KR = 0xAAAA` + 记录喂狗时刻/最大间隔 | 强符号桩:记录"何时被喂" |
| `wd_platform_start()` | 解锁 → 写 PR/RLR(等 PVU/RVU)→ `0xCCCC` | 强符号桩:不碰寄存器 |
| `wd_platform_tick_ms()` | `HAL_GetTick()`(时基 = TIM7,非 SysTick) | 强符号桩:驱动时间推进 |
与 `App/cfg/cfg_file_store.h` 的"共享核心 + 平台后端"同款:判定状态机成为纯逻辑,主机侧 `#include "task_watchdog.c"` 原样纳入生产源码并以强符号覆盖 → **测的是生产代码**,MCU 构建行为零变化。
### 5.5 心跳埋点位置(实测)
| 任务 | 位置 |
|---|---|
| defaultTask | 主循环首段 `task_wd_poll()` + `task_wd_beat(TASK_WD_DEFAULT)`;入口 `task_wd_bind()` |
| netTask | 主循环;`net_stack_bring_up` 的 PHY 等待循环内;就绪屏障等待分片;入口 `task_wd_bind()` |
| ftpTask | 主循环 = lftpd 事件循环:`lftpd.c` 事件循环每轮、RETR/STOR 每分片;`lftpd_inet.c` 连接/数据等待每轮每片;`lftpd_io.c` 卷锁等待每片;入口 `task_wd_bind()` |
| uiTask | 主循环;就绪屏障等待分片;入口 `task_wd_bind()` |
| timeSyncTask | 主循环;监听重试(500 ms/2 s)前;accept 轮询;`time_sync_handle_conn` 的 **200 ms 切片**;屏障等待分片;入口 `task_wd_bind()` |
| adcTask | 主循环每 1 ms 节拍 `task_wd_beat(TASK_WD_ADC)`;**不做 NAND I/O → 不登记 FTL 心跳路由**(无需 `task_wd_bind()`) |
| testTask | **不埋点、不登记、不入掩码** |
| NAND FTL | `gd5f2gq5ue.c`:长操作入口 `nand_ftl_wd_beat()` + `nand_ftl_wd_feed()`;`gd5f_wait_busy` 每 100 ms 双管(路由到调用者任务) |
> 弱钩子在驱动侧声明(`lftpd.h` / `gd5f2gq5ue.h`),本模块提供**强符号**路由到心跳位图 → 驱动层不向上依赖应用层(分层铁律)。
### 5.6 取证机制(复位后仍可读)
误复位后 RAM 态丢失、串口又可能来不及打,故有三层"复位不丢"的取证(全部只取证,不参与判定):
| 层 | 载体 | 开机读出并打印 |
|---|---|---|
| 第 7 轮 | RAM(近 8 次抽样位图环、最大喂狗间隔、console tx_fault 计数) | 停喂时 `STALL snap=… req=… h=… mg=… cf=… seg=…`(裸 UART + `/log/wdev.log` 各一份) |
| 第 8 轮 | `.noinit` 槽 `s_wd_seg_trail[4]`(magic/段号/时刻/循环序号) | `PREVRUN seg=N seq=N t=ms`——直接指向 defaultTask 主循环最后进段 |
| 第 8b 轮 | `.noinit` 槽 `s_wd_tick_cur/prev[4]`(uwTick + TIM7 中断计数) | `TICKRUN uw=… n=… duw=…`——判定 HAL 时基(TIM7)是否与停摆同刻冻结 |
要点:
- `.noinit` 必须 `section(".noinit")` **与** `zero_init` 同时给,并配 scatter 的 `UNINIT` 执行区;只给 section 时 armcc 会当"带初值 RW"→ 启动 scatterload 抹掉保留内容。
- `task_wd_tick_snapshot()` **必须在 `HAL_Init()` 之前**调用:TIM7 在 `HAL_Init` 里就开始 1 ms 中断,而开机上报要到 `app_main_init`(≈117 ms)才跑,不先搬走上一轮终值就被覆盖。
- `task_wd_tick_probe()` 在 TIM7 ISR 内**严禁调用任何 FreeRTOS/CMSIS-RTOS API**:TIM7 远早于 `xPortStartScheduler()`,此时 ISR 版内核 API 触发 `port.c` 优先级断言并把系统卡死在"USART1 尚未初始化"的断言打印路径上(2026-10-02 实测)。
---
## 6. 可观测性与判读
### 6.1 串口标记
| 标记 | 时机 | 含义 |
|---|---|---|
| `[APP] reset cause: <类型> (CSR=0x%08X)` | `app_main_init` 早期 | 复位原因 + `RCC_CSR` 原值(`@test` 启动锚点,出现 0 次或 >1 次判 FAIL) |
| `PREVRUN seg=… seq=… t=…` | 开机(裸 UART) | 上一轮 defaultTask 最后进段(magic 命中才打,一次复位只报一次) |
| `TICKRUN uw=… n=… duw=…` | 紧随其后 | 上一轮 HAL 时基终值;`duw≈0` → TIM7 与主循环同刻停摆;`duw≈2000+` → 另有阻塞点 |
| `[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms` | `task_wd_start()` | IWDG 已启动(真机自检必查标记) |
| `MISS start snap=… req=… t=…` | **复合**缺席 episode 首轮(裸 UART) | poller 仍在抽样,给出首次缺席位图。⚠ v1.5 起为**纯诊断行**:出现不代表停喂(停喂只看同一必需位第 8 连击窗) |
| `[WD] task stall: mask=… req=… miss=…` | **同一必需位**连续 8 窗缺席起,每窗一条(v1.5 按位分账;旧复合判据见变更记录) | 该位对应任务 800 ms 没跑过 → 停喂 → 最坏 1.7 s 后 IWDG 复位;`mask` 的 0 位 = 缺席任务(多个 0 位且**跨窗换位** = 抖动串联嫌疑,v1.5 后不再触发本行) |
| `STALL snap=… req=… h=… mg=… cf=… seg=…` | 停喂 episode 首次 | 盘上+串口双份证据;`mg` 最大喂狗间隔、`cf` 控制台发送故障计数 |
| `[ERR] ASSERT FAILED <file>:<line> (resetting)` | `configASSERT` | 断言失败,打印后软复位 |
| `[ERR] HARDFAULT / MEMFAULT / BUSFAULT / USAGEFAULT PC=… CFSR=… [BFAR=] [MMFAR=]` | 4 个 Fault | 崩溃现场 + 故障名归因 |
| `[ERR] STACK OVERFLOW task=<name>` / `[ERR] MALLOC FAILED …` | 内核钩子 | 栈溢出(复位)/ 堆耗尽(仅告警) |
> **与 `@test` 禁项的关系**:`run_boot_test.py` 的 Forbidden 含 `[ERR]` → **任何崩溃日志都会让本次运行判 FAIL**(刻意)。四个 Fault 打各自名字的意义在于**归因**,不必再从笼统 `HARDFAULT` 反推 `CFSR` 子域。
> 日志标签:`[APP]` = `app_main.c` 的 `DBG_TAG`;`[WD]` = `task_watchdog.c` 的 `DBG_TAG`。全部纯 ASCII(规避 ARMCC CP936 字符串陷阱)。
### 6.2 判读顺序(现场"设备反复重启")
1. 看 `reset cause:` —— `IWDG` = 看门狗抓到;`software` = 崩溃钩子/断言主动复位;`power-on` = 真的断电重启。
2. `IWDG` 时看 `PREVRUN seg=` —— 指向 defaultTask 主循环阻塞点;再看 `TICKRUN duw=` 判断 HAL 时基是否同时冻结。
3. 看 `MISS start` / `task stall` 的 `mask` 缺席位 —— 定位是哪个任务先停摆。
4. 串口被僵尸通道吞掉时读 `/log/wdev.log`(`STALL` 行的盘上副本)。
---
## 7. 验证记录
| # | 场景 | 结果 | 证据 / 备注 |
|---|---|---|---|
| V1 | 正常启动、长期不复位 | ✅ 常态成立 | 每次 `@test` 开机日志均含 `[WD] armed:`;2026-10-10 复测开机 524 ms 内完成、无 stall |
| V2 | **任务饿死**(netTask 插 `while(1)`) | ✅ **2026-10-03 闭环** | 800 ms 声明 `task stall: mask=0x00000001 req=0x00000037` → ≤2.5 s IWDG 复位(`reset cause: IWDG`,`RCC_CSR=0x24000000`)。首轮曾不复位,根因是证据落盘自身喂狗 → 加门闩(§5.3) |
| V3 | 断言失败 | 🟡 未单独注入 | 宏与钩子已在用(`configASSERT` 全内核可达);行为经编译与代码路径核对,未做定向注入用例 |
| V4 | **关中断死循环** | ✅ **2026-10-03 闭环** | `__disable_irq()` + `while(1)` → 每 ~4.9 s 稳定复位(20+ 周期,`IWDG`)→ 证实 IWDG 独立于中断 |
| V5 | **长期不误杀**(真实负载 ≥30 min) | ✅ **2026-10-03 闭环** | FTP 512 KB 上/下传 + `e02_probe` 探针 30 min → **120/120 次传输全 PASS、零 IWDG 复位**。遗留观察 44 次 stall(探针场景专属)→ 登记「探针窗口心跳缺口」 |
| V6 | 测试套件共存 | 🟡 部分 | 2026-10-02 曾出现测试构建 IWDG 复位循环,已按 §3.5 掩码规则修复;未跑完整套件用例矩阵 |
| V7 | 上电复位辨识 | 🟡 未单独注入 | `reset cause:` 每次开机都在打(POR 串已实现),未做专门断电对照 |
| V8 | 引脚复位辨识 | ⏳ 未做 | 未做定向注入(`external pin (NRST)` 分支已实现) |
| 主机单测 | 判定状态机主机侧单测 | ✅ 39/39 | `test/host/watchdog/run_unit_test.py`(L0.5,pre-commit `watchdog-unit-test`) |
| 门禁触发 | 门禁接入 | ✅ | 改 `App/util/task_watchdog*.{c,h}` 或 `test/host/watchdog/**` 触发 |
| 模拟器 | `--watchdogtest` | ⏳ **已降级不做** | 位图与判定逻辑已由主机单测覆盖;模拟器不编译本模块 |
用例细节见 [`../测试说明/看门狗单元测试说明.md`](../测试说明/看门狗单元测试说明.md);故障注入流程见 [`../测试说明/实机测试实施指南.md`](../测试说明/实机测试实施指南.md) §4。
---
## 8. 资源预算(`.map` 实测,2026-10-10)
| 项 | 大小 | 位置 |
|---|---|---|
| `task_watchdog.o` 代码 | **1098 B**(含内联数据 294 B)+ RO 137 B | Flash |
| `task_watchdog.o` RW + ZI | **44 B + 104 B** | `.data` / `.bss`(主 SRAM) |
| `.noinit` 取证槽(3 × 4 字) | **48 B**(`UNINIT_CCM` 上限 256 B,占 18.8%) | CCM 尾部,复位不清、启动不碰 |
| `iwdg.o` | **28 B**(只剩填 `hiwdg` 结构体) | Flash;`HAL_IWDG_Init` 94 B、`HAL_IWDG_Refresh` 12 B 均被链接器移除 |
| 复位原因锁存 `g_rcc_csr_latched` | 4 B | `.data` |
全局水位(`docs/内存管理/RAM使用分析与优化方案.md` §2.1 受管快照):RW_IRAM1 **89,168 / 131,072 B(68.0%)**、RW_CCM 49,184 / 65,280 B(75.3%)、ROM 268,276 B(25.6%)。看门狗模块自身增量(≈1.2 KB Flash / ≈150 B RAM + 48 B CCM)在其中可忽略。
> 与"走裸寄存器不引入 HAL 驱动"的旧估算不同:现走 CubeMX 路径,`stm32f4xx_hal_iwdg.c` **已编译进工程**,但真正被链接的只有 `MX_IWDG_Init` 填结构体那 28 B(§2.4 的宏改名把 94 B 的 `HAL_IWDG_Init` 移除)。
---
## 9. 边界与未办项
### 9.1 已知边界(刻意接受)
| # | 边界 | 现状与处置 |
|---|---|---|
| R1 | **启动段无保护**(复位 → `task_wd_start()` 约数秒) | 接受;表现为"无输出 + LED 不闪",人工辨识。要覆盖需"短超时 + 手动多喂",复杂度收益比不划算 |
| R2 | **LSI 精度差** | 已按 47 kHz 最坏值校验,停喂到复位仍留 ~900 ms |
| R3 | **喂狗节奏饥饿** | 控制台 drain 曾把主循环拉长导致误复位(2026-10-02 台架日志代次 288)。现缓解 = drain 单次 250 ms 预算 + 发送故障 300 ms 冷却;**结构根治(USART1 改只发不收)2026-10-26 已裁定不做 → 现行缓解即长期方案**(机制详述 `控制台单写者日志设计说明.md` §12) |
| R4 | **NAND 真实卡死时 FTL 直接喂狗** | 设备存活但存储功能冻结(与 ORE 自愈同类边界)。若要求"NAND 死也必须复位",需给 `nand_ftl_wd_feed()` 加次数/时限上限 |
| R5 | **栈溢出与断言在复位原因上不可区分**(都是 `SFTRSTF`) | 可接受;串口分别打 `[ERR] STACK OVERFLOW` / `[ERR] ASSERT FAILED`,`selftest.log` 侧另有记录 |
| R6 | **`RCC_CSR` 读后即清** | 已在 `main.c` 统一处理;漏清会让下次软复位读回上次标志 |
| R7 | **UI 推流的网络往返卡死不占心跳位**(产品掩码仍 `0x77`) | 刻意:往返卡死的根因在 netTask,netTask 已在掩码内、由它暴露;为"远端显示卡了"新增一位只会把观感问题升级成整机复位。UI 心跳已由自身喂位自产、不受推流影响;推流耗时看 `[PROF]` 的 `uis`/`uisn`。成因与判读见本文 **§11.3 第 2 条**;门控方案与残留暴露见 `../UI设计/RemoDispBus协议与接收设计说明.md` §3 |
### 9.2 未办项(登记簿同步)
| 条目 | 内容 | 与本案关系 |
|---|---|---|
| 探针窗口心跳缺口 | `time_sync` 30 s 挂起客户端掉线 + 监听重建期间 NET/FTP/UI 心跳缺口 800 ms~1 s(声明 stall 但未复位),**余量仅 ~0.7 s** | 待定位监听重建为何阻塞其它任务;必要时缩短重试 |
| O1 | 是否需要复位计数与"连续 N 次看门狗复位后进入安全模式" | 当前不做;若现场出现复位风暴再评估 |
| O2 | 细粒度复位原因是否上 `run.log`/UI | 倾向不上(避免中文字符集重生成),只保留串口 + `selftest.log` |
---
## 10. CubeMX 重生成下的定制存活(方法论)
看门狗是本项目**第一次**把 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)。同族另一笔只登记指针:断言钩子与临界区在控制台路径上互达会构成**幻影环**,armlink 的 `In Cycle` 记账随环形状波动(同一 port.o 三棵树实测每链差 0~80 B,休眠≠解耦),三树取证、量化表与「configASSERT 裁空宏 ≠ 优化收益」的钉死句见 `docs/内存管理/RAM使用分析与优化方案.md` §4.0.7(道G task-38)。
### 10.1 覆盖史(三次重生成)
| 次 | 触发 | 被覆盖的定制 | 结论 |
|---|---|---|---|
| 1(2026-09-29) | 勾选 IWDG | 4 处:`Src/iwdg.c` 开机死循环(P0)、`HardFault_Handler` 采集被打回空(P1)、`FreeRTOSConfig.h` 两个加固宏整块删除(P1)、`freertos.c` 栈注释(P2) | 覆盖**有限且可枚举**,"重生成 → 逐项恢复 → 编译验证"可固化 |
| 2(2026-09-29) | 再次重生成 | 审计脚本检出 5 处;**关键发现**:CubeMX 换了一代中断函数模板(旧自造区 `HardFault_Handler`/`HardFault_Report` → 函数体内 `HardFault_IRQn 0`/`W1_HardFault_IRQn 0`),**生成区之外的自造标记区连同内容一起消失** | 🔴 **只信 CubeMX 当前模板真实生成的区名**,别凭记忆;优先用 `USER CODE BEGIN 0` 这类从未变过的标准位 |
| 3(2026-10-09) | 启用 ADC1 | 同一批定制再次全被抹;`FREE_RTOS_CONFIG_HARDENING` 自造区**第二次**消失;`freertos.c` 三处栈注释第四次丢失 | 加固宏改走 **L1**(`.ioc` 的 `FREERTOS.configCHECK_FOR_STACK_OVERFLOW=2` / `configUSE_MALLOC_FAILED_HOOK=1`,GUI 在 FreeRTOS → **Config parameters** → **Hook function related definitions**);启用后 CubeMX 生成 `USER CODE BEGIN 4/5` **空桩** → 钩子实现必须整体搬进 4/5 区(否则同名两处定义 = 编译错),helper `hook_ptr_ok()` 放 `PD` 区 |
> ⚠ **别凭印象断言"界面里没有该项"**——先翻标签页或查 `db/mcu/IP/*_Modes.xml`:那两个加固宏一直躺在 "Config parameters" 里,因此白丢了两次。
### 10.2 三层手段(优先级从高到低)
| 层 | 手段 | 适用 | 可靠性 |
|---|---|---|---|
| **L1 写进 `.ioc`** | 让 CubeMX 自己生成 | 界面能表达的配置 | ⭐⭐⭐ 根治——重生成只会照写 |
| **L2 搬进 `USER CODE` 区** | 移到 `USER CODE BEGIN/END` 之间 | 有真实标记区(**当前模板生成的**)的文件 | ⭐⭐ 有效(`KeepUserCode=true` 时逐字保留) |
| **L3 审计 + 基线** | 脚本定期核对 | 标记区外的存量 | ⭐ 兜底——不防丢,但丢了一定知道 |
**本项目已落 L1 的项**:任务栈/优先级/名称(`FREERTOS.Tasks01`,**改栈请改 `.ioc`,不要改 `freertos.c`**)、队列 `netMsgQueue`、事件组 `sys_evt`、堆 `configTOTAL_HEAP_SIZE`、FPU、两个加固宏、**IWDG 分频/重载**、时钟树(含 LSI)、`KeepUserCode=true`(必须为 true,否则 L2 整体失效)。
**生成区内那句"必须去掉"的调用 → 用宏改名升级为 L2**(2026-10-09 定,本案两处):
- `Src/iwdg.c`:`USER CODE BEGIN 0` 放空实现 + `#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)` → 生成区原样、**重生成零 diff**(§2.4)。
- `Src/stm32f4xx_it.c`:4 个 `__asm void X_Handler` 放 `USER CODE BEGIN 0`,**必须写在 4 个 `#define X X_Gen` 之前**(写在之后会被自己的宏改名);生成实体经宏改名让位,链接器自动移除(`.map` 各 `Removing … (2 bytes)`),向量表仍指向我们的 `__asm` 版。
> **L3 清单已清空**:所有定制要么在 `.ioc`(L1)、要么在 `USER CODE` 区(L2)——**重生成应当零 diff**。剩余 1 项 WARN 是 `Src/freertos.c` 的 `timeSyncTask` 手工定义(属性块在 `USER CODE BEGIN Variables` 区,本身受保护,WARN 只提示"它不在 `.ioc` 里")。**`adcTask`(B1,2026-10-10)同为手工定义**——属性块在同区、`osThreadNew` 在 `USER CODE BEGIN RTOS_THREADS` 区,同样受保护、同样不在 `.ioc` 里(B1 原计划走 L1,实际按 timeSyncTask 先例落 L2:避免为单任务触发全工程重生成)。
**为什么 `fault_handler_enable()`(SHCSR 使能三位)写不进 `.ioc`**(2026-10-01 查模板源码三重证据):① `tpl_main_c.ftl` 的 `MX_NVIC_Init()` 只在 `systemHandler=="false"` 时生成 `HAL_NVIC_EnableIRQ`,而三个 Fault 在 `NVIC-STM32F417_Modes.xml` 都标 `Y`;② 全部模板中 `SHCSR` 只出现 1 次且是 MPU 清理的 `&= ~MEMFAULTENA`(禁用),无任何 `|=`;③ `.ioc` 无 MPU,且 `HAL_MPU_Enable` 只置 `MEMFAULTENA` 一位。⚠ `.ioc` 的 `NVIC.MemoryManagement_IRQn=true` **不代表已使能**(只是向量可见)。现走 L2(`USER CODE BEGIN 1`)+ 审计项兜底。
### 10.3 审计脚本
```bash
python scripts/check_cubemx_survival.py # 审计报告(人读)
python scripts/check_cubemx_survival.py --write # 确认改动无误后重建基线
```
- **清单 21 项**覆盖 P0/P1 全部防线(iwdg 延后启动、4 个 Fault Handler 采集、故障使能、两个加固宏、断言改造 ×2、看门狗模块与工程挂载、复位原因锁存、HAL 驱动与使能宏、`.ioc` 关键项、钩子落点三项)。
- **漂移检测**:各项内容哈希存 `.cubemx_survival_baseline.json`;有意改动确认后 `--write` 重建。
- ⚠ `MDK-ARM/*.uvprojx` **排除在漂移检测之外**(`no_baseline=True`)——该文件每次 `@build` 后被 UV4 改写(XML 归一化 + `PackID` 降级),参与检测会每次构建后都被拦;只保留"源文件仍挂载"断言。
- **注释过滤**:断言前剥掉 C 注释,故 `protected` 项的 `must_contain` **只能断言代码符号**(锚点注释会被剥掉,断言它等于没断言)。
- 已挂 pre-commit `cubemx-survival`(改 `.ioc`/`Src/`/`Inc/`/`task_watchdog`/`.uvprojx` 触发)。
> **工作流**:每次 CubeMX 重生成后**立刻**跑一次该脚本——它直接列出"被覆盖了哪几处 + 修复提示",比事后靠故障现象反推快得多。
### 10.4 ARMCC `__asm` 的三条语法约束(2026-09-29 踩坑)
> **出处**:自 `AGENTS.md`「ARMCC 内联汇编语法」条目迁入(AGENTS.md 瘦身,不再当事实存放地)。4 个 Fault Handler 是手工 `__asm`(§10.2 的 L2 条);ARMCC 的内联汇编只有一种合法形态,以下三种写法全部实测踩坑:
| # | 尝试写法 | ARMCC 实际报错 | 根因 |
|---|---|---|---|
| ① | `__asm volatile ("MRS %0, lr" : "=r" (x))` | `#18: expected a ")"` | 这是 **GCC 内联汇编语法**,ARMCC 不支持 |
| ② | `__asm { MRS x, lr }`(块内联) | `#20: identifier "lr" is undefined` | 块内联把寄存器名当 **C 标识符**解析 |
| ③ | `__asm` 块内 `ADR r1, xxx_tag` 取 C 侧字符串/表格 | `A1516E: Bad symbol 'xxx_tag'` | `__asm` 块里**取不到 C 的数据符号**;去掉 `static` 也无效——汇编器只认自己作用域内的符号 |
**结论 / 可用写法**:整个函数写成 `__asm void f(void) { ... }`;要传"字符串/表格"这类 C 侧数据,就让 `__asm` 只取**寄存器参数**然后 `B` 到一个普通 C 函数、由 C 侧补上——所以 4 个 Fault 的故障名必须由 C 侧 per-fault 入口 `fault_entry_{hard,mem,bus,usage}` 带上,`__asm` 里不取 C 变量。
**覆盖 CubeMX 生成的同名函数**(对 §10.2 那条的补充):用 `#define f f_Gen`(**写在生成实体之前**)把生成实体改名,链接器会把未被引用的 `f_Gen` 自动移除(`.map` 可见 `Removing ... (N bytes)`)——**零代码开销且不删生成区任何一行**。
---
## 11. 任务级饥饿的诊断方法与实测基线(2026-10-10 结案沉淀)
> **来源**:`[ERR][WD] task stall: mask=0x00000067 req=0x00000077`(缺 bit4 = `TASK_WD_UI`)
> 的完整归因与处置。故障本身已结案——**累计 38 次上板启动 0 复现**(原 L2 基线 3/10),
> 原始分析文档按登记簿"完成即销项"规则删除。本节只留**可复用的判读口径、诊断方法、
> 实测基线与教训**;ADC 侧的处置细节在 [`ADC采样节拍设计说明.md`](ADC采样节拍设计说明.md) §6 与 §9,
> UI 推流门控在 `../UI设计/RemoDispBus协议与接收设计说明.md` §3,就绪门控 API 在
> `../网络/BSD_Socket_API_使用指南.md` §10.1。
### 11.1 现场日志的三条判读口径(都被误读过)
| 现象 | 正确读法 |
|---|---|
| 启动期出现 `MISS start snap=0x67 req=0x77` | **良性 episode**:`[WD] armed` 在 bring-up 完成后武装,而 `required_mask` 静态含 UI 位 → UI 首跳之前必现一次,随后自行恢复。**不是故障判据**,只作"UI 尚未起跳"的旁证 |
| `miss=11` / `miss=12` | `miss=` 是**累计**缺席次数(`s_miss_total`),**不是连续次数**;连续次数自 v1.5 起按位分账(`s_miss_bits[]`,各饱和在 `WD_MISS_TOLERANCE=8`;旧全局 `s_miss_streak` 退化为复合边沿计数)。缺口要用 `t=`(毫秒时基)差算,本例恰为 800 ms |
| `reset cause: software (NVIC_SystemReset)` | **不能**用来区分"IWDG 溢出"与"台架拉 NRST / 烧录工具软件复位"——本台架**健康**启动同样报这一串。停摆的权威证据是 `[ERR][WD] task stall` 行本身 |
### 11.2 诊断四步:先量化,再归因
1. **看掩码缺哪几位。** 只缺**一位** = 定点打击(该任务独有的依赖或优先级处境);
**多位同时缺** = 全局饥饿 / CPU 饱和。本例 `0x67` 只缺 UI,其余五个 Normal 任务心跳
全正常 → **当场否掉"计算量把 CPU 吃满"这个直觉**(用户假设方向对、结论不成立)。
2. **把嫌疑任务的单拍成本逐项算出来**(不要估)。当时算式:ADC1 一轮 9 通道 ≈67 µs、
TPAFE 快照 + 9 路取数 ≈10~20 µs、压环 + 5 实例傅氏 ≈10~15 µs → 单拍 0.10~0.13 ms
= **占空比 10%~13%**,距饱和很远。
3. **用计数速率反证"有没有空转"。** `ring` 在两次心跳间严格 +10000/10 s = **恰好 1000 次/秒**;
若该任务失去阻塞(空转),按 0.11 ms 单拍应为 ≈9000 次/秒 → 反证它每拍都睡满。
4. **用 `[PROF]` 指认最大非让出占用**(§11.4)。实测最大是 `defaultTask` 控制台排空
≈46 ms,**没有任何任务接近 800 ms** → 800 ms 属**异常长尾**,只能靠复现现场的快照抓,
不能靠健康剖面指认;⚠ 别把"健康剖面里没有"当成"不存在"。
### 11.3 三条结构性脆弱点(当时全部从代码证实)
| # | 脆弱点 | 现状 |
|---|---|---|
| 1 | **uiTask 是 `required_mask` 里唯一的 `osPriorityBelowNormal`**(其余五个全 `Normal`)。FreeRTOS 数值越大优先级越高 → 任一 Normal 任务持续就绪,uiTask 就**完全拿不到 CPU**;`configUSE_TIME_SLICING=1` 只保护**同优先级**公平 | **至今未变**(刻意不提优先级,理由见 §11.6)。风险由"心跳自产 + 往返门控 + 采集零忙等"三面压住 |
| 2 | **UI 心跳曾依赖 netTask 往返**:`ui_net_sink_poll()` → `net_accept()` → `osMessageQueuePut(osWaitForever)` + `xTaskNotifyWait(portMAX_DELAY)`,**之后**才 `task_wd_beat(TASK_WD_UI)` → netTask 慢一拍、UI 心跳晚一拍。这正是"**只有 UI 位缺失**"的根因(别的任务没这条依赖) | ✅ 已断两处:心跳提到 poll **之前**(`App/task/ui_task.c`);无客户端时门控让往返 = **0 次**(`uis` 实测 1 µs) |
| 3 | **自定截止期一旦错过就不可恢复**:`next += 1; osDelayUntil(next)` 且**丢弃返回值** → 单拍 >1 ms 后 `next` 相对当前时刻永久落后 → 该任务从此不再阻塞、以 Normal 优先级空转(**连续**慢拍才锁死,单次慢拍会自己追平)。返回值本是现成的重同步钩子 | ✅ 已消:adcTask 改**事件驱动唤醒**(`osThreadFlagsWait`),"落后"这个概念不存在了(`ADC采样节拍设计说明.md` §6) |
⚠ 顺带揪出的两个**独立正确性**缺陷(与停摆无关,但同源发现,别只当性能问题):
- **无新帧仍压环**:`r == 0`(TPAFE 无新帧)时也压帧 → 上一拍的重复值进 20 点 DFT 窗,
等效采样间隔被拉长。当时 `idle` 占 3.2% → 窗长从 20.0 ms 漂到 ≈20.6 ms ≈ **3% 频率失配**
→ 幅值/相位**系统性误差**(不是随机噪声)。现仅 `r == 1` 压帧,上板印证 `ring == frames`。
- **失败拍静默沿用上一拍值**:失败支路不清 `s_stm32_valid` → 照样压环、`skip` 抓不到。
现失败即清标志走整帧跳过(`ADC采样节拍设计说明.md` §4.4;单测 TC-ADCIN-070..078 钉死)。
### 11.4 `[PROF]` / `[PROF2]` 字段语义与当前基线
探针本体见 `App/util/task_prof.h`(DWT `CYCCNT`,168 周期 = 1 µs;每 ID 记 max/count/last,
enter/exit 段还能读出"此刻仍在段内已多久"——**永久阻塞的段永不结算,只有这个字段能证明**)。
| 字段 | 探针 | 含义 | 基线(2026-10-10 v2.8,无客户端,开机 29 s) |
|---|---|---|---|
| `uip` / `uil` | UI_PERIOD | uiTask 循环周期最坏 / 最近值 | **61.3 ms** / 10.0 ms——⚠ 地板是 **LVGL 整帧渲染**(独立实测帧周期 61.6 ms),**与网络无关** |
| `uiw` | UI_WORK | 本轮起点→心跳前(含 LVGL) | **11.4 ms**(改前 ≈50 ms 且 ≈`uis` → 当时单轮几乎全是推流) |
| `uis` / `uisn` | UI_SINK | 推流往返最坏 / 此刻仍在段内 | **1 µs** / 0(改前 ≈50 ms);**`uisn` 持续非 0 = "客户端在连且 netTask 拥塞"这一格真发生的铁证** |
| `adcm` | ADC_BEAT | adcTask 单拍(含心跳日志) | 2.0~2.25 ms——⚠ 峰值来自**每 10 s 一次的心跳日志**,不是采集,勿混读 |
| `adcp` | ADC_POLL | `adc_input_poll()` 单次 | **8 µs**(DMA 前 122 µs;超时拍曾 2.2 ms) |
| `defd` / `defs` | DEF_DRAIN / DEF_SYSLOG | 控制台排空 / syslog 落盘 | **46 ms** / 7 µs——**当前最大的单一非让出占用** |
| `netp` / `netn` | NET_POLL | `net_poll()` 最坏 / 在段内 | **0 µs** / 0——⚠ **0 是真实值**:空闲时 `net_poll()` 开头读 INT# GPIO 为高即 `break`,整条就是一次 GPIO 读,别判成"探针坏了" |
| `gl` | glitch | DWT 丢样 / 计数器重置计数 | 0(非 0 时以上数据不可信) |
**判读口诀**:`uip` 大而 `uiw`/`uis` 小 → **被调度饿死**;`uis` 大 → **阻塞在网络往返**;
`uiw` 大而 `uis` 小 → **自身渲染拖长**。三种机理在日志上必须能分开,否则归因只能靠猜。
### 11.5 三条教训(同类问题会复发)
1. **门控类优化必须保留低频兜底。** `readable` 只由 RECV_OK 中断置位,而 `net_reconcile_tick()`
的三类对账**不含**"芯片 RX 非空但 `readable=0`" → 中断一旦丢失,纯门控会把已到达的数据
**永久藏住**。现每 32 轮无条件 recv 一次。**拿"自愈能力"换性能是隐性负债。**
2. **门控会顺手消灭暴露路径。** 加了 accept 门控后,监听控制块消失时 `net_can_accept()`
恒返 0 → 改前"每轮 accept 撞 BADF"这条**暴露**随之消失 → 端口永久不再接受新客户端而无声。
现补 `net_get_sock(g_listen) == NULL` 判定。**加"跳过"逻辑时要问:原来哪条错误是靠这次调用暴露出来的?**
3. **把"定时轮询"换成"事件驱动"时,必须单独核对节拍源启动时刻 vs 消费者创建时刻**
(本仓 TIM7 分支 2026-10-02 的实测坑同款)——否则第一次上电就踩"ISR 对还不存在的消费者动手"。
### 11.6 明确不做的四条(理由要留着,别被"顺手优化"推翻)
| 备选 | 为什么不做 |
|---|---|
| **提高 uiTask 优先级** | 会**掩盖**"心跳依赖他任务"与"忙等"两个真实缺陷;而且它根本不解决"UI 在等 netTask"。缺陷已修完后,提优先级才成为可讨论项 |
| 给 `net_*` 加"有界等待"版 | 回执通道是"每任务一个 32 位通知值",超时后**迟到的回执会被下一次调用的零等待拾取当成新结果**(`net_wait_result` 注释里 L7 挂死/整段错位的同款根因)。要做对必须引入请求序号,代价高于搬家 |
| 把推流搬出 uiTask(独立 `uiSinkTask`) | 实测 `uis`=1 µs 而 `uip` 仍 60.9 ms = **LVGL 帧周期地板** → 搬走推流**不会让刷新变快**;收益只剩极端并发下的观感隔离,成本含**必改 `.ioc`** 的重生成与全套回归。若日后成问题,正确方向是先治 netTask 排队公平性(见 `../网络/Net层阻塞模型架构设计.md`),不是搬 UI |
| 把推流塞进 netTask | 零往返零新增 RAM,但共享源码要走 backend 端口(双端同改)、netTask 这个唯一执行者更胖、一个 UI 侧 bug 直接变成整机复位 |
> **2026-10-25 大窗补记(判读实锤,勿与上表混淆)**:条目「提高 uiTask 优先级」与
> 「有界等待」的否决理由**依旧成立**——大窗里 UI 的 ~1.2 s 单串停摆(9 条 stall 中唯一
> 的真单任务链,bit4 全程缺席到 mg≈900+)发生在**监听重建窗**内,正是上表所说
> 「UI 在等 netTask」的实况;修复方向仍按 v1.3 既注走**事件驱动 accept / 等待面搬家**,
> 不是提优先级、也不是给 net_* 裸加超时(迟到回执拾取风险原样在册)。同期暴露的
> **复合停喂判据**问题(本窗缺 UI、下窗缺 IEC104/FTP 串成 16:24/16:28 两起误申报,
> `mg=877/918` 均为复合链而非单任务链)已由 **v1.5 按位分账**根治:判据见 §2.2/§3.2/
> §5.3,回归钉 = 主机单测 TC-WATCHDOG-017(交替缺席零申报、持续喂狗)/ 018(同位 8
> 连击必报 + 恢复后账本重启)。原始日志判读材料:`docs/测试说明/boot_log_data/`
> (megawin 同日摘录入库,gz)。
---
## 附录 A:参考依据(当前有效)
| 来源 | 引用点 |
|---|---|
| `docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md` §21 | 12 位递减计数器、LSI 时钟源、启动后不可停、超时公式、`SR.PVU/RVU` 同步 |
| `docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md` §6.3.22(`RCC_CSR`) | `PORRSTF/BORRSTF/PINRSTF/SFTRSTF/IWDGRSTF/WWDGRSTF/RMVF`;"复位标志仅由电源复位清除" |
| `Drivers/CMSIS/.../stm32f407xx.h` | `IWDG_TypeDef`(KR/PR/RLR/SR)、`IWDG_BASE`、`IWDG_SR_PVU/RVU`、`RCC_CSR_*` 位定义 |
| `STM32F407-Demo.ioc` | `Mcu.IP4=IWDG`、`IWDG.Prescaler/Reload`、`RCC.LSI_VALUE=32000`、`FREERTOS.Tasks01`、两个加固宏 |
| `Inc/stm32f4xx_hal_conf.h:58` | `HAL_IWDG_MODULE_ENABLED`(已启用) |
| `MDK-ARM/STM32F407-Demo.uvprojx` | 挂载 `task_watchdog.c`、`stm32f4xx_hal_iwdg.c` |
| `MDK-ARM/STM32F407-Demo.sct` | `UNINIT_CCM 0x1000FF00 … 0x100` + `*(.noinit)` |
| `MDK-ARM/STM32F407-Demo.map` | `Removing … HAL_IWDG_Init (94 bytes)`、`task_watchdog.o` 尺寸、`.noinit` 48 B |
| `Inc/FreeRTOSConfig.h:165` | `configASSERT` → `vApplicationAssertFailed` |
| `App/task/net_task.c` / `default_task.c` / `sync/time_sync.c` / `NET/lftpd/*` / `GD5F2GQ5UE/gd5f2gq5ue.c` | 心跳与喂狗埋点(见 §5.5) |
> 立项时(2026-09-29)的"仓库里根本没有 IWDG 驱动"一类取证已随实施失效,不再列出;当时的原始登记在《项目审查与优化建议报告》§4.1(该报告 2026-10-25 已删除),已随「修复即销项」删除——**取证与实施记录以本文为准**。
## 附录 B:备选方案取舍记录
| 备选 | 否决理由 |
|---|---|
| 每任务各自喂狗 | 任一任务存活就喂狗,检测不到单任务卡死(§3.1) |
| FreeRTOS 软件定时器喂狗 | 定时器任务存活不代表业务健康;栈小优先级高,不适合承载判定 |
| `osEventFlags` 做心跳 | 需 6 位,`osEventFlagsSet` 可能触发调度,循环内代价过高 |
| WWDG | 依赖 APB1 与中断,在关中断/Fault/时钟异常场景失效(§2.1,V4 已反证 IWDG 独立性) |
| 独立看门狗任务 | 新建任务(TCB+栈)与 RAM 预算冲突;"谁来监视监视者"无解 |
| 直接 `NVIC_SystemReset()` 而非让 IWDG 溢出 | 复位原因变 `SFTRSTF`,丢失权威归因(§4.2) |
| 动态改 IWDG 超时 | 启动后 `PR/RLR` 不可改(须先复位再配),无需此能力 |
| 手工补 HAL IWDG 驱动 + 手改 `.uvprojx` | 工程 XML 结构脆弱、`.ioc` 与代码脱节 → 改走 CubeMX(§2.4) |
| 每任务独立计数器而非位图 | 回绕判定繁琐且需临界区;位图"取走清零"单字原子更优(§3.3) |
| 在 `IWDG_Init 2` 区"反初始化"撤销启动 | IWDG 启动后**无法停止**(唯一手段是复位)→ 只能在启动前拦截(§2.4) |
| 放宽监视窗口以容纳 30 s 网络等待 | 窗口一放宽,真卡死的检测就迟钝;改取"**切片喂位**"(§4.3) |
---
## 变更记录
| 日期 | 变更 | 说明 |
|---|---|---|
| 2026-09-29 | 创建 | 调研 + 设计方案:IWDG + 任务心跳位图;含选型、九项设计决策、验证计划与风险清单 |
| 2026-09-29 | 修正实施路径 | 实测发现 HAL IWDG 驱动源码不在仓库且未编入 MDK 工程 → 初稿改为**直接寄存器操作**(§2.2 路径 C);超时数值按公式核算修正;补 `RCC_CSR`/`IWDG` 寄存器定义的实测行号 |
| 2026-09-29 | **路径再变更 + 实施落地** | 决策改走 **CubeMX 图形配置**(§2.2 路径 A):`HAL_IWDG_MODULE_ENABLED` 改为经 CubeMX 生成(避免"取消注释即编译失败")。同时落地 §5 骨架到 `App/util/task_watchdog.{c,h}`,各任务埋点(default/net/ui/time_sync),`RCC_CSR` 复位原因锁存(`Src/main.c` + `App/app/app_main.c`),`configASSERT` 改为 `vApplicationAssertFailed()`。实测 CubeMX 重生成覆盖 4 处既有定制(P0×1 + P1×2 + P2×1),已全部恢复,`@build` 0 错误 0 警告 |
| 2026-09-29 | **新增 §10.2 三层手段 + 审计脚本** | 沉淀通用方法论(L1 `.ioc` / L2 `USER CODE` 区 / L3 审计+基线);新建 `scripts/check_cubemx_survival.py`(16 项清单 + 漂移检测)+ `.cubemx_survival_baseline.json` + pre-commit `cubemx-survival` 钩子;`AGENTS.md` 与 `docs/代码规范/pre-commit使用说明.md` 同步 |
| 2026-09-29 | **第二次重生成验证 + 机制修正** | 审计脚本**实测有效**(准确检出 5 处覆盖)。⚠️ **关键发现**:CubeMX 换了一代中断函数模板(旧 `HardFault_Handler`/`HardFault_Report` 自造区 → 新函数体内 `HardFault_IRQn 0`/`W1_HardFault_IRQn 0`),**"生成区外的自造标记区"同样不被保留**——上一轮假设被证伪。据此升级恢复方案:报告实现迁入 `USER CODE BEGIN 0`(标准保护位);Handler 本体改为生成区整函数替换(`#define` 改名保留生成实体,链接器自动移除,`.map` 已验证)+ `HARD-FAULT-ANCHOR` 锚点。同步修正审计脚本(`hardfault-handler` 改 `wholefile` + 锚点)、`UNPROTECTED_LEGACY` 清单、§10.1/§10.2 全文。`@build` 0 错误 0 警告 |
| 2026-09-29 | **3 个 Fault 升级为崩溃采集** | `MemManage` / `BusFault` / `UsageFault` 由空 `while(1)`(死机静默)升级为与 `HardFault` 同一套采集:生成区整函数替换(各带独立锚点 `MEMFAULT-ANCHOR` / `BUSFAULT-ANCHOR` / `USAGEFAULT-ANCHOR`)+ `#define X X_Gen` 改名保留生成实体(链接器各移除 2 字节)。新增 `fault_handler_enable()` 使能 `SHCSR` 后三者(默认只开 HardFault,不开则故障被升级、看不出根因),声明入 `Inc/stm32f4xx_it.h`、调用在 `app_main_init()` 第一行。因 ARMCC `__asm` 取不到 C 数据符号,改为 per-fault C 入口 `fault_entry_{hard,mem,bus,usage}` 带故障名调 `hard_fault_report(tag, frame)`。**刻意不启用** `UNALIGN_TRP` / `DIV_0_TRP`(避免把普通 bug 升级成死机)。审计脚本新增 `fault-handlers-3` / `fault-handler-enable` 两项、`UNPROTECTED_LEGACY` 清掉 3 个 Fault(WARN 2→1)、清单 16→18 项。`@build` 0 错误 0 警告;审计 18 通过 / 1 警告 / 0 失败 |
| 2026-10-01 | **查证 `fault_handler_enable()` 的 CubeMX 可行性** | 用户问"能否通过 CubeMX 配置"→ **否**。查 CubeMX 安装目录模板源码得三重证据:① `tpl_main_c.ftl` 的 `MX_NVIC_Init()` 只在 `systemHandler=="false"` 时生成 `HAL_NVIC_EnableIRQ`,而 `NVIC-STM32F417_Modes.xml` 中三个 Fault 都标 `Y`(=systemHandler)→ 永不生成使能代码;② **全部模板中 `SHCSR` 只出现 1 次**且是 MPU 清理的 `&= ~MEMFAULTENA`(禁用),**无任何 `\|=` 置位**;③ `.ioc` 无 MPU(`Mcu.IP` 列表),且 `HAL_MPU_Enable` 只置 `MEMFAULTENA` 一位。实测印证:`HAL_MspInit()` 里三个 Fault 一行代码都没生成。结论:只能手工写,但属 **L2**(`USER CODE BEGIN 1` 区)→ 重生成不丢,有审计项兜底。另新增警示:`.ioc` 的 `NVIC.MemoryManagement_IRQn=true` **不代表已使能**(第 1 段只是"向量可见")。写入 §10.2 与四个 Fault 采集小节 |
| 2026-10-01 | **新增 §7.3 主机侧单元测试(L0.5)** | 硬件访问收敛到薄平台层(`App/util/task_watchdog.h` 末段声明 3 个 `__weak` 钩子:喂狗/启动/取 tick),MCU 端行为零变化(`@build` 0/0)。新建 `test/host/watchdog/`(`watchdog_test.c` 39 用例 + `run_unit_test.py` MSVC 运行器 + `stub/` 三桩头),`#include` 生产源码以保证测的是真代码。挂 pre-commit `watchdog-unit-test` 钩子。新增 `docs/测试说明/看门狗单元测试说明.md`;`App/util/README.md`、`test/host/README.md`、`测试开发标准流程.md`(新增 L0.5 层)同步。顺带改善:`s_last_tick` 由函数局部 static 提为文件 static(可测性 + 模块状态归一)。§5.1 文件表与 §10 步骤表登记;§7.2 S1 优先级降低(位图逻辑已由 U1 覆盖) |
| 2026-10-02 | **P0 根因修复:IWDG 超时被静默配成复位默认值(比设计短 5 倍)** | round 3~8 反复出现的"空 STOR(0 字节截断)→ IWDG 误复位"**根因不是"poller 自身停摆"**。真因:`Src/iwdg.c` 为实现**延后启动**(§4.5 决策 C)删掉了 `HAL_IWDG_Init()`(`IWDG-DEFER-START-ANCHOR`),而 `HAL_IWDG_Init()` 是**唯一**会把 `hiwdg.Init.Prescaler/Reload` 写进 IWDG 寄存器的路径——删掉后 `MX_IWDG_Init()` 只填结构体,`WD_IWDG_PRESCALER`/`WD_IWDG_RELOAD` 沦为**死宏**,寄存器停在**复位默认值** `PR=0`(÷4)/`RLR=4095`。实测(SWD 回读)实际超时 = 4×4096/LSI ≈ **349~512 ms**,而设计是 64×1250/LSI ≈ **1701~2498 ms**。两个后果:① `WD_MISS_TOLERANCE`(8×100ms=800ms)**永远等不到**就被 IWDG 复位 → **停喂判定分支成死代码**(这正是 §7"STALL 从未执行"的真正原因,当时据此误判为"poller 停摆");② 任何 >400ms 的心跳缺口都直接误复位(空 STOR 的 FTL flush 使 NET/FTP/UI 心跳缺 ~400ms)。**修复**:`wd_platform_start()`(`__weak`,**不在 CubeMX 生成区**,重生成不丢)补上按设计值配置:解锁(`KR=0x5555`) → `PR` → 等 `SR.PVU` 清 → `RLR` → 等 `SR.RVU` 清 → `KR=0xCCCC`(不等同步会静默丢弃后写的那只,SWD 实测)。`.map` 证实 `Removing stm32f4xx_hal_iwdg.o(i.HAL_IWDG_Init), (94 bytes)`,故这是全固件**唯一**写 PR/RLR 的点。**验证**:SWD 现场改回设计值后,同一空 STOR 由 **5/5 复位 → 100% 成功**;固件烧录后 3 次开机回读均为 `PR=4/RLR=1249`。`@build` 0/0;本地 CI L0+L0.5 全绿(278/278 断言) |
| 2026-10-03 | **V2/V4/V5 台架验收闭环(N-03 销项)** | 三用例全部通过。**V2 任务饿死**:`netTask` 循环插 `while(1)` → 800ms 声明 `[WD] task stall: mask=0x00000001 req=0x00000037`,≤2.5s 触发 IWDG 复位(`reset cause: IWDG`,`RCC_CSR=0x24000000`)。⚠️ 首轮实测**不复位**(stall 已声明、`miss` 累至 1157≈115s):根因是**停摆证据落盘自身喂狗**——`wd_stall_evidence()`→`sys_log_evidence()` 在停喂窗口里每 100ms 轮都写 NAND(`/log/wdev.log` + `SYSLOG_RUN` flush 诱导),经 `nand_ftl_wd_feed()` **直接重装 IWDG**,"停喂"语义被证据机制抵消。修复:加 `s_stall_evidenced` **每 episode 门闩**(每轮停摆只落盘一次,此后不再触碰 NAND)→ 复测复位正常。**V4 关中断死循环**:`__disable_irq()` 后插 `while(1)` → 设备每 ~4.9s 稳定复位(20+ 周期,`reset cause: IWDG`),证实 IWDG **独立于中断**(PRIMASK 屏蔽 TIM7/SysTick/PendSV 仍能复位)。**V5 长期不误杀**:FTP 512KB 上/下传 + `e02_probe` 网络探针持续 **30 min** → **120/120 次 FTP 传输全 PASS(MATCH)、零 IWDG 复位**(唯一复位为起始烧录的 `software (NVIC_SystemReset)`)。Step 4 堆告警全程未触发(`min_ever=4728 B` > 3072 B)。**遗留观察(已登记 `待处理内容.md`)**:V5 期间 44 次 `task stall`(`mask=0x00000001` 持续 ≥800ms)**100% 与 `e02_probe` 的 30s 挂起客户端→`time_sync` 掉线+监听重建(`listen FAIL`×2,约 4s)重合**;隔离实验证实「仅 FTP(无探针)120s = 0 次 stall」→ 该停摆为**探针场景专属**,非一般负载误杀。因缺口(~1s)< IWDG 最坏超时(1701ms)而未复位,与 §4.3 包含关系一致 |
| 2026-10-09 | **IWDG 延后启动由 L3 升级为 L2:不再需要每次重生成后手工删那一句** | 起因是提问"能否在初始化函数下方的自定义区里先反初始化"。查证:**反初始化不可行**——IWDG 一旦启动就**无法停止**(参考手册:唯一停止手段是复位),故不能在 `IWDG_Init 2` 区撤销。改用等价且免手工的做法:`Src/iwdg.c` 的 `USER CODE BEGIN 0`(标准保护位)放空实现 `iwdg_deferred_start()` + `#define HAL_IWDG_Init(a_h) iwdg_deferred_start(a_h)`,把**本编译单元里**那句生成调用 `HAL_IWDG_Init(&hiwdg)` 改名到空实现 → 生成区保持 CubeMX 原样、**重生成零 diff**。`.map` 证实 `Removing stm32f4xx_hal_iwdg.o(i.HAL_IWDG_Init), (94 bytes)`、`iwdg.o(i.MX_IWDG_Init)` 只引用 `.bss`(空实现被编译器消掉)。审计项 `iwdg-defer-start` 相应由 `wholefile`+锚点注释改为 `protected`(区 `0`)+ 代码符号断言(`iwdg_deferred_start` / `#define HAL_IWDG_Init`——`protected` 比对前会剥掉注释,锚点注释不能当断言)。§10.2 硬教训②与 L3 存量清单同步:L3 只剩 4 个 Fault Handler 实体(同一招可根治,尚未做)。`@build` 0 错误 0 警告;审计 21 通过 / 1 警告 / 0 失败 |
| 2026-10-09 | **4 个 Fault Handler 由 L3 升级为 L2:重生成完全零 diff** | 用与 iwdg 同一招。`Src/stm32f4xx_it.c` 的 4 个 `__asm void X_Handler` 从**生成区**搬进 `USER CODE BEGIN 0`,且必须写在文件头那 4 个 `#define X X_Gen` **之前**(写在之后会被自己的宏改名);配套的 `#undef X` 随之删除(不再需要)。生成区恢复 CubeMX 原样(`void HardFault_Handler(void)` + `HardFault_IRQn 0` / `W1_HardFault_IRQn 0` 标记区),4 个锚点注释(`HARD-FAULT-ANCHOR` / `MEMFAULT-ANCHOR` / `BUSFAULT-ANCHOR` / `USAGEFAULT-ANCHOR`)全部退役。`.map` 复核:`startup_stm32f407xx.o(RESET) refers to stm32f4xx_it.o(.emb_text) for HardFault_Handler`(向量表仍指向我们的 `__asm` 版)、`.emb_text → i.fault_entry_* → i.hard_fault_report`、4 个 `*_Gen` 各 `Removing ... (2 bytes)`。审计项 `hardfault-handler` / `fault-handlers-3` 由 `wholefile`+锚点改为 `protected`(区 `0`)+ 代码符号断言。**L3 清单清空**,重生成预期零 diff。`@build` 0 错误 0 警告;审计 21 通过 / 1 警告 / 0 失败 |
| 2026-10-10 | **正文按当前代码重写(清理立项前的描述)** | 正文原样保留了大量**立项前(2026-09-29)**的状态取证与已废弃的"路径 C 考古",与现状矛盾,故重写为现状设计说明(1144 → 571 行;**历史与逐次决策全部保留在下方变更记录,未删**)。主要更正:① §1.1"现状:无任何硬件看门狗"改为**历史背景**(IWDG 已在 `.ioc`/`hal_conf`/工程三处就位);② **ftpTask 早已纳入监视**——经 `lftpd_task_wd_beat()` 弱钩子(事件循环 / RETR·STOR 分片 / 连接与卷锁等待),旧正文"首期不埋点、由掩码剔除"及 R3/O1 已作废;③ 补齐正文缺失的三块现状实现:**FTL 心跳路由**(`task_wd_bind()` + `nand_ftl_wd_beat()`)、**FTL 直接喂狗**(`nand_ftl_wd_feed()`,含"设备存活但功能冻结"边界)、**round 7/8/8b 取证机制**(`/log/wdev.log`、`.noinit` 槽 + `UNINIT_CCM`、`PREVRUN`/`TICKRUN`/`MISS start`/`STALL`);④ 掩码改为按代码实况列表(产品 `0x37` / `ENABLE_HW_LAYER_TESTS` `0x35` / **testTask 任何构建都不入掩码**)并写明"精确相等"语义;⑤ 复位原因优先级按 `reset_cause_str()` 实际顺序改写(旧表把 POR 排第一,代码是 **IWDG 最优先**);⑥ §4.6 更正为**已集成**:看门狗复位写 `selftest.log`(旧正文"未改");⑦ §8 预算改用 `.map` 实测(`task_watchdog.o` 1098 B 代码 / 44+104 B RAM / `.noinit` 48 B;`iwdg.o` 仅 28 B,`HAL_IWDG_Init` 94 B 被移除);⑧ 附录 B 自相矛盾修正(旧表把"借助 CubeMX"列为否决,实际已选它);⑨ §7 由"验证计划"改为**验证记录**(V2/V4/V5 ✅、U1 39 断言 ✅,V3/V6/V7 标为未单独注入、V8 未做)。连带同步:`Src/iwdg.c`、`Src/freertos.c`、`App/task/default_task.c` 三处注释的章节引用(旧 §4.5/§4.6 → 新 §4.1/§4.4),`scripts/check_cubemx_survival.py` 提示语(§10.1 → §10.2/§10.3),`实机测试实施指南.md`(§10 → §7 ×3)、`看门狗单元测试说明.md`(§10 → §7)、`模拟器差距清单.md` H-1(§4.9 → §4.7,并更正"模拟器不编译本模块、无 IWDG 桩")、`docs/README.md`("实施中/待验证" → 已闭环)。⚠️ **下方历史行里的 §x.y 引用指当时编号**,不再回溯改写 |
| 2026-10-10 | **adcTask 纳入监视(B1):必需掩码 `0x37`→`0x77`** | B1(ADC 采集链路)落地时新增 `adcTask`(`App/task/adc_task.c`:1 ms 采样节拍触发 CONVST + 消费 `tpafe5160_snapshot()`)并纳入 IWDG 监视:`task_wd_id_t` 新增 `TASK_WD_ADC = 6`;`wd_required_mask()` 产品 `0x37`→`0x77`、`ENABLE_HW_LAYER_TESTS` `0x35`→`0x75`(adcTask **不依赖任何就绪屏障** → 任何构建都置位)。adcTask 主循环每 1 ms `task_wd_beat(TASK_WD_ADC)`;**不做 NAND I/O → 不登记 FTL 心跳路由**(无需 `task_wd_bind()`)。主机侧单测 `beat_all()` 与掩码断言(`tc_003`/`tc_005`/`tc_011` 的 `0x37`→`0x77`)同步,39 断言全绿。任务由 `Src/freertos.c` 的 `USER CODE` 区手工创建(同 timeSyncTask,不在 `.ioc` 里),栈 512 words、优先级 Normal,`stack_report.py` 实测最坏链 504 B(余 1544 B / 75%)。见 `docs/算法/继电保护功能复现规划.md` §7 |
| 2026-10-10 | **B1 `@test` 实机闭环:adcTask 掩码真机验证** | 台架闭环(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)确认 `adcTask` 纳入掩码后**无 adc 相关误复位**:`[STACK] adc peak_used=496/2048 free_min=1552 (75%)`、`frames/seq` 每 10 s 严格 +10000(=1000 Hz)、`idle=0`/`frst_err=0`、全片回读 `No difference found`。启动早期两次 `MISS start snap=0x67 req=0x77` 仅缺 bit4(`timeSync` 未启动,属正常就绪窗;adc 的 bit6 自始置位),掩码随后收敛、全程无复位。同轮另修 `dbg_console_writer_ready()` 门控(adcTask 周期日志在写者注册前抑制,见 `控制台单写者日志设计说明.md`) |
| 2026-10-10 | **新增 §11:任务级饥饿的诊断方法与实测基线** | 承接 Task #32(`task stall: mask=0x67 req=0x77`,缺 `TASK_WD_UI`)结案后的沉淀——原分析文档 `docs/问题记录/UI任务看门狗停摆分析.md` 按"完成即销项"删除,可复用部分并入本文:**§11.1** 三条易误读的判读口径(启动期 `MISS start 0x67` 良性、`miss=` 是累计不是连续、`reset cause` 不能区分 IWDG 与台架);**§11.2** 诊断四步(先看掩码缺几位 → 量化占空比 → 用计数速率反证空转 → 用 `[PROF]` 指认占用者;本例否掉"CPU 饱和",实测 10%~13%);**§11.3** 三条结构性脆弱点(uiTask 唯一 BelowNormal/心跳依赖 netTask 往返/自定截止期不可恢复)与两个顺带修掉的**正确性**缺陷(无新帧仍压环 ≈3% 频率失配、失败拍静默沿用旧值);**§11.4** `[PROF]`/`[PROF2]` 全字段语义 + v2.8 基线(含 `netp=0` 是真实值、`adcm` 峰值来自心跳日志、`uip` 地板 = LVGL 帧周期)与三机理判读口诀;**§11.5** 三条教训(门控须留低频兜底、门控会消灭暴露路径、事件驱动要核对节拍源与消费者创建时刻);**§11.6** 明确不做的四条及理由。§9.1 的 R7 行改指本文 §11.3 |
| 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 逐字节相同) |