Files
STM32F4-Base/docs/子系统设计/看门狗设计说明.md
T

76 KiB
Raw Blame History

看门狗设计说明

模块名称:独立看门狗(IWDG)+ 任务心跳位图 文档定位:实施中——记录选型依据、在既有平台上落地的约束、实现现状与 待台架验证项。代码骨架已落地并编译通过(§10 第 06 步);第 710 步 (故障注入 / 长跑 / 本文档转为"现状设计说明")待台架。 状态:🚧 代码已落地(@build 0/0),待台架故障注入与长跑验证;登记于 ../待处理内容.md N-03 日期:2026-09-29 相关文档: ../审查记录/项目审查与优化建议报告.md(N-03 缺陷原始登记)、 ../审查记录/下一步任务规划.md(排期)、 运行与自检记录设计说明.md(§5.3 上电/重启分类)、 ../架构设计/项目启动顺序管理.md(就此绪屏障)、 ../测试说明/ST-LINK_CLI_复位使用说明.md(复位语义)


1. 问题域

1.1 现状:无任何硬件看门狗

事实 证据
全库无 IWDG/WWDG 初始化 grep -r "MX_IWDG_Init|HAL_IWDG_Refresh" 零命中
HAL 模块未启用 Inc/stm32f4xx_hal_conf.h:58 /* #define HAL_IWDG_MODULE_ENABLED */(注释态)
HAL IWDG 驱动源码不在仓库 find Drivers -iname "*iwdg*" 零命中——CubeMX 未勾选该外设,源文件从未拷入
MDK 工程未编入 IWDG MDK-ARM/*.uvprojx 的 stm32f4xx_hal_*.c 列表无 stm32f4xx_hal_iwdg.c
stm32f4xx_hal_conf.h:375 却 include 了 #include "stm32f4xx_hal_iwdg.h" 位于 #ifdef HAL_IWDG_MODULE_ENABLED 内,宏未定义故不生效
.ioc 无看门狗配置 STM32F407-Demo.ioc 无 IWDG/WWDG 条目
无复位原因读取 Src/main.c / App/util/*.c 无 __HAL_RCC_GET_FLAG(RCC_FLAG_*)

后果:现场任何形态的死机(任务饿死、死锁、HardFault、初始化失败挂死) 都不可自恢复,必须人工断电。这是可靠性上的单点缺口。

⚠️ 实施路径因此改变:HAL_IWDG_MODULE_ENABLED 不是一个"取消注释即可"的开关—— 取消后 stm32f4xx_hal_conf.h 会去 include 一个不存在的头文件,直接编译失败。 故须由 CubeMX 勾选 IWDG 后重生成,让驱动源码与 HUD 开关一并就位(见 §2.2)。

1.2 现存的两处"假保护"

项目已开两个 FreeRTOS 钩子,但它们的兜底动作并不完整:

钩子 位置 当前动作 缺口
vApplicationStackOverflowHook Src/freertos.c:323 裸串口打任务名 → HAL_Delay(200) → NVIC_SystemReset() ✅ 会复位;但复位原因与"看门狗复位"混为一谈(都留 SFTRSTF)
vApplicationMallocFailedHook Src/freertos.c:355 裸串口告警,不复位 堆耗尽后系统继续运行但已不可用,且无人再喂狗时也不会被拉回
configASSERT(x) Inc/FreeRTOSConfig.h:163 taskDISABLE_INTERRUPTS(); for(;;); ❌ 关中断死循环:连日志都不打,且关中断后即使 IWDG 已启动也照样能复位(IWDG 独立于中断)——这是唯一一处"关中断也救得回来"的幸运

configASSERT 的关中断死循环恰好是 IWDG 能覆盖的场景:IWDG 由 LSI 独立计数, 不依赖 CPU 时钟与中断,taskDISABLE_INTERRUPTS() 对它无效。所以引入 IWDG 后, 该断言形态从"永久砖"降级为"1 次重启"。

1.3 设计目标

  1. 可恢复:任务级死锁 / 饿死 / 断言死循环 → 硬件复位,无需人工到场。
  2. 可诊断:复位后能区分"上电 / 看门狗 / 软件 / 引脚 / 欠压",并落 run.log。
  3. 不误杀:正常业务的长耗时窗口(NAND 大块擦写、FAT 挂载扫描、PHY 链路等待 3s、LVGL 大页重绘)不得触发复位。
  4. 低代价:RAM/Flash 增量可核算,且不破坏既有 osDelay 节拍与"串口静默窗口"观测习惯。

2. 选型:为什么是 IWDG 而不是 WWDG

STM32F407 有两个看门狗,本项目只能用其中一个。

维度 IWDG(独立) WWDG(窗口)
时钟源 LSI ≈ 32 kHz(独立 RC,IWDG 一启动即强制 LSI 开启且不可关闭) APB1 时钟(168/4 = 42 MHz,依赖 PLL)
依赖 CPU 时钟 ❌ 不依赖 ✅ 依赖;PLL 失锁/时钟切换期失效
依赖中断 ❌ 不依赖 ✅ 依赖(早期唤醒中断 EWI)
复位类型 IWDGRSTF(VDD 域) WWDGRSTF
能否停 启动后只能靠复位停 可关
喂狗窗口 只有下限(太晚不喂就复位) 有上下限(过早喂也复位)

结论:选 IWDG。 核心理由是本项目的故障谱——死锁、饿死、HardFault、断言挂死。 这类故障可能伴随时钟异常或中断被关闭(configASSERT 就是关中断), 而 WWDG 依赖 APB1 与中断,恰恰在这些场景下失效。IWDG 走独立 LSI,是全场景兜底。

代价:LSI 精度差(datasheet 典型 32 kHz,实际可在 17–47 kHz 区间漂移,且随 温度/电压变化)。项目 .ioc 已按 RCC.LSI_VALUE=32000 建模,超时需按最坏情况 留裕度(见 §4.3)。

2.1 IWDG 超时计算

STM32F4 的 IWDG 为 12 位递减计数器,超时公式(参考手册 §21.3):

T_IWDG = (4 × 2^PR) × RLR / F_LSI

其中 PR = 预分频寄存器值(0..7,对应分频 4/8/.../256),RLR = 重装值(0..4095)。

按 F_LSI = 32 kHz 标称值:

PR 分频 RLR 标称超时 最坏超时(F_LSI=47 kHz)
4 64 1249 2498 ms ≈ 1701 ms
4 64 2499 4998 ms ≈ 3403 ms
4 64 4999 9998 ms ≈ 6807 ms
5 128 1249 4996 ms ≈ 3402 ms
5 128 2499 9996 ms ≈ 6806 ms

(表中数值经脚本按上述公式核算,非目测估计。)

⚠️ 关键:LSI 偏快时超时变短。设计必须按"最坏情况(LSI=47 kHz,即超时打约七折)" 校验,而不是按标称 32 kHz。上表"最坏超时"列即按 47 kHz 计算。

本方案选定(见 §4.3):PR=4(64 分频)/ RLR=1249 → 标称 2498 ms、 最坏 ≈1701 ms。

2.2 实施路径:走 CubeMX 生成(原方案为直接寄存器,已变更)

变更说明(2026-09-29):本节初稿推荐"直接寄存器操作"(下称路径 C),依据是 HAL IWDG 驱动源码不在仓库。经与项目负责人确认,改走 CubeMX 图形界面生成 (路径 A)——让 .ioc 成为配置的唯一事实源、与其他外设保持一致。路径 A 的 原评估"高风险"已被本次实践证伪:重生成带来的定制覆盖是有限且可枚举的 (实测见 §10 第 2 步),处理方式已固化为"重生成 → 逐项恢复 → 编译验证"。 以下保留路径比较,作为决策记录。

约束事实(§1.1 表的第 3、4 行):HAL IWDG 驱动源码不在仓库、也不在 MDK 工程中。 CubeMX 只在勾选该外设时才会把 stm32f4xx_hal_iwdg.c/h 拷进 Drivers/STM32F4xx_HAL_Driver/。

因此 #define HAL_IWDG_MODULE_ENABLED 一旦取消注释,stm32f4xx_hal_conf.h:375 就会 include 一个不存在的头文件 → 编译失败。

三条可选路径:

路径 做法 评估
A. 从 CubeMX 重新生成(勾选 IWDG)(实际采用) 用 .ioc 勾选 IWDG 后生成 ✅ 配置单一事实源、与其他外设一致;代价 = 需恢复被覆盖的既有定制(实测 4 处,见 §10 第 2 步),且须按 AGENTS.md 处理行尾空白抖动
B. 手工补入 stm32f4xx_hal_iwdg.c/h 从 STM32CubeF4 包拷贝两文件 + 注册进 MDK 工程 ⚠️ 需手改 .uvprojx(XML 结构脆弱),且引入约 0.3 KB Flash 的 HAL 封装;.ioc 与代码脱节
C. 直接寄存器操作(初稿推荐,已放弃) 用 CMSIS 已提供的 IWDG 实例指针 + 寄存器位域宏 ✅ 零新增依赖、零工程文件改动、约 20 行;❌ 但破坏了"外设配置统一由 CubeMX 表达"的约定,后续维护者无处可查"这台设备的 IWDG 配了什么"

选 A 的依据:本项目的既有约定是"外设配置 = .ioc"(AGENTS.md「平台约束」: Inc//Src/ 中 CubeMX 生成文件带 USER CODE 标记,自定义代码只写标记区内)。 IWDG 若用裸寄存器,则分频/重载值只散落在 App/util/task_watchdog.c 的宏里, 与 .ioc 无关联——下一个维护者改超时值时不会想到去改代码宏。 路径 A 让该配置出现在 CubeMX 界面里,符合项目惯例。

CubeMX 侧配置值(在 .ioc / CubeMX 图形界面填写):

项 值 说明
Activated ✅ 勾选 Peripheral 树 → IWDG
IWDG counter clock prescaler 64 对应 IWDG_PRESCALER_64,即 PR=4
IWDG down-counter reload value 1249 12 位,范围 0~4095
Window value —— ⚠️ 本 HAL 版本 IWDG_InitTypeDef 无此字段(仅 Prescaler / Reload),CubeMX 界面亦无该项;窗口寄存器保持复位默认 0xFFF = 禁用窗口,符合预期

生成结果:Src/iwdg.c / Inc/iwdg.h(新文件)、Drivers/STM32F4xx_HAL_Driver/{Inc,Src}/stm32f4xx_hal_iwdg.{h,c}、 Inc/stm32f4xx_hal_conf.h 中 HAL_IWDG_MODULE_ENABLED 解除注释、.uvprojx 挂载驱动、 Src/main.c 的 MX_*_Init() 序列新增 MX_IWDG_Init()。

⚠️ 路径 A 的固有陷阱(本次实测踩中,P0):CubeMX 把 MX_IWDG_Init() 插在 app_main_init() 之前,而 HAL_IWDG_Init() 内部第一句即 __HAL_IWDG_START(hiwdg)(立即启动、之后不可停)。启动段(NAND 初始化等)耗时 可达数秒且无喂狗点 → 按 2.5s 超时必然复位 → 开机死循环。 CubeMX 界面无法关闭这个自动启动(HAL 行为,无对应选项)。 解决办法见 §4.5 决策 C:把 Src/iwdg.c 里那一行 HAL_IWDG_Init(&hiwdg) 换成 只填参数的 USER CODE 注释块,启动动作移交 task_wd_start()。 这是路径 A 下唯一必须手工修改生成文件的地方,已在文件内留注释说明恢复方法。

参考:CMSIS 设备头已提供的寄存器定义(路径 C 曾依赖,现仅作 task_wd_beat/task_wd_poll 里裸写 IWDG->KR 的依据):

已有定义 位置 内容
IWDG_TypeDef stm32f407xx.h:573-579 KR / PR / RLR / SR 四寄存器结构体
IWDG_BASE :944 APB1PERIPH_BASE + 0x3000
IWDG 实例指针 :1068 ((IWDG_TypeDef *) IWDG_BASE)
IWDG_KR_KEY_Msk :9331 键值字段掩码
IWDG_PR_PR_Msk / IWDG_PR_PR_0..2 :9336-9340 分频字段
IWDG_RLR_RL_Msk :9344 重装值字段(12 位)
IWDG_SR_PVU / IWDG_SR_RVU :9349-9353 更新进行中标志(须轮询等它清零)

键值常量(由 HAL 定义在 stm32f4xx_hal_iwdg.h,本方案在 task_watchdog.c 内裸写):

用途 值
喂狗(重装计数) 0x0000AAAA
启动看门狗 0x0000CCCC
解锁 PR/RLR 写保护 0x00005555
锁定 PR/RLR(保护配置) 0x00000000

IWDG 是**"写键值"式外设**:写 0x5555 解锁 → 配 PR/RLR → 写 0xCCCC 启动 (启动后不可停,只能复位)→ 此后周期性写 0xAAAA 喂狗。

喂狗为何仍走裸寄存器而非 HAL_IWDG_Refresh():喂狗是 100 ms 级高频操作, HAL 封装带 IWDG_HandleTypeDef 有效性检查(读 State 字段、判 LOCKED), 在高频路径上无收益。Task_wd_poll() 里写 IWDG->KR = 0xAAAA 是 1 条存储指令。 这不是"绕过 HAL",而是"HAL 提供了该寄存器定义,直接用"——与路径选择无关。


3. 架构:心跳位图 + 单一喂狗者

3.1 为什么不能"每个任务各自喂狗"

最朴素的方案——HAL_IWDG_Refresh() 塞进每个任务循环——有致命缺陷:

只要还有任何一个任务在跑,狗就会被喂。 那么真正的故障场景(netTask 死锁、 uiTask 饿死)永远检测不到,因为别的任务还在正常喂。

它退化成"只检测全部任务同时挂死",而那种情况现实中几乎不发生。

3.2 采用方案:任务置位 → 校验者判定 → 校验者喂狗

看门狗心跳模型:任务置位 → 校验者判定 → 校验者喂狗

图源:tools/arch_diagram/gen_watchdog_svg.py(改图改脚本重跑,见 ../架构设计/图形绘制流程.md)。

语义:喂狗 = "所有被监视的任务都在窗口内动过"。 任何单个任务卡死 → 位图长期残缺 → 喂狗停止 → 2.5 s 后 IWDG 复位。

具体到代码(App/util/task_watchdog.c):

阶段 代码
① 任务置位(生产者) s_hb_bits |= (1u << id)(task_wd_beat,仅置位不清零)
② 校验者取走清零 snapshot = s_hb_bits; s_hb_bits = 0u;(task_wd_poll,单字原子)
③ 掩码判定 snapshot == wd_required_mask()(默认 0x37 = bit0|1|2|4|5)
④ 判定为真 → 喂狗 s_miss_streak = 0u; IWDG->KR = 0x0000AAAAu;
④ 判定为假 → 累计 s_miss_total++;连续 8 次(800 ms)后打 [WD] task stall 并停喂

图中时间轴的四个时刻(0 / 0.8 s / 1.7 s / 2.5 s)与约 900 ms 裕度的推导见 §4.3。

3.3 为什么心跳位图用"取走并清零"

snapshot = s_hb_bits;      /* 读 */
s_hb_bits = 0;             /* 清 */

