从 fork 到 exit:Linux 调度器全解Linux 6.12.110

A3 第一次上 CPU:从 WFI 到 eret

系列 A · 场景主线+1 场景视图逻辑视图进程视图开发视图物理视图

A1 结束时,子进程 C 已经挂在 CPU2 的运行队列上,一个 IPI 正飞向还在 wfi 里睡觉的 CPU2。这一篇在 CPU2 上把镜头拉到最近:CPU 醒来、__schedule 选中 C、换页表、换寄存器、换栈,然后从 A1 伪造好的 ret_from_fork 一路走到 eret,最后让 C 在 EL0 看到 fork() 返回 0。这也是理解所有“任务切换”的最佳样本:新任务走的就是和老任务完全相同的切换代码。

0. 场景设定

此刻的 CPU2

  • 运行的是 idle 任务 swapper/2,它停在 cpu_do_idle()wfi 上。
  • CPU2 进 idle 之前最后运行的用户进程是某个进程 X(比如一个 shell)。idle 仍然“借用”着 X 的 mmactive_mm),所以 TTBR0_EL1 还指向 X 的页表。
  • 运行队列:nr_running = 1,唯一的任务是 C(v = 101.0d = 102.4util_avg = 134,都是 A1 算好的)。
  • C 从没运行过:on_cpu = 0cpu_context.pc = ret_from_forkmm->context.id = 0(还没有 ASID)。

本篇要回答:

  1. CPU2 从 wfi 醒来之后,怎么走到 __schedule()
  2. __schedule(SM_IDLE) 选中 C 的过程,和普通的调度有什么不同?
  3. 换地址空间时,C 的 ASID 是怎么分配的?为什么它的第一次切入一定走慢路径?
  4. __switch_tocpu_switch_to 各换了哪些 ARM64 状态?为什么只存 13 个寄存器?
  5. rq 锁由 idle 拿、由 C 放,这笔账怎么对上?
  6. eret 之前还发生了什么?为什么 C 第一次返回用户态时要装载 FP/SIMD 寄存器?

1. 全景:15 步

图 A3-1 子进程第一次上 CPU2 的完整路径(场景 + 开发视图)
图 A3-1 子进程第一次上 CPU2 的完整路径(场景 + 开发视图)
# 步骤 源码位置 运行在谁的栈上
SGI 到达,WFI 退出,do_handle_IPIscheduler_ipi arch/arm64/kernel/smp.c:945include/linux/sched.h:1976 idle(IRQ 栈)
do_idle 退出循环,调用 schedule_idle() kernel/sched/idle.c:271:375 idle
❸-❺ __schedule(SM_IDLE):加锁、选中 C、更新 rq->curr kernel/sched/core.c:6613 idle
❻-❽ context_switchprepare_task、切换 mm kernel/sched/core.c:5296 idle
ARM64 check_and_switch_contextcpu_do_switch_mm arch/arm64/mm/context.c:215:349 idle
__switch_to:换 ARM64 线程私有状态 arch/arm64/kernel/process.c:577 idle
cpu_switch_to:换寄存器、换栈、换 sp_el0 arch/arm64/kernel/entry.S:825 idle → C
ret_from_fork arch/arm64/kernel/entry.S:858 C
⓬⓭ schedule_tailfinish_task_switch:放锁、收尾 kernel/sched/core.c:5271:5177 C
asm_exit_to_user_modedo_notify_resume arch/arm64/kernel/entry-common.c:183:130 C
ret_to_userkernel_exit 0eret arch/arm64/kernel/entry.S:612:335 C → EL0

2. ❶❷ 从 WFI 醒来

2.1 idle 在等什么

// kernel/sched/idle.c:271(简化)
static void do_idle(void)
{
        ...
        while (!need_resched()) {
                local_irq_disable();
                arch_cpu_idle_enter();
                if (cpu_idle_force_poll || tick_check_broadcast_expired())
                        cpu_idle_poll();
                else
                        cpuidle_idle_call();       // → cpuidle 驱动选一个 PSCI 状态,或 arch_cpu_idle()
                arch_cpu_idle_exit();
        }
        ...
        schedule_idle();                           // idle.c:375
}

没有 cpuidle 驱动时,最终会走到 arch/arm64/kernel/idle.c:23cpu_do_idle()dsb(sy); wfi;进入 wfi 时中断是关着的(上面的 local_irq_disable()),这没有问题:ARM 架构规定,只要有未决的中断,即使它在 PSTATE 里被屏蔽,wfi 也会退出。退出之后,idle 代码重新打开中断,中断随即被处理。

例:CPU2 睡得越深,C 等得越久

