PREFACE · 导读

PREEMPT_RT 原理手册

基于 Linux 6.12.110 源码,回答五个问题:没有 RT 时实时任务难在哪里,RT 补丁怎么解决,背后的原理是什么,解决之后内核变成了什么样,以及内核线程和用户态线程各受到什么影响。架构细节以 ARM64 + GICv3 为准。

PREEMPT_RT 只做一件事:让内核里几乎所有代码都可以被抢占。
办法有三种:能睡眠的锁换成带优先级继承的 rtmutex;中断和软中断挪进有优先级的线程;剩下真正必须原子执行的少量代码,显式标成 raw。

2004 → 2024
从 realtime-preempt 补丁到主线可选,前后约二十年
1 行
6.12 在 arm64 上的全部改动:select ARCH_SUPPORTS_RT
131 个文件
全树带 CONFIG_PREEMPT_RT 条件编译的文件数,大部分工作是理清语义
µs 级
目标:最坏延迟从毫秒级、无法界定,变成可测、有上界的微秒级

0.1一张图看懂 RT 改了什么

判断一个内核"实时不实时",就看一件事:最高优先级的任务被唤醒以后,最多还要等多久才能上 CPU。下图左边是普通内核。任务上面压着好几层不可抢占的执行上下文:硬中断、软中断、关抢占的自旋锁区。这几层不管谁在跑,都比任何任务优先,连 SCHED_FIFO 99 也得等。右边是 PREEMPT_RT。这几层大部分被"压平"成普通线程,回到调度器手里,按优先级排队。

非 RT 内核(PREEMPT / VOLUNTARY) PREEMPT_RT 内核 谁能打断谁 NMI / 关中断区(local_irq_save) 硬中断 handler(全部驱动) GICv3 单一优先级,不嵌套 softirq / tasklet(irq_exit 时就地跑) NET_RX、TIMER、RCU、BLOCK… spinlock_t / preempt_disable 区 持锁者不可被抢占 ——— 调度器管辖范围 ——— SCHED_FIFO 99 … 1 的任务 SCHED_OTHER 任务 / kworker 线程化 变睡眠锁 + PI 大部分挪走 NMI / raw_spinlock_irqsave 区(极少) 最小硬中断:primary handler、 arch timer、IPI(SGI)、HARD hrtimer raw_spinlock_t / preempt_disable(调度器、GIC…) 全部由调度器按优先级仲裁 用户 RT 任务(如 FIFO 80–98) irq/N-xxx 中断线程(FIFO 50) rcuc/N、rcub/N、ksoftirqd/N… spinlock_t 持有者:可抢占、可被 PI 提升 SCHED_OTHER 任务 / kworker
执行上下文的"优先级压平"。左侧 4 层不可抢占上下文压在所有任务上面;右侧只剩两层很薄的原子区,其余都回到调度器手里
核心结论

RT 并没有让内核跑得更快,平均延迟甚至略有上升,吞吐也会下降。它换来的是最坏情况可以界定:任务能被耽搁多久,只取决于剩下的那点原子区有多长,与系统负载无关。

0.2问题 → 解法 → 章节

每个延迟来源,RT 都给了一个对应的解法,而每个解法又会带出新的问题。比如锁一旦可以睡眠,就会有优先级反转,于是必须有 PI;RCU 读者一旦可以被抢占,宽限期就可能被拖长,于是需要 RCU boost。下图把问题和解法一一对上。

