没有 RT 之前:延迟从哪里来
一个 SCHED_FIFO 99 的任务已经被唤醒了,为什么还要等上几百微秒甚至几毫秒才能真正跑起来?本章把这段等待拆开,逐一找出非 RT 内核里每一段"不可抢占"的来源。后面所有章节,都是在消灭这里列出的敌人。
1.1实时到底是什么
"实时"最常见的误解是把它等同于"快"。实时系统关心的不是平均响应有多快,而是最坏情况下的响应时间有没有一个可证明、可测量的上界。一台平均延迟 5µs、但每运行几小时就出现一次 8ms 卡顿的机器,对电机控制来说毫无用处;一台平均 20µs、最坏也不超过 60µs 的机器,才是实时系统。
实时 = 确定性(determinism)。衡量指标是最坏延迟(worst-case latency)和延迟分布的长尾,而不是吞吐量或平均值。PREEMPT_RT 的所有改动都在压缩这条长尾,代价往往是平均值变差、吞吐下降。
硬实时、软实时、固实时
| 类别 | 错过截止期的后果 | 典型例子 | Linux + PREEMPT_RT 的定位 |
|---|---|---|---|
| 硬实时(hard) | 系统失败,可能造成物理损害 | 安全气囊、飞控、刹车 | 一般不作为唯一保障;通常配合 MCU/独立核或形式化验证的 RTOS |
| 固实时(firm) | 该次结果作废,但系统继续 | 工业总线周期、机器人关节控制、音频 | 主战场:EtherCAT 主站、PLC 软核、机器人控制器 |
| 软实时(soft) | 质量下降 | 视频播放、交易行情、游戏 | 非 RT 内核通常就够,RT 能进一步收敛抖动 |
PREEMPT_RT 本身给出的不是数学证明,而是"内核里几乎所有代码都可以被高优先级任务抢占"这一结构性保证,加上可测量的统计上界(cyclictest / timerlat 长时间压测)。在设计良好的 ARM64 平台上,经验量级是几十微秒的最坏唤醒延迟;非 RT 内核在压力下则可能出现毫秒级尖峰。具体数字强依赖硬件、固件和负载,本手册不引用具体基准数据。
1.2延迟的解剖:从事件发生到任务运行
以最典型的场景为例:一个周期性控制线程睡在 clock_nanosleep() 上,到点后由 ARM64 的 arch timer(GICv3 的 PPI)触发中断把它唤醒。从定时器到期到线程的第一条用户态指令执行,中间要经过下面这几段:
hrtimer_wakeup() 就已变成 TASK_RUNNING(可运行,不等于正在运行);③④⑤ 是它醒着排队等 CPU 的时间。RT 主要压缩 ③ 和 ④,并缩短 ①;⑤ 基本不变,硬件部分 RT 无能为力。| 段 | 名称 | 非 RT 下的主要放大器 | PREEMPT_RT 的对策 | 详见 |
|---|---|---|---|---|
| ① | 中断延迟 | 长关中断区、硬中断不嵌套 | spinlock 不再关中断,只剩 raw 锁区 | 第 3 章 |
| ② | 硬中断处理 | 驱动 handler 在硬中断里干重活 | 强制线程化,只留极短 primary | 第 5 章 |
| ③ | 中断尾部 | softirq 就地执行最长约 2ms × 多轮 | softirq 进线程、可被抢占 | 第 6 章 |
| ④ | 调度延迟 | 关抢占区、无 PI 的锁 | 睡眠锁 + 优先级继承,处处可抢占 | 第 2 章、第 4 章 |
| ⑤ | 上下文切换 | — | 不变(RT 反而切换更多) | 第 8 章 |
1.3非 RT 内核的八类延迟来源
下面这张图按"谁能被 RT 补丁解决"把延迟来源分了类。前七类是软件结构问题,是 PREEMPT_RT 的工作对象;第八类来自硬件和固件,任何内核补丁都只能绕开,不能消除。
flowchart LR A["高优先级任务已就绪
却没有在运行"] --> B{"CPU 此刻
在做什么"} B -->|"关着中断"| C1["1 关中断区
local_irq_save
spin_lock_irqsave"] B -->|"关着抢占"| C2["2 关抢占区
spin_lock / preempt_disable
rcu_read_lock"] B -->|"在跑硬中断"| C3["3 硬中断 handler
且不可嵌套"] B -->|"在跑中断尾部"| C4["4 softirq 就地执行"] B -->|"它要的锁被别人拿着"| C5["5 优先级反转"] B -->|"在一条很长的内核路径里"| C6["6 printk / 内存回收
RCU 回调批处理"] B -->|"在跑定时器回调"| C7["7 timer / hrtimer 回调"] B -->|"根本不在 Linux 里"| C8["8 固件 / 硬件
EL3 / SDEI / 深睡退出"] classDef hardirq fill:#fde8da,stroke:#c2410c,color:#1d1d1f classDef softirq fill:#fbf0d9,stroke:#b7791f,color:#1d1d1f classDef lock fill:#f5e1eb,stroke:#8b3a62,color:#1d1d1f classDef hw fill:#ece8f7,stroke:#6b5ca5,color:#1d1d1f classDef rt fill:#f6e3dc,stroke:#b5371c,color:#1d1d1f class C1,C3 hardirq class C4,C7 softirq class C2,C5,C6 lock class C8 hw class A rt
① 关中断区
非 RT 内核里,spin_lock_irqsave() 是最常见的锁原语之一:既要互斥,又要防止同一 CPU 上的中断 handler 来抢同一把锁造成自死锁。它在 ARM64 上的展开是:先把 DAIF 存进 flags 并置位 PSTATE.I(启用 pseudo-NMI 时改写 ICC_PMR_EL1),再自旋拿锁。
在这段时间里,GICv3 的 CPU interface 即使已经把中断标记为 pending,CPU 也不会取异常。中断延迟 ① 的下界就是系统里最长的那段关中断区——而这个"最长"分散在成千上万个驱动和子系统里,没人能静态地给出上界。
spin_lock_irqsave(&dev->lock, flags); /* 关本 CPU 中断 + 关抢占 + 自旋 */
/* ... 若这里遍历了一个很长的链表,整个 CPU 对所有设备"失聪" ... */
spin_unlock_irqrestore(&dev->lock, flags);
② 关抢占区
比关中断更普遍的是关抢占。非 RT 的 spin_lock() 隐含 preempt_disable();get_cpu()、local_bh_disable()、per-CPU 变量访问、非抢占 RCU 的 rcu_read_lock() 都会把 preempt_count 加上去。在 preempt_count != 0 期间:
- 中断可以进来、处理完、甚至把 RT 任务唤醒——但中断返回时
arm64_preempt_schedule_irq()检查到preempt_count非零,直接返回,不调度; - 被唤醒的 RT 任务只能等到当前代码执行到
preempt_enable()那一刻才有机会被调度(见 第 2 章的抢占点)。
static void __sched arm64_preempt_schedule_irq(void)
{
if (!need_irq_preemption())
return;
/* preempt_count 非零:被打断的代码在关抢占区里,不能抢占 */
if (READ_ONCE(current_thread_info()->preempt_count) != 0)
return;
...
if (system_capabilities_finalized())
preempt_schedule_irq();
}
在 CONFIG_PREEMPT_NONE 下情况更糟:内核态根本没有中断返回抢占,只能等到系统调用返回用户态、或代码主动调用 cond_resched()。
③ 硬中断 handler,以及 ARM64 上中断不嵌套
硬中断 handler 运行在关中断、不可抢占的上下文里,优先级高于系统中的任何任务——包括 SCHED_FIFO 99。一个设计不好的驱动在 handler 里轮询硬件、拷贝数据、甚至打印日志,整个 CPU 就被它占着。
GICv3 硬件支持 256 级优先级和中断抢占嵌套,但 Linux 刻意只用了三个优先级值(include/linux/irqchip/arm-gic-v3-prio.h:28):GICV3_PRIO_UNMASKED 0xe0、GICV3_PRIO_IRQ 0xc0、GICV3_PRIO_NMI 0x80。所有普通中断——网卡、磁盘、串口、定时器——都是同一个 0xc0;再加上 CPU 取异常时硬件自动置位 PSTATE.I,结论是:arm64 Linux 上硬中断永远不嵌套。一个 handler 在跑,这个 CPU 上的高精度定时器中断也进不来。原因和取舍见 第 8 章。
④ softirq:在中断返回路径上插队
为了让硬中断尽量短,Linux 把大部分工作推迟到 softirq。但在非 RT 内核里,softirq 的"推迟"只推迟到了同一次中断返回之前:irq_exit_rcu() 发现 pending 位图非零,就地执行。
#define MAX_SOFTIRQ_TIME msecs_to_jiffies(2)
#define MAX_SOFTIRQ_RESTART 10
handle_softirqs()(kernel/softirq.c:536)每轮把所有 pending 向量跑一遍;跑完如果又有新的 pending,并且 time_before(jiffies, end) && !need_resched() && --max_restart,就再来一轮,否则唤醒 ksoftirqd。注意两个细节:
!need_resched()只阻止下一轮,当前这一轮里的向量照样要跑完。一轮NET_RX_SOFTIRQ的net_rx_action()自己的预算是netdev_budget = 300个包或netdev_budget_usecs = 2 * USEC_PER_SEC / HZ(net/core/hotdata.c:15)——HZ=250 时就是 8ms 量级的时间上限。- softirq 运行时借用的是被打断任务的
current,preempt_count里 SOFTIRQ 位置位,它在调度器眼里根本不存在,没有优先级可言。一个 SCHED_FIFO 99 的任务被唤醒后,也只能排在网络收包后面。
⑤ 优先级反转
经典场景:低优先级任务 L 持有锁;高优先级任务 H 要这把锁,阻塞;中优先级任务 M 与锁无关但可以抢占 L。结果 L 跑不起来、锁放不掉,H 实际上被 M 无限期地挡住——这就是无界优先级反转。1997 年火星探路者号(Mars Pathfinder)在 VxWorks 上反复复位,根因正是一个未开启优先级继承的互斥量,后来通过远程打开 PI 修复。
非 RT Linux 里有两种形态:
- spinlock:持锁方关了抢占,M 抢不了 L,反转是有界的,但界就是临界区长度——而 H 在自旋,白白烧 CPU;
- mutex / rwsem / 信号量:持锁方可被抢占,又没有优先级继承,反转是无界的。用户态
pthread_mutex默认也一样。
PREEMPT_RT 把绝大多数内核锁都换成带优先级继承的 rtmutex,详见 第 4 章。
⑥ 长的不可抢占路径
- printk:传统 printk 在调用者上下文里拿
console_lock,逐字节把日志推到串口。ARM64 板子上常见的 PL011 串口 115200 波特率下,每个字符约 87µs(10 bit / 115200),一行 80 字符就是约 7ms,而这期间常常是关中断或关抢占的。这是 PREEMPT_RT 合入主线前最后一块路障,6.12 的解法是把控制台输出挪进打印线程。nbcon 框架已经合入,但 6.12 的串口驱动还没有改成 nbcon,所以 RT 内核上实际起作用的是 legacy console 被强制线程化后的pr/legacy线程(第 7 章)。 - 内存分配与回收:SLUB 的 per-CPU 快路径、页分配器的 per-CPU 页链表都靠关中断/关抢占保护;直接回收、compaction 可能长时间持锁。
- RCU 回调批处理:
RCU_SOFTIRQ一次可能执行成千上万个kfree回调;非抢占 RCU 的读侧临界区本身就是关抢占区。 - 大循环里的 spinlock:遍历进程表、页表、文件系统元数据时持有 spinlock 的长路径。
⑦ 定时器回调
hrtimer 回调在 hrtimer_interrupt() 里——也就是硬中断上下文——执行;timer_list 回调在 TIMER_SOFTIRQ 里执行。任何子系统挂一个耗时的定时器回调,都会直接加到所有任务的延迟上,包括那个正在等定时器唤醒的 RT 任务自己。RT 的对策是把 hrtimer 分成"硬"和"软"两类(第 6 章)。
⑧ 硬件与固件:RT 也管不了的部分
下面这些延迟发生在 Linux 看不见、或无法抢占的地方。PREEMPT_RT 不会让它们变短,只能靠平台选型、固件配置、CPU 隔离和测量来规避。
| 来源 | ARM64 上的表现 | 常见规避 |
|---|---|---|
| EL3 固件 | TF-A 处理 SMC(PSCI、SiP 服务)期间 EL1 被挂起,Linux 无从观测 | 减少运行期 SMC 调用;评估厂商固件 |
| SDEI / 固件中断 | 由固件注入的事件会抢走 CPU | 确认平台是否启用,测量其占用 |
| cpuidle 深睡退出 | PSCI CPU_SUSPEND 深度状态退出可达数十到数百微秒 | 限制 idle 深度(/dev/cpu_dma_latency、cpuidle 参数) |
| DVFS | 调频调压期间的切换停顿,频率低时代码变慢 | performance governor 或固定频率 |
| 缓存 / TLB | 冷缓存、TLB 广播维护(TLBI ...IS + DSB)跨核影响 | CPU 隔离、mlockall、预热 |
| 总线与内存争用 | GPU、DMA、其他核对共享 L3/DDR 的争用 | QoS 配置,负载隔离 |
1.4一个完整的非 RT 场景(ARM64)
把上面的来源串起来看一次真实的执行流。设定:CPU0 上一个普通进程在内核态执行系统调用;一个 SCHED_FIFO 80 的控制线程 ctrl 绑在 CPU0,睡在 clock_nanosleep();同时网卡持续收包,它的中断也路由到 CPU0。
sequenceDiagram autonumber participant HW as arch timer / 网卡 participant GIC as GICv3 participant CPU as CPU0 (EL1) participant SIRQ as softirq(借用 current) participant T as ctrl (FIFO 80) Note over CPU: 普通进程在内核里持有 spin_lock
preempt_count = 1 HW->>GIC: 定时器到期 (PPI) GIC->>CPU: IRQ 挂起 (等待关中断区结束) CPU->>CPU: el1h_64_irq → gic_handle_irq CPU->>T: hrtimer_wakeup → try_to_wake_up Note over T: TASK_RUNNING,但还没运行 CPU->>SIRQ: irq_exit_rcu:发现 NET_RX pending SIRQ->>SIRQ: net_rx_action 处理一批包
(期间网卡中断又到,继续 raise) SIRQ-->>CPU: softirq 结束 CPU->>CPU: arm64_preempt_schedule_irq
preempt_count != 0 → 不抢占 Note over CPU: 回到被打断的 spin_lock 临界区继续执行 CPU->>CPU: spin_unlock → preempt_enable CPU->>T: preempt_schedule → __schedule Note over T: 终于开始运行
对应的调用栈如下。注意 arm64 在 __el1_irq() 里先执行 irq_exit_rcu()(softirq 就在这里面),之后才执行 arm64_preempt_schedule_irq()——所以 softirq 永远排在抢占之前。
- VBAR_EL1 + 0x280 → el1h_64_irqarch/arm64/kernel/entry.S:526CPU 取异常:PC→ELR_EL1,PSTATE→SPSR_EL1,置 DAIF
- el1h_64_irq_handler()arch/arm64/kernel/entry-common.c:643
- el1_interrupt()entry-common.c:632
- __el1_irq()entry-common.c:619enter_from_kernel_mode → irq_enter_rcu → handler → irq_exit_rcu → 抢占检查
- do_interrupt_handler() → gic_handle_irq()drivers/irqchip/irq-gic-v3.c:964切到 per-CPU IRQ 栈;读 ICC_IAR1_EL1 拿到 INTID(arch timer PPI)
- handle_percpu_devid_irq() → arch_timer_handler_virt()kernel/irq/chip.c:924 · drivers/clocksource/arm_arch_timer.c:681
- hrtimer_interrupt() → __hrtimer_run_queues() → hrtimer_wakeup()kernel/time/hrtimer.c:1849 / 1785 / 1981
- try_to_wake_up(ctrl) → … → resched_curr()kernel/sched/core.c:4142 / 1083本 CPU:只置 TIF_NEED_RESCHED,等待下一个抢占点
- …… handler 返回,EOI 写 ICC_EOIR1_EL1,回到 __el1_irq ……
- irq_exit_rcu() → __irq_exit_rcu()kernel/softirq.c:672 / 652!in_interrupt() && local_softirq_pending()
- invoke_softirq() → do_softirq_own_stack()kernel/softirq.c:444 · arch/arm64/kernel/irq.c:84非 RT:就地执行,借 IRQ 栈防爆栈
- __do_softirq() → handle_softirqs()kernel/softirq.c:611 / 536local_irq_enable() 后逐个执行 pending 向量
- net_rx_action() → __napi_poll() → 驱动 pollnet/core/dev.c:7052 / 6886ctrl 已就绪,但它在这里等 300 个包或数毫秒
- …… softirq 结束 ……
- arm64_preempt_schedule_irq()entry-common.c:270preempt_count != 0(被打断的代码持有 spin_lock)→ 直接返回
- exit_to_kernel_mode() → ERET回到被打断的临界区,ctrl 继续等
同样的场景在 PREEMPT_RT 下会变成:网卡中断只唤醒 irq/NN-eth0 线程(SCHED_FIFO 50),softirq 在该线程或 ksoftirqd 中执行;被打断的 spin_lock 临界区本身是可抢占的 rtmutex;于是中断一返回,ctrl(FIFO 80)就直接被调度上来。这段对照会在 第 5 章 和 第 6 章 展开。
1.5二十年:RT 是怎样一块块进入主线的
PREEMPT_RT 不是 6.12 一次性合入的大补丁。它是一个长期维护在树外的补丁集(realtime-preempt / -rt),二十年里把能独立成立的部分一块块送进主线。到 6.12 时,Thomas Gleixner 在 sched-rt-2024-09-17 合并请求里写道 "All prerequisites are merged",架构层只需各加一行 select ARCH_SUPPORTS_RT。
arch/arm64/Kconfig 里一行 select ARCH_SUPPORTS_RT(commit d8fccd9ca5f9)。6.12 树里能直接看到这个开关的样子(kernel/Kconfig.preempt:70、arch/arm64/Kconfig:107):
config PREEMPT_RT
bool "Fully Preemptible Kernel (Real-Time)"
depends on EXPERT && ARCH_SUPPORTS_RT
select PREEMPTION
help
This option turns the kernel into a real-time kernel by replacing
various locking primitives (spinlocks, rwlocks, etc.) with
preemptible priority-inheritance aware variants, enforcing
interrupt threading and introducing mechanisms to break up long
non-preemptible sections. This makes the kernel, except for very
low level and critical code paths (entry code, scheduler, low
level interrupt handling) fully preemptible and brings most
execution contexts under scheduler control.
这段 help 本身就是 RT 设计的一句话总结。注意 6.12 中它仍然 depends on EXPERT,配置时必须先打开 CONFIG_EXPERT。
1.6小结:RT 要做的三件事
锁变可睡眠
spinlock_t / rwlock_t 改为 rtmutex 实现,local_bh_disable、local_lock 不再关抢占,只留 raw_spinlock_t 给真正必须原子的代码。
中断与软中断线程化
硬中断 handler、softirq、大部分 hrtimer、RCU 回调、printk 输出都变成有优先级、可抢占的线程。
优先级继承
可睡眠的锁带来无界反转风险,rtmutex 的 PI 链把持锁者临时提升到等待者的优先级。
非 RT 内核的问题不是"慢",而是太多代码运行在调度器管不到的上下文里:关中断、关抢占、硬中断、softirq。PREEMPT_RT 的本质,是把这些上下文尽可能地变成普通的、有优先级的、可被抢占的线程——"把调度器的权力还给调度器"。