Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.2 最小看门狗——alive监督的从零实现

场景导入:迟到的心跳

你家楼下那台电梯,每个月都要接受一次年检。检验员会做一项看似无聊的测试:在电梯运行到一半时,拔掉主控板的电源插头。你猜会发生什么?不是自由落体,制动器会在断电后150毫秒内钳住导轨,电梯稳稳停住,乘客除了轻微晃动外毫发无伤。

这个“150毫秒“背后站着一个永恒的守护者:看门狗定时器

在嵌入式世界里,看门狗是最古老也最基础的安全机制。它的工作原理简单到可以用一句话描述:“如果我在X毫秒内没收到你告诉我还活着的信号,我就认为你已经死了,然后做点什么。“但就是这个简单的机制,让无数失控的ECU免于酿成大祸。

今天,你将在Linux上从零实现一个教学级看门狗。没有硬件定时器?没问题:Linux内核的timerfd机制提供了用户空间的高精度单调时钟。没有硬件复位线?也没问题:我们把“重置系统“替换为“触发用户注册的回调函数“。所有核心逻辑(checkpoint注册、deadline监控、miss计数、容忍度窗口)都完整保留。

设计思想:不是“一次性“的狗

许多入门教程教你这样用看门狗:

// 错误示范:单一超时模式
void main_loop() {
    wdg_start(500);  // 500ms超时
    while (1) {
        do_work();
        wdg_kick();  // 每次循环喂狗
    }
}

这个模式有一个致命缺陷:它只能判断“整个系统是否活着“,不能判断“哪个部分已经不呼吸了“。

想象一个三任务系统:传感器采集(100Hz)、控制算法(200Hz)、CAN通信(50Hz)。如果CAN通信任务死锁,但另外两个任务仍然在喂狗,单一超时的看门狗永远不会发现CAN通信已经挂了

正确的做法是多槽位独立监控,每个任务注册自己的“心跳周期“,看门狗分别跟踪:

safe_wdg_register_slot(&wdg, 0, "sensor",    10, 2, 5);   // 10ms±2ms,连续5次错过=超时
safe_wdg_register_slot(&wdg, 1, "controller", 5, 1, 3);   // 5ms±1ms,连续3次错过=超时
safe_wdg_register_slot(&wdg, 2, "can_tx",     20, 5, 2);  // 20ms±5ms,连续2次错过=超时

这就像医院的ICU监护仪不是只测一次心率就下结论,它同时监测心率、血氧、呼吸频率,每项参数都有独立的报警阈值和报警延迟。safe-lite的看门狗正是这个设计哲学的C语言表达。

数据结构:safe_wdg_slot_t 和 safe_wdg_t

打开 safe_wdg.h,我们先看“槽位“的定义:

typedef struct {
    const char *name;          /* 槽位名称,用于超时日志输出 */
    uint32_t    deadline_ms;   /* 最大的允许的checkpoint间隔(ms) */
    uint32_t    tolerance_ms;  /* 额外容忍度(ms),deadline + tolerance = 实际阈值 */
    uint32_t    max_misses;    /* 连续错过多少次才判定为超时 */
    uint32_t    miss_count;    /* 当前连续错过次数 */
    uint64_t    last_seen_us;  /* 最后一次checkpoint的单调时间戳(微秒) */
} safe_wdg_slot_t;

关键设计决策:为什么用 deadline_ms + tolerance_ms 两个参数,而不是一个“总超时“?

因为它们在系统设计时的来源不同

  • deadline_ms来自于任务调度分析(schedulability analysis):控制器任务设计为每5ms运行一次,这就是deadline。
  • tolerance_ms来自于最坏执行时间(WCET)的抖动范围,如果控制器的WCET在3ms到5.5ms之间波动,你需要在deadline上加至少0.5ms的容忍。

把两者分开,使配置者能够清晰地表达调度意图执行不确定性两个维度。合并成一个参数虽然更简单,但丢失了设计信息:当系统集成后出现间歇性超时告警时,你无法判断是调度器的问题(deadline设置太紧)还是执行抖动的问题(tolerance不足)。

看门狗主体的结构:

