4、软件看门狗实现:任务级看门狗、中断级看门狗、基于时间戳的监控、心跳机制设计
硬件看门狗虽然可靠,但有个硬伤——它只能检测整个系统有没有「死透」。如果CPU还在跑,只是某个关键任务卡住了,硬件看门狗是喂不死的。这时候,就得靠软件看门狗上场了。
我个人习惯把软件看门狗分成四类:任务级、中断级、时间戳监控、心跳机制。它们各有各的适用场景,也各有各的坑。下面我一个一个说。
4.1 任务级看门狗
任务级看门狗,说白了就是监控每个任务是否按时完成了它的「规定动作」。比如一个数据采集任务,要求每10ms采集一次。如果超过15ms还没采,那肯定出问题了。
实现思路其实很简单:每个任务在关键节点「打卡」,一个监控任务定期检查这些打卡记录。
核心思想:每个任务在完成关键操作后,更新一个共享的时间戳或计数器。监控任务检查这些值是否在预期时间内被更新。
我在项目中遇到过这样一个案例:一个电机控制任务,要求每5ms执行一次PID计算。结果有一次因为中断频繁,任务被延迟了20ms才跑完。电机当场就抖起来了。后来加了任务级看门狗,一旦检测到超时,立刻切换到安全模式。
/* 任务级看门狗示例 */
typedef struct {
TaskHandle_t taskHandle;
uint32_t lastFeedTime;
uint32_t maxInterval; /* 最大允许间隔,单位ms */
uint8_t isAlive;
} TaskWatchdog_t;
TaskWatchdog_t wdTasks[MAX_TASKS];
void Task_FeedWatchdog(int taskId) {
wdTasks[taskId].lastFeedTime = GetSysTick();
wdTasks[taskId].isAlive = 1;
}
void Task_WatchdogMonitor(void *param) {
while(1) {
for(int i = 0; i < MAX_TASKS; i++) {
uint32_t now = GetSysTick();
uint32_t elapsed = now - wdTasks[i].lastFeedTime;
if(elapsed > wdTasks[i].maxInterval) {
/* 任务超时!执行恢复 */
Task_Recovery(i);
}
}
vTaskDelay(pdMS_TO_TICKS(100)); /* 每100ms检查一次 */
}
}
注意:监控任务的优先级不能太低。我曾经见过一个系统,监控任务被其他高优先级任务饿死了,结果所有任务都超时了也没人管。监控任务本身也需要被监控,这是个递归问题。
4.2 中断级看门狗
中断级看门狗,用来监控中断服务程序是否正常执行。你想想看,如果某个中断被意外关闭了,或者中断里死循环了,系统会怎么样?
实现方法:在中断服务程序中「喂狗」,在另一个地方(比如一个高优先级定时器中断)检查。
嗯,这里要注意:中断级看门狗不能放在同一个中断里检查自己,那没意义。得用两个不同的中断源互相监控。
/* 中断级看门狗示例 */
volatile uint32_t isrWatchdogCount = 0;
/* 被监控的中断服务程序 */
void TIM2_IRQHandler(void) {
/* 清除中断标志 */
TIM2->SR = 0;
/* 执行关键操作 */
DoCriticalWork();
/* 喂狗 */
isrWatchdogCount++;
}
/* 监控中断(更高优先级) */
void TIM3_IRQHandler(void) {
static uint32_t lastCount = 0;
TIM3->SR = 0;
if(isrWatchdogCount == lastCount) {
/* TIM2中断没有执行!执行恢复 */
System_Recovery();
}
lastCount = isrWatchdogCount;
}
避坑指南:我曾经把监控中断的优先级设得和被监控中断一样高,结果两个中断互相抢占,计数器更新乱套了。记住:监控中断的优先级一定要高于被监控中断。
4.3 基于时间戳的监控
时间戳监控,其实是一种更精细化的看门狗。它不只看任务有没有跑,还看任务跑得「对不对」。比如一个通信任务,不仅要检查它有没有接收数据,还要检查接收的数据是不是在合理的时间范围内。
实现方式:每个关键操作都记录一个时间戳,监控逻辑检查这些时间戳的「合理性」。
| 监控对象 | 时间戳记录点 | 合理范围 | 超时处理 |
|---|---|---|---|
| CAN通信接收 | 收到一帧数据时 | 10ms内应有新帧 | 重启CAN控制器 |
| 传感器采样 | 采样完成时 | 5ms内完成 | 重新初始化传感器 |
| 看门狗喂食 | 喂狗操作时 | 50ms内喂一次 | 系统复位 |
为什么会这样?因为很多故障不是「死机」,而是「慢死」。系统还在跑,但响应越来越慢,最后超时。时间戳监控能提前发现这种「慢性死亡」。
/* 时间戳监控示例 */
typedef struct {
uint32_t lastTimestamp;
uint32_t expectedInterval;
char name[16];
} TimestampMonitor_t;
int CheckTimestamp(TimestampMonitor_t *mon) {
uint32_t now = GetSysTick();
uint32_t diff = now - mon->lastTimestamp;
if(diff > mon->expectedInterval * 2) {
/* 严重超时 */
Log_Error("%s 严重超时: %d ms", mon->name, diff);
return -2;
} else if(diff > mon->expectedInterval) {
/* 轻微超时,记录警告 */
Log_Warning("%s 轻微超时: %d ms", mon->name, diff);
return -1;
}
return 0;
}
4.4 心跳机制设计
心跳机制,是分布式系统里最常用的健康检查手段。每个节点定期发送一个「我还活着」的信号,监控节点如果收不到,就认为这个节点挂了。
在单芯片系统里,心跳机制同样有用。比如一个系统有多个核心模块,每个模块都发心跳,一个中心监控模块统一管理。
我个人习惯把心跳分成三级:
- 一级心跳(快速):每1ms发送,用于中断级监控
- 二级心跳(正常):每10ms发送,用于任务级监控
- 三级心跳(慢速):每100ms发送,用于模块间通信
设计要点:心跳信号不能太频繁,否则浪费CPU;也不能太稀疏,否则故障检测延迟太大。一般取任务周期的2-3倍作为心跳间隔。
/* 心跳机制示例 */
typedef struct {
uint32_t heartbeatId;
uint32_t lastBeatTime;
uint32_t beatInterval;
uint8_t missedBeats;
uint8_t maxMissedBeats; /* 允许丢失的心跳数 */
} Heartbeat_t;
void Heartbeat_Send(Heartbeat_t *hb) {
hb->lastBeatTime = GetSysTick();
hb->missedBeats = 0;
}
int Heartbeat_Check(Heartbeat_t *hb) {
uint32_t now = GetSysTick();
if((now - hb->lastBeatTime) > hb->beatInterval) {
hb->missedBeats++;
if(hb->missedBeats >= hb->maxMissedBeats) {
/* 节点失联!执行恢复 */
return -1; /* 节点死亡 */
}
return 1; /* 丢了一拍,但还能忍 */
}
return 0; /* 正常 */
}
/* 使用示例 */
void ModuleA_Task(void *param) {
Heartbeat_t hb = {
.beatInterval = 10, /* 10ms发一次心跳 */
.maxMissedBeats = 3, /* 允许连续丢3次 */
.missedBeats = 0
};
while(1) {
/* 执行模块A的工作 */
DoModuleAWork();
/* 发送心跳 */
Heartbeat_Send(&hb);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
我曾经踩过的坑:心跳机制里有个经典问题——「心跳风暴」。当系统负载高时,心跳任务本身可能被延迟,导致误报。后来我加了个「容忍次数」的机制,允许连续丢失2-3次心跳才判定故障。这个缓冲很重要。
最后说一句:软件看门狗不是万能的。它依赖CPU和操作系统本身正常工作。如果系统已经严重损坏(比如栈溢出导致PC指针乱飞),软件看门狗也救不了。这时候还得靠硬件看门狗兜底。两者配合使用,才是王道。