1. 先把"故障与降级"这件事摆到台面上
做嵌入式开发时间长了,你会发现一个规律:功能代码写得再花哨,真正决定项目能不能交付、能不能在现场活过三年的,往往是那几百行看起来最"无聊"的代码——看门狗、复位原因判别、故障计数、降级策略、掉电保存。我刚入行的时候也觉得这些东西是"兜底用的,出问题再说",直到有一批设备装在没人值守的机房里,半夜三点集体挂死,第二天运维打电话过来的时候,我才真正理解工程可靠性这四个字的分量。
这篇内容想聊的就是这件事:嵌入式系统里的故障检测与降级设计到底该怎么做,看门狗应该怎么配、怎么喂,保护机制要分几层,降级策略怎么落地到代码里。我不打算只讲概念,会拿一个具体的东西当靶子——一个环境监控终端,采集温湿度、气体浓度,通过以太网或无线模块上报,本地做数据存储,还能驱动继电器做联动控制。这类设备典型特点是:长期无人值守、供电环境一般、通信链路不稳定、出故障必须自己扛过去。我会把这个终端从"能跑"改造成"扛造"的完整过程拆开讲,包括超时参数怎么算、喂狗位置为什么不能放中断里、故障状态机怎么写、掉电数据怎么保证不丢。
如果你正在做类似的东西,或者手上有个项目已经出现过"死机需要重新上电"的情况,那这篇内容应该能帮上忙。刚入门的朋友也不用担心,我会把每个机制背后的"为什么"讲清楚,而不是直接甩一堆寄存器配置。
提示:下文涉及的所有代码都是工程骨架级别的示例,寄存器名和时钟频率按常见主流 MCU 的习惯写法给出,实际移植时需要对照你所用芯片的参考手册核对。参数计算过程我会完整列出来,方便你换成自己的时钟频率重新算一遍。
1.1 实验室里的板子和现场的设备根本不是一回事
先说清楚一个认知差。在实验室里,你的板子放在桌面上,电源是稳压电源或者电脑 USB,环境温度二十几度,通信线是半米长的杜邦线,你在旁边盯着调试器看变量。这种条件下跑通的功能,只能证明逻辑是通的,不能证明它能活。
到了现场,情况完全变了。开关电源的输出纹波可能比你测的时候大好几倍,电网波动的时候 12V 掉到 9V 都有可能;机箱内的温度夏天能干到六十度以上,芯片的时序余量被压缩;通信线可能要走几十米,旁边还有变频器、接触器这种强干扰源;最要命的是没人看着它,出问题不会有人按复位键。
我遇到过最典型的一类故障是"假死":程序主循环卡在某个while等待里,比如等一个永远不来的通信响应,或者等一个 I2C 总线上被拉低的引脚恢复高电平。这时候 CPU 还在跑,看门狗如果喂在定时器中断里,照样被喂得饱饱的,系统就这么静静地卡死了。所以"故障"这个词在嵌入式里其实包含很多种形态:程序跑飞、死循环、任务饿死、外设异常、电源异常、数据损坏、通信中断。每一种的检测手段和恢复手段都不一样,指望一个看门狗解决全部问题,是不现实的。
1.2 检测、隔离、恢复、降级:四步走的基本盘
我在做可靠性设计的时候,习惯把整个体系拆成四个动作,顺序不能乱。
第一步是检测。你得先知道出事了。检测手段包括:看门狗超时、任务心跳缺失、通信应答超时、传感器数据超出物理合理范围、CRC 校验失败、栈魔数被改写、电源电压低于阈值、复位原因寄存器显示异常复位等等。检测的核心要求是"快"和"准",太慢了故障已经造成后果,误报太多则会让系统频繁做无谓的降级。
第二步是隔离。故障源要能被切断,不能让它继续污染其他模块。比如某个传感器挂在 I2C 总线上把总线拉死了,你得有能力把这条总线的驱动复位甚至断电重上电,而不是让整个系统陪着它一起挂。再比如某个任务陷入死循环,通过 RTOS 的任务删除或者强制复位该任务,把影响范围限制住。
第三步是恢复。恢复分软硬两级。软恢复是重新初始化外设、重建通信连接、清空缓冲区;硬恢复就是整机复位重启。能用软恢复解决的,尽量别整机重启,因为重启期间设备是不可用的,而且重启会丢掉运行时状态。
第四步是降级。当故障没法完全恢复,或者恢复需要较长时间时,系统要能带着缺陷继续提供核心功能。这就是降级的意义:从"全部功能正常"退到"核心功能可用",而不是直接躺平。降级是有层级的,这个后面单独开一节讲。
这四步走下来,你会发现它其实是一个闭环:降级运行期间还在持续检测,如果故障消失了,系统应该自己爬回正常状态。这个"爬回来"的过程比"掉下去"更容易出问题,因为降级期间的状态是残缺的,恢复时的初始化顺序、数据一致性都很容易踩坑。我后面会专门讲去抖和迟滞怎么设计。
1.3 几个我踩过的认知坑
第一个坑是"加了看门狗就万事大吉"。看门狗只能检测"CPU 完全没在执行你期望的代码路径"这一种情况,它检测不了逻辑错误、检测不了数据错误、检测不了某个任务被饿死但其他任务还在跑。把看门狗当成唯一防线的项目,出故障的时候往往连日志都留不下来。
第二个坑是"降级就是关几个功能"。降级之后系统的资源占用、任务优先级、超时参数都得跟着变。举个例子,正常模式下你开了五个任务,降级到最小系统只留两个任务,那看门狗的超时时间要不要调整?喂狗点还对不对?这些不重新梳理,降级本身就会引入新故障。
第三个坑是"故障日志存在 RAM 里,复位就没了"。这个坑我吃了不止一次。设备半夜复位,第二天去看,什么线索都没有,只能靠猜。后来我强制要求所有能反映故障原因的信息,要么写进带备份电池的寄存器区,要么写进 EEPROM 的循环日志区,复位之后第一件事就是把上一次的复位原因读出来上报。
第四个坑,也是最隐蔽的:测试阶段没有人为制造故障。很多人测可靠性就是"让它跑一晚上看会不会死",这不叫测试。真正的做法是主动注入:拔网线、短暂断电、把传感器数据线短路、把栈空间故意写溢出、把某个任务人为挂起。只有故障被主动注入过并且系统正确响应了,你才敢说这套机制是有效的。
2. 看门狗到底该怎么配,怎么喂
看门狗是嵌入式可靠性的第一道也是最硬的一道防线,但它的使用姿势差别非常大。我见过把喂狗放在 1ms 定时器中断里的,也见过超时时间设成 8 秒结果故障响应慢得离谱的。这一节把原理、参数计算、喂狗策略讲透。
2.1 独立看门狗、窗口看门狗、外部看门狗的区别
先建立一个分类概念。从实现位置分,看门狗有芯片内部的和芯片外部的;从工作时钟源分,有独立的(用内部低速 RC 振荡器)和依附于总线时钟的。
**独立看门狗(IWDG)**用的是芯片内部独立的低速时钟,通常是几十 kHz 的 RC 振荡器。它的最大优点是不依赖主时钟,就算你的外部晶振停了、PLL 失锁了,它照样在数数。缺点是 RC 振荡器精度差,同样的配置在不同芯片、不同温度下超时时间可能差出三四十个百分点。所以用 IWDG 的时候,超时时间的余量必须留足。
**窗口看门狗(WWDG)**挂在 APB 总线上,时钟来自系统时钟,精度高,但它有个特性:喂狗必须在"窗口期"内完成,喂太早也会触发复位。这个特性专门用来抓"程序跑得太快"这类异常,比如某个循环本该执行 100ms,结果因为指针错误只执行了 1ms 就回来喂狗了。窗口看门狗能抓到这类节奏异常,独立看门狗抓不到。
外部看门狗芯片是独立于 MCU 存在的,通过一个 GPIO 发脉冲喂狗,超时后直接拉 MCU 的复位引脚。它的价值在于:MCU 完全死透、内部看门狗模块也没工作的时候,外部芯片仍然能把它拉回来。而且很多外部看门狗芯片还集成了电源监测功能,电压跌到阈值以下就输出复位,一举两得。
| 类型 | 时钟源 | 精度 | 能否抓"跑太快" | 独立性 | 典型场景 |
|---|---|---|---|---|---|
| 独立看门狗 IWDG | 内部低速 RC | 差(±30%以上) | 否 | 高,不依赖主时钟 | 通用兜底,必配 |
| 窗口看门狗 WWDG | APB 总线时钟 | 好 | 能 | 中,依赖主时钟 | 对执行节奏有严格要求的控制环 |
| 外部看门狗芯片 | 自身 RC | 中 | 一般 | 最高,MCU 完全死也能复位 | 无人值守、安全相关的设备 |
我的常规做法是:内部独立看门狗必开,作为最后兜底;关键控制设备再加一颗外部看门狗芯片,两级串联。窗口看门狗只在确实需要严格节奏监控的场合用,因为它配置麻烦,而且调试阶段容易自己把自己复位。
2.2 超时时间怎么算,给一个能直接抄的计算过程
拿常见的独立看门狗举例,超时时间的计算式是:
Tout = (预分频系数 × (重装载值 + 1)) / F_lsi其中F_lsi是内部低速时钟频率,多数芯片标称 32kHz,但手册里通常会注明实际范围可能是 17kHz 到 47kHz。预分频系数常见可选 4、8、16、32、64、128、256,重装载值是 12 位,最大 4095。
假设我要一个 2 秒左右的超时,用标称 32kHz 算:
- 取预分频 64,则
Tout = 64 × (RLR + 1) / 32000 - 令
Tout ≈ 2s,得RLR + 1 = 2 × 32000 / 64 = 1000,即RLR = 999
但这里有个关键动作不能省:按最坏情况复核。如果实际F_lsi是 47kHz,那么Tout = 64 × 1000 / 47000 ≈ 1.36s,比目标短了三分之一;如果实际只有 17kHz,则Tout = 64 × 1000 / 17000 ≈ 3.76s,接近目标的近两倍。
所以真正定参数的时候,要看你的需求落在哪一侧:
- 如果你要求"故障后必须尽快复位",那就按
F_lsi最大值算,保证超时不超过上限; - 如果你要求"绝对不能误复位",那就按
F_lsi最小值算,保证最短时间也够用。
大多数场合是后者,因为误复位比晚复位几个小时更糟。我一般会让实际最短超时时间是自己喂狗周期的 3 到 5 倍。比如主循环 100ms 跑一圈、在循环里喂狗,那最短超时至少要 300ms 以上,我通常配到 1 秒左右,留足余量。
2.3 喂狗位置:为什么放在中断里等于没放
这是最需要反复强调的一点。看门狗的本质是"监督 CPU 是否在按预期执行主流程"。如果你把喂狗放在周期性定时器中断里,那么只要中断还能进,看门狗就一直被喂,主循环卡死根本检测不出来。这就是前面说的"假死"场景。
正确的喂狗位置应该满足两个条件:一是它位于主流程必须经过的路径上;二是喂狗动作本身有前置条件判断。
第一点在裸机程序里比较好实现,在主循环末尾喂就行。但在 RTOS 环境下就麻烦了,因为主循环被拆成了多个任务,没有一个统一的"主流程"。这时候必须上任务级看门狗,也就是软件看门狗。
做法是这样:给每个关键任务分配一个 bit 位,任务每完成一轮自己的核心工作,就把自己对应的 bit 置上。一个专门的监控任务(或者定时器回调)周期性检查这个位图,只有所有关键任务的 bit 都置齐了,才去喂硬件看门狗,然后把位图清零。任何一个任务卡住,它的 bit 就置不上,几轮之后监控任务判定异常,不喂狗,硬件看门狗超时复位。
/* 任务心跳位图,每个关键任务占一位 */ #define HB_BIT_SENSOR (1U << 0) #define HB_BIT_COMM (1U << 1) #define HB_BIT_STORAGE (1U << 2) #define HB_BIT_CONTROL (1U << 3) #define HB_BIT_ALL (HB_BIT_SENSOR | HB_BIT_COMM | \ HB_BIT_STORAGE | HB_BIT_CONTROL) static volatile uint32_t g_hb_bitmap = 0; /* 任务在自己的循环末尾调用 */ void hb_report(uint32_t bit) { /* 使用原子操作,避免多任务竞争 */ __disable_irq(); g_hb_bitmap |= bit; __enable_irq(); } /* 监控任务:每 50ms 调用一次 */ void hb_check_and_feed(void) { static uint8_t miss_cnt = 0; if ((g_hb_bitmap & HB_BIT_ALL) == HB_BIT_ALL) { miss_cnt = 0; g_hb_bitmap = 0; wdt_feed(); /* 只有全部到齐才喂狗 */ } else { miss_cnt++; /* 记录是哪个任务没到,便于排查 */ uint32_t missing = HB_BIT_ALL & (~g_hb_bitmap); fault_report(FAULT_TASK_HANG, missing); if (miss_cnt >= 4) { /* 200ms 都没补齐,直接不喂,等复位 */ /* 复位前尽量把现场存下来 */ fault_save_to_backup(); /* 什么都不做,让硬件看门狗超时 */ } } }这段代码里有个细节值得说:在判定异常到真正复位之间,留了一个短暂的窗口用于保存现场。这几十毫秒到几百毫秒非常值钱,把复位原因、缺失的任务位、当前的关键变量写进备份寄存器或者 EEPROM,下次上电就能看到"是通信任务卡住了",而不是只看到"设备重启过"。
2.4 窗口看门狗的窗口时间怎么定
窗口看门狗的参数稍微绕一点,公式是:
T_reset_max = (4096 × 2^WDGTB × (T[5:0] + 1)) / F_pclk1 T_window = (4096 × 2^WDGTB × (W[5:0] + 1)) / F_pclk1其中WDGTB是 0 到 3 的分频档位,T[5:0]是计数器的低 6 位,W[5:0]是窗口值。喂狗必须发生在T_window到T_reset_max之间。
假设F_pclk1 = 42MHz,WDGTB = 3(分频 8),取T[5:0] = 0x7F:
T_reset_max = 4096 × 8 × 128 / 42e6 = 4194304 / 42e6 ≈ 99.9ms如果窗口值取W[5:0] = 0x5F(95):
T_window = 4096 × 8 × 96 / 42e6 = 3145728 / 42e6 ≈ 74.9ms也就是说,喂狗动作必须落在 74.9ms 到 99.9ms 之间。如果你的控制环设计周期是 80ms,那就刚好合适;如果某次只用了 20ms 就回来喂狗了,会立刻复位,说明执行路径出问题了。
注意:窗口看门狗调试阶段非常容易把自己坑死。在打断点单步调试的时候,程序停在断点上超过窗口上限,芯片就会复位,你会以为是代码逻辑错了。建议调试阶段先用独立看门狗,功能稳定后再切到窗口看门狗,并且把窗口下限设得宽松一些。
3. 保护机制:从硬件到软件的五层防线
看门狗只管"CPU 还在不在跑",剩下的问题得靠保护机制。我习惯把它分成五层:电源层、引脚层、存储层、数据层、软件层。每一层解决一类问题,层层叠加才有意义。
3.1 电源与复位层:最容易被忽视,也最致命
很多"莫名其妙的死机",根源都在电源。开关电源上电时的过冲、负载突变时的跌落、继电器吸合瞬间的电压塌陷,都会让 MCU 工作在手册规定之外。
硬件上的常规做法是:电源入口加 TVS 管吸收浪涌,加 LC 滤波抑制高频纹波,MCU 的 VDD 引脚就近放 100nF 加 10uF 的组合电容,模拟部分和数字部分用磁珠隔离。如果条件允许,加一颗电源监测芯片,电压低于阈值时直接拉复位,不要让 MCU 在低压下"半死不活"地运行——低压运行时 Flash 读取出错、RAM 数据翻转的概率会明显上升。
软件层面必须做的是复位原因判别。主流芯片都有复位原因寄存器,能区分上电复位、看门狗复位、软件复位、外部复位、低压复位。上电后第一件事就是读它,并把它写进故障日志。
typedef enum { RST_POWER_ON = 0, RST_WATCHDOG, RST_SOFTWARE, RST_EXTERNAL, RST_BROWNOUT, RST_UNKNOWN } reset_cause_t; reset_cause_t reset_cause_get(void) { uint32_t flags = RCC->CSR; reset_cause_t cause = RST_UNKNOWN; if (flags & RCC_CSR_IWDGRSTF) cause = RST_WATCHDOG; else if (flags & RCC_CSR_SFTRSTF) cause = RST_SOFTWARE; else if (flags & RCC_CSR_PORRSTF) cause = RST_POWER_ON; else if (flags & RCC_CSR_PINRSTF) cause = RST_EXTERNAL; else if (flags & RCC_CSR_BORRSTF) cause = RST_BROWNOUT; RCC->CSR |= RCC_CSR_RMVF; /* 清标志,为下次判断做准备 */ return cause; }这里有个细节:清标志必须在读取之后立即做,否则下次上电读到的是历史残留值。另外,有些芯片的低压复位标志需要额外的手动清除序列,移植时务必查手册。
还有一个实践建议:统计看门狗复位次数。如果一台设备一周内看门狗复位超过三次,说明有持续性问题,应该在日志上报时打上高优先级标签,别让它默默重启一百次都没人知道。
3.2 引脚与通信层:把死锁的可能掐掉
I2C 总线有个经典死锁:主设备正在读数据时被复位,从设备还在等着发下一位,把 SDA 拉低不放,主机再怎么发时钟也没用。解决办法是总线恢复例程:把 SCL 配置成普通 GPIO,手动发 9 个时钟脉冲,再补一个 STOP 条件,然后重新初始化 I2C 外设。
void i2c_bus_recover(void) { /* 1. 关闭 I2C 外设,释放引脚 */ I2C1->CR1 &= ~I2C_CR1_PE; /* 2. 配置 SCL 为推挽输出,SDA 为输入 */ gpio_config_od_output(I2C_SCL_PIN); gpio_config_input(I2C_SDA_PIN); /* 3. 发 9 个时钟脉冲 */ for (int i = 0; i < 9; i++) { gpio_write(I2C_SCL_PIN, 0); delay_us(5); gpio_write(I2C_SCL_PIN, 1); delay_us(5); } /* 4. 补一个 STOP 条件:SCL 高时 SDA 由低变高 */ gpio_config_od_output(I2C_SDA_PIN); gpio_write(I2C_SDA_PIN, 0); delay_us(5); gpio_write(I2C_SCL_PIN, 1); delay_us(5); gpio_write(I2C_SDA_PIN, 1); delay_us(5); /* 5. 重新初始化 I2C */ i2c_init(); }通信层的另一个重点是所有阻塞等待都必须有超时。我在代码评审时有一条硬规则:while循环里如果出现"等待某个标志位"的写法,必须配套一个计数器或者时间戳,超过阈值就退出并返回错误码。裸机环境下这一点尤其重要,因为一个while(!flag)就能让整个系统停摆。
/* 反面教材,绝对不要这么写 */ while (!(SPI1->SR & SPI_SR_TXE)) { } /* 正确写法 */ uint32_t timeout = 10000; while (!(SPI1->SR & SPI_SR_TXE)) { if (--timeout == 0) { fault_report(FAULT_COMM_TIMEOUT, SPI_ERR_TXE); return ERR_TIMEOUT; } }3.3 存储与数据层:CRC 和双备份
EEPROM 和 Flash 里的配置数据、累计值,是最容易"静默损坏"的地方。所谓静默损坏,就是数据位翻了个个儿,读出来是个合法但错误的数值,程序不报错,但行为已经不对了。
标准做法是每块数据都带校验和序号:
typedef struct { uint16_t seq; /* 写入序号,用于判断新旧 */ uint16_t crc; /* 前 N 字节的 CRC16 */ uint16_t len; /* 有效数据长度 */ uint8_t payload[64]; } nv_block_t;存储时采用双区轮换:A 区和 B 区交替写入,每次写入把seq加一。上电时两个区都读出来,先校验 CRC,再比较seq,取有效且序号较大的那一块。这样即使写入过程中掉电,损坏的也只是其中一个区,另一个区仍然完整。
CRC 的粒度也要注意。如果把 64 字节打包成一个块算一次 CRC,那么块内任何一字节出错都会被检测到,但如果同一块被频繁写入,写次数会集中消耗在同一个扇区。比较平衡的做法是:变化频繁的数据单独放一块,变化少的配置单独放一块,分块管理,各自独立校验和轮换。
还有一个技巧:累加型数据要写增量而不是全程。比如累计运行小时数,如果每次都把完整值写一遍,写次数很快就到寿命。可以改成在 RAM 里累加,只有每增加 1 小时才往 EEPROM 写一次,或者写到一个专门的日志区。
3.4 软件层:栈保护、断言、错误码
栈溢出是嵌入式里最隐蔽的杀手。症状五花八门:某个局部变量突然变成随机值、函数返回地址被改写导致跑飞、任务切换后进 HardFault。检测手段有两种。
第一种是栈魔数填充:启动时把栈区全部填成0xA5A5A5A5,运行中周期性从栈底往上扫描,找到第一个不是魔数的位置,就知道栈的最大使用深度了。如果魔数被冲掉的区域接近或超过栈的预留边界,说明快溢出了。
void stack_check_init(void) { uint32_t *p = (uint32_t *)&_stack_start; while (p < (uint32_t *)&_stack_end) { *p++ = 0xA5A5A5A5; } } uint32_t stack_get_used(void) { uint32_t *p = (uint32_t *)&_stack_start; uint32_t used = 0; while (p < (uint32_t *)&_stack_end && *p == 0xA5A5A5A5) { p++; used += 4; } return (uint32_t)((uint8_t *)&_stack_end - (uint8_t *)p); }第二种是硬件内存保护。带 MPU 的芯片可以把栈底的一小段区域设成只读或者不可访问,一旦栈写穿过去立刻触发异常,比魔数检测更及时。代价是要多花点时间配置 MPU 区域和异常处理,而且和 RTOS 的任务栈分配要配合好。
断言在嵌入式里要慎用。标准库的assert在失败时通常调用abort,而abort在裸机环境下行为不确定。我更倾向于自己定义:
#define APP_ASSERT(cond, fault_id) \ do { \ if (!(cond)) { \ fault_report((fault_id), __LINE__); \ fault_save_to_backup(); \ system_safe_stop(); \ } \ } while (0)注意这里的处理是"记录故障 → 保存现场 → 进入安全状态",而不是直接复位。因为断言失败往往意味着逻辑错误,复位之后大概率会以同样的方式再挂一次,形成复位循环。进入安全状态(比如关闭输出、保持通信、上报故障)至少能让运维知道设备有问题。
错误码的统一也很重要。所有函数的返回值都要能表达"成功""参数错""超时""校验错""硬件错""忙"这几种基本语义,不要用 0 表示成功、-1 表示一切失败。我在项目里会强制要求头文件里给每个模块定义独立的错误码枚举,这样日志里看到一个错误码就能定位到具体模块,排查效率能提升一大截。
4. 降级策略:怎么退,退到哪,什么时候回来
故障处理做到最后,你会发现最难的不是检测,而是决策:出这个故障,是重启,是降级,还是停机?降级的层级怎么划分?降级期间还检测不检测?故障消失了多久才能恢复?这一节聊的就是这套决策逻辑。
4.1 先定故障分级,再定降级等级
我的做法是先给故障编号分级,再给系统定义降级等级,两者用一张映射表关联起来。
故障等级按严重程度分四档:
- F1 提示级:不影响功能,只记录。比如一次通信重传成功、一次 CRC 校验失败后重读成功。
- F2 降级级:影响部分功能,需要降低服务等级。比如某个传感器连续读不到数据、上报链路断了。
- F3 严重级:核心功能受损,需要立即处置。比如存储介质写失败、关键任务心跳丢失。
- F4 致命级:安全相关或者不可恢复。比如输出控制失效、内存保护异常。
系统降级等级也分四档:
| 等级 | 名称 | 保留的功能 | 关闭的功能 | 进入条件 |
|---|---|---|---|---|
| L0 | 正常 | 全部 | 无 | 无 F2 及以上故障 |
| L1 | 告警 | 采集、上报、存储、联动 | 高频采样、非必要自检 | 出现 F2 故障且未自愈 |
| L2 | 降载 | 采集、上报、本地缓存 | 实时上报改为批量上报、关闭联动 | F2 持续超时或出现 F3 |
| L3 | 最小系统 | 采集、本地缓存 | 停止上报、关闭联动、降低采样率 | F3 持续或出现 F4 |
| L4 | 安全停机 | 保留本地指示和故障记录 | 关闭所有输出 | F4 且无法恢复 |
这张表的价值在于它是可执行的。每个等级下哪些功能开、哪些关,写死在代码里,切换的时候一个个执行,不靠临时判断。
typedef enum { DEGRADE_L0_NORMAL = 0, DEGRADE_L1_WARN, DEGRADE_L2_REDUCE, DEGRADE_L3_MINIMAL, DEGRADE_L4_SAFE_STOP } degrade_level_t; static degrade_level_t g_cur_level = DEGRADE_L0_NORMAL; void degrade_apply(degrade_level_t level) { if (level == g_cur_level) return; /* 从高往低逐级恢复,保证资源按顺序打开 */ switch (level) { case DEGRADE_L0_NORMAL: storage_set_mode(STORAGE_FULL); comm_set_mode(COMM_REALTIME); control_enable(true); sample_set_period(1000); break; case DEGRADE_L1_WARN: sample_set_period(2000); break; case DEGRADE_L2_REDUCE: comm_set_mode(COMM_BATCH); control_enable(false); break; case DEGRADE_L3_MINIMAL: comm_set_mode(COMM_OFF); sample_set_period(10000); break; case DEGRADE_L4_SAFE_STOP: control_enable(false); output_all_off(); comm_set_mode(COMM_OFF); indicator_set(IND_FAULT); break; } degrade_log_transition(g_cur_level, level); g_cur_level = level; }4.2 降级必须"看得见"
降级最大的风险不是降级本身,而是降级了但没人知道。设备明明已经关掉了联动控制,运维还按正常状态去理解现场数据,这比设备直接停机更危险。
所以降级必须做到三件事:本地可见、远程可报、日志可查。
本地可见靠指示灯和蜂鸣器:正常运行绿灯常亮,降级状态黄灯慢闪,安全停机红灯快闪。别小看这个,现场运维很多时候就看一眼灯就知道要不要去看日志。
远程可报靠心跳包里的状态字段。每次上报数据的时候,把当前降级等级、当前活跃故障码、累计故障次数打包进去。上位机侧做规则判断,某个设备的降级等级超过 L1 持续超过十分钟就触发告警。
日志可查靠故障日志区。每次等级切换都记一条,包含时间戳、切换前后的等级、触发原因(哪个故障码、来自哪个模块)。这个日志用循环覆盖的方式写,保留最近两三百条就够了。
typedef struct { uint32_t timestamp; /* 相对开机时间,单位秒 */ uint8_t from_level; uint8_t to_level; uint16_t fault_id; uint16_t fault_src; uint16_t crc; } degrade_log_t;4.3 恢复比降级更需要小心:去抖与迟滞
故障消失之后马上恢复,是很多项目翻车的地方。原因很简单:故障往往不是瞬间消失的,而是间歇性出现。比如无线信号弱导致的通信失败,可能一次重连成功,两秒后又断了。如果一成功就恢复到 L0,系统会在 L0 和 L2 之间反复横跳,每次切换都要重初始化外设、重建连接,反而制造了更多不稳定。
解决办法是故障确认 + 恢复迟滞两条规则:
- 故障确认:连续出现 N 次同类故障,或者持续存在 T 时间,才认定为真故障。单次偶发只记日志不降级。通信类故障我一般设 N=3 或连续 10 秒。
- 恢复迟滞:故障消失后,要连续 M 次检测都正常,或者持续 T2 时间正常,才允许恢复。这个 T2 通常比 T 长,我一般取 3 到 5 倍。
#define FAULT_CONFIRM_CNT 3 #define FAULT_RECOVER_CNT 15 typedef struct { uint16_t fault_id; uint8_t occur_cnt; /* 连续出现次数 */ uint8_t clear_cnt; /* 连续正常次数 */ bool active; /* 是否已确认激活 */ } fault_entry_t; void fault_poll(fault_entry_t *e, bool is_ok) { if (!is_ok) { if (e->occur_cnt < 255) e->occur_cnt++; e->clear_cnt = 0; if (!e->active && e->occur_cnt >= FAULT_CONFIRM_CNT) { e->active = true; degrade_evaluate(); /* 触发重新评估等级 */ } } else { if (e->active) { if (e->clear_cnt < 255) e->clear_cnt++; if (e->clear_cnt >= FAULT_RECOVER_CNT) { e->active = false; e->occur_cnt = 0; e->clear_cnt = 0; degrade_evaluate(); } } else { e->occur_cnt = 0; } } }这套逻辑有个额外好处:偶发故障会被统计出来但不触发降级。你可以在日志里看到"某传感器一个月内偶发 CRC 错误 47 次",这个数据对判断硬件老化趋势很有价值。
注意:恢复迟滞的时间不能设得太长。我见过把恢复条件设成"连续正常 30 分钟"的,结果设备白天噪音大一直降级,晚上安静了也得等半小时才恢复,体验很差。建议根据故障类型分别配置:通信类 10 秒到 1 分钟,传感器类 1 到 3 分钟,存储类不自动恢复(等下次重启)。
4.4 什么情况下必须停机而不是降级
降级不是万能的。以下三类情况我倾向于直接进安全停机,甚至不让它自动恢复:
第一类,输出控制相关的故障。如果设备驱动的是继电器、阀门、加热器这类会对外部世界产生实际影响的东西,而你又检测到控制通路可能不可靠,那继续"降级运行"就是在赌。正确的做法是关闭所有输出,进入安全状态,等人来处理。
第二类,安全相关的检测失效。比如气体浓度报警功能,如果传感器本身故障了,你没法判断环境是否安全,这时候设备应该明确地报告"我测不准了",而不是沉默地继续上报一个可能错误的低浓度值。
第三类,反复复位循环。如果设备在五分钟内看门狗复位超过五次,说明有个持续性问题在反复触发,继续复位只是在浪费时间和磨损存储器。这时候应该在启动阶段检出这种情况,主动进入停机状态,只保留最小的通信能力等待远程指令。
实现方式是在备份寄存器里放一个复位计数器,每次启动时加一,如果系统正常运行超过一定时间(比如 5 分钟)就清零。这样启动时读到计数器大于阈值,就知道陷入了复位循环。
#define RST_LOOP_THRESHOLD 5 void boot_check_reset_loop(void) { uint32_t cnt = bkp_read(BKP_RST_CNT); cnt++; bkp_write(BKP_RST_CNT, cnt); if (cnt > RST_LOOP_THRESHOLD) { /* 短时间内反复复位,进入最低功耗的等待状态 */ comm_enter_low_rate_mode(); indicator_set(IND_FAULT); while (1) { comm_poll(); /* 只保留最基础的通信和指示 */ delay_ms(100); } } /* 正常运行时由主循环定期清零这个计数器 */ }5. 实操:把上面这些东西串成一个完整的可靠性框架
概念讲完了,接下来是把它落地。我拿前面说的环境监控终端做例子,从文件结构到关键代码走一遍。
5.1 模块划分与初始化顺序
整个可靠性相关的部分我拆成五个模块:
fault/ 故障管理与降级状态机 fault_mgr.c 故障记录、分级、降级评估 degrade.c 降级动作执行 fault_log.c 故障日志读写 watchdog/ 看门狗管理 wdt.c 硬件看门狗驱动 hb.c 任务心跳位图 protect/ 保护机制 stack_guard.c 栈魔数检测 i2c_recover.c 总线恢复 nv_store.c 双区掉电存储 reset_cause.c 复位原因判别初始化顺序非常关键,顺序错了会导致保护机制本身失效。我的顺序是:
- 时钟和基础 GPIO 初始化(此时还没有保护)
- 尽早读取并清除复位原因标志,写入备份寄存器
- 检查复位循环计数器
- 初始化栈魔数填充
- 初始化掉电存储,恢复参数
- 初始化故障管理模块
- 初始化外设与通信
- 最后一步才使能看门狗
为什么把看门狗放在最后?因为初始化阶段本身可能耗时较长(比如等待通信模块上电稳定要几百毫秒),如果看门狗提前开了,初始化还没走完就被复位了,设备会陷入"永远起不来"的状态。正确做法是在初始化开头先配置好看门狗但不开,初始化全部完成后立刻开始喂狗并启用,并且从此刻起保证喂狗逻辑正常运转。
int main(void) { clock_init(); gpio_init(); reset_cause_t cause = reset_cause_get(); fault_log_boot_record(cause); boot_check_reset_loop(); stack_check_init(); nv_store_init(); fault_mgr_init(); wdt_config(WDT_TIMEOUT_1S); /* 配置好,但不启动 */ sensor_init(); comm_init(); storage_init(); control_init(); scheduler_init(); scheduler_start(); /* 启动调度器,心跳开始上报 */ wdt_start(); /* 最后一步:启动看门狗 */ while (1) { hb_check_and_feed(); fault_poll_all(); degrade_poll(); power_mgr_poll(); delay_ms(50); } }5.2 故障管理与降级评估的核心逻辑
故障管理模块维护一张表,每个条目对应一个被监控的故障源。每隔一段周期,采集模块会把"这个故障源当前正常/异常"的状态喂进来,模块内部做去抖和计数,然后统一评估应该处于哪个降级等级。
typedef struct { uint16_t fault_id; uint8_t level; /* 该故障对应的严重等级 F1~F4 */ uint8_t occur_cnt; uint8_t clear_cnt; bool active; } fault_slot_t; static fault_slot_t g_slots[] = { { FAULT_SENSOR_INVALID, F2_LEVEL, 0, 0, false }, { FAULT_COMM_TIMEOUT, F2_LEVEL, 0, 0, false }, { FAULT_STORAGE_FAIL, F3_LEVEL, 0, 0, false }, { FAULT_TASK_HANG, F3_LEVEL, 0, 0, false }, { FAULT_STACK_NEAR_FULL, F3_LEVEL, 0, 0, false }, { FAULT_OUTPUT_FAIL, F4_LEVEL, 0, 0, false }, { FAULT_MPU_VIOLATION, F4_LEVEL, 0, 0, false }, }; degrade_level_t degrade_evaluate(void) { uint8_t max_level = 0; uint32_t active_mask = 0; for (size_t i = 0; i < ARRAY_SIZE(g_slots); i++) { if (g_slots[i].active) { active_mask |= (1UL << i); uint8_t lv = fault_to_degrade(g_slots[i].level); if (lv > max_level) max_level = lv; } } g_active_fault_mask = active_mask; return (degrade_level_t)max_level; } static uint8_t fault_to_degrade(uint8_t fault_level) { switch (fault_level) { case F1_LEVEL: return DEGRADE_L0_NORMAL; case F2_LEVEL: return DEGRADE_L1_WARN; case F3_LEVEL: return DEGRADE_L2_REDUCE; case F4_LEVEL: return DEGRADE_L4_SAFE_STOP; default: return DEGRADE_L0_NORMAL; } }这里用"取最高等级"的策略,简单可靠。实际项目中如果你想做得更细,可以让多个 F2 故障叠加升高一级,但要小心别让等级在边界上反复跳。我一般不做叠加,保持映射关系单一,逻辑更容易验证。
5.3 掉电保存的实现细节
掉电保存的难点在于你不知道什么时候会掉电。所以正确的思路是:在关键状态变化的那一刻就写,而不是掉电瞬间写。指望检测到掉电再抢时间保存,风险很高,因为电容里的余量可能只够写几十个字节。
我的做法是维护一份 RAM 里的"影子数据",关键字段发生变化时,标记该块为脏;后台每 5 秒检查一次,有脏块就落盘。同时对于特别关键的字段(比如累计运行时间、故障计数),变化频率低,可以做到变化即写。
typedef struct { uint32_t total_runtime_s; uint32_t fault_count[DEGRADE_LEVEL_MAX]; uint32_t last_fault_id; uint16_t degrade_level; uint8_t dirty; } nv_runtime_t; static nv_runtime_t g_nv_ram; static nv_runtime_t g_nv_bak; /* 回滚用 */ void nv_flush_task(void) { if (!g_nv_ram.dirty) return; g_nv_bak = g_nv_ram; int ret = nv_store_write(NV_ID_RUNTIME, &g_nv_ram, sizeof(g_nv_ram)); if (ret == 0) { g_nv_ram.dirty = 0; } else { /* 写失败,回滚内存中的"已保存"状态,等下一轮重试 */ nv_store_write(NV_ID_RUNTIME, &g_nv_bak, sizeof(g_nv_bak)); fault_report(FAULT_STORAGE_FAIL, NV_ID_RUNTIME); } }还有一个容易忽略的点:写 EEPROM 的时候要关中断或者加锁。因为 I2C 写操作通常是分多次传输,如果中间被高优先级任务打断去了同一个总线,数据就乱了。I2C 总线必须用互斥量或者信号量保护起来,这是基本纪律。
5.4 上位机侧怎么配合
设备端做得再完善,如果上位机不配合,故障还是看不见。上位机侧我一般要求三件事:
一是状态字段必须解析并入库,而不是只取数据值。上报报文里那几个字节的状态位(降级等级、活跃故障掩码、复位原因),要单独存成字段,便于做趋势分析。
二是做设备健康度统计。每个设备一张健康档案:最近 24 小时的降级次数、看门狗复位次数、通信失败率、数据校验失败率。这几个指标突然上升的设备,提前安排巡检,比坏了再修划算得多。
三是告警规则要有层次。不是所有故障都发短信。F1 只记录,F2 进日报表,F3 在线告警,F4 立即通知。告警太密会让人麻木,这是运维里很现实的问题。
一个简单的健康度计算示例,用 Python 处理设备上报的历史数据:
import json from collections import deque class DeviceHealth: def __init__(self, window=288): # 假设 5 分钟上报一次,288 条就是最近 24 小时 self.history = deque(maxlen=window) def push(self, record): """record 包含 degrade_level、reset_cause、crc_err 等字段""" self.history.append(record) def score(self): if not self.history: return 100.0 penalty = 0.0 for r in self.history: penalty += r.get("degrade_level", 0) * 2.0 penalty += r.get("reset_cause_wdt", 0) * 10.0 penalty += r.get("crc_err", 0) * 0.5 penalty += r.get("comm_retry", 0) * 0.2 relative = penalty / len(self.history) return max(0.0, 100.0 - relative) def need_inspect(self): return self.score() < 70.0这段代码的重点不是算法多精妙,而是把散落在报文里的状态位变成可比较的数字。运维看分数比看原始字段高效得多。
6. 常见问题与排查技巧实录
这一节是我这些年积累的问题清单,都是真实踩过的坑,按排查方向整理。
6.1 排查速查表
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 设备频繁重启,复位原因显示看门狗 | 喂狗点被阻塞 / 超时时间过短 | 统计复位间隔,看是否有规律;检查最长阻塞路径耗时 | 调整喂狗位置到主流程;超时时间取阻塞最坏情况 3 倍以上 |
| 复位原因显示上电复位但没断过电 | 电源跌落触发低压复位 | 用示波器看供电纹波和瞬态跌落 | 加大电容、优化电源布局,必要时换电源模块 |
| 通信偶尔失败但能自愈 | 链路质量波动 / 总线死锁 | 统计失败率与时段;检查总线恢复是否被调用 | 加大重试次数并区分瞬时/持续故障;确保恢复例程生效 |
| EEPROM 数据读出来是乱值 | 写入过程被打断 / 校验缺失 | 检查是否每次写都带 CRC 和序号 | 改用双区轮换 + CRC 校验 |
| 某任务不响应但系统还活着 | 任务级看门狗未覆盖该任务 | 检查心跳位图是否包含这个任务 | 把所有影响核心功能的路径纳入心跳 |
| 栈使用量随运行时间增长 | 递归未收敛 / 大数组放栈上 | 定期打印栈使用深度曲线 | 把大缓冲区改静态分配;检查递归出口 |
| 降级后系统更容易挂 | 降级路径本身没测试过 | 强制注入故障走一遍各降级等级 | 把降级路径纳入回归测试 |
| 设备反复进出降级 | 恢复条件太宽松 | 看故障日志的切换频率 | 增加恢复迟滞计数,调整阈值 |
6.2 三个印象最深的坑
第一个坑:喂狗周期和主循环周期不匹配。曾经有个项目,主循环正常跑一圈 30ms,看门狗超时设的是 100ms,看起来余量很足。但代码里有一段 Flash 写操作,擦除一个扇区要 80ms 到 200ms,而且这个操作是定期做的。结果就是每次擦写都可能触发复位。排查方式很简单:把所有可能耗时长的操作列出来,算出最坏情况的累计耗时,看门狗超时必须大于它。后来我们把 Flash 擦写挪到独立的低优先级任务里,并在操作前后主动喂狗,问题才解决。这里的关键不是"多喂一次",而是明确知道自己在哪个位置喂狗、为什么在这里喂。
第二个坑:I2C 总线死锁导致传感器任务卡住,进而触发看门狗复位循环。设备重启后一切正常,跑几个小时又重复。日志里只有看门狗复位记录,没有别的线索。后来我在传感器读取函数里加了超时和总线恢复,并且在恢复失败时把 SDA/SCL 的实际电平记进备份寄存器。下次复位后读出来,发现 SDA 一直是低电平,确认是从设备拉死总线。这类问题的排查关键就是把故障瞬间的硬件状态记录下来,光靠软件日志不够。
第三个坑:降级逻辑本身有 bug。某个版本的降级函数里,comm_set_mode(COMM_OFF)之后没有做状态同步,导致上层还认为通信在正常工作,继续往发送队列里塞数据。队列满了之后内存申请失败,触发断言,直接进安全停机。这个问题在正常测试中完全发现不了,因为只有降级路径才会走到。后来我强制要求:每个降级等级至少做一次完整的端到端测试,从注入故障、观察降级动作、检查资源释放,到故障消除后恢复,全程走一遍,并且用日志确认每一步都执行到位。
6.3 上线前的可靠性检查清单
我自己的项目上线前会过一遍这份清单,每一条都要有明确的"是"或"否",不能含糊:
- 看门狗是否已使能,喂狗点是否位于包含前置条件判断的主流程上?
- 超时时间是否按最坏情况(时钟偏差、最长阻塞、降级路径耗时)核算过?
- 复位原因是否有记录并在上报数据中体现?
- 是否存在复位循环检测?阈值多少,进入后做什么?
- 所有阻塞等待是否都有超时?
- I2C、SPI 等共享总线是否有互斥保护和恢复例程?
- 关键存储数据是否带 CRC 和序号,是否双区轮换?
- 栈使用深度是否测过?余量是否大于 30%?
- 每个降级等级的进入条件、退出条件、保留功能是否明确?
- 降级和恢复是否都有去抖和迟滞?
- 故障日志的容量、覆盖策略、读取方式是否明确?
- 是否做过主动故障注入测试?注入了哪些类型?
清单里最后一条最重要。前面十一条都是"设计层面"的东西,只有真正注入过故障,你才知道这些设计有没有生效。我一般的注入方式包括:用继电器切断电源模拟掉电、把网线拔掉再插上、用调试器强制某个任务死循环、人为修改配置数据的校验字节、把栈指针故意往深处用。每做一次注入,都要检查三件事:故障被正确检测到了吗?降级动作正确执行了吗?故障消失后能正确恢复吗?
最后分享一个我觉得最实用的经验:给每台出厂的设备留一个"故障快照"区域。这个区域用带备份电池的寄存器或者独立的 EEPROM 扇区实现,里面固定存放最近一次的复位原因、复位时的降级等级、活跃故障掩码、看门狗复位累计次数。设备出问题时,只要能读到这个区域,排查时间能从半天缩短到十分钟。我在几个项目里加上这个之后,现场问题的定位效率提升非常明显,很多原本"查不出原因"的偶发死机,都在快照里留下了线索。