看门狗任务设计:独立看门狗任务的设计模式

看门狗任务,说白了就是系统里的「最后一道防线」。

我刚开始做VxWorks项目时,总觉得看门狗就是个定时器,喂喂狗就完事了。直到有一次,系统在极端负载下死锁,看门狗却因为和业务任务绑在一起,也跟着「躺平」了。那次事故让我明白——看门狗任务必须独立,必须纯粹。

为什么需要独立看门狗任务?

你想想看,如果把喂狗操作放在业务任务里,业务任务一旦卡死,谁去喂狗?看门狗也就形同虚设了。

独立看门狗任务的核心思想就一句话:用一个最可靠的任务,去监控其他所有任务

我个人习惯的做法是:

  • 看门狗任务优先级设为系统最高(或次高)
  • 看门狗任务不依赖任何业务模块
  • 看门狗任务只做一件事——喂狗和超时处理

核心原则:看门狗任务必须是一个「孤岛」,不参与任何业务逻辑,不调用任何可能阻塞的API。

典型的设计模式

我在项目中常用的模式有两种,这里重点讲第一种——心跳检测模式

心跳检测模式的工作流程:

  1. 每个业务任务定期向共享内存写一个「心跳时间戳」
  2. 看门狗任务定期扫描所有任务的心跳
  3. 如果某个任务的心跳超过阈值,触发超时处理
  4. 看门狗任务自己喂硬件看门狗

代码示例:

/* 心跳数据结构 */
typedef struct {
    TASK_ID tid;           /* 任务ID */
    UINT64 last_heartbeat; /* 上次心跳时间 */
    UINT32 timeout_ms;     /* 超时阈值 */
    BOOL   is_alive;       /* 存活标志 */
} HEARTBEAT_ENTRY;

/* 看门狗任务主循环 */
void watchdogTask(void)
{
    HEARTBEAT_ENTRY *entry;
    UINT64 now;
    int i;

    while (1) {
        now = tickGet() * sysClkRateGet(); /* 获取当前时间ms */

        for (i = 0; i < g_heartbeatCount; i++) {
            entry = &g_heartbeatTable[i];

            if ((now - entry->last_heartbeat) > entry->timeout_ms) {
                /* 任务超时!触发回调 */
                watchdogTimeoutHandler(entry->tid);
            }
        }

        /* 所有任务正常,喂硬件狗 */
        sysWdogFeed();

        taskDelay(sysClkRateGet() / 10); /* 100ms扫描一次 */
    }
}

我的经验:扫描周期不要设得太短。100ms是个不错的起点。太频繁反而增加系统负担,而且硬件看门狗通常有几百ms到几秒的超时窗口。

喂狗策略:什么时候喂?怎么喂?

喂狗不是「随便喂喂」就行的。喂早了、喂晚了、喂错了,都会出问题。

策略一:固定周期喂狗

最简单的方式。看门狗任务每隔固定时间喂一次。

void watchdogTask(void)
{
    while (1) {
        sysWdogFeed();          /* 喂狗 */
        taskDelay(50);          /* 50ms喂一次 */
    }
}

嗯,这里要注意:喂狗周期必须小于硬件看门狗的超时时间。一般建议喂狗周期设为超时时间的1/3到1/2。

硬件看门狗超时 推荐喂狗周期 安全余量
1秒 200-300ms 700-800ms
2秒 500-800ms 1.2-1.5秒
5秒 1-2秒 3-4秒

策略二:条件触发喂狗

不是所有系统都适合固定周期。有些场景下,业务任务完成一个关键步骤后才允许喂狗。

我曾经在一个工业控制器项目里用过这种策略:

  • 主控任务完成一次控制周期后,发送「完成信号」
  • 看门狗任务收到信号才喂狗
  • 如果控制周期超时,看门狗不喂狗,系统复位

避坑指南:我曾经犯过一个错误——在中断服务程序里直接喂狗。结果中断频繁触发,看门狗永远不超时,系统死锁了都没人知道。记住:喂狗操作一定要放在任务上下文中,不要在ISR里喂狗!

策略三:分级喂狗

对于复杂系统,我建议用分级策略:

  1. 一级喂狗:看门狗任务自身存活证明
  2. 二级喂狗:关键业务任务的心跳正常
  3. 三级喂狗:系统整体健康状态良好

只有三级都通过,才真正喂硬件看门狗。这样能最大程度避免「假活」现象。

超时处理回调函数

看门狗超时了怎么办?不是只有「复位」一条路。

我习惯把超时处理设计成回调函数,这样灵活性更高。

回调函数的设计

/* 超时处理回调类型 */
typedef void (*WDOG_TIMEOUT_CB)(TASK_ID tid, UINT32 timeout_count);

/* 默认超时处理 */
void defaultTimeoutHandler(TASK_ID tid, UINT32 timeout_count)
{
    logMsg("Task 0x%x timeout! count=%d\n", tid, timeout_count);

    if (timeout_count >= 3) {
        /* 连续超时3次,强制重启任务 */
        taskRestart(tid);
        logMsg("Task 0x%x restarted\n", tid);
    }
}

/* 严重超时处理 */
void fatalTimeoutHandler(TASK_ID tid, UINT32 timeout_count)
{
    logMsg("FATAL: Task 0x%x dead! System reset...\n", tid);
    taskDelay(sysClkRateGet()); /* 留时间打印日志 */
    sysReset();                 /* 系统复位 */
}

回调函数的注册机制

每个任务可以注册自己的超时回调:

STATUS registerWatchdogCallback(TASK_ID tid, 
                                 WDOG_TIMEOUT_CB cb,
                                 UINT32 timeout_ms)
{
    HEARTBEAT_ENTRY *entry;
    int i;

    /* 查找空闲槽位 */
    for (i = 0; i < MAX_HEARTBEAT_ENTRIES; i++) {
        if (g_heartbeatTable[i].tid == 0) {
            entry = &g_heartbeatTable[i];
            entry->tid = tid;
            entry->timeout_ms = timeout_ms;
            entry->cb = cb;
            entry->last_heartbeat = tickGet() * sysClkRateGet();
            entry->is_alive = TRUE;
            return OK;
        }
    }

    return ERROR; /* 槽位满了 */
}

我的建议:回调函数里不要做复杂操作。我曾经见过有人在回调里做文件写入,结果文件系统也卡住了。回调里只做两件事:记录日志、触发恢复动作。复杂逻辑放到单独的任务里处理。

超时处理的三种策略

策略 适用场景 风险
任务重启 单个任务偶发卡死 重启后状态丢失
系统复位 严重故障、安全关键系统 所有任务中断
降级运行 非关键功能失效 功能不完整

我个人最常用的是「先尝试任务重启,连续失败再系统复位」的策略。这样既保证了可靠性,又避免了频繁复位带来的用户体验问题。

总结一下

独立看门狗任务的设计,核心就三点:

  • 独立性:看门狗任务必须独立于业务任务
  • 策略性:喂狗策略要根据系统特点选择
  • 灵活性:超时处理用回调机制,支持不同场景

嗯,最后说一句:看门狗不是万能的。它只能解决任务卡死的问题,解决不了逻辑错误。但用好看门狗,至少能让你的系统在出问题时「死得明白」,甚至「起死回生」。