typedef void (*safe_wdg_timeout_cb_t)(int slot_id, const char *name);

typedef struct {
    int                   timer_fd;        /* Linux timerfd 文件描述符 */
    safe_wdg_slot_t       slots[SAFE_WDG_MAX_SLOTS];  /* 最多8个监控槽位 */
    uint32_t              slot_count;      /* 已注册的槽位数量 */
    safe_wdg_timeout_cb_t on_timeout;      /* 超时回调函数指针 */
    bool                  enabled;         /* 看门狗是否已启动 */
    bool                  fired;           /* 是否已触发过超时(防止重复回调) */
} safe_wdg_t;

fired字段是一个安全关键的设计。一旦看门狗触发过超时,safe_wdg_service()将直接返回SAFE_TIMEOUT,不再调用回调。这是为了防止在系统已经处于异常状态时,重复的回调调用造成二次伤害(比如触发已经在进行中的复位流程)。

初始化与启动:timerfd的使用

safe_status_t safe_wdg_init(safe_wdg_t *wdg, safe_wdg_timeout_cb_t cb)
{
    memset(wdg, 0, sizeof(*wdg));
    wdg->timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK);
    if (wdg->timer_fd < 0) {
        perror("timerfd_create");
        return SAFE_ERROR;
    }
    wdg->on_timeout = cb;
    return SAFE_OK;
}

为什么用CLOCK_MONOTONIC? 因为看门狗的超时判断必须不受系统时间调整的影响。如果系统管理员修改了墙上时钟(CLOCK_REALTIME),你绝对不希望看门狗因此误判超时或错过真正的超时。CLOCK_MONOTONIC从系统启动开始单调递增,不受NTP同步或手动调时的影响。这和生产系统中使用独立RC振荡器驱动硬件看门狗的设计意图一致。

TFD_NONBLOCK标志确保safe_wdg_service()中的read()调用不会阻塞主循环。如果timerfd还没有到期事件,read()立即返回EAGAIN,主循环继续执行其他安全检查。

看门狗启动代码:

safe_status_t safe_wdg_start(safe_wdg_t *wdg)
{
    struct itimerspec its;
    its.it_interval.tv_sec  = 0;
    its.it_interval.tv_nsec = 20 * 1000 * 1000;  /* 20ms 服务周期 */
    its.it_value            = its.it_interval;

    if (timerfd_settime(wdg->timer_fd, 0, &its, NULL) < 0) {
        perror("timerfd_settime");
        return SAFE_ERROR;
    }
    wdg->enabled = true;
    return SAFE_OK;
}

20ms的配置是一把双刃剑。更短的周期(如1ms)能提供更精细的deadline监控精度,但会过度消耗CPU。更长的周期(如100ms)则可能在实际超过deadline几十毫秒后才检测到。这段时间足以让失控的执行器造成物理损坏。20ms是在Linux用户空间调度精度(~1-5ms)和实时性需求之间的合理折中。

PRODUCTION对照:在真实的ECU中,timerfd_settime()的位置被替换为对硬件看门狗外设的寄存器操作,初始化独立时钟源、配置窗口模式、设置早期警告中断(Early Warning Interrupt, EWI)。EWI在超时发生前触发一次中断,给系统最后一次“自救“机会(如保存关键日志到NVRAM),然后再执行硬件复位。

Checkpoint机制:谁还活着?

checkpoint是看门狗系统的核心动词。当一个任务完成了它的一个周期后,它“打卡“:

safe_status_t safe_wdg_checkpoint(safe_wdg_t *wdg, int id)
{
    struct timespec now;
    clock_gettime(CLOCK_MONOTONIC, &now);
    wdg->slots[id].last_seen_us = (uint64_t)now.tv_sec * 1000000ULL
                                + (uint64_t)now.tv_nsec / 1000ULL;
    wdg->slots[id].miss_count = 0;  /* 重置连续错过计数 */
    return SAFE_OK;
}

这段代码做了两件事:

  1. 记录时间戳:保存当前单调度时钟的微秒值。注意这里的tv_nsec / 1000是必要的:timespec提供纳秒精度,但我们只需要微秒。
  2. 重置miss_count:每次成功的checkpoint都是对系统健康的“一票信任“,它完全抵消了之前的miss,前提是miss_count还没达到max_misses。如果已经超时了,checkpoint不再有意义(看门狗不会“复活“一个已经认定为死的任务)。