这是单字读-改-写,在 Cortex-M4 上天然原子(无锁)。相比之下:

方案 问题
每任务用独立 volatile uint32_t 计数器,校验者比较"是否增长" 需额外临界区,且 32 位计数器回绕判定繁琐
用 osEventFlags 做心跳 需要 7 个事件位;osEventFlagsSet 可能阻塞/触发调度,任务循环里代价过高
单字位图 + 取走清零 ✅ 零临界区、单指令原子、一个静态字

取走清零的语义优势:校验者看到的位图 = "自上次抽样以来哪些任务动过"。 若某任务卡死,它的位在每轮抽样里恒为 0 → 连续缺失可累计 → 可判"连续 N 轮未达标"。

3.4 为什么喂狗者在 defaultTask

候选 评估
defaultTask ✅ 已具"单写者/属主"文化(配置引擎属主、syslog 唯一写者、控制台唯一发送者);循环基准 50 ms,天然适合 100 ms 抽样;栈已 4096 words,加少量逻辑无压力
netTask ❌ 主循环 osDelay(1),节拍最紧,但它是最可能卡死的任务之一(SPI 事务等待)——让被监视者喂狗语义错乱
独立看门狗任务 ❌ 需新建任务(栈 + TCB),RAM 预算紧张;且它比 defaultTask 更"旁路",出问题时无人知道
中断/timer ❌ FreeRTOS timer 任务优先级高、栈小,且它的存活本身不能代表业务健康

defaultTask 是既有的"系统管家"角色,喂狗职责与之一致。

⚠️ 对抗性风险:defaultTask 自身卡死时,狗永远不会被喂 → 必然复位。 这正是期望行为(fail-safe),无需额外设计。


4. 关键设计决策

4.1 决策一:监视谁 —— 是否包含 testTask

testTask 仅在启用测试套件时创建(configCHECK_FOR_STACK_OVERFLOW 与 TEST_SUITE_* 决定),且套件运行时独占存储/CH395F,其循环可能长时间 阻塞(如 nand_ftl 掉电模拟)。

方案 说明
A. 位图掩码按构建配置编译期生成(推荐) #if defined(TEST_SUITE_*) 决定 required mask 是否含 testTask 位
B. 运行时判定(句柄非 NULL 才要求) 更灵活,但要求校验者接触 TCB,代价高

选 A:编译期掩码是最简单且零运行时开销的做法,与项目"按套件条件编译"的既有风格一致。

4.2 决策二:ENABLE_HW_LAYER_TESTS(netTask 自挂起)的处理

net_task_func 在 ENABLE_HW_LAYER_TESTS 下入口即 for(;;) osDelay(1000); (App/task/net_task.c:166-171)——该任务在该构建下本就不干活。

若照常要求它置心跳位,则该构建永不喂狗(要么把 osDelay(1000) 加心跳, 要么从掩码剔除)。

决策:与 TEST_SUITE_* 同样走编译期掩码——ENABLE_HW_LAYER_TESTS 下剔除 netTask 位, 并保留其 osDelay(1000) 自挂起形态(心跳位反而说明该构建下 netTask "活着",语义无意义)。

4.3 决策三:超时时长与监视窗口

三个时长必须满足严格的包含关系:

T_任务循环 < T_监视窗口 < T_IWDG超时 < T_最坏超时(打七折)

推荐取值:

参数 值 依据
任务循环周期 netTask 1 ms / defaultTask 50 ms / uiTask 10 ms / timeSyncTask ≤ 50 ms / ftpTask 事件驱动(可能长时间阻塞) 各自既有 osDelay 节拍
监视窗口 100 ms(抽样周期) 是 defaultTask 50 ms 循环基准的整数倍;对 netTask 1 ms 有 100× 裕度
缺失容忍 连续 8 次(= 800 ms 无心跳才停喂) 容忍 NAND 擦写(最坏数百 ms)、PHY 等待 3 s 不在本窗口(见 §4.4)
IWDG 超时 2498 ms(PR=4 / RLR=1249) 最坏超时 ≈1701 ms,仍 > 800 ms 停喂判定期 + 约 900 ms 复位余量
最坏超时校验 1701 ms > 800 ms ✅ 即使 LSI 偏快 47 kHz,停喂后也在 1.7 s 内复位

