从看门狗到降级策略:嵌入式系统可靠性设计实战
2026/9/18 5:52:38 网站建设 项目流程

1. 先想清楚:能跑的代码和能扛三年的设备,差在哪里

看门狗这个词,几乎所有做过嵌入式的人都听过,但我见过太多项目把它当成一个"启动时初始化一下、主循环里喂一口"的形式化动作。真到了现场,设备在客户机柜里跑上半年,某天不声不响地死在那里,屏幕还亮着、指示灯还在闪,但业务逻辑彻底不动了——这时候你才发现,那个被你随手配置的看门狗,压根没起作用。这套东西的本质不是"加个定时器",而是一整套故障与降级的设计思路:怎么检测到系统已经不正常了,检测到之后怎么恢复,恢复不了的时候怎么保住最基本的功能,这就是工程可靠性的底线。

我把这篇文章写给三类人看:第一类是刚开始做产品级固件、还在拿开发板当玩具的同学,你需要知道你的代码离"产品"还有多远;第二类是被现场问题折磨过、半夜爬起来远程指导客户断电重启的工程师,你需要一套系统化的方法来收口;第三类是负责架构和技术评审的人,你需要知道哪些保护机制是必须有的、哪些是过度设计。全文我尽量不摆术语架子,能算式的地方给你算式,能上代码的地方给你代码,能踩过的坑我一定把它写出来。

先说一个我自己的判断标准:一个嵌入式项目从"能用"走到"可靠",中间隔的不是三五个bug,而是三层东西——检测层(能不能感知到自己出问题了)、恢复层(感知到之后能不能自己爬回来)、兜底层(爬不回来的时候,能不能保证设备处于一个安全且可控的状态)。看门狗只是检测层里的一个器件,很多人只做了这一层,然后指望它解决所有问题,结果就是复位风暴、数据丢失、现场状态永远查不清。下面我按这三层往下拆,顺带把成本账也算一算。

1.1 实验室的Demo和现场的三年设备,到底差在哪

实验室里验证一个功能,通常是这样:上电,跑通,数据对,OK,收工。整个过程可能就几分钟,最多几小时。这里的隐含假设是"环境稳定、电源干净、外设一直在线、没有电磁干扰、没人乱插拔线缆、温度恒定"。而现场把这些假设全部打破:电源可能来自一个杂牌开关电源,纹波大得能上示波器;电机启停时地线电位跳几十伏;通信线缆旁边就躺着一根变频器输出线;客户为了省事,把设备装在密闭铁柜里,夏天内部温度六七十度;网线或串口线拔了又插,插了又拔。这些因素单独看都不致命,叠在一起就是慢性病。

慢性病的特点是:它不会立刻让程序崩,而是让某个外设悄悄挂掉、某个变量被翻转、某块内存被覆写。程序还在跑,主循环还在转,喂狗照样喂,但功能已经废了。这就是我常说的"活着但已经死了"。看门狗在这类故障面前几乎无能为力,因为它的判断依据是"程序有没有在跑",而不是"程序有没有在干正事"。所以真正要解决的第一个问题,是把"活着"的定义从"能喂狗"提升到"业务任务都在正常推进"。这个思路的转变,是整套可靠性设计的地基。

另外一个巨大差异是时间尺度。实验室里跑一小时没出错,不代表跑一千小时没出错。假设某个边界条件在极端情况下每百万次调用触发一次,业务代码每秒调用一次,平均就是 11.6 天出一次问题。如果每十次触发才导致一次死机,那就是一百多天——正好落在"客户用了三个月开始投诉"这个区间里。很多所谓"偶发问题"根本不是玄学,是概率还没攒够。想清楚这一点,你就不会再用"我这儿跑了三天没问题"来给自己壮胆了。

1.2 检测、恢复、兜底:可靠性设计的三层结构

我把三层结构再讲细一点,因为它直接决定了后面所有代码怎么写。

检测层要做的事情是"发现异常"。手段包括:看门狗定时器、任务心跳监控、通信超时计数、CRC 校验、栈使用量检查、内存分配失败统计、电源电压监控、时钟失效检测、外设寄存器回读比对。注意这里有个原则:检测手段要覆盖不同类型的故障。用看门狗检测"程序跑飞"、用心跳检测"任务卡死"、用超时检测"外设挂掉"、用校验检测"数据被破坏",四者不可互相替代。

恢复层要做的是"尝试自愈"。比如 I2C 总线卡死之后发送 9 个时钟脉冲把它踢醒、通信失败之后按退避策略重试、内存分配失败之后释放缓存再试一次、任务超时之后重置该任务的状态机。恢复动作要可重复、有次数上限,不能出现"重试 forever"这种写法,否则系统会卡在一个死循环式的自愈里,比直接复位还糟。

兜底层要做的是"退到安全状态"。降级到只保留核心功能、关闭非必要外设、把关键参数写进带校验的存储、记录故障现场、然后主动复位并回滚到可用配置。这一层最容易被忽略,但它决定了设备出问题之后是"能自己回来继续干活"还是"需要人去现场断电"。做过现场维护的人都懂,一次上门成本有多高。

三层做完之后,还有一个工程上的现实约束:代码量、RAM、Flash 都是钱。一个 8 位 MCU、32K Flash、2K RAM 的项目,你没那么多空间做完整方案。这时候就要分级取舍,后面我会专门讲怎么做这个取舍。

1.3 加多少保护才算够:一份可落地的成本判断

我通常用一个很土的办法来决策:给每个保护机制估一个"失效概率降低值"和"实现成本",按性价比排。举几个典型例子。