X9 的 cpuidle 驱动(PSCI)通常会提供几种状态,下面的延迟是示意量级:

状态 做了什么 退出延迟(示意) 对 A1 里那条 IPI 的影响
WFI 核停止取指,时钟门控 ~1 µs 几乎立刻醒来
核掉电(CPU off) 核的上下文丢失,要靠固件恢复 几十 µs 要先恢复现场才能处理 IPI
簇掉电(cluster off) 连同 L2/DSU 的部分逻辑一起断电 百 µs 量级 C 要多等这么久才能第一次运行

cpuidle 的 governor(menu/teo)会根据“预计还会空闲多久”来选择状态,所以一个很少被用到的 CPU,被叫醒时反而更慢。A1 里 fork 选核挑中了“util 最小”的 CPU2,而 util 小往往意味着它空闲得久、睡得深,这里面存在一个小小的矛盾。对延迟敏感的 RT 系统,通常会用 PM QoS(/dev/cpu_dma_latency)限制 idle 深度。A6 会展开讲。

2.2 IPI 做了什么:几乎什么都没做

// arch/arm64/kernel/smp.c:953
case IPI_RESCHEDULE:
        scheduler_ipi();       // include/linux/sched.h:1976 → preempt_fold_need_resched()
        break;

scheduler_ipi() 只做一件事:把 TIF_NEED_RESCHED “折叠”preempt_count。在 ARM64 上,preempt_count 的高 32 位存放的是取反的 need_resched 标志,0 表示需要调度(arch/arm64/include/asm/preempt.h)。折叠之后,preempt_count 的 64 位整体值就能反映“要不要调度”。真正的调度并不在 IPI 处理函数里做,而是在 IPI 返回、do_idle 重新检查 need_resched() 时发生。

IPI 只负责“叫醒”,不负责“调度”。 这是一个很重要的设计:中断处理函数运行在中断上下文里,不能直接切换任务;它只留下一个标记,让被打断的代码在合适的地方自己去调度。

3. ❸❹❺ __schedule(SM_IDLE):选中 C

schedule_idle()kernel/sched/core.c:6837)直接调用 __schedule(SM_IDLE),不经过 sched_submit_work,因为 idle 任务没有要提交的块设备 IO 请求,也不是 workqueue worker。

// kernel/sched/core.c:6613(节选)
local_irq_disable();
rcu_note_context_switch(preempt);
rq_lock(rq, &rf);
smp_mb__after_spinlock();                           // ❸
update_rq_clock(rq);
prev_state = READ_ONCE(prev->__state);              // idle 永远是 TASK_RUNNING
if (sched_mode == SM_IDLE) {
        if (!rq->nr_running && !scx_enabled()) {    // 队列为空:直接 next = prev,不做任何事
                next = prev;
                goto picked;
        }
}
next = pick_next_task(rq, prev, &rf);               // ❹ nr_running = 1 → 选
picked:
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
if (likely(prev != next)) {
        rq->nr_switches++;
        RCU_INIT_POINTER(rq->curr, next);           // ❺
        ++*switch_count;
        trace_sched_switch(preempt, prev, next, prev_state);
        rq = context_switch(rq, prev, next, &rf);
}

3.1 SM_IDLE 优化了什么

SM_IDLE 是 6.12 新加的模式(commit 3dcac251b066,v6.12-rc1)。idle 被“假唤醒”时,比如 IPI 实际上是给别的用途,或者任务刚被别的 CPU 偷走,队列可能是空的。这时直接 next = prev,跳过整个 pick_next_task,其中包括 newidle balance,也就是主动去别的 CPU 拉任务的那一步。在我们的场景里 nr_running = 1,所以不走这个捷径。

3.2 pick_next_task 的快路径

__pick_next_taskkernel/sched/core.c:5976)的快路径条件,在这里两条都满足:

于是直接调用 pick_next_task_fair(rq, prev, rf)

pick_next_task_fair
 ├─ pick_task_fair → pick_eevdf(cfs_rq)
 │     └─ cfs_rq->nr_running == 1 → 直接返回唯一的 se(C),不做 eligible 判断(fair.c:937)
 ├─ prev->sched_class != fair → goto simple
 └─ put_prev_set_next_task(rq, idle, C)          (kernel/sched/sched.h:2488)
       ├─ put_prev_task_idle(rq, idle)
       └─ set_next_task_fair(rq, C, true) → set_next_entity(cfs_rq, &C->se)
             ├─ set_protect_slice(se):se->vlag = se->deadline   ← A1 例 b 里保护 P 的机制,现在轮到保护 C
             ├─ cfs_rq->curr = se
             └─ se->prev_sum_exec_runtime = se->sum_exec_runtime (= 0)

