9. 定时器事件:定时器实现原理、时间轮算法、定时器事件管理

大家好,我是你们的老朋友。今天我们来聊聊嵌入式系统里一个绕不开的话题——定时器事件。

说实话,我做了这么多年嵌入式开发,几乎每个项目都离不开定时器。从简单的LED闪烁,到复杂的协议栈超时重传,再到任务调度,背后都是定时器在默默工作。你想想看,一个没有定时器的系统,就像一个人没有时间观念,那得多混乱?

这一章,我们就来深入扒一扒定时器的底裤。我会从最基础的实现原理讲起,然后带你看看业界常用的时间轮算法,最后聊聊怎么管理好一堆定时器事件。

9.1 定时器实现原理

定时器说白了,就是「过一段时间后干一件事」。但怎么知道「时间到了」呢?

底层依赖的是硬件定时器。比如STM32的SysTick,或者Linux的timerfd。这些硬件会以固定频率产生中断,我们叫它「tick」。

每个tick到来,软件里维护的计数器就加1。当计数器的值达到预设的阈值,就触发超时回调。

嗯,这里要注意:硬件tick的频率决定了定时器的精度。比如1ms一个tick,那定时器精度就是1ms。你设个0.5ms的超时?不好意思,做不到。

核心概念:

  • tick:硬件中断的最小时间单位
  • 计数器:软件维护的累加值
  • 超时阈值:触发回调的边界值
  • 回调函数:时间到了要执行的代码

最简单的实现,就是维护一个链表,每个节点存超时时间和回调。每次tick中断,遍历链表,检查哪些节点超时了。但这样做有个致命问题——时间复杂度是O(n)。如果系统里有几百个定时器,每次tick都遍历一遍,CPU就全耗在这上面了。

我在项目中遇到过这种情况:一个网关设备,同时管理着200多个终端的保活定时器。用链表实现,每次tick中断要跑几百微秒,直接导致其他中断响应延迟。后来我换成了时间轮,问题才解决。

9.2 时间轮算法

时间轮,说白了就是一个「环形数组」。每个槽位代表一个时间刻度,槽位里挂着一个链表,存放该时刻要触发的定时器。

为什么叫「轮」?因为指针转一圈,所有定时器都检查一遍。就像钟表的秒针,转一圈就是60秒。

我画了个图,帮你理解一下:

0 4 6 2 7 1 5 3 当前指针 定时器A 定时器B 槽位2的链表

你看,这个时间轮有8个槽位(实际项目中通常是256个或更多)。每个tick,指针就往前走一格。指针指向的槽位里,所有定时器都到期了,依次执行回调。

这样做的好处是什么?插入和删除都是O(1)。只需要计算哈希槽位,然后挂到链表头就行。遍历也只需要处理当前槽位的链表,不用全扫一遍。

但有个问题:如果定时器超时时间大于一圈怎么办?比如槽位是256个,每个tick是10ms,那最大只能定时2560ms。超过这个数,就需要「多级时间轮」了。

多级时间轮,说白了就是多个时间轮串联。第一级转一圈,第二级走一格。就像钟表的秒针、分针、时针。Linux内核的timer wheel就是这么干的。

我的经验:对于大多数嵌入式场景,单级256槽位的时间轮已经够用了。如果定时范围不够,可以加大tick间隔,或者用多级。但多级实现起来复杂,调试也麻烦。我个人习惯先用单级,实在不行再升级。

9.3 定时器事件管理

有了时间轮这个数据结构,接下来就是怎么管理定时器事件了。我把它拆成三个核心操作:创建、取消、处理

9.3.1 创建定时器

创建定时器时,需要指定超时时间、回调函数、以及一个可选的用户参数。代码大概长这样:

typedef struct timer_node {
    uint32_t expire;          // 绝对超时时间(基于tick计数)
    void (*callback)(void *); // 回调函数
    void *arg;                // 用户参数
    struct timer_node *next;  // 链表指针
} timer_node_t;

timer_node_t *timer_create(uint32_t delay_ms, void (*cb)(void *), void *arg) {
    timer_node_t *node = malloc(sizeof(timer_node_t));
    if (!node) return NULL;
    
    // 计算绝对超时时间
    node->expire = get_current_tick() + ms_to_tick(delay_ms);
    node->callback = cb;
    node->arg = arg;
    node->next = NULL;
    
    return node;
}

