第2章:Linux内核实时性基础:内核抢占模型详解
做嵌入式Linux实时系统,第一个绕不开的问题就是:内核到底能不能被抢占?
说白了,抢占模型决定了你的实时任务在调用系统服务时,会不会被一个不相干的内核线程堵死。我早年在一个工业控制项目里就吃过这个亏——一个普通的GPIO中断响应,硬生生被内核的某个后台任务拖了十几毫秒。从那以后,我对抢占模型的理解就再也不敢停留在“知道概念”层面了。
这一章,我们就把Linux内核的四种抢占模型掰开揉碎讲清楚。从最基础的PREEMPT_NONE,到硬实时领域的PREEMPT_RT,每个模型我都会结合项目中的实际场景来聊。
2.1 四种抢占模型:从无到有的进化
Linux内核的抢占模型,本质上是在回答一个问题:当进程在内核态执行时,更高优先级的任务来了,能不能打断它?
根据这个问题的答案,内核社区定义了四种模型。我习惯用一张表格来对比它们的核心差异:
| 抢占模型 | 内核态抢占能力 | 典型延迟 | 适用场景 |
|---|---|---|---|
| PREEMPT_NONE | 无(仅自愿抢占) | 10~100ms | 服务器、桌面、批处理 |
| PREEMPT_VOLUNTARY | 在显式抢占点可抢占 | 1~10ms | 轻度实时、多媒体 |
| PREEMPT | 大部分内核代码可抢占 | 100μs~1ms | 软实时、工业控制 |
| PREEMPT_RT | 几乎全部可抢占(含中断) | 10~100μs | 硬实时、航空航天、医疗 |
嗯,这里要注意:延迟数据只是参考值,实际表现跟硬件、负载、驱动质量都有关系。但趋势是明确的——越往右,实时性越好,代价是吞吐量下降和上下文切换开销增加。
2.1.1 PREEMPT_NONE:传统服务器的选择
这是最原始的Linux内核模型。进程在内核态执行时,除非它主动调用schedule()或者系统调用返回用户空间,否则谁也抢不走CPU。
好处是什么?吞吐量高,上下文切换少。坏处呢?一个低优先级的进程如果正在执行write()系统调用,高优先级的实时任务只能干等着。
2.1.2 PREEMPT_VOLUNTARY:加了一些“让出点”
这个模型在内核代码的关键路径上插入了显式的抢占点——说白了就是一些cond_resched()调用。当高优先级任务就绪时,内核会在这些点检查是否需要重新调度。
我个人觉得,这个模型有点“半吊子”。它比NONE好,但不可预测。因为抢占点只覆盖了部分代码路径,如果你运气不好,实时任务刚好撞上了一个没有抢占点的长路径,延迟照样飙上去。
2.1.3 PREEMPT:软实时的主力
这是大多数嵌入式Linux发行版的默认配置。它允许在非原子上下文的内核代码中随时发生抢占。怎么实现的?内核在持有spinlock时会禁止抢占,释放锁后重新允许。
你想想看,这意味着什么?只要你的实时任务不跟内核争同一个自旋锁,它就能在微秒级得到响应。我在一个机器人控制项目里用的就是这个模型,配合优先级继承,基本能满足100Hz的控制循环。
2.1.4 PREEMPT_RT:硬实时的终极方案
这个模型把抢占做到了极致。它通过两个核心机制实现了几乎全部内核代码的可抢占性:中断线程化和rt-mutex替换spinlock。
说白了,PREEMPT_RT把中断处理程序变成了内核线程,可以被调度器管理;又把自旋锁换成了可睡眠的实时互斥锁。这样一来,高优先级的实时任务几乎不会被任何内核路径阻塞。
2.2 中断线程化原理
中断线程化是PREEMPT_RT的基石之一。传统Linux中,中断处理程序运行在中断上下文,优先级最高,不可被抢占。这意味着一个简单的网卡中断就能打断你的实时控制任务。
中断线程化做了什么?它把中断处理程序分为两部分:
- 上半部(hardirq):极简处理,只做必要的硬件操作(如清除中断标志),运行在原始中断上下文。
- 下半部(threaded IRQ):主要的处理逻辑,作为一个内核线程运行,可以被调度、被抢占、被优先级管理。
配置方法很简单,在设备树或驱动中指定IRQF_ONESHOT | IRQF_SHARED标志,或者通过request_threaded_irq()注册线程化中断处理函数。
// 中断线程化示例代码
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
// 这个函数运行在 threaded 上下文
// 可以睡眠、可以持有 mutex
struct my_device *dev = (struct my_device *)dev_id;
mutex_lock(&dev->lock);
// 处理数据...
mutex_unlock(&dev->lock);
return IRQ_HANDLED;
}
static int my_probe(struct platform_device *pdev)
{
int irq = platform_get_irq(pdev, 0);
// 注册线程化中断
return request_threaded_irq(irq, NULL, my_irq_handler,
IRQF_TRIGGER_RISING, "my_device", pdev);
}
2.3 实时内核配置选项解析
配置实时内核,说白了就是选对Kconfig选项。我整理了一份常用配置清单,你在make menuconfig时可以直接参考:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
CONFIG_PREEMPT_RT_FULL |
y | 启用完整RT抢占,这是硬实时的开关 |
CONFIG_HZ_1000 |
y | 设置时钟频率为1000Hz,提高调度精度 |
CONFIG_NO_HZ_FULL |
y | 减少无用的定时器中断,降低抖动 |
CONFIG_CPU_FREQ |
n | 关闭CPU频率调节,避免延迟抖动 |
CONFIG_CPU_IDLE |
n | 关闭CPU空闲状态,减少唤醒延迟 |
CONFIG_RCU_NOCB_CPU |
y | 将RCU回调卸载到专用线程,减少中断延迟 |
嗯,这里要特别提醒:关闭CPU_FREQ和CPU_IDLE会显著增加功耗。如果你的设备是电池供电的,需要做权衡。我一般在开发阶段全关,量产时再根据实测数据选择性开启。
2.4 spinlock vs rt-mutex:实时内核中的锁差异
这是很多开发者容易踩坑的地方。在标准内核中,spinlock是忙等的——它禁止抢占和中断,然后循环等待。在PREEMPT_RT内核中,spinlock被悄无声息地替换成了rt-mutex。
为什么会这样?因为忙等对于实时系统是灾难。想象一下:一个高优先级任务想获取一个被低优先级任务持有的锁,如果锁是spinlock,高优先级任务只能空转,白白浪费CPU时间。换成rt-mutex后,高优先级任务会睡眠等待,把CPU让给其他任务,同时通过优先级继承机制提升锁持有者的优先级,防止优先级反转。
我画了一张图来展示这个差异:
这个替换是透明的——你的驱动代码不需要改。但有一个陷阱:在PREEMPT_RT下,spin_lock_irqsave()不再真的关闭中断,而是把中断线程化。如果你依赖spin_lock_irqsave()来保证某段代码不被中断打断,那在RT内核下这个假设就失效了。
spin_lock_irqsave()保护一个共享缓冲区,以为这样能保证原子性。切换到PREEMPT_RT后,数据偶尔出现错乱。查了半天才发现,RT内核下spinlock不关中断了。解决方案是改用local_irq_disable() + raw_spinlock来保护真正的临界区。
2.5 如何选择适合你的抢占模型
说了这么多,到底该选哪个?我个人的经验是:
- 如果你的系统没有实时要求(比如数据记录、日志服务器):用PREEMPT_NONE,省心省电。
- 如果只有软实时要求(比如多媒体播放、人机交互):PREEMPT就够用了,没必要上RT补丁。
- 如果需要硬实时(比如电机控制、飞行控制):必须上PREEMPT_RT,同时配合中断线程化和rt-mutex。
嗯,还有一个建议:不管选哪个模型,都先用cyclictest跑一下延迟测试。我习惯在开发板上跑24小时,看最大延迟和抖动情况。如果最大延迟超过你应用要求的2倍,那就得考虑升级抢占模型了。
cyclictest -t1 -p 99 -n -i 1000 -l 1000000 -m这个命令会创建一个优先级99的实时线程,每1ms唤醒一次,统计唤醒延迟。跑完看max值,如果超过100μs,你的系统就不适合硬实时场景。