例:C 的第一片时间片从这一刻开始受保护

set_protect_slicevlag 设成 deadline(102.4)。之后如果 CPU2 上又来了一个任务 D(例如 io 线程被唤醒到这里),只要 C 还 eligible、vlag == deadline 这个“保护标记”还在,pick_eevdf 就会直接返回 C,D 不能立刻抢占。C 至少能运行到它的 deadline,也就是 1.4ms 的虚拟时间,对 nice 0 来说就是 1.4ms 的真实时间。

有两种情况会打破保护:D 的 slice 比 C 短,这由 PREEMPT_SHORT 控制(kernel/sched/features.h:25);或者 D 属于更高的调度类,比如 ctrl(FIFO 80)被唤醒到 CPU2,那么直接走 resched_curr,fair 内部的保护对 RT 任务完全无效。

3.3 rq->curr = C:切换还没发生,“当前任务”就已经变了

❺ 这一步之后,rq->curr 已经是 C,但 CPU2 实际上还在 idle 的栈上执行代码,要到 ❿ 才真正换过去。这段“名实不符”的窗口期对其他 CPU 来说是可见的,比如另一个 CPU 读 cpu_curr(2) 就会看到 C。所有依赖 rq->curr 的远程代码,都必须持有 rq 锁,或者能容忍这个窗口期。rq 锁要到 ⓬ 才释放。

源码注释还指出了一个 ARM64 特有的内存序要求:membarrier() 系统调用要求在写 rq->curr 之后、返回用户态之前,必须有一个全屏障。x86 等架构靠 switch_mm 里的写 CR3 或者 mmdrop 来提供这个屏障,而 ARM64 的 spin_unlock 只有 release 语义,所以这个屏障是由 ❾ __switch_to 里的 dsb(ish) 来提供的。

4. ❻❼❽ 换地址空间

4.1 四种组合

context_switch()kernel/sched/core.c:5296)先按 prev/next 是否有用户地址空间,分四种情况处理 mm:

prev → next 典型例子 做什么 要不要写 TTBR0
内核线程 → 内核线程 kworker → ksoftirqd next->active_mm = prev->active_mm继续借用
用户进程 → 内核线程 worker → kworker 借用 worker 的 mm,并 mmgrab_lazy_tlb 增加引用
内核线程 → 用户进程 idle → C(本篇) switch_mm_irqs_off;并把借来的 mm 记到 rq->prev_mm,稍后释放
用户进程 → 用户进程 worker → 另一个进程的线程 switch_mm_irqs_off 是(同一个进程的线程之间除外)

例:同一个 CPU 上,demo 的三次切换

切换 mm 关系 ARM64 实际动作
idle → C(子进程首次运行) 借来的 X->mm → C->mm check_and_switch_context分配 ASID,写 TTBR0/TTBR1
C → worker(C 的线程) C->mm == C->mm switch_mmprev != next 不成立,什么都不做,只调用 update_saved_ttbr0
worker → kworker/2:1 worker->mm → 借用 不写 TTBR;TTBR0 仍然指向 C 的页表(lazy TLB)。kworker 访问不到用户地址,所以没关系

这就是为什么线程切换比进程切换便宜:同一个进程内部切换时,页表、ASID 和 TLB 都不需要动。

4.2 C 的第一个 ASID

图 A3-3 C 第一次被切入时的 ASID 分配与 TTBR 写入(物理视图)
图 A3-3 C 第一次被切入时的 ASID 分配与 TTBR 写入(物理视图)

ARM64 的 TLB 表项带有 ASID 标签,所以切换进程时不需要刷 TLB:只要换一个 ASID,旧进程的 TLB 表项就自动不会命中了。内核用 “代数(generation)+ ASID” 来管理有限的 ASID 空间。mm->context.id 的高位存代数,低位存 ASID 号。

A1 里 dup_mminit_new_context 把 C 的 mm->context.id 设成了 0(arch/arm64/include/asm/mmu_context.h:174)。代数为 0 永远不等于当前代数,所以 C 的第一次切入一定走慢路径

// arch/arm64/mm/context.c:215(节选)
asid = atomic64_read(&mm->context.id);                     // 0
old_active_asid = atomic64_read(this_cpu_ptr(&active_asids));
if (old_active_asid && asid_gen_match(asid) && ...)         // gen 不匹配 → 不走快路径
        goto switch_mm_fastpath;
raw_spin_lock_irqsave(&cpu_asid_lock, flags);
if (!asid_gen_match(asid)) {
        asid = new_context(mm);                             // 分配:例如 #42
        atomic64_set(&mm->context.id, asid);
}
if (cpumask_test_and_clear_cpu(cpu, &tlb_flush_pending))
        local_flush_tlb_all();                              // 只有发生过 rollover 才会进来