机制实现成本能挡住的故障性价比
独立看门狗极低(十几行)程序跑飞、死循环极高,必做
复位原因记录极低(读寄存器)无法直接挡故障,但让排查提速数倍极高,必做
参数双备份 + CRCFlash 位翻转、掉电写坏高,建议做
任务级心跳任务卡死、优先级反转高,多任务系统必做
窗口看门狗程序跑得"太快"或"太慢"中,视场景
HardFault 现场快照空指针、非法访问高,Cortex-M 平台必做
MPU 内存保护中高越界写、野指针中,视芯片
A/B 分区回滚升级失败变砖高,有升级需求就必做
故障注入测试不挡故障,但能验证前八项真的有效高,容易被跳过

这张表我的建议是:前两行无条件做,第三到第六行有条件就做,第七第八行看产品形态,最后一行一定要排进测试计划。最后一行我单独说一句,很多团队做了看门狗、做了心跳,但从没验证过"如果我把这个任务卡死,系统到底会不会复位"。没验证过的保护机制,等于没有。

2. 看门狗到底在看什么:从喂狗这件事说起

看门狗的原理简单到可以一句话讲完:一个独立的递减计数器,程序必须周期性地把它重置到初值,如果程序没能及时重置,计数器减到零就产生复位。就这么简单。但恰恰因为简单,它的用法被滥用得最厉害。我见过在主循环里喂狗的、在定时器中断里喂狗的、在多个任务里各喂一口的、把喂狗语句写在while(1)里的、甚至有人在串口接收中断里喂狗导致"通信一忙就复位"。下面把几种看门狗讲清楚,再把喂狗的位置问题单独拎出来。

2.1 独立看门狗的时钟与超时怎么算

独立看门狗(IWDG)的特点是它用一个独立的低速时钟源,通常是内部 RC 振荡器,典型标称 32kHz 但实际范围可能在 17kHz 到 47kHz 之间。这意味着你的超时时间本身就有相当大的误差,不能把超时时间卡得太极限

大部分 MCU 的 IWDG 超时公式是:

Tout = (4 × 2^PR × (RLR + 1)) / F_lsi

其中 PR 是预分频系数(0 到 7,对应分频 4 到 256),RLR 是重装载值(通常 12 位,最大 4095),F_lsi 是低速时钟频率。举个实际计算的例子:假设 F_lsi 标称 32000Hz,我想得到 2.5 秒超时。

先试 PR = 4,则 2^PR = 16,分频系数 4 × 16 = 64。代入得 64 × (RLR + 1) / 32000 = 2.5,即 RLR + 1 = 2.5 × 32000 / 64 = 1250,RLR = 1249,在 4095 以内,可行。

如果我想做 10 秒的超时,用 PR = 4 算下来 RLR + 1 = 5000,超了 12 位范围。改用 PR = 5,分频系数 4 × 32 = 128,得 RLR + 1 = 10 × 32000 / 128 = 2500,RLR = 2499,可以。

代码里通常这么写:

/* 目标:IWDG 超时约 2.5s,LSI 按 32kHz 估算 */ void iwdg_init(void) { IWDG->KR = 0x5555; /* 解除写保护 */ IWDG->PR = 0x04; /* 预分频 64 */ IWDG->RLR = 1249; /* 重装载值 */ while (IWDG->SR != 0) { } /* 等待寄存器同步 */ IWDG->KR = 0xAAAA; /* 首次喂狗,装载初值 */ IWDG->KR = 0xCCCC; /* 启动看门狗 */ } void iwdg_feed(void) { IWDG->KR = 0xAAAA; }

这里有个必须提醒的点:一旦启动,IWDG 通常无法用软件关闭(除非复位),所以调试期间要小心,断点停下来超过超时时间就会立刻复位,把调试器连接打断。我一般的做法是在调试版本里把超时设得很长(比如 30 秒),发布版本再改回目标值,或者用编译宏区分。这个坑我踩过不止一次,尤其是单步调试一段初始化代码的时候,设备突然复位,还以为是代码逻辑有问题。

还有 LSI 的频率偏差问题。如果产品对超时精度有要求,就不能纯靠内部 RC,要么用外部晶振做校准,要么把超时窗口留出足够余量。比如你需要业务最坏情况 800ms 完成一轮循环,那就把看门狗设到 2 秒以上,而不是设到 1 秒然后天天出问题。

2.2 窗口看门狗:喂得太早也是错

如果说独立看门狗是"你必须在规定时间内喂狗",窗口看门狗(WWDG)就是"你必须在规定的时间窗口内喂狗",早喂也复位。它的设计初衷是检测那些"跑飞之后恰好落回主循环、把喂狗语句执行了"的故障——这类故障独立看门狗抓不到,因为程序确实喂了狗。

窗口看门狗的计数器从上往下减,当计数值降到窗口值以下时喂狗是合法的,但如果计数值还高于窗口值就去喂狗,就直接触发复位。也就是说,喂狗动作必须落在"计数器已降到窗口值"和"计数器降到 0x40(下界)之间"这个时间段里。这个窗口的宽度计算公式大致是:

T_window = 4096 × 2^WDGTB × (T[5:0] - W[6:0]) / F_pclk

其中 T[5:0] 是计数器的初始值,W[6:0] 是窗口值,WDGTB 是分频系数(0 到 3,分频 1 到 8)。举个实例:F_pclk = 36MHz,WDGTB = 3(分频 8),T 设为 0x3F(63),W 设为 0x30(48)。总超时是 4096 × 8 × 64 / 36e6 ≈ 58.3ms;允许喂狗的窗口宽度是 4096 × 8 × (63 - 48) / 36e6 ≈ 13.6ms。这意味着每次喂狗必须精确落在这 13.6ms 的窗口里,窗口之前的喂狗全部无效并触发复位。

窗口看门狗适合什么场景?我认为适合循环周期非常稳定的系统,比如高速采样、闭环控制。对于循环时间抖动很大的业务系统,用窗口看门狗会把自己折腾疯。而且它的时钟源通常来自系统总线时钟,一旦时钟配置被改错,看门狗行为也跟着变,这点要额外注意。

2.3 把喂狗从主循环里挪出来:任务心跳与仲裁