flowchart LR
    p1["长时间关中断"]
    p2["spinlock 关抢占"]
    p3["硬中断 handler 不可调度"]
    p4["softirq 在 irq_exit 就地跑"]
    p5["优先级反转"]
    p6["printk 同步刷串口"]
    p7["RCU 读侧 = 关抢占"]
    s1["spinlock_t → rtmutex 睡眠锁"]
    s2["强制中断线程化"]
    s3["softirq 线程化 + local_lock"]
    s4["优先级继承 PI"]
    s5["printk 线程化 pr/legacy"]
    s6["可抢占 RCU + RCU_BOOST"]
    s7["hrtimer 硬/软分离"]
  p1 --> s1
  p2 --> s1
  p3 --> s2
  p4 --> s3
  p5 --> s4
  p4 --> s7
  p6 --> s5
  p7 --> s6
  click s1 "03-locks.html"
  click s2 "05-irq-thread.html"
  click s3 "06-softirq-timer.html"
  click s4 "04-pi.html"
  click s5 "07-rcu-printk-mm.html"
  click s6 "07-rcu-printk-mm.html"
  click s7 "06-softirq-timer.html"
  click p1 "01-challenges.html"
  click p2 "01-challenges.html"
  click p3 "01-challenges.html"
  click p4 "01-challenges.html"
  click p5 "04-pi.html"
  click p6 "07-rcu-printk-mm.html"
  click p7 "07-rcu-printk-mm.html"
    
左列是第 1 章列出的延迟来源,右列是 RT 的应对;点击节点跳到对应章节。解法之间的连锁关系:锁能睡眠 → 必须 PI;irq 线程里 raise 的软中断 → softirq 线程化;RCU boost 借助 rtmutex 的 PI

0.3跟着一个中断走一遍

最能体现 RT 改了什么的,是这样一个场景:一个网卡中断到来,而此时一个 SCHED_FIFO 80 的用户态控制线程,正在等定时器把它唤醒。下面把同一件事在两种内核上的执行顺序并排列出来。细节分散在第 5、6、8 章。

非 RT:控制线程要排在所有中断工作后面
el0t_64_irq                    ← 假设打断的是用户态
 gic_handle_irq
  ICC_IAR1_EL1 → INTID 72      ← 网卡 SPI
  网卡 handler(关中断执行)
  ICC_EOIR1_EL1
 irq_exit_rcu
  handle_softirqs              ← 就地执行
   net_rx_action  ~数百 µs
   run_timer_softirq
 exit_to_user_mode
  do_notify_resume: schedule()
  → 这时才轮到 FIFO 80 线程
PREEMPT_RT:硬中断只做"叫醒"
el0t_64_irq
 gic_handle_irq
  ICC_IAR1_EL1 → INTID 72
  handle_fasteoi_irq: ONESHOT 先 mask
  irq_default_primary_handler
   → wake irq/72-eth0 (FIFO 50)
  EOI(线程跑完再 unmask)
 irq_exit_rcu                  ← 无 softirq
 exit_to_user_mode → schedule()
  选中 FIFO 80 控制线程
  ……控制线程跑完让出 CPU……
irq/72-eth0 运行:驱动 handler
  local_bh_enable → net_rx_action

右侧硬中断窗口只剩"读 IAR、屏蔽中断线、唤醒线程、EOI"这几步,这一段是 RT 也去不掉的,第 8 章会细讲。其余工作都变成按优先级排队的线程。所以 FIFO 80 的线程只需要等这一小段硬中断窗口,网卡上有多少流量都不影响它。

如果被打断的是内核态

上面假设中断打断的是用户态(入口 el0t_64_irq),所以抢占点是返回用户态时的 exit_to_user_mode → do_notify_resume → schedule(),任何内核都有这个点。如果打断的是内核态(入口 el1h_64_irq),抢占点换成 __el1_irq 里的 arm64_preempt_schedule_irq()。在非 RT 内核里,只要被打断的代码持着 spinlock(preempt_count != 0),这个点就会失效,FIFO 80 还得等到对方 spin_unlock。在 RT 内核里,持 spinlock_t 不增加 preempt_count,这个点几乎总是有效。详见第 2 章。

0.4贯穿全书的配色与术语

全书的调用栈和架构图用同一套颜色表示执行上下文。读每个调用栈时先问一句:这一帧是在什么上下文里跑的,能不能被抢占?

