Linux Device Drivers-第七章 时间, 延迟及延缓操作

张开发
2026/4/22 20:59:30 15 分钟阅读

分享文章

Linux Device Drivers-第七章 时间, 延迟及延缓操作
让我门看看内核代码是如何对时间为题进行处理的并按由简到难的顺序逐步讨论包括①如何衡量时间差②如何获得当前时间③如何将操作延迟一段时间④如何调度异步函数到指定的时间之后执行。7.1 测量时间差内核通过定时中断来度量时间流动中断在第10章详述。硬件定时器 → 中断 → HZ → jiffies → 驱动用 jiffies 做延时/超时硬件定时器启动时内核根据HZ编程确定。每发生一次时钟中断内核内部计数器加一该值在系统引导时初始化为0因此该值就是自上次操作系统引导以来的时钟滴答数被称为 jiffies_64, jiffies 可能是其底32位jiffiy机制是内核管理的低分辨率计时机制某些平台还具备软件可读高分辨率计数器7.1.1. jiffies 计数器(ms级精度、全局、可移植)所有架构都有 jiffies可移植性高。作为Linux程序员你需要了解如何使用jiffies进行计时比如计算1s后的jiffies的值,然后缓存起来这包括原理jiffies的本质是个全局变量。记录系统启动以来经过了多少个“时钟滴答”Tick。HZ是内核配置的时钟中断频率单位是Hz每秒中断次数。定义在./arch/arm64/include/uapi/asm/param.h或其他文件。jiffies与HZ的关系HZ 是“每秒多少 jiffies”。HZ100 → 1 jiffy 10 毫秒HZ1000 → 1 jiffy 1 毫秒可以在编译kernel时在系统配置界面设置比如CONFIG_HZ250。这个值越大则频率越快则CPU需要处理的中断数越多但现在的CPU性能强了不在乎这点儿精度较低。最小分辨率通常是 1ms ~ 10ms。无法测量微秒us甚至纳秒ns级别的代码执行时间。用途用于超时控制、延时较长的等待、统计运行时间等。头文件linux/sched.hjiffies_64和jiffies其实是同一个东西jiffies_64用于64位系统而jiffies用于32位系统。为了兼容不同的硬件jiffies其实就是jiffies_64的低32位。不管是32位还是64位的jiffies都有溢出的风险溢出以后会重新从0开始计数相当于绕回来了因此有些资料也将这个现象也叫做绕回。假如HZ为最大值1000的时候32位的jiffies只需要49.7天就发生了绕回对于64位的jiffies_64来说大概需要5.8亿年才能绕回因此jiffies_64的绕回忽略不计。处理32位jiffies的绕回显得尤为重要Linux内核提供了如表30.1.1.1所示的几个API函数来处理绕回防止手动对比时因绕回导致的错误# 位于include/linux/jiffies.h 提供了如下宏定义#unkown通常为 jiffiesknown 通常是需要对比的值。inttime_after(unsignedlog unkown,unsignedlongknown);# unkown 超过 known的话返回真inttime_before(unsignedlog unkown,unsignedlongknown);# unkown 没超过 known的话返回真inttime_after_eq(unsignedlog unkown,unsignedlongknown);# unkown 超过或等于 known的话返回真inttime_before_eq(unsignedlog unkown,unsignedlongknown);# unkown 没超过或等于 known的话返回真使用jiffies判断超时的小例子unsignedlongtimeout;timeoutjiffies(2*HZ);/* 超时的时间点 *//************************************* 要被度量时间的代码逻辑 ************************************//* 判断有没有超时 */if(time_before(jiffies,timeout)){/* 超时未发生 */}else{/* 超时发生 */}为何在32位处理器上直接读取 jiffies_64 的值不可靠因为在32位机器上对 jiffies_64 的读取分两次进行这期间可能发生更新从而获取错误的值。应该使用内核提供的u64 get_jiffies_64(void)辅助函数该函数完成了适当的锁定。为了方便开发Linux内核提供了几个**jiffies和 ms、us、ns 之间的转换函数**#includelinux/jiffies.h//头文件// 将毫秒、微秒、纳秒转换为 jiffies 类型。unsignedlongmsecs_to_jiffies(constunsignedintm)unsignedlongusecs_to_jiffies(constunsignedintu)unsignedlongnsecs_to_jiffies(u64 n)// 将 jiffies 类型的参数 j 分别转换为对应的毫秒、微秒、纳秒。intjiffies_to_msecs(constunsignedlongj)intjiffies_to_usecs(constunsignedlongj)u64jiffies_to_nsecs(constunsignedlongj)内核提供了哪些函数用于完成jiffies与用户空间时间表述法struct timespec之间的转换#includelinux/jiffies.h// 用户时间 → jiffiesunsignedlongtimespec64_to_jiffies(conststructtimespec64*value)// jiffies → 用户时间jiffies_to_timespec64(constunsignedlongjiffies,structtimespec64*value);7.1.2. 处理器特定的寄存器ns/us级精度如果需要度量非常短的时间或是需要极高的时间精度就可以使用高精度时钟了由于 CPU 内部的不确定性缓存等导致执行时间在ns/us级剧烈波动而jiffies的粒度是ms级它完全无法捕捉到这些波动。*。现在处理器中由于缓存指令调度分支预测等技术的应用在大部分的cpu设计中指令时序本质上是不可预测的这样依赖于指令周期的经验型性能描述方法就不再使用为了解决这一问题CPU制造商引入了一种通过计算时钟周期来度量时间差的简便而可靠的方法绝大多数现在处理器都包含一个随时钟周期不断递增的计数寄存器。这个时钟计数器是完成高分辨率计时任务的唯一可靠路径。不同平台对该寄存器的实现不同ARMv8 架构有一个标准的通用计时器 (Generic Timer / Architectural Timer)。(x86 叫 TSC, ARM 叫 Generic Timer, 代码不通用)。自 ARMv7 晚期和所有 ARMv8 (Aarch64, 包括 Cortex-A55) 起这是强制要求实现的硬件特性。(在IMX93RM手册25.2.6和LX2160A手册Chapter 7以及ARM官方文档的’Generic Timer.pdf’文件中都有介绍)原理这个时钟是平台无关的他的来源是外部晶振。但是可能可以通过中间的PLL分频器调整但跟CPU工作频率无关举例阅读imx93RM - 25.2.6 Timestamp Generation 一节即可发现一个 System Counter 的计数器为所有的核心提供时钟。64.1.3 Global System Counter (SCTR) 节指出该时钟通过总线连接Cortex-A55 MPCore generic timers。多时钟域同步。这个imx93的 System Counter 外接24M物理时钟正常模式可以认为频率与CPU频率无关。看lx0146RM也会发现系统时钟来源于一个外部100M时钟。不要直接写汇编去读寄存器用内核封装好的宏/函数虽然平台不同但内核还是贴心的统一了接口来获取单调时间get_cycles()-头文件:linux/timex.h功能: 返回当前的时钟周期数 (cycles_t)。对应关系:x86: 编译为rdtsc指令。ARM: 编译为读取CNTVCT_EL0寄存器。注意: 返回的是“周期数”不是“纳秒”。你需要知道频率才能换算。某些古老架构如果不支持可能返回 0。ktime_get()(首选)待确认可能是错误的头文件:linux/ktime.h功能: 返回ktime_t类型内部已经是纳秒 (ns)单位。优势: 自动处理了周期到时间的换算且自动选择高精度时钟源hrtimer。用法:ktime_tstartktime_get();// ... code ...s64 nsktime_us_delta(ktime_get(),start);// 直接得到微秒差用户态代码有没有使用到这个计数器的接口有而且非常常用。Linux 提供了多种机制让用户态安全地读取这些寄存器而无需陷入内核System Call在用户态需要高性能计时时用比如游戏引擎高频交易工业协议同步如EtherCAT,用户态调用方法clock_gettime()(标准 POSIX 接口——最通用)#includetime.hstructtimespects;// CLOCK_MONOTONIC_RAW 通常直接映射到硬件计数器不做 NTP 修正速度最快clock_gettime(CLOCK_MONOTONIC_RAW,ts);时钟 ID特性CLOCK_REALTIME系统真实时间可被 NTP/settimeofday 修改类似gettimeofdayCLOCK_MONOTONIC单调递增时间不受系统时间调整影响适合测量间隔 测时间间隔一律用这个CLOCK_MONOTONIC_RAW更原始的单调时间跳过 NTP 频率调整CLOCK_BOOTTIME包含系统 suspend 时间的单调时间从开机算起7.2 获取当前时间内核很少用本节介绍内核中获取时间的不同方式及其适用场景。驱动开发中90% 场景应使用“单调时间”。只有极少数需要与外部世界同步的场景才用“真实时间”。jiffies测量时间间隔的首选看上文。避免在驱动中处理“墙上时间”Wall-clock Time墙上时间指的是年月日时分格式的时间。mktime已经废弃。如果非要获取未验证#include linux/timekeeping.h // 方式1填充 struct timespec64推荐 void ktime_get_real_ts64(struct timespec64 *ts); // 方式2直接返回 ktime_t真实时间 ktime_t ktime_get_real(void); // 方式3直接返回纳秒数u64 u64 ktime_get_real_ns(void);❌ 不鼓励直接读取xtime变量因为很难原子地访问timeval变量的两个成员。已经废弃do_gettimeofdaycurrent_kernel_time7.3 延迟执行7.3.1. 长延时长于一个滴答时钟的延时。先这样粗略的区分7.3.1.1. 忙等待过时“LDD3 中的/proc/jitbusy是一个教学用的反面示例用于展示忙等待的危害。该接口并非内核标准功能需手动编译示例模块才存在。在现代驱动开发中应绝对避免此类实现而使用msleep()或高精度定时器等可调度、低功耗的延时机制。7.3.1.2. 让出处理器因为忙等待使得CPU负担大如何优雅地处理延时Delay以避免浪费CPU资源呢。先引出不推荐的做法可以通过在循环中直接调用schedule()进行延时该函数在linux/sched.h中声明。在这种方法时如果系统中没有其他进程需要运行调度器会立即再次选中该进程导致它实际上仍在空转自旋无法让真正的“空闲任务”Idle Task进程号0运行。无法让CPU进入低功耗模式这是种伪休眠。现代内核开发中绝对禁止在驱动中使用while(time_before(...)) { schedule(); }这种模式来做延时。如果调度器在忙其他的这个进程还不会按照期望的时间得到调度。现代内核中正确的延时和等待机制短时间延时 (原子上下文/中断上下文中使用)如果需要在极短时间内微秒级等待且不能睡眠udelay(unsigned long usecs)忙等待微秒。适用于非常短的延时通常1ms此时忙等待的开销是可以接受的。ndelay(unsigned long nsecs)纳秒级忙等待。较长时间延时 (可睡眠上下文中使用)如果需要毫秒级或更长的延时且当前上下文允许睡眠即不在中断处理程序或持有自旋锁时msleep(unsigned int msecs)最推荐。让当前进程进入TASK_UNINTERRUPTIBLE状态睡眠指定毫秒数。这是替代文中schedule()循环的正确做法。msleep_interruptible(unsigned int msecs)类似msleep但可以被信号唤醒TASK_INTERRUPTIBLE。适用于用户空间触发的操作允许用户在等待时通过 CtrlC 终止。usleep_range(unsigned long min, unsigned long max)高精度推荐。用于微秒级的睡眠通常10us - 20ms。它允许内核根据系统负载和定时器精度在最小和最大值之间选择一个最优的唤醒时间比固定的udelay更节能比msleep更精准。等待特定事件 (替代轮询)如果是在等待硬件状态变化而不是单纯的时间流逝等待队列 (Wait Queues)使用wait_event(),wait_event_interruptible(),wait_event_timeout()等宏。进程主动进入睡眠直到硬件中断或其他进程调用wake_up()唤醒它。参见【6.2.2 简单休眠 - 等待队列】小节。wait_event_timeout基于等待队列的超时。适用于驱动既在等待某个硬件事件通过wake_up唤醒又需要设置一个保底时间的场景。如果超时时间到函数返回0如果被提前唤醒返回剩余的jiffies时间。schedule_timeout基于调度器的超时。适用于单纯的“延时”场景不需要等待特定的队列事件。原理是将当前进程状态改为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE然后调用schedule_timeout进入休眠直到时间流逝。7.3.1.3. 超时这段文字的核心在于教导驱动开发者不要使用“忙等待”空循环来实现延时而应该让出CPU使用内核提供的调度机制来实现“休眠”。7.3.2. 短延时短时间延时 (原子上下文/中断上下文中使用)如果需要在极短时间内微秒级等待且不能睡眠#include linux/delay.h具体跟体系架构有关mdelay(unsigned long nsecs)纳秒级忙等待。udelay(unsigned long usecs)忙等待微秒。适用于非常短的延时通常1ms此时忙等待的开销是可以接受的。ndelay(unsigned long nsecs)纳秒级忙等待。注意这些等待操作都是盲等。且函数的输入值有限制要根据你要延时的长度选择合适的函数。7.4 内核定时器内核定时器用于调度未来特定时间执行函数无需阻塞当前进程适用于硬件轮询资源回收等场景其核心特征是异步执行定时器函数运行于中断上下文而非原进程上下文因此原进程可能已经睡眠、切换CPU或退出。由于缺乏进程上下文定时器函数必须遵守原子操作规则严禁访问用户空间不能用current指针指向当前正在CPU上运行的进程方便获取进程信息、严禁睡眠或调度如不可调用schedule或kmalloc(…,GFP_KERNEL)分配内存,开发者可通过in_interrupt()和in_atomic()判断当前上下文状态。定时器与其他代码异步运行容易引发竞态条件因此访问共享数据时需要使用原子变量或自旋锁严格保护确保系统稳定。[!IMPORTANT]这个内核定时器与前几节的jiffies计数器有什么关系jiffies相关的函数足够完成定时一段时间的任务了为什么还要设计这个定时器链表答明确两者关系jiffies是时间的“标尺”内核定时器是基于这把标尺构建的“闹钟”机制。jiffies是一个全局变量随硬件时钟中断Tick每秒增加HZ次。它只负责记录“现在是什么时间”。内核定时器是一个数据结构timer list它记录了“未来某个jiffies值时需要执行什么函数”。两者协作方式每次发生时钟中断后内核不仅更新jiffies还会去检查内核定时器链表看是否有定时器到期(如果有就执行回调函数)。既然jiffies可用为何还需内核定时器链表避免忙等异步执行释放CPU资源。定时器链表的设计解决了多任务并发时检查进程是否到时的复杂度。7.4.1. 定时器 API旧的内核教程常使用init_timerfunction/data字段的方式这在现代内核中已被标记为过时Deprecated。推荐的标准流程现代内核第一步定义定时器结构在你的驱动结构体中嵌入struct timer_listincludelinux/timer.hstructmy_device{structtimer_listmy_timer;intdata_value;// ... 其他成员};第二步编写回调函数注意函数原型的变化。现代内核不再通过data参数传递数据而是直接传入timer_list指针你需要用from_timer宏反推容器结构体。// 回调函数原型固定为void func(struct timer_list *t)voidmy_timer_callback(structtimer_list*t){// 关键从 timer_list 指针反推包含它的结构体指针structmy_device*devfrom_timer(dev,t,my_timer);// 执行业务逻辑 (例如读取硬件状态)pr_info(Timer expired! Data value is: %d\n,dev-data_value);// 【可选】若需要周期执行回调需在此重新添加定时器// mod_timer(dev-my_timer, jiffies msecs_to_jiffies(1000));}第三步初始化与启动在驱动初始化或打开设备时设置定时器structmy_device*devkmalloc(sizeof(*dev),GFP_KERNEL);// 1. 初始化定时器 (关联回调函数)// 参数1定时器指针 2回调函数名 3标志位 (通常为 0)timer_setup(dev-my_timer,my_timer_callback,0);// 2. 启动定时器 - 把你的 struct timer_list 节点插入到全局的定时器链表定时器轮Timer Wheels中// 参数定时器指针到期时间 (基于 jiffies)// 例如1 秒后执行 (假设 HZ1000)mod_timer(dev-my_timer,jiffiesmsecs_to_jiffies(1000));第四步删除定时器在驱动卸载或设备关闭时必须清理定时器防止回调函数在模块卸载后仍然被调用导致内核崩溃。del_timer_sync函数是del_timer函数的同步版会等待其他处理器使用完定时器再删除del_timer_sync不能使用在中断上下文中。// 同步删除确保如果回调正在运行会等待其执行完毕再返回 del_timer_sync(dev-my_timer);最小化可运行代码示例#includelinux/module.h#includelinux/init.h#includelinux/timer.h#includelinux/jiffies.hstaticstructtimer_listexample_timer;staticintcount0;// 回调函数staticvoidtimer_func(structtimer_list*t){count;pr_info(Timer fired! Count: %d\n,count);if(count5){// 重新调度1 秒后再次触发mod_timer(t,jiffiesmsecs_to_jiffies(1000));}else{pr_info(Timer stopped.\n);}}staticint__timer_init(void){pr_info(Initializing timer...\n);// 1. 设置定时器timer_setup(example_timer,timer_func,0);// 2. 启动定时器 (立即开始1 秒后第一次触发)mod_timer(example_timer,jiffiesmsecs_to_jiffies(1000));return0;}staticvoid__timer_exit(void){pr_info(Cleaning up timer...\n);// 3. 安全删除del_timer_sync(example_timer);}module_init(__timer_init);module_exit(__timer_exit);MODULE_LICENSE(GPL);[!WARNING]由于定时器是异步运行的它可能在你的主代码正在修改共享数据时突然触发。问题如果主代码和定时器同时修改同一个变量会导致数据错乱。解决必须使用**自旋锁Spinlock或原子变量Atomic Variables**来保护共享数据。切记在定时器回调中断上下文中只能使用spin_lock_irqsave/spin_unlock_irqrestore绝对不能使用互斥锁Mutex因为 Mutex 可能会睡眠。7.4.2. 内核定时器的实现(看了没总结)7.5 tasklet 机制参考学习链接【中断】tasklet机制解析中断管理第十章会讲大量使用该机制。在现代 Linux 内核大约从 5.9 版本开始中Tasklet 已经被标记为废弃Deprecated。不推荐在新代码中使用如果你现在开始写一个新的设备驱动不应该再使用 Tasklet。替代方案官方推荐的替代方案是工作队列 (Workqueue)或线程化中断 (Threaded IRQ)。根本原因在于 Tasklet 运行在中断上下文导致不能睡眠调试困难。工作队列运行在进程上下文可以睡眠功能更优但是我们还是在这里引入中断处理的“两阶段”思想 (Top Half / Bottom Half)上半部(Top Half)硬件中断处理函数唯一任务是快速响应硬件比如读一个状态寄存器清除中断标志将书籍从硬件缓冲区拷贝到内存等。要求要快因为在此期间同类型的硬件中断通常会被屏蔽处理慢了会影响系统实时性。下半部**Bottom Half即 Tasklet 这类机制。它负责处理那些可以延迟、但必须由中断触发**的“繁重”工作。比如对接收到的网络数据包进行协议解析、唤醒等待数据的进程等。类比你正在洗衣服CPU主任务此时门铃响了硬件中断你需要立即去门口发现是快递包裹上半部快速响应你把包裹放在一边想着衣服洗完了再拆吧下半部然后继续洗衣服去了。Tasklet 就是实现“下半部”的一种轻量级机制。Tasklet 的核心特性有基于软中断(Softirq)。原子性执行。一个 tasklet 的完整生命周期创建与初始化DECLARE_TASKLET(name, func, data): 静态声明并初始化一个 tasklet。tasklet_init(): 动态初始化一个 tasklet。上面这两个都是用于建立一个tasklet_struct结构体并告诉内核当这个tasklet被执行时请调用funx函数并把data传给它。调度 (Schedule)tasklet_schedule(my_tasklet): 这是触发下半部工作的“开关”。通常在上半部中断处理函数的末尾调用它。作用是告诉内核“嘿有个延迟任务要处理请在合适的时候执行它。”tasklet_hi_schedule(): 高优先级版本会比普通 tasklet 更早被执行。禁用与使能 (Disable / Enable)tasklet_disable(my_tasklet): 临时禁止一个 tasklet 执行。即使被调度了它也不会运行。这通常用于保护临界区或模块卸载时的同步。tasklet_enable(my_tasklet): 解除禁止。如果之前被调度过它会很快被执行。销毁 (Kill)tasklet_kill(my_tasklet): 在模块卸载或设备关闭时调用。它会等待这个 tasklet 如果正在运行则运行完毕并确保它不会再被调度。这是资源清理的关键一步。7.6 工作队列一篇很牛逼的博文供参考workqueuelinux kernel工作队列知乎作者大雨#include linux/workqueue.h struct demo_type { char *name; struct work_struct wk; //一份工作 }; static void demo_work(struct work_struct *work) { struct demo_type *dm container_of(work, struct demo_type, wk); printk(KERN_INFO “demo work begin\n”); //用于调试验证 msleep(1000); printk(KERN_INFO “demo’s name: %s\n”, dm-name); //用于调试验证 } void demo_init(void) { struct demo_type *dm NULL; //申请demo结构体内存(为简洁省略判空) dm kzalloc(sizeof(*struct demo_type), GFP_KERNEL); dm-name “Demo”; //初始化一个工作把工作函数demo_work 绑定到工作变量wk上 INIT_WORK(dm-wk, demo_work); //在需要的地方激活一次工作demo_work会被调用一次 schedule_work(dm-wk); //使用系统共用的工作队列 printk(KERN_INFO “demo work wake\n”); //用于调试验证 }工作队列类似于tasklet允许我们把工作推迟执行或者是把高耗时任务放到另一个线程执行。用于中断下半部及在中断函数中不能运行太耗时的工作在中断中只做一个“触发函数运行的动作”然后退出中断。tasklet在软中断上下文运行因此所有tasklet代码都必须是原子的。而工作队列在特殊内核进程上下文运行因此灵活性更好。工作队列可以休眠。多核处理器SMP环境下Tasklet 对 CPU 的“忠诚”是强制的、底层的而工作队列对 CPU 的“忠诚”是可选的、应用层的。内核代码可以请求工作队列函数的执行延迟给定的时间间隔。结论工作队列的诞生就是为了解决 Tasklet 不能睡觉、不能处理复杂任务的痛点。[!CAUTION]工作队列与等待队列什么关系工作队列用来干活的。当中断发生时如何处理中断下半部的问题。把中断下半部交给内核线程执行是一种异步执行机制。等待队列用来睡觉的。进程同步机制让自己挂起直到等到某个条件满足被唤醒。怎么使用工作队列在书本中的关于创建工作队列填充初始化将工作提交到工作队列取消工作队列等等操作。但是随着Linux kernel的发展不需要一个线程对应一个工作队列了。而现在Linux 引入了并发管理工作队列CMWQ全称“Concurrency Managed Workqueue”内核维护了一个全局的工作队列你只需要用系统默认的system_wq直接调用schedule_work(my_work)即可一般不需要自己创建队列但是要直到包括①使用系统队列②使用私有队列③使用延时运行队列等API。工作队列缺点多个工作挤在某个内核线程中依次序执行前面的函数如果执行得很慢就会影响到后面的函数。使用系统队列最简单包含linux/workqueue.h文件#includelinux/workqueue.hstructdemo_type{char*name;structwork_structwk;//一份工作};staticvoiddemo_work(structwork_struct*work){structdemo_type*dmcontainer_of(work,structdemo_type,wk);printk(KERN_INFOdemo work begin\n);//用于调试验证msleep(1000);printk(KERN_INFOdemos name: %s\n,dm-name);//用于调试验证}voiddemo_init(void){structdemo_type*dmNULL;//申请demo结构体内存(为简洁省略判空)dmkzalloc(sizeof(*structdemo_type),GFP_KERNEL);dm-nameDemo;//初始化一个工作把工作函数demo_work 绑定到工作变量wk上INIT_WORK(dm-wk,demo_work);//在需要的地方激活一次工作demo_work会被调用一次schedule_work(dm-wk);//使用共享工作队列我们在和其他人共享该工作队列printk(KERN_INFOdemo work wake\n);//用于调试验证}下面几个函数用到时再详细了解参见linux/workqueue.h文件全局workqueuesysem_wq指定cpu指定workqueue指定workqueue、cpu无延迟schedule_workschedule_work_onqueue_workqueue_work_on延迟schedule_delayed_workschedule_delayedwork_onqueue_delayed_workqueue_delayed_work_on7.6.1 共享队列就是共享工作队列上面一小节讲完了。

更多文章