这是我最想强调的一节。在主循环里喂狗,等于没加看门狗。原因很直白:主循环只要还在转,哪怕里面所有业务都因为某个标志位卡住、所有状态机都停在错误分支、所有通信都超时失败了,喂狗语句照样每次都执行。看门狗完全感知不到"业务已经废了"。

正确的做法是建立任务级心跳机制。每个关键任务维护一个心跳计数器,任务每完成一轮有效工作就自增一次。再起一个独立的监控任务(或定时器中断里做轻量检查),周期性地检查所有心跳是否都在推进,只有全部推进才喂狗。任意一个任务卡住,心跳不再增长,监控逻辑就不再喂狗,看门狗超时复位——这样就实现了"业务级"的故障检测。

#define TASK_NUM 4 typedef struct { volatile uint32_t heartbeat; uint32_t last_seen; uint32_t timeout_ticks; /* 允许的最大停滞节拍数 */ const char *name; } task_monitor_t; static task_monitor_t g_tasks[TASK_NUM] = { { 0, 0, 30, "comm" }, { 0, 0, 20, "control" }, { 0, 0, 50, "storage" }, { 0, 0, 20, "display" }, }; /* 由各任务在自己的循环末尾调用 */ void task_beat(int idx) { g_tasks[idx].heartbeat++; } /* 监控任务:500ms 周期调用,全部任务健康才喂狗 */ void monitor_supervise(void) { int all_ok = 1; for (int i = 0; i < TASK_NUM; i++) { if (g_tasks[i].heartbeat != g_tasks[i].last_seen) { g_tasks[i].last_seen = g_tasks[i].heartbeat; } else { /* 这一轮没推进,累计停滞计数 */ g_tasks[i].stall_ticks++; if (g_tasks[i].stall_ticks > g_tasks[i].timeout_ticks) { all_ok = 0; log_fault(TASK_STALL, i); } } } if (all_ok) { iwdg_feed(); } /* 注意:不健康时不喂狗,让硬件看门狗复位 */ }

这里有几个细节要设计好。第一,心跳计数器的读写必须是原子的,32 位变量在 Cortex-M 上单次读写通常是原子的,但如果编译器优化或位域操作就要小心,必要时用关中断保护。第二,超时阈值要按任务的实际周期留足余量,比如一个任务正常 100ms 一轮,阈值设 500ms 到 1 秒比较合适,设太紧会误复位。第三,监控逻辑本身不能阻塞,最好放在定时器中断里做最轻量的检查,把日志等重操作放到后面。

还有一个更彻底的做法:不喂狗,只"喂能力"。也就是监控任务不直接操作 IWDG,而是通过一个"喂狗许可"标志,只有所有任务健康时才置位,由另一个最低优先级的空闲任务在标志置位时喂狗。这样即使监控任务本身挂了,看门狗也会因为没人喂而复位。这个设计我称之为"双保险喂狗",在多任务系统里非常值得做。

2.4 喂狗位置的三条铁律和几个真实翻车案例

关于喂狗位置,我总结成三条铁律:

铁律一:喂狗语句必须放在"所有关键任务都确认完成一轮"之后。也就是喂狗是结果,不是过程。把喂狗当成一个"证明我还健康"的动作,而不是"例行公事"。

铁律二:喂狗语句绝对不要放在任何中断里,除非你非常清楚那个中断的触发频率和执行时间。我见过把喂狗放在串口接收中断里的代码,平时通信稀疏没问题,结果客户用一条长报文连续灌数据,中断频繁触发、主循环被饿死,但喂狗一直在做,看门狗完全不作为。

铁律三:喂狗语句只能有一处。多处喂狗是灾难的根源,你永远不知道是哪一处把它喂活了。如果确实需要多个监控点,就做汇聚判断,最终只在一个地方调用喂狗函数。

翻车案例我举两个。第一个是某个网关设备,主循环里喂狗,同时一个大循环里的 for 语句处理 8K 字节的协议解析,某次客户下发的数据包异常大,解析循环耗时超过看门狗超时时间,设备复位。排查花了整整两天,因为代码逻辑完全正确——问题在于看门狗超时时间的设定没有考虑最坏执行路径。修复方法不是加长超时,而是把大循环拆成状态机,每轮处理一小块,中间主动让出控制权。

第二个案例更隐蔽。某个项目在启动阶段做 Flash 擦除,擦除时间随芯片状态波动,偶尔超过看门狗超时。硬件看门狗在启动时已经开启,结果就是"十次上电偶尔有一次直接复位"。这个问题只在冷启动时出现,热重启不复现,查了很久才定位到。修复方案是在长耗时操作前先喂一次狗,并且在擦除循环里分段喂狗——但严格来说,更好的方案是把启动阶段的长耗时操作放到看门狗启动之前,或者启动时用较长的超时配置

注意:擦写 Flash、等待外部器件上电稳定、执行自检等长耗时操作,都要在看门狗的时间预算里单独预留,不能按正常业务周期估算。

3. 保护机制的组合拳:光有看门狗远远不够

看门狗解决的是"程序不跑了"这一类故障,而工程现场的故障类型远比这个丰富。下面按硬件层、内存层、异常层、外设层四个维度展开。这四层做完,你大概率能覆盖八成以上的现场故障;剩下的两成属于玄学和电磁兼容,需要靠结构设计和屏蔽来兜。

3.1 硬件层:电源、时钟和复位的那些事

电源是最容易被低估的故障源。电压跌落、上电缓慢、纹波过大,都会导致 MCU 处于一个"半死不活"的状态——电压不够高,内核逻辑开始出错,但还没低到复位阈值以下。现代 MCU 一般都有欠压复位(BOR)功能,检测到电压低于阈值就强制复位,这个功能务必开启,并选择合适的阈值等级。有些芯片的 BOR 默认是关闭的,需要配置选项字节或者初始化寄存器,一旦漏了,设备在电源波动时就会表现得很诡异。