这里有个细节:绝对超时时间。为什么不存相对时间?因为如果存相对时间,每次tick都要减1,效率低。存绝对时间,直接比较当前tick和expire就行。

9.3.2 启动定时器

创建完定时器,要把它挂到时间轮上。计算槽位索引:

void timer_start(timer_node_t *node) {
    uint32_t ticks = node->expire;
    int slot = ticks % WHEEL_SIZE;  // 取模得到槽位
    
    // 头插法挂到槽位链表
    node->next = wheel[slot];
    wheel[slot] = node;
}

嗯,这里要注意:如果定时器超时时间小于当前tick,说明已经过期了。这种情况要么立即执行,要么丢弃。我一般选择立即执行,毕竟用户设了超时,总得给个交代。

9.3.3 取消定时器

取消定时器,就是从链表里摘除节点。但链表删除需要遍历,最坏情况O(n)。怎么优化?

我常用的技巧是:延迟删除。给定时器加一个标志位,标记为「已取消」。在tick处理时,检查到标志位就跳过,不执行回调。等下次定时器被覆盖或重新分配时,再真正释放内存。

typedef struct timer_node {
    // ... 其他字段
    uint8_t canceled;  // 1表示已取消
} timer_node_t;

void timer_cancel(timer_node_t *node) {
    node->canceled = 1;  // 标记取消,不真正删除
}

这样做的好处是:取消操作变成了O(1)。代价是多占一个字节的内存,以及回调执行时多一次判断。对于嵌入式系统来说,这点代价完全可以接受。

避坑指南:我曾经在一个项目中,直接用free()释放定时器节点。结果回调执行时,节点已经被释放了,导致野指针崩溃。后来我改用延迟删除+引用计数,才彻底解决这个问题。

9.3.4 处理定时器事件

每次tick中断,时间轮指针前移,处理当前槽位的所有定时器:

void timer_tick(void) {
    static uint32_t current_tick = 0;
    current_tick++;
    
    int slot = current_tick % WHEEL_SIZE;
    timer_node_t *node = wheel[slot];
    
    while (node) {
        timer_node_t *next = node->next;
        
        if (!node->canceled && node->expire <= current_tick) {
            // 执行回调
            node->callback(node->arg);
        }
        
        // 释放节点(延迟删除的节点在这里真正释放)
        free(node);
        node = next;
    }
    
    wheel[slot] = NULL;  // 清空槽位
}

这里有个关键点:回调函数里不能做耗时操作。因为tick中断是硬实时上下文,如果回调里执行了阻塞操作,整个系统都会卡住。我一般建议回调里只做标记或投递事件,真正的处理放到主循环或任务里。

9.4 实际项目中的考量

讲完了原理,聊聊实际项目中要注意的几个点。

考量点 说明 我的建议
tick精度 硬件tick频率决定了定时精度 一般用1ms或10ms,别太细也别太粗
时间轮大小 槽位数量影响内存和范围 256槽位是黄金比例,内存占用小,范围够用
回调执行时间 不能在中断里做耗时操作 回调里只做标记,实际处理放到任务里
内存管理 定时器节点的分配和释放 用内存池,避免动态分配碎片
多线程安全 中断和任务可能同时访问时间轮 关中断或使用自旋锁保护

说到内存池,我习惯在系统初始化时就分配好一批定时器节点。比如预分配64个,用链表串起来。创建定时器时从池里取,释放时还回去。这样既避免了动态内存碎片,也提高了分配速度。

总结一下:

  • 定时器的底层是硬件tick,上层是软件计数器
  • 时间轮用空间换时间,插入删除都是O(1)
  • 延迟删除可以避免链表遍历,提高取消效率
  • 回调函数要轻量,耗时操作放到任务里
  • 内存池管理定时器节点,避免碎片和泄漏

好了,这一章的内容就到这里。定时器看似简单,但要做好、做稳,还是有不少门道的。希望我的这些经验能帮你少走一些弯路。


公众号:蓝海资料掘金营,微信deep3321