CHAPTER 01

没有 RT 之前:延迟从哪里来

一个 SCHED_FIFO 99 的任务已经被唤醒了,为什么还要等上几百微秒甚至几毫秒才能真正跑起来?本章把这段等待拆开,逐一找出非 RT 内核里每一段"不可抢占"的来源。后面所有章节,都是在消灭这里列出的敌人。

1.1实时到底是什么

"实时"最常见的误解是把它等同于"快"。实时系统关心的不是平均响应有多快,而是最坏情况下的响应时间有没有一个可证明、可测量的上界。一台平均延迟 5µs、但每运行几小时就出现一次 8ms 卡顿的机器,对电机控制来说毫无用处;一台平均 20µs、最坏也不超过 60µs 的机器,才是实时系统。

核心结论

实时 = 确定性(determinism)。衡量指标是最坏延迟(worst-case latency)和延迟分布的长尾,而不是吞吐量或平均值。PREEMPT_RT 的所有改动都在压缩这条长尾,代价往往是平均值变差、吞吐下降。

唤醒延迟(对数刻度示意) 样本数 1µs 10µs 100µs 1ms 10ms RT:最坏延迟有上界 非 RT:几小时一次的毫秒级尖峰 非 RT(PREEMPT / NONE) PREEMPT_RT 平均值很好看
两个内核的延迟分布示意。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_interrupt ③ 中断尾部工作 softirq 就地执行 ④ 调度延迟 等待抢占点 / 等锁释放 ⑤ 切换 __schedule ⑥ 运行 回到 EL0 定时器到期 被唤醒:TASK_RUNNING 已到期,但还没被唤醒(还在睡) 已唤醒、在运行队列里,但还没轮到它上 CPU 真正开始运行 非 RT 内核里,谁能把每一段拉长: ① local_irq_save / spin_lock_irqsave 区、慢的 handler 正在跑、cpuidle 深睡退出 ② 同一中断里其他到期的 hrtimer 回调 ③ 网络收包、块设备完成、RCU 回调——全都在 irq_exit 时插队 ④ 当前任务处于 spin_lock / preempt_disable / rcu_read_lock 区;或者 RT 任务要的锁被低优先级任务持有 ⑤ 上下文切换本身(寄存器、FPSIMD 懒保存、TLB/ASID)——量级小且稳定 cyclictest 测到的延迟,就是从“定时器到期”到“真正开始运行”这一整段,即 ① 到 ⑤ 的总和。
一次定时器唤醒的延迟构成。线程在 ② 里的 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
八类延迟来源。1–7 是 PREEMPT_RT 的改造对象,8 只能靠选型、固件配置和 CPU 隔离规避。

① 关中断区

非 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 就被它占着。

ARM64 / GICv3

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 也管不了的部分

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: 终于开始运行
非 RT(CONFIG_PREEMPT)下,一个已经被唤醒的 FIFO 80 任务依次被:关中断区、softirq、持锁的关抢占区挡住。

对应的调用栈如下。注意 arm64 在 __el1_irq() 里先执行 irq_exit_rcu()(softirq 就在这里面),之后才执行 arm64_preempt_schedule_irq()——所以 softirq 永远排在抢占之前。

定时器中断唤醒 RT 任务,随后被 softirq 插队 非 RT · ARM64 · v6.12
  1. VBAR_EL1 + 0x280 → el1h_64_irqarch/arm64/kernel/entry.S:526CPU 取异常:PC→ELR_EL1,PSTATE→SPSR_EL1,置 DAIF
  2. el1h_64_irq_handler()arch/arm64/kernel/entry-common.c:643
  3. el1_interrupt()entry-common.c:632
  4. __el1_irq()entry-common.c:619enter_from_kernel_mode → irq_enter_rcu → handler → irq_exit_rcu → 抢占检查
  5. do_interrupt_handler() → gic_handle_irq()drivers/irqchip/irq-gic-v3.c:964切到 per-CPU IRQ 栈;读 ICC_IAR1_EL1 拿到 INTID(arch timer PPI)
  6. handle_percpu_devid_irq() → arch_timer_handler_virt()kernel/irq/chip.c:924 · drivers/clocksource/arm_arch_timer.c:681
  7. hrtimer_interrupt() → __hrtimer_run_queues() → hrtimer_wakeup()kernel/time/hrtimer.c:1849 / 1785 / 1981
  8. try_to_wake_up(ctrl) → … → resched_curr()kernel/sched/core.c:4142 / 1083本 CPU:只置 TIF_NEED_RESCHED,等待下一个抢占点
  9. …… handler 返回,EOI 写 ICC_EOIR1_EL1,回到 __el1_irq ……
  10. irq_exit_rcu() → __irq_exit_rcu()kernel/softirq.c:672 / 652!in_interrupt() && local_softirq_pending()
  11. invoke_softirq() → do_softirq_own_stack()kernel/softirq.c:444 · arch/arm64/kernel/irq.c:84非 RT:就地执行,借 IRQ 栈防爆栈
  12. __do_softirq() → handle_softirqs()kernel/softirq.c:611 / 536local_irq_enable() 后逐个执行 pending 向量
  13. net_rx_action() → __napi_poll() → 驱动 pollnet/core/dev.c:7052 / 6886ctrl 已就绪,但它在这里等 300 个包或数毫秒
  14. …… softirq 结束 ……
  15. arm64_preempt_schedule_irq()entry-common.c:270preempt_count != 0(被打断的代码持有 spin_lock)→ 直接返回
  16. 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。

2004 realtime-preempt 补丁集诞生 2.6.16 hrtimer 高精度定时器 2.6.18 rtmutex + PI futex 用户态优先级继承 2.6.25 可抢占 RCU 2.6.30 threaded IRQ request_threaded_irq 5.3 前后 CONFIG_PREEMPT_RT Kconfig 符号进主线 5.15 rtmutex 锁实现 spinlock_rt / rwbase_rt 6.12 printk 线程化 + ARCH_SUPPORTS_RT arm64/x86/riscv 中间还有大量没有单独标出的工作:lockdep wait-type、local_lock、migrate_disable、softirq 的 RT 实现、各驱动 raw_spinlock 化…… 版本号为该特性首次进入主线的大致版本;6.12 之后的演进(PREEMPT_LAZY 等)见第 10 章。
RT 进入主线的关键节点(示意)。6.12 的 arm64 改动只有 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 要做的三件事

一句话

非 RT 内核的问题不是"慢",而是太多代码运行在调度器管不到的上下文里:关中断、关抢占、硬中断、softirq。PREEMPT_RT 的本质,是把这些上下文尽可能地变成普通的、有优先级的、可被抢占的线程——"把调度器的权力还给调度器"。