槽位注册是使用前的必要步骤:

safe_status_t safe_wdg_register_slot(safe_wdg_t *wdg, int id,
                                     const char *name,
                                     uint32_t deadline_ms,
                                     uint32_t tolerance_ms,
                                     uint32_t max_misses)
{
    safe_wdg_slot_t *s = &wdg->slots[id];
    s->name          = name;
    s->deadline_ms   = deadline_ms;
    s->tolerance_ms  = tolerance_ms;
    s->max_misses    = max_misses;
    s->miss_count    = 0;
    s->last_seen_us  = 0;
    wdg->slot_count++;
    return SAFE_OK;
}

注意:last_seen_us = 0作为哨兵值。在safe_wdg_service()中,如果某个槽位的last_seen_us仍然是0,说明这个任务一次checkpoint都还没打过,监督逻辑跳过该槽位。这避免了“刚启动就看门狗叫“的尴尬,系统启动阶段,所有任务都需要时间初始化,此时不应判定为超时。

核心逻辑:safe_wdg_service()

这是整个看门狗模块的精华所在。这个函数必须被周期性地调用(在我们的设计中,20ms一次):

safe_status_t safe_wdg_service(safe_wdg_t *wdg)
{
    /* 1. 检查前置条件 */
    if (wdg == NULL || !wdg->enabled) return SAFE_NOT_INITIALIZED;
    if (wdg->fired) return SAFE_TIMEOUT;  /* 已触发过,不再重复检查 */

    /* 2. 排空timerfd事件: 这是"时钟源" */
    uint64_t expirations;
    ssize_t  n = read(wdg->timer_fd, &expirations, sizeof(expirations));
    if (n < 0 && errno != EAGAIN) {
        perror("read(timer_fd)");
        return SAFE_ERROR;
    }

    /* 3. 获取当前时间 */
    struct timespec now;
    clock_gettime(CLOCK_MONOTONIC, &now);
    uint64_t now_us = (uint64_t)now.tv_sec * 1000000ULL
                    + (uint64_t)now.tv_nsec / 1000ULL;

    /* 4. 遍历所有已注册槽位,检查是否超时 */
    for (uint32_t i = 0; i < SAFE_WDG_MAX_SLOTS; i++) {
        safe_wdg_slot_t *s = &wdg->slots[i];
        if (s->name == NULL) continue;       /* 未注册 → 跳过 */
        if (s->last_seen_us == 0) continue;  /* 未打过checkpoint → 跳过启动期 */

        uint64_t elapsed_ms = (now_us - s->last_seen_us) / 1000ULL;
        uint64_t limit_ms   = (uint64_t)s->deadline_ms + (uint64_t)s->tolerance_ms;

        if (elapsed_ms > limit_ms) {
            s->miss_count++;
            if (s->miss_count >= s->max_misses) {
                wdg->fired = true;
                if (wdg->on_timeout != NULL) {
                    wdg->on_timeout((int)i, s->name);
                }
                return SAFE_TIMEOUT;
            }
        } else {
            s->miss_count = 0;  /* 及时checkpoint → 清零计数器 */
        }
    }
    return SAFE_OK;
}

逐行分析核心判断逻辑

elapsed_ms = now - last_seen
limit_ms   = deadline + tolerance

if elapsed_ms > limit_ms:
    miss_count++
    if miss_count >= max_misses:
        FIRE
else:
    miss_count = 0

这三个参数构成了一个三级防护网

层级参数作用
第一层deadline_ms基本的调度期望——任务应该在这个时间内完成一次周期
第二层tolerance_ms允许的执行抖动——偶发的WCET超标不应立即告警
第三层max_misses抗假阳性——偶发的调度延迟不应触发安全响应

