拆解 schedule() 调度全链路:pick_next_task 调度类遍历、TTBR0_EL1+ASID 页表、cpu_switch_to 栈切换瞬间、rq->lock 锁交接,揭秘 switch_to 分身术,v6.6 源码。
schedule() 源码精读——从 pick_next_task 到 context_switch,内核如何完成一次无缝调度
系列:调度器演进 · 从 CFS 到 EEVDF前置知识:CFS 调度的核心数据结构、唤醒路径中的锁机制、ARM64 内存管理基础(TTBR/ASID)参考内核版本:v6.6 LTS (环境:Jetson Nano)
一、一次调度切换,CPU 上发生了什么
考虑一个最简单的场景:进程 A 在终端敲下 sleep 1,内核需要让出 CPU 给进程 B。
从 A 的视角看,sleep 1 只是一行命令。但从内核的视角,这意味着一次完整的"CPU 交接"——A 的状态必须被完整保存、B 的状态必须被完整恢复,并且在 B 看来这一切必须是"无缝"的。
"无缝"是什么意思? 假设 B 之前在 read() 系统调用中被阻塞。当调度器把 CPU 交给 B 时,B 应该从 read() 中醒来,仿佛时间从未流逝——它的栈、寄存器、页表,一切都和在 schedule() 之前一模一样。
这篇文章要回答的就是:内核怎么做到这一点?
具体来说,我们将逐函数拆解 __schedule() 的核心步骤:
__schedule() // kernel/sched/core.c:6576 ├── 处理 prev 的状态(主动睡眠则 deactivate_task 出队) ├── pick_next_task() // ① 选择下一个进程 ├── context_switch() // ② 完成 CPU 交接 │ ├── switch_mm_irqs_off() // ②a 切换页表(地址空间) │ ├── switch_to() // ②b 切换内核栈 + 寄存器 │ └── finish_task_switch() // ②c 释放 rq->lock + 善后 prev └── 返回(在 next 的上下文中)注意:远程唤醒队列(rq->wake_list)由 ttwu_queue_remote 注册的 IPI 中断回调 sched_ttwu_pending() 异步处理,不在 __schedule 路径上。二、前置知识:三重上下文的切换
在进入代码之前,需要明确"一次调度切换"到底切换了什么。很多文章笼统地说"上下文切换",但内核实际切换了三层上下文:
| 地址空间 | switch_mm_irqs_off | ||
| 内核栈 | cpu_switch_to | ||
| 用户态上下文 |
关键洞察:内核在 context_switch 中只切换地址空间和内核栈。用户态的通用寄存器(x0–x30 等)是延迟恢复的——异常/系统调用进入内核时它们已被压入内核栈的 pt_regs,在从内核态返回用户态时(eret)才被恢复。这个延迟设计避免了在每次调度时都保存和恢复全部寄存器,显著减少了上下文切换开销。
💡 彩蛋知识点 #1
为什么 FPSIMD/NEON 寄存器的保存也是延迟的?ARM64 有 32 个 128 位向量寄存器(AArch64 执行态提供 32 个 V0–V31 的 FP/SIMD 寄存器 [^5]),加上 FPSR/FPCR 共约 520 字节状态(32×16B + 8B)。内核的做法是"谁用谁负责":每个 CPU 记住"上一个占用 FPU 的用户任务",fpsimd_thread_switch 内部调用 fpsimd_save()——后者先检查当前任务的 TIF_FOREIGN_FPSTATE,若状态已"foreign"(不在寄存器里)则跳过保存,否则才把旧状态写回内存;随后根据 wrong_task || wrong_cpu 为 next 设置 TIF_FOREIGN_FPSTATE。用户任务恢复运行前若发现自己的 TIF_FOREIGN_FPSTATE 置位(说明自己的状态在别处被覆盖过),才从内存重新加载。内核态自身要用 NEON 时也必须显式申请(kernel_neon_begin()/end()),用完即还。这套 lazy FPSIMD 机制用"按需搬运"替代了每次切换的 520 字节内存传输——对 Jetson 上密集的 NEON 推理内核来说尤其关键。
三、__schedule():入口函数,不是 schedule()
3.1 为什么不能直接调用 schedule()
在阅读 schedule() 的源码之前,有一个重要的概念纠正:你看到的几乎所有内核代码调用的都是 schedule(),但实际执行调度逻辑的是 __schedule()。
// kernel/sched/core.c (v6.6)asmlinkage __visible void __sched schedule(void){structtask_struct *tsk = current;// 防止递归调度:如果你在原子上下文中调用 schedule(),这里就会告警 sched_submit_work(tsk);do { preempt_disable(); __schedule(SM_NONE); sched_preempt_enable_no_resched(); } while (need_resched()); sched_update_worker(tsk);}版本提示:v6.6 的
schedule()直接内联 do-while 循环;从 v6.8 起,Peter Zijlstra 的sched: Clean up schedule()系列才把循环体抽取出独立的__schedule_loop()。本文以 v6.6 LTS 为准,不引入__schedule_loop。
sched_submit_work 负责在调度前完成一些延迟工作(如块层 IO plug 的提交),这里不展开。我们直接进入 __schedule 本体。
3.2 __schedule:持有 rq->lock 的主循环
// kernel/sched/core.c (v6.6, 极度简化但保留核心逻辑)staticvoid __sched notrace __schedule(unsignedint sched_mode){structtask_struct *prev, *next;unsignedlong prev_state;structrq_flagsrf;structrq *rq;int cpu; cpu = smp_processor_id(); rq = cpu_rq(cpu); // ① 获取当前 CPU 的就绪队列 prev = rq->curr; // ② 保存"调度前"正在运行的进程 local_irq_disable(); rcu_note_context_switch(!!sched_mode); rq_lock(rq, &rf); // ③ 拿 rq->lock(会关本地中断)+ 保存 flags smp_mb__after_spinlock(); // 内存屏障:配合 signal/membarrier rq->clock_update_flags <<= 1; // ④ Promote REQ → ACT(左移一位,非清零) update_rq_clock(rq); // ⑤ 更新 rq 的时钟/* * ⑥ 处理 prev 的状态:若 prev 主动睡眠(非 RUNNING)且无 pending signal, * 调 deactivate_task() 把 prev 从就绪队列移除。 * 这是 __schedule 的核心职责之一——主动让出 CPU 的进程要"出队"。 * (省略 nr_uninterruptible / nr_iowait 等统计细节) */ prev_state = READ_ONCE(prev->__state);if (!(sched_mode & SM_MASK_PREEMPT) && prev_state) {if (signal_pending_state(prev_state, prev)) WRITE_ONCE(prev->__state, TASK_RUNNING);else deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK); }这里有几个容易被忽视的细节:
第一,rq_lock 不仅拿锁,还保存了当前的 preempt 和 irq 状态到 rf(struct rq_flags)中。后续释放时,这些状态会被完全恢复。
第二,远程唤醒队列(rq->wake_list)不在 __schedule 中处理。它由 ttwu_queue_remote 通过 IPI 注册的中断回调 sched_ttwu_pending() 异步处理——目标 CPU 一收到 reschedule IPI 就在中断上下文里批量消化唤醒请求。__schedule 自己只关心"挑下一个 + 切上下文"。
/* * ⑦ 步骤 1:选择下一个进程(核心决策) * * pick_next_task 返回: * - 非 NULL 的 task_struct* → 找到了要运行的进程 * - RETRY_TASK → 需要重试(deactivate 了 prev 后情况变了) * (idle 调度类始终返回 idle 线程,故不会真正返回 NULL) */ next = pick_next_task(rq, prev, &rf); clear_tsk_need_resched(prev); // ⑧ 清除 prev 的 TIF_NEED_RESCHED clear_preempt_need_resched();这里清除了 prev 的 TIF_NEED_RESCHED——prev 即将失去 CPU,它的"需要重新调度"标志在本次调度中已经被满足了。
rq = context_switch(rq, prev, next, &rf); // ⑨ 步骤 2:执行上下文切换/* * ⚠️ 注意:从这一行开始,代码在 "next" 的上下文中执行了! * (context_switch 内部完成 switch_to 后会 return finish_task_switch(prev)) * * 当前进程(current)= 之前选出来的 "next" * 变量 "prev" 仍然指向被切换出去的那个进程 * * 详见第五节——context_switch 返回后,世界已经变了。 */这就是 __schedule 最反直觉的地方:context_switch 之后,代码继续执行,但 current 已经变了。prev 这个局部变量还指向之前被换出去的进程,但执行流的上下文是 next 的。
四、pick_next_task:调度类的优先级遍历链
这是调度器决策的核心——在所有就绪进程中,选出一个"最应该运行"的。
4.1 调度类的优先级顺序
Linux 有五个调度类,按优先级从高到低:
stop_sched_class ← 最高优先级(CPU 热插拔、迁移线程) ↓dl_sched_class ← Deadline 调度(硬实时) ↓rt_sched_class ← RT 调度(软实时、SCHED_FIFO/SCHED_RR) ↓fair_sched_class ← CFS(普通进程,绝大多数) ↓idle_sched_class ← 最低优先级(每个 CPU 一个 idle 线程)调度类的遍历使用了 v6.0 起引入的链接器段排序机制(由 Peter Zijlstra 重构,取代了旧版 next 指针链表):
// kernel/sched/sched.h (v6.6)// 每个调度类实例被放入一个独立链接器段,段名形如 __stop_sched_class#define DEFINE_SCHED_CLASS(name) \const struct sched_class name##_sched_class \ __aligned(__alignof(struct sched_class)) \ __section("__" #name "_sched_class")// 链接器脚本(include/asm-generic/vmlinux.lds.h)按逆序排列各段,// 使得起止符号 __sched_class_highest / __sched_class_lowest 框住全部调度类:externstructsched_class __sched_class_highest[];// → stop(最高优先级)externstructsched_class __sched_class_lowest[];// → idle 之后#define for_class_range(class, _from, _to) \ for (class = (_from); class < (_to); class++)#define for_each_class(class) \ for_class_range(class, __sched_class_highest, __sched_class_lowest)// 优先级比较退化为地址比较(段在内存中逆序排列)#define sched_class_above(_a, _b) ((_a) < (_b))// kernel/sched/sched.h (v6.6) 中各调度类的声明externconststructsched_classstop_sched_class;externconststructsched_classdl_sched_class;externconststructsched_classrt_sched_class;externconststructsched_classfair_sched_class;externconststructsched_classidle_sched_class;注意 struct sched_class本身没有 next 字段——遍历靠 class++ 在链接器段内逐个推进,靠段的内存排列顺序决定优先级:
// kernel/sched/sched.h (简化)structsched_class {// 注意:没有 next 指针!优先级由链接器段顺序决定void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);structtask_struct * (*pick_next_task)(structrq *rq);void (*put_prev_task)(struct rq *rq, struct task_struct *p);void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);// ... 几十个函数指针};遍历时,for_each_class 从 __sched_class_highest(stop)开始用 class++ 逐个推进,每个调度类的 pick_next_task 被依次调用——谁先返回非 NULL 的 task_struct,谁就获得 CPU。 这种设计的好处:调度类优先级在链接期就固化,运行时无法被误改,且省去了一次间接跳转(class->next)。
4.2 pick_next_task 的完整逻辑
// kernel/sched/core.c (v6.6, 逐行注释版)// 注意:pick_next_task 是 wrapper,快速路径逻辑实际在 __pick_next_task()(core.c:5990)staticstructtask_struct *__pick_next_task(structrq *rq, structtask_struct *prev, structrq_flags *rf){conststructsched_class *class;structtask_struct *p;/* * ⑩ 优化:如果所有就绪进程都是 CFS(最常见的情况), * 跳过调度类的遍历,直接调 pick_next_task_fair。 * * 检查条件(注意是"不高于"而非"等于"): * prev 的调度类优先级不高于 fair * (即 prev 是 fair 或更低,如 idle;允许 prev 本身是 idle) * && rq 上所有就绪进程的数量 = CFS 的就绪进程数量 * → 意味着 stop/dl/rt 队列都为空 * * 这是一次巨大的优化:在普通 Linux 系统上, * 99.9% 的进程都是 CFS,这个优化覆盖了几乎所有调度场景。 */if (likely(!sched_class_above(prev->sched_class, &fair_sched_class) && rq->nr_running == rq->cfs.h_nr_running)) { p = pick_next_task_fair(rq, prev, rf);if (unlikely(p == RETRY_TASK))goto restart; // 走完整遍历/* 如果 fair 返回 NULL,直接退到 idle(假设下一优先级就是 idle) */if (!p) { put_prev_task(rq, prev); p = pick_next_task_idle(rq); }return p; // ← 快速路径出口 }/* * ⑪ 完整路径:按调度类优先级顺序遍历 * * for_each_class 从 stop → dl → rt → fair → idle * 每个调度类的 pick_next_task 被调用 * * stop_sched_class::pick_next_task: * 只有 stop_machine 线程,通常不运行 * * dl_sched_class::pick_next_task: * 选择最早 deadline 的实时任务 * * rt_sched_class::pick_next_task: * 选择最高优先级的 RT 任务(按位图 O(1)) * * fair_sched_class::pick_next_task: * 选择 vruntime 最小的 CFS 任务(红黑树 O(log n)) * * idle_sched_class::pick_next_task: * 返回 idle 线程(永不为 NULL) */restart: put_prev_task_balance(rq, prev, rf);again: for_each_class(class) { p = class->pick_next_task(rq);if (p)return p; }/* 理论上不会到这里(idle 调度类始终返回 idle 线程) */ BUG();💡 彩蛋知识点 #2
注意上面这个
likely()宏(__builtin_expect)的使用。它告诉 CPU 的分支预测器:"这个条件大概率成立,把 fall-through 路径预取到指令缓存。"在 CFS 覆盖 99.9% 场景的前提下,这个likely让调度器的主体路径几乎没有分支预测失败——CPU 流水线在几乎每个时钟周期都能正确预取下一条指令。这就是为什么 Linux 调度器在单次调度上的开销能做到几百个周期级别:不是代码少,而是分支预测几乎从不失败。
4.3 pick_next_task_fair:从红黑树到进程的最后一跳
我们在第 1 讲中已经见过 pick_next_task_fair 的简化版。在 pick_next_task 的完整路径中,它还需要处理一些额外的情况:
// kernel/sched/fair.c (v6.6, 简化关键路径)staticstruct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf){structcfs_rq *cfs_rq = &rq->cfs;structsched_entity *se;// 步骤 1:如果 prev 是 CFS 进程且状态是 TASK_RUNNING,// 需要把它重新放回红黑树(之前执行时它不在树上)if (prev->sched_class == &fair_sched_class) {structsched_entity *pse = &prev->se;// 更新 prev 的 vruntime(它刚运行了一段时间) update_curr(cfs_rq);// 如果 prev 还是 TASK_RUNNING,放回红黑树if (prev->on_rq) { put_prev_entity(cfs_rq, pse); // 更新统计 + 重新插入 } }// 步骤 2:从红黑树取最左节点(O(1),因为 rb_leftmost 被缓存) se = pick_next_entity(cfs_rq);// 步骤 3:将其设为当前调度实体 set_next_entity(cfs_rq, se);// 步骤 4:取回 task_structreturn task_of(se);}put_prev_entity 的作用:prev 在执行期间不在红黑树上(它是 cfs_rq->curr,不在 tasks_timeline 树中)。在决定要调度它之前,需要把它重新插回树——让它的 vruntime 与其他进程公平比较。
⏱️ 一个鲜为人知的细节
put_prev_entity不仅仅是在做重新插入。它还负责更新cfs_rq的min_vruntime(通过update_min_vruntime)。如果prev在刚过去的执行中获得了很多 CPU 时间,它的 vruntime 可能已经远大于min_vruntime了。这时候put_prev_entity不会直接把min_vruntime拉高——min_vruntime的更新策略是:取min(curr->vruntime, leftmost->vruntime)。这是一种"滞后更新"策略,避免因为一个进程的突然大量执行就把整个队列的基准值拉高——这会让那些刚睡眠醒来的小 vruntime 进程也受到影响。
五、context_switch:CPU 交接的核心
这是整篇文章中最微妙的章节——context_switch 的代码量不大(不到 100 行),但它完成了内核中最重要的"状态交接"。
// kernel/sched/core.c (v6.6, 逐行注释版)static __always_inline struct rq *context_switch(struct rq *rq, struct task_struct *prev,struct task_struct *next, struct rq_flags *rf){ prepare_task_switch(rq, prev, next); // ① 准备工作(架构相关)/* * ② 步骤 3a:切换页表 * * switch_mm_irqs_off 将 CPU 的地址空间从 prev 切换到 next。 * 在 ARM64 上,这意味着写 TTBR0_EL1 寄存器(并处理 ASID)。 * * 参数: * prev->active_mm:prev 使用的地址空间(可能是借来的) * next->mm:next 自己的地址空间(NULL = 内核线程,借 prev 的) * next:目标进程 * * 返回值:在新进程中是否需要刷新 TLB */ arch_start_context_switch(prev);if (!next->mm) {/* * next 是内核线程,没有自己的地址空间。 * 进入 lazy TLB 模式:继续使用 prev 的 active_mm, * 但不刷新 TLB(因为只访问内核空间,内核空间在所有 * 页表中映射相同)。 * * 细节:若 prev 是用户进程(prev->mm != NULL), * 还要 mmgrab_lazy_tlb(prev->active_mm) 给借用的 mm 加引用计数; * 若 prev 也是内核线程(prev->mm == NULL), * 把 prev->active_mm 清空(它本来就是借来的)。 */ enter_lazy_tlb(prev->active_mm, next); next->active_mm = prev->active_mm; // 借用 prev 的 active_mmif (prev->mm) mmgrab_lazy_tlb(prev->active_mm);else prev->active_mm = NULL; } else {/* * next 是普通进程,有自己的地址空间。 * 切换页表 = 写 TTBR0_EL1 = next 的 PGD 物理地址 * (内核页表在 TTBR1_EL1,固定不动) */ membarrier_switch_mm(rq, prev->active_mm, next->mm); switch_mm_irqs_off(prev->active_mm, next->mm, next); lru_gen_use_mm(next->mm);// 如果 prev 是内核线程(从内核线程切到用户进程),// 把它之前借用的 active_mm 记到 rq->prev_mm,留给 finish_task_switch 释放引用if (!prev->mm) { rq->prev_mm = prev->active_mm; prev->active_mm = NULL; } }内核线程为什么借用页表? 内核线程只访问内核空间。内核空间的页表映射在所有进程的页表中是完全相同的(PGD 的高位部分一致)。所以内核线程不需要自己的 mm_struct——直接借用上一个进程的页表,切换成本为零。
/* * ③ 步骤 3b:切换内核栈 + 寄存器 * * switch_to(prev, next, prev) 是一个架构相关的宏。 * 在 ARM64 上,宏展开为 __switch_to()(arch/arm64/kernel/process.c), * 其最核心的切换由 arch/arm64/kernel/entry.S 中的 * cpu_switch_to 汇编例程完成。 * * ⚠️ 这是整篇文章最重要的"心智模型转变点": * * switch_to() 调用后,代码继续执行——但执行上下文是 "next" 的。 * 返回值是 "prev" 的 task_struct 指针(保存在寄存器中传递回来)。 * * 换句话说:调用 switch_to 的 CPU 现在是 "next", * 但函数返回值告诉你 "prev" 是谁。 */ switch_mm_cid(rq, prev, next); // 多线程 LRU/MEMCG 的 mm_cid 切换 rq->clock_update_flags &= ~(RQCF_ACT_SKIP|RQCF_REQ_SKIP); prepare_lock_switch(rq, next, rf); // 准备锁交接(next 即将释放)/* * ⚡ ⚡ ⚡ 核心切换点 ⚡ ⚡ ⚡ * * 在这一行之后,current = next,prev = 被换出去的进程 * * switch_to(prev, next, prev) 的参数含义: * arg0 (prev): 要保存状态的进程 * arg1 (next): 要恢复状态的进程 * arg2 (last): 传出参数——返回"上一个运行过"的 task_struct* */ switch_to(prev, next, prev); barrier(); // 编译屏障:防止编译器把后面的代码重排到 switch_to 之前/* * ④ finish_task_switch:释放 rq->lock + 善后 prev * (current 现在是 next!) * 它是 context_switch 的尾调用——见第八节详解。 */return finish_task_switch(prev);}理解 switch_to 要分两层:
从调用者(CPU)的视角:switch_to 保存当前寄存器状态到 prev 的 thread_struct,从 next 的 thread_struct 加载新寄存器状态,然后跳转到 next 上次被换出时的 PC。对 CPU 来说,这就是在 "next" 的上下文里继续执行。
从进程的视角:prev 的寄存器状态被保存,next 的寄存器状态被恢复。next "醒来"时,switch_to 返回的是 last 参数——也就是上一个运行者。当 prev 将来被换回来时,它也走相同的路径——另一个 switch_to 的调用返回了 prev(作为 last)。
⏱️ 理解
switch_to模型把
switch_to想成一个"分身术"指令:switch_to(prev, next, last)执行前: CPU 寄存器 = prev 的状态 current = prev SP 指向 prev 的内核栈执行后: CPU 寄存器 = next 的状态(包括 SP、PC) current = next SP 指向 next 的内核栈 last = prev(x0 活着穿过切换,作为返回值传回)在
switch_to的"另一边"——当 prev 将来被重新调度回来时——它会从这个相同的switch_to调用中返回,但last参数变成了它自己的 task_struct。对 prev 来说,它只是经历了一次switch_to调用,仿佛 CPU 从未离开——虽然实际上可能已经过去了数毫秒甚至数秒。
六、switch_to 的汇编细节:寄存器保存在哪里
switch_to 是架构相关的。在 ARM64(AArch64——Jetson Orin 的 Cortex-A78AE 正是此架构)上有个容易踩坑的事实:arch/arm64/include/asm/ 下根本没有 switch_to.h——arm64 是极少数直接使用通用版本 include/asm-generic/switch_to.h 的主流架构(构建系统自动生成包装)。真正的三级调用链是:
switch_to(prev, next, last) // include/asm-generic/switch_to.h:21 └─ last = __switch_to(prev, next) // arch/arm64/kernel/process.c:523 ├─ fpsimd_thread_switch() // FPSIMD/NEON 延迟保存标记 ├─ tls_thread_switch() // TPIDR_EL0(TLS) ├─ hw_breakpoint_thread_switch() ├─ contextidr_thread_switch() // CONTEXTIDR_EL1(配合 ETM 追踪) └─ last = cpu_switch_to(prev, next) // arch/arm64/kernel/entry.S:822 ★核心汇编6.1 保存了什么
下面是 cpu_switch_to 的真实源码(v6.6,仅去掉编译条件分支),比 x86 版本干净得多:
// arch/arm64/kernel/entry.S:822 (v6.6)// 入口约定:x0 = prev, x1 = nextSYM_FUNC_START(cpu_switch_to) mov x10, #THREAD_CPU_CONTEXT add x8, x0, x10 // x8 → &prev->thread.cpu_context mov x9, sp // 先把当前 SP 存进 x9(后面要用) stp x19, x20, [x8], #16 // 保存 callee-saved 寄存器 stp x21, x22, [x8], #16 stp x23, x24, [x8], #16 stp x25, x26, [x8], #16 stp x27, x28, [x8], #16 stp x29, x9, [x8], #16 // 帧指针 FP + 内核栈指针 SP str lr, [x8] // 链接寄存器 → cpu_context.pc(恢复点) add x8, x1, x10 // x8 → &next->thread.cpu_context ldp x19, x20, [x8], #16 // 恢复 callee-saved 寄存器 ldp x21, x22, [x8], #16 ldp x23, x24, [x8], #16 ldp x25, x26, [x8], #16 ldp x27, x28, [x8], #16 ldp x29, x9, [x8], #16 // FP 恢复;x9 = next 的内核栈指针 ldr lr, [x8] // lr = next 上次被换出时的 PC mov sp, x9 // ★ 内核栈切换的瞬间 msr sp_el0, x1 // SP_EL0 = next(current 指针从此跟随) ret // 跳转到 lr → 在 next 的上下文中继续SYM_FUNC_END(cpu_switch_to)三个值得注意的真实细节:
SP 的切换被刻意放在最后:先恢复全部通用寄存器, mov sp, x9之后紧跟msr sp_el0和ret,中断窗口被压缩到最小。current的更新藏在 SP_EL0 里:ARM64 内核运行时 SP_EL0 存的是当前task_struct指针,msr sp_el0, x1一条指令就让current宏指向了 next——x86 需要每 CPU 变量,ARM64 直接复用了一个 otherwise-unused 的寄存器。x0(prev 指针)全程未被覆盖,它"活着穿过"了这次切换,返回后就是 last——这是 C 侧能拿到last的全部秘密。
与 x86 版本最大的不同:寄存器不压到内核栈上,而是存进 prev->thread.cpu_context——一段位于 task_struct 内部的连续内存。AAPCS64 调用约定规定 x19-x28、x29(FP) 为 callee-saved,共 11 个寄存器,加上 SP 和 PC(LR),cpu_context 一共 13 个 8 字节字段。
C 侧的宏简洁到只有一行——arm64 直接用通用版本:
// include/asm-generic/switch_to.h (v6.6) —— arm64 无自己的 switch_to.h,// 构建系统自动用本文件生成 <asm/switch_to.h>externstructtask_struct *__switch_to(structtask_struct *,structtask_struct *);#define switch_to(prev, next, last) \ do { \ ((last) = __switch_to((prev), (next))); \ } while (0)所有架构相关的"杂活"——FPSIMD、TLS、硬件断点、CONTEXTIDR——都在 __switch_to()(process.c:523)里、cpu_switch_to 之前完成。这个分工是刻意的设计:慢速的、可能睡眠的操作放在切栈前,cpu_switch_to 只保留最精简的纯寄存器操作,保证栈切换窗口内没有任何可能失败或阻塞的路径。
6.2 内核栈的切换就是 SP 的切换
整个 switch_to 的奥秘在于 SP 的切换。当前进程的内核栈由 SP 指向。当你把 SP 从 prev 的内核栈顶切换到 next 的内核栈顶时,接下来所有的 stp/ldp/局部变量访问都发生在 next 的内核栈上——代码还是在同一个 __schedule 里,但栈帧已经在另一个进程的地址空间了。
structtask_struct {// ...structthread_structthread;// 包含 cpu_context(切换的全部家当)// ...};structthread_struct {structcpu_contextcpu_context;// x19-x28、FP、SP、PCunsignedlong tp_value; // TLS 指针(TPIDR_EL0)// ... FPSIMD 状态、调试寄存器等(多数延迟处理)};structcpu_context { u64 x19; u64 x20; // ... x21-x27 同理 u64 x28; u64 fp; // x29:帧指针 u64 sp; // 内核栈指针 u64 pc; // 下次恢复执行的地址(即换出时的 LR)};prev 的执行状态在切换后没有被破坏。 x19–x28、FP、SP、LR 都安安稳稳躺在 prev->thread.cpu_context 里,prev 的内核栈也原封不动地待在内存中。当 prev 将来被重新调度回来时,另一个 switch_to 会把这些寄存器原样恢复——ret 一执行,它就从上次消失的那条指令继续,仿佛中间什么都没发生过。
💡 彩蛋知识点 #3
在 AArch64 上,cpu_switch_to 只保存 x19–x28、x29(FP)、SP、LR——共 13 个 64 位字段(104 字节),全部集中在 cpu_context 这块连续内存里,前 12 个用 stp 成对写入(6 条指令),最后 str lr 单独存入 cpu_context.pc。为什么不存 x0–x17?因为按 AAPCS64 调用约定它们是 caller-saved(x18 被保留作影子调用栈指针),编译器在调用点已经处理;x0 甚至活着穿过整个切换,充当返回值 last。更妙的是 FPSIMD——32 个 128 位向量寄存器加 FPSR/FPCR 共约 520 字节状态:fpsimd_thread_switch 内部调用 fpsimd_save(),后者先检查 TIF_FOREIGN_FPSTATE,只在状态仍存活在寄存器中时才写回内存,否则跳过;next 的 TIF_FOREIGN_FPSTATE 由 wrong_task || wrong_cpu 决定,真正的 520 字节搬运被推迟到冲突发生的那一刻。对跑推理负载的 Jetson 来说,这意味着 NEON/FP 密集任务之间的切换几乎不付 FPSIMD 代价。
七、switch_mm_irqs_off:页表切换的工程细节(ARM64)
地址空间的切换是上下文切换中成本最高的部分。在 ARM64 上,switch_mm_irqs_off 由 arch/arm64/include/asm/mmu_context.h 映射到 switch_mm → check_and_switch_context(实现在 arch/arm64/mm/context.c)。
// arch/arm64/mm/context.c 中 check_and_switch_context 的概念性简化// 真实签名:单参数(注意没有 cpu 参数)voidcheck_and_switch_context(struct mm_struct *mm){unsignedlong flags;unsignedint cpu = smp_processor_id(); u64 asid, old_active_asid;/* * ① 快速路径:mm 当前已有 ASID 且属于本 generation, * 用 cmpxchg_relaxed 把它登记到本 CPU 的 active_asids, * 命中即跳过锁——绝大多数进程切换走这条路。 */ asid = atomic64_read(&mm->context.id); old_active_asid = atomic64_read(this_cpu_ptr(&active_asids));if (old_active_asid && asid_gen_match(asid) && atomic64_cmpxchg_relaxed(this_cpu_ptr(&active_asids), old_active_asid, asid))goto switch_mm_fastpath;/* * ② 慢速路径:拿 cpu_asid_lock,校验/分配 ASID * 若 ASID 属于旧 generation(asid_gen_match 失败), * 调 new_context(mm) 触发 generation 滚动 + 全局 TLB 刷新—— * 罕见但昂贵(ASID 硬件位宽 8/16 位,由 ID_AA64MMFR0_EL1.ASIDBits 报告;进程数超过容量时耗尽)[^3]。 */ raw_spin_lock_irqsave(&cpu_asid_lock, flags); asid = atomic64_read(&mm->context.id);if (!asid_gen_match(asid)) { asid = new_context(mm); // 可能触发 generation 滚动 atomic64_set(&mm->context.id, asid); }if (cpumask_test_and_clear_cpu(cpu, &tlb_flush_pending)) local_flush_tlb_all(); atomic64_set(this_cpu_ptr(&active_asids), asid); raw_spin_unlock_irqrestore(&cpu_asid_lock, flags);switch_mm_fastpath: arm64_apply_bp_hardening();/* * ③ 核心动作:写 TTBR0_EL1 * TTBR0_EL1 = mm->pgd 物理地址 | ASID * (用户地址空间;TTBR1_EL1 完全不动,永远指向内核页表 swapper_pg_dir) * 注意:若启用了 PAN 软件模拟,TTBR0 的写入会延迟到 uaccess_enable()。 */if (!system_uses_ttbr0_pan()) cpu_switch_mm(mm->pgd, mm);}TTBR0 / TTBR1 双页表基址——ARM64 的先天优势:x86 只有一个 CR3,切换进程时整个地址空间一起换(内核映射靠 global page 才能在 TLB 中幸存)。ARM64 从体系结构层面把虚拟地址空间劈成两半:低一半归用户(TTBR0),高一半归内核(TTBR1,恒指向 swapper_pg_dir)。切换进程只动 TTBR0——内核页表天然跨进程共享,连 global page 的特殊处理都不需要。
ASID 的重要性:ASID 给每个进程的 mm 打上硬件标签,TLB 条目带着标签存活——切换进程时旧条目不必清除,每个进程的翻译照常命中属于自己的条目。这与 x86 的 PCID 思想相同,但有两点不同:ASID 是 ARMv8 与生俱来的机制(PCID 是 x86 后期补上的,且不少配置默认关闭);ASID 耗尽时的 generation 换血是一次性全局刷新,逻辑更简洁。对 Jetson 上"一进程一模型"的多进程推理服务来说,ASID 命中的快速路径让进程间切换几乎不付 TLB 代价。
八、finish_task_switch:prev 进程的善后
context_switch 从 switch_to 返回后,控制流在 next 的上下文中。此时 finish_task_switch 被调用来处理 prev 的善后工作:
// kernel/sched/core.c (v6.6, finish_task_switch 的核心清理序列)staticstruct rq *finish_task_switch(struct task_struct *prev) __releases(rq->lock){structrq *rq = this_rq(); // 在 next 的上下文中获取 rqstructmm_struct *mm = rq->prev_mm; // 取出 context_switch 存下的 prev 借用 mmunsignedint prev_state; rq->prev_mm = NULL; // 清空,一次性的传递完成/* * ⚠️ 关键:释放 rq->lock! * 注意这个锁是在 __schedule 的 rq_lock() 中拿的, * 一直持有到现在才释放。在 context_switch 之后, * prev 已经不在运行了,next 正在运行。 * 释放锁的进程不是拿锁的进程——这是调度路径独有的"锁交接"模式。 */ prev_state = READ_ONCE(prev->__state); vtime_task_switch(prev); // 虚拟时间记账 perf_event_task_sched_in(prev, current); finish_task(prev); // 清除 prev->on_cpu(与唤醒侧的 smp_cond_load 配对) tick_nohz_task_switch(); finish_lock_switch(rq); // ★ 这里真正释放 rq->lock finish_arch_post_lock_switch(); kcov_finish_switch(current); kmap_local_sched_in(); fire_sched_in_preempt_notifiers(current);/* * 处理 prev 借用的 active_mm 的延迟释放 * (若 prev 是内核线程,它借用的 mm 在这里 mmdrop_lazy_tlb_sched 释放引用) */if (mm) mmdrop_lazy_tlb_sched(mm);if (unlikely(prev_state == TASK_DEAD)) {if (prev->sched_class->task_dead) prev->sched_class->task_dead(prev); put_task_stack(prev); put_task_struct_rcu_user(prev); }return rq; // 返回给 __schedule(在 next 的上下文中)}⏱️ 锁交接:调度器最精妙的并发设计
rq->lock 是在 __schedule 的入口(rq_lock)由 prev 拿的,在 finish_task_switch 中由 next 释放。这打破了通常的"谁拿锁谁释放锁"原则,但在调度器中是正确且必要的:
prev拿着锁做完了pick_next_task和context_switchswitch_to后current = nextnext可以安全地释放锁(它现在拥有这个 CPU)
这个"锁交接"避免了在两个进程之间留出无锁窗口——不需要在 switch_to 前后释放和重新拿锁,这在多核并发场景下提供了正确的防护。
另外,finish_task_switch 还顺带处理了 prev 的 TASK_DEAD 情况:若 prev 已标记为死亡(最后一次 schedule 不会再返回),则调用其调度类的 task_dead 回调并释放 task_struct 引用。
九、完整调度时序:从 need_resched 到 next 获得 CPU
现在我们来串起整个调度路径。假设进程 A 在执行 sleep 1,内核需要调度到进程 B:
时间线 进程 A(prev) 进程 B(next,睡眠中)───────────────────────────────────────────────────────────────────────────────T0 用户态:执行 sleep 系统调用 → 进入内核态 → hrtimer_nanosleep() → schedule()T1 schedule() → sched_submit_work(tsk) → preempt_disable() → __schedule(SM_NONE) ← v6.6 直接内联 do-while,无 __schedule_loopT2 __schedule() 入口 cpu = smp_processor_id() rq = cpu_rq(cpu) prev = rq->curr = AT3 rq_lock(rq, &rf) ← 拿 rq->lock(关本地中断) smp_mb__after_spinlock() update_rq_clock(rq)T4 处理 prev 的状态(若 A 主动睡眠且无 signal) → deactivate_task(rq, A, DEQUEUE_SLEEP) ← A 出队 (唤醒队列 rq->wake_list 由 IPI 中断回调 sched_ttwu_pending() 异步处理,不在 __schedule 路径上)T5 next = pick_next_task(rq, prev, &rf) → pick_next_task_fair(rq, prev, &rf) ├── put_prev_entity(cfs_rq, &A.se) │ 更新 A 的 vruntime,放回红黑树 └── pick_next_entity(cfs_rq) → rb_leftmost → B 的 sched_entity → next = BT6 clear_tsk_need_resched(A) ← A 不再需要调度T7 context_switch(rq, A, B, &rf) ├── prepare_task_switch(rq, A, B) │ 架构相关:保存 FPU 状态等 │ ├── switch_mm_irqs_off(A.active_mm, B.mm, B) │ TTBR0_EL1 = B->pgd 物理地址(TTBR1 不动) │ 地址空间已切换到 B 的用户页表 │ TLB 未刷新(B 的 ASID 条目依然有效) │ ├── switch_to(A, B, A) │ ┌── 保存 A 的寄存器到 A.thread.cpu_context: │ │ STP x19,x20 … x27,x28(5 条) │ │ STP x29(FP), x9(SP) ← FP + SP 成对 │ │ STR LR → cpu_context.pc ← LR 单独存 │ │ │ ├── 加载 B 的状态(与保存完全对称): │ │ LDP x19,x20 … x27,x28 │ │ LDP x29(FP), x9(SP) │ │ LDR LR ← cpu_context.pc │ │ MOV SP, x9 ← ★ 内核栈切换 │ │ MSR SP_EL0, x1 ← current = B │ │ │ └── ⚡ ret → 从 switch_to 返回(在 B 的上下文中!) │ └── finish_task_switch(A) ← 注意:current 现在是 B! 释放 rq->lock(锁交接!) mmdrop(A 的 borrowed_mm)T8 __schedule 继续(在 B 的上下文) sched_preempt_enable_no_resched() → need_resched()? 否 → 退出 do-while → schedule() 返回T9 B 从 hrtimer_nanosleep() → schedule() 调用中返回 → 仿佛时间从未流逝! → 继续执行 B 的代码💡 彩蛋知识点 #4
看看
cpu_switch_to的真实指令顺序,你会发现一个精妙之处:mov sp, x9被排在最后——先保存 prev 的一切、再恢复 next 的全部寄存器,SP 切换之后只剩msr sp_el0和ret两条指令。这不是巧合:SP 的切换本身是原子的(一条指令),中断在切换前后到达都无害——切换前,中断压在 prev 的栈上;切换后,中断压在 next 的栈上,两种情况都自洽。真正危险的是"切换了 SP 但栈上状态不完整"的窗口,而 arm64 用指令排序把这个窗口压缩到两条指令之内。外层还有双保险:context_switch全程持有rq->lock,而rq->lock的获取会关本地中断。指令排序 + 关中断,两层防护确保栈切换万无一失。
十、总结:为什么说这是一次"无缝"调度
回到文章开头的问题:内核如何做到一次"无缝"的调度?
答案是三个维度的设计:
状态快照式保存: switch_to把prev的执行状态"冻结"——callee-saved 寄存器(x19–x28、FP、LR、SP)躺在prev->thread.cpu_context,内核栈帧原封不动地留在内存中。当prev将来被唤醒时,它从同一个switch_to调用返回——寄存器和栈都完整,一切如初。锁的交接: rq->lock由prev拿、next释放。在switch_to的瞬间没有锁的"空隙"——从 prev 的视角,锁一直持有到它被换出;从 next 的视角,锁在它被换入时立即被释放。延迟恢复:用户态寄存器和 FPSIMD 状态不在 context_switch中恢复——用户态通用寄存器在eret返回时才从内核栈的pt_regs恢复,FPSIMD 靠TIF_FOREIGN_FPSTATE按需加载。这让每次上下文切换只保存 13 个 64 位字段(104 字节:x19–x28 + FP + SP + LR,6 条stp加 1 条str写完cpu_context),而不是全部 31 个通用寄存器(248 字节)外加约 520 字节的 FPSIMD 状态。
这三层设计叠加在一起,让 Linux 调度器在最简单的场景下(相同地址空间、CFS 快速路径、ASID 命中)能在 200 个 CPU 周期内完成一次完整的调度——这已经逼近了硬件能支持的理论极限。
系列预告
这三篇文章已经把调度器的"核心循环"凑齐了:进程如何被挑选(第 1 讲 CFS)→ 进程如何被唤醒(第 2 讲 try_to_wake_up)→ 调度如何被触发和执行(本讲 schedule + context_switch)。
但还有一个基础问题我们一直没有正面回答:CFS 怎么知道一个进程"应该"有多重?
这就是 PELT(Per-Entity Load Tracking)——CFS 的负载跟踪系统。下一讲我们将拆解:
PELT 的几何级数衰减公式——为什么用 y^32 = 0.5而不是线性衰减?struct sched_avg的三个负载指标分别代表什么?怎么用?PELT 的负载如何影响 select_task_rq、唤醒抢占和 SMP 均衡?下一篇:PELT 负载跟踪——CFS 如何用几何级数计算进程"重量"
互动思考
根据这篇文章的内容,思考以下问题:
switch_to之后,代码在 B 的上下文中继续执行。此时 B 的内核栈上有哪些内容?__schedule的局部变量prev的值被保存在哪里?
提示:回顾第六节的寄存器保存机制(cpu_context 与内核栈各保存了什么),以及 C 语言局部变量在 AArch64 上通常存储在栈上还是寄存器中。
上篇文章的思考题答案:两个 CPU 同时对一个睡眠进程 P1 调用 wake_up_process 时,内核通过 TASK_WAKING 状态防止重复入队。 简化的竞态分析:
CPU 0 CPU 1wake_up_process(P1) wake_up_process(P1) try_to_wake_up(P1) try_to_wake_up(P1) 拿 pi_lock ↓(等 pi_lock) P1->state & TASK_NORMAL ✓ WRITE_ONCE(P1->state, TASK_WAKING) 释放 pi_lock ──────────────────→ 拿到 pi_lock P1->state & TASK_NORMAL? TASK_WAKING & TASK_NORMAL = 0 → goto unlock → 返回 0第二个 CPU 的 try_to_wake_up 返回 0(唤醒失败),调用者看到 0 就知道"这个进程已经被其他人唤醒了"。
关于作者:专注 Linux 内核子系统源码级解读,参考内核版本 v6.6 LTS。不搬文档,只讲源码背后的设计权衡。
本文基于 Linux v6.6 LTS 源码分析(ARM64/AArch64 视角,参考平台 Jetson Orin / Cortex-A78AE)。__schedule 完整实现位于 kernel/sched/core.c:6576-6720,context_switch 位于 kernel/sched/core.c:5324-5388,finish_task_switch 位于 kernel/sched/core.c:5211-5290,__pick_next_task 快速路径位于 kernel/sched/core.c:5990-6028;调度类组织(for_each_class/DEFINE_SCHED_CLASS)位于 kernel/sched/sched.h:2308-2338;switch_to 宏位于 include/asm-generic/switch_to.h(arm64 无自有 switch_to.h),__switch_to 位于 arch/arm64/kernel/process.c:523,核心汇编 cpu_switch_to 位于 arch/arm64/kernel/entry.S:822,check_and_switch_context(单参数)位于 arch/arm64/mm/context.c:215。
附录 A:性能数据来源与可信度说明
本文涉及的数值分两类,可信度如下:
可验证的架构事实(有权威来源)
struct user_fpsimd_state) | arch/arm64/include/uapi/asm/sigcontext.h [^6] | |
ID_AA64MMFR0_EL1.ASIDBits 报告 | ||
附录 B:ARM 体系结构术语表
为不熟悉 ARM64 的读者整理本文出现的 ARM 架构术语,按类别分组。
执行状态与异常级别
| AArch64 | |
| ARMv8.2-A | |
| EL0 / EL1 | |
| eret | ELR_ELx 恢复 PC、从 SPSR_ELx 恢复 PSTATE。系统调用/异常返回用户态的最终指令。 |
内存管理
| TTBR0_EL1 / TTBR1_EL1 | swapper_pg_dir,不随进程切换)。这是 ARM64 与 x86 仅一个 CR3 的关键架构差异。 |
| ASID | TTBR0_EL1 的高位。 |
| TLB | |
| CSV3 | ID_AA64PFR0_EL1.CSV3 |
| CONTEXTIDR_EL1 | |
PAN / system_uses_ttbr0_pan | uaccess_enable()。 |
| CNP | TTBR0_EL1[0]=CnP 位,允许多核共享同一 TTBR0(同进程的多线程),减少硬件 TLB 比较开销。 |
| KPTI |
寄存器与调用约定
| x0–x30 | |
| SP / SP_EL0 / SP_EL1 | task_struct 指针(current 宏 mrs sp_el0 读取),一条指令即得 current。 |
| PC | cpu_context.pc 存的是任务被换出时的 LR 值,恢复后 ret 跳回此处继续。 |
| LR (x30) | bl)时自动存入返回地址;ret 跳转到 LR。任务切换时 LR 存入 cpu_context.pc。 |
| AAPCS64 | cpu_switch_to 只需保存 callee-saved 集(x19–x28、FP、SP、LR)。 |
| callee-saved / caller-saved | cpu_switch_to 保存 13 个 callee-saved 字段,x0 活着穿过切换充当返回值。 |
| TPIDR_EL0 | tls_thread_switch 在任务切换时更新它。 |
指令与屏障
| stp / ldp | cpu_switch_to 用 6 条 stp 成对保存寄存器,比逐个 str 高效。 |
| str / ldr | cpu_switch_to 末尾用 str lr/ldr lr 处理奇数个字段。 |
| msr / mrs | msr sp_el0, x1 设 current,mrs x0, sp_el0 读 current)。 |
| mov sp, x9 | cpu_switch_to 中这一条是内核栈切换的原子瞬间。 |
| DSB | __switch_to 中 dsb(ish)(inner-shareable)确保 TLB/缓存维护在迁移前完成,也是 membarrier 系统调用要求。 |
| ISB |
扩展与安全特性
| FPSIMD / NEON | TIF_FOREIGN_FPSTATE 延迟保存。 |
| SVE | |
| TIF_FOREIGN_FPSTATE | |
| MTE | __switch_to 中 mte_thread_switch 处理。 |
| PAuth / ptrauth | ptrauth_thread_switch_user 切换用户态指针签名密钥。 |
| SCS | scs_save/scs_load_current 在切换中处理。 |
| Meltdown / Spectre |
微架构
| Cortex-A78AE |
[^1]: NVIDIA Jetson Orin 官方技术规格——CPU Max Frequency 2.2 GHz(AGX Orin 12-core Cortex-A78AE)。https://www.nvidia.com/en-eu/autonomous-machines/embedded-systems/jetson-orin[1]
[^2]: Mukesh Pilaniya, Virtual Memory Internals — Part 2(AArch64 TLB/ASID/页表遍历开销与 generation rollover 详解)。https://mukeshpilaniya.github.io/posts/virtual_memory_internals_part_2[2]
[^3]: ARM, Learn the architecture — AArch64 memory management Guide §4.6 Address Space Identifiers(ASID 标记、TTBR0_EL1.ASID 字段、8/16 位宽度)。https://documentation-service.arm.com/static/655de9bdee7b9d0d88eb7dbb[3]
[^4]: 多课网,《Linux 上下文切换的开销是多少?》——ARM Cortex-A72 类核心上下文切换约 2-5µs/次(参考量级,非 A78AE 实测)。https://duoke360.com/post/13145[4]
[^5]: ARM, ARM Architecture Reference Manual ARMv8, for ARMv8-A architecture profile(A1.3.1:AArch64 提供 31 个 64 位通用寄存器 + 32 个 128 位 FP/SIMD 寄存器 + EL0–EL3 四级异常)。https://www.cse.unsw.edu.au/~cs9242/20/project/armv8.pdf[5]
[^6]: Linux Kernel v6.6 源码,arch/arm64/include/uapi/asm/sigcontext.h——struct user_fpsimd_state { __u32 fpsr; __u32 fpcr; __uint128_t vregs[32]; }(520 字节)。https://github.com/torvalds/linux/blob/v6.6/arch/arm64/include/uapi/asm/sigcontext.h[6]
[^7]: Texas Instruments, AM64x Linux Performance Guide——LMBench lat_ctx 实测(Cortex-A53 @1GHz,参考量级)。https://downloads.ti.com/processor-sdk-linux/esd/docs/08_00_00_21/devices/AM64X/Linux_Performance_Guide.html[7]
引用链接
[1]https://www.nvidia.com/en-eu/autonomous-machines/embedded-systems/jetson-orin
[2]https://mukeshpilaniya.github.io/posts/virtual_memory_internals_part_2
[3]https://documentation-service.arm.com/static/655de9bdee7b9d0d88eb7dbb
[4]https://duoke360.com/post/13145
[5]https://www.cse.unsw.edu.au/~cs9242/20/project/armv8.pdf
[6]https://github.com/torvalds/linux/blob/v6.6/arch/arm64/include/uapi/asm/sigcontext.h
[7]https://downloads.ti.com/processor-sdk-linux/esd/docs/08_00_00_21/devices/AM64X/Linux_Performance_Guide.html
夜雨聆风