“拼凑内核”这套连载做到第三章第三节,前两回我们已经把中断控制器、时钟芯片驱动和任务切换勉强跑通,代码能在一个模拟环境下“活”起来。这一节要聊的软件定时器,正好是整个内核里非常讨巧的一块基础设施:它不直接碰硬件,却依赖时钟中断这个“心跳”来驱动自己。你可能会说,不就是一个到了时间就执行回调的机制吗?实际写起来远没那么简单,从数据结构怎么设计、时间怎么换算,到高并发下怎么安全地删一个定时器,坑一个接一个。
这篇内容适合两类读者:一类是正在跟做操作系统实验、想把手写内核里程碑补全的学生,另一类是刚接触内核驱动开发、想弄明白timer_list和hrtimer到底怎么用的工程师。我会从“为什么需要软件定时器”讲起,一路说到时间轮算法、实际驱动里的用法和调试经验,尽量把教材里一笔带过的地方展开讲透。
1. 软件定时器是什么,为什么内核离不开它
1.1 从硬件定时器到软件定时器
硬件定时器是芯片上一组独立的计数器电路,比如常见的可编程间隔定时器,它由外部晶振提供时钟源,计数到设定值后触发中断。问题在于,一块板子上的硬件定时器通道非常有限,而内核里需要“延迟执行”“超时判断”“周期性任务”的地方数不胜数:网络协议栈要重传超时、块设备层要处理请求超时、内核线程要做周期调度统计。靠硬件定时器一个一个去满足,完全不现实。
所以内核把硬件定时器当作一个“节拍发生器”:让它以固定频率产生时钟中断,每次中断到来时,软件定时器模块就被唤起一次,检查哪些定时器到期,然后执行对应的回调函数。这样,一个或几个硬件通道就能支撑无数个软件定时器同时运行。
软件定时器的本质,就是把“时间维度上的事件调度”从硬件迁移到软件层面。它牺牲了一点精度,换来的是数量上的几乎无上限。编写驱动时,你申请一个软件定时器只是在内存里填了个结构体,注册到内核的定时器链表里,不再占用任何硬件资源。这东西在实战里的出场频率极高,凡是驱动里需要“等一会儿再做事”的逻辑,基本都要靠它。
1.2 两个必需的基础概念:时钟节拍与 jiffies
理解软件定时器,绕不开两个底层概念,一个是时钟节拍(tick),一个是jiffies。
系统开机后,硬件定时器被编程为每1/HZ秒产生一次中断,这个时间单位被称为一个 tick。HZ是一个编译期常量,常见取值是 100、250、1000,含义是每秒产生多少个时钟中断。如果HZ=250,那么每个 tick 就是 4 毫秒,软件定时器的精度上限就锁死在 4 毫秒——你说我想定时 1 毫秒,老式的低精度定时器是做不到的,这是你先要接受的一个硬约束。
jiffies是内核里的全局“心跳计数器”,每次时钟中断它自增 1。它从开机开始累积,类型是unsigned long volatile,在 32 位系统上大约 497 天后会回绕一次,64 位系统上这个概念基本可以忽略。所有软件定时器的到期时间都以jiffies为单位表示。
一个定时器的生命周期是这样的:初始化时填好回调函数和参数,设置一个到期时间差,比如“10 个 jiffies 之后”,然后挂到内核的管理结构中。之后每来一次时钟中断,内核就把jiffies加 1,并检查有没有到期项。这种设计把硬件只需要负责“周期性发中断”这一件事,其余全部逻辑都收拢到通用的软件框架里。
2. 定时器对象与常用 API:一切从数据结构开始
2.1 struct timer_list 是怎么设计的
经典低精度定时器的结构体定义,不同内核版本略有差异,但核心成员一直是那几项。我以比较普及的内核版本为参考:
struct timer_list { struct hlist_node entry; // 挂入时间轮散列表的节点 unsigned long expires; // 到期时刻,单位:jiffies void (*function)(struct timer_list *); // 回调函数 u32 flags; // 标志位 } __randomize_layout;entry用来把定时器节点挂到内核的时间轮桶里,这个成员在用户视角不需要碰。expires是绝对到期时间,注意是“绝对值”而不是“还要等多久”。如果现在jiffies是 10000,你想等 20 个 tick,就要填 10020,而不是填 20。这个细节新手经常搞反,一旦填了相对值,定时器要么立即触发,要么等到系统运行很久很久之后才轮到它,看起来就是“定时器完全不可用”。
function是回调函数指针,它的入参是struct timer_list *,也就是说回调函数能拿到定时器结构体本身。经典教材里老版本内核经常写:
void my_callback(unsigned long data)这是 2.6 时代的老接口。现在统一改成传入struct timer_list *,好处很明显:回调里可以直接用container_of通过定时器结构找到宿主数据结构,不需要依赖一个裸的unsigned long参数。比如你在一个设备结构体里内嵌了定时器,回调函数里就能通过container_of找回整个设备结构体。
flags字段在正常驱动开发时通常直接传 0。它内部可能携带一些与 CPU 亲和性相关的标记,但这是内核框架自己用的,驱动层一般不需要主动设置。
2.2 初始化的正确姿势:timer_setup
初始化接口现在统一使用timer_setup,它把setup_timer那套老 API 淘汰了。一个典型的初始化是这样的:
static struct timer_list my_timer; static void my_timer_handler(struct timer_list *t) { // 定时器到期的处理逻辑 } static int __init xxx_init(void) { timer_setup(&my_timer, my_timer_handler, 0); // 此时定时器还没挂到时间轮上,只是完成了对象初始化 return 0; }timer_setup会把回调函数指针填好,把节点的链表指针清空,标志位置好。这一步只做“对象初始化”,不参与调度。很多不熟悉内核框架的人以为初始化完就开始计时了,其实还差两步:设置到期时间,把定时器真正挂载到时间轮桶中。
独立设置到期时间可以用timer->expires = jiffies + x,但更推荐用辅助函数:
mod_timer(&my_timer, jiffies + msecs_to_jiffies(100));mod_timer是“修改到期时间并挂载”的复合操作。如果定时器已经在时间轮里,它会把旧的桶节点摘下来,按新到期时间重新插入;如果定时器还没挂载,它就直接以新到期时间挂进去。这个接口内部会做必要的同步保护,是驱动开发里最常用的一个入口。
还有一对容易混淆的删除接口。del_timer只是把节点从时间轮里摘下来,不关心是否已注册,也不关心是否正在其他 CPU 上执行回调流程。del_timer_sync额外保证:当函数返回时,该定时器的回调函数一定没有在其他 CPU 上运行或者已经运行完毕。在驱动卸载路径上,最稳妥的选择是del_timer_sync。
2.3 改期:mod_timer 的语义坑
mod_timer的语义比很多入门文档写的更强。除了“改到期时间”,它实际上等价于“若已激活则先删除、再按新时间激活”。这意味着一个已激活的定时器可以反复用mod_timer调整时间,实现“滚动定时器”,也就是周期性任务。
但这里有第一个值得警惕的坑:mod_timer返回 0 表示定时器之前是未激活的,返回 1 表示它之前已经激活并被重新调度了。有些驱动代码只关心“定时器有没有被激活过”,就可以利用这个返回值做状态判断。不过我要提醒你,别在工作量很大的回调函数里反复高频调用mod_timer,那会引入不必要的锁竞争和处理器间干扰。批量执行周期任务时,更推荐用hrtimer或者内核工作队列配合延时函数。
3. 时间换算的艺术:从用户态的毫秒到内核的 jiffies
3.1 常用换算函数清单
应用层程序员用毫秒、微秒,内核里的人却活在jiffies里。两种时间单位之间必须显式换算,不能想当然地直接赋值。Linux 内核提供了一组语义清晰的辅助函数:
msecs_to_jiffies(m):毫秒转 jiffies,向上取整usecs_to_jiffies(u):微秒转 jiffies,向上取整timespec_to_jiffies(ts):结构体时间转 jiffiesjiffies_to_msecs(j):反向换算,用于把内核时间输出给用户态
为什么要“向上取整”?因为内核定时器的到期检查粒度是 tick。当HZ=1000时,每个 tick 是 1 毫秒,user 传一个 1.5 毫秒的延时,如果不向上取整,直接按照 1 毫秒算,实际延时就会比请求时间短。向上取整能保证“只晚不早”,这在超时类业务里是行规——宁可晚一点唤醒,不能早一点误判超时。
来看一个实际换算:
unsigned long delay = msecs_to_jiffies(250); mod_timer(&my_timer, jiffies + delay);当HZ=100时,250 毫秒等于 25 个 jiffies,取整后没有任何误差。当HZ=1000时,250 毫秒等于 250 个 jiffies。但如果传入 255 毫秒且HZ=100,因为一个 tick 是 10 毫秒,25.5 必须向上取整到 26 个 tick,实际到期时间就变成了 260 毫秒,误差 5 毫秒。
注意:在模块代码中做了msecs_to_jiffies之后,不要再试图把结果转换成毫秒传回用户态。内核内部的时间处理必须保持在 native 单位里进行,反复横跳只会引入无谓的精度损失。
3.2 HZ 的取值是个权衡,不是越大越好
有人觉得,把HZ配成 1000,定时器精度变成 1 毫秒,岂不是更好?事情没有这么简单。
HZ越大,每个 tick 间隔越短,做一次进程调度、检查定时器、更新系统统计的频率就越高。这些工作全部叠加在中断上下文里,会吃掉不少 CPU 周期。桌面内核倾向于 250 或 1000 让交互更顺滑,服务器内核反而倾向 100,因为大批量计算任务根本不在乎这几毫秒的定时粒度,省下中断开销才是实打实的收益。
低精度软件定时器的精度上限就是/HZ秒。有些课程实验把HZ设成 10,那定时器误差高达 100 毫秒,做简单的 LED 闪烁没问题,做网络重传计时就完全不可用。所以你写代码时,凡是精度敏感的场合要对HZ足够敏感;凡是要求微秒级定时的场合,就该扭头去看hrtimer,而不是指望提高HZ来硬扛。
3.3 和 real time 的差异:定时器到点了,不代表现在是那个时刻
软件定时器的到期事件发生在时钟中断上下文里,它表示的是“系统运行了足够多的 tick”,而不是“现实世界过去了准确的时间”。系统休眠、内核被抢占、中断长时间关闭,都会让定时器触发晚点。如果你在用户态通过gettimeofday去测量定时器到点的真实时刻,会发现它可能偏差几毫秒甚至更久。
这在实时性要求到“必须准点”的应用里是不能接受的。结论很简单:低精度定时器适合做“相对延时、超时判断、轮询节流”,不适合做“硬实时调度”。要硬实时,用hrtimer,它基于硬件时钟提供的精确时间线,与jiffies解耦。
4. 定时器核心机制:从朴素链表到多级时间轮
4.1 朴素链表实现为什么不行
如果让你设计一个最简单的定时器框架,脑子里第一反应肯定是:维护一个全局有序链表,每个节点记录到期时间。时钟中断来了,从链表头开始检查,凡是expires <= jiffies的节点都取出来执行,然后把它们从链表里摘除。插入时按到期时间排序,保持链表有序。
这个方案的瓶颈非常明显。插入一个定时器的时间复杂度是 O(n),因为要沿着链表找到合适的位置;最坏情况下,系统的定器数量如果有数千个,每次插入都是一次大规模的线性扫描。海量定时器情况下,中断处理函数里做这种高成本操作是不可接受的。你想想,网络栈里每个连接可能同时挂着若干超时定时器,高并发服务器上定时器数量轻松上万,线性扫描几万个节点,中断延迟会被拖垮。
4.2 多级时间轮的核心思想
内核采用的多级时间轮,本质上是“以空间换时间”的典型代表,也是各种高性能定时器方案的基础思想。
想象钟表上的表盘:秒针走一圈 60 格,每走一格是 1 秒;分针 60 格,时针 12 格。秒针走完一圈,分针动一格;分针走完一圈,时针动一格。多级时间轮完全复刻了这种“进制”思想。
具体到内核:时间轮有 5 个级别,每个级别是一个桶数组。第一级桶数量较多,每个桶代表一个 tick;后面每个级别的桶数量变少,但每个桶代表的 tick 数成倍放大。当第一级的某个桶轮转到位时,如果发现里面有定时器,就把他们从第一级的桶取出来,重新按剩余时间散列到更精确的级别里去。这个动作叫“级联”。
在中断处理里,内核只需要看当前 tick 对应第一级的哪个桶,把那个桶里的定时器全部取出来执行即可。每过固定数量的 tick,才需要把下一级的桶展开、重新散列,所以平均下来每个 tick 的开销非常小,插入和删除还是 O(1) 时间。它跟有序链表最大的差别在于,不排序,只散列到固定的时间区间里。
4.3 索引计算:一个简化的时间轮推导
我用一个简化版本来演示索引计算逻辑,帮助你建立直觉。假设第一级桶数组有 8 个桶,时间范围是一个 8-tick 周期。现在jiffies当前值是base,某个定时器到期值是expires。它在第一级桶里的索引可以直接用:
index = (expires & 7);因为底层是 8 个桶,取低 3 位即可。问题在于,当第一级轮转多次以后,只靠低 3 位就无法区分这个到期时间落在哪一个轮次里。所以需要二级轮子。
两级时间轮做法是:第一级 8 个桶,每格 1 个 tick;第二级也是 8 个桶,但每格代表“第一级完整轮转一圈”的长度。第二级一个格子里挂的定时器,到期时间离jiffies的距离会是“几个完整轮”加“桶内偏移”。计算第二级索引时,需要先算出时间差,再除以第一级周期,得到“圈数”,然后对第二级取模。
处理流程是这样的:
- 每次 tick,检查第一级对应的桶,执行桶内所有到期定时器。
- 当第一级转完一整圈,触发一次提升,把第二级当前桶里的定时器全部取出。
- 对每个取出的定时器,按新的剩余时间重新放回第一级的某个桶,或者放到更高一级。
这就是级联定时器的基本运作。真实内核里的时间轮比这个演示复杂,但底层的“区间划分、级联、O(1) 插入删除”核心一模一样。在实现自己的小内核时,你完全可以先用这个简化版本,先把功能跑通,再去考虑匹配真实内核的实现细节。
重要提醒:写内核代码时,别自己去实现一套多级时间轮替代内核自带定时器,除非你是在课程作业里自己拼一个微型内核。真实内核的时间轮已经处理了 SMP 并发、回调上下文、延迟迁移等各种边界,自己造轮子很难在正确性上胜过它。
5. 实操:在一个内核模块里用好软件定时器
5.1 场景设计与完整示例
现在我们把知识落到代码上。场景:写一个非常简单的内核模块,它提供一个 proc 节点,用户往节点写一个毫秒数值,模块就在指定时长后打印一条日志,并自动把时间重置为下一个周期,实现周期性打印。
#include <linux/module.h> #include <linux/kernel.h> #include <linux/timer.h> #include <linux/jiffies.h> #include <linux/proc_fs.h> #include <linux/uaccess.h> static struct timer_list demo_timer; static unsigned long interval_jiffies = HZ; /* 默认 1 秒 */ static void demo_timer_callback(struct timer_list *t) { pr_info("demo timer fired at %lu\n", jiffies); /* 周期持续运行:把到期时间改到当前时间再加一个间隔 */ mod_timer(&demo_timer, jiffies + interval_jiffies); } static ssize_t demo_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[32]; unsigned int msecs = 0; if (count >= sizeof(kbuf)) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] = '\0'; if (kstrtouint(kbuf, 10, &msecs)) return -EINVAL; if (msecs == 0) interval_jiffies = HZ; else interval_jiffies = msecs_to_jiffies(msecs); mod_timer(&demo_timer, jiffies + interval_jiffies); pr_info("timer interval updated to %u ms\n", msecs); return count; } static const struct proc_ops demo_proc_ops = { .proc_write = demo_write, }; static int __init demo_init(void) { timer_setup(&demo_timer, demo_timer_callback, 0); mod_timer(&demo_timer, jiffies + interval_jiffies); proc_create("demo_timer", 0666, NULL, &demo_proc_ops); pr_info("demo timer module loaded\n"); return 0; } static void __exit demo_exit(void) { remove_proc_entry("demo_timer", NULL); del_timer_sync(&demo_timer); pr_info("demo timer module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL");这个模块的设计有几个值得琢磨的点。
第一,回调函数里把mod_timer放在最后一行,而不是放在回调一开始,这样能避免一种竞争:如果定时器刚被触发,此刻还没有重新排队,用户在 proc 写操作里改时间,边界的顺序就变得可控。先重新排期,再打印日志是更好的做法,但为了代码简洁,放在pr_info之后也问题不大——只要你不在这两者之间做状态清理。
第二,del_timer_sync是卸载时最关键的一行。模块退出函数里,先删除 proc 节点,防止新用户操作进来;再同步删除定时器,保证回调函数已经不在任何处理器上运行,之后才能放心释放模块申请的资源。顺序不能反,如果先删定时器再删 proc 节点,中间窗口期用户还是可能改定时器的到期时间,卸载路径上就多了一个需要同步的变量。
第三,用户传入 0 毫秒时的处理。把它当成重置为默认值,这里使用 0 作为非法值是刻意设计,避免msecs_to_jiffies(0)直接生成 0 个 jiffies 导致定时器立刻触发,让系统陷入空转。
5.2 回调函数里什么事情绝对不能做
如果说定时器 API 是形式,那回调函数的约束才是灵魂。软件定时器的回调运行在软中断上下文,准确说是TIMER_SOFTIRQ这种软中断环境里,不是进程上下文。这里有几条高压线:
- 不能调用
sleep或任何可能睡眠的函数,比如msleep、wait_event、mutex_lock。睡眠会导致软中断卡死,进而连累整个系统的定时器分发。 - 不能调用
kmalloc并带有GFP_KERNEL标志,因为分配内存可能需要阻塞来回收页面。这时候要用GFP_ATOMIC,但也要尽量少用。 - 不能直接操作用户态内存,比如通过指针访问用户空间地址。因为此时没有合法的进程地址空间上下文。
- 不能长时间自旋忙等,因为软中断期间本地中断处理也受约束,长时间忙等会让其他中断延迟剧增。
如果回调里确实需要做耗时操作,标准做法是立刻把一个工作项提交到 Linux 工作队列,定时器回调只是“叫醒服务”,真正干活的人在进程上下文里运行。用一句话概括:定时器回调是门铃,不是厨师。门铃响了,厨师才从休息室出来开火,你不能让门铃自己把一整桌菜做出来。
static void timer_cb(struct timer_list *t) { /* 只投递,不干活 */ schedule_work(&my_work); }5.3 周期定时器三种写法的取舍
实现周期任务有三种常见写法,我也给新手一个选择依据。
第一种,回调函数末尾再调用mod_timer。优点是代码简单;缺点是如果回调执行时间波动较大,周期会跟着漂移,因为它总是从“回调执行时刻”重新计算下个到期点。
第二种,在回调开头记录jiffies,末尾用“基准时刻加上固定间隔”重新设置到期时间。这能让周期尽量逼近理想值,但要小心如果回调执行时间已经超过一个周期,就会出现到期时间落在当前时间之前的情况,mod_timer会对过去时间做什么?它会直接通知你马上到期,导致连续触发,俗称“追赶”。
第三种,用hrtimer的HRTIMER_MODE_REL或HRTIMER_MODE_ABS模式实现高精度周期任务。虽然更复杂,但能利用硬件时钟的精确性。比起纠结低精度定时器的周期漂移,直接提升定时器精度往往更省心。
我个人写驱动时,除非目标是极简验证流程,否则周期任务优先考虑用内核工作队列的schedule_delayed_work,它自带延迟调度,语义也更适合跑流程较重的逻辑。
6. 常见问题与调试实录
6.1 症状:定时器从不触发
如果加载模块后日志里一次回调都没出现,先别急着怀疑 API 用法。多数情况是下面三个原因之一。
第一个原因是到期时间设置为相对值而不是绝对值。比如写的是timer.expires = 100,意思想表达“等 100 个 tick”,实际语义却是“系统运行到 100 个 tick 时就触发”。如果系统已经跑了几十万个 tick,这个定时器永远等不来。解决方式是在打印日志时把jiffies也输出,对比一下当前值和到期值。
第二个原因是定时器根本没有被挂到时间轮上。只调用了timer_setup,没调add_timer或mod_timer,定时器只是初始化了,但不在任何桶里。检查代码里有没有真的执行挂载操作,以及挂载操作是否在 error path 里被跳过。
第三个原因是回调函数本身出错导致模块崩溃,但这个症状往往不是“从不触发”,而是触发后系统死掉或者模块 unload 失败。用 dmesg 抓一下崩溃的栈信息,基本都能定位到回调函数的哪一行出了问题。
我在调试时习惯在初始化和回调入口各打一条带jiffies的日志,这样能立刻看到时间是否在推进、定时器是否到期、回调是否入库。
6.2 症状:模块卸载时死锁或者崩溃
卸载时遇到死锁,首先要想到del_timer_sync和回调函数的锁顺序问题。假设回调函数持有一个锁 A,卸载路径先拿到同一个锁 A,再去调用del_timer_sync等回调结束,这种情况下必定死锁。因为回调在等锁,而持锁方在等回调结束。
这类死锁的本质是“回调函数和卸载路径共享了同一把锁”。解决办法是不要让卸载路径持锁去等待定时器同步结束,可以在释放锁之后再做del_timer_sync;或者回调函数内部只用无锁的原子变量传递状态。
如果卸载时直接崩溃,大概率是del_timer_sync之后仍然访问了回调函数可能用到的数据。比如某个设备结构体已经被kfree,而回调函数里还通过container_of把这个结构体取出来用。标准做法是:先del_timer_sync保证回调不再运行,再释放相关数据结构。次序错了,就是“定时器先释放、回调后访问”的悬垂指针灾难。
6.3 症状:定时器回调里睡眠导致系统异常
新手最容易踩的就是在回调函数里调用msleep(10)。表面看只是一次毫秒级睡眠,实际上软中断上下文不允许睡眠,内核会在调度器入口处打出BUG: scheduling while atomic的警告,系统进入不稳定状态,时不时无响应。
要彻底扭转自己的直觉:不是“尽量别在回调里睡眠”,而是“回调里绝对不许睡眠”。耗时工作全部外包给schedule_work或者queue_work。频繁触发的高频定时器,外包时要注意合并请求,避免产生堆积的大量工作项,可以考虑使用work_pending之类的判断来防止重复提交。
附上一份我在教学时常用的排查速查表,可以帮你快速定位问题:
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 定时器完全不触发 | expires 填了相对值、没挂载 | 打印 jiffies 对比;检查 add_timer/mod_timer |
| 触发时间偏晚 | HZ 太小、回调被抢占 | 核对 HZ 配置;区分开软件定时器与 hrtimer |
| 卸载时死锁 | 锁顺序问题 | 分析卸载路径与回调的锁依赖 |
| 卸载后崩溃 | del_timer_sync 之后数据被释放 | 调整释放顺序:先同步删除定时器,再释放结构体 |
| 系统警告 scheduling while atomic | 回调中睡眠 | 改用工作队列;禁止在回调里调用可睡眠函数 |
| 定时器被重复触发 | 回调里 mod_timer 逻辑有误 | 检查边界条件,保证至少有一个固定间隔间隔 |
6.4 调试技巧:利用软中断上下文信息
当你在dmesg里看到BUG: scheduling while atomic这类消息时,它其实带着一份调用栈,足以指出睡眠发生的位置。不要只盯着最后一行BUG标题,往上翻,找__might_sleep调用之前的函数链,找到的是你的定时器回调被调用的入口。
如果系统只是卡死而不报错,优先怀疑锁的问题而非死循环。软中断里死循环的现象是所有 CPU 软中断被卡住,而普通用户态任务可能还能运行。这种区分方式比无脑打印日志更高效。配合perf采样可以更精准定位热点,不过很多人忽略了,这里也算一个经验之谈。
6.5 一个容易忽视的提升点:定时器对象的复用注意
定时器对象可以被反复mod_timer,但它不能被同时挂到两个不同的执行路径上。比如,驱动里如果有两个线程都想操作同一个定时器,各自调用mod_timer,最后的到期时间取决于后调用的那个。这种竞争在低并发场景下可能无害,但在高并发驱动里可能导致一个事件的超时判断被另一个无关请求覆盖。
解决办法很简单:要么保证代码里只有一个执行路径能修改定时器,要么为每个需求创建独立的定时器实例,要么用spinlock保护对定时器的所有操作。我在实际项目中偏好“独立实例”,因为锁虽简单,但一旦回调里再操作同一把锁,死锁风险就悄悄爬上来了。独立实例让锁定边界更清晰,是一个工程直觉上的加分选项。
收尾之前,一句话给你兜底
软件定时器这个模块,说简单也简单,说难也难。简单在于它的 API 只有初始化、改期、删除三个动作;复杂在于它和时钟中断、软中断、并发安全、锁依赖这些问题紧紧抱在一起。在我带过的新人里,能把mod_timer用得顺手的人不算少,但能同时说清楚“为什么回调不能睡、为什么卸载要先删 proc 再删定时器、为什么 expires 必须填绝对值”的,基本都能算对内核调度有点真理解了。
最后一件事分享给做实验的同学:HZ设置成 100 做演示没有什么问题,但你在文档里描述定时器精度时记得把CONFIG_HZ的值写进去,否则别人复现你的实验换个HZ=1000的发行版内核,得到的触发间隔和你的结论对不上,排查半天发现是配置差异。我在指导某个学生项目时踩过这个坑,后来所有实验文档第一行就固定写清环境参数,这类问题再也没出现过。拼凑内核拼到定时器这一站,后面的路就顺了——毕竟,时间能“软”起来,剩下的调度、超时、协作就都有地方落脚。