乐于分享
好东西不私藏

schedule() 源码精读——从 pick_next_task 到 context_switch,内核如何完成一次无缝调度

schedule() 源码精读——从 pick_next_task 到 context_switch,内核如何完成一次无缝调度

拆解 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 路径上。

二、前置知识:三重上下文的切换

在进入代码之前,需要明确"一次调度切换"到底切换了什么。很多文章笼统地说"上下文切换",但内核实际切换了三层上下文:

上下文层
切换内容
对应的函数
O(开销)
地址空间
TTBR0_EL1 寄存器(用户页表基址)+ ASID
switch_mm_irqs_off
数十~数百 cycles(ASID 命中免 TLB 刷新)
内核栈
SP 指向的栈
cpu_switch_to
(汇编)
数十 cycles
用户态上下文
通用寄存器、FPSIMD/NEON 状态
延迟到返回用户态时恢复
延迟成本

关键洞察:内核在 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 状态到 rfstruct 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 *rqstructtask_struct *prevstructrq_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)

三个值得注意的真实细节:

  1. SP 的切换被刻意放在最后:先恢复全部通用寄存器,mov sp, x9 之后紧跟 msr sp_el0 和 ret,中断窗口被压缩到最小。
  2. current 的更新藏在 SP_EL0 里:ARM64 内核运行时 SP_EL0 存的是当前 task_struct 指针,msr sp_el0, x1 一条指令就让 current 宏指向了 next——x86 需要每 CPU 变量,ARM64 直接复用了一个 otherwise-unused 的寄存器。
  3. 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_switch
  • switch_to 后 current = next
  • next 可以安全地释放锁(它现在拥有这个 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 的获取会关本地中断。指令排序 + 关中断,两层防护确保栈切换万无一失。



十、总结:为什么说这是一次"无缝"调度

回到文章开头的问题:内核如何做到一次"无缝"的调度?

答案是三个维度的设计

  1. 状态快照式保存switch_to 把 prev 的执行状态"冻结"——callee-saved 寄存器(x19–x28、FP、LR、SP)躺在 prev->thread.cpu_context,内核栈帧原封不动地留在内存中。当 prev 将来被唤醒时,它从同一个 switch_to 调用返回——寄存器和栈都完整,一切如初。
  2. 锁的交接rq->lock 由 prev 拿、next 释放。在 switch_to 的瞬间没有锁的"空隙"——从 prev 的视角,锁一直持有到它被换出;从 next 的视角,锁在它被换入时立即被释放。
  3. 延迟恢复:用户态寄存器和 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-6720context_switch 位于 kernel/sched/core.c:5324-5388finish_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-2338switch_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:822check_and_switch_context(单参数)位于 arch/arm64/mm/context.c:215


附录 A:性能数据来源与可信度说明

本文涉及的数值分两类,可信度如下:

可验证的架构事实(有权威来源)

数据
来源
链接
Cortex-A78AE 在 Jetson AGX Orin 上 CPU 最高频率 2.2 GHz
NVIDIA Jetson Orin 官方技术规格
nvidia.com/en-eu/autonomous-machines/embedded-systems/jetson-orin [^1]
AArch64 提供 32 个 128 位 FP/SIMD 寄存器(V0–V31)
ARM Architecture Reference Manual ARMv8(A1.3.1 Execution state)
ARM ARM [^5]
FPSIMD 状态 = 32×16B + FPSR(4B) + FPCR(4B) = 520 字节
由寄存器规格推算(struct user_fpsimd_state
Linux arch/arm64/include/uapi/asm/sigcontext.h [^6]
ASID 宽度 8 或 16 位,由 ID_AA64MMFR0_EL1.ASIDBits 报告
ARM Learn the architecture: AArch64 memory management
ARM 文档 [^3]
ASID 标记 TLB 条目,切换进程不必刷 TLB
ARM AArch64 memory management Guide §4.6
ARM 文档 [^3]
ASID 耗尽时 generation 滚动 + 全局 TLB 刷新
Virtual Memory Internals Part 2
[^2]
TLB 命中约 1-2 cycles,TLB miss 触发页表遍历约 10-100 cycles
Virtual Memory Internals Part 2
[^2]

附录 B:ARM 体系结构术语表

为不熟悉 ARM64 的读者整理本文出现的 ARM 架构术语,按类别分组。

执行状态与异常级别

术语
定义与在 ARM 架构中的作用
AArch64
ARMv8 的 64 位执行状态。提供 31 个 64 位通用寄存器、32 个 128 位 FP/SIMD 寄存器、单一 A64 指令集,以及 EL0–EL3 四级异常级别。本文分析均在此执行态下。
ARMv8.2-A
ARMv8 架构的一个扩展版本集。Cortex-A78AE 实现的是 ARMv8.2-A,引入了如 SVE 的部分基础、改进的 RAS 等。
EL0 / EL1
Exception Level 0/1。EL0 = 用户态(应用运行,最低特权);EL1 = 内核态(操作系统运行,可访问系统寄存器、配置页表)。本文中"用户态/内核态切换"即 EL0↔EL1。还有 EL2(Hypervisor)、EL3(TrustZone monitor),本文不涉及。
eret
Exception Return 指令。从高异常级别返回低异常级别(如 EL1→EL0),从 ELR_ELx 恢复 PC、从 SPSR_ELx 恢复 PSTATE。系统调用/异常返回用户态的最终指令。

内存管理

术语
定义与作用
TTBR0_EL1 / TTBR1_EL1
Translation Table Base Register 0/1。EL1 下两个页表基址寄存器:TTBR0 指向用户空间页表(低半地址),TTBR1 指向内核页表(高半地址,恒指向 swapper_pg_dir,不随进程切换)。这是 ARM64 与 x86 仅一个 CR3 的关键架构差异。
ASID
Address Space IDentifier。给每个进程的 TLB 翻译打硬件标签(8 或 16 位),切换进程时旧 TLB 条目带标签保留,不必全局刷新。存于 TTBR0_EL1 的高位。
TLB
Translation Lookaside Buffer。缓存虚拟→物理地址翻译的硬件 cache。命中约 1-2 cycles;miss 则触发页表遍历(3-5 级,约 10-100 cycles)。
CSV3ID_AA64PFR0_EL1.CSV3
 字段。报告处理器是否对推测执行侧信道(如 Meltdown,CVE-2017-5754)安全。=1 表示安全,无需 KPTI。A78AE 报告 CSV3=1。
CONTEXTIDR_EL1
Context ID Register。EL1 写入的进程标识,配合 ETM(嵌入式追踪宏单元)把指令流归属到具体进程,调试/性能分析用。
PAN / system_uses_ttbr0_pan
Privileged Access Never。阻止内核在 EL1 直接访问用户空间地址(除非显式启用),防止内核漏洞读写用户内存。软件模拟 PAN 时 TTBR0 写入会延迟到 uaccess_enable()
CNP
Common Not Private。TTBR0_EL1[0]=CnP 位,允许多核共享同一 TTBR0(同进程的多线程),减少硬件 TLB 比较开销。
KPTI
Kernel Page Table Isolation。每个进程两套页表(用户/内核分离),进出内核各切一次页表基址以隔离推测执行侧信道。代价高,仅在受 Meltdown 影响的核心上默认开启。

寄存器与调用约定

术语
定义与作用
x0–x30
31 个 64 位通用寄存器。x30 又称 LR(Link Register,存函数返回地址);x29 又称 FP(Frame Pointer)。x31 作栈指针 SP 或零寄存器 ZR(视指令上下文)。
SP / SP_EL0 / SP_EL1
Stack Pointer。每级异常有独立 SP:内核运行用 SP_EL1 指向内核栈;SP_EL0 在内核态被复用为存放当前 task_struct 指针(current 宏 mrs sp_el0 读取),一条指令即得 current。
PC
Program Counter。下一条要执行的指令地址。cpu_context.pc 存的是任务被换出时的 LR 值,恢复后 ret 跳回此处继续。
LR (x30)
Link Register。函数调用(bl)时自动存入返回地址;ret 跳转到 LR。任务切换时 LR 存入 cpu_context.pc
AAPCS64
ARM Architecture Procedure Call Standard。规定哪些寄存器由调用者保存(caller-saved:x0–x18)、哪些由被调用者保存(callee-saved:x19–x29)。cpu_switch_to 只需保存 callee-saved 集(x19–x28、FP、SP、LR)。
callee-saved / caller-saved
调用约定分类。callee-saved 寄存器在被调用函数中若要使用必须先保存再恢复;caller-saved 由调用者负责。cpu_switch_to 保存 13 个 callee-saved 字段,x0 活着穿过切换充当返回值。
TPIDR_EL0
Thread Pointer ID Register, EL0。每个线程的 TLS(线程局部存储)基址指针。tls_thread_switch 在任务切换时更新它。

指令与屏障

术语
定义与作用
stp / ldp
Store/Load Pair。一条指令读写两个 64 位寄存器(共 16 字节)。cpu_switch_to 用 6 条 stp 成对保存寄存器,比逐个 str 高效。
str / ldr
Store/Load Register。单寄存器访存。cpu_switch_to 末尾用 str lr/ldr lr 处理奇数个字段。
msr / mrs
Move to/from System Register。读写系统寄存器(如 msr sp_el0, x1 设 current,mrs x0, sp_el0 读 current)。
mov sp, x9
把 x9 的值装入栈指针。cpu_switch_to 中这一条是内核栈切换的原子瞬间。
DSB
Data Synchronization Barrier。强制其前的所有内存访问完成才执行后续指令。__switch_to 中 dsb(ish)(inner-shareable)确保 TLB/缓存维护在迁移前完成,也是 membarrier 系统调用要求。
ISB
Instruction Synchronization Barrier。刷新流水线、强制重新取指。上下文切换后保证后续指令按更新后的系统寄存器状态执行。

扩展与安全特性

术语
定义与作用
FPSIMD / NEON
Floating-point + Advanced SIMD。ARMv8 的浮点与 SIMD 引擎,32 个 128 位寄存器 V0–V31。NEON 是其 SIMD 部分,做向量/矩阵运算(推理负载常用)。状态约 520 字节,靠 TIF_FOREIGN_FPSTATE 延迟保存。
SVE
Scalable Vector Extension。向量长度可变(可达 2048 位)的 SIMD 扩展,是 FPSIMD 的超集。状态比 FPSIMD 大得多。
TIF_FOREIGN_FPSTATE
线程信息标志位。置位表示"该任务的 FPSIMD 状态不在寄存器里(被别的任务覆盖过)",返回用户态前需从内存重新加载。实现 lazy FPSIMD。
MTE
Memory Tagging Extension。给每块内存打 4 位标签,硬件自动校验指针标签匹配,捕捉内存越界/UAF。__switch_to 中 mte_thread_switch 处理。
PAuth / ptrauth
Pointer Authentication。给指针签名/校验,防 ROP/JOP 攻击。ptrauth_thread_switch_user 切换用户态指针签名密钥。
SCS
Shadow Call Stack。影子调用栈,把返回地址存到独立栈防栈溢出覆盖。x18 寄存器作 SCS 指针。scs_save/scs_load_current 在切换中处理。
Meltdown / Spectre
推测执行侧信道漏洞族(CVE-2017-5753/5754 等)。Meltdown 可越权读内核内存,KPTI 是其缓解之一;ARM 多数 Cortex-A 核不受 Meltdown 影响。

微架构

术语
定义与作用
Cortex-A78AE
ARM 的高性能应用处理器核,AE = Automotive Enhanced,面向车规/边缘安全场景。ARMv8.2-A,CSV3=1(不受 Meltdown 影响),在 Jetson Orin 上最高 2.2GHz。本文参考平台。

[^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