atomic64_set(this_cpu_ptr(&active_asids), asid);
raw_spin_unlock_irqrestore(&cpu_asid_lock, flags);
switch_mm_fastpath:
arm64_apply_bp_hardening();
if (!system_uses_ttbr0_pan())
        cpu_switch_mm(mm->pgd, mm);                         // → cpu_do_switch_mm

cpu_do_switch_mmarch/arm64/mm/context.c:349)里有一个很容易想反的细节:ASID 写在 TTBR1_EL1 里,而不是 TTBR0_EL1。Linux 设置了 TCR_EL1.A1 = 1,让 ASID 从 TTBR1 中取。这样在 SW PAN 模式下,可以随时把 TTBR0 换成指向空页表,禁止内核访问用户内存,而 ASID 保持不变。写入的顺序是:先把 TTBR0 设成保留值,再写 TTBR1(新 ASID),然后写 TTBR0(新页表),最后执行 isb。这个顺序保证了任何时刻都不会出现“新 ASID 配旧页表”的组合。

例:X9 上的 ASID 会不会用完

  • 16 位 ASID:NUM_USER_ASIDS = 65536;开启 KPTI 时每个进程占用一对(用户态、内核态各一个),剩 32768 个。
  • new_context 先尝试恢复进程“上一世”用过的 ASID;如果没有,就从 cur_idx 往后找一个空闲位。C 是全新的进程,id = 0,没有上一世,直接找空闲位。
  • 假设系统里一直在跑 make -j12 这种每秒 fork 上千个短命进程的负载,大约几十秒就会用完一轮,发生 rollover:代数加一,所有 CPU 当前正在用的 ASID 被标记为 reserved 保留下来,其余全部作废。每个 CPU 在下一次走慢路径时自己执行一次 local_flush_tlb_all,整个过程不需要发 IPI。rollover 的代价是一次全局位图清零,加上每个 CPU 各一次本地 TLB 刷新。

4.3 ❽ idle 借来的 mm 什么时候还

if (!prev->mm) {                        // idle 是内核线程
        rq->prev_mm = prev->active_mm;  // 记下借来的 X->mm
        prev->active_mm = NULL;
}

这里不能直接调用 mmdrop,因为此时还持有 rq 锁、中断也是关的,而 mmdrop 有可能触发整个 mm 的释放(如果 X 已经退出了),那是一个很重的操作。所以先记在 rq->prev_mm 里,到 ⓬ finish_task_switch 释放 rq 锁之后,再调用 mmdrop_lazy_tlb_sched

5. ❾ __switch_to:ARM64 线程私有状态

arch/arm64/kernel/process.c:577。每一项都对应一块只属于“当前线程”的硬件状态:

调用 切换的硬件状态 在 C 的首次切入中发生了什么
fpsimd_thread_switch(C) FP/SIMD/SVE/SME 寄存器(懒切换 只检查这个 CPU 上装载的 FP 状态是不是 C 的。当然不是(A1 里 copy_thread 调用了 fpsimd_flush_task_state),于是给 C 设置 TIF_FOREIGN_FPSTATE寄存器此时不装载
tls_thread_switch(C) TPIDR_EL0(用户 TLS)、TPIDRRO_EL0TPIDR2_EL0 先把 idle 的保存起来(idle 没有用户态,这一步基本是空操作),再写入 C 继承自父进程的 TLS 指针
hw_breakpoint_thread_switch 调试断点、观察点寄存器 C 没有设置,跳过
contextidr_thread_switch CONTEXTIDR_EL1 = PID 开了 CONFIG_PID_IN_CONTEXTIDR 才写,供 CoreSight/ETM trace 区分进程
entry_task_switch(C) per-cpu 变量 __entry_task = C 重要:下一次从 EL0 进入内核时,kernel_entry 从这里取 current 写回 sp_el0entry.S:224),原因见第 7 节
ssbs_thread_switch PSTATE.SSBS(Spectre-v4 缓解) 按 C 的设置调整
cntkctl_thread_switch CNTKCTL_EL1(EL0 能否访问计时器) 只有 32 位或 PR_SET_TSC 的任务才会不同
ptrauth_thread_switch_user 用户态 PAC 密钥 APIB/APDA/APDB/APGA C 继承了父进程的密钥(fork 不换密钥,exec 才换)
permission_overlay_switch POR_EL0(POE,权限覆盖) 支持 POE 的核才有
dsb(ish) —— 确保之前的 TLBI 和 cache 维护在 Inner Shareable 域内全部完成。同时提供 membarrier 要求的全屏障(3.3 节)
mte_thread_switch MTE:GCR_EL1TFSRE0_EL1 必须在 dsb 之后执行,确保异步 tag 检查故障已经记录到 TFSR
update_sctlr_el1(有条件) SCTLR_EL1 的用户位(TCF0、EnIA 等) 只有 prev 和 next 的值不同时才写,因为写 SCTLR 很慢