硬件 / 异常入口硬中断上下文软中断上下文内核线程 / 进程上下文用户态锁 / 临界区
原子:不可抢占、不可睡眠 可抢占(可睡眠的锁带 PI) 变成独立线程,按优先级调度 取决于配置 代码区间 / API 非 RT(PREEMPT 等) PREEMPT_RT NMI / pseudo-NMI 原子 原子(不变) 硬中断:普通驱动 handler 原子 · 关中断执行 线程 irq/N · FIFO 50 变 硬中断:arch timer · IPI · HARD hrtimer 原子 原子(不变,RT 的硬下限) local_irq_save() / raw_spin_lock_irq*() 原子 原子(不变) raw_spin_lock() / preempt_disable() 原子 原子(不变) spin_lock() / spin_lock_irqsave() 原子 · irqsave 还关中断 可抢占 · 可睡眠 · PI · 不关中断 变 local_bh_disable() / softirq 执行 原子 · BH 计数进 preempt_count 可抢占 · per-CPU 睡眠锁 变 local_lock() 原子 · 等价 preempt_disable 可抢占 · migrate_disable + 睡眠锁 变 rcu_read_lock() NONE/VOL 原子;PREEMPT 可抢占 可抢占 · 被抢占读者可 boost 变 mutex_lock() / 普通进程上下文 可睡眠 · 无 PI 可睡眠 · 有 PI 变 8 类区间原子 只剩 4 类原子,都显式写着 raw / irq / NMI 评审时看右列红格: 它们的长度决定 RT 的最坏延迟
可抢占性矩阵:同一个 API 在两种内核下的语义。带"变"标记的行是 RT 改写过的;右列剩下的红格就是 RT 内核里全部的不可抢占来源,也是代码评审要重点盯的地方
术语含义RT 下的变化
原子上下文不能睡眠、不能被调度的上下文:硬中断、关中断、关抢占、持 raw 锁范围大幅缩小,只剩 raw 锁区、关中断区和真正的硬中断
preempt_countper-task 计数,按位域记录关抢占深度、softirq、hardirq、NMI 嵌套。用来决定能否抢占、判断当前上下文、检查原子区睡眠、给 RCU 判断静止态(详解)spin_lock 不再增加它;softirq 位改由 current->softirq_disable_cnt 提供
抢占点内核检查 need_resched 并可能切换任务的位置几乎处处都是抢占点
PIPriority Inheritance,锁的持有者临时继承最高等待者的优先级从只给用户态 PI futex 用,变成所有 spinlock_t、mutex 都有
rawraw_spinlock_t 等"真自旋"原语RT 下唯一的不可抢占锁,用在调度器、irqchip、hrtimer、RCU 节点等
线程化把中断或软中断处理放进内核线程执行RT 下对所有没标 IRQF_NO_THREAD 的中断强制生效

0.5阅读路线

  • 想快速建立整体认识:00 → 01 → 03 → 05 → 09。
  • 写驱动或改内核代码:03 → 04 → 05 → 06 → 10 的编码红线。
  • 做 ARM64 实时系统落地:01 → 08 → 09 → 10。

0.6关于 6.12 这个版本

6.12 是 PREEMPT_RT 第一次能在主线直接打开的版本(2024 年 11 月发布)。Thomas Gleixner 在 sched-rt-2024-09-17 这个 merge tag 里写道:"After twenty years of development we finally reached the point to enable PREEMPT_RT support in the mainline kernel. All prerequisites are merged." 最后一块拼图是 printk。

事实位置 / 说明
arm64 打开 RT 的提交只有一行d8fccd9ca5f9 "arm64: Allow to enable PREEMPT_RT.",arch/arm64/Kconfig:107
6.12 里 PREEMPT_RT 仍然 depends on EXPERTkernel/Kconfig.preempt:70,要先开 CONFIG_EXPERT
与 PREEMPT_DYNAMIC 互斥只能在编译期选定,不能用 preempt= 启动参数切换
还没有 PREEMPT_LAZY6.13 才引入,用来给 SCHED_OTHER 任务挽回吞吐
还没有 ktimers 线程6.12 RT 下定时器软中断和其他软中断一样,由 ksoftirqd 或 raise 它的线程执行
代码基线linux-6.12.y @ fa5b06866b64(v6.12.110)。文中行号都在这个版本上核对过
怎么读代码引用

文中 path/file.c:123 形式的引用都对应上面这个版本。换了版本行号会漂移,建议按函数名 grep -n 去找。调用栈里的函数名也是在这个版本里核对过的。