76 KiB
看门狗设计说明
模块名称:独立看门狗(IWDG)+ 任务心跳位图 文档定位:实施中——记录选型依据、在既有平台上落地的约束、实现现状与 待台架验证项。代码骨架已落地并编译通过(§10 第 0
6 步);第 710 步 (故障注入 / 长跑 / 本文档转为"现状设计说明")待台架。 状态:🚧 代码已落地(@build0/0),待台架故障注入与长跑验证;登记于../待处理内容.mdN-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 设计目标
- 可恢复:任务级死锁 / 饿死 / 断言死循环 → 硬件复位,无需人工到场。
- 可诊断:复位后能区分"上电 / 看门狗 / 软件 / 引脚 / 欠压",并落
run.log。 - 不误杀:正常业务的长耗时窗口(NAND 大块擦写、FAT 挂载扫描、PHY 链路等待 3s、LVGL 大页重绘)不得触发复位。
- 低代价: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),理由:
- 完全绕开"初始化深链超时"这一最大陷阱——最简单即最可靠。
defaultTask的 bring-up 包含f_mount/f_mkfs/ 卷探针 / 配置加载, 是全系统最长的初始化链。它完成即代表系统进入稳态。- 代价:从复位到 bring-up 完成这段约数秒的窗口无保护。 这段窗口内若死机,用户观察到"上电后无任何输出 + LED 不闪"——可通过 §6 的复位原因分类与 LED 指示来区分(LED 不闪 = 启动段挂死)。
- 与 §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",理由:
- 串口标记已经能支撑
@test判定与人工归因,run.log的价值在于长期累计; - 写
run.log涉及 UI 字符集("看门狗"三字是否在位)与既有 600 s 时间窗分类逻辑, 改动面比串口上报大,留作二期(待处理内容.mdN-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区。
每步改完必须
@build0 错误 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 3Src/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 ≈ **349WD_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 断言) |