ftpTask 是最大变数:它在 osEventFlagsWait(..., osWaitForever) 上无限阻塞 (Src/freertos.c:247),之后 lftpd_start 内部是长驻事件循环。 处理:ftpTask 的心跳位不放在"每次循环"上,而是放在它内部的 event loop 与 文件传输分片处——这需要改 Lib/lftpd 或在 lftpd 调用的回调里埋点。 若短期不想改 liblftpd:把 ftpTask 从掩码中暂时剔除, 并在此文档记录为已知缺口(FTP 死锁不可自动恢复)。推荐后者作为第一步。

4.4 决策四:长耗时窗口的白名单

以下窗口天然超过 100 ms 监视窗口,必须显式处理,否则必然误杀:

场景 位置 时长 处理
NAND 大块擦写 / f_mkfs storage_bring_up 数百 ms ~ 数秒 启动段不喂狗(IWDG 在 bring-up 完成后才启动,见 §5.2)
dhara_map_resume 全盘扫描 nand_ftl_init 可能数秒(首次) 同上
f_mount forced storage_bring_up 数百 ms 同上
PHY 链路等待 net_stack_bring_up 最长 3 s(60×50 ms) 该循环内显式置心跳位,或 net_stack_bring_up 完成后才开始监视 netTask
LVGL 大页重绘 ui_task_func 单帧数十 ms 10 ms 循环 + 100 ms 窗口已有裕度 ✅
存储套件掉电模拟 testTask 秒级 由 §4.1 掩码剔除 ✅
syslog 落盘 / 配置写盘 defaultTask 数十 ms 50 ms 循环内完成,窗口 100 ms ✅

统一原则:启动段不喂狗,主循环段才喂。

4.5 决策五:IWDG 的启动时刻

关键约束来自项目铁律「osKernelStart 前禁止任何 FreeRTOS/CMSIS-RTOS API 调用」, 以及 app_main_init 内的深链耗时(NAND 初始化、RTC 读取、ADC 校准,合计可能 > 1 s)。

方案 评估
A. 在 app_main_init 最早处启动 IWDG ❌ 后续 bring-up 深链(NAND 初始化可达数秒)会超时复位——必死
B. 在 osKernelStart 前、app_main_init 完成后启动 ⚠️ 仍要保证 osKernelStart 到 defaultTask 首次喂狗 < 2.5 s,实测任务创建 + 首次调度耗时很小 ✅
C. 在 defaultTask 启动段末尾(bring-up 完成后)启动 ✅ 最保守:所有长耗时初始化都已完成,监视一开始就处于稳态
D. 在 app_main_init 完成后 + 手动扩展超时 ⚠️ 复杂(IWDG 分频/重装值一旦启动不可改,除非先复位再配)

决策:C(defaultTask bring-up 完成后启动 IWDG),理由:

  1. 完全绕开"初始化深链超时"这一最大陷阱——最简单即最可靠。
  2. defaultTask 的 bring-up 包含 f_mount / f_mkfs / 卷探针 / 配置加载, 是全系统最长的初始化链。它完成即代表系统进入稳态。
  3. 代价:从复位到 bring-up 完成这段约数秒的窗口无保护。 这段窗口内若死机,用户观察到"上电后无任何输出 + LED 不闪"——可通过 §6 的复位原因分类与 LED 指示来区分(LED 不闪 = 启动段挂死)。
  4. 与 §4.4 白名单统一:既然启动段本就不该被监视,干脆不启动。

代价显式声明:启动段无看门狗保护,这是本方案的已知边界。 若后续需要覆盖,可在 app_main_init 完成后启动一个"短超时 + 手动多喂" 的临时监视(复杂度显著上升,暂不采用)。

⚠️ 决策 C 的必配项(2026-10-02 P0 实测补记,曾漏做并致 5 倍超时偏差): 既然"启动"被延后到 wd_platform_start(),PR/RLR 的写入也必须一并搬过去。 删 HAL_IWDG_Init() 的初衷只是阻止它"立即启动",但它同时是唯一写这两只 寄存器的路径——漏搬的后果是寄存器停在复位默认值(PR=0→÷4、RLR=4095, 实测 ≈349~512ms,比设计短约 5 倍),使 §4.3 的"超时 ⊃ 监视窗口"包含关系失效、 §3.2 的停喂判定分支沦为死代码,任何 >400ms 心跳缺口都直接误复位。 现已在 wd_platform_start() 内补写(见 §5.3 与变更记录)。 通用教训:凡"把某个 HAL 初始化函数拆开/延后"的改动,必须逐一核对被拆掉的 函数还有哪些副作用没有搬到新位置——HAL 的 Init() 往往不只是"赋值 + 调用"。

4.6 决策六:configASSERT 改为"先记录后复位"

现状(Inc/FreeRTOSConfig.h:163):

#define configASSERT( x ) if ((x) == 0) {taskDISABLE_INTERRUPTS(); for( ;; );}

改为:

/* 断言失败:记录任务名后软复位(由 IWDG 兜底真死循环场景) */
#define configASSERT( x ) \
    if ((x) == 0) { vApplicationAssertFailed( ( const char * )__FILE__, __LINE__ ); }

在 Src/freertos.c 实现 vApplicationAssertFailed(),复用现有 hook_ptr_ok() + dbg_console_write_raw() 的裸 UART 有界发送模式 (与 vApplicationStackOverflowHook 同构),随后 NVIC_SystemReset()。

收益:断言从"无日志永久砖"变为"有日志 + 自动复位"。 即使复位逻辑本身因内核态损坏而失效,IWDG 仍会在 2.5 s 后强制复位(双保险)。

4.7 决策七:与既有钩子的复位原因区分

NVIC_SystemReset() 置 SFTRSTF,IWDG 超时置 IWDGRSTF。 两者在 RCC_CSR 中不同位,可区分。

但要注意 RCC_CSR 的复位标志只由 POR(上电)清除, 且读后不会自动清——必须在读完并记录后手工 SET_BIT(RCC->CSR, RCC_CSR_RMVF) 清标志,否则下次软复位会看到"上一次的残留标志"。

统一方案:在 Src/main.c 的 HAL_Init() 之后、app_main_init() 之前, 一次性读取并锁存复位原因到静态变量,随后清标志:

读 RCC_CSR → 判定优先级(POR > PIN > IWDG > WWDG > SFT > LPWR > BOR)
           → 锁存到 s_reset_cause
           → SET_BIT(RCC->CSR, RCC_CSR_RMVF)   ← 清标志

然后由 syslog_mcu_mounted() 把 s_reset_cause 写进 run.log 的描述串 (扩展 运行与自检记录设计说明.md §5.3 的 "上电/重启"二分类为多分类)。

4.8 决策八:复位原因判定的优先级

多个复位标志可能同时置位(如 IWDG 复位也会置 PINRSTF? —— 不会,但 IWDG 复位常伴随 LPWRRSTF 若同时发生低压)。按因果强度取优先级:

优先级 标志 含义 描述串
1 PORRSTF / BORRSTF 上电 / 欠压(真cold start) power on
2 PINRSTF 外部复位引脚(人工按复位/调试器) pin reset
3 IWDGRSTF 看门狗超时(本次引入的核心目标) watchdog reset
4 WWDGRSTF 窗口看门狗(本项目不用) wwdg reset
5 SFTRSTF 软件复位(NVIC_SystemReset:栈溢出钩子 / 断言钩子) soft reset
6 LPWRRSTF 低功耗管理复位 lpwr reset
7 全 0 未识别 unknown reset

注意 §5.3 的既有"上电/重启"时间窗分类(10 min)与本节的复位原因分类是 互补的:前者靠 run.log 时间差推断(无硬件依据),后者靠硬件标志(权威)。 建议:硬件标志优先,时间窗分类降级为兜底(当 RCC_CSR 读到全 0 时使用)。

4.9 决策九:模拟器如何兼容

模拟器(simulator/)在 PC 上跑 UI 与配置/日志自检。IWDG 是 MCU 专属外设:

文件 处理
simulator/sim/sim_port.c 增加 iwdg_* 桩函数(空实现 + 计数器,供自检断言)
心跳位图本身 可在模拟器正常工作——它是纯 RAM 逻辑。建议模拟器也跑位图,
便于用 --watchdogtest 自检模式回归"缺失检测"逻辑

模拟器无法验证的部分:真实复位行为、LSI 漂移、RCC_CSR 标志—— 这些必须真机验证(见 §7)。


5. 详细设计

5.1 文件与接口