除了 BOR,还有可编程电压检测(PVD)。PVD 的好处是它可以在电压还没跌到复位阈值之前就产生中断,让你有机会提前保存关键数据、关闭外设、进入降级模式。举个例子,如果设备检测到电压已经跌到 2.7V 而正常是 3.3V,就可以立刻停止写 Flash、停止电机输出、把当前状态标志写入备份寄存器,然后等待可能到来的复位。这个提前量非常有用,因为电压掉到 BOR 阈值之后留给你的时间通常只有几十微秒。

时钟失效检测也很重要。如果系统用的是外部晶振,晶振可能因为焊接不良、温度异常或者机械振动停振。很多 MCU 提供时钟安全系统(CSS),一旦检测到外部时钟失效就自动切换到内部 RC 时钟并产生中断,你可以在中断里记录故障、切换降级运行模式。如果芯片不支持 CSS,至少要在启动后定期检查时钟相关的状态标志。

复位源寄存器是"零成本"的排查利器,很多团队从来没读过它。典型的复位源包括:上电复位、引脚复位、软件复位、独立看门狗复位、窗口看门狗复位、低功耗管理复位、欠压复位。启动时读一次并记录到非易失存储,下次维护时就能知道"上次这台设备到底是怎么重启的"。这个信息在排查现场问题时价值极高——是看门狗复位还是上电复位,排查方向完全不同。

typedef enum { RST_UNKNOWN = 0, RST_POWER_ON, RST_PIN, RST_SOFTWARE, RST_IWDG, RST_WWDG, RST_LOW_POWER, RST_BOR } reset_cause_t; reset_cause_t read_reset_cause(void) { uint32_t csr = RCC->CSR; reset_cause_t cause = RST_UNKNOWN; if (csr & RCC_CSR_PORRSTF) cause = RST_POWER_ON; else if (csr & RCC_CSR_PINRSTF) cause = RST_PIN; else if (csr & RCC_CSR_SFTRSTF) cause = RST_SOFTWARE; else if (csr & RCC_CSR_IWDGRSTF) cause = RST_IWDG; else if (csr & RCC_CSR_WWDGRSTF) cause = RST_WWDG; else if (csr & RCC_CSR_LPWRRSTF) cause = RST_LOW_POWER; else if (csr & RCC_CSR_BORRSTF) cause = RST_BOR; RCC->CSR |= RCC_CSR_RMVF; /* 清除标志,为下次记录做准备 */ return cause; }

这段代码的意图是:启动流程里第一件事就是读复位源并清除标志,然后才做其他初始化。清除标志这个动作一定要做,否则下次复位读到的还是旧标志。另外,如果设备有备份寄存器(Backup Register)或非易失存储,可以把复位原因存起来,配合时间戳形成一条简短的"重启历史"。

3.2 内存层:栈溢出、堆碎片和越界写

内存类故障是最难查的一类,因为它们往往不立刻发作,而是悄悄破坏数据,等到几十秒甚至几天后才在某个无关的地方表现出来。

栈溢出检测的常规做法是"金丝雀"填充:在启动时把栈空间填满一个特定模式(比如 0xA5),运行一段时间后检查栈底附近还有多少未被覆盖的字节,就能估算出栈使用的峰值。更好一点的做法是在任务切换时检查栈指针是否越界,一旦发现立刻记录并降级。

#define STACK_PATTERN 0xA5A5A5A5u /* 在任务创建时填充栈 */ void stack_fill(uint32_t *stack, uint32_t words) { for (uint32_t i = 0; i < words; i++) { stack[i] = STACK_PATTERN; } } /* 周期调用,返回剩余未使用字数的近似值 */ uint32_t stack_high_water(uint32_t *stack, uint32_t words) { uint32_t i = 0; while (i < words && stack[i] == STACK_PATTERN) { i++; } return words - i; /* 被用掉的字数 */ }

这个方法的局限是它只能告诉你"用到了多少",不能阻止溢出。要真正防住,需要 MPU 或栈保护区。Cortex-M 的 MPU 可以给栈底下一个区域设置成不可读写,一旦越界立刻触发 MemManage 异常,你就能在异常处理里拿到确切的任务和地址。这个配置稍微麻烦一点,但收益很大,尤其是跑 RTOS 的多任务系统。

堆碎片是另一个隐形杀手。频繁地 malloc 和 free 小块内存,运行几天后堆就碎成一地,最后明明还有几十 K 的空闲总量,却找不到一块连续的 2K 空间。我的建议是:嵌入式系统里尽量不用动态内存,能用静态分配就用静态。如果非要用,就用固定大小的内存池(memory pool),每个池只放一种大小的块,分配和释放都是 O(1),不会产生碎片。再退一步,如果必须用堆,至少要监控malloc失败次数和堆的空闲峰值,一旦连续失败就进入降级模式,关闭非核心功能。

越界写和野指针,靠代码审查很难根除,因为人不擅长盯着几千行找索引错误。实用的手段有三个:一是对所有从外部数据推导出来的索引做边界检查,尤其是协议解析;二是关键结构体加 magic 字段,周期性校验,一旦被破坏立刻发现;三是给关键数据区加 CRC,发现校验失败就用备份数据恢复。

3.3 异常层:HardFault 现场快照怎么抓

Cortex-M 平台上,空指针、非法地址访问、除零(取决于配置)、未对齐访问,最后都可能落到 HardFault。默认的 HardFault_Handler 往往是一个while(1),设备就死在那里了。这非常浪费——你完全可以在异常处理里把现场保存下来,然后主动复位,让设备快速恢复,同时留下排查线索。

关键点在于:进入异常时,CPU 会把一部分寄存器压栈(R0、R1、R2、R3、R12、LR、PC、xPSR),通过读取栈指针就能拿到这些值,尤其是 PC 能直接告诉你出错时程序执行到了哪个地址。判断用的是主栈还是进程栈,靠 LR 的值:传入的 LR 是 EXC_RETURN,其 bit2 为 0 表示异常前用的是主栈(MSP),为 1 表示进程栈(PSP)。

