
看门狗这个词做嵌入式的人几乎都听过但真正把它用对的项目我见过的不到三成。更多人是在项目后期被客户投诉设备偶尔死机、必须断电重启才回头在主循环末尾补上一句IWDG_ReloadCounter()然后觉得问题解决了。实际上看门狗、保护机制、故障降级这三件事是同一个问题的三个面设备在无人值守的环境里遇到它无法完全避免的故障时怎么保证不出安全事故、不丢关键数据、并且能被远程诊断。这篇内容面向做过单片机或嵌入式 Linux 开发、准备把产品从实验室能用推到现场能扛的工程师尤其是工业控制、电力仪表、汽车电子、医疗设备这类不允许随便死机的方向。我会把故障分类、看门狗选型与喂狗周期计算、软硬件保护层的具体做法、降级等级的设计以及真实排查手法一层层拆开能抄的代码和参数我都贴出来。1. 先把故障和降级这两件事说清楚1.1 故障不是一个东西瞬态、间歇、永久很多工程师一说故障就想着加个看门狗复位一下但故障本身分三类处理方式完全不同。瞬态故障是环境引起的、转瞬即逝的电源毛刺、电磁干扰打在信号线上、宇宙射线导致 RAM 里某个 bit 翻转、总线竞争。这类故障的特点是复现不了、复位后大概率就好了所以策略是检测 重试 记录次数次数超标才升级处理。间歇性故障是硬件在退化连接器氧化、焊点虚焊、电容接近寿命末期、芯片在特定温度下时序临界。它的特点是随温度、湿度、振动相关早期一天一次、后期一小时一次。这类故障最忌讳复位了事因为复位只会掩盖趋势正确做法是把它记录成带时间戳的日志通过维护接口暴露出去。永久故障是器件真的坏了、Flash 某块擦写寿命耗尽、或者代码逻辑上的死锁——逻辑死锁虽然是软件问题但对系统来说表现就是永久性的。这三类故障的诊断路径完全不一样。我自己的判断习惯是看复位间隔的分布如果复位集中在某几个特定工况比如电机启动瞬间、继电器吸合时基本是瞬态干扰如果复位间隔在慢慢缩短是间歇退化如果复位后立刻又复位、且复位源始终指向同一个任务那就要去查逻辑死锁或者堆栈溢出。先把故障归类再决定用什么保护手段这个顺序不能反。1.2 降级不是认输是给优先级排序降级这个词听起来很消极好像功能做不完才降级。实际工程里降级是一套主动设计的策略系统检测到某个部件或某条链路不可靠之后按预设的优先级把资源收缩到仍然安全可用的最小集合。优先级排序的通用顺序是人身与设备安全 数据完整性 基本可用性 功能完整性 性能指标。举个例子一台带加热功能的设备如果温度传感器读数校验失败正确的降级不是继续用这个可疑读数闭环而是切断加热输出、保持通信上报、进入待机。通信断了要降级成本地按键可操作而不是整机瘫痪。多路采集通道里某一路 ADC 异常就标记那一路无效并上报告警其他通道照常跑用户看到的是某通道数据不可用而不是设备离线。这些决定的共同点是降级行为必须是提前设计好的、可枚举的状态而不是运行时临时拍脑袋。后面第 4 节我会给一套可以直接复用的降级等级定义。1.3 先跑起来再说的项目为什么最后都炸我复盘过几个出问题的项目缺失的东西高度雷同列出来给做自查用没有复位源记录。设备复位了代码从 main 重新跑没人知道是掉电、看门狗还是硬件复位现场无法还原只能靠猜。看门狗在主循环里喂。主循环里一个while(等待某个标志)卡住喂狗语句就在这个 while 外面于是永久死等、狗却一直喂着保护机制形同虚设。断言写成while(1)。产品上线后断言触发没人按复位只能等客户拔电。参数区只有一份。写参数时掉电参数区变成半新半旧的垃圾数据上电校验失败直接不启动返厂。所有阻塞调用都没超时。通信对端不发数据任务就永远挂在接收函数里连带整机响应变慢。没有故障日志。偶发问题在实验室复现不了工程师只能多测几次看看。这六条里任意一条都足以让一个功能完成度很高的产品在现场口碑崩塌。所以我的观点是可靠性不是功能做完之后加的一层它和功能是同一层东西必须在架构阶段就占位置。2. 看门狗最便宜也最容易被用废的保命手段2.1 独立看门狗和窗口看门狗到底该选哪个以常见的 ARM Cortex-M 平台为例芯片内部一般有两种看门狗选型绕不开这张表对比项独立看门狗 IWDG窗口看门狗 WWDG时钟源独立低速时钟LSI约 32kHz总线时钟PCLK1与主时钟关系主时钟挂了它还在跑主时钟异常会一起异常喂狗时机限制只要在超时前喂就行必须落在窗口内喂太早也复位能否关闭一旦启动除复位外不可停可通过配置关闭典型超时范围毫秒到几十秒通常几十微秒到几十毫秒适合的角色系统最终兜底检测程序跑飞、跑太快关键差别在窗口这两个字。IWDG 只能检测程序卡死、跑太慢程序如果因为某个分支跑飞、把主循环转得飞快IWDG 反而被喂得饱饱的一点事没有。WWDG 要求你在计数器已经减到小于窗口值、但还没减到 0的这段时间里喂狗喂早了直接复位这就把程序跑得太快、跳过了该执行的代码也覆盖了。WWDG 的超时时间可以用这个式子估t 4096 × 2^WDGTB × (T[5:0] 1) / F_pclk1。假设 PCLK1 是 36MHz预分频取 8WDGTB3计数器初值 0x7F那就是4096 × 8 × 128 / 36e6 ≈ 116ms初值取 0x3F 时约 58ms这也是很多例程里 58ms 这个数字的来历。窗口值一般设成计数器初值的 60%~75%喂狗动作安排在主循环靠后的位置保证该跑的都跑完了才喂。我的实际搭配是WWDG 管主循环的节奏IWDG 管最后底线两个都开。主循环正常时由主循环喂 WWDG同时由监督任务按健康度喂 IWDG一旦主循环卡住WWDG 先复位响应快如果连 WWDG 的中断都进不去IWDG 再兜底。这样能把快速跑飞和彻底卡死分开定位复位源寄存器一读就知道是哪一层报的警。提示IWDG 使用 LSI而 LSI 的精度很差datasheet 里通常只给出 17kHz~47kHz 这样的宽范围中心值 32kHz。算超时时间时必须按最坏情况最高频 47kHz 对应最短超时去验证否则标称 1s 的超时可能在高温下只有 700ms。2.2 喂狗喂的是任务健康度不是主循环还活着裸机项目里在主循环末尾喂狗还算能接受但 RTOS 项目里这么做就是自欺欺人。因为多任务系统里主循环可能只是某个低优先级任务它跑得很欢而真正干活的通信任务、控制任务早就挂死了。正确的模型是心跳上报 集中监督每个关键任务在自己的周期里递增一个 alive 计数器监督任务按固定周期检查每个计数器是否在规定时间内增长过。全部正常才去喂硬件狗任何一个任务超时先不喂让硬件狗自然溢出复位同时把超时的任务 ID 写进备份区。骨架大概是这样typedef struct { volatile uint32_t alive; /* 任务每次循环自增 */ uint32_t last_alive; /* 上次检查时的值 */ uint32_t timeout_ms; /* 允许的最大静默时间 */ uint32_t silent_ms; /* 当前累计静默时间 */ uint8_t required; /* 是否必须存活 */ const char *name; } task_hb_t; static task_hb_t g_hb[] { {0, 0, 50, 0, 1, ctrl}, {0, 0, 200, 0, 1, comm}, {0, 0, 100, 0, 0, ui }, /* 非关键超时只降级不复位 */ }; void monitor_task(void *arg) { for (;;) { for (int i 0; i ARRAY_SIZE(g_hb); i) { if (g_hb[i].alive ! g_hb[i].last_alive) { g_hb[i].last_alive g_hb[i].alive; g_hb[i].silent_ms 0; } else { g_hb[i].silent_ms MONITOR_PERIOD_MS; if (g_hb[i].silent_ms g_hb[i].timeout_ms) { fault_report(g_hb[i].required ? FAULT_FATAL : FAULT_WARN, (uint16_t)(FAULT_SRC_TASK_BASE i)); } } } if (system_health_ok()) { wdg_feed(); /* 全部健康才喂硬件狗 */ } osDelay(MONITOR_PERIOD_MS); } }这段代码里有几个设计取舍值得说。required字段区分了关键任务和非关键任务非关键任务超时只降级不复位避免界面卡一下就把整机重启用户体验和可靠性要平衡。fault_report里要做去重和限次同一种故障连续上报不能把日志区写爆。监督任务的优先级要高于被监督的任务否则它自己都可能被饿死。还有一点容易忽略监督任务自己也要被监督常见做法是让硬件狗的超时时间大于监督周期的 3 倍以上同时给监督任务设一个独立的小心跳由中断或定时器回调去戳。2.3 喂狗周期和超时时间怎么算给一套能直接抄的过程很多人设看门狗超时是凭感觉给个 1 秒。其实有个明确的算法我按这个流程走过好几个项目没出过偏差。第一步列出所有需要监督的任务周期和它们的最大允许延迟。假设控制任务 10ms、通信任务 50ms、显示任务 200ms人机交互允许丢失 3 次显示刷新那最坏情况下系统仍可接受的最大无响应时间是 600ms。第二步把监督开销和抖动算进去。监督任务本身 10ms 周期检测到一个任务超时需要连续几个周期确认防抖假设 3 个周期即 30ms。再留 1.5~2 倍裕量取超时时间T_wdg 800ms。第三步反算寄存器值。IWDG 超时公式是T (4 × 2^PR) × (RLR 1) / F_LSI。取预分频 PR4对应 64 分频LSI 按 32kHz 算(64 × (RLR1)) / 32000 0.8得到RLR 1 400即RLR 399。写进代码就是/* F_LSI 标称 32kHzPR4 即 64 分频RLR399 - 0.8s */ IWDG-KR 0x5555; /* 解锁 */ IWDG-PR 4; /* 64 分频 */ IWDG-RLR 399; IWDG-KR 0xAAAA; /* 先喂一次装载 RLR */ IWDG-KR 0xCCCC; /* 启动此后不可关闭 */第四步确定喂狗节奏。喂狗周期取超时的 1/3 到 1/2即 250~400ms。注意喂狗周期的抖动必须远小于裕量如果监督任务本身周期抖动就有 100ms那 800ms 的超时就显得紧张了。最后一步也是最容易被跳过的一步用示波器或者翻转 IO 的方式实测一次真实复位时间。做法是在测试版本里加一个命令收到后停止喂狗用逻辑分析仪抓复位引脚或者抓启动后的第一个 IO 翻转看从停喂到重启的实际耗时。这个数和你算出来的值一般会差 10% 上下LSI 精度导致的偏差都在这儿体现。2.4 AUTOSAR 里的看门狗配置思路值得借鉴的三层结构如果你在做汽车电子或者类似功能安全的项目AUTOSAR 的看门狗体系可以直接拿来借鉴它把任务健康这件事拆成了很有参考价值的三个监督维度。整体路径是应用层的软件组件SWC在执行到关键点时调用检查点上报接口服务层的看门狗管理器WdgM做判断ECU 抽象层的接口WdgIf转发最后由 MCU 抽象层的看门狗驱动Wdg真正去设置触发条件或者切换模式。WdgM 提供的三类监督各有分工。**Alive Supervision存活监督**检查某个实体的检查点在一定周期内被上报的次数是否落在配置的上下限之间这对应我们前面说的心跳有没有按时来而且它还带下限检查能发现跑太快、跳过了某些路径。**Deadline Supervision截止时间监督**检查两个检查点之间的时间间隔是否在允许范围内用来约束某段关键流程必须在多少毫秒内走完这比单纯的心跳精确得多。**Logical Supervision逻辑监督**检查执行顺序是否符合预设的控制流图能抓住某个错误分支跳过去之后状态机的迁移顺序乱了这种问题。三种监督的本地状态汇总成全局状态全局状态异常时驱动看门狗进入失效反应常见的是先切到慢速模式最后停止喂狗。这套结构最值得学的地方是监督的时间和监督的职责是解耦的应用层只管在正确的位置打检查点什么时候算超时、超时后怎么办全在配置里。这样调整阈值不需要改应用代码测试阶段可以放心地把阈值调得很紧去暴露问题量产再放宽。自己做项目时即便不用 AUTOSAR也可以照这个思路把打点接口和判定策略分成两个模块收益很明显。2.5 喂狗的反模式清单对照自查反模式后果正确做法在定时器中断里无条件喂狗主循环死透了狗还在被喂中断只递增心跳喂狗交给监督任务只用主循环喂单个任务挂死检测不到多任务心跳汇总后统一喂所有分支都喂狗保护形同虚设只在该跑完的都跑完的位置喂超时设得极短如 10ms正常抖动就复位现场频繁重启按最坏执行时间算留 1.5~2 倍裕量调试时随手关掉狗忘了恢复量产无保护用编译开关区分量产配置强制打开并在启动时回读校验喂狗函数里打印日志串口阻塞时连狗一起拖死喂狗路径禁止任何可能阻塞的操作最后一条我踩过早期在一个项目里把调试打印放在喂狗函数里串口发送队列满了就阻塞结果主循环好好的狗却因为阻塞没喂上现场随机重启查了整整两天。喂狗路径必须是一条最纯粹、最短、绝不可能阻塞的路径这个原则比什么都重要。3. 保护机制从硬件到软件一层层兜底3.1 硬件层的兜底复位源、电源监控、时钟监控保护机制的第一层在芯片内部这部分能力经常被浪费。复位源寄存器是最基础的它能告诉你上次复位是上电复位、引脚复位、软件复位、看门狗复位还是低压复位。用法上有个大坑这类寄存器很多是读后清零的如果你在 main 里等到外设初始化完才去读中途某些库代码可能已经碰过它了。我的做法是在启动文件跳转到 main 之后的第一件事就把它读出来存到.noinit段或者 RTC 备份寄存器里再去做别的初始化。电源监控分两级用。芯片自带的欠压复位BOR是硬件保护电压掉到阈值以下直接复位防止低压下 Flash 写坏或者逻辑乱跳这一级通常只能在选项字节里调档位。更早的预警要用可编程电压检测PVD把阈值设在比 BOR 高一点的位置比如 2.9V 对 2.7V触发中断后你有几百微秒到几毫秒的时间去做紧急保存——把关键状态写进 Flash、让执行器回到安全位置、停止对外输出。这几毫秒的操作顺序要提前排好并且计时验证过我见过有人在 PVD 中断里做浮点运算加 Flash 擦除结果还没写完电就没了反而把参数区搞成了半截数据。时钟监控指的是失效自动切换。HSE 晶振因为振动或者低温停振如果没有时钟监控CPU 直接停摆看门狗虽然用的是内部时钟还能复位但频繁复位不如自动降级运行。使能时钟安全系统后HSE 失效会自动切到内部 RC 并产生中断你在中断里记录一次时钟故障、把依赖精确时钟的外设比如高波特率串口、USB标记为不可用系统降级继续跑至少通信和告警还能工作。另外还有一类外置看门狗芯片特点是超时时间可调、有独立的复位延时适合电源质量很差、芯片可能反复复位的场合用来保证复位脉冲足够宽、MCU 能可靠重启。选这类芯片要看两个参数复位输出有效电平宽度是否大于 MCU 最小复位脉宽以及超时时间是否支持窗口模式。3.2 存储与参数双备份、CRC 和原子写参数区是现场变砖的头号元凶根本原因是写 Flash 不是原子的。解决办法是双区交替 序列号 校验。布局上把参数区切成 A、B 两块每块开头放一个头部typedef struct { uint32_t magic; /* 固定魔数用于识别是新数据 */ uint16_t version; /* 结构体版本升级时兼容用 */ uint16_t reserved; uint32_t seq; /* 单调递增越大越新 */ uint32_t payload_len; uint32_t crc32; /* 覆盖 header 其余字段 payload */ /* payload 紧跟其后 */ } param_header_t;上电读取流程是先分别校验 A、B 两块的 magic 和 CRC两块都合法就取 seq 更大的那块只有一块合法就用那块两块都不合法就加载出厂默认值并置一条告警。写入流程关键在于永远不要动当前有效的那一份先把新数据写到 seq 较小的那块也就是旧的那块写完回读校验 CRC确认无误后再更新它的 seq 让它的序号反超最后才把内存里的当前指针切过去。这样在任何一个时刻掉电最多是新数据没写成功旧数据仍然完好绝对不会出现两份都不合法。有几点工程细节要注意。掉电时机最好用可编程电压检测中断来触发保存而不是在业务逻辑里随机保存。擦写次数必须估算一颗 Flash 扇区通常十万次如果参数每秒保存一次不到两天就写坏了——所以要么加限频同一参数最短保存间隔要么做磨损均衡要么把频繁变动的运行数据放到带电池的 RAM 里。CRC 的选择上参数区用 CRC32 就够不必上密码学哈希但要注意别用简单的累加和累加和对字节顺序不敏感反而会放过一些明显的损坏。版本字段一定要留以后加参数不会因为结构体长度变了导致旧数据被判非法。3.3 通信保护超时、重试、心跳和协议边界通信是嵌入式系统里最容易出问题的地方因为它连接的是系统外部你对对端的可靠性没有任何控制权。第一原则是任何阻塞点都必须有超时。裸机里是HAL_UART_Receive(huart, buf, len, HAL_MAX_DELAY)这种写法要坚决消灭改成明确的毫秒数RTOS 里所有带等待的 API 都要给 timeout包括信号量、消息队列、事件组。凡是写了portMAX_DELAY的地方都要问一句如果对端永远不来这个任务怎么办。第二原则是重试要有边界和退避。简单的三次立即重试在总线冲突场景下会把冲突放大正确做法是退避比如 10ms、30ms、90ms而且重试的操作要是幂等的。如果这条命令会改变设备状态比如启动电机重试前必须确认上一次有没有执行成功靠序列号或者状态回读来判断否则会出现重试两次、动作做了两次。第三是心跳和协议边界的处理。心跳的意义不只是对方还活着还要携带序号让接收方能发现中间丢了几帧。协议解析上长度字段加 CRC 是最省事的组合但要注意 CRC 校验的时机——先收完再校验避免边收边校验导致部分写入状态被污染。接收路径我推荐 DMA 加空闲中断的方式DMA 负责把字节搬进环形缓冲区空闲中断负责标记这一帧收完了主循环或者接收任务只处理完整帧这样中断里做的事情极少也不会因为主循环忙而丢字节。环形缓冲区的读写指针用无锁的单生产者单消费者模式写指针只在中断里动读指针只在任务里动。最后一点通信任务的健康状态要纳入看门狗监督。链路断了不等于任务可以躺平相反链路断的时候任务更应该继续按周期尝试重连并在连续失败超过阈值时上报故障、触发降级。把通信断了等同于通信任务可以不跑了是很多设备断网后彻底失联的根本原因。3.4 任务与内存层栈溢出、堆碎片、优先级反转RTOS 环境下栈和堆的问题占比非常高。栈溢出检测至少要开两级把configCHECK_FOR_STACK_OVERFLOW设成 2它会在任务切换时检查栈末尾的填充图案是否被破坏比只检查栈指针越界更可靠再实现vApplicationStackOverflowHook在钩子里把出事的任务名写进备份区再主动复位而不是默认的while(1)死循环。更彻底的做法是用 MPU 把每个任务栈的下界设成一个不可访问区域一旦踩过去立刻触发内存管理异常能精确抓到越界的那条指令。栈大小的确定不能靠猜用uxTaskGetStackHighWaterMark跑完最坏工况所有分支都触发、日志全打、浮点全用上之后看剩余水位留 30% 以上余量。堆碎片是长时间运行设备的隐形杀手表现是刚上电跑得好好的连续运行 72 小时后 malloc 失败。解决办法很简单关键路径禁止动态分配全部改成静态数组、固定大小的内存池或者heap_5管理多段内存。如果确实需要动态内存用内存池而不是通用堆块大小固定就不会有碎片代价是有一点内部碎片。我经手的一个项目就是从malloc改成 4 种固定大小的内存池连续运行时间从 48 小时的稳定性提升到了 30 天无异常。优先级反转在多任务共用互斥资源时很容易出现典型症状是高优先级任务偶尔卡住几百毫秒低优先级任务却很活跃。解决方式是所有共享资源用支持优先级继承的互斥量而不是二值信号量临界区尽量短绝对不要在临界区里调用可能阻塞的接口也不要在持锁期间做 Flash 擦写或者等外设。还有一条红线中断服务函数里禁止调用任何可能引起阻塞的 API包括带等待的互斥量、动态分配、打印。中断里给任务发通知用FromISR版本的接口并且要检查返回值判断有没有触发任务切换。3.5 把故障设计成一个状态安全状态与状态机前面讲的手段都是发现故障真正区分工程水平的是发现之后做什么。我的做法是把安全状态明确定义出来一个不需要通信参与、不需要复杂计算、在有限步骤内一定能到达的稳定状态。对电机系统就是断开驱动输出、让电机自由停车或者按安全斜率制动对加热系统就是切断加热回路、保持温度采样用于告警对阀门就是回到预设的安全位置对含运动部件的设备还要考虑重力带来的失控风险可能需要抱闸。安全状态的设计有三条硬性要求。第一进入安全状态的路径不能依赖故障部件如果温度采样坏了就不能让安全状态的判定依赖温度读数。第二进入路径的步骤数要少且可穷举最好三步以内每一步都能独立完成不要搞成先关 A、等 B 反馈、再关 C这种链式依赖。第三安全状态要可观测也就是外部能通过一个确定的输出比如一个状态引脚、一条告警报文知道设备已经进入安全状态方便维护人员判断。把故障处理做成状态机的另一个好处是恢复过程也可以设计。我习惯给每个降级等级配一个恢复条件比如连续 60 秒没有新的故障、且关键部件自检通过满足才向上恢复一级并且加滞回防止在阈值附近来回跳。没有恢复条件的状态机一旦降级就再也回不去白白损失可用性。4. 降级策略落地把健康度变成可执行的分级4.1 定义健康度和降级等级空谈降级没有意义得把它变成一张谁都看得懂的表。下面这套分级我在几个项目里用过稍加调整就能套到大多数设备上等级名称触发条件系统行为对外表现L0正常所有自检通过无故障全功能运行正常数据 心跳L1性能降级非关键部件异常如某 UI 任务超时、某路非关键采样失效关闭该功能其余照常数据带局部不可用标记L2功能降级关键外设部分失效如某路通信断、某传感器数据不可信关闭依赖该外设的功能切换到冗余通道或本地操作上报告警功能列表缩减L3安全降级关键部件失效或故障反复出现执行器回到安全状态仅保留监测与通信告警 等待人工干预L4停机保护复位次数超限、多条关键故障同时存在停止所有输出进入最低功耗待机只响应维护命令明确故障码需现场处理分级的价值在于每一个等级都有明确的进出条件和可观测的外部表现测试人员能按要求逐级验证维护人员看告警就知道该做什么。特别提醒一点等级的进入条件要允许一条致命故障直接跳到 L3不要为了流程整齐强行从 L0 走到 L3安全相关的判断越快越好。4.2 一个可复用的降级管理器骨架实现上我习惯用一个按位组织的故障位图加一个当前等级变量所有故障上报走同一个入口等级计算集中在degrade_update()里#define FAULT_OVERTEMP (1u 0) #define FAULT_COMM_LOST (1u 1) #define FAULT_SENSOR_INVALID (1u 2) #define FAULT_PARAM_BROKEN (1u 3) #define FAULT_TASK_TIMEOUT (1u 4) #define FAULT_RESET_LOOP (1u 5) static uint32_t g_fault_bitmap; static uint8_t g_level 0; static uint8_t g_level_candidate 0; static uint16_t g_hold_counter 0; void fault_report(uint32_t bit, uint8_t is_fatal) { if (is_fatal) { g_fault_bitmap | bit; degrade_force(DEG_L3); /* 致命故障直接跳级 */ } else { g_fault_bitmap | bit; } fault_log_push(bit); /* 带时间戳落盘 */ } static uint8_t level_from_bitmap(uint32_t bits) { if (bits (FAULT_RESET_LOOP | FAULT_PARAM_BROKEN)) return DEG_L4; if (bits (FAULT_OVERTEMP | FAULT_SENSOR_INVALID)) return DEG_L3; if (bits (FAULT_COMM_LOST)) return DEG_L2; if (bits FAULT_TASK_TIMEOUT) return DEG_L1; return DEG_L0; } void degrade_task(void *arg) { for (;;) { uint8_t want level_from_bitmap(g_fault_bitmap); if (want g_level) { /* 立即升级不犹豫 */ g_level want; g_level_candidate want; g_hold_counter 0; degrade_apply(g_level); } else if (want g_level) { /* 降级恢复要慢加滞回 */ if (want g_level_candidate) { if (g_hold_counter HOLD_PERIODS) { g_level--; degrade_apply(g_level); g_hold_counter 0; } } else { g_level_candidate want; g_hold_counter 0; } } else { g_hold_counter 0; } osDelay(1000); } }这套代码的关键点有三个。升级立即、降级缓慢故障出现立刻保护恢复时必须连续稳定一段时间才允许往上走避免在阈值附近反复跳。等级判定集中在一处业务代码只负责上报故障位不自己决定等级这样规则调整只需要改一个函数。恢复时要清理对应的故障位比如通信恢复后要主动清除FAULT_COMM_LOST否则等级永远下不来清除动作要带条件比如连续收到 10 帧合法报文才清防止链路时通时断导致状态抖动。4.3 黑匣子故障记录怎么写才有用现场问题能不能查清取决于故障发生那一刻你留下了什么。我的最低要求是记录六项时间戳、复位源、故障码、故障发生时的任务或模块标识、关键变量快照、复位累计次数。时间戳如果没有 RTC可以退化成上电后的毫秒数 睡眠次数用来判断故障出现在启动阶段还是运行很久之后。存储位置有几个选择各有利弊。备份寄存器如 RTC 的备份域寄存器容量小但掉电不丢适合放复位源和复位次数这种极短信息。.noinit段 RAM 速度快、容量大但只在复位不清电的情况下有效真正的断电就丢了适合放崩溃瞬间的调用现场。Flash 日志区适合放需要长期保留的记录代价是擦写寿命所以要用环形结构并限制写入频率一般只在故障发生和状态切换时才写。写 Flash 日志有个必须注意的顺序问题先写数据再更新索引或序号最后才更新校验。这样掉电时最多丢最后一条不会破坏已有记录。另外日志条目要定长定长才能用序号取模直接定位避免变长记录带来的碎片和解析复杂度。4.4 恢复策略从软复位到冷复位要分级复位不是只有一种。我的分级是局部重初始化重新配置某个外设、重启某个任务→软件复位NVIC_SystemReset()从头跑但电源没断→硬件复位看门狗溢出效果接近软件复位但走的是硬件路径→断电重上电需要外部电路或人工。前两级软件能自己完成后两级通常要靠硬件配合。选择哪一级取决于故障性质。通信控制器进入异常状态重初始化外设往往就够了堆栈溢出或者内存被破坏必须软件复位因为内存里的状态已经不可信外设寄存器被干扰写乱、或者芯片内部某个模块锁死软件复位可能解决不了这时要靠硬件复位甚至断电。有一个很实用的技巧复位后先自检再决定行为。启动时做外设回环测试、RAM 走查、参数区校验任何一项不过就不要再启动正常业务直接进入降级状态并记录。还有个容易被忽略的保护复位循环检测。如果设备因为某个固定原因反复复位而业务代码在启动后几秒内还没开始跑就又被复位用户看到的就是开机—重启—开机—重启。做法是在备份区累计复位次数启动后如果 N 秒内运行正常就把计数清零如果计数超过阈值比如 5 次/分钟进入 L4 停机保护只维持通信把故障码报出去等人工处理。5. 排查实录现场那点事怎么定位5.1 复位源还原要从启动第一行开始复位源寄存器是排查的第一把钥匙但它的用法有讲究。多数芯片的复位标志是读后清而且启动过程中某些库函数可能会读它。所以正确顺序是启动文件跳转到 main 后的第一条语句先把复位源寄存器读到一个.noinit变量里再调用任何库初始化。代码大概这样/* 放在 .noinit 段复位不清零掉电才丢 */ __attribute__((section(.noinit))) volatile uint32_t g_boot_reset_flags; __attribute__((section(.noinit))) volatile uint32_t g_boot_magic; int main(void) { uint32_t flags RCC-CSR; /* 第一步就读别等 HAL 初始化 */ if (g_boot_magic ! 0x5A5A5A5A) { g_boot_reset_flags 0; /* 冷启动之前的记录无效 */ g_boot_magic 0x5A5A5A5A; } g_boot_reset_flags flags; __HAL_RCC_CLEAR_RESET_FLAGS(); hw_init(); if (flags RCC_CSR_IWDGRSTF) fault_log_push(FAULT_SRC_IWDG); if (flags RCC_CSR_WWDGRSTF) fault_log_push(FAULT_SRC_WWDG); if (flags RCC_CSR_SFTRSTF) fault_log_push(FAULT_SRC_SOFT); if (flags RCC_CSR_PORRSTF) fault_log_push(FAULT_SRC_POR); if (flags RCC_CSR_BORRSTF) fault_log_push(FAULT_SRC_BROWNOUT); ... }拿到复位源之后判断方向就很清楚了。复位源是欠压复位说明问题在电源或者线缆不用再花时间翻代码是独立看门狗说明任务健康度监督报的警去看记录里哪个任务超时是窗口看门狗说明主循环节奏乱了重点查有没有漏掉的死循环或者某段代码执行时间突然变长是软件复位那就是你自己的代码或断言主动重启的去查故障位图。我遇到过一个案例客户投诉每天重启几次读复位源全是欠压复位最后发现是现场电源线和动力线捆在同一个线槽里电机启动瞬间把电压拉到阈值以下加了独立供电和一段磁环就解决了一行代码都没改。5.2 偶发死机的排查顺序现场问题复现不了是常态所以排查要讲顺序从最快能排除的开始。下面这张表是我自己常用的顺序基本能覆盖八成情况现象优先怀疑验证手法运行几分钟就重启复位源是看门狗某任务死循环或阻塞记录任务心跳和最后执行位置用 IO 翻转测各任务周期随机重启复位源是欠压电源设计余量不足示波器抓电源纹波和瞬态跌落加大电容或换供电复位后立刻又复位启动阶段就触发了故障在启动各阶段翻转 IO看卡在哪一步长时间运行后功能异常但没复位堆碎片、句柄泄漏、计数器溢出打印内存池水位和各模块资源计数趋势通信时好时坏时序临界、地线干扰、波特率误差测波特率实际误差抓总线波形看边沿只在低温或高温下出问题器件参数漂移、时序余量不足温箱跑边界温度同时监测复位源整机偶发无响应但指示灯正常中断优先级配置错误导致高优先级饿死低优先级统计中断进入次数检查优先级分组修改某段代码后问题变多编译器优化导致的时序变化或未初始化变量对比 -O0 与 -O2 行为检查 map 文件栈用量一个经验偶发问题一定要先想办法量化。比如偶尔死机改成连续运行 100 小时死机 3 次再在代码里加计数器和时间戳把这些次数对应到具体时段和工作模式上。很多问题一旦量化规律就自己冒出来了比如每次都是在继电器吸合后 200ms 内。5.3 硬件异常定位从堆栈里把现场挖出来Cortex-M 的硬件异常处理函数如果只写个while(1)等于把最有价值的证据扔了。发生异常时内核会把若干寄存器自动压栈你可以在处理函数里把这些值取出来void hard_fault_handler_c(uint32_t *stack) { uint32_t r0 stack[0]; uint32_t r1 stack[1]; uint32_t r2 stack[2]; uint32_t r3 stack[3]; uint32_t r12 stack[4]; uint32_t lr stack[5]; uint32_t pc stack[6]; /* 出事的那条指令 */ uint32_t psr stack[7]; uint32_t cfsr SCB-CFSR; /* 可配置故障状态 */ uint32_t hfsr SCB-HFSR; uint32_t mmfar SCB-MMFAR; uint32_t bfar SCB-BFAR; blackbox_save(pc, lr, cfsr, hfsr, mmfar, bfar, r0, r1, r2, r3, r12, psr); NVIC_SystemReset(); }拿到 PC 之后用工具链里的地址解析命令把地址翻回函数和行号就能定位到出问题的位置。需要提前做两件准备编译时保留带调试信息的固件和对应的 map 文件并且记录每次固件版本否则拿到地址也还原不出来。如果开了优化导致行号对不上可以用 -O0 复现 反汇编核对 的方式确认。故障状态寄存器里信息量很大。可配置故障状态寄存器会区分是使用错误、总线错误还是内存管理错误其中还有细分的子标志比如非对齐访问、除零需要先使能、非法状态、取指错误等。内存管理错误的地址寄存器会给出触发访问的具体地址这一条对于定位数组越界、空指针解引用特别有用。我定位过一个空指针问题地址寄存器给出的地址是0x00000004说明访问了结构体里偏移 4 字节的成员而结构体指针是空的——这类信息光靠打印日志是拿不到的。还有几个常见误区要提醒。栈溢出导致的异常堆栈指针本身可能已经不可信这时取出来的 PC 未必是真实现场要结合栈水位和 MPU 保护记录交叉判断。中断里触发的故障要特别看 LR 寄存器的值它能告诉你出事时用的是主栈还是进程栈从而判断是任务代码还是中断代码的问题。记录不要写 Flash 太慢异常处理里如果 Flash 擦除耗时过长可能被看门狗提前复位稳妥做法是先把现场写进.noinitRAM复位后在启动阶段再落盘。5.4 故障注入和验证没做过故障测试就等于没测过前面所有的机制如果不做故障注入测试你都不知道它到底有没有用。我每个项目上线前必做的几项测试停喂测试在测试模式下发一条命令停止喂狗用逻辑分析仪测从停喂到复位引脚有效的实际时间和计算值对比偏差超过 20% 就要重新核算预分频和重载值。任务挂起测试人为把一个任务阻塞住比如在临界区里死等验证监督任务能不能在预期时间内报出故障并触发复位同时确认记录的故障 ID 是对的。电源扰动测试用可编程电源做缓慢下降和快速跌落验证欠压预警中断能不能在电压跌破阈值前完成关键保存做法是保存后在参数区打标记重新上电检查标记是否完整。连续掉电测试在参数写入的过程中反复随机断电至少几百次验证参数区不会出现两份都非法的情况。这个测试最容易暴露原子写的漏洞。通信破坏测试注入错误 CRC、超长帧、半截帧、连续错误帧确认协议解析不越界、不死循环并且能在连续错误后恢复。复位循环测试人为制造持续故障验证复位次数达到阈值后能否进入停机保护而不是无限重启。老化测试常温连续运行 72 小时以上高温 48 小时同时记录复位源计数、内存水位、任务最长响应时间三条曲线这三条曲线平就是真的稳。有一个测试我特别推荐把看门狗的超时时间临时缩短到正常值的十分之一跑一轮完整的功能测试。这个压力模式能暴露很多平时藏着的时序问题比如某个函数在最坏情况下执行时间比预期长很多、某个任务的周期抖动很大。跑通了再改回正常值系统的时间余量就心里有数了。6. 一份可以贴在工位上的可靠性底线清单做硬件和嵌入式项目这么多年我把最核心的几条经验压成了一张清单。它不是规范是底线任何一条不满足产品上现场我都会睡不着项目最低要求为什么看门狗多任务心跳汇总后喂喂狗路径禁止阻塞主循环喂狗等于没喂复位源启动第一行读取并保存唯一的现场证据错过就没了参数存储双区 序号 CRC32写入不动有效区掉电变砖的唯一解阻塞调用全部带明确超时禁用无限等待一处永久等待能拖死整机故障记录至少留时间戳、故障码、复位源、任务 ID现场问题复现不了只能靠日志降级分级L0~L4 定义清楚升级立即、降级滞回避免行为不可预期安全状态不依赖故障部件三步内可达外部可观测故障时能不能保住设备就看这个复位循环计数超限进停机保护不死循环重启反复重启比停机更糟内存关键路径禁用动态分配栈留 30% 余量长时间运行的隐形杀手验证做过停喂、掉电、通信破坏、老化测试没注入过故障机制就是纸面上的我在实际项目里最深的一个体会是可靠性工作最反直觉的地方在于做得好的系统看不出做了什么。它不重启、不丢数据、不报错用户就觉得这是理所当然的。反过来只有当设备在现场反复重启、数据莫名其妙丢失的时候大家才会回头找原因而那时候往往已经错过了最好的修改时机。所以我现在的习惯是项目立项的时候就把这份清单过一遍把看门狗、参数双备份、故障日志、降级状态机这四件基础设施先搭起来再往上堆业务功能。前期多花的那几天换来的是整个开发周期里不用反复处理同一类现场问题。再补一个实用的小技巧把复位源、故障位图和降级等级的当前值映射到一串对外可读的寄存器或者一条状态报文里维护人员用一个简单的工具就能读出来。这样很多现场问题不用返厂、不用连调试器电话里让对方读一下状态码就能判断方向。我在一个批量部署的项目里加了这个功能之后售后问题的平均处理时间从三四天缩短到了半天以内收益比想象中大得多。