举个具体例子:控制器任务设定deadline=5ms、tolerance=1ms、max_misses=3。

  • 场景A:任务每5.5ms checkpoint一次 → elapsed=5.5ms,limit=6ms → 不触发miss(tolerance保护了)
  • 场景B:任务偶尔一次7ms → elapsed=7ms,limit=6ms → miss_count=1(但还没触发max_misses)
  • 场景C:连续3次超过6ms → miss_count=3,达到max_misses → FIRE

这就是容忍度博弈的艺术:tolerance_ms太小,系统频繁假报警;太大,真正的故障检测延迟增加。max_misses太小,一次偶然的调度尖刺就触发安全响应;太大,任务死锁后需要等很久才能检测到。

在ISO 26262的语言中(deadline + tolerance) × max_misses不能超过该安全目标的故障容忍时间间隔(FTTI)。如果某个安全目标要求在200ms内进入安全状态,你的参数组合必须保证从故障发生到超时触发的时间 ≤ 200ms。

集成测试:验证看门狗行为

main.c中有两个关键测试:

测试1:基线测试,健康系统不应触发看门狗

safe_wdg_register_slot(&wdg, 0, "main_loop", 200, 50, 2);
safe_wdg_register_slot(&wdg, 1, "sensor_task", 500, 100, 3);
safe_wdg_start(&wdg);

for (int i = 0; i < 5; i++) {
    safe_wdg_checkpoint(&wdg, 0);
    safe_wdg_checkpoint(&wdg, 1);
    usleep(30000);  // 30ms — 远小于所有deadline
    st = safe_wdg_service(&wdg);
}
// 预期:st == SAFE_OK

测试2:超时检测,故意饿死一个槽位

for (int i = 0; i < 20; i++) {
    safe_wdg_checkpoint(&wdg, 0);   // main_loop 仍然活着
    // slot 1 故意不checkpoint → 饥饿
    usleep(60000);  // 60ms
    st = safe_wdg_service(&wdg);
    if (st == SAFE_TIMEOUT) break;
}
// 预期:st == SAFE_TIMEOUT
// 输出:*** WATCHDOG TIMEOUT: slot 1 (sensor_task) unresponsive ***

注意测试2的循环结构:每次只checkpoint slot 0,slot 1被故意遗弃。每次sleep 60ms。经过若干周期后,slot 1的elapsed_ms持续超过limit_ms(comm_task的limit=500+100=600ms),miss_count累加到max_misses,看门狗开火。

看门狗最容易被误解的地方:它不是“性能监控“,而是“liveness监控“。很多工程师把checkpoint放在某个复杂计算完成之后,然后设置一个“足够大“的deadline。如果计算本身陷入无限循环,checkpoint永远不会被调用。

正确的做法:checkpoint应该放在最简单的路径上,比如调度器的主循环顶部。证明“程序计数器还在合理的地址范围内“就够了。复杂的业务逻辑是否完成,那是上层应用的职责。看门狗只回答一个二进制问题:这个软件组件是否还在运行指令?

本篇小结

  1. 多槽位看门狗通过独立 deadline、tolerance、max_misses 三参数实现逐任务 alive 监督,避免“单一超时“模式无法定位故障组件的缺陷。
  2. deadline_ms 来源于调度分析(任务期望周期),tolerance_ms 来源于 WCET 抖动,两者分开配置保留了设计决策信息。
  3. timerfd_create(CLOCK_MONOTONIC) 在 Linux 用户空间模拟硬件看门狗的单调时钟行为,不受系统时间调整影响。
  4. checkpoint 应放在任务的最简路径上(如调度器循环顶部),而不是复杂计算之后,避免无限循环导致 checkpoint 永远不被调用。
  5. (deadline + tolerance) × max_misses 必须不超过安全目标的 FTTI(故障容忍时间间隔)。

【下集预告】: 看门狗能回答“任务还活着吗“,但回答不了“收到的数据是对的吗“。下一节构建 E2E 保护模块,用 CRC8+4-bit 计数器+DataID 三要素为每条消息签发数字身份证。你会看到一个反直觉的设计决策:为什么 CRC 必须覆盖 DataID 和 Counter 而不仅仅是 payload?因为 2013 年某汽车品牌的转向系统事故已经用血的教训证明:只验证内容的完整性是不够的,你还需要验证消息的“身份“和“新鲜度“。