MicroPython下的软件看门狗设计:心跳表与任务分级恢复 先讲一个我实际遇到过的场景。一台环境采集设备用MicroPython跑在ESP32上部署到现场大概两周后开始出现奇怪现象数据上报每隔几分钟就漏一条但远程看设备的网络连接、CPU负载又好像都正常。接上日志排查问题出在I2C总线上挂着的一颗传感器偶尔会把总线拉死采集任务卡在底层驱动里出不来而上报任务因为是独立的路由反而还在正常发数据。这种“局部脑死亡”的状态传统硬件看门狗是发现不了的。硬件看门狗只关心“喂没喂”只要主循环还能跑它就觉得一切正常可真正的问题是某个业务任务已经挂掉了。也是从那次之后我开始在MicroPython工程里加入一套带恢复机制的软件看门狗这篇文章就把这套方案从头到尾拆开讲清楚。软硬件看门狗怎么分工、心跳表怎么设计、恢复机制分几级以及落地时最容易踩的那些坑都会覆盖到。1. 为什么MicroPython场景下传统看门狗常常“心有余而力不足”不管你是做ESP32、RP2040还是STM32一类的板子只要跑的是MicroPython第一反应基本都是开machine.WDT然后写个喂狗循环就完事了。但实际上这套做法在MicroPython环境下有先天短板理解这点很重要。1.1 VM被打死时喂狗线程跟着陪葬硬件看门狗本身只是一个硬件的递减计数器超时了就直接复位芯片。问题在于“喂狗”这个动作是谁执行的在很多MicroPython工程里喂狗逻辑写在主循环里或者放在一个独立线程里。如果你熟悉MicroPython的底层实现就知道Python代码是跑在MicroPython虚拟机VM之上的。VM在执行Python字节码时是协作式调度的虽然_thread模块能开线程但它并不是像PC上那种抢占式操作系统线程。绝大多数情况下同一时刻只有一个线程在跑VM靠内部轮转去切换上下文。这就带来一个很尴尬的局面如果某个任务里出现了一个死循环而且这个循环里没有做任何会触发VM调度的事情那么while True: wdt.feed()这个线程就没机会被切到。硬件看门狗该喂的没喂最后只能整板复位。也就是说硬件看门狗在MicroPython下确实能防“死机”但它的保护粒度非常粗而且触发时机很被动。它没法告诉你“到底是哪个任务出了问题”只能告诉你“整个系统死了”。1.2 硬件看门狗只会“一键重启”救不了“局部瘫痪”回到我前面讲的传感器总线拉死那个场景。硬件看门狗如果管用它会把整个设备复位——传感器重新初始化了但代价是所有任务全部中断上报链路、网络连接、数据缓存全部跟着重建。对于低功耗设备、长时间无人值守的设备来说这种“一刀切”的恢复方式影响面太大。我在实际项目中还遇到过一种更难缠的情况某个非关键任务卡住了但主循环里还在正常跑网络任务也还在正常上报。硬件看门狗压根不会触发因为喂狗点在主循环里一直是执行的。系统“看起来活着”但那个卡住的任务一直在做无用重试、重复申请内存时间一长内存碎片越来越严重最后整个设备才真正崩掉。这种场景下真正有效的做法是任务级的健康感知。你需要的不是“整机死了帮我重启”而是“这个任务心跳丢了把它单独拉起来”。1.3 软件看门狗到底看管什么软件看门狗不负责替代硬件看门狗它负责的是“任务级活性”。核心思路就是给每个关键任务登记一条心跳记录任务每跑完一个周期就在心跳表里更新一下时间戳。然后由一个独立的监控循环定期检查这些时间戳看哪个任务超过阈值没有心跳了就判定它为异常接着触发对应的恢复动作。这里要强调一个边界软件看门狗解決不了主线死锁的问题。如果整个Python运行环境都卡死了那监控循环本身也没办法运行所以后面我会专门讲它和硬件看门狗怎么配合。先理解到这一层才不会把软件看门狗当成万能药盲目替换掉硬件方案。2. 软件看门狗的核心设计心跳表、监控循环与喂狗点分离软件看门狗看起来简单就是“记录时间-检查时间-超时恢复”三个步骤。但真正要让它可靠运行有几个设计原则必须摆正否则写了也是摆设。2.1 心跳表的数据结构心跳表的本质是一个小型的注册表。每个要受保护的任务需要在系统启动时先向看门狗注册一条记录。这条记录至少要包含以下字段字段作用示例值name任务名称用于日志定位sensor_taskperiod_ms任务正常执行周期100timeout_ms允许的最大心跳间隔500last_beat_ms最后一次心跳的系统时标time.ticks_ms()missed_count连续超时次数0recovery恢复回调函数recover_sensorstate任务当前状态healthy在MicroPython里用类对象存这些字段比用多个dict列表要清晰得多。每个任务一个实例注册的时候直接register()心跳的时候直接heartbeat(name)监控循环里直接遍历对象检查逻辑一目了然。用list存储这些对象就行MicroPython下面不用过度设计不需要做RB树、跳表之类的数据结构。任务数量一般也就5~10个线性遍历的开销可以忽略不计。2.2 超时判定与连续失败计数超时判定有个原则我要反复强调千万不要一超过阈值就立刻判死。实际场景里任务偶尔会抖动比如某个周期内刚好在做耗时操作microPython垃圾回收跑了或者外设读数据慢了一点心跳晚了100ms。这种情况下直接触发恢复动作反而会造成系统不稳定。我的做法是引入“连续失败计数”机制。每个任务设定一个超时阈值比如正常周期100ms阈值设置为500ms。监控循环每次发现该任务心跳超时missed_count加1。只有连续超过2次或者3次看具体情况才确定这个任务真的出了问题进入恢复流程。这样设计的目的是过滤瞬态抖动避免把“稍微晚了点”误判成“任务死了”。这个思想其实和硬件看门狗的超时余量是相通的多点容错系统的稳定性会上一个台阶。2.3 喂狗点应该打在哪喂狗点打在哪里也是门学问。我见过有人把心跳放在任务函数末尾比如def sensor_task(): read_sensor() report_data() watchdog.heartbeat(sensor_task) time.sleep_ms(100)这个写法的缺点是如果read_sensor()卡死了那整个任务的循环都进不去心跳自然就断了——这个逻辑本身没问题。但反过来如果你把心跳放在read_sensor()之前那即使传感器读取卡死心跳依然能更新看门狗就失效了。所以喂狗点的原则是放在任务一个完整周期的末尾并且这个周期内必须包含所有关键操作。也就是说一旦心跳能更新就代表上一个周期的所有关键操作都执行完了。对于用状态机实现的任务喂狗点要放在主状态分支处理完之后而不是放在每个子状态里。放在子状态里会产生“状态机卡死但心跳还活着”的假象。对于异步协程uasyncio实现的任务心跳更新要放在协程的顶层循环里不要放在某个只执行几毫秒的小函数里。3. MicroPython实操一个可运行的看门狗与恢复示例光讲原理不够直接上一套可以在ESP32、RP2040上跑起来的MicroPython代码。代码不依赖任何第三方库只用标准库。3.1 核心类代码先定义一个任务条目类import time class TaskEntry: def __init__(self, name, period_ms, timeout_ms, recoveryNone): self.name name self.period_ms period_ms self.timeout_ms timeout_ms self.last_beat_ms time.ticks_ms() self.missed_count 0 self.recovery recovery self.state healthy再看门狗监控类class WDTMonitor: def __init__(self, check_interval_ms200): self.tasks [] self.check_interval_ms check_interval_ms self.max_missed 2 def register(self, name, period_ms, timeout_ms0, recoveryNone): if timeout_ms 0: timeout_ms period_ms * 5 entry TaskEntry(name, period_ms, timeout_ms, recovery) self.tasks.append(entry) return entry def heartbeat(self, name): for e in self.tasks: if e.name name: e.last_beat_ms time.ticks_ms() e.missed_count 0 return True return False def check(self): now time.ticks_ms() for e in self.tasks: elapsed time.ticks_diff(now, e.last_beat_ms) if elapsed e.timeout_ms: e.missed_count 1 if e.missed_count self.max_missed: self._do_recover(e) else: e.missed_count 0 def _do_recover(self, entry): print([WDT] recover:, entry.name, missed, entry.missed_count) if entry.recovery: entry.recovery(entry) else: # 默认行为至少把心跳清掉防止恢复期间反复触发 import machine machine.reset() entry.last_beat_ms time.ticks_ms() entry.missed_count 0这里有个细节_do_recover执行完恢复回调之后要把心跳时间戳重置掉并把missed_count清零。不然恢复回调执行期间任务还没正式跑起来下一次check()又判定超时就会陷入“疯狂重启”的死循环。3.2 任务侧接入方法主程序里先创建看门狗实例再注册几个关键任务wd WDTMonitor(check_interval_ms200) def recover_sensor(entry): print([WDT] re-init sensor bus) sensor_bus.deinit() time.sleep_ms(20) sensor_bus.init() # 有需要的话重新启动对应协程或线程 def main(): wd.register(sensor_task, period_ms100, timeout_ms500, recoveryrecover_sensor) wd.register(report_task, period_ms1000, timeout_ms3000) start_sensor_task() # 内部周期调用 wd.heartbeat(sensor_task) start_report_task() # 内部周期调用 wd.heartbeat(report_task) while True: wd.check() time.sleep_ms(wd.check_interval_ms)任务侧典型写法def sensor_task_loop(): while True: # 读取传感器、滤波、状态机的关键步骤 read_sensor() process_data() # 周期末尾打点表示这轮所有关键操作都完成了 wd.heartbeat(sensor_task) time.sleep_ms(100)3.3 故障注入验证写完代码怎么验证我的做法是故意写一个会卡死的任务来“钓鱼”。def hang_task_loop(): while True: wd.heartbeat(hang_task) # 模拟异常进入死循环不再让出调度 while True: pass把hang_task_loop注册进看门狗超时设500ms。运行后观察日志看门狗会打印出recover信息。如果你给这个任务绑定的recovery函数是重启一个协程那就能看到恢复动作被反复触发的效果。我实际测试时的建议把日志打印放到_do_recover里这样每次恢复都有迹可循。恢复动作触发后再人为在任务侧打印一次启动日志就能清晰看出“挂掉-检测-恢复-重新拉起”的完整链路。4. 恢复机制从容错、任务重启到整机复位的分级设计看门狗本身只是“探测器”真正体现工程水平的是恢复机制。恢复动作的激进程度一定要和故障的严重程度匹配。这就是我设计“分级恢复模型”的原因。4.1 分级恢复模型我一般把恢复分成四个等级按需选用级别恢复动作恢复耗时适用场景L0仅记录日志不做干预0偶发抖动、非关键任务短暂超时L1任务级软重启几十毫秒到几百毫秒外设瞬态异常、单任务状态机卡死L2整板软复位1~5秒多任务同时超时、全局状态异常L3配置降级/备份态切换数秒到数分钟硬件故障、传感器彻底失效、数据缓存损坏L0和L1是日常主力。以传感器总线拉死为例recover_sensor做的事就是重新初始化总线、清掉挂起的异常标志、把采集任务的状态机重置到初始状态。恢复完成后正常采集继续不影响上报任务。L2整板复位要谨慎使用。我在_do_recover里加过一种逻辑当监控循环发现超过一半的任务同时超时就认为问题出在公共环境上比如内存耗尽、底层驱动全乱这时候单点恢复没意义直接machine.reset()最干脆。L3在MicroPython场景下很少见但也不是没有。比如某些设备的配置文件存在Flash里如果程序反复在相同位置崩溃我会让看门狗在恢复时尝试读取备用分区配置或者切换到一个降级工作模式先把最核心的功能维持住。4.2 恢复函数怎么写才不“医死”恢复函数比较容易踩的坑是“恢复动作本身又把系统搞崩了”。我见过有个同事写的恢复回调是import machine; machine.reset()但这其实不算恢复只是把问题交给了硬件看门狗。合格的恢复函数应该是幂等的也就是重复执行结果一致、不会叠加副作用。以传感器总线恢复为例def recover_sensor(entry): try: sensor_bus.deinit() except Exception: pass time.sleep_ms(20) try: sensor_bus.init() except Exception as e: # 初始化失败也要吃掉异常不能抛出去 print([WDT] sensor re-init failed:, e) return # 重置状态机状态 sensor_state IDLE print([WDT] sensor task recovered)try-except包住每个可能失败的环节防止恢复回调未执行完就直接抛出异常导致整个监控循环崩掉。任务的状态位、标志位、队列长度都记得复位不然恢复完旧的错误状态还在很快会再次触发看门狗。4.3 恢复参数计算与防抖恢复参数里面最核心的是三个超时阈值timeout_ms、最大连续超时次数max_missed、恢复冷却时间cooldown_ms。超时阈值的经验法则是任务正常周期的3到5倍。周期100ms的任务超时设置500ms左右比较合理。太小容易被调度抖动误杀太大则故障发现太慢。关于防抖我再补充一个细节如果恢复回调本身执行完成后任务还是没能正常运行下次check()又判超时就会形成“心跳-恢复-超时-再恢复”的无限循环。这不仅消耗CPU还可能损坏外设。解决办法是加入冷却时间比如60秒内最多允许恢复3次超过3次直接machine.reset()def _do_recover(self, entry): now time.ticks_ms() if time.ticks_diff(now, entry.last_recover_ms) self.cooldown_ms: return entry.last_recover_ms now # 超过最大恢复次数转为硬复位 if entry.recover_count self.max_recover: import machine machine.reset() return if entry.recovery: entry.recovery(entry) entry.recover_count 1 entry.last_beat_ms time.ticks_ms() entry.missed_count 0last_recover_ms和recover_count需要加在TaskEntry上。跑一段时间后如果发现某个任务反复恢复日志里会有明显的规律这往往是硬件层面的问题而不是代码层面的抖动。5. 软件看门狗与硬件看门狗如何配合前面说过软件看门狗防不了整体死锁所以它和硬件看门狗之间的关系不是替代而是配合。这里重点讲这个问题。5.1 两级看门狗的分工定位给一个简化的协作模型硬件看门狗保护整个系统级死锁。它的喂狗点必须在最外层主循环里只要系统在正常运转就周期性喂狗。主循环一旦卡死硬件看门狗超时触发整板复位。软件看门狗保护任务级活性。它跑在主循环的check()里检查的是各任务的心跳。某个业务任务卡死它触发任务级恢复不影响其他任务。这俩的层级关系可以这样理解硬件看门狗是“最后一道保险丝”软件看门狗是“日常运维团队”。保险丝不能天天烧运维团队才是处理日常问题的第一响应人。5.2 硬件超时时间怎么算硬件看门狗的超时时间不能拍脑袋必须大于软件看门狗完成一次“检测恢复”的最坏时间。举个例子软件看门狗检测周期200ms任务级恢复动作执行200ms系统重新初始化到稳定状态2秒软件恢复的最大总耗时约2.5秒那么硬件看门狗超时时间可以设置为4秒或者5秒。设太短的话软件恢复动作还没执行完就被硬件强制复位了设太长真死锁时系统要“瘫”很久才复位对无人值守设备来说不可接受。我在实践里一般给硬件看门狗留1.5到2倍的余量。比如上面那个例子我会用4秒前提是软件恢复逻辑确实能在2.5秒内完成。如果代码里还有长时间阻塞操作得先把阻塞拆掉才能按这个公式算。5.3 长时间阻塞操作的处理MicroPython里很容易写出time.sleep(10)这种长时间阻塞代码。这种阻塞对软件看门狗很不利因为如果阻塞发生在关键路径上其他任务的心跳可能也会断掉。所以我会建议非关键路径不要使用长阻塞改成循环短睡眠加检查退出条件把阻塞操作的“最大惯性”压到几百毫秒以内。# 不推荐 time.sleep_ms(10000) # 推荐 for _ in range(100): time.sleep_ms(100) # 检查是否有退出事件有就break这样改完硬件看门狗喂狗点、软件看门狗check()都能保留间歇性执行窗口两级看门狗都能正常工作。6. 实测踩坑与优化建议这部分是我在MicroPython上跑这套方案时真正吃过的亏每一条都是血泪教训。6.1 ticks_ms回绕不处理迟早翻车MicroPython的time.ticks_ms()返回一个基于内部时钟的毫秒计数它不是无限增长的到了一定值会回绕归零。如果直接用now - last_beat_ms做差在回绕瞬间会得到一个巨大的负数导致误判。正确用法是time.ticks_diff(now, last_beat_ms)它对回绕做了正确处理。这个坑在短时间测试时根本测不出来但设备连续运行几十天后就会中招。机器复位一次问题重新计时又开始回绕看起来像“偶发故障”实际是代码写错了。6.2 线程/协程混用的调度陷阱我早期在做ESP32时想用_thread来跑监控循环但MicroPython的线程模块不是每个固件版本都默认编译进去的而且线程间共享全局对象时还有GIL限制。后来在多数项目里我改用协作式方案主循环里直接调用wd.check()简单可靠不依赖线程支持。如果非要用uasyncio要注意协程任务里不能有阻塞的time.sleep_ms而是用await asyncio.sleep_ms()。把阻塞调用混进协程里整个事件循环都会被拖住看门狗检查照样执行不了。6.3 日志写Flash要控制频率我给看门狗加上日志输出之后发现设备运行一两个月后莫名其妙变慢。排查原因每5秒写一条WDT日志到FlashFlash频繁擦写寿命被快速消耗而且擦写过程本身阻塞了系统。后来我把日志改成内存环形缓冲区只有需要诊断时才导出到串口或者网络。如果确实要持久化至少做“冷热分离”平时只写内存达到一定量级或者特定错误级别才落盘。6.4 一个完整的MINI工程建议如果你想把这套方案用在自己的设备上建议最小工程这样搭main.py初始化全局对象创建WDTMonitor注册任务启动主循环tasks.py定义各个具体业务任务的循环函数统一调用wd.heartbeat()recovery.py收集所有恢复回调按任务分模块wdt.py放WDTMonitor和TaskEntry的实现另外我建议在上电初期做一个“自检注册”机制任务启动时如果发现自己的注册项不存在立即补注册这样后续代码迭代时不会因为漏注册而失去保护。还有一个小技巧看门狗触发恢复时把恢复前的任务名、恢复次数、触发时间一起打出来格式统一方便之后用脚本做离线分析。这个信息对定位现场问题价值巨大比单纯知道“设备重启过”有用得多。写完这套方案之后我把它应用到好几个量产项目里效果最明显的就是那个I2C总线拉死的场景现在传感器卡死会在1秒内被软件看门狗发现重新初始化总线后采集任务自动恢复上报任务全程不受影响。整个过程无人工介入设备依然稳定对外服务。个人体会是调试看门狗方案时不像普通功能调试那么容易看出结果需要耐心做故障注入反复验证恢复动作的边界。但一旦这套机制稳定运行长期无人值守项目里省下的运维成本远远超过开发它投入的时间。