例:为什么 FP/SIMD 用懒切换

X9 的核支持 SVE2。一个 SVE 向量寄存器最长可以到 2048 位,32 个 Z 寄存器加上 P 寄存器、FFR,全部保存下来可能有好几 KB。而大多数切换是在内核线程之间,或者切到很快又会睡眠的任务上,这些任务根本用不到 FP 寄存器。

所以 ARM64 的策略是:切换时只打标记TIF_FOREIGN_FPSTATE),等到任务真正要返回用户态时(⓮),才把它的 FP 状态装进寄存器。C 的第一次返回用户态就会触发这次装载;如果 C 在返回用户态之前就被抢占了,这次装载就被省掉了。

6. ❿ cpu_switch_to:真正的切换只有 20 条指令

图 A3-2 cpu_switch_to 的寄存器搬运(物理视图)
图 A3-2 cpu_switch_to 的寄存器搬运(物理视图)
// arch/arm64/kernel/entry.S:825
SYM_FUNC_START(cpu_switch_to)
        save_and_disable_daif x11
        mov     x10, #THREAD_CPU_CONTEXT
        add     x8, x0, x10              // x8 = &idle->thread.cpu_context
        mov     x9, sp
        stp     x19, x20, [x8], #16      // 保存 idle 的 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]                 // pc ← 返回地址(__switch_to 里 cpu_switch_to 之后那一行)
        add     x8, x1, x10              // x8 = &C->thread.cpu_context
        ldp     x19, x20, [x8], #16      // 恢复 C 的:x19 = 0, x20 = 0(copy_thread 写的)
        ...
        ldp     x29, x9, [x8], #16       // fp = &pt_regs->stackframe, x9 = &pt_regs
        ldr     lr, [x8]                 // lr = ret_from_fork
        mov     sp, x9                   // ★ 换栈:此后 sp 指向 C 的内核栈
        msr     sp_el0, x1               // ★ 换 current:get_current() 从此返回 C
        ptrauth_keys_install_kernel x1, x8, x9, x10
        scs_save x0
        scs_load_current
        restore_irq x11
        ret                              // ★ 跳到 lr = ret_from_fork

为什么只存 13 个寄存器? 三个原因叠加在一起:

  1. cpu_switch_to 是一次普通的函数调用。按照 AAPCS64 调用约定,x0-x18 是 caller-saved 寄存器,调用方的编译器在调用点已经假定它们会被破坏,所以不需要保存。
  2. 用户态的全部寄存器,早在进入内核时就被 kernel_entry 保存到内核栈顶的 pt_regs 里了。栈一换,pt_regs 也就跟着换了。
  3. FP/SIMD 走懒切换(第 5 节)。

x0 为什么贯穿全程不变? 因为它就是返回值 lastret 跳到 ret_from_fork 之后,x0 仍然是 idle 的 task_struct 指针,恰好作为 schedule_tail(prev) 的参数。对于老任务,比如之后 C 被切走、再被切回来时,cpu_switch_toret 回到 __switch_tox0 就成了 __switch_to 的返回值 last,再赋给 context_switch 里的 prev。这就是 switch_to(prev, next, last) 里第三个参数的由来:切回来的时候,栈上保存的局部变量 prev 已经过时了,真正的“上一个任务”只能从寄存器里带过来。

例:“三个任务”问题

CPU2 上依次发生 C → A → B → C 三次切换。当 C 最后一次被切回来时:

  • C 栈上的局部变量 prev 还是它自己被切走那一刻的值,也就是 C。
  • 但刚刚让出 CPU 的是 B。finish_task_switch 需要清理的是 B:把 B->on_cpu 清零,如果 B 已经 TASK_DEAD,还要释放它的栈。
  • cpu_switch_to 在 B → C 这次切换中,x0 = B 一直没变,于是 last = B 被带回到 C 的栈帧里。

sp_el0 为什么能存 current? ARM64 在 EL1 运行内核时使用 SP_EL1 作为栈指针,SP_EL0 这个寄存器就空出来了。Linux 拿它来存当前任务的 task_struct 指针,这样 get_current() 只需要一条 mrs x0, sp_el0,不用访问内存。msr sp_el0, x1 这一条指令就完成了“切换 current”。