文件 类型 职责
App/util/task_watchdog.c 新建 心跳位图、喂狗判定、IWDG 寄存器级初始化与刷新(§2.2 路径 C)
App/util/task_watchdog.h 新建 任务 ID 枚举、task_wd_beat() / task_wd_poll() / task_wd_start() 接口
test/host/watchdog/{watchdog_test.c,run_unit_test.py,stub/*} 新建 主机侧单元测试:#include 生产源码 + 强符号覆盖钩子(§7.3)
Src/main.c 修改 复位原因锁存(HAL_Init() 后,USER CODE 区内)
Src/freertos.c 修改 ◆ vApplicationAssertFailed() 实现 + configASSERT 改造;◆ 见下方"埋点方式"
App/task/*.c 修改 各任务主循环插入 task_wd_beat(TASK_WD_XXX)
App/log/sys_log_mcu.c 修改 run.log 写入复位原因描述串
Inc/FreeRTOSConfig.h 修改 configASSERT 宏改写(§4.6)

不需要改动的:

  • ❌ Inc/stm32f4xx_hal_conf.h —— 不启用 HAL_IWDG_MODULE_ENABLED(会 include 不存在的头文件,§2.2)
  • ❌ MDK-ARM/*.uvprojx —— 无需手改 XML 添加源文件
  • ❌ STM32F407-Demo.ioc —— 无需重新生成 CubeMX

⚠️ App/util/task_watchdog.c 需登记进 MDK 工程:新建 .c 文件后必须在 MDK-ARM/*.uvprojx 的对应 Group 中挂载,否则不参与编译。 这是唯一一处工程文件改动(新增一个 <File> 节点),与"删改既有节点"的低风险操作不同。 替代做法:若不便改工程文件,也可把实现直接放进 App/task/default_task.c 内的 USER CODE 区(.c 已在工程中)——代价是模块边界不清,不推荐。

关于 Src/freertos.c 的埋点:该文件是 CubeMX 生成文件,vApplicationAssertFailed() 必须写在 /* USER CODE BEGIN Application */ 区内(现有两个钩子就在该区内,位置正确); 心跳埋点则不在该文件——各任务循环分别在 App/task/*.c / App/sync/*.c 中。

5.2 接口设计

/* App/util/task_watchdog.h */

/* 任务 ID(位序号 = 位图上的位置,与掩码一一对应) */
typedef enum {
    TASK_WD_DEFAULT  = 0,
    TASK_WD_NET      = 1,
    TASK_WD_FTP      = 2,
    TASK_WD_TEST     = 3,
    TASK_WD_UI       = 4,
    TASK_WD_TIMESYNC = 5,
    TASK_WD_ID_MAX
} task_wd_id_t;

/* 各任务在自己的循环里调用:置位自己的心跳位(1~2 条指令,可放中断外任意位置) */
void task_wd_beat(task_wd_id_t id);

/* defaultTask 主循环调用:抽样 + 判定 + 必要时喂狗;内部节流到 100ms 一次 */
void task_wd_poll(void);

/* defaultTask 启动段末尾调用:启动 IWDG 并初始化位图 */
void task_wd_start(void);

/* 观测接口(供自检/串口报告) */
uint32_t task_wd_miss_count(void);      /* 累计"未达标"轮数 */
uint32_t task_wd_last_mask(void);       /* 上次抽样到的位图(定位卡死任务) */

5.3 实现要点

/* App/util/task_watchdog.c 核心逻辑(示意) */

/* ---- IWDG 寄存器级驱动(§2.2 路径 C,不依赖 HAL IWDG 驱动) ---- */
#include "stm32f407xx.h"        /* 提供 IWDG 实例指针与位域宏(CMSIS 设备头已含) */

#define IWDG_KEY_RELOAD        0x0000AAAAu   /* 喂狗:重装计数器 */
#define IWDG_KEY_ENABLE        0x0000CCCCu   /* 启动(此后不可停) */
#define IWDG_KEY_WRITE_ENABLE  0x00005555u   /* 解锁 PR/RLR 写保护 */
#define IWDG_KEY_WRITE_DISABLE 0x00000000u   /* 锁定 PR/RLR */

/* 超时配置:PR=4 → 4×2^4 = 64 分频;RLR=1249
 * 标称 64×1249/32000 ≈ 2498 ms;最坏(LSI=47k) ≈ 1701 ms  */
#define WD_IWDG_PRESCALER      4u
#define WD_IWDG_RELOAD         1249u

static void wd_iwdg_init(void)
{
    IWDG->KR  = IWDG_KEY_WRITE_ENABLE;              /* 解锁 PR/RLR */
    IWDG->PR  = WD_IWDG_PRESCALER;                  /* 写入即开始同步 */
    IWDG->RLR = WD_IWDG_RELOAD;
    while (IWDG->SR != 0u) { /* 等 PVU/RVU 清零:配置已同步到 LSI 域 */ }
    IWDG->KR  = IWDG_KEY_RELOAD;                    /* 先装初值 */
    IWDG->KR  = IWDG_KEY_ENABLE;                    /* 启动:写 0xCCCC */
}

static inline void wd_iwdg_feed(void)
{
    IWDG->KR = IWDG_KEY_RELOAD;                     /* 写 0xAAAA */
}

/* ---- 心跳位图与判定 ---- */
static volatile uint32_t s_hb_bits;          /* 心跳位图:各任务只置位,校验者取走清零 */
static uint32_t s_miss_streak;               /* 连续未达标轮数 */
static uint32_t s_miss_total;                /* 累计未达标轮数(观测用) */
static uint32_t s_last_mask;                 /* 上次抽样结果(观测用) */

#define WD_POLL_PERIOD_MS   100u             /* 抽样周期 */
#define WD_MISS_TOLERANCE   8u               /* 连续未达标容忍次数(8×100ms = 800ms) */

/* 必需位掩码:编译期按构建配置生成(§4.1 / §4.2) */
static uint32_t wd_required_mask(void)
{
    uint32_t m = (1u << TASK_WD_DEFAULT) | (1u << TASK_WD_NET)
               | (1u << TASK_WD_FTP)     | (1u << TASK_WD_UI)
               | (1u << TASK_WD_TIMESYNC);
#if defined(TEST_SUITE_STORAGE) || defined(TEST_SUITE_NET)   /* 按实际套件宏调整 */
    m |= (1u << TASK_WD_TEST);
#endif
#if defined(ENABLE_HW_LAYER_TESTS)
    m &= ~(1u << TASK_WD_NET);           /* netTask 自挂起,不要求心跳 */
#endif
    return m;
}

void task_wd_beat(task_wd_id_t id)
{
    s_hb_bits |= (1u << (uint32_t)id);   /* 单字写,天然原子 */
}

void task_wd_poll(void)
{
    static uint32_t s_last_tick;
    uint32_t now = HAL_GetTick();
    uint32_t snapshot;

    if ((now - s_last_tick) < WD_POLL_PERIOD_MS) {
        return;                          /* 节流:非 100ms 边界直接返回 */
    }
    s_last_tick = now;

    snapshot  = s_hb_bits;               /* 读 */
    s_hb_bits = 0u;                      /* 清:语义 = "自上次抽样以来谁动过" */
    s_last_mask = snapshot;

    if (snapshot == wd_required_mask()) {
        s_miss_streak = 0u;
        wd_iwdg_feed();                  /* ✅ 全员健康:喂狗 */
    } else {
        s_miss_total++;
        if (s_miss_streak < WD_MISS_TOLERANCE) {
            s_miss_streak++;
        }
        if (s_miss_streak >= WD_MISS_TOLERANCE) {
            /* 停喂 → 约 2.5s 后 IWDG 复位。
             * 复位前尽力留证据:裸 UART(不依赖内核、不缺界) */
            dbg_console_write_raw("[ERR] WATCHDOG: task stall mask=...\r\n", ...);
            /* 不喂狗,让 IWDG 自己复位(真复位原因=IWDGRSTF,可被 §4.7 识别) */
        }
    }
}

为何"停喂"而不是直接 NVIC_SystemReset(): 直接软复位会让 RCC_CSR 置 SFTRSTF 而非 IWDGRSTF, 丢失"是看门狗抓到的"这一关键信息。让 IWDG 自然溢出, 复位原因才是权威的 watchdog reset。代价是额外的 ≤2.5 s 延迟(可接受)。

5.4 各任务的埋点位置(2026-09-29 已全部落地)

任务 实际埋点位置 备注
defaultTask App/task/default_task.c:for(;;) 首行 task_wd_poll() + task_wd_beat(TASK_WD_DEFAULT) 50 ms 节拍,天然满足;同时是唯一喂狗者
netTask App/task/net_task.c:① 主循环 for(;;) 首行;② net_stack_bring_up 的 PHY 等待循环内 osDelay(50) 前;③ ENABLE_HW_LAYER_TESTS 自挂起分支(掩码已剔除,埋点无副作用) ② 为 §4.4 白名单项:PHY 最长等待 3 s(60×50 ms),必须在其中补心跳
ftpTask ⚠️ 首期不埋点——由 wd_required_mask() 剔除(见 §4.3) 已知缺口:Lib/lftpd 事件循环改动面大,二期再补
uiTask App/task/ui_task.c:ui_port_step(10) / ui_net_sink_poll() 之后 10 ms 节拍;等待 NET_READY 期间不喂属预期(该段卡死即应复位)
timeSyncTask App/sync/time_sync.c:① time_sync_task 主循环首行;② 监听重试的 500 ms / 2 s 延时前;③ accept 轮询 200 ms 延时前;④ 收尾 50 ms 延时前;⑤ time_sync_handle_conn 内的等待循环(关键:把 30 s 总预算切成 TIME_SYNC_WAIT_SLICE_MS=200 ms 的片,每片先喂心跳) ⑤ 是本案最要害的一处:对端连上但不发时间包是完全正常的场景,若不切片,30 s 等待必然被判卡死
testTask 不埋点——由套件掩码决定,按 §4.1 排除 套件运行时可长阻塞

⚠️ 实施时的新发现(补充 §4.4 白名单):timeSyncTask 的风险不止 主循环里那两处 vTaskDelay(2000)——time_sync_handle_conn 内部的 net_recv_to(..., rem) 单次可睡满 30 s 总预算。初稿只注意到 vTaskDelay 的两处,会漏掉这处。处理方式取"切片"而非"放宽窗口": 新增 TIME_SYNC_WAIT_SLICE_MS = 200U,把单次等待的 rem 夹到 ≤200 ms, 每片先 task_wd_beat()。总预算语义(≤30 s)完全不变,只是把一次长睡眠 拆成多次短睡眠——同时提升了"对端提前到达"的响应粒度。


6. 可观测性

6.1 串口标记(供 @test 采集)

实际实现(2026-09-29 落地,App/app/app_main.c + App/util/task_watchdog.c + Src/freertos.c):

标记 时机 含义
[APP] reset cause: <类型> (CSR=0x%08X) app_main_init 早期 复位原因,类型为 IWDG (independent watchdog) / WWDG (window watchdog) / software (NVIC_SystemReset) / power-on (POR) / external pin (NRST) / brown-out (BOR) / unknown;同时打印 RCC_CSR 原值便于事后核对
[WD] armed: timeout ~2498ms (worst ~1701ms), miss_tol=800ms task_wd_start() IWDG 已启动(真机自检必查标记)
[WD] task stall: mask=0x%08X req=0x%08X miss=%lu 连续 8 窗未达标时(每窗一次,共打印至复位) 即将停喂 → 约 2.5 s 后 IWDG 复位。mask 的 0 位 = 缺席任务
[ERR] ASSERT FAILED <file>:<line> (resetting) vApplicationAssertFailed() FreeRTOS 断言失败(Src/freertos.c),打印后主动复位
[ERR] HARDFAULT PC=.. LR=.. xPSR=.. R0-R3=.. CFSR=.. HFSR=.. [BFAR=..] [MMFAR=..] HardFault_Handler → fault_entry_hard 硬故障现场(Src/stm32f4xx_it.c)
[ERR] MEMFAULT PC=.. … MemManage_Handler → fault_entry_mem MPU/特权级违规,含 MMFAR 出错地址(CFSR.MMARVALID 置位时)
[ERR] BUSFAULT PC=.. … BusFault_Handler → fault_entry_bus 非法总线访问/取指,含 BFAR 出错地址(CFSR.BFARVALID 置位时)
[ERR] USAGEFAULT PC=.. … UsageFault_Handler → fault_entry_usage 未定义指令/非法状态
[ERR] STACK OVERFLOW task=<name> vApplicationStackOverflowHook 既有钩子(栈溢出)
[ERR] MALLOC FAILED (heap exhausted) vApplicationMallocFailedHook 既有钩子(堆耗尽,只告警不复位)

四个 Fault 报告都由 hard_fault_report(tag, frame) 输出,格式统一为 [ERR] <TAG> PC=… LR=… xPSR=… R0..R3=… CFSR=… HFSR=…,后接 BFAR / MMFAR(仅当 CFSR 对应 valid 位置 1)。输出后 HAL_Delay(200) 排空 UART 再 NVIC_SystemReset(),因此 @test 采集到的日志会以 一次干净的复位收尾(复位原因应为 software,而非 IWDG)。

⚠️ 与 @test 禁项的关系:run_boot_test.py 的 FORBIDDEN_DEFAULT = "FAIL;ERROR;HardFault;[ERR]" 已含 [ERR], 因此任何一条崩溃日志都会让 @test 判 FAIL —— 这是刻意的: 出现 Fault 即代表本次运行不算通过。「4 个 Fault 打各自的名字」的意义在于 归因:日志里直接看到 MEMFAULT / BUSFAULT / USAGEFAULT 就知道是 哪类故障,而不必再从笼统的 HARDFAULT 里反推(后者还需人工读 CFSR 子域)。

日志标签说明:[APP] 是 App/app/app_main.c 的 DBG_TAG;[WD] 是 App/util/task_watchdog.c 的 DBG_TAG(定义在两个 include 之间, 是 dbg_log.h 的既有取标签方式)。全部为纯 ASCII,规避 AGENTS.md 记载的 ARMCC CP936 字符串字面量陷阱。

6.2 与 run.log 的集成

sys_log_mcu.c 的 syslog_mcu_mounted() 当前只写 power on / reboot (靠 run.log 末条 epoch 时间差推断)。现状:未改——本次实施选择 "先只在串口上报 reset cause",理由:

  1. 串口标记已经能支撑 @test 判定与人工归因,run.log 的价值在于长期累计;
  2. 写 run.log 涉及 UI 字符集("看门狗"三字是否在位)与既有 600 s 时间窗分类逻辑, 改动面比串口上报大,留作二期(待处理内容.md N-03 后续项)。

二期计划(复位原因映射表,优先级见 §4.8):

power-on (POR)  → "power on"     (既有)
IWDG / WWDG     → "watchdog"     (新增,UI 字符集需补"看门狗"三字)
software        → "soft reset"   (新增)
external pin    → "pin reset"    (新增)
其余            → 回退既有 600s 时间窗分类

⚠️ UI 字符集影响:docs/子系统设计/运行与自检记录设计说明.md §6.1 记录了 UI 中文字符集需按 font-regen 流程重生成。新增描述串会引入新汉字 (如"看门狗",或改用"装置重启记录"复用既有字),实施时须跑一次字符集 diff。 简化方案:直接复用既有"装置重启记录",把细粒度复位原因只写 run.log 与串口, 不上 UI——推荐(零字符集改动)。


7. 验证计划

7.1 真机验证(@test 闭环)

# 场景 注入方式 期望
V1 正常启动 常规烧录 串口 [WD] watchdog armed;运行 10 min 不复位
V2 任务饿死 临时在 netTask 循环插 while(1); 800 ms 后串口 [ERR] WATCHDOG: task stall mask=0x...;≤2.5 s 复位;重启后 [WD] reset cause=watchdog reset
V3 断言失败 临时在某处插 configASSERT(0) 串口 [ERR] ASSERT ...;立即软复位;重启后 cause=soft reset
V4 关中断死循环 临时插 taskDISABLE_INTERRUPTS(); while(1); ≤2.5 s 复位(检验 IWDG 独立于中断);cause=watchdog reset
V5 长期不误杀 跑 FTP 大文件传输 + UI 菜单遍历 + 配置保存 + 网络重配 ≥ 30 min 零复位(重点验证 §4.4 白名单是否完备)
V6 存储套件共存 TEST_SUITE_STORAGE 构建下跑套件 套件运行期间不复位(验证掩码剔除生效)
V7 上电复位辨识 断电再上电 cause=power on(非 watchdog)
V8 引脚复位辨识 按复位键(或 reset.bat 无参复位) cause=pin reset

V5 是本次实施的核心验收项:误杀比不杀更有害。必须在真实业务负载下 长时间无复位,才算通过。

7.2 模拟器验证

# 场景 命令 期望
S1 心跳位图逻辑 新增 lvgl_sim.exe --watchdogtest 模拟某任务停摆 → 断言 miss_streak 递增、required_mask 判定正确

IWDG 硬件行为在模拟器不可建模,S1 只验证位图与判定逻辑(纯 RAM 部分)。

7.3 主机侧单元测试(L0.5,2026-10-01 已落地)

在不依赖真机与模拟器的前提下,把判定状态机直接编进主机进程做纯逻辑回归:

# 场景 命令 期望
U1 判定状态机全用例 python test/host/watchdog/run_unit_test.py 39 项断言全过,退出码 0
U2 门禁接入 改动 App/util/task_watchdog*.{c,h} 或 test/host/watchdog/** 后提交 pre-commit watchdog-unit-test 钩子触发并通过

架构:App/util/task_watchdog.c 的硬件访问经 3 个 __weak 钩子收敛到 task_watchdog.h 末段"平台后端(内部)"(wd_platform_feed / wd_platform_start / wd_platform_tick_ms)。主机侧测试文件 #include "task_watchdog.c" 原样纳入生产源码, 并提供同名强符号覆盖这三个钩子(注入 IWDG->KR 影子寄存器与虚拟 tick), 故测的是生产代码本身而非副本。

覆盖范围:置位/清零语义、必需掩码 0x37 判定、WD_MISS_TOLERANCE=8(800 ms) 停喂、WD_POLL_PERIOD_MS=100 节流、tick 回绕安全、task_wd_start() 先喂一次等。

用例清单与扩展方法见 ../测试说明/看门狗单元测试说明.md。 本层取代(并优于)原 S1 计划中"位图与判定逻辑"部分——无需模拟器即可回归; S1 保留意义仅剩"在模拟器全链路中联调",优先级可降。


8. 内存与资源预算

项 大小 位置
心跳位图 + 状态(s_hb_bits / 计数 / s_last_mask / 节流 tick) ≈ 20 B .bss
task_watchdog.c 代码(含 IWDG 寄存器操作,约 20 行) ≈ 400~600 B Flash(.text)
vApplicationAssertFailed() ≈ 100 B Flash
复位原因锁存(s_reset_cause) ≈ 4 B .bss
合计 ≈ 24 B RAM / ≈ 0.7 KB Flash —

对比现状 RAM 占用(RW_IRAM1 ≈ 87 KB / 66%,见 docs/内存管理/), 增量可忽略。无需重新做 RAM 预算评估。

✅ 注意:因走寄存器路径(§2.2),不引入 stm32f4xx_hal_iwdg.c (HAL 封装约 0.3 KB Flash),上表已据此计。相比"补 HAL 驱动"路径省下的这部分 Flash 恰与本模块自身代码量相当——两条路径的 Flash 净增量接近,但寄存器路径 零工程文件改动,这是选它的实质理由。


9. 风险与开放问题

# 风险 评估 处置
R1 启动段(app_main_init + bring-up)无保护 已知边界;该段死机表现为"无输出 + LED 不闪",可人工辨识 §4.5 决策 C 的显式代价;后续如需覆盖另立方案
R2 LSI 精度差导致超时漂移 47 kHz 时超时打七折(2500→1705 ms) §4.3 已按最坏值校验,并留 2× 裕度
R3 ftpTask 卡死检测不到 第一步剔除掩码 = FTP 死锁不可自恢复 记录为已知缺口;第二步改 Lib/lftpd 埋点(需评估对第三方库的侵入度)
R4 timeSyncTask 2 s delay 与窗口冲突 必然误杀 §5.4 推荐拆分为 10×200 ms
R5 NAND 擦写偶发超 800 ms 表现为偶发误复位;若发生会极难定位(复位后现场已丢) 先按 800 ms 实施,台架长跑(V5)观测;若出现则增大容忍次数或把存储操作改为分段置位
R6 RCC_CSR 标志清不完导致误判 忘清 RMVF 会使下次软复位读回上次标志 §4.7 明确"读后即清";V7/V8 验证
R7 断言钩子自身崩溃(内核态已损坏) 打印可能二次 HardFault 复用 hook_ptr_ok() 的指针校验模式;即使失败仍由 IWDG 兜底
R8 configCHECK_FOR_STACK_OVERFLOW 钩子复位归为 soft reset 栈溢出与断言在复位原因上不可区分 可接受;run.log 的 selftest.log 侧已有告警记录

开放问题

# 问题 待定
O1 是否把 ftpTask 纳入监视 取决于是否愿意改 Lib/lftpd(第三方库侵入度评估)
O2 是否上 UI 显示细粒度复位原因 涉及字符集重生成成本,倾向"只写 log/串口"
O3 启动段(R1)是否需要覆盖 待现场故障统计决定;倾向不做(复杂度收益比不划算)
O4 是否需要复位计数与"连续复位"抑制 若某故障导致复位风暴(复位→立刻再死),可考虑"连续 N 次看门狗复位后进入安全模式"。当前不做,记录为后续可选增强

10. 实施步骤(按序执行,每步端到端验证)

步 内容 验证 状态
0 (新增) CubeMX 勾选 IWDG(Prescaler=64 / Reload=1249)→ 重生成 → 恢复被覆盖的定制 @build 0/0 + 启动日志齐全 ✅ 2026-09-29 完成,覆盖 4 处见下
1 新建 App/util/task_watchdog.c/h;实现心跳位图 + 抽样判定 + IWDG 延后启动;登记进 MDK 工程(App 组,sys_clock.c 之后) @build 0/0 ✅ 完成
2 Src/main.c 复位原因锁存 + 清标志;App/app/app_main.c 打印描述串 [APP] reset cause: 出现 ✅ 完成(run.log 写入留二期)
3 Src/iwdg.c 阻止 HAL_IWDG_Init() 自动启动(P0) 开机不死循环 ✅ 完成
4 各任务埋点(含 timeSyncTask 切片而非拆 delay) 编译 0/0;串口无 miss ✅ 完成
5 task_wd_start() 由 defaultTask 在 bring-up 完成后调用 [WD] armed: 出现 ✅ 完成
6 configASSERT 改造 + vApplicationAssertFailed() 编译 0/0 ✅ 完成
6.5 (新增) 硬件访问收敛到薄平台层(task_watchdog.h 末段,3 个 __weak 钩子)+ 主机侧单元测试(test/host/watchdog/,39 用例)+ pre-commit 钩子 run_unit_test.py 退出码 0;@build 0/0(行为不变) ✅ 2026-10-01 完成
7 故障注入(V2/V4):人为制造任务卡死 复位 + reset cause: IWDG ⏳ 待台架
8 模拟器 --watchdogtest + stub S1 ⏳ 待做(位图逻辑已由 U1 覆盖,优先级降低)
9 长跑(V5,≥30 min,真实负载) 零复位 ⏳ 待台架
10 本文档改写为"现状设计说明";待处理内容.md N-03 销项;README.md 登记 门禁 ⏳ 待第 7~9 步结论

10.1 第 0 步实测:CubeMX 重生成覆盖了哪 4 处

.ioc 的 ProjectManager.DeletePrevious=true + KeepUserCode=true 决定了 只有 USER CODE BEGIN/END 标记区内的代码受保护。实测被覆盖的定制:

级别 文件 被覆盖内容 恢复方式
P0 Src/iwdg.c(新文件) CubeMX 生成的 MX_IWDG_Init() 直接调 HAL_IWDG_Init() → 开机死循环 改为只填参数(此改动在生成区,无 USER CODE 位可用),插 IWDG-DEFER-START-ANCHOR 锚点注释供审计检出
P1 Src/stm32f4xx_it.c HardFault_Handler 崩溃现场采集(PC/LR/xPSR/R0-R3/CFSR/HFSR/BFAR/MMFAR)被打回空 while(1) 整函数替换为 __asm 版;生成实体用 #define 改名保留(链接器自动移除);报告实现迁入 USER CODE BEGIN 0
P1 Inc/FreeRTOSConfig.h configCHECK_FOR_STACK_OVERFLOW 2 + configUSE_MALLOC_FAILED_HOOK 1 整个块被删除 插回 USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING 块
P2 Src/freertos.c 两处中文注释丢失(任务栈大小的实测记录) 恢复注释(栈数值未丢,.ioc 同步到位)

⚠️ P1 两处的教训:它们都在 USER CODE 标记区之外(stm32f4xx_it.c 的 HardFault_Handler 由 CubeMX 直接生成;FreeRTOSConfig.h 的加固宏原本写在 非 USER CODE 区)。P1 的丢失不会立刻暴露——栈溢出检测与堆失败钩子只在 真发生溢出/耗尽时才起作用,且 HardFault_Handler 被打回空死循环后 崩溃时连现场都打不出来(只剩 IWDG 兜底复位,但归因信息全丢)。 故本次已把这三个宏搬进 USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING 区。

每步改完必须 @build 0 错误 0 警告(AGENTS.md 铁律)。

⚠️ 第 0 步复现(2026-09-29 第二次重生成):审计脚本有效,但机制假设需修正

第一次重生成后我们建立了审计脚本并"加固"了恢复方案,随后用户又重生成了一次, 据此验证机制:

  • ✅ 审计脚本准确检出全部 5 处覆盖(16 项清单中 5 项 FAIL)——L3 兜底手段有效
  • ✅ Src/main.c 的复位原因锁存完好(在 USER CODE BEGIN Init 区)——L2 有效性的正向证据
  • ✅ MDK-ARM/*.uvprojx 中 task_watchdog.c 挂载保住

但暴露出 L2 的一个致命前提错误:

🔴 关键发现:CubeMX 会"换一代"中断函数模板 —— "生成区之外的自造标记区"同样不被保留。

  • 旧模板:在 HardFault_Handler 函数前后生成自造区 /* USER CODE BEGIN HardFault_Handler */ 与 /* USER CODE BEGIN HardFault_Report */ → 上一轮我们把手写实现放进这两个区,当时确实生效了。
  • 新模板:改成函数体内部的 /* USER CODE BEGIN HardFault_IRQn 0 */ 与 /* USER CODE BEGIN W1_HardFault_IRQn 0 */ → 旧的 HardFault_Handler / HardFault_Report 区整段消失,放在里面的代码一并丢失。

教训:USER CODE 区有效的前提是"该区由 CubeMX 生成且名字仍被当前模板使用"。 CubeMX 换模板(或改区名)时,依赖旧区名的内容会连同区一起消失, 而它看起来仍像"我们遵守了规则"。不要假设自造区名的稳定性——只信 CubeMX 当前 模板真实生成的区名(用一次重生成后的文件核对,而非记忆)。

据此对 stm32f4xx_it.c 的恢复方案做了第二次升级:

组件 上一轮落点 本轮最终落点 稳定性
崩溃报告实现(hard_fault_report 等) 自造区 HardFault_Report USER CODE BEGIN 0(文件头部,CubeMX 标准保护位,名字从未变过) ⭐⭐ L2 真有效
HardFault_Handler 实体 自造区 HardFault_Handler 生成区内整函数替换 + HARD-FAULT-ANCHOR 锚点;生成实体经 #define HardFault_Handler HardFault_Handler_Gen 改名保留,链接器自动移除(.map 实测 Removing ... (2 bytes)) ⭐ L3 审计兜底

为什么 Handler 只能放生成区:CubeMX 本代模板把整个函数体(含 HardFault_IRQn 0 / W1_HardFault_IRQn 0)连同函数一起重写;函数体之外的自造区不保留。查询 .map 确认向量表指向我们的 __asm 版(0x080001a1,.emb_text,18 字节), 生成版被链接器丢弃 —— 零代码开销,且未删除生成区任何一行。

ARMCC 语法坑(本轮踩过):__asm volatile ("MRS %0, lr" : "=r" (x)) 是 GCC 内联 汇编语法,ARMCC(MDK)报 #18: expected a ")";__asm { MRS x, lr } 块内联会把 lr 当 C 标识符报 #20: identifier "lr" is undefined。唯一可行写法是 整个函数用 __asm void f(void) { ... }(旧代码即此写法,已验证可行)。

10.2 让定制"重生成不丢"的三层手段(通用方法论)

第 0 步的 4 处覆盖暴露了一个与看门狗无关的通用问题:CubeMX 重生成会覆盖 标记区之外的一切手工改动。解决办法按可靠性分三层,优先级从高到低:

层 手段 适用 可靠性
L1 写进 .ioc 让 CubeMX 自己生成这份定制 凡是 CubeMX 界面能表达的配置 ⭐⭐⭐ 根治——配置成了事实源,重生成只会照着写
L2 搬进 USER CODE 区 把手工代码移到 /* USER CODE BEGIN X */ 与 /* USER CODE END X */ 之间 有现成标记区的文件(Src/freertos.c / mpu_wrappers 等),或 CubeMX 为该函数生成了标记区 ⭐⭐ 有效——KeepUserCode=true 时 CubeMX 逐字保留
L3 审计 + 基线 无法 L1/L2 的存量,用脚本定期核对 标记区外的存量、Src/iwdg.c 这类"生成区必须改动"的特例 ⭐ 兜底——不防丢失,但丢了一定知道

L1:哪些定制能写进 .ioc

本项目 .ioc 已持有(重生成不会丢):

定制 .ioc 键 说明
任务栈大小 FREERTOS.Tasks01=...defaultTask,24,1024,... ✅ 最值得强调:栈值写在 .ioc 里,CubeMX 直接生成到 Src/freertos.c 的 .stack_size。曾担心"重生成会覆盖栈大小",实测保住了(.ioc 已同步)。改栈请改 .ioc,不要改 freertos.c
任务优先级 / 名称 / 入口函数 同上 ✅ 同一条目
消息队列 netMsgQueue FREERTOS.Queues01 ✅
事件组 sys_evt FREERTOS.Events01 ✅
FreeRTOS 堆大小 FREERTOS.configTOTAL_HEAP_SIZE=49152 ✅
FPU 使能 FREERTOS.configENABLE_FPU=1 ✅
IWDG 分频 / 重载 IWDG.IPParameters + IWDG.Prescaler / IWDG.Reload ✅ 本次新增,见 §2.2
时钟树(含 LSI) RCC.* ✅
KeepUserCode=true ProjectManager.KeepUserCode ✅ 这个必须为 true,否则 L2 整体失效

能写进 .ioc 但本项目尚未写的(可作为后续加固项):

定制 现状 建议
configCHECK_FOR_STACK_OVERFLOW 在 USER CODE BEGIN FREE_RTOS_CONFIG_HARDENING 区(L2) ⚠️ CubeMX 的 FreeRTOS 界面没有这一项的勾选框,只能靠 L2 + 审计。保持现状
configUSE_MALLOC_FAILED_HOOK 同上 同上
configASSERT 定义 在 USER CODE BEGIN 1 区(L2) 同上。CubeMX 无对应界面项
4 个 Fault Handler 实体 stm32f4xx_it.c 标记区外(L3) ⚠️ CubeMX 为这些函数不生成 USER CODE 区,无法 L2。见下方"存量清单"
fault_handler_enable()(SHCSR 使能三位) Src/stm32f4xx_it.c 的 USER CODE BEGIN 1 区(L2) ❌ CubeMX 无此配置项(已查模板源码证实,见下)
为什么 fault_handler_enable() 写不进 .ioc(2026-10-01 查模板源码证实)

用户问"能否通过 CubeMX 配置",逐条查证 CubeMX 安装目录 db/templates/*.ftl 与 db/mcu/IP/NVIC-STM32F417_Modes.xml (F407 复用 F417 定义),三条可能路径全部否定:

路径 查证结果
① NVIC 面板勾选三个 Fault 的 Enabled tpl_main_c.ftl 的 MX_NVIC_Init():[#if vector.systemHandler=="false"] 才生成 HAL_NVIC_EnableIRQ(...)。而 NVIC-STM32F417_Modes.xml 中 MemoryManagement/BusFault/UsageFault_IRQn 都标 Y(= systemHandler)→ 永不生成使能代码(HardFault_IRQn 标 N 是个例外,但它恒使能、无需使能)
② 任何模板里找 SHCSR 置位 全部模板中 SHCSR 只出现 1 次(tpl_main_c.ftl:2269),且是 MPU 清理时的 SCB->SHCSR &= ~SCB_SHCSR_MEMFAULTENA_Msk(禁用)。无任何 |= 置位
③ 启用 MPU(HAL_MPU_Enable 内部会置 MEMFAULTENA) .ioc 的 Mcu.IP0..13 列表无 MPU;且该函数只置 MEMFAULTENA 一位,BUSFAULTENA/USGFAULTENA 仍拿不到

实测印证:Src/stm32f4xx_hal_msp.c 的 HAL_MspInit() 里只有 HAL_NVIC_SetPriority(PendSV_IRQn, 15, 0)(因它优先级 ≠ 0 才生成), 三个 Fault 一行代码都没生成——与模板逻辑完全吻合。

结论:fault_handler_enable() 只能手工写。但它是 L2(放在 USER CODE BEGIN 1 区)——重生成不会丢,所以这条不是风险,只是 "无法享受 L1 的'CubeMX 自己生成'待遇"。已挂审计项 fault-handler-enable 兜底。

⚠️ 顺带提醒:.ioc 里那三行 NVIC.MemoryManagement_IRQn=true\:0\:0\:false\:... 不代表"已使能"——第 1 段 true 只是"该向量在 NVIC 表里可见/已配置优先级", 与 SHCSR 使能位无关。不要误以为勾了 CubeMX 的 Enabled 就等于使能了故障。

判断口诀:能在外设/中间件的图形界面里找到对应输入框的 → 一定写 .ioc; 找不到(如 FreeRTOS 的两个加固宏、断言定义、故障异常处理函数体)→ 只能 L2/L3。

L2:USER CODE 区的可用位置(本项目实测)

文件 可用标记区 已用于
Inc/FreeRTOSConfig.h Header / Includes / FREE_RTOS_CONFIG_HARDENING / MESSAGE_BUFFER_LENGTH_TYPE / 1 / Defines 加固宏(HARDENING)、configASSERT(1)
Src/freertos.c 共 25 个,含 Includes / Application / Variables / PD / PTD / … 钩子实现(Application)、timeSyncTask 属性定义(Variables)
Src/stm32f4xx_it.c 共 52 个,含文件头 0(标准保护位)+ 各中断函数内部的 <Func>_IRQn 0 / W1_<Func>_IRQn 0 崩溃报告实现(0)
Src/main.c 共 19 个,含 Init / SysInit / 2 / PV / PFP 复位原因锁存(Init)
Src/iwdg.c 共 7 个,含 IWDG_Init 0/1/2 ❌ 本次改动用不上(见下方特例)
Inc/main.h 共 7 个 引脚宏等

🔴 两条硬教训(2026-09-29 实测,务必先读)

① 不要依赖"自造标记区"。上一轮我们按"CubeMX 保留任何形态的 USER CODE 注释对" 的结论,在 stm32f4xx_it.c 里自造了 HardFault_Handler / HardFault_Report 区, 当时确实生效。但第二次重生成时 CubeMX 换了中断函数模板,区名从 HardFault_Handler 变成函数体内的 HardFault_IRQn 0 / W1_HardFault_IRQn 0, 旧区连同内容一起消失。 → 只信 CubeMX 当前模板真实生成的区名,用重生成后的文件核对,别凭记忆。 → 宁可用文件头 USER CODE BEGIN 0 这类"区名从未变过"的标准位。

② 生成区内无标记位可用的改动,只能 L3 兜底。Src/iwdg.c 需要改的是 hiwdg.Init.* 赋值与 HAL_IWDG_Init() 调用之间那一句,而 CubeMX 只在调用之前 (IWDG_Init 0/1)和之后(IWDG_Init 2)生成标记区——正中间没有。 塞进 IWDG_Init 1 区也不能阻止区外那行被覆盖(HAL 调用在区外)。 → 该改动只能在生成区,靠 IWDG-DEFER-START-ANCHOR 锚点注释 + 审计脚本检出。 同理 HardFault_Handler 本体(本代模板整函数重写,函数体外无区)。

L3:存量清单(标记区外,只能靠人记 + 脚本盯)

文件 内容 为什么无法 L1/L2
Src/iwdg.c 不调 HAL_IWDG_Init()(P0:开机死循环防线) 生成区内无标记位可用(见上方硬教训②)。锚点 IWDG-DEFER-START-ANCHOR + 审计 FAIL 项
Src/stm32f4xx_it.c 4 个 Fault Handler 实体(HardFault / MemManage / BusFault / UsageFault) 本代模板整函数重写、函数体外无区。各带独立锚点(HARD-FAULT-ANCHOR / MEMFAULT-ANCHOR / BUSFAULT-ANCHOR / USAGEFAULT-ANCHOR)+ 审计 FAIL 项
Src/freertos.c .stack_size 行内的中文注释 该行由 CubeMX 按 .ioc 生成,"值"受保护、"注释"不受。注释是锦上添花,丢失不影响功能(值在 .ioc 里)

结论:L3 清单只剩 2 项(iwdg 生成区改动、4 个 Fault Handler 实体)——均为 生成区内无标记位可用的客观限制,靠锚点注释 + 审计 FAIL 兜底(丢了一定知道)。 其余要么已在 .ioc(L1),要么已进 USER CODE 区(L2)。 剩余 1 项 WARN 是 Src/freertos.c 的 timeSyncTask 手工定义(属性块在 USER CODE BEGIN Variables 区,本身受保护;WARN 只是提示"它不在 .ioc 里")。

四个 Fault 异常的采集(2026-09-29 完成)

HardFault 之外,MemManage / BusFault / UsageFault 原先都是空 while(1) (死机静默)。现已全部升级为崩溃采集:

Fault 触发条件 命中时的地址寄存器 使能位
HardFault 其他 Fault 未使能时的兜底 / 升级 — 恒使能
MemManage MPU 禁止区访问、非法特权级访问 MMFAR(CFSR.MMARVALID bit7) SHCSR.MEMFAULTENA bit16
BusFault 非法总线访问/取指(时钟未开、野指针、DMA 越界) BFAR(CFSR.BFARVALID bit15) SHCSR.BUSFAULTENA bit17
UsageFault 未定义指令、非法状态(本项目不启用非对齐/除零陷入) —(看 CFSR 子域) SHCSR.USGFAULTENA bit18

关键:复位后 SHCSR 只开了 HardFault,后三者默认禁用——不使能的话, 它们触发时会被升级成 HardFault,日志只显示笼统的 HARDFAULT、看不出根因。 故新增 fault_handler_enable()(Src/stm32f4xx_it.c 的 USER CODE BEGIN 1 区, 声明在 Inc/stm32f4xx_it.h 的 USER CODE BEGIN EFP 区),在 app_main_init() 第一行调用(纯寄存器写,不依赖日志/时钟)。

⚠️ 这三位没法在 CubeMX 里配(2026-10-01 查模板源码证实,详见 §10.2 「为什么 fault_handler_enable() 写不进 .ioc」)。不要以为在 NVIC 面板 勾了 Enabled 就使能了故障——CubeMX 对 System Exception 只生成优先级 (且仅当优先级 ≠ 0),从不生成使能代码。好在函数体在 USER CODE BEGIN 1 区,重生成不会丢(L2),另有审计项 fault-handler-enable 兜底。

刻意不打开的两项(都会把普通 bug 升级成死机,对本项目弊大于利):

  • CCR.UNALIGN_TRP:非对齐访问陷入。Cortex-M4 硬件本可处理非对齐访问, 打开后协议解析里常见的非对齐取字段会直接死机
  • CCR.DIV_0_TRP:除零陷入。除零应由业务代码自己判,不该靠硬件兜

实现要点(ARMCC 汇编限制):__asm 块取不到 C 的 static 数据符号 (实测 ADR r1, xxx_tag 报 A1516E: Bad symbol,去掉 static 也无效), 且 __asm 里无法给 C 函数传字符串参数。故采用:

__asm 版 Handler(.emb_text)        → 只取异常帧指针,B 到 per-fault 入口
fault_entry_hard/mem/bus/usage(C)  → 各自带上故障名,调 hard_fault_report(tag, frame)
hard_fault_report(tag, frame)(C)   → 统一打印现场后 NVIC_SystemReset()

日志形如 [ERR] MEMFAULT PC=... CFSR=... MMFAR=...(故障名 + 全量现场)。

L3 落地:审计脚本 + 基线

python scripts/check_cubemx_survival.py            # 审计报告(人读)
python scripts/check_cubemx_survival.py --check    # 门禁模式(pre-commit 已挂)
python scripts/check_cubemx_survival.py --write    # 重建基线(确认改动无误后)
  • 清单:18 项覆盖 P0/P1 全部防线(iwdg 延后启动、4 个 Fault Handler 采集、 故障使能、两个加固宏、断言改造 ×2、看门狗模块与工程挂载、复位原因锁存、 HAL 驱动与使能宏、.ioc 关键项)
  • 已实测有效:2026-09-29 第二次重生成时,脚本准确检出全部 5 项被覆盖(5 FAIL)
  • 漂移检测:把各项内容哈希存 .cubemx_survival_baseline.json,--check 比对。 ⚠️ MDK-ARM/*.uvprojx 排除在漂移检测之外(17 项入基线,18 项参与断言)—— 该文件每次 @build 后被 Keil UV4 自动改写(XML 格式归一化 + PackID 降级到 本机 DFP 版本),内容每次构建都不同;若参与检测则每次构建后提交都会被拦。 脚本内以 no_baseline=True 标记,只保留"源文件仍挂载"断言
  • 已挂 pre-commit(cubemx-survival 钩子):改动 .ioc / Src/ / Inc/ / task_watchdog / .uvprojx 时自动触发
  • 注释过滤:断言前先剥掉 C 注释,避免"注释里提到 HAL_IWDG_Init"被误判 (本项目多处注释刻意引用历史代码,不做过滤会恒定误报); 但正项检查(must_contain)在 wholefile 模式下不剥注释, 因为锚点本身就是注释(IWDG-DEFER-START-ANCHOR / HARD-FAULT-ANCHOR)。 脚本内以 probe(正项)/ probe_neg(禁项)分离处理

推荐工作流:每次 CubeMX 重生成后,立刻跑一次 python scripts/check_cubemx_survival.py——它会把"被覆盖了哪几处" 直接列出来(含修复提示),比事后靠故障现象反推快得多。 本项目的两次重生成都是靠它把损失锁定在"5 处、含修复提示"的范围内。


11. 结论

议题 结论
选型 IWDG(独立 LSI),非 WWDG(依赖 APB1 + 中断,在关中断/HardFault 场景失效)
实施路径 CubeMX 勾选 IWDG 生成(.ioc = 配置单一事实源,与其他外设一致);唯一手工改动 = Src/iwdg.c 中阻止 HAL_IWDG_Init() 立即启动(§2.2 / §4.5)
防丢失 L1 写 .ioc > L2 搬进 USER CODE 区 > L3 审计兜底(§10.2);⚠️ 自造区名不可依赖(CubeMX 换模板会连区带内容一起删,2026-09-29 实测);已落 scripts/check_cubemx_survival.py + pre-commit 钩子
架构 任务心跳位图(各自置位,无锁单字)+ defaultTask 单一喂狗者——避免"有人跑就喂狗"的假保护
超时 2498 ms(PR=4 / RLR=1249);最坏(LSI 47 kHz)≈1701 ms;中间留 800 ms 停喂判定裕度
启动时机 defaultTask bring-up 完成后——绕开初始化深链超时陷阱,代价是启动段无保护
断言 configASSERT 改"先记录后复位",与 IWDG 形成双保险
诊断 ① 读 RCC_CSR 硬件标志判复位原因(权威),替代"靠时间差猜上电/重启";② 4 个 Fault(Hard/MM/Bus/Usage)全部采集现场并打故障名(fault_handler_enable() 使能后三者,否则被升级成 HardFault)
已知代价 ① 启动段无保护 ② ftpTask 首期不监视 ③ LSI 精度差需按最坏值设计
成本 ≈ 24 B RAM / ≈ 0.7 KB Flash,可忽略

附录 A:调研依据

来源 引用点
docs/芯片手册/STM32F4xx参考手册_V18英文版_1-1000.md §21(IWDG) 12 位递减计数器、LSI 时钟源、启动后不可停、超时公式
同上 §6.3.21(RCC_CSR) IWDGRSTF(bit29) / WWDGRSTF(bit30) / SFTRSTF(bit28) / PORRSTF(bit27) / PINRSTF / LPWRRSTF / RMVF(bit24);"except reset flags by power reset only"
同上 §(LSI 小节) LSI ≈ 32 kHz、可用于 IWDG、制造偏差大、可测校
STM32F407-Demo.ioc RCC.LSI_VALUE=32000;无 IWDG 配置项
Inc/stm32f4xx_hal_conf.h:58 HAL_IWDG_MODULE_ENABLED 处于注释态
Inc/stm32f4xx_hal_conf.h:375 #include "stm32f4xx_hal_iwdg.h" 位于 #ifdef HAL_IWDG_MODULE_ENABLED 内
find Drivers -iname "*iwdg*" 零命中——HAL IWDG 驱动源码不在仓库
MDK-ARM/*.uvprojx stm32f4xx_hal_*.c 列表无 stm32f4xx_hal_iwdg.c
Drivers/CMSIS/.../stm32f407xx.h:573-579 IWDG_TypeDef(KR/PR/RLR/SR)
同上 :944 / :1068 IWDG_BASE / IWDG 实例指针
同上 :9330-9353 IWDG_KR_KEY_Msk / IWDG_PR_PR_Msk / IWDG_RLR_RL_Msk / IWDG_SR_PVU / IWDG_SR_RVU
同上 :10317-10336 RCC_CSR_RMVF(24) / BORRSTF(25) / PINRSTF(26) / PORRSTF(27) / SFTRSTF(28) / IWDGRSTF(29) / WWDGRSTF(30)
Inc/FreeRTOSConfig.h:163 configASSERT = 关中断死循环
Src/freertos.c:323-360 既有 vApplicationStackOverflowHook(复位)/ vApplicationMallocFailedHook(不复位)
App/task/net_task.c:160-215 ENABLE_HW_LAYER_TESTS 自挂起、主循环 osDelay(1)、PHY 等待 60×50 ms
App/task/default_task.c:95-204 bring-up 深链、主循环 50 ms 基准
App/sync/time_sync.c:68/75 vTaskDelay(2000) 与监视窗口冲突
Src/freertos.c:242-250 ftpTask 在屏障上 osWaitForever 阻塞
docs/审查记录/项目审查与优化建议报告.md §4.1 N-03a/b/c 缺陷原始登记与建议

附录 B:备选方案取舍记录

备选 否决理由
每任务各自喂狗 只要有任一任务存活就喂狗,无法检测单任务卡死(§3.1)
用 FreeRTOS 软件定时器喂狗 定时器任务独立于业务任务,其存活不代表业务健康;且其栈小、优先级高,不适合承载判定逻辑
用 osEventFlags 做心跳 需 7 位,osEventFlagsSet 可能触发调度,任务循环内代价过高
WWDG(窗口看门狗) 依赖 APB1 时钟与中断,在关中断/HardFault/时钟异常场景失效(§2)
独立看门狗任务 需新建任务(TCB+栈),RAM 预算紧张;且"谁来监视监视者"问题无解
直接 NVIC_SystemReset() 而非让 IWDG 溢出 复位原因变 SFTRSTF,丢失"看门狗抓到"的权威信息(§5.3)
动态改 IWDG 超时 IWDG 分频/重装值在启动后不可改(需先复位再配),项目无需此能力
借助 CubeMX 重新生成以取得 IWDG 驱动 重生成会重写 Src//Inc/ 生成区,与既有定制冲突;且历史上有行尾空白抖动问题(§2.2 路径 A)
手工补入 HAL IWDG 驱动并改 .uvprojx 需手改 XML 工程文件(结构脆弱),且引入约 0.3 KB Flash 封装,而 CMSIS 头已够用(§2.2 路径 B)
每任务用独立递减计数器而非位图 计数器回绕判定繁琐,且需临界区保护;位图"取走清零"单字原子更优(§3.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 38 反复出现的"空 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 ≈ **349512 ms**,而设计是 64×1250/LSI ≈ 1701~2498 ms。两个后果:① WD_MISS_TOLERANCE(8×100ms=800ms)永远等不到就被 IWDG 复位 → 停喂判定分支成死代码(这正是 §7/实机测试规划.md §0.5.4.1 "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 断言)