第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()系统调用,高优先级的实时任务只能干等着。

避坑提醒: 我曾经在一个网络网关项目里默认用了PREEMPT_NONE,结果VoIP通话时不时出现卡顿。排查了三天才发现是内核抢占延迟导致的。如果你做的是实时通信类产品,千万别用这个模型。

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把中断处理程序变成了内核线程,可以被调度器管理;又把自旋锁换成了可睡眠的实时互斥锁。这样一来,高优先级的实时任务几乎不会被任何内核路径阻塞。

核心要点: PREEMPT_RT不是独立的内核,而是一组补丁集。你需要从 kernel.org 获取对应内核版本的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);
}
经验之谈: 我建议在PREEMPT_RT环境下,所有非关键中断都改成线程化。但要注意,有些中断(如时钟中断、IPI)必须保持原始上下文,否则系统会出问题。判断标准很简单:如果中断处理时间超过100μs,就线程化它。

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让给其他任务,同时通过优先级继承机制提升锁持有者的优先级,防止优先级反转。

我画了一张图来展示这个差异:

标准内核 vs PREEMPT_RT 锁机制对比 标准内核:spinlock 禁止抢占 + 禁止中断 忙等(while循环) 高优先级任务被阻塞 PREEMPT_RT:rt-mutex 可睡眠等待 优先级继承 高优先级任务让出CPU 核心差异 标准内核:spinlock 忙等 → 高优先级任务被阻塞,CPU空转 PREEMPT_RT: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,你的系统就不适合硬实时场景。