ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从看门狗到降级策略:嵌入式系统可靠性设计实战

从看门狗到降级策略:嵌入式系统可靠性设计实战 1. 先想清楚能跑的代码和能扛三年的设备差在哪里看门狗这个词几乎所有做过嵌入式的人都听过但我见过太多项目把它当成一个启动时初始化一下、主循环里喂一口的形式化动作。真到了现场设备在客户机柜里跑上半年某天不声不响地死在那里屏幕还亮着、指示灯还在闪但业务逻辑彻底不动了——这时候你才发现那个被你随手配置的看门狗压根没起作用。这套东西的本质不是加个定时器而是一整套故障与降级的设计思路怎么检测到系统已经不正常了检测到之后怎么恢复恢复不了的时候怎么保住最基本的功能这就是工程可靠性的底线。我把这篇文章写给三类人看第一类是刚开始做产品级固件、还在拿开发板当玩具的同学你需要知道你的代码离产品还有多远第二类是被现场问题折磨过、半夜爬起来远程指导客户断电重启的工程师你需要一套系统化的方法来收口第三类是负责架构和技术评审的人你需要知道哪些保护机制是必须有的、哪些是过度设计。全文我尽量不摆术语架子能算式的地方给你算式能上代码的地方给你代码能踩过的坑我一定把它写出来。先说一个我自己的判断标准一个嵌入式项目从能用走到可靠中间隔的不是三五个bug而是三层东西——检测层能不能感知到自己出问题了、恢复层感知到之后能不能自己爬回来、兜底层爬不回来的时候能不能保证设备处于一个安全且可控的状态。看门狗只是检测层里的一个器件很多人只做了这一层然后指望它解决所有问题结果就是复位风暴、数据丢失、现场状态永远查不清。下面我按这三层往下拆顺带把成本账也算一算。1.1 实验室的Demo和现场的三年设备到底差在哪实验室里验证一个功能通常是这样上电跑通数据对OK收工。整个过程可能就几分钟最多几小时。这里的隐含假设是环境稳定、电源干净、外设一直在线、没有电磁干扰、没人乱插拔线缆、温度恒定。而现场把这些假设全部打破电源可能来自一个杂牌开关电源纹波大得能上示波器电机启停时地线电位跳几十伏通信线缆旁边就躺着一根变频器输出线客户为了省事把设备装在密闭铁柜里夏天内部温度六七十度网线或串口线拔了又插插了又拔。这些因素单独看都不致命叠在一起就是慢性病。慢性病的特点是它不会立刻让程序崩而是让某个外设悄悄挂掉、某个变量被翻转、某块内存被覆写。程序还在跑主循环还在转喂狗照样喂但功能已经废了。这就是我常说的活着但已经死了。看门狗在这类故障面前几乎无能为力因为它的判断依据是程序有没有在跑而不是程序有没有在干正事。所以真正要解决的第一个问题是把活着的定义从能喂狗提升到业务任务都在正常推进。这个思路的转变是整套可靠性设计的地基。另外一个巨大差异是时间尺度。实验室里跑一小时没出错不代表跑一千小时没出错。假设某个边界条件在极端情况下每百万次调用触发一次业务代码每秒调用一次平均就是 11.6 天出一次问题。如果每十次触发才导致一次死机那就是一百多天——正好落在客户用了三个月开始投诉这个区间里。很多所谓偶发问题根本不是玄学是概率还没攒够。想清楚这一点你就不会再用我这儿跑了三天没问题来给自己壮胆了。1.2 检测、恢复、兜底可靠性设计的三层结构我把三层结构再讲细一点因为它直接决定了后面所有代码怎么写。检测层要做的事情是发现异常。手段包括看门狗定时器、任务心跳监控、通信超时计数、CRC 校验、栈使用量检查、内存分配失败统计、电源电压监控、时钟失效检测、外设寄存器回读比对。注意这里有个原则检测手段要覆盖不同类型的故障。用看门狗检测程序跑飞、用心跳检测任务卡死、用超时检测外设挂掉、用校验检测数据被破坏四者不可互相替代。恢复层要做的是尝试自愈。比如 I2C 总线卡死之后发送 9 个时钟脉冲把它踢醒、通信失败之后按退避策略重试、内存分配失败之后释放缓存再试一次、任务超时之后重置该任务的状态机。恢复动作要可重复、有次数上限不能出现重试 forever这种写法否则系统会卡在一个死循环式的自愈里比直接复位还糟。兜底层要做的是退到安全状态。降级到只保留核心功能、关闭非必要外设、把关键参数写进带校验的存储、记录故障现场、然后主动复位并回滚到可用配置。这一层最容易被忽略但它决定了设备出问题之后是能自己回来继续干活还是需要人去现场断电。做过现场维护的人都懂一次上门成本有多高。三层做完之后还有一个工程上的现实约束代码量、RAM、Flash 都是钱。一个 8 位 MCU、32K Flash、2K RAM 的项目你没那么多空间做完整方案。这时候就要分级取舍后面我会专门讲怎么做这个取舍。1.3 加多少保护才算够一份可落地的成本判断我通常用一个很土的办法来决策给每个保护机制估一个失效概率降低值和实现成本按性价比排。举几个典型例子。机制实现成本能挡住的故障性价比独立看门狗极低十几行程序跑飞、死循环极高必做复位原因记录极低读寄存器无法直接挡故障但让排查提速数倍极高必做参数双备份 CRC低Flash 位翻转、掉电写坏高建议做任务级心跳中任务卡死、优先级反转高多任务系统必做窗口看门狗低程序跑得太快或太慢中视场景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 到 256RLR 是重装载值通常 12 位最大 4095F_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 1250RLR 1249在 4095 以内可行。如果我想做 10 秒的超时用 PR 4 算下来 RLR 1 5000超了 12 位范围。改用 PR 5分频系数 4 × 32 128得 RLR 1 10 × 32000 / 128 2500RLR 2499可以。代码里通常这么写/* 目标IWDG 超时约 2.5sLSI 按 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 36MHzWDGTB 3分频 8T 设为 0x3F63W 设为 0x3048。总超时是 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 用状态机实现降级切换降级动作本身必须是有序的不能半路切换。比如设备正在控制一个电机你不能在运行中直接把控制模块停掉而要先把输出降到安全值再切换模式。这就需要一个明确的状态机。我一般的做法是定义几个状态RUN、PREPARE_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客户直接投诉设备变卡了。保护机制本身也是代码也有执行时间和资源消耗必须放在整个系统的时间预算里一起算。合理的做法是关键路径上做轻量校验非关键路径上做重量校验把开销分摊到不同的时间片里。第四个是关于测试心态的。保护机制的测试本质上是主动去证伪自己的设计。这和正常的功能测试心态完全相反——功能测试是我要证明它能工作可靠性测试是我要证明它在坏的情况下会怎样。这两种心态切换不过来的人做不好可靠性。我自己的习惯是每写完一个保护机制先不看它成功的表现而是先想办法让它触发一次看它触发得对不对。触发对了才算写完了。最后一个体会是关于文档的。保护机制这种东西代码里往往只有几行但背后的设计意图、触发条件、降级路径、恢复方式不写文档根本没人看得懂包括半年后的你自己。我建议每个项目都维护一份简短的可靠性设计说明把这几件事写清楚有哪些保护机制、各自的触发条件、触发后系统进入什么状态、怎么恢复、怎么验证。这份文档不需要长一两页就够但它能让你在半夜被叫起来处理现场问题的时候快速回忆起系统的设计意图而不是从头读代码。嵌入式这个方向做久了会发现功能和可靠性是两套完全不同的能力。功能做出来靠的是对业务和硬件的理解可靠性做出来靠的是对失败模式的想象力和纪律性。看到别人代码里那些多余的超时判断、啰嗦的状态检查、浪费的备份存储不要急着删掉先想想他是不是踩过你没踩过的坑。
返回列表