LV055-看门狗问题与解决方案
一、 两个现象
1. 现象 A:拔掉网线就启动不起来
拔掉网线给板子上电,串口日志打到一半就断,然后从头再来,如此反复。插上网线则一切正常。
同一份代码,插线能跑、拔线不能跑,说明问题不在业务逻辑,而在"某件依赖外部链路的事"把启动流程拖长了。
2. 现象 B:跳转之后又回到 Bootloader
flash-app 或 sram-app 已经落盘成功,发 go flash / go sram 之后,日志出现跳转提示,APP 的 APP: ready... 有时刚打出来、有时还没打出来,板子就重新启动,又回到 Bootloader 的命令行。整个过程大约 3 秒一个循环。
这一条与网线无关,插不插线都会复现;能落盘说明下载链路没问题,问题出在"跳过去之后"。
3. 怎么区分两者
两个现象都属于"被看门狗复位",但发生的阶段不同,靠日志就能分开:
| 判据 | 现象 A | 现象 B |
|---|---|---|
| 复位发生的位置 | Bootloader 初始化中段(PHY 等待附近) | 跳转之后,APP 刚起步 |
是否见过 jump to APP | 没有 | 有 |
是否见过 APP: ready... | 没有 | 有时有,有时没有 |
| 是否与网线有关 | 有关(拔线必现) | 无关 |
| 周期 | 10 秒左右(撞上 10 秒等待) | 约 3 秒 |
两者的共性是同一条硬件事实:看门狗一旦开始计时,除复位之外没有任何办法让它停下。区别只是"谁在计时窗口内没喂上狗"。
二、 根因 A:初始化阶段就被咬
1. 3 秒窗口从哪来
看门狗参数是 LSI 40kHz、32 分频、重装值 3750:
[ t_{out} = \frac{32 \times 3750}{40000} = 3.0\ \text{s} ]
LSI 本身精度很差(±40% 量级),实际超时可能在 2 ~ 4 秒之间波动,但它不是本次问题的变量——变量是"启动花了多久"。
2. 初始化里的 10 秒
CubeMX 默认把 MX_IWDG_Init() 放在所有外设初始化之前,函数体里直接 HAL_IWDG_Init(),看门狗当场开始倒计时。
而初始化链的末端是 lwip_comm_init() → DM9000_Init()。DM9000 上电后要与对端做 PHY 自协商,驱动里的等待逻辑是"最多轮询 100 次、每次 100ms":
- 插着网线:几十毫秒就协商完成,总耗时远小于 3 秒;
- 没插网线:协商永远不成功,老老实实等满 10 秒。
3 秒的窗口撞上 10 秒的阻塞,必然被复位。
3. 为什么是无限循环
看门狗复位不是"停在那里",而是重新走一遍启动流程:复位 → 初始化 → 又在同一处等 10 秒 → 再被咬。只要外部条件不变(网线仍然没插),循环就永远出不去。
这也解释了为什么日志"每次停在差不多的地方":每次都在 PHY 等待的同一段代码里被复位。
三、 根因 B:跳转之后无人喂狗
1. 原理:硬件上就没有"停止"通路
先把 IWDG 这条链路摊开看:时钟、分频、计数器、复位输出,中间没有任何一个可以"关掉它"的开关。
第一层:时钟是独立的。 IWDG 由 LSI(内部低速 RC 振荡器,约 40kHz)直接驱动,不经过 SystemInit() 里配置的 PLL、AHB/APB 分频链。这意味着主时钟配错、系统进低功耗、程序跑飞把时钟改了,它都照常倒计时——这正是它作为"最后一道保险"的价值。
第二层:软件能碰的只有一个寄存器。 整个模块对外只暴露 IWDG_PR、IWDG_RLR、IWDG_SR 与 IWDG_KR,而前三个在启动后都受写保护,唯一的控制入口是键寄存器:
| 写入值 | 作用 |
|---|---|
0xCCCC | 启动看门狗计数 |
0xAAAA | 喂狗,计数器重装为 RLR |
0x5555 | 解除 PR/RLR 的写保护 |
三个命令字里没有"停"。写 0x5555 只是让你能改分频与重装值,并不停止计数;写其它任何值都不产生动作(键寄存器只认这三个值,非法值被硬件忽略)。
第三层:只有两条路能结束倒计时。 一条是计数器自然减到 0——产生复位;另一条是芯片断电。系统复位会把 IWDG_KR 清回默认值,启动位随之消失,计数不再继续,固件从复位向量重新开始。
所以"关掉看门狗"这件事在 F103 上是不存在的操作,不是驱动没提供、也不是 CubeMX 没生成,而是硅片上没有这条通路。
2. 为什么偏偏要设计成关不掉
因为看门狗的职责是"在软件失控时把系统拉回来"。如果留一个软件可写的停止位,那么跑飞的程序(或者一段被写坏的代码)同样能写这个位,把自己身上的保险关掉——保护就变成了自欺。
于是硬件的取舍是明确的:
- 允许你调整超时(改 PR/RLR,但要先解锁);
- 允许你延后复位(喂狗重装计数器);
- 不允许你取消监控(没有停止位,也无法用调试口永久关闭)。
把这三条映射到 IAP 场景,就是必须接受的前提:Bootloader 一旦使能过看门狗,"跳到 APP 之前先把它关掉"这条路从原理上就走不通。
3. 于是只剩"喂"和"接管"两条路
跳过不去、关又关不掉,Bootloader 在交权这件事上能做的动作只有两个:
| 做法 | 效果 | 是否可行 |
|---|---|---|
| 跳转前喂一次狗 | 把窗口重置为完整 3 秒 | 只能买 3 秒,APP 不接管照样复位 |
| 跳转前拉长 RLR | 把窗口改成 10 秒、30 秒 | 保护被削弱,且 APP 迟早要面对同一问题 |
| 跳转前关闭看门狗 | 彻底没有窗口 | 硬件不支持 |
| 让 APP 重新装载并接管 | 窗口刷新,责任转移,保护强度不变 | 唯一的正解 |
这也解释了为什么本工程 iap_load_app() 里根本没有"关闭 IWDG"的代码,只留了一句注释:"喂狗责任转给 APP"。它不是遗漏,而是在硬件约束下唯一能做的事。
顺带说明两种"看起来能关"的例外,都不是产品可用的手段:
- 调试器把内核 halt 时可以靠
DBGMCU_CR的IWDG_STOP位冻结计数,但这只在调试会话里成立,脱机运行无效; - 选项字节可以让 IWDG 由硬件自动启动,那种情况下更彻底——连"启动时机"都不归软件管。
4. 跳转不是复位,计时继续
把"Bootloader 使能了看门狗"和"跳转不等于复位"这两件事实叠起来,就得到现象 B 的完整机理:
(1)Bootloader 在初始化末尾 iwdg_start(),计数器开始 3 秒倒计时,主循环每 500ms 喂一次,所以停在命令行多久都没事;
(2)收到 go,iap_load_app() 停掉 SysTick 与串口中断、关网络,然后跳转——喂狗随之停止,但计数器还在跑;
(3)APP 从 Reset_Handler 开始重新初始化,这段时间里没人喂狗;
(4)如果 APP 没有接管、或接管得太晚,计数器减到 0,硬件复位,于是又回到 Bootloader。
现象 B 之所以容易误判,是因为它的表现很像"APP 一启动就崩":日志断在 APP 刚打印的地方,而复位原因又没有任何提示。区别在于崩通常是立即的、位置随机的,而这条是稳定约 3 秒的周期。
5. 接管晚于窗口的代价
于是多出一条硬约束:APP 从 Reset_Handler 到调用 iwdg_start() 的耗时,必须小于 Bootloader 留给它的剩余窗口(最坏 3 秒,LSI 偏快时只有 2 秒出头)。
本工程的两个 APP 满足这条约束:main() 里在 iwdg_start() 之前只有 HAL_Init()、时钟、GPIO、USART 初始化与一行 ready 打印,实测几十毫秒量级。
反过来,如果 APP 的初始化链里也有慢速外设(比如它自己也要等 PHY 自协商),就必须重新设计:把 iwdg_start() 提前到初始化早期,并在每个长等待处显式喂狗——这正是我们在 Bootloader 里极力避免的形状,APP 侧要付的代价。
四、 解决方案
两个现象对应两个改法,缺一个都不完整:A 的改法让 Bootloader 初始化不被咬,B 的改法让跳转之后有人喂狗。
1. 针对 A:配置与使能分离
思路是初始化期间不让看门狗计时,初始化完成后再让它开始计时。落到代码上是把 CubeMX 生成的 MX_IWDG_Init() 改成空操作,另加一个 iwdg_start() 承担真正的使能。
空操作写在 USER CODE 区里,return 之后的生成代码不可达,ARMCC 会报 #111-D(unreachable code),用 #pragma diag_suppress 111 抑制到函数尾再恢复:
// atk-f103/Core/Src/iwdg.c
void MX_IWDG_Init(void)
{
/* USER CODE BEGIN IWDG_Init 0 */
#pragma diag_suppress 111
return;
/* USER CODE END IWDG_Init 0 */
hiwdg.Instance = IWDG;
hiwdg.Init.Prescaler = IWDG_PRESCALER_32;
hiwdg.Init.Reload = 3750;
if (HAL_IWDG_Init(&hiwdg) != HAL_OK)
{
Error_Handler();
}
/* USER CODE BEGIN IWDG_Init 2 */
#pragma diag_default 111
/* USER CODE END IWDG_Init 2 */
}真正的使能放在文件末尾的 USER CODE BEGIN 1 区,参数与生成区保持一致:
// atk-f103/Core/Src/iwdg.c
void iwdg_start(void)
{
hiwdg.Instance = IWDG;
hiwdg.Init.Prescaler = IWDG_PRESCALER_32; /* 32 分频 */
hiwdg.Init.Reload = 3750; /* 3s = 32 x 3750 / LSI 40kHz */
if (HAL_IWDG_Init(&hiwdg) != HAL_OK)
{
Error_Handler();
}
}这样做的附带好处是:CubeMX 重新生成代码时,MX_IWDG_Init() 的空操作和 iwdg_start() 都会保留(都在 USER CODE 区),不需要每次手工改回来。
2. 针对 B:跳转后由 APP 接管
Bootloader 侧不需要(也没法)做任何"关闭"动作,只需要把责任交出去;真正的动作在 APP 里:用同一组参数重新初始化一次。HAL_IWDG_Init() 内部会往 KR 写 0x5555 解锁、写入 PR 与 RLR、写 0xAAAA 重装计数、再写 0xCCCC 启动,计数器由此回到满值。
// sram-app/Core/Src/main.c(flash-app 完全一致)
SCB->VTOR = APP_BASE; /* 向量表重定位, 早于 HAL_Init */
HAL_Init();
/* ... 时钟、GPIO、USART1 初始化 ... */
(void)usart1_cmd_register("reboot", app_cmd_reboot);
usart1_rx_start();
printf("APP: ready, send a line to echo, commands: 'reboot' restart chip\r\n");
/* 与 Bootloader 一致: 所有初始化完成后再使能看门狗, 之后由主循环周期喂狗 */
iwdg_start();iap_load_app() 里没有"关闭看门狗"的代码,注释写明"喂狗责任转给 APP",就是怕后人误以为漏了一步。
参数必须四处一致,任何一处不同都会让现象 B 以另一种形式复现:
| 情况 | 现象 |
|---|---|
| APP 没接管、也没喂狗 | 跳转后约 3 秒复位,日志停在 APP 初始化中途 |
| APP 接管但 RLR 更大(如 6250 → 5 秒) | 保护变弱,真死机时要等更久才复位 |
| APP 接管但 RLR 更小(如 1250 → 1 秒) | 正常业务里偶发复位,且很难归因到看门狗 |
| APP 接管但分频不同 | 同上,实际超时与预期不符 |
3. 三个工程的调用点与参数
使能点统一定在"所有初始化完成、状态已经报出去"之后,三个工程完全一致:
| 工程 | 调用位置 | 喂狗位置 | 周期 |
|---|---|---|---|
atk-f103(Bootloader) | iap_service_init() 末尾(串口 IAP、外部 SRAM 自检、lwIP 与 TCP 全部就绪后) | 主循环,每 500ms 一批 | 500ms |
flash-app | APP: ready... 打印之后 | 主循环,30 × 100ms 内每圈 | 100ms |
sram-app | 同上 | 同上 | 100ms |
四个参数保持一致:LSI 40kHz、32 分频、Reload 3750,即 3 秒。裕量分配也很清楚——Bootloader 500ms 喂一次约 6 倍裕量,APP 100ms 喂一次约 30 倍裕量。
4. 责任链一览
| 阶段 | 看门狗状态 | 谁负责喂 | 漏喂的后果 |
|---|---|---|---|
| Bootloader 初始化 | 未启动 | 无需 | 无(现象 A 已消除) |
| Bootloader 命令行等待 | 运行中(3 s) | Bootloader 主循环,500 ms 一次 | 3 秒后复位,回到 Bootloader |
go 跳转窗口 | 运行中,剩余时间递减 | 无人(客观空档) | 窗口超时 → 复位(现象 B) |
| APP 初始化 | 运行中 | 无人,直到 iwdg_start() | 同上 |
| APP 主循环 | 运行中,已重新装载 | APP 主循环,100 ms 一次 | 3 秒后复位,回到 Bootloader |
reboot | 复位时停止 | — | — |
五、 为什么不选"喂得更勤"
第一反应往往是"在初始化里也喂狗"。这条路走不通,原因是阻塞点的形态:
- PHY 自协商是单次长阻塞,那 10 秒里没有任何可以插进去喂狗的位置;
- 除非改驱动,把等待拆成"每 100ms 回调一次喂狗",但这会把看门狗逻辑渗进外设驱动,且以后再加一个慢速外设又要重来一次;
- 更根本的问题是:在初始化完成前就让看门狗计时,本质是在赌"初始化一定够快"。插线时赌赢,拔线时赌输,故障是否出现取决于现场条件——这是最难排查的一类问题。
把使能点后移,则完全不依赖时序假设:初始化多久都不会被咬,只看主循环是否还在跑。
至于现象 B,也不能靠"Bootloader 跳转前多喂几次"来解决:喂狗只能把窗口往后推 3 秒,APP 不接管的话,3 秒后照样复位。这个问题的正解只有一个——让 APP 接管。
另外两条配套约定:
- 三个工程只改
USER CODE区的代码,CubeMX 重新生成不丢; - 参数改动时四处同步:Bootloader 的
iwdg_start()、两个 APP 的iwdg_start()、以及 CubeMX 工程里的 IWDG 配置。
六、 验证与回归
| 场景 | 期望结果 |
|---|---|
| 拔网线上电 | 日志完整打完,停在 go/reboot 命令行,不重启(现象 A 消失) |
| 插网线上电 | 与拔线一致,PHY 协商提前完成 |
发 app.img 途中拔线 | 3 秒后报超时并回到扫描态,不重启 |
go flash / go sram 跳转 | APP 起来后持续运行,不在跳转窗口内复位(现象 B 消失) |
| APP 运行中阻塞超过 3 秒 | 应当复位(说明看门狗确实在工作) |
| APP 正常运行 | 不复位 |
reboot 之后 | 回到 Bootloader 命令行,可再次 go,不出现"刚复位又被咬" |
临时把 APP 的 iwdg_start() 注释掉 | 跳转后约 3 秒复位——用于确认接管确实在起作用 |
中间四行是回归的关键:既要证明"该复位时会复位",也要证明"不该复位时不会复位",尤其是跳转窗口这一格——它同时覆盖了"Bootloader 使能时机"和"APP 接管"两件事。只验证前者容易把超时改得过长,只验证后者容易把看门狗变成摆设。
最后一行的破坏性验证建议只做一次、做完立刻恢复:它是确认"APP 接管生效"最直接的手段,若注释掉之后反而不复位,说明 APP 根本没在喂狗,问题在别处。
排查时可用的两个观测点:usart1_rx_error_count() 是否增长(初始化被阻塞的间接证据)、以及复位后日志是否总停在同一位置(看门狗被咬的典型特征)。
本文档由 markdowncli 技能辅助生成