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;
}
这段代码做了两件事:
- 记录时间戳:保存当前单调度时钟的微秒值。注意这里的
tv_nsec / 1000是必要的:timespec提供纳秒精度,但我们只需要微秒。 - 重置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应该放在最简单的路径上,比如调度器的主循环顶部。证明“程序计数器还在合理的地址范围内“就够了。复杂的业务逻辑是否完成,那是上层应用的职责。看门狗只回答一个二进制问题:这个软件组件是否还在运行指令?
本篇小结
- 多槽位看门狗通过独立 deadline、tolerance、max_misses 三参数实现逐任务 alive 监督,避免“单一超时“模式无法定位故障组件的缺陷。
deadline_ms来源于调度分析(任务期望周期),tolerance_ms来源于 WCET 抖动,两者分开配置保留了设计决策信息。timerfd_create(CLOCK_MONOTONIC)在 Linux 用户空间模拟硬件看门狗的单调时钟行为,不受系统时间调整影响。- checkpoint 应放在任务的最简路径上(如调度器循环顶部),而不是复杂计算之后,避免无限循环导致 checkpoint 永远不被调用。
(deadline + tolerance) × max_misses必须不超过安全目标的 FTTI(故障容忍时间间隔)。
【下集预告】: 看门狗能回答“任务还活着吗“,但回答不了“收到的数据是对的吗“。下一节构建 E2E 保护模块,用 CRC8+4-bit 计数器+DataID 三要素为每条消息签发数字身份证。你会看到一个反直觉的设计决策:为什么 CRC 必须覆盖 DataID 和 Counter 而不仅仅是 payload?因为 2013 年某汽车品牌的转向系统事故已经用血的教训证明:只验证内容的完整性是不够的,你还需要验证消息的“身份“和“新鲜度“。