typedef struct { uint32_t r0, r1, r2, r3, r12, lr, pc, psr; } fault_frame_t; void hardfault_report(uint32_t *frame) { fault_frame_t *f = (fault_frame_t *)frame; /* 把关键信息写入非易失存储的故障区 */ fault_save(FAULT_HARDFAULT, f->pc, f->lr, f->psr); } __attribute__((naked)) void HardFault_Handler(void) { __asm volatile ( "tst lr, #4 \n" "ite eq \n" "mrseq r0, msp \n" "mrsne r0, psp \n" "b hardfault_report\n" ); }

拿到 PC 之后,用工具链的地址映射文件(比如 arm-none-eabi-addr2line 或者 IDE 的反汇编视图)就能定位到具体哪一行。这一步的价值在于:你不用再猜"大概哪里出问题了",而是直接看到出错的指令地址和函数。我做过好几个项目,靠这个方法在半小时内定位到别人查了两天的问题。

这里有个实操细节要注意:故障处理函数最好用__attribute__((naked))或者纯汇编写入口,避免编译器在函数序言里压栈操作破坏原始栈帧。另外,fault_save内部不要再调用可能触发异常的操作,尽量只做寄存器和简单整数的操作,写存储的动作可以留一个标志位,复位后在启动阶段再落地。

注意:抓到的 PC 值不一定精确指向出错指令,因为编译器插入的跳转和优化可能让地址落在相邻位置,用反汇编对照时要把前后几条指令一起看。

3.4 外设层:通信超时、总线卡死和 DMA 错误

外设层面的故障非常常见,因为外设是"外部世界"的接口,最容易受到干扰。

通信超时必须显式设计。我见过太多代码用while (!(USART->SR & USART_SR_RXNE));这种无超时等待,一旦对端不响应,程序就永远卡在这里。所有等待外部响应的循环都要加超时计数,超时之后记录错误、执行恢复动作、继续往下走。这个习惯要刻进骨子里。

I2C 总线卡死是经典问题:某个从机在应答位拉低 SDA 不放,主机永远等不到总线空闲。标准的恢复动作是手动切换 GPIO 模式,在 SCL 上发送 9 个时钟脉冲,让从机把剩余的位发完、释放 SDA,然后再切回 I2C 外设重新初始化。这段代码建议封装成一个函数,遇到 I2C 超时就调用一次。

void i2c_bus_recover(void) { /* 1. 关掉 I2C 外设,把引脚切成普通 GPIO */ I2C1->CR1 &= ~I2C_CR1_PE; gpio_config_od(SCL_PIN); gpio_config_od(SDA_PIN); /* 2. 如果 SDA 被拉低,在 SCL 上打 9 个脉冲 */ if (gpio_read(SDA_PIN) == 0) { for (int i = 0; i < 9; i++) { gpio_write(SCL_PIN, 0); delay_us(5); gpio_write(SCL_PIN, 1); delay_us(5); } } /* 3. 补一个停止条件 */ gpio_write(SDA_PIN, 0); delay_us(5); gpio_write(SCL_PIN, 1); delay_us(5); gpio_write(SDA_PIN, 1); delay_us(5); /* 4. 重新初始化 I2C 并恢复引脚复用 */ i2c_init(); }

这段代码看起来简单,但每一个延时都有必要——太快了从机反应不过来,太慢了恢复过程拖长。5 微秒是个比较通用的经验值,如果你的从机比较慢,可以调到 10 微秒。

DMA 错误也要处理。DMA 传输错误、传输完成但长度不符、缓冲区被覆盖,这些都会导致数据错乱。至少要开启 DMA 的错误中断,并且在传输完成后校验接收长度和 CRC。对于环形缓冲的 DMA 接收,还要处理"半满"和"全满"两种中断,并且计算好读指针和写指针的关系,防止覆盖未处理的数据。

4. 降级策略:从"全功能"退到"能干活"

前面讲的是检测和恢复,这一节讲兜底。降级策略的核心思想是:当系统无法维持全部功能时,不要死撑,也不要直接停摆,而是有序地关闭次要功能,保住核心功能继续运行。这个思路听起来简单,落地的时候需要设计故障分级和一套状态机。

4.1 故障分级与降级矩阵

我一般把故障按严重程度分四级,每一级对应不同的处理动作。

等级名称典型场景处理动作
L1轻微偶发通信重试成功、单次校验失败后恢复记录计数,不做功能调整
L2一般某外设反复超时、某任务偶发超时重置该外设或任务,关闭相关非核心功能
L3严重关键外设失效、关键任务持续卡死、内存告警进入受限模式,只保留核心功能并上报
L4致命无法恢复、保护机制连续触发保存现场,主动复位,必要时回滚配置

分级的关键是要有升级和降级路径。不能只升不降,否则设备在经历一次 L2 故障之后就永远处于受限状态了,哪怕故障早已消失。我通常用一个"健康度"计数器来做:每次检测到故障加权重,每次正常周期减权重,权重低于阈值就自动恢复到上一个等级。

typedef enum { MODE_NORMAL = 0, MODE_LIMITED, /* 关闭显示、日志、非核心通信 */ MODE_MINIMAL, /* 只保留核心控制 + 报警 */ MODE_SAFE /* 停止输出,等待复位或人工介入 */ } run_mode_t; static int health_score = 100; /* 0 ~ 100 */ void health_update(int fault_weight, int recover) { if (fault_weight > 0) { health_score -= fault_weight; if (health_score < 0) health_score = 0; } if (recover) { health_score += 1; if (health_score > 100) health_score = 100; } } run_mode_t decide_mode(int score) { if (score >= 80) return MODE_NORMAL; if (score >= 50) return MODE_LIMITED; if (score >= 20) return MODE_MINIMAL; return MODE_SAFE; }

