ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Linux内核软件定时器详解:从jiffies到时间轮算法

Linux内核软件定时器详解:从jiffies到时间轮算法 “拼凑内核”这套连载做到第三章第三节前两回我们已经把中断控制器、时钟芯片驱动和任务切换勉强跑通代码能在一个模拟环境下“活”起来。这一节要聊的软件定时器正好是整个内核里非常讨巧的一块基础设施它不直接碰硬件却依赖时钟中断这个“心跳”来驱动自己。你可能会说不就是一个到了时间就执行回调的机制吗实际写起来远没那么简单从数据结构怎么设计、时间怎么换算到高并发下怎么安全地删一个定时器坑一个接一个。这篇内容适合两类读者一类是正在跟做操作系统实验、想把手写内核里程碑补全的学生另一类是刚接触内核驱动开发、想弄明白timer_list和hrtimer到底怎么用的工程师。我会从“为什么需要软件定时器”讲起一路说到时间轮算法、实际驱动里的用法和调试经验尽量把教材里一笔带过的地方展开讲透。1. 软件定时器是什么为什么内核离不开它1.1 从硬件定时器到软件定时器硬件定时器是芯片上一组独立的计数器电路比如常见的可编程间隔定时器它由外部晶振提供时钟源计数到设定值后触发中断。问题在于一块板子上的硬件定时器通道非常有限而内核里需要“延迟执行”“超时判断”“周期性任务”的地方数不胜数网络协议栈要重传超时、块设备层要处理请求超时、内核线程要做周期调度统计。靠硬件定时器一个一个去满足完全不现实。所以内核把硬件定时器当作一个“节拍发生器”让它以固定频率产生时钟中断每次中断到来时软件定时器模块就被唤起一次检查哪些定时器到期然后执行对应的回调函数。这样一个或几个硬件通道就能支撑无数个软件定时器同时运行。软件定时器的本质就是把“时间维度上的事件调度”从硬件迁移到软件层面。它牺牲了一点精度换来的是数量上的几乎无上限。编写驱动时你申请一个软件定时器只是在内存里填了个结构体注册到内核的定时器链表里不再占用任何硬件资源。这东西在实战里的出场频率极高凡是驱动里需要“等一会儿再做事”的逻辑基本都要靠它。1.2 两个必需的基础概念时钟节拍与 jiffies理解软件定时器绕不开两个底层概念一个是时钟节拍tick一个是jiffies。系统开机后硬件定时器被编程为每1/HZ秒产生一次中断这个时间单位被称为一个 tick。HZ是一个编译期常量常见取值是 100、250、1000含义是每秒产生多少个时钟中断。如果HZ250那么每个 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. 时间换算的艺术从用户态的毫秒到内核的 jiffies3.1 常用换算函数清单应用层程序员用毫秒、微秒内核里的人却活在jiffies里。两种时间单位之间必须显式换算不能想当然地直接赋值。Linux 内核提供了一组语义清晰的辅助函数msecs_to_jiffies(m)毫秒转 jiffies向上取整usecs_to_jiffies(u)微秒转 jiffies向上取整timespec_to_jiffies(ts)结构体时间转 jiffiesjiffies_to_msecs(j)反向换算用于把内核时间输出给用户态为什么要“向上取整”因为内核定时器的到期检查粒度是 tick。当HZ1000时每个 tick 是 1 毫秒user 传一个 1.5 毫秒的延时如果不向上取整直接按照 1 毫秒算实际延时就会比请求时间短。向上取整能保证“只晚不早”这在超时类业务里是行规——宁可晚一点唤醒不能早一点误判超时。来看一个实际换算unsigned long delay msecs_to_jiffies(250); mod_timer(my_timer, jiffies delay);当HZ100时250 毫秒等于 25 个 jiffies取整后没有任何误差。当HZ1000时250 毫秒等于 250 个 jiffies。但如果传入 255 毫秒且HZ100因为一个 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的值写进去否则别人复现你的实验换个HZ1000的发行版内核得到的触发间隔和你的结论对不上排查半天发现是配置差异。我在指导某个学生项目时踩过这个坑后来所有实验文档第一行就固定写清环境参数这类问题再也没出现过。拼凑内核拼到定时器这一站后面的路就顺了——毕竟时间能“软”起来剩下的调度、超时、协作就都有地方落脚。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表