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秒。
我画了个图,帮你理解一下:
你看,这个时间轮有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)
- 延迟删除可以避免链表遍历,提高取消效率
- 回调函数要轻量,耗时操作放到任务里
- 内存池管理定时器节点,避免碎片和泄漏
好了,这一章的内容就到这里。定时器看似简单,但要做好、做稳,还是有不少门道的。希望我的这些经验能帮你少走一些弯路。