这段代码的意图是让降级变成渐进和可逆的。通信偶尔超时,健康度掉几个点,但很快恢复;只有连续大量故障才会真正退到最低模式。这样避免了"一次抖动就关机"的激进策略,也避免了"永远不降级"的放任策略。

4.2 用状态机实现降级切换

降级动作本身必须是有序的,不能"半路切换"。比如设备正在控制一个电机,你不能在运行中直接把控制模块停掉,而要先把输出降到安全值,再切换模式。这就需要一个明确的状态机。

我一般的做法是定义几个状态:RUNPREPARE_DEGRADE(准备降级,执行安全动作)、DEGRADED(受限运行)、RECOVERING(尝试恢复)、SAFE_STOP(安全停止)。状态迁移由故障事件和健康度共同驱动。

typedef enum { ST_RUN, ST_PREPARE_DEGRADE, ST_DEGRADED, ST_RECOVERING, ST_SAFE_STOP } sys_state_t; static sys_state_t state = ST_RUN; void state_machine_tick(void) { run_mode_t mode = decide_mode(health_score); switch (state) { case ST_RUN: if (mode != MODE_NORMAL) { state = ST_PREPARE_DEGRADE; } break; case ST_PREPARE_DEGRADE: /* 执行安全动作:输出归零、保存参数、关闭外设 */ output_to_safe_level(); save_critical_params(); state = ST_DEGRADED; break; case ST_DEGRADED: if (mode == MODE_NORMAL) { state = ST_RECOVERING; } else if (mode == MODE_SAFE) { state = ST_SAFE_STOP; } break; case ST_RECOVERING: /* 逐步恢复外设,每恢复一个观察一段时间 */ if (recover_one_peripheral()) { state = ST_RUN; } else { state = ST_DEGRADED; } break; case ST_SAFE_STOP: /* 保持安全输出,等待复位或人工处理 */ break; } }

这个状态机有两点值得说。第一,PREPARE_DEGRADE阶段必须有,它是"安全落地"的关键;很多团队直接在检测到故障的那一帧把功能关掉,结果输出停在一个非安全值上,可能造成机械损伤或者人身风险。第二,RECOVERING阶段要一个一个地恢复外设而不是一次性全开,每恢复一个观察一段时间,如果又出问题立刻退回去。这种"慢恢复"策略能避免系统在边界条件下反复震荡。

4.3 掉电保护和带校验的参数存储

参数被写坏是现场最常见的数据类故障之一,尤其在"正在写 Flash 的时候突然断电"这个场景下。解决办法是经典的双备份 + CRC 校验:把参数存两份,每份都带一个 CRC 和一个版本号。读取时优先读版本号高、CRC 正确的那份;如果两份都坏,就用出厂默认值并置一个"参数已重置"的标志。

#define PARAM_MAGIC 0x50415241u /* "PARA" */ typedef struct { uint32_t magic; uint32_t version; uint32_t crc; uint8_t data[PARAM_DATA_LEN]; } param_block_t; static param_block_t blk_a; static param_block_t blk_b; static uint32_t use_slot = 0; int param_save(const uint8_t *data, uint32_t len) { param_block_t blk; blk.magic = PARAM_MAGIC; blk.version = next_version(); memcpy(blk.data, data, len); blk.crc = crc32((uint8_t *)&blk, sizeof(blk) - sizeof(uint32_t)); /* 写到另一份,交替使用,避免写坏当前有效数据 */ uint32_t target = use_slot ? 0 : 1; if (flash_program(target, &blk, sizeof(blk)) != 0) { return -1; } if (flash_read(target, &blk, sizeof(blk)) != 0) { return -1; } if (blk.crc != crc32((uint8_t *)&blk, sizeof(blk) - sizeof(uint32_t))) { return -1; /* 回读校验失败,保留原有效数据 */ } use_slot = target; return 0; }

这段逻辑的关键是先写备份区、回读校验通过之后才切换使用区。这样任何时刻掉电,至少有一份数据是完好的。另外要注意 Flash 的写入粒度,很多芯片要求按页擦除、按字写入,写之前必须先擦掉整页,所以双备份最好分在两个不同的页里,避免擦一页把另一份也弄没了。

注意:不要在主循环里频繁写 Flash,擦写寿命通常是十万次量级,频繁写会提前耗完。参数变化时打个标记,在系统空闲或者进入低功耗前统一落地。

4.4 升级失败怎么回滚:A/B 分区的实操要点

如果设备支持远程升级,回滚机制是必须的,否则一次失败的升级就意味着要么返厂,要么上门。A/B 分区的思路是:Flash 里存两份固件,一份是当前运行(A),一份是待升级(B)。升级流程是:把新固件下到 B,校验完整性和签名,设置一个"启动到 B"的标志,重启。B 启动后如果自检通过就确认升级,把标志改成"启动到 A/B 交替";如果自检失败或者第一次启动后一段时间内没能确认,bootloader 就认为 B 有问题,自动回滚到 A。

这里有几个实操要点。第一,新固件的完整性校验必须做,CRC 不够,最好用哈希或者签名,防止传输中途损坏或者被篡改。第二,升级标志要有"尝试次数",比如允许尝试启动 B 三次,三次都失败就永久回滚,避免无限重启循环。第三,A 分区在升级期间不能被破坏,最好带写保护。第四,确认升级的时机要选在"真正证明它能工作"之后,比如核心功能自检通过、通信正常建立、跑满一分钟,而不是刚进 main 函数就确认。