7. ⓫⓬⓭ ret_from_fork 与收尾:锁跨任务交接

// arch/arm64/kernel/entry.S:858
SYM_CODE_START(ret_from_fork)
        bl      schedule_tail            // x0 = prev = idle
        cbz     x19, 1f                  // x19 == 0 → 用户进程
        mov     x0, x20
        blr     x19                      // 内核线程:执行 fn(fn_arg)
1:      get_current_task tsk
        mov     x0, sp
        bl      asm_exit_to_user_mode
        b       ret_to_user
// kernel/sched/core.c:5271
asmlinkage __visible void schedule_tail(struct task_struct *prev)
{
        finish_task_switch(prev);           // ⓬
        preempt_enable();                   // ⓭
        if (current->set_child_tid)
                put_user(task_pid_vnr(current), current->set_child_tid);   // A1 里 CLONE_CHILD_SETTID 的兑现
        calculate_sigpending();
}
图 A3-4 切换前后的四个“交接”(进程视图)
图 A3-4 切换前后的四个“交接”(进程视图)

finish_task_switch(idle)kernel/sched/core.c:5177)现在运行在 C 的栈上,但它清理的是 idle

动作 为什么要在这里做
WARN_ONCE(preempt_count() != 2*PREEMPT_DISABLE_OFFSET) 对账。老任务切回来时,计数是切走时留下的 2(__schedule_loop 关一层,rq 锁关一层);新任务 C 的计数来自 A1 的 FORK_PREEMPT_COUNT正好也是 2。所以同一个检查对新老任务都成立
finish_task(idle)smp_store_release(&idle->on_cpu, 0) 必须是 release 语义,并且必须在完全不再使用 idle 的栈之后才执行。别的 CPU 上的 try_to_wake_up 会用 smp_cond_load_acquire(&p->on_cpu, !VAL) 等这个值变成 0,才能把任务搬走。idle 不会被搬走,但对普通任务来说,这就是“可以迁移了”的信号(A6、C2)
finish_lock_switch(rq) spin_acquire 修正 lockdep 的持有者,然后 raw_spin_rq_unlock_irq释放 idle 在 ❸ 拿到的 rq 锁,并打开中断
mmdrop_lazy_tlb_sched(rq->prev_mm) 归还 4.3 节 idle 借用的 X->mm
prev_state == TASK_DEAD 时释放栈 对 idle 不适用。A9 里 C 退出时,就是下一个任务在这里替它释放栈和 task_struct

例:put_user 为什么要等到这里才做

A1 里 glibc 的 fork 传了 CLONE_CHILD_SETTID,要求把子进程的 TID 写到 child_tidptr 指向的地方。这个地址位于子进程的地址空间里(COW 之后,它和父进程里的同一个虚拟地址已经是两个不同的物理页)。在 A1 的 kernel_clone 里,current 是父进程,TTBR0 指向父进程的页表,没办法往子进程的地址空间里写。

只有到了这里,TTBR0 已经切换成 C 的页表(❼),current 也是 C 了,put_user 才能写对地方。这一次写还会触发 C 的第一次 COW 缺页(第 9 节)。

8. ⓮⓯ 返回 EL0

8.1 exit_to_user_mode:返回用户态之前的“待办清单”

// arch/arm64/kernel/entry-common.c:130(节选)
static void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags)
{
        do {
                local_irq_enable();
                if (thread_flags & _TIF_NEED_RESCHED)   schedule();          // 又要调度?先让出去
                if (thread_flags & (_TIF_SIGPENDING | _TIF_NOTIFY_SIGNAL)) do_signal(regs);
                if (thread_flags & _TIF_NOTIFY_RESUME)  resume_user_mode_work(regs);
                if (thread_flags & _TIF_FOREIGN_FPSTATE) fpsimd_restore_current_state();   // ← C 走这里
                local_irq_disable();
                thread_flags = read_thread_flags();
        } while (thread_flags & _TIF_WORK_MASK);
}

C 带着 TIF_FOREIGN_FPSTATE(第 5 节设置的),所以会在这里装载 FP/SIMD 状态,也就是从父进程那里继承来的那一份。如果这时 TIF_NEED_RESCHED 也被置上了,比如 ctrl 恰好被唤醒到 CPU2,C 会先 schedule() 让出 CPU。C 第一次返回用户态之前就可能被抢占,那样的话 FP 装载就推迟到它下次返回用户态时再做。

8.2 kernel_exit 0eret

图 A3-5 kernel_exit → eret:父子两次返回(物理视图)
图 A3-5 kernel_exit → eret:父子两次返回(物理视图)

