
做嵌入式最怕什么不是功能做不出来而是设备在现场跑着跑着就“失联”了。我在做一个户外环境采集项目的时候就栽过跟头设备放在塔架上白天好好的晚上某个传感器I2C总线一卡整个主循环就陷在底层驱动里出不来程序既不崩溃也不复位就是没有任何响应。人跑过去一看电源灯亮着串口敲啥都没反应只能断电重启。后来我加了硬件看门狗设备倒是能自己重启了但重启完业务状态全丢了通信模块复位不干净照样要人工干预。也就是从那时候开始我认真研究起“带恢复机制的软件看门狗”这套玩法用 MicroPython 在嵌入式设备上做了完整实现。如果你正在做 MicroPython 项目或者从单片机转过来想提升系统稳定性又或者准备嵌入式相关面试这篇内容应该能帮你少走不少弯路。1. 为什么软件看门狗比你想的更重要1.1 看门狗的本质给系统装一个“强制下班”的闹钟看门狗这个机制简单来说就是系统里有一个不断倒计时的定时器应用程序必须周期性地“喂狗”也就是在定时器溢出之前重新装载计数值。一旦程序卡死、跑飞或者陷入某个无法返回的分支定时器就会溢出触发芯片复位让系统重新开始工作。硬件看门狗是芯片内部的独立定时器外设不依赖 CPU 主频运行即使程序完全跑飞只要时钟还在它就能拉复位引脚。在 MicroPython 里启用硬件看门狗非常简单就是machine.WDT这个类import machine # 初始化看门狗超时时间设为 8 秒 wdt machine.WDT(timeout8000) # 在主循环里周期性调用 wdt.feed()这段代码就是最基础的用法。timeout单位是毫秒意思是如果超过 8 秒没人调用feed()芯片就自动复位。很多初学者觉得这就够了毕竟“卡死了能重启”但实际上硬件看门狗解决的是“整个系统死掉”的最坏情况它不会帮你处理“业务层卡住但系统还活着”的中间状态。我举个例子你就明白了。设备的主循环还在跑wdt.feed()也在正常调用但某个关键业务线程因为等待一个永远等不到的互斥锁已经挂起两个小时了。这时候看门狗根本不会触发因为喂狗函数一直在执行系统“看起来”活着实际上业务早就死了。这种场景只有软件看门狗能发现。1.2 MicroPython 环境下的看门狗有三个特殊之处MicroPython 跑看门狗和裸机 C 语言跑看门狗表面上都是“定时溢出 复位”但实际使用中有几个非常不一样的坑。第一MicroPython 的machine.WDT是底层固件实现的一旦初始化在很多移植版本里你就无法关闭它。也就是说看门狗一旦启动整个程序生命周期内它都跟在背后你写的每一条while循环、每一个if分支都必须考虑“这里如果卡住了怎么办”。第二MicroPython 的虚拟机执行代码本身有不确定性。垃圾回收GC触发时如果堆碎片很多一次gc.collect()可能耗时几百毫秒而底层 C 扩展模块一旦 Block 住Python 代码是没有任何机会去喂狗的。所以在 MicroPython 项目里看门狗超时时间不能设计得太短我一般习惯取 6 到 10 秒给 GC、Wi-Fi 重连、文件系统写入留出余量。第三也是最容易被忽视的MicroPython 的堆栈异常、内存分配失败会抛出异常但如果代码里没有全局的异常捕获机制异常一旦逃逸到顶层MCU 会进入KeyboardInterrupt或者直接打印 traceback 后停在 REPL没有自动重启。也就是说MicroPython 程序出故障的形态比 C 程序更多你的看门狗机制也必须处理“Python 层异常逃逸”这种特殊情况。正因为这些特殊性我后来在项目里把看门狗从单纯的“硬件 WDT”升级成了一套“软件看门狗管理系统”。它不光管系统复位还管业务健康检查、状态恢复、现场日志记录。这也就是标题里说的“带恢复机制”的含义。2. 从“会重启”到“能恢复”软件看门狗的架构设计2.1 硬件看门狗解决不了的两个问题我强调过硬件看门狗只能做一次性复位。但在实际项目中复位只是“最低保障”你真正需要的是“恢复”。这完全是两个层次的诉求。第一个问题硬件看门狗复位后不会告诉你“为什么复位”。它只会让系统从头再来但如果你是做数据采集的设备复位后现场数据可能已经在内存里丢了你连日志都没有下次卡死照样卡死。没有上下文的重启本质上就是赌运气。第二个问题硬件看门狗只有“重启”这一个动作没有中间态。有些故障其实不需要重启整个系统。比如某个温度传感器 I2C 通信超时了你只需要重新初始化这个传感器的驱动根本不需要让 MCU 整体复位更不需要重新拨号联网、重新同步时间。一上来就硬重启反而是制造新问题——通信模块重连、文件系统挂载、时钟同步这些都可能在新一轮启动中再次卡住。所以我在设计软件看门狗时第一个原则就是能局部恢复就不全局重启能软恢复就不硬复位必须在最严重的情况下才动machine.reset()。2.2 带恢复机制的看门狗整体分层我最终采用的方案是一个三层结构。最底层仍然是硬件看门狗它作为最后一道防线兜住所有极端情况。中间层是业务健康检查模块它负责监控各个业务子模块的喂狗状态。最上层就是业务代码本身通过注册和喂狗接口参与到体系中。业务健康检查模块是核心它维护着一张任务注册表里面记录每个业务任务的名字、允许的最大执行间隔、最近一次喂狗时间、连续掉线次数。主循环每次迭代都调用一次check()检查所有任务是否超时然后根据超时的严重程度决定走哪条恢复路径。这个设计最舒服的地方在于业务代码完全不需要关心自己“什么时候该被检查”它只需要在活着的时候调一声“我在这儿”。而看门狗管理器把“发现问题”“判断等级”“执行恢复”这几件事集中起来逻辑非常清晰。分层之后我再也不用在每个子模块里写一堆错误处理代码了。传感器驱动卡了上报通信模块异常上报主循环阻塞太久上报。剩下的判断和恢复都交给中间层统一处理。2.3 恢复等级和触发条件怎么定“恢复机制”不是一句空话你得把它拆成具体的策略。我在项目里定义了四个状态等级你也可以根据自己的业务调整。等级名称触发条件恢复策略0正常所有任务都在超时时间内喂狗无操作1警告单个非关键任务掉线 1 次记录日志不处理2错误单个关键任务连续掉线 2 次以上或多个任务同时掉线软恢复重新初始化相关模块清空状态3严重所有任务全部掉线或者关键任务连续掉线超过 3 次硬复位保存现场触发machine.reset()这个分级不是凭空拍脑袋定的而是基于一个非常朴素的观察很多故障其实是“瞬时的、可恢复的”。比如传感器偶尔一次 I2C 仲裁失败你只要重试一次就能成功但如果连续几次都失败说明可能是硬件挂了这时候光靠重试是没用的得重新初始化驱动甚至断电重启外设。关键点在于等级判定不能只看“当前是否超时”还要看“连续超时多少次”。我见过不少人实现软件看门狗时只判断超时时间结果一个每次刚好卡在超时边缘的任务每隔几秒就被“恢复”一次反而把系统搞得更不稳定。所以我的设计里加入了miss计数器记录连续掉线次数恢复动作要有“迟滞”不要一掉线就反应过度。3. 完整实现一个能直接用的看门狗管理器3.1 WatchdogManager 类设计我最终把整个模块封装成了一个单例类WatchdogManager放在watchdog_manager.py里。为什么用单例因为 MicroPython 底层硬件看门狗只有一个喂狗动作必须全局唯一如果多个实例同时操作很容易出现逻辑混乱。下面是核心代码我尽量把注释写清楚import machine import time import gc class WatchdogLevel: OK 0 APP_WARN 1 APP_ERROR 2 SYS_CRITICAL 3 class WatchdogManager: _instance None def __new__(cls, timeout_ms8000): if cls._instance is None: cls._instance super().__new__(cls) return cls._instance def __init__(self, timeout_ms8000): if hasattr(self, _inited): return self._inited True self._timeout_ms timeout_ms self._regs {} # 注册表: {任务名: 超时时间} self._alive {} # 运行时状态: {任务名: 上次喂狗时间, miss数} self._wdt None self._level WatchdogLevel.OK self._fault [] self._recover_cb {} # 恢复回调: {等级: 回调函数} self._last_feed time.ticks_ms() self._fault_history [] def register(self, name, interval_ms): 注册一个业务任务, interval_ms 是该任务允许的最大执行间隔 self._regs[name] interval_ms self._alive[name] {last: time.ticks_ms(), miss: 0} def feed(self, name): 业务任务调用此方法上报存活 if name in self._alive: self._alive[name][last] time.ticks_ms() self._alive[name][miss] 0 def on_recover(self, level, cb): 注册指定恢复等级的回调函数 self._recover_cb[level] cb def start(self): 启动底层硬件看门狗 self._wdt machine.WDT(timeoutself._timeout_ms) def check(self): 主循环周期性调用, 检查所有任务状态 now time.ticks_ms() fault [] for name, interval in self._regs.items(): delta time.ticks_diff(now, self._alive[name][last]) if delta interval: self._alive[name][miss] 1 fault.append(name) else: self._alive[name][miss] 0 if fault: self._dispatch(fault) def _dispatch(self, fault): self._fault fault max_miss max(self._alive[name][miss] for name in fault) if len(fault) len(self._regs) and max_miss 3: # 所有任务全部掉线且持续多次, 系统级严重故障 self._do_recover(WatchdogLevel.SYS_CRITICAL) elif max_miss 3 or len(fault) 2: # 单个任务连续多次掉线, 或多个任务同时掉线 self._do_recover(WatchdogLevel.APP_ERROR) else: # 第一次掉线, 先警告并给业务一次机会 self._do_recover(WatchdogLevel.APP_WARN) def _do_recover(self, level): self._level level fault_info task: ,.join(self._fault) self._fault_history.append((time.ticks_ms(), level, fault_info)) # 控制历史记录长度, 防止无限增长 if len(self._fault_history) 20: self._fault_history.pop(0) print([WDT] level%d, fault%s, miss%s % ( level, fault_info, {k: self._alive[k][miss] for k in self._fault} )) # 先执行该等级对应的恢复回调 cb self._recover_cb.get(level) if cb: try: cb(self._fault) except Exception as e: print([WDT] recover cb error:, e) if level WatchdogLevel.SYS_CRITICAL: # 严重故障, 打印历史记录后硬复位 for h in self._fault_history: print([WDT] history:, h) # 给串口一点时间把日志输出完 time.sleep_ms(200) machine.reset() else: # 软恢复: 清理内存, 重置所有任务的时间戳 self._soft_recover() def _soft_recover(self): gc.collect() now time.ticks_ms() for name in self._regs: self._alive[name] {last: now, miss: 0} print([WDT] soft recover done, free mem:, gc.mem_free())这个类有几个细节我想特别说明一下。time.ticks_ms()配合ticks_diff()做时间差计算而不是直接用当前时间相减这是因为 MicroPython 的ticks_ms()是一个会回绕的计数器大约 49 天回绕一次直接用减法在回绕边界会算出负数。用ticks_diff()是官方推荐的安全写法。恢复回调我用try/except包了一层原因很简单恢复回调本身也是代码也可能出错。如果恢复回调里抛异常导致流程中断那看门狗就真的失去兜底能力了。所以任何恢复动作都不能影响最终的硬复位判定。gc.collect()放在软恢复里做是刻意的。MicroPython 设备内存本来就小很多时候任务卡死不是因为逻辑错误而是堆内存碎片化导致无法分配新的对象。软恢复时先整理内存把碎片的堆合并一下再重置任务时间戳业务循环就能相对健康地继续跑。3.2 业务任务如何注册和喂狗类的设计只是骨架真正要用起来还得看业务侧怎么配合。我的习惯是在main.py里统一注册任务代码结构大概是这样import time from watchdog_manager import WatchdogManager, WatchdogLevel # 获取看门狗管理器单例 wdt WatchdogManager(timeout_ms8000) # 业务模块导入 import sensor_task import comm_task import app_core def recover_comm(fault_tasks): print([recover] re-init comm module) comm_task.reinit() def recover_sensor(fault_tasks): print([recover] re-init sensor module) sensor_task.reinit() def recover_system(fault_tasks): print([recover] system critical, save state) app_core.save_state_to_flash() def main(): # 注册两个核心业务任务 wdt.register(sensor, 2000) # 传感器任务每2秒内必须喂狗 wdt.register(comm, 3000) # 通信任务每3秒内必须喂狗 # 注册恢复回调 wdt.on_recover(WatchdogLevel.APP_WARN, recover_comm) wdt.on_recover(WatchdogLevel.APP_ERROR, recover_sensor) wdt.on_recover(WatchdogLevel.SYS_CRITICAL, recover_system) # 启动底层硬件看门狗 wdt.start() # 初始化业务模块 sensor_task.init() comm_task.init() # 主循环 while True: # 执行业务逻辑 sensor_task.run() # 内部每跑一次调一次 wdt.feed(sensor) comm_task.run() # 内部每跑一次调一次 wdt.feed(comm) # 周期检查所有任务状态 wdt.check() # 给其他协程或中断一点调度机会 time.sleep_ms(10) if __name__ __main__: main()在具体业务任务里喂狗动作要放在“保证能执行到”的位置。我拿传感器任务举例import time from watchdog_manager import wdt # 导入单例 def sensor_task_run(): # 读取传感器 data read_sensor() if data is None: # 读取失败, 先不喂狗, 让管理器感知到这个任务超时 return None # 处理数据 process(data) # 执行成功, 喂狗 wdt.feed(sensor) return data这里有个很重要的设计思路任务失败时故意不喂狗让管理器的超时检测机制介入。因为如果你每次都兜底喂狗看门狗就永远发现不了问题。这是很多初学看门狗的人最容易犯的错误——把feed()放在try/finally里无论任务成功失败都喂狗结果就是看门狗形同虚设。3.3 三级恢复机制落地细节上面代码里的_dispatch和_do_recover就是三级恢复机制的核心。我按实际效果把恢复动作分成了三个层面分别对应APP_WARN、APP_ERROR、SYS_CRITICAL三个等级。第一级APP_WARN对应单个任务第一次超时。这个级别我通常只记录日志不做实际恢复动作。因为很多时候第一次超时只是瞬时抖动比如中断太频繁导致某个任务晚了几毫秒喂狗这种无伤大雅的情况不值得大动干戈。但记录日志很有价值后面通过串口看到日志里频繁出现level1你就知道这个任务长期处于临界状态需要优化代码了。第二级APP_ERROR对应单个任务连续超时 3 次或者多个任务同时超时。这时候我会执行软恢复调gc.collect()整理内存重置所有任务的时间戳并调用注册的软恢复回调。软恢复回调里通常做的是重新初始化外设驱动、重置通信模块缓冲区、重连 Wi-Fi 这类操作。执行完软恢复后系统继续跑不需要重启 MCU。第三级SYS_CRITICAL对应所有任务全部掉线且持续多次或者系统进入完全不可用的状态。这时候软恢复已经没有意义了必须硬复位。硬复位前我会做两件事打印故障历史记录方便事后分析调用recover_system回调把关键业务状态存到 Flash避免重启后数据全丢。做完这些再执行machine.reset()。你可能注意到_do_recover里对SYS_CRITICAL的处理有一个time.sleep_ms(200)这是为了让串口缓冲区里的日志能真正发出去。MicroPython 的print是同步输出到 UART 的但如果你用 USB-CDC 串口缓冲区是异步的立即machine.reset()很可能会丢掉最后几条关键日志。200 毫秒足够 CDC 把缓冲区数据推给上位机了。这套三级机制最核心的价值是能复位解决的问题坚决不复位必须复位的时候带着现场信息复位。设备的可用性和可诊断性都上来了。4. 实测与排坑看门狗不是写完了就能用4.1 故障注入测试怎么验证看门狗真的能救命写完看门狗管理器之后最重要的一步是故障注入测试。不要等实际现场出问题才验证那样太晚了。我在自己的项目里写了一套专门用来“制造故障”的测试脚本思路是人为让某个业务任务卡死观察管理器是否能按预期恢复。第一类故障是模拟长阻塞。比如在传感器任务里插入一个 10 秒的time.sleep(10)模拟底层驱动阻塞。按 2 秒超时时间算第一次超时应该在 2 秒左右触发APP_WARN第二次超时触发第二次警告第三次超时进入APP_ERROR此时会执行软恢复。如果我在测试脚本里故意让传感器任务在软恢复后继续阻塞那么最终会触发SYS_CRITICAL系统硬复位。第二类故障是模拟死循环。在通信任务里插入一个while True: pass这种死循环比time.sleep更危险因为feed()根本没有机会执行而且 CPU 完全被占用。实测下来看门狗管理器的check()也是死循环里无法执行的此时只能靠底层的硬件看门狗来兜底复位。这个测试印证了一个结论软件看门狗不能替代硬件看门狗二者是配合关系。第三类故障是模拟内存分配失败。在长时间运行后如果某些异常分支导致大量列表不断 append 而不释放堆内存会被耗尽MicroPython 会抛出MemoryError。我会在测试脚本里故意写一个泄漏内存的函数观察MemoryError抛出后全局异常捕获能不能兜住以及看门狗管理器能不能在内存紧张时依然执行软恢复。实测结果我记录过一组数据这里是模拟测试环境下的恢复时间故障类型故障描述触发等级恢复耗时恢复结果瞬时抖动单次任务延迟 2.1 秒喂狗APP_WARN无操作系统继续运行重复超时传感器任务连续超时 3 次APP_ERROR约 1.2 秒软恢复成功传感器重连全部阻塞所有任务 5 秒无喂狗SYS_CRITICAL约 8.3 秒硬件看门狗复位内存耗尽堆内存被耗尽APP_ERROR约 0.8 秒gc 回收后恢复这组数据说明大部分软故障都能在秒级内恢复而硬复位是最后兜底的保险耗时则由底层看门狗超时时间决定。4.2 常见问题排查记录我在项目里跑这套机制跑了小半年踩了不少坑挑几个典型的记录下来应该能帮你省点时间。问题一喂狗正常但还是频繁复位。现象是代码里feed()调用位置看似没问题设备依然周期性复位。排查后发现问题出在某个耗时较高的操作上。比如我把 Flash 写入操作放在主循环里同步执行写入 4KB 数据需要约 1.5 秒加上 GC 暂停单次循环就可能超过 2 秒导致喂狗间隔超过超时时间。解决方法是把耗时操作拆成异步步骤或者把硬件看门狗超时时间加长到能覆盖最长单次操作的时间。但注意超时时间不能无限加长否则真死机了要等很久才重启。问题二软恢复后传感器状态还是不对。现象是触发APP_ERROR后系统能继续跑但传感器读数一直异常。排查后发现软恢复回调里只重新初始化了传感器驱动但没有重新枚举设备地址。有些 I2C 设备在驱动异常后设备地址会发生变化必须重新扫描总线并配置地址。解决方法是把软恢复回调做成“完整重新初始化”而不只是调一下init()。这个坑让我明白软恢复动作必须覆盖到“让模块回到完全健康的初始状态”这个级别而不能只是浅层重置。问题三硬复位后数据丢失。现象是系统发生SYS_CRITICAL复位后内存里缓存的数据全没了。我后来在SYS_CRITICAL的回调里加入了状态持久化逻辑把关键数据写入 Flash。但这里也有坑——如果 Flash 写入操作本身卡住反而会阻碍复位动作。所以我给恢复回调设置了超时保护比如 Flash 写入最多允许执行 1 秒超过就直接跳过。实践下来这个策略效果很好既保证了数据持久化又不影响系统复位。4.3 几个容易踩的坑最后整理几个我在代码审查和实际调试中反复强调的细节。第一不要在中断服务函数里喂狗。很多人为了让看门狗更可靠把feed()放在定时器中断里这样即使主循环卡死看门狗依然被喂永远不会复位。但这样就完全失去了看门狗的意义——主程序都死了中断里的喂狗还在“虚报存活”。正确做法是喂狗必须发生在业务主流程的真实成功路径上。第二恢复回调不能依赖当前系统状态。触发恢复时系统大概率已经处于异常状态你的回调里如果还去读取刚才已经异常的传感器数据拿到的可能是垃圾值。正确做法是恢复回调只做“初始化、清理、重连”这类确定性动作不要做依赖业务数据的判断。第三日志和故障历史不是可选项。我见过很多人的看门狗只做了“默默复位”连个日志都没有结果现场设备隔几天重启一次谁都不知道为什么。我的管理器里保留了故障历史记录每次硬复位前都会打印出来。后来排查一个通信模块 bug全靠这些日志定位到是某次异常收到一个超长数据包导致缓冲区溢出。第四状态恢复要用try/except包住整个恢复流程。我在_do_recover里已经做了这件事但在实际项目中任何业务回调里都可能出现意外异常不要在恢复流程里再用“应该不会出错”这种心态写代码。恢复流程本身要是最健壮的那段代码。5. 这个看门狗方案还能怎么扩展写到这里这套带恢复机制的软件看门狗方案基本完整了。如果你打算把它用在更复杂的项目里还有几个扩展方向可以考虑。一个方向是增加看门狗状态的可视化。我现在的日志是串口输出后续可以考虑把看门狗状态通过一个状态 LED 展示出来——正常时 LED 呼吸闪烁警告时快闪严重故障时常亮。现场工程师不用接串口也能一眼看出设备状态。实现上就是在_do_recover里根据不同等级切换 LED 模式。另一个方向是把故障处理策略做成可配置的。不同业务对恢复策略的要求不一样有些业务希望“出错就尽量恢复”有些业务希望“出错就立刻上报不要自己乱动”。我现在的实现是在注册任务时指定超时时间后续可以扩展成注册时指定该任务在APP_ERROR级别的恢复策略比如“仅记录”“软恢复”“上报主控等待决策”。还有一个思路是把这套看门狗和远程运维平台联动。设备发生SYS_CRITICAL复位前除了打印日志还可以通过通信模块上报一条复位原因到云端。这样设备即使重启了运行人员也能在后台看到完整的故障链路。这个我在一些工业远程监控项目里见过实际落地效果非常好。不过不管怎么扩展核心的思想没变看门狗不应该是“最后一道简单粗暴的复位保险”而应该是一个有体系、有分级、有恢复能力、有可诊断性的系统组件。这套思路放到任何嵌入式平台上都适用不局限于 MicroPython。