typedef struct { uint32_t magic; uint32_t active_slot; /* 0: A, 1: B */ uint32_t try_count; uint32_t max_try; uint32_t confirmed; } boot_ctrl_t; void bootloader_decide(void) { boot_ctrl_t ctl; boot_ctrl_read(&ctl); if (ctl.magic != BOOT_MAGIC) { /* 首次上电或控制块损坏,默认从 A 启动 */ boot_from(SLOT_A); return; } if (!ctl.confirmed && ctl.try_count < ctl.max_try) { ctl.try_count++; boot_ctrl_write(&ctl); boot_from(ctl.active_slot); /* 尝试启动新固件 */ } else if (!ctl.confirmed) { /* 尝试次数用尽,回滚 */ ctl.active_slot = 0; ctl.try_count = 0; boot_ctrl_write(&ctl); boot_from(SLOT_A); } else { boot_from(ctl.active_slot); } }

这段代码在真实的 bootloader 里会复杂一些,因为要考虑控制块的读写安全(同样需要双备份和 CRC)、要考虑中断向量表的偏移、要考虑固件头部的格式,但主干思路就是上面这些。我在做这类方案时的一条经验是:bootloader 本身要尽可能简单,简单到一眼能看完、没有动态分配、没有复杂逻辑,因为它是最后一道防线,它出事就没救了。

5. 把现场留下来:复位原因、故障日志和上报

排查现场问题的效率,很大程度上取决于"设备出事的时候留下了多少信息"。我见过很多项目,设备复位之后什么痕迹都没有,只能靠猜、靠复现、靠运气。这一节讲怎么把现场尽量留下来,同时不引入新的风险。

5.1 环状日志与 Flash 磨损的平衡

日志不能无限制地写,Flash 也不是随便能写的。思路是用一块固定大小的日志区做环状缓冲:每条日志带序号、时间戳(可以是相对心跳)、故障码和少量参数,写满之后从头覆盖最旧的一条。这样日志空间是固定的,不需要动态管理。

考虑到 Flash 的擦写寿命,有几个技巧可以延长使用时间。一是日志按扇区组织,一个扇区写满再擦下一个扇区,轮流使用若干扇区,而不是每次写都擦整个区域。二是日志写入频率要控制,正常运行不写,只在故障和状态变化时写,避免把 Flash 写废。三是如果芯片支持,可以把日志放到 FRAM 或带 EEPROM 仿真的区域,写入次数更多,代价也更低。

typedef struct { uint16_t seq; uint16_t code; uint32_t tick; uint32_t arg0; uint32_t arg1; } log_entry_t; #define LOG_SLOTS_PER_SECTOR 32 void log_fault(uint16_t code, uint32_t a0, uint32_t a1) { static uint16_t seq = 0; log_entry_t e; e.seq = ++seq; e.code = code; e.tick = get_tick_ms(); e.arg0 = a0; e.arg1 = a1; /* 追加写入当前扇区的下一个空位 */ log_append(&e); }

日志里最值得记录的几类信息:复位原因、故障码和发生时间、任务卡死时是哪个任务、通信超时的对象和次数、内存告警(栈峰值、malloc 失败次数)、电压异常事件。这些信息组合起来,往往能直接指向问题根源。

5.2 故障上报:别让上报本身成为故障源

设备发现了故障,通常还需要上报给后台或者上位机。这里有个反直觉的坑:上报过程本身可能拖垮系统。比如网络不通,上报代码一直在重试,占用了大量时间导致看门狗复位;或者上报数据量太大,把内存耗光。所以我做上报功能时坚持几条原则。

第一,上报必须是异步的、非阻塞的。放到独立任务里,用队列传递事件,主业务只负责把事件入队。

第二,上报要有次数上限和退避策略。第一次失败等 1 秒重试,第二次等 2 秒,第三次等 4 秒,最多重试若干次就放弃,把事件留在本地日志里等着下次有网络时批量补传。

第三,上报失败绝不影响核心功能。如果网络模块挂了,设备应该继续正常工作,只是少了远程监控能力而已。

第四,上报内容要精简。不要把整个状态机快照上报上去,只报故障码和关键参数,需要详细信息时再单独拉取。

5.3 故障现场快照的取舍

理想情况下,每次故障都能保存一份完整的现场快照:所有全局变量的值、任务状态、栈内容、外设寄存器。但这在实际中往往做不到,因为快照本身占空间、耗时,而且可能在保存过程中再次出问题。

我的建议是分级快照。轻量级快照只存几十字节:故障码、出错 PC、栈指针、当前状态机的状态、健康度。这是默认开启的,成本极低。重量级快照存几百字节到几 K:关键结构体、任务栈峰值、最近若干条日志,只在严重故障时触发。超重量级快照基本不建议做,因为收益不高,风险不小。

选择存什么的时候,问自己一个问题:如果只能看三个变量来定位这个问题,我会选哪三个?通常答案是"当前状态""出错位置""关键输入"。把这三个存下来,八成问题能定位方向。

6. 实测踩过的坑与排查速查

前面讲的是设计和实现,这一节讲实际调试过程中会遇到什么。我把常见问题和排查思路整理成表格,再补充几个真实案例。

6.1 常见问题速查表

现象可能原因排查方向
设备周期性复位,间隔固定看门狗超时、长耗时操作超预算读复位源寄存器,测量最长循环耗时
复位间隔随机电压波动、干扰、偶发死锁抓电源波形,加 PVD,检查地线
看门狗不生效喂狗放在中断或主循环、多处喂狗搜索所有喂狗调用点,改成任务级心跳
复位后参数丢失写 Flash 时掉电、无备份引入双备份 + CRC
升级后设备不启动新固件校验缺失、无回滚加完整性校验和 try_count 回滚
现场设备卡死但指示灯正常任务卡死、外设挂掉、喂狗照常任务心跳未做,补上任务级监控
HardFault 后死机未实现异常处理加现场快照并主动复位
通信偶发失败总线干扰、从机未复位、无超时加超时、加重试、加总线恢复

这张表里最值得强调的是最后两行。通信偶发失败,很多人的第一反应是"硬件问题",但软件层面完全可以通过超时、重试、总线恢复把大部分偶发失败消化掉。做好这三件事,能显著降低现场投诉量。

6.2 看门狗误复位的排查思路

