Linux PREEMPT_RT 实时内核运行原理:从锁机制改造到中断线程化
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
本文以 Linux 内核 实时抢占文档(theory.rst) 为核心骨架,系统讲解 PREEMPT_RT 如何通过"可睡眠的自旋锁 + 优先级继承 + 强制中断线程化"三大改造,把传统内核中不可抢占的长临界区压缩到极少数底层路径,从而显著缩短"高优先级任务就绪到真正上 CPU 执行"之间的调度延迟。读完本文,你将理解 PREEMPT_RT 锁语义与普通内核的差异、优先级继承的运作机制、线程化中断的两阶段模型,以及这些设计在源码中的具体落点,可直接用于实时系统的内核配置与驱动代码审查。
一、总览:PREEMPT_RT 如何把内核变成实时内核
在非 PREEMPT_RT 的内核中,大量执行路径(如自旋锁临界区、中断处理函数)运行在禁止抢占或禁止中断的上下文里,调度器在这些区间内完全失效——即使一个更高优先级的任务已经就绪,也只能干等。PREEMPT_RT 的核心目标就是把这些不可抢占的执行路径全部"交还"给调度器。
正如 theory.rst 前言 所概括的,PREEMPT_RT 通过两项关键改造实现这一点:
- 锁原语替换:把
spinlock_t等自旋锁替换为可抢占、支持优先级继承的rtmutex实现; - 强制中断线程化:把中断处理移入受调度器管理的内核线程上下文。
改造完成后,内核变得"几乎完全可抢占",仅剩下少数真正必须原子执行的代码路径仍然关闭抢占,包括:入口代码(entry code)、调度器本身、底层中断处理例程。这一点在内核配置中也有对应描述——kernel/Kconfig.preempt中CONFIG_PREEMPT_RT的帮助文本(kernel/Kconfig.preempt)明确写道:该选项通过替换自旋锁/读写锁等锁原语、强制执行中断线程化、引入拆分长非抢占段(long non-preemptible sections)的机制,使内核除极少数底层关键路径外完全可抢占,并把大多数执行上下文纳入调度器控制。
二、调度基础:SCHED_OTHER 与 SCHED_FIFO 的行为差异
理解 PREEMPT_RT 之前,先要理解实时调度策略与传统调度策略的本质区别。Linux 调度及相关的用户空间 API 在sched(7)手册页中有完整说明,本文只摘取与实时性直接相关的核心差异。
默认策略 SCHED_OTHER(CFS):一个任务只有在调度器判定它相对其他可运行任务"已经消耗了公平份额的 CPU 时间"时才会被抢占。关键在于,当一个新的 SCHED_OTHER 任务变为可运行状态时,策略并不保证当前任务会被立即抢占——当前正在运行的任务可能继续执行下去,就绪的新任务只能排队等待。
实时策略 SCHED_FIFO:当一个带实时策略的任务变为可运行,且其优先级高于当前正在运行的任务时,调度器立即选中它执行。该任务会持续运行,直到它主动让出 CPU(典型情况是阻塞在某个事件上),或被更高优先级的实时任务抢占。
这一差异正是实时系统的基石:PREEMPT_RT 的所有改造,本质上都是为了让"高优先级任务就绪"这个事件能够立刻打断任何非关键路径的执行上下文,而不是等待自然调度点。
三、睡眠自旋锁:spinlock_t 在 PREEMPT_RT 下的语义转变
3.1 非 PREEMPT_RT 内核:自旋 + 关抢占
在普通内核中,spinlock_t的获取分两步:先关闭抢占(preempt_disable),然后自旋等待直到锁可用,释放时再恢复抢占。从实时性角度看,这种设计有严重缺陷——关闭抢占意味着调度器无法切换到更高优先级的任务,即使该任务已经就绪。锁持有时间越长,优先级反转和延迟抖动越严重。
内核文档 Documentation/locking/locktypes.rst 对锁类型做了系统分类:spinlock_t/rwlock_t在非 PREEMPT_RT 内核中属于自旋锁(Spinning locks),并隐式关闭抢占;其带后缀的变体提供额外保护——_bh()关闭/使能下半部(软中断)、_irq()关闭/使能中断、_irqsave/restore()保存并关闭/恢复中断状态。
3.2 PREEMPT_RT 内核:基于 rtmutex 的睡眠锁
为解决上述问题,PREEMPT_RT 把自旋锁替换为可睡眠的自旋锁(sleeping spin locks),其实现基于rtmutex。当一个任务试图获取一个已被占用的锁时,它不再自旋,而是执行以下动作:
- 禁止 CPU 迁移(migrate_disable)——这与关闭抢占有相同的效果:任务被"钉"在当前 CPU 上,因此对 per-CPU 变量的指针引用保持有效;
- 向锁的持有者捐赠自己的优先级(即优先级继承,详见第四节);
- 主动调度出去(voluntary schedule out),睡眠等待锁变为可用。
关键点在于:禁止 CPU 迁移 ≠ 禁止抢占。任务在持有睡眠锁期间仍然可以被抢占,只是保证不会被调度到其他 CPU。这正是 PREEMPT_RT 的巧妙之处——既保证了 per-CPU 数据结构访问的安全性(这是自旋锁原本提供的),又不再阻塞调度器切换更高优先级任务。
3.3 源码佐证:语义变化的深层含义
Documentation/locking/locktypes.rst 详细列出了 PREEMPT_RT 下spinlock_t语义的全部变化:
- 不关闭抢占:PREEMPT_RT 中
spinlock_t映射到独立的基于 rt_mutex 的实现,获取/释放不再影响 CPU 的抢占状态; _irq/_irqsave后缀不再影响 CPU 中断关闭状态:因为中断已被线程化(见第五节),不再需要以关中断方式与硬中断上下文同步;但_bh()后缀仍会关闭软中断处理——非 PREEMPT_RT 靠关闭抢占实现这一效果,PREEMPT_RT 则使用 per-CPU 锁做串行化,同时保持抢占开启;- 持有锁的任务不会迁移:非 PREEMPT_RT 靠关闭抢占防迁移,PREEMPT_RT 靠关闭迁移(migrate_disable),因此即使任务被抢占,per-CPU 指针依然有效;
- 任务状态在锁获取期间被保存与恢复:由于任务可能在获取锁时阻塞,PREEMPT_RT 会在阻塞前保存
task->state到saved_state,置为TASK_UNINTERRUPTIBLE后schedule();锁唤醒时恢复saved_state。若在等待期间收到其他唤醒源,则该唤醒改写saved_state而非直接置RUNNING,从而保证"真正的唤醒不丢失"。
同时,locktypes.rst强调了两类重要边界:
raw_spinlock_t在所有内核中都是严格自旋锁(包括 PREEMPT_RT),仅在真正的核心代码、底层中断处理、需要关闭抢占/中断访问硬件状态的场景使用;其临界区内禁止获取普通spinlock_t/rwlock_t,也禁止调用内存分配器(PREEMPT_RT 下内存分配器完全可抢占,不能在真正原子上下文调用)——典型反例是raw_spin_lock()后调用kmalloc(..., GFP_ATOMIC)在 PREEMPT_RT 下会失败;- 位自旋锁(bit spinlocks)无法被替换:单个 bit 放不下一个 rt_mutex,因此语义在 PREEMPT_RT 下保持不变,
raw_spinlock_t的注意事项同样适用。
此外,锁嵌套规则也随之调整:PREEMPT_RT 把spinlock_t/rwlock_t从自旋类别改为睡眠类别、把local_lock替换为 per-CPU 的spinlock_t,因此它们不能在被raw_spinlock_t持有的情况下获取。由此形成严格的三级嵌套顺序:① 睡眠锁 → ② spinlock_t / rwlock_t / local_lock → ③ raw_spinlock_t 与位自旋锁,lockdep(CONFIG_PROVE_LOCKING)会在违反约束时报错。
四、优先级继承(Priority Inheritance)
4.1 机制原理
PREEMPT_RT 下spinlock_t、mutex等锁都建立在 rtmutex 之上,而 rtmutex 的核心价值就是优先级继承(PI):当一个任务阻塞在锁上时,PI 机制会把它(阻塞者)的调度参数临时传播给锁的持有者。
文档中给出了经典示例:
- 场景:SCHED_FIFO 任务 A 阻塞在一个当前由 SCHED_OTHER 任务 B 持有的锁上;
- 动作:A 的调度策略与优先级临时被 B 继承,随后 A 进入睡眠等待;
- 效果:B 事实上变成系统中最高优先级的任务,得以继续执行、推进、最终释放锁;
- 收尾:B 释放锁后恢复原有调度参数,A 恢复运行。
这解决了经典的优先级反转问题——低优先级任务因持有高优先级任务所需的锁而阻止后者运行,如果没有 PI,高优先级任务会被无限期饿死;有了 PI,低优先级持有者被"提升"到高优先级,尽快完成临界区并释放锁。
4.2 源码落点
在 kernel/locking/rtmutex.c 中可以看到 PI 的完整实现脉络:
- 锁的等待者队列(
pi_waiters)以优先级排序,task_top_pi_waiter(p)返回优先级最高的等待者,其任务即为pi_task(rtmutex.c); - 当
pi_task存在时调用rt_mutex_setprio(p, pi_task),把持有者p的优先级提升到最高等待者水平; - 注释中的
boost()/deboost()描述(rtmutex.c)刻画了提权与去权的路径——提权沿持有链逐级传播,去权同样逐级回溯,确保"提升谁、就恢复谁"。
需要说明的是,并非所有锁都支持 PI:
- 信号量(semaphore)在 PREEMPT_RT 下不做替换——计数信号量没有"所有者"概念,无法确定提升对象,因此阻塞在信号量上仍可能发生优先级反转(详见 locktypes.rst);
- rw_semaphore / rwlock_t 在 PREEMPT_RT 下映射到基于 rt_mutex 的实现,但公平性发生变化:写者无法把自己的优先级授予多个读者,被抢占的低优先级读者继续持有锁可能导致高优先级写者饥饿;反之,读者可以把优先级授予写者,低优先级写者会被提升直到释放锁,从而避免写者饿死读者。
五、线程化中断(Threaded Interrupts)
5.1 为什么需要线程化
中断处理函数是另一类在关闭抢占、脱离调度器控制下执行的代码。为了把中断处理纳入调度器管理,PREEMPT_RT 强制执行线程化中断处理。
5.2 两阶段模型
线程化后,中断处理被拆成两个阶段:
| 阶段 | 执行上下文 | 职责 |
|---|---|---|
| 主处理函数(primary handler) | IRQ 上下文,中断关闭 | 唯一职责是唤醒对应的线程化处理函数 |
| 线程化处理函数(threaded handler) | 进程上下文,由内核调度 | 即传给request_irq()的中断处理函数,实际完成设备处理 |
从唤醒中断线程到线程化处理完成之间,中断源在中断控制器中保持屏蔽(masked):设备中断保持 pending 状态但不会再次触发 CPU,系统得以退出 IRQ 上下文,在可调度的线程中从容处理中断。
5.3 默认调度参数:SCHED_FIFO 优先级 50
文档明确指出:默认情况下,线程化处理函数以SCHED_FIFO 策略、优先级 50(MAX_RT_PRIO / 2)运行——正好位于最小与最大实时优先级的中点。源码常量可验证这一点:include/linux/sched/prio.h定义#define MAX_RT_PRIO 100(prio.h),实时优先级范围为 0..99,50 恰为中点。
5.4 软中断的处理
如果线程化中断处理函数在执行期间触发了软中断(softirq),这些软中断例程会在同一线程内、线程化处理函数完成之后被调用,且执行软中断处理期间抢占保持开启。这意味着 PREEMPT_RT 下不能假设软中断上下文是不可抢占的——Documentation/core-api/real-time/differences.rst 特别警告:不要依赖local_bh_disable()在进程上下文保护 per-CPU 变量,因为软中断处理函数在 PREEMPT_RT 下可被抢占,这种同步方式不可靠。
5.5 源码落点:irq_thread 与强制线程化
在 kernel/irq/manage.c 中可以印证整个线程化模型:
irq_thread()(manage.c)是中断线程主体:通过sched_set_fifo(current)把线程设为 SCHED_FIFO,循环调用irq_wait_for_interrupt()等待中断、执行处理函数;irq_forced_thread_fn()(manage.c)是强制线程化时的包装函数:对非 PREEMPT_RT 构建它需local_bh_disable()+local_irq_disable()模拟硬中断上下文;而对 PREEMPT_RT 构建则跳过local_irq_disable()——这正是"中断已线程化、无需关中断"的代码级印证(!IS_ENABLED(CONFIG_PREEMPT_RT)条件分支);setup_irq_thread()创建名为irq/%d-%s的内核线程(manage.c);- 例外情况:以
IRQF_NO_THREAD、IRQF_PERCPU、IRQF_ONESHOT标志请求的中断不会被强制线程化(irq_setup_forced_threading()中的检查,manage.c)。其中IRQF_ONESHOT用于只提供线程处理函数的request_threaded_irq()场景,保证中断线保持屏蔽直到线程处理函数完成;若此时还提供了主处理函数,该主函数不会被线程化,因此绝不能在其中获取睡眠锁,且应保持最小化、避免忙等硬件寄存器。
六、相关配置与延伸阅读
PREEMPT_RT 的行为直接由内核配置选项驱动。除CONFIG_PREEMPT_RT本身外(kernel/Kconfig.preempt,依赖EXPERT && ARCH_SUPPORTS_RT),CONFIG_PREEMPT_RT_NEEDS_BH_LOCK(kernel/Kconfig.preempt)用于在怀疑可抢占软中断出错时强制旧式的软中断同步行为做测试对比。
对于构建实时系统的集成者,Documentation/core-api/real-time/kernel-configuration.rst 给出了影响最坏延迟的关键选项建议,与本文主题直接相关:
CONFIG_PREEMPT_RT:必须启用(严重级别 fatal),否则内核不具实时能力;CONFIG_CPU_FREQ/CONFIG_CPU_FREQ_DEFAULT_GOV_PERFORMANCE:建议启用(high)——实时负载期望 CPU 频率在执行期间固定不变,performance 调速器是最简单的达成方式;非 performance 调速器(尤其是ONDEMAND)应禁用,因其频率变化依赖负载行为,会显著破坏确定性;CONFIG_CPU_IDLE:启用但建议用processor.max_cstate=1把最大 C 状态限制在 C1,避免深睡眠状态带来的进出延迟与缓存冲刷;CONFIG_EFI_DISABLE_RUNTIME:建议启用(medium)——调用 EFI 运行时服务期间系统可能无法响应中断,造成延迟尖峰;PREEMPT_RT 默认启用,也可用efi=noruntime启动参数禁用;CONFIG_NO_HZ/CONFIG_NO_HZ_FULL:建议禁用(medium)——无 tick 模式会增加内核到用户态切换延迟;周期型负载(如每 100µs 的控制循环)应保持固定 tick;CONFIG_TRACING:建议启用但生产运行时不激活;CONFIG_IRQSOFF_TRACER/CONFIG_PREEMPT_TRACER即使不激活也有可观开销,建议禁用(high);- 调试选项:开发测试期鼓励开启 lockdep(
CONFIG_PROVE_LOCKING)等调试选项以暴露锁错误,但生产构建应禁用(high)——CONFIG_LOCKUP_DETECTOR会周期性在硬 IRQ 上下文执行定时器回调、CONFIG_PROVE_LOCKING显著增加最坏延迟。
七、总结
PREEMPT_RT 的设计哲学可以浓缩为一句话:用可睡眠的锁替换自旋锁、用内核线程承接中断处理,把"关闭抢占/关闭中断"的代码区间压缩到最小,把绝大多数执行上下文重新交还给调度器。
通过 theory.rst 所述的三大机制,PREEMPT_RT 实现了"高优先级任务就绪即抢占"这一实时内核的基本承诺:
- 调度:实时策略(SCHED_FIFO)保证高优先级任务就绪即被选中,替代 SCHED_OTHER 的"公平但不即时";
- 睡眠自旋锁:
spinlock_t基于 rtmutex,获取竞争锁时禁止迁移 + 优先级继承 + 主动调度出,而非自旋 + 关抢占; - 线程化中断:主处理函数仅唤醒线程,实际处理在 SCHED_FIFO 优先级 50 的内核线程中完成,软中断随后在同一线程、可抢占状态下执行。
最终效果正如文档所总结:PREEMPT_RT 显著减少了中断或抢占被关闭的代码段,使调度器能够随时抢占当前执行上下文并切换到更高优先级的任务——这正是实时系统可预测延迟(bounded latency)的根本保障。
【免费下载链接】linuxLinux kernel source tree项目地址: https://gitcode.com/GitHub_Trending/li/linux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考