arch/arm64/kernel/entry.S:335kernel_exit 0 按这个顺序执行:

  1. pt_regs 取出 pcpstate,放进 x21x22
  2. msr sp_el0, x23:把用户栈指针写回 SP_EL0。从这一刻起,sp_el0 不再是 current。这就是 __switch_to 要更新 per-cpu __entry_task 的原因:下一次从 EL0 进入内核时,kernel_entry__entry_task 里取回 current(entry.S:224)。
  3. 安装用户态 PAC 的 IA 密钥,设置 MTE 的 GCR_EL1,应用 SSBD。
  4. msr elr_el1, x21; msr spsr_el1, x22
  5. ldp x0, x1, … 一直到 x28, x29,从 pt_regs 恢复全部通用寄存器。x0 = 0,这是 A1 里 copy_thread 写进去的。
  6. 如果开了 KPTI,先经过 tramp_exit 切到 trampoline 向量和用户页表;然后执行 eret

eret 之后,CPU 回到 EL0,pc 等于 svc #0 的下一条指令,x0 = 0。在 glibc 看来,就是 clone 返回了 0,于是 fork() 返回 0,demo 进入 if (pid == 0) 分支,打印出 [child ] pid=1235 first ran on cpu=2

9. 紧接着:第一次 COW 缺页

C 返回用户态之后执行的前几条指令,几乎一定会写栈,比如函数调用时压栈、写局部变量。A1 里 dup_mm 已经把这些页的 PTE 改成了只读,于是:

EL0 写栈 → Data Abort(权限错误)→ el0_da(entry-common.c:663)→ do_page_fault
       → handle_mm_fault → do_wp_page → wp_page_copy:分配新页、复制 4KB、改 PTE 为可写
       → 返回 EL0,重新执行那条写指令

实际上 7 节里的 put_user(set_child_tid) 更早,那是 C 的第一次写,只不过它发生在内核态,走的是内核的 uaccess 缺页路径。

例:fork 的真实成本在哪里

在 X9 上对 demo 运行 perf stat -e page-faults,context-switches

  • fork() 系统调用本身的耗时,主要是复制页表,量级在几十到几百微秒,取决于父进程映射的大小;
  • 子进程随后每写一个新页,都要付一次缺页加复制页面的代价,这些缺页分散在子进程运行的整个过程里
  • 如果子进程紧接着就执行 exec,这些 COW 页几乎都不会被写到,大部分代价被省掉了。这也是 fork + exec 模式虽然看起来浪费、实际上却并不慢的原因。

10. PREEMPT_RT 视角:这一段是不可抢占的

从 ❸ rq_lock(关中断)到 ⓬ finish_lock_switch(开中断),CPU2 一直是关中断、关抢占的。在 PREEMPT_RT 内核里也一样,因为 rq->lock 是 raw spinlock,调度器核心属于 RT 明确保留下来的“不可抢占区”(C3)。

所以这一段的长度会直接计入所有 RT 任务的最坏调度延迟。影响它的因素主要有:

因素 在 X9 上的表现 可以做什么
ASID 慢路径 + rollover rollover 时要拿全局锁 cpu_asid_lock、清位图 避免在 RT 核上频繁运行短命进程;把它们隔离到别的 CPU 上
sched_switch tracepoint、perf 事件 打开 tracing 之后每次切换都多几百纳秒 测延迟时只开必要的事件
update_sctlr_el1、MTE 切换 prev 和 next 的 MTE 模式不同时才会写 让 RT 任务和普通任务的 MTE 配置保持一致
idle 退出延迟(第 2 节) 不在这一段里,但会叠加到唤醒延迟上 PM QoS 限制 idle 深度

11. 动手实验

11.1 用 function_graph 看完整路径

# 只跟踪 schedule_tail,也就是只有新任务第一次运行时才会走的路径
sudo trace-cmd record -p function_graph -g schedule_tail --max-graph-depth 3 ./demo 1
sudo trace-cmd report | head -40

期望看到类似下面的输出(示意):

 2)               |  schedule_tail() {
 2)               |    finish_task_switch.isra.0() {
 2)   0.312 us    |      _raw_spin_unlock_irq();
 2)   0.905 us    |      mmdrop_lazy_tlb ...
 2)   2.107 us    |    }
 2)   0.420 us    |    __put_user_...  (set_child_tid)
 2)   3.260 us    |  }

2) 表示 CPU2,这和 A1 的选核结果对得上。

11.2 数一数“子进程的第一次切换”花了多久