看门狗误复位是最让人头疼的问题之一,因为它往往复现不了。我的排查路径是这样的。

第一步,确认真的是看门狗复位。读复位源寄存器,确认是 IWDG 还是 WWDG,还是其他原因。这一步能排除一半的方向性错误。

第二步,测量最坏循环耗时。在喂狗前后各翻转一个 GPIO,用示波器看高电平间隔的最大值。也可以在每个任务入口和出口打时间戳,把每个任务的最长执行时间记录下来,看有没有超过预期。我一般会在开发阶段加一个"最大执行时间"统计变量,正常运行不打印,出现异常时通过日志输出。

第三步,检查喂狗位置的逻辑。是不是有某个分支跳过了喂狗?是不是某个中断把主循环饿死了?是不是有长耗时的阻塞调用(比如等待 Flash 写完成、等待外部 ADC 转换)?这些都要逐一确认。

第四步,检查超时时间的设置是否有余量。可能的错误是把超时设得太紧,正常情况下刚好够,稍微有点抖动就超。一般的经验是,超时时间至少是正常循环时间的三到五倍

第五步,检查低功耗模式的影响。有些看门狗在低功耗模式下时钟行为会发生变化,超时时间跟着变,如果没考虑这一点就会在进入低功耗后意外复位。

6.3 主动把系统搞坏:故障注入测试怎么做

这是我最想推荐但最常被跳过的一步。保护机制写完不等于有效,必须验证。故障注入测试就是主动制造各种故障,看系统是不是按设计响应。下面这些测试项,我建议至少做一遍。

看门狗测试:在某个任务里加一个"卡死开关",触发之后该任务进入死循环不喂狗,观察系统是否在预期时间内复位,并检查复位源记录是否正确。这个测试能同时验证心跳机制和看门狗联动。

栈溢出测试:故意写一个深度递归函数,或者在任务里分配一个超大局部数组,看 Golden 检查是否报警、MPU 是否触发、系统是否降级。

HardFault 测试:故意访问非法地址,验证异常处理是否能抓到 PC 并复位,验证故障日志是否写入成功。

通信断线测试:拔掉通信线缆,观察超时计数、重试逻辑、降级策略是否符合预期。恢复线缆后,观察系统是否能自动恢复到正常模式。

掉电测试:在参数写入的过程中随机断电,反复多次,验证参数存储的双备份机制是否有效,有没有出现两份都坏的情况。

升级失败测试:故意上传一份损坏的固件,验证校验是否拦截;故意上传一份能通过校验但功能异常(比如自检不通过)的固件,验证回滚是否生效。

做完这些测试,你对自己代码的信心会完全不同。我个人的经验是,第一次做故障注入测试,几乎必然会发现至少一个保护机制是失效的——可能是喂狗位置不对,可能是异常处理没生效,可能是回滚没有触发。这些如果不主动测,就要等现场来教你。

注意:故障注入代码不要留在发布版本里,用编译宏严格隔离,或者只在专门的测试固件中启用。

7. 一些没那么技术但更重要的体会

写到这里,技术上的东西基本讲完了。最后说几点这些年做工程下来,觉得比具体代码更重要的体会。

一个是保护机制要有"可观测性"。你做了很多保护,但如果它们什么时候触发、触发了几次,你完全不知道,那这些机制的价值就打了对折。每一次保护动作都应该是可见的、被记录的,最好还能被统计。我在项目里习惯做一个"保护事件计数器",按类型统计触发次数,设备运行一年后把这个数读出来,就知道哪些保护在真正起作用、哪些从来没触发过。从来没触发过的机制不一定没用,但至少说明当前场景下不是主要矛盾;频繁触发的机制说明系统在那个方向上有系统性缺陷,需要从根上改。

第二个是别指望一层保护解决所有问题。看门狗、心跳、超时、校验、降级,每一层都有自己的覆盖范围,也都有自己的盲区。看门狗挡不住"活着但已经死了",心跳挡不住"内存被慢慢腐蚀",超时挡不住"数据算错了"。真正的可靠性来自多层叠加和互相印证,而不是某一层做得特别完美。

第三个是保护的代价要算清楚。我见过一个项目,为了追求极致可靠,加了七八层保护,结果每次正常操作都要跑一堆校验和状态检查,响应时间从 20ms 涨到 200ms,客户直接投诉"设备变卡了"。保护机制本身也是代码,也有执行时间和资源消耗,必须放在整个系统的时间预算里一起算。合理的做法是关键路径上做轻量校验,非关键路径上做重量校验,把开销分摊到不同的时间片里

第四个是关于测试心态的。保护机制的测试,本质上是主动去证伪自己的设计。这和正常的功能测试心态完全相反——功能测试是"我要证明它能工作",可靠性测试是"我要证明它在坏的情况下会怎样"。这两种心态切换不过来的人,做不好可靠性。我自己的习惯是,每写完一个保护机制,先不看它成功的表现,而是先想办法让它触发一次,看它触发得对不对。触发对了,才算写完了。

最后一个体会是关于文档的。保护机制这种东西,代码里往往只有几行,但背后的设计意图、触发条件、降级路径、恢复方式,不写文档根本没人看得懂,包括半年后的你自己。我建议每个项目都维护一份简短的"可靠性设计说明",把这几件事写清楚:有哪些保护机制、各自的触发条件、触发后系统进入什么状态、怎么恢复、怎么验证。这份文档不需要长,一两页就够,但它能让你在半夜被叫起来处理现场问题的时候,快速回忆起系统的设计意图,而不是从头读代码。

嵌入式这个方向做久了会发现,功能和可靠性是两套完全不同的能力。功能做出来靠的是对业务和硬件的理解,可靠性做出来靠的是对失败模式的想象力和纪律性。看到别人代码里那些"多余"的超时判断、"啰嗦"的状态检查、"浪费"的备份存储,不要急着删掉,先想想他是不是踩过你没踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询