A3 第一次上 CPU:从 WFI 到 eret
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 的
mm(active_mm),所以TTBR0_EL1还指向 X 的页表。 - 运行队列:
nr_running = 1,唯一的任务是 C(v = 101.0,d = 102.4,util_avg = 134,都是 A1 算好的)。 - C 从没运行过:
on_cpu = 0,cpu_context.pc = ret_from_fork,mm->context.id = 0(还没有 ASID)。
本篇要回答:
- CPU2 从
wfi醒来之后,怎么走到__schedule()? __schedule(SM_IDLE)选中 C 的过程,和普通的调度有什么不同?- 换地址空间时,C 的 ASID 是怎么分配的?为什么它的第一次切入一定走慢路径?
__switch_to和cpu_switch_to各换了哪些 ARM64 状态?为什么只存 13 个寄存器?- rq 锁由 idle 拿、由 C 放,这笔账怎么对上?
eret之前还发生了什么?为什么 C 第一次返回用户态时要装载 FP/SIMD 寄存器?
1. 全景:15 步
| # | 步骤 | 源码位置 | 运行在谁的栈上 |
|---|---|---|---|
| ❶ | SGI 到达,WFI 退出,do_handle_IPI → scheduler_ipi |
arch/arm64/kernel/smp.c:945、include/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_switch:prepare_task、切换 mm |
kernel/sched/core.c:5296 |
idle |
| ❼ | ARM64 check_and_switch_context → cpu_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_tail → finish_task_switch:放锁、收尾 |
kernel/sched/core.c:5271、:5177 |
C |
| ⓮ | asm_exit_to_user_mode → do_notify_resume |
arch/arm64/kernel/entry-common.c:183、:130 |
C |
| ⓯ | ret_to_user → kernel_exit 0 → eret |
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:23 的 cpu_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_task(kernel/sched/core.c:5976)的快路径条件,在这里两条都满足:
prev(idle)的调度类不高于 fair,因为 idle 类排在最后;rq->nr_running == rq->cfs.h_nr_queued,即 1 == 1,队列里全是 fair 任务。
于是直接调用 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_slice 把 vlag 设成 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_mm 里 prev != next 不成立,什么都不做,只调用 update_saved_ttbr0 |
| worker → kworker/2:1 | worker->mm → 借用 | 不写 TTBR;TTBR0 仍然指向 C 的页表(lazy TLB)。kworker 访问不到用户地址,所以没关系 |
这就是为什么线程切换比进程切换便宜:同一个进程内部切换时,页表、ASID 和 TLB 都不需要动。
4.2 C 的第一个 ASID
ARM64 的 TLB 表项带有 ASID 标签,所以切换进程时不需要刷 TLB:只要换一个 ASID,旧进程的 TLB 表项就自动不会命中了。内核用 “代数(generation)+ ASID” 来管理有限的 ASID 空间。mm->context.id 的高位存代数,低位存 ASID 号。
A1 里 dup_mm → init_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_mm(arch/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_EL0、TPIDR2_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_el0(entry.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_EL1、TFSRE0_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 条指令
// 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 个寄存器? 三个原因叠加在一起:
cpu_switch_to是一次普通的函数调用。按照 AAPCS64 调用约定,x0-x18是 caller-saved 寄存器,调用方的编译器在调用点已经假定它们会被破坏,所以不需要保存。- 用户态的全部寄存器,早在进入内核时就被
kernel_entry保存到内核栈顶的pt_regs里了。栈一换,pt_regs也就跟着换了。 - FP/SIMD 走懒切换(第 5 节)。
x0 为什么贯穿全程不变? 因为它就是返回值 last。ret 跳到 ret_from_fork 之后,x0 仍然是 idle 的 task_struct 指针,恰好作为 schedule_tail(prev) 的参数。对于老任务,比如之后 C 被切走、再被切回来时,cpu_switch_to 的 ret 回到 __switch_to,x0 就成了 __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();
}
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 0 与 eret
arch/arm64/kernel/entry.S:335 的 kernel_exit 0 按这个顺序执行:
- 从
pt_regs取出pc和pstate,放进x21、x22。 msr sp_el0, x23:把用户栈指针写回SP_EL0。从这一刻起,sp_el0不再是 current。这就是__switch_to要更新 per-cpu__entry_task的原因:下一次从 EL0 进入内核时,kernel_entry从__entry_task里取回 current(entry.S:224)。- 安装用户态 PAC 的 IA 密钥,设置 MTE 的
GCR_EL1,应用 SSBD。 msr elr_el1, x21; msr spsr_el1, x22。ldp x0, x1, …一直到x28, x29,从pt_regs恢复全部通用寄存器。x0 = 0,这是 A1 里copy_thread写进去的。- 如果开了 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_el0;ret 跳到 A1 伪造的 ret_from_fork |
| 对账 | rq 锁由 idle 拿、由 C 放;preempt_count 靠 FORK_PREEMPT_COUNT 对上;idle->on_cpu 用 store-release 清零 |
| 返回 EL0 | do_notify_resume 装载 FP 状态 → kernel_exit 把 sp_el0 换回用户栈 → eret,此时 x0 = 0 |
13. 自测
- 如果 C 是 worker 线程(
pthread_create出来的),第一次被切入时,第 4 节的哪些步骤会被跳过? cpu_switch_to里如果把msr sp_el0, x1放到mov sp, x9之前,会有问题吗?如果去掉save_and_disable_daif呢?finish_task_switch为什么要在开头先把prev->__state读到局部变量prev_state里,然后才调用finish_task(prev)?(提示:看finish_task的注释,“the load of prev->state ... must happen before this”)- 在
eret之前,C 有没有可能被迁移到别的 CPU 上去?在哪一步之后才有可能?