sudo bpftrace -e '
tracepoint:sched:sched_wakeup_new { @t[args->pid] = nsecs; @cpu[args->pid] = args->target_cpu; }
tracepoint:sched:sched_switch /@t[args->next_pid]/ {
    printf("pid %d: wakeup_new -> first switch on cpu%d = %d us\n",
           args->next_pid, cpu, (nsecs - @t[args->next_pid]) / 1000);
    delete(@t[args->next_pid]);
}'

在 X9 上,目标 CPU 如果是 WFI 状态,这个值一般是个位数微秒;如果是簇掉电状态,会到上百微秒。可以用 echo 1 > /sys/devices/system/cpu/cpu2/cpuidle/state2/disable 禁用深度 idle 状态,对比前后的差别。

11.3 看切换次数和 ASID

grep -E "nr_switches|nr_voluntary|nr_involuntary" /proc/$(pgrep -n demo)/sched
# ARM64 没有直接导出 ASID;可以用 perf 事件观察 TLB 行为(事件名取决于具体的核):
perf stat -e dTLB-load-misses,iTLB-load-misses ./demo 1

12. 小结

问题 答案
从 WFI 到 __schedule SGI 让 WFI 退出 → IPI 处理函数只把 need_resched 折叠进 preempt_count → do_idle 退出循环 → schedule_idle()__schedule(SM_IDLE)
选中 C SM_IDLE 发现队列不空 → 快路径 pick_next_task_fair → 只有一个任务,直接返回 → set_next_entity 开始保护 C 的时间片
换地址空间 idle 借用的 mm → C 的 mm;C 的 context.id = 0,必走慢路径分配 ASID;ASID 写在 TTBR1;借来的 mm 在收尾时归还
换线程状态 __switch_to:FP 只打标记,TLS/PAC/MTE/SCTLR 等按需切换,dsb(ish) 兼作 membarrier 屏障
换寄存器 cpu_switch_to:13 个 callee-saved 寄存器、栈指针、sp_el0ret 跳到 A1 伪造的 ret_from_fork
对账 rq 锁由 idle 拿、由 C 放;preempt_countFORK_PREEMPT_COUNT 对上;idle->on_cpu 用 store-release 清零
返回 EL0 do_notify_resume 装载 FP 状态 → kernel_exitsp_el0 换回用户栈 → eret,此时 x0 = 0

13. 自测

  1. 如果 C 是 worker 线程(pthread_create 出来的),第一次被切入时,第 4 节的哪些步骤会被跳过?
  2. cpu_switch_to 里如果把 msr sp_el0, x1 放到 mov sp, x9 之前,会有问题吗?如果去掉 save_and_disable_daif 呢?
  3. finish_task_switch 为什么要在开头先把 prev->__state 读到局部变量 prev_state 里,然后才调用 finish_task(prev)?(提示:看 finish_task 的注释,“the load of prev->state ... must happen before this”)
  4. eret 之前,C 有没有可能被迁移到别的 CPU 上去?在哪一步之后才有可能?
参考答案 1. 线程和父进程共享同一个 mm。只要 CPU2 上一次借用的 mm 恰好就是这个 mm,`switch_mm` 里 `prev != next` 不成立,ASID 分配和写 TTBR 全部跳过。即使不是同一个 mm,由于该进程的 mm 早就有了当前代的 ASID,也会走快路径。另外 `x19` 仍然是 0,线程在 `ret_from_fork` 里走的也是用户进程那条路径。 2. 调换顺序本身在功能上没有问题,因为这两条都只修改寄存器;中断在函数开头已经被 `save_and_disable_daif` 屏蔽。但如果去掉屏蔽中断,就可能在“栈已经换成 C 的、`sp_el0` 还是 idle”这个窗口里来一个中断,中断处理代码拿到的 current 和它实际所在的栈不一致,后果不可预料。 3. `finish_task` 用 store-release 把 `prev->on_cpu` 清零之后,prev 就可以被别的 CPU 唤醒、迁移,甚至运行起来(对普通任务而言)。`finish_task_switch` 需要根据 `prev->__state` 判断它是不是 `TASK_DEAD`、要不要释放它的栈。如果在 `finish_task` 之后才读,读到的可能是另一个 CPU 已经把它唤醒后写入的 `TASK_RUNNING`,或者更糟,prev 已经在别处退出了。所以必须先读出来,再把 prev“交出去”。 4. 在 ⓬ 释放 rq 锁、⓭ `preempt_enable` 之后,C 就是一个普通的可抢占、可迁移任务了。比如 `do_notify_resume` 里的 `schedule()` 让出 CPU 之后,负载均衡可能把它拉到别的 CPU 上。此后它从别的 CPU `eret`,也完全没有问题:它要的所有状态都在它自己的内核栈和 `task_struct` 里。