Linux内核分析之进程管理-05
This language version is unavailable; showing the other language.
13.1 上下文切换的触发条件
上下文切换并非凭空发生,它总是由特定的事件或条件触发。Linux 内核中触发调度(进而可能导致上下文切换)的入口最终都汇聚到 __schedule() 函数(kernel/sched/core.c:6748),但到达这个入口的路径却多种多样。根据触发方式的不同,内核定义了四种调度模式:
// kernel/sched/core.c:6480-6483
#define SM_IDLE (-1)
#define SM_NONE 0
#define SM_PREEMPT 1
#define SM_RTLOCK_WAIT 2
这些模式不仅影响 __schedule() 内部的代码路径选择(如是否允许阻塞当前任务),还影响 RCU 和调试子系统的行为。下面逐一分析每种触发条件。
1. 显式阻塞(Explicit Blocking)
1.1 概述
显式阻塞是进程主动让出 CPU 的方式。当进程需要等待某个事件(如 I/O 完成、锁释放、信号量可用)时,它会将自己的状态从 TASK_RUNNING 设置为某种阻塞状态(如 TASK_INTERRUPTIBLE 或 TASK_UNINTERRUPTIBLE),然后调用 schedule() 主动触发调度。
典型的显式阻塞场景包括:
- 互斥锁(mutex):
mutex_lock()在锁不可用时,调用__schedule()将当前任务阻塞 - 信号量(semaphore):
down()/down_interruptible()等待信号量计数 - 等待队列(waitqueue):
wait_event()/wait_event_interruptible()宏 - 完成量(completion):
wait_for_completion()等待其他线程的完成信号 - I/O 等待:磁盘、网络等异步操作完成前的阻塞
1.2 schedule() 入口
显式阻塞的入口是 schedule() 函数(kernel/sched/core.c:6975):
// kernel/sched/core.c:6975-6988
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
#ifdef CONFIG_RT_MUTEXES
lockdep_assert(!tsk->sched_rt_mutex);
#endif
if (!task_is_running(tsk))
sched_submit_work(tsk);
__schedule_loop(SM_NONE);
sched_update_worker(tsk);
}
schedule() 首先检查当前任务是否处于非 RUNNING 状态(通常在调用 schedule() 前,调用者已通过 set_current_state() 设置了目标状态),如果是,则通过 sched_submit_work() 提交待处理的 I/O 工作项。随后调用 __schedule_loop(SM_NONE) 进入调度循环。
1.3 __schedule_loop() 调度循环
// kernel/sched/core.c:6966-6973
static __always_inline void __schedule_loop(int sched_mode)
{
do {
preempt_disable();
__schedule(sched_mode);
sched_preempt_enable_no_resched();
} while (need_resched());
}
这个循环确保在 __schedule() 返回后,如果又设置了 TIF_NEED_RESCHED 标志(通过 need_resched() 检查),会立即再次进入调度。注意这里使用 sched_preempt_enable_no_resched() 而非 preempt_enable(),避免在使能抢占时又触发调度导致递归。
1.4 进程状态转换
在调用 schedule() 之前,进程通常会执行如下模式:
set_current_state(TASK_INTERRUPTIBLE); // 或 TASK_UNINTERRUPTIBLE
schedule();
set_current_state(TASK_RUNNING); // 被唤醒后恢复
set_current_state() 通过 smp_store_mb() 将新状态写入 current->__state 并执行全内存屏障,确保后续的 schedule() 能正确观察到状态变更。
在 __schedule() 内部,当 sched_mode == SM_NONE 且 prev_state != 0(即 prev 有非 RUNNING 状态)时,进入 try_to_block_task() 路径(kernel/sched/core.c:6823-6832):
// kernel/sched/core.c:6823-6832
} else if (!preempt && prev_state) {
/*
* We pass task_is_blocked() as the should_block arg
* in order to keep mutex-blocked tasks on the runqueue
* for selection with proxy-exec (without proxy-exec
* task_is_blocked() will always be false).
*/
try_to_block_task(rq, prev, &prev_state,
!task_is_blocked(prev));
switch_count = &prev->nvcsw;
}
这里 preempt 的值在 kernel/sched/core.c:6809 被重新赋值:
// kernel/sched/core.c:6809
preempt = sched_mode == SM_PREEMPT;
因此只有在非抢占模式(SM_NONE)且进程有非零状态时,才会尝试阻塞。switch_count 被切换为 &prev->nvcsw(自愿上下文切换计数),与之相对的是默认的 &prev->nivcsw(非自愿上下文切换计数,在 kernel/sched/core.c:6806 初始化)。
1.5 try_to_block_task() 详细流程
try_to_block_task() 定义在 kernel/sched/core.c:6493,是阻塞任务的核心函数:
// kernel/sched/core.c:6493-6536
static bool try_to_block_task(struct rq *rq, struct task_struct *p,
unsigned long *task_state_p, bool should_block)
{
unsigned long task_state = *task_state_p;
int flags = DEQUEUE_NOCLOCK;
步骤一:信号检查
// kernel/sched/core.c:6499-6503
if (signal_pending_state(task_state, p)) {
WRITE_ONCE(p->__state, TASK_RUNNING);
*task_state_p = TASK_RUNNING;
return false;
}
如果进程设置为 TASK_INTERRUPTIBLE 状态且有挂起的信号,signal_pending_state() 返回 true,此时不会阻塞,而是将状态恢复为 TASK_RUNNING 并返回 false。这确保了可中断的睡眠不会错过信号。
注意 TASK_UNINTERRUPTIBLE 状态下,signal_pending_state() 总是返回 false,进程不可被信号唤醒。
步骤二:Proxy Execution 检查
// kernel/sched/core.c:6505-6513
if (!should_block)
return false;
在启用 CONFIG_SCHED_PROXY_EXEC 时,如果任务因 mutex 而阻塞(task_is_blocked(prev) 为 true),should_block 参数为 false,任务不会从运行队列移除,以便被选作 proxy 执行。在未启用 Proxy Execution 的配置中,task_is_blocked() 始终返回 false,因此 should_block 始终为 true。
步骤三:负载贡献计算
// kernel/sched/core.c:6515-6518
p->sched_contributes_to_load =
(task_state & TASK_UNINTERRUPTIBLE) &&
!(task_state & TASK_NOLOAD) &&
!(task_state & TASK_FROZEN);
只有 TASK_UNINTERRUPTIBLE 状态(且没有 TASK_NOLOAD 和 TASK_FROZEN 标志)的任务才会被计入系统平均负载(load average)。TASK_INTERRUPTIBLE 状态不计入,因为它随时可能被信号唤醒。
步骤四:从运行队列移除
// kernel/sched/core.c:6520-6521
if (unlikely(is_special_task_state(task_state)))
flags |= DEQUEUE_SPECIAL;
特殊状态(如 TASK_STOPPED、TASK_TRACED)需要额外的 DEQUEUE_SPECIAL 标志通知调度类。
// kernel/sched/core.c:6534
block_task(rq, p, flags);
return true;
block_task() 最终调用调度类的 dequeue_task() 将任务从运行队列移除,并设置 p->on_rq = 0。此后,try_to_wake_up() 才能将任务重新加入运行队列。
1.6 __schedule() 与 try_to_wake_up() 的竞争
源码注释(kernel/sched/core.c:6781-6796)详细描述了一个关键的竞争场景,也是 smp_mb__after_spinlock() 存在的原因之一:
__set_current_state(@state) signal_wake_up()
schedule() set_tsk_thread_flag(p, TIF_SIGPENDING)
wake_up_state(p, state)
LOCK rq->lock LOCK p->pi_state
smp_mb__after_spinlock() smp_mb__after_spinlock()
if (signal_pending_state()) if (p->state & @state)
如果没有 smp_mb__after_spinlock(),__schedule() 可能会读取到过时的 __state 值,导致在 signal_wake_up() 已经发出信号并尝试唤醒的情况下,仍然将任务阻塞。
2. 抢占(Preemption)
2.1 概述
抢占是内核强制中断当前进程执行的行为。与显式阻塞不同,被抢占的进程通常不知道自己何时会被切换出去。抢占的实现依赖于 TIF_NEED_RESCHED 线程标志:某个触发者设置此标志,然后当前进程在下一个检查点检测到该标志并调用 __schedule(SM_PREEMPT)。
2.2 TIF_NEED_RESCHED 标志的设置
设置重调度标志的核心函数是 resched_curr()(kernel/sched/core.c:1154),它调用 __resched_curr()(kernel/sched/core.c:1112):
// kernel/sched/core.c:1112-1146
static void __resched_curr(struct rq *rq, int tif)
{
struct task_struct *curr = rq->curr;
struct thread_info *cti = task_thread_info(curr);
int cpu;
lockdep_assert_rq_held(rq);
/* idle 任务总是立即被抢占 */
if (is_idle_task(curr) && tif == TIF_NEED_RESCHED_LAZY)
tif = TIF_NEED_RESCHED;
/* 如果标志已经设置,无需重复 */
if (cti->flags & ((1 << tif) | _TIF_NEED_RESCHED))
return;
cpu = cpu_of(rq);
if (cpu == smp_processor_id()) {
/* 本 CPU:直接设置标志 */
set_ti_thread_flag(cti, tif);
if (tif == TIF_NEED_RESCHED)
set_preempt_need_resched();
return;
}
/* 远程 CPU:发送 IPI */
if (set_nr_and_not_polling(cti, tif)) {
if (tif == TIF_NEED_RESCHED)
smp_send_reschedule(cpu);
} else {
trace_sched_wake_idle_without_ipi(cpu);
}
}
// kernel/sched/core.c:1154-1157
void resched_curr(struct rq *rq)
{
__resched_curr(rq, TIF_NEED_RESCHED);
}
关键细节:
- 本 CPU 路径:如果目标 CPU 就是当前 CPU,直接通过
set_ti_thread_flag()设置 thread_info 的 flags 字段中的对应位,并通过set_preempt_need_resched()更新 per-CPU 的__preempt_count中的PREEMPT_NEED_RESCHED位(x86 使用preempt_count的最高位作为 need_resched 的快速检查路径)。 - 远程 CPU 路径:对于其他 CPU 上的任务,需要通过
smp_send_reschedule()发送处理器间中断(IPI RESCHEDULE),通知目标 CPU 尽快检查重调度标志。但如果目标 CPU 的 idle 任务处于 polling 模式(通过set_nr_and_not_polling()检查),则可以省略 IPI,因为 idle 循环会主动轮询 flag。 - TIF_NEED_RESCHED_LAZY:在
PREEMPT_LAZY配置下,某些场景只设置 lazy 标志而非立即抢占。但 idle 任务例外:对 idle 任务,lazy 标志会被升级为立即抢占标志。
2.3 触发抢占的场景
resched_curr() 被调用的典型场景包括:
时钟中断调度 tick
在高精度 tick 模式下,hrtick() 回调(kernel/sched/core.c:885)会调用当前任务的调度类 task_tick() 方法。对于 CFS 调度类,当发现当前任务的运行时间超过其时间片时,会调用 resched_curr() 设置重调度标志。
// kernel/sched/core.c:885-898
static enum hrtimer_restart hrtick(struct hrtimer *timer)
{
struct rq *rq = container_of(timer, struct rq, hrtick_timer);
struct rq_flags rf;
WARN_ON_ONCE(cpu_of(rq) != smp_processor_id());
rq_lock(rq, &rf);
update_rq_clock(rq);
rq->donor->sched_class->task_tick(rq, rq->donor, 1);
rq_unlock(rq, &rf);
return HRTIMER_NORESTART;
}
远程 tick(NO_HZ_FULL)
在 tickless 模式(NO_HZ_FULL)下,隔离的 CPU 可能长时间不接收本地 tick。此时通过 sched_tick_remote()(kernel/sched/core.c:5634)以 1Hz 的频率在 housekeeping CPU 上通过工作队列远程检查并触发调度 tick:
// kernel/sched/core.c:5634-5674
static void sched_tick_remote(struct work_struct *work)
{
// ...
if (tick_nohz_tick_stopped_cpu(cpu)) {
guard(rq_lock_irq)(rq);
struct task_struct *curr = rq->curr;
if (cpu_online(cpu)) {
update_rq_clock(rq);
if (!is_idle_task(curr)) {
u64 delta = rq_clock_task(rq) - curr->se.exec_start;
WARN_ON_ONCE(delta > (u64)NSEC_PER_SEC * 30);
}
curr->sched_class->task_tick(rq, curr, 0);
calc_load_nohz_remote(rq);
}
}
唤醒高优先级任务
当 try_to_wake_up() 唤醒一个优先级高于当前运行任务的进程时,通过 check_preempt_curr() -> resched_curr() 设置标志。详见下文唤醒部分。
2.4 抢占检查时机
设置 TIF_NEED_RESCHED 标志本身不会立即触发调度,必须在某个检查点检测到该标志才会真正调用 __schedule(SM_PREEMPT)。检查时机取决于内核配置:
PREEMPT 内核(CONFIG_PREEMPTION)
在可抢占内核中,抢占检查发生在 preempt_enable() 的路径中:
// include/linux/preempt.h 中 preempt_enable() 展开后会调用
// __preempt_count_sub_and_resched() 或类似机制
具体入口是 preempt_schedule()(kernel/sched/core.c:7088):
// kernel/sched/core.c:7088-7098
asmlinkage __visible void __sched notrace preempt_schedule(void)
{
if (likely(!preemptible()))
return;
preempt_schedule_common();
}
preempt_schedule_common()(kernel/sched/core.c:7054)在循环中调用 __schedule(SM_PREEMPT):
// kernel/sched/core.c:7054-7081
static void __sched notrace preempt_schedule_common(void)
{
do {
preempt_disable_notrace();
preempt_latency_start(1);
__schedule(SM_PREEMPT);
preempt_latency_stop(1);
preempt_enable_no_resched_notrace();
} while (need_resched());
}
注意这里先将 preempt_disable_notrace() 禁用抢占(因为 __schedule() 要求在抢占禁用状态下调用),然后调用 __schedule(SM_PREEMPT)。循环继续检查 need_resched(),如果又有重调度请求则继续调度。
非 PREEMPT 内核
在非抢占内核中,内核代码运行期间不会被抢占。抢占检查只发生在:
cond_resched():内核代码中的自愿让出点。定义在kernel/sched/core.c:7396:
// kernel/sched/core.c:7396-7420
int __sched __cond_resched(void)
{
if (should_resched(0) && !irqs_disabled()) {
preempt_schedule_common();
return 1;
}
#ifndef CONFIG_PREEMPT_RCU
rcu_all_qs();
#endif
return 0;
}
- 返回用户态:系统调用或异常返回用户空间前,体系结构代码会检查
TIF_NEED_RESCHED标志 - 中断返回:中断处理程序返回时(可能返回到内核态或用户态),检查该标志
中断上下文中的抢占
preempt_schedule_irq()(kernel/sched/core.c:7203)是从中断返回路径进入调度的入口:
// kernel/sched/core.c:7203-7221
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
enum ctx_state prev_state;
BUG_ON(preempt_count() || !irqs_disabled());
prev_state = exception_enter();
do {
preempt_disable();
local_irq_enable();
__schedule(SM_PREEMPT);
local_irq_disable();
sched_preempt_enable_no_resched();
} while (need_resched());
exception_exit(prev_state);
}
注意这个函数在调用 __schedule() 前先启用中断(local_irq_enable()),因为 __schedule() 内部会禁用中断并获取 rq->lock,如果带着中断禁止的状态调用,会导致死锁。调用 __schedule() 后再禁用中断,匹配中断上下文的要求。
2.5 preempt_schedule_notrace()
preempt_schedule_notrace()(kernel/sched/core.c:7130 附近)是函数追踪器(function tracer)使用的变体。由于函数追踪器本身也会调用 preempt_enable_notrace(),如果使用普通的 preempt_disable() 会导致无限递归。因此这个变体使用 preempt_disable_notrace() 避免被追踪。
3. 唤醒(Wake-up)
3.1 概述
唤醒是将处于阻塞状态的任务重新标记为可运行的过程。唤醒操作本身不直接触发 __schedule(),而是通过将任务加入运行队列并在必要时设置 TIF_NEED_RESCHED 标志来间接影响调度决策。
3.2 try_to_wake_up() 核心流程
try_to_wake_up()(kernel/sched/core.c:4092)是所有唤醒操作的最终汇聚点:
// kernel/sched/core.c:4092-4249
int try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags)
{
guard(preempt)();
int cpu, success = 0;
wake_flags |= WF_TTWU;
步骤一:唤醒 current 的快速路径
// kernel/sched/core.c:4099-4122
if (p == current) {
WARN_ON_ONCE(p->se.sched_delayed);
if (!ttwu_state_match(p, state, &success))
goto out;
trace_sched_waking(p);
ttwu_do_wakeup(p);
goto out;
}
如果唤醒的是当前任务自身,不需要获取任何锁。这发生在当前任务在同一 CPU 上同时作为唤醒者(例如信号处理)时。
步骤二:获取 pi_lock 并检查状态
// kernel/sched/core.c:4130-4133
scoped_guard (raw_spinlock_irqsave, &p->pi_lock) {
smp_mb__after_spinlock();
if (!ttwu_state_match(p, state, &success))
break;
获取 p->pi_lock(优先级继承锁)后,首先执行 smp_mb__after_spinlock() 确保状态读取的有序性,然后通过 ttwu_state_match() 检查任务状态是否匹配唤醒条件。如果任务已经处于 RUNNING 或 WAKING 状态,则无需进一步操作。
步骤三:检查任务是否在运行队列上
// kernel/sched/core.c:4159-4161
smp_rmb();
if (READ_ONCE(p->on_rq) && ttwu_runnable(p, wake_flags))
break;
如果任务已经在运行队列上(p->on_rq == 1),只需通过 ttwu_runnable() 更改其状态即可,不需要重新入队。此处的 smp_rmb() 确保 on_rq 的读取在 __state 读取之后。
步骤四:等待 schedule 完成
// kernel/sched/core.c:4186
smp_acquire__after_ctrl_dep();
// kernel/sched/core.c:4215-4217
if (smp_load_acquire(&p->on_cpu) &&
ttwu_queue_wakelist(p, task_cpu(p), wake_flags))
break;
// kernel/sched/core.c:4228
smp_cond_load_acquire(&p->on_cpu, !VAL);
如果目标任务的 on_cpu 为 1(仍在运行),且任务仍在原始 CPU 的运行队列上,则将唤醒请求加入远程 CPU 的 wake_list(通过 IPI 传递)。否则自旋等待 on_cpu 变为 0(即 finish_task() 中的 smp_store_release(&p->on_cpu, 0) 已完成)。
步骤五:选择目标 CPU 并入队
// kernel/sched/core.c:4230-4242
cpu = select_task_rq(p, p->wake_cpu, &wake_flags);
if (task_cpu(p) != cpu) {
if (p->in_iowait) {
delayacct_blkio_end(p);
atomic_dec(&task_rq(p)->nr_iowait);
}
wake_flags |= WF_MIGRATED;
psi_ttwu_dequeue(p);
set_task_cpu(p, cpu);
}
ttwu_queue(p, cpu, wake_flags);
select_task_rq() 根据调度类的负载均衡策略选择最合适的目标 CPU。如果目标 CPU 与当前不同,设置 WF_MIGRATED 标志并迁移任务。最终通过 ttwu_queue() 将任务加入目标 CPU 的运行队列。
3.3 唤醒与抢占的关联
唤醒操作完成后,如果被唤醒任务的优先级高于当前 CPU 上运行的任务,则通过 check_preempt_curr() -> resched_curr() 设置重调度标志。这确保了高优先级任务能尽快获得 CPU。
但注意,唤醒操作本身不会调用 __schedule()。它只是设置了标志并可能发送 IPI,实际的上下文切换发生在下一个抢占检查点。
4. 时钟中断(Timer Tick)
4.1 高精度 tick(hrtick)
Linux 调度器支持高精度调度 tick,通过 hrtimer(高精度定时器)实现。在 sched_feat(HRTICK) 启用时,调度器在任务入队或时间片开始时启动一个精确到纳秒的定时器。
hrtick() 回调(kernel/sched/core.c:885)在定时器到期时执行:
// kernel/sched/core.c:885-898
static enum hrtimer_restart hrtick(struct hrtimer *timer)
{
struct rq *rq = container_of(timer, struct rq, hrtick_timer);
struct rq_flags rf;
rq_lock(rq, &rf);
update_rq_clock(rq);
rq->donor->sched_class->task_tick(rq, rq->donor, 1);
rq_unlock(rq, &rf);
return HRTIMER_NORESTART;
}
这里调用当前任务调度类的 task_tick() 方法。对于 CFS,这会执行 check_preempt_wakeup() 或类似逻辑,在时间片耗尽时调用 resched_curr()。
4.2 传统 tick
在未启用 hrtick 的情况下,内核依赖传统的调度器 tick(通常 100Hz-1000Hz)来驱动时间记账和抢占决策。scheduler_tick()(在 kernel/time/timer.c 中调用)执行类似功能:更新当前任务的执行时间、检查时间片是否用完、必要时设置重调度标志。
4.3 NO_HZ(动态时钟)
CONFIG_NO_HZ(也称为 tickless kernel)允许在 CPU 空闲时停止周期性 tick,以节省功耗。更进一步的 CONFIG_NO_HZ_FULL 允许在 CPU 只有一个可运行任务时也停止 tick,减少不必要的定时器中断开销。
在 NO_HZ_FULL 模式下,内核通过 sched_tick_remote()(kernel/sched/core.c:5634)以 1Hz 的低频率从 housekeeping CPU 远程执行调度 tick,确保统计数据不会过度滞后。该函数通过工作队列在 housekeeping CPU 上执行,远程获取目标 CPU 的 rq 锁,然后调用 task_tick() 方法。
4.4 时钟更新与抢占的关系
在 __schedule() 内部,获取 rq 锁后有一段时钟更新逻辑(kernel/sched/core.c:6801-6804):
// kernel/sched/core.c:6801-6804
/* Promote REQ to ACT */
rq->clock_update_flags <<= 1;
update_rq_clock(rq);
rq->clock_update_flags = RQCF_UPDATED;
clock_update_flags 通过左移操作将时钟更新请求(REQ)升级为实际更新(ACT),然后调用 update_rq_clock() 更新运行队列的时间戳。这确保了调度决策基于最新的时间信息。
5. IDLE 调度
5.1 schedule_idle()
idle 任务是每个 CPU 上的特殊任务,当运行队列上没有其他可运行任务时执行。idle 任务进入睡眠时使用 schedule_idle()(kernel/sched/core.c:7000):
// kernel/sched/core.c:7000-7013
void __sched schedule_idle(void)
{
WARN_ON_ONCE(current->__state);
do {
__schedule(SM_IDLE);
} while (need_resched());
}
注意这里不使用 __schedule_loop() 而是手写循环,且没有 preempt_disable() / preempt_enable() 的配对。这是因为 idle 任务从不被抢占(如果 idle 被抢占,synchronize_rcu_tasks() 可能永远等待它离开内核态)。
5.2 SM_IDLE 模式的特殊处理
在 __schedule() 中,SM_IDLE 模式有特殊路径(kernel/sched/core.c:6816-6822):
// kernel/sched/core.c:6816-6822
if (sched_mode == SM_IDLE) {
/* SCX must consult the BPF scheduler to tell if rq is empty */
if (!rq->nr_running && !scx_enabled()) {
next = prev;
rq->next_class = &idle_sched_class;
goto picked;
}
}
如果运行队列上没有可运行任务(nr_running == 0)且未启用 sched_ext(BPF 调度器),直接选择 idle 任务自身(next = prev),跳过 pick_next_task()。这是一个快速路径优化:如果无事可做,就继续 idle。
当启用了 sched_ext 时,即使 nr_running == 0,也需要咨询 BPF 调度器,因为它可能通过特殊的调度策略维护了额外的可运行任务。
5.3 idle 循环的整体流程
cpu_idle_loop()
|
+-> tick_nohz_idle_enter() // 进入 tickless 模式
+-> default_idle_call() // 架构相关的低功耗状态
| |
| +-> arch_cpu_idle() // 进入 mwait/halt 等低功耗指令
|
+-> schedule_idle() // __schedule(SM_IDLE)
|
+-> tick_nohz_idle_exit() // 退出 tickless 模式
+-> 重复循环
idle 任务在中断唤醒后退出低功耗状态,如果 need_resched() 为 true(有任务需要运行),则通过 schedule_idle() 调度到新任务。
6. RT 锁等待(SM_RTLOCK_WAIT)
6.1 PREEMPT_RT 内核的特殊路径
在 CONFIG_PREEMPT_RT 内核中,普通的自旋锁和读写锁被转换为基于 rt_mutex 的可睡眠锁。当一个任务在 PREEMPT_RT 内核中等待这种锁时,它通过 schedule_rtlock() 进入调度(kernel/sched/core.c:7045 附近):
// kernel/sched/core.c:7045-7052
void __sched schedule_rtlock(void)
{
__schedule_loop(SM_RTLOCK_WAIT);
}
SM_RTLOCK_WAIT(值为 2)的引入是因为 __schedule() 需要区分普通阻塞和 RT 锁等待。在 kernel/sched/core.c:6755 的 preempt 判断中:
bool preempt = sched_mode > SM_NONE;
SM_RTLOCK_WAIT > 0,因此被 RCU 和调度调试视为抢占而非阻塞。这是因为 RT 锁等待在语义上更接近于"被抢占等待锁"而非"主动阻塞等待事件"。
6.2 与 Proxy Execution 的交互
在启用 CONFIG_SCHED_PROXY_EXEC 的情况下,RT 锁等待的任务可能不会从运行队列移除,而是作为 mutex 持有者的 proxy 继续运行。这使得其他等待同一 mutex 的任务可以在锁持有者的上下文中执行,减少上下文切换的开销。
在 __schedule() 的主流程中(kernel/sched/core.c:6839-6845),如果 pick_next_task() 选出的任务被标记为 blocked:
// kernel/sched/core.c:6839-6845
if (unlikely(task_is_blocked(next))) {
next = find_proxy_task(rq, next, &rf);
if (!next)
goto pick_again;
if (next == rq->idle)
goto keep_resched;
}
find_proxy_task() 尝试找到阻塞任务的 mutex 持有者作为 proxy。如果找不到合适的 proxy,则重新选择任务(goto pick_again)。如果找到的是 idle 任务,跳转到 keep_resched 标签保持重调度标志。
7. 调度模式总结
| 调度模式 | 值 | preempt | 触发场景 | 典型入口 | 关键行为 |
|---|---|---|---|---|---|
| SM_IDLE | -1 | false | CPU 空闲 | schedule_idle() |
nr_running==0 时直接返回 idle;不调用 try_to_block_task() |
| SM_NONE | 0 | false | 显式阻塞 | schedule() |
允许 try_to_block_task(),检查 prev 状态和信号 |
| SM_PREEMPT | 1 | true | 抢占 | preempt_schedule(), preempt_schedule_irq() |
不进入 try_to_block_task() 路径,prev 保持 RUNNING |
| SM_RTLOCK_WAIT | 2 | true | RT 锁等待 | schedule_rtlock() |
RCU 视为抢占;可能触发 Proxy Execution |
表中的 "preempt" 列对应 kernel/sched/core.c:6755 的 bool preempt = sched_mode > SM_NONE,影响 RCU 上下文切换通知和调试检查。而 kernel/sched/core.c:6809 的 preempt = sched_mode == SM_PREEMPT 进一步区分了任务状态处理的抢占语义:只有 SM_PREEMPT 被视为"真正的抢占",SM_RTLOCK_WAIT 虽然被 RCU 视为抢占,但在任务状态处理中不被视为抢占。
这种两级 preempt 语义的设计确保了 RT 锁等待可以在适当的时机被当作阻塞处理(例如允许任务状态变更),同时在 RCU 层面保持抢占语义(避免不必要的宽限期延迟)。
8. do_task_dead() -- 任务终局的上下文切换
当任务即将退出时,它通过 do_task_dead()(kernel/sched/core.c:6901)执行最后一次调度:
// kernel/sched/core.c:6901-6910
void __noreturn do_task_dead(void)
{
/* Causes final put_task_struct in finish_task_switch(): */
set_special_state(TASK_DEAD);
/* Tell freezer to ignore us: */
current->flags |= PF_NOFREEZE;
__schedule(SM_NONE);
BUG();
}
set_special_state(TASK_DEAD) 设置任务状态为 TASK_DEAD 并禁用中断(确保原子性)。__schedule(SM_NONE) 被调用后永远不会返回(函数标记为 __noreturn,末尾的 BUG() 理论上不可达)。在 finish_task_switch() 中,检测到 prev_state == TASK_DEAD 后会执行最终的资源释放:调用 task_dead() 调度类回调、sched_ext_dead()、cgroup_task_dead()、put_task_stack() 和 put_task_struct_rcu_user()(参见 kernel/sched/core.c:5184-5200)。
13.2 __schedule() 核心流程
__schedule() 是 Linux 内核调度器的核心函数,所有调度入口最终都汇聚于此。它负责从运行队列中选出下一个任务,执行地址空间切换和硬件上下文切换,并完成切换后的清理工作。本章详细分析 __schedule() 的每一步执行流程,深入源码细节。
1. __schedule() 入口
1.1 函数签名与参数
// kernel/sched/core.c:6748
static void __sched notrace __schedule(int sched_mode)
__sched:标注此函数为调度相关,防止某些静态分析工具将其视为普通函数调用notrace:禁止 ftrace 追踪此函数(避免追踪器干扰调度路径)sched_mode:调度模式参数,取值为SM_IDLE(-1)、SM_NONE(0)、SM_PREEMPT(1) 或SM_RTLOCK_WAIT(2)
1.2 局部变量声明
// kernel/sched/core.c:6750-6761
struct task_struct *prev, *next;
bool preempt = sched_mode > SM_NONE;
bool is_switch = false;
unsigned long *switch_count;
unsigned long prev_state;
struct rq_flags rf;
struct rq *rq;
int cpu;
prev:即将被切换出去的任务next:即将被切换进来的任务preempt:初始值为sched_mode > SM_NONE(SM_PREEMPT 和 SM_RTLOCK_WAIT 为 true),影响 RCU 和 schedule_debug 的行为。注意此值稍后在kernel/sched/core.c:6809会被覆盖switch_count:指向prev->nivcsw(非自愿切换)或prev->nvcsw(自愿切换)rf:运行队列锁的 flags,用于 irqsave/irqrestorerq:当前 CPU 的运行队列
1.3 确定当前 CPU 和运行队列
// kernel/sched/core.c:6766-6768
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
smp_processor_id() 获取当前 CPU 编号(调用时必须禁用抢占),cpu_rq() 通过 per-CPU 数组获取对应的运行队列,prev 设为当前正在运行的任务。此时 prev == current,但不使用 current 宏是因为后续可能在 rq->curr 更新后需要精确的运行队列视角。
1.4 preempt 语义的两级定义
源码中对 preempt 有两级定义,这是一个容易被忽视但极其重要的细节:
第一级(kernel/sched/core.c:6755):
bool preempt = sched_mode > SM_NONE;
用于 schedule_debug() 和 rcu_note_context_switch()。SM_RTLOCK_WAIT 在此级被视为抢占。
第二级(kernel/sched/core.c:6809):
preempt = sched_mode == SM_PREEMPT;
用于任务状态处理。只有 SM_PREEMPT 才被视为"真正的抢占"——这意味着在任务状态处理逻辑中,SM_RTLOCK_WAIT 的行为与 SM_NONE 相同,允许进入 try_to_block_task() 路径。
2. 进入调度前的准备
2.1 调度入口追踪
// kernel/sched/core.c:6764
trace_sched_entry_tp(sched_mode == SM_PREEMPT);
触发调度入口的 tracepoint,用于 perf 和 ftrace 分析。仅当 SM_PREEMPT 模式时标记为抢占。
2.2 schedule_debug() 调试检查
// kernel/sched/core.c:6770
schedule_debug(prev, preempt);
schedule_debug() 执行一系列完整性检查:
- 原子上下文检查:如果在不安全的状态下调用了 schedule()(如持有自旋锁、在中断上下文中调用 schedule() 而非 preempt_schedule_irq()),会输出警告
- preemption count 的合理性验证
- 在 PREEMPT_RT 内核中,检查 RT 锁相关的约束
2.3 hrtick 清除
// kernel/sched/core.c:6772-6773
if (sched_feat(HRTICK) || sched_feat(HRTICK_DL))
hrtick_clear(rq);
如果启用了高精度调度 tick 特性,在进入调度前取消挂起的 hrtick 定时器。因为调度后运行的不再是 prev 任务,其对应的 hrtick 已无意义。
2.4 Livepatch 切换点
// kernel/sched/core.c:6775
klp_sched_try_switch(prev);
内核热补丁(Kernel Live Patching)利用调度点作为安全的补丁切换时机。klp_sched_try_switch() 检查是否有待应用的补丁,并在当前任务即将被切换出去时安全地完成补丁切换。这是一个保守的切换策略:只有在任务不会被再次调度回同一栈帧时才应用补丁。
2.5 禁用本地中断
// kernel/sched/core.c:6777
local_irq_disable();
禁用本地 CPU 的中断。这是获取运行队列自旋锁的前提条件。在 __schedule() 的整个核心逻辑中,中断始终处于禁用状态,直到 finish_task_switch() 中的 finish_lock_switch() 释放锁时才重新启用。
中断禁用确保了调度过程中的原子性:不会有中断处理器干扰运行队列的状态,也不会在中途触发另一个调度。
2.6 RCU 上下文切换通知
// kernel/sched/core.c:6778
rcu_note_context_switch(preempt);
通知 RCU 子系统当前 CPU 即将发生上下文切换。RCU 利用此信息来确定宽限期(grace period)的推进。preempt 参数影响 RCU 的处理方式:
preempt == false(SM_NONE/SM_IDLE):RCU 将此视为阻塞(quiescent state),可以加速宽限期推进preempt == true(SM_PREEMPT/SM_RTLOCK_WAIT):RCU 将此视为抢占,需要更谨慎地处理(被抢占的任务可能仍处于 RCU 读侧临界区)
2.7 迁移禁用处理
// kernel/sched/core.c:6779
migrate_disable_switch(rq, prev);
在 PREEMPT_RT 内核中,任务可以通过 migrate_disable() 禁止被迁移到其他 CPU。此函数在调度切换时处理迁移禁用的状态,确保不会违反任务的迁移约束。
3. 获取运行队列锁
3.1 锁获取与内存屏障
// kernel/sched/core.c:6798-6799
rq_lock(rq, &rf);
smp_mb__after_spinlock();
rq_lock() 获取运行队列的 raw_spinlock_t __lock(kernel/sched/sched.h:1159)。这是一个自旋锁,用于保护运行队列的所有状态。获取锁后立即执行 smp_mb__after_spinlock(),这是一个全内存屏障。
3.2 为什么需要 smp_mb__after_spinlock()
这个屏障是 __schedule() 中最关键的同步原语之一。源码注释(kernel/sched/core.c:6781-6796)详细解释了它解决的竞争问题:
__set_current_state(@state) signal_wake_up()
schedule() set_tsk_thread_flag(p, TIF_SIGPENDING)
wake_up_state(p, state)
LOCK rq->lock LOCK p->pi_state
smp_mb__after_spinlock() smp_mb__after_spinlock()
if (signal_pending_state()) if (p->state & @state)
场景:进程 A 设置自身为 TASK_INTERRUPTIBLE 并调用 schedule()。与此同时,另一个 CPU 上的进程 B 向 A 发送信号并调用 signal_wake_up() 尝试唤醒 A。
如果没有 smp_mb__after_spinlock():
1. __schedule() 可能读到一个过时的 prev->__state 值(仍是旧值或中间值)
2. signal_pending_state() 可能读到过时的信号标志
3. 结果可能是 A 被阻塞,同时信号也已发送但未被处理
有了这个屏障:
- 在 rq->lock 获取和 smp_mb__after_spinlock() 之后,__schedule() 保证能看到 signal_wake_up() 对 __state 和信号标志的所有更新
- 同样,signal_wake_up() 在获取 pi_lock 后也能看到最新的 __state 值
此外,这个屏障还服务于 membarrier 系统调用。membarrier 需要在从用户空间进入内核后、更新 rq->curr 之前有一个全内存屏障,以匹配 membarrier 系统调用入口处的全屏障。
3.3 时钟更新
// kernel/sched/core.c:6801-6804
/* Promote REQ to ACT */
rq->clock_update_flags <<= 1;
update_rq_clock(rq);
rq->clock_update_flags = RQCF_UPDATED;
clock_update_flags 机制用于优化时钟更新频率:
RQCF_REQ_SKIP(值为 1):请求跳过时钟更新(入队/出队操作设置)RQCF_ACT_SKIP(值为 2):实际跳过(由左移RQCF_REQ_SKIP得到)RQCF_UPDATED(值为 4):标记时钟已更新
左移操作 <<= 1 将 REQ_SKIP 转换为 ACT_SKIP。如果之前没有 REQ_SKIP 标志,左移后为 0,update_rq_clock() 正常更新时钟。之后设置 RQCF_UPDATED 标记时钟已是最新。
3.4 switch_count 初始化
// kernel/sched/core.c:6806
switch_count = &prev->nivcsw;
默认指向非自愿上下文切换计数器(non-involuntary context switches)。如果稍后进入 try_to_block_task() 路径(自愿阻塞),则切换为 &prev->nvcsw(voluntary context switches,见 kernel/sched/core.c:6832)。
4. 进程状态处理
4.1 读取 prev 状态
// kernel/sched/core.c:6815
prev_state = READ_ONCE(prev->__state);
__state 字段使用 volatile 语义,必须通过 READ_ONCE() 读取以确保编译器不会优化掉此次访问或重排。注释(kernel/sched/core.c:6811-6814)说明:
We must load prev->state once (task_struct::state is volatile), such that we form a control dependency vs deactivate_task() below.
读取的 prev_state 值形成控制依赖(control dependency):后续的 if (prev_state) 判断依赖于此次读取的结果,编译器和 CPU 都不会将 prev_state 的使用重排到读取之前。
4.2 SM_IDLE 路径
// kernel/sched/core.c:6816-6822
if (sched_mode == SM_IDLE) {
if (!rq->nr_running && !scx_enabled()) {
next = prev;
rq->next_class = &idle_sched_class;
goto picked;
}
}
IDLE 模式下,如果运行队列中没有可运行任务且未启用 sched_ext,直接选择 idle 任务自身并跳过任务选择。这是一个重要的优化:避免了在无事可做时调用 pick_next_task() 的开销。
4.3 非抢占模式 + 有状态路径
// kernel/sched/core.c:6823-6832
} else if (!preempt && prev_state) {
try_to_block_task(rq, prev, &prev_state,
!task_is_blocked(prev));
switch_count = &prev->nvcsw;
}
此路径在以下条件同时满足时进入:
- sched_mode != SM_PREEMPT(即 SM_NONE 或 SM_RTLOCK_WAIT 的第二级 preempt 语义)
- prev_state != 0(prev 处于非 RUNNING 状态)
try_to_block_task() 可能将 prev 的状态改回 TASK_RUNNING(如有挂起信号),此时 *task_state_p 会被更新。无论如何,switch_count 切换为自愿切换计数器。
4.4 try_to_block_task() 内部详解
(详细分析见 01-switch-trigger.md 的 1.5 节。这里补充其在 __schedule() 上下文中的意义。)
try_to_block_task() 返回后,有两种情况:
- 返回 true:任务成功阻塞,已从运行队列移除(
p->on_rq = 0)。此时 prev 不会参与pick_next_task()的选择。 - 返回 false:任务未能阻塞(有信号或 should_block 为 false)。此时 prev 仍在运行队列上(
p->on_rq可能为 1),有可能被pick_next_task()再次选中。
4.5 控制依赖与 deactivate_task()
源码注释(kernel/sched/core.c:6524-6532)描述了 __schedule() 和 try_to_wake_up()(ttwu())之间的同步:
__schedule() ttwu()
prev_state = prev->state; if (p->on_rq && ...)
if (prev_state) goto out;
p->on_rq = 0; smp_acquire__after_ctrl_dep();
p->state = TASK_WAKING
block_task() 中设置 p->on_rq = 0 的操作与 prev_state 的读取形成控制依赖。ttwu() 通过 smp_acquire__after_ctrl_dep() 等待这个控制依赖完成,确保看到一致的 on_rq 和 __state 值。
5. 任务选择 pick_next_task
5.1 pick_next_task() 入口
// kernel/sched/core.c:6836
next = pick_next_task(rq, rq->donor, &rf);
pick_next_task() 有两个实现,取决于是否启用 CONFIG_SCHED_CORE。
5.2 __pick_next_task() 快速路径
在未启用 Core Scheduling 时(或 Core Scheduling 的委托路径),使用 __pick_next_task()(kernel/sched/core.c:5909):
// kernel/sched/core.c:5909-5965
static inline struct task_struct *
__pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
const struct sched_class *class;
struct task_struct *p;
rq->dl_server = NULL;
if (scx_enabled())
goto restart;
SCX 跳转:如果启用了 sched_ext(BPF 调度器扩展),跳过快速路径直接进入慢速路径,因为 BPF 调度器可能有自己的任务选择逻辑。
Fair 快速路径:
// kernel/sched/core.c:5927-5941
if (likely(!sched_class_above(prev->sched_class, &fair_sched_class) &&
rq->nr_running == rq->cfs.h_nr_queued)) {
p = pick_next_task_fair(rq, prev, rf);
if (unlikely(p == RETRY_TASK))
goto restart;
if (!p) {
p = pick_task_idle(rq, rf);
put_prev_set_next_task(rq, prev, p);
}
return p;
}
快速路径的条件:
- prev 的调度类不高于 fair(即不是 RT 或 DL 任务)
- 运行队列上所有可运行任务都在 CFS 中(nr_running == cfs.h_nr_queued)
当这些条件满足时(大多数桌面/服务器工作负载的常见情况),直接调用 pick_next_task_fair() 避免遍历所有调度类。如果 CFS 没有可运行任务,直接选择 idle。
RETRY_TASK 返回值表示快速路径检测到需要重试的条件(如调度组变化),跳转到 restart 标签进入慢速路径。
慢速路径:
// kernel/sched/core.c:5943-5965
restart:
prev_balance(rq, prev, rf);
for_each_active_class(class) {
if (class->pick_next_task) {
p = class->pick_next_task(rq, prev, rf);
if (unlikely(p == RETRY_TASK))
goto restart;
if (p)
return p;
} else {
p = class->pick_task(rq, rf);
if (unlikely(p == RETRY_TASK))
goto restart;
if (p) {
put_prev_set_next_task(rq, prev, p);
return p;
}
}
}
BUG(); /* The idle class should always have a runnable task. */
prev_balance() 在选择新任务前执行负载均衡检查。for_each_active_class 宏按优先级从高到低遍历调度类:stop_sched_class -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class。
每个调度类通过 pick_next_task() 或 pick_task() 提供候选任务。如果高优先级类有可运行任务,直接返回;否则继续检查下一个类。idle 调度类始终会返回 idle 任务,因此最后的 BUG() 理论上不可达。
5.3 Core Scheduling 的 pick_next_task()
当启用 CONFIG_SCHED_CORE 时,pick_next_task()(kernel/sched/core.c:6010)实现了 SMT(对称多线程)核心级别的任务选择,以防止基于硬件共享资源的侧信道攻击。
// kernel/sched/core.c:6010-6013
static struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
{
// ...
if (!sched_core_enabled(rq))
return __pick_next_task(rq, prev, rf);
如果 Core Scheduling 未在运行时启用,退化到 __pick_next_task()。
快速缓存路径:
// kernel/sched/core.c:6049-6058
if (rq->core->core_pick_seq == rq->core->core_task_seq &&
rq->core->core_pick_seq != rq->core->core_sched_seq &&
rq->core_pick) {
WRITE_ONCE(rq->core_sched_seq, rq->core->core_pick_seq);
next = rq->core_pick;
rq->dl_server = rq->core_dl_server;
rq->core_pick = NULL;
rq->core_dl_server = NULL;
goto out_set_next;
}
使用三个序列号来缓存上次的选择结果:
- core_task_seq:任务集合变更时递增
- core_pick_seq:上次选择时的 core_task_seq 值
- core_sched_seq:上次实际调度时的 core_pick_seq 值
如果任务集合没有变化(core_pick_seq == core_task_seq)且上次的选择还未被调度(core_pick_seq != core_sched_seq),直接复用缓存的选择结果。
单核心快速路径:
// kernel/sched/core.c:6098-6113
if (!need_sync) {
restart_single:
next = pick_task(rq, rf);
if (unlikely(next == RETRY_TASK))
goto restart_single;
if (!next->core_cookie) {
rq->core_pick = NULL;
rq->core_dl_server = NULL;
task_vruntime_update(rq, next, false);
goto out_set_next;
}
}
当没有 cookie 约束(need_sync == false,即没有 cookied 任务在 SMT 兄弟上运行)且选出的任务没有 cookie 时,直接使用该任务,无需协调兄弟 CPU。
多核心协调路径:
// kernel/sched/core.c:6122-6176
restart_multi:
max = NULL;
for_each_cpu_wrap(i, smt_mask, cpu) {
rq_i = cpu_rq(i);
// ...
p = pick_task(rq_i, rf);
rq_i->core_pick = p;
rq_i->core_dl_server = rq_i->dl_server;
if (!max || prio_less(max, p, fi_before))
max = p;
}
cookie = rq->core->core_cookie = max->core_cookie;
第一步:遍历 SMT 核心的所有硬件线程(超线程),每个线程选出其最高优先级任务,然后找出其中优先级最高的任务 max。max 的 cookie 成为整个核心的 cookie。
// kernel/sched/core.c:6152-6176
for_each_cpu(i, smt_mask) {
rq_i = cpu_rq(i);
p = rq_i->core_pick;
if (!cookie_equals(p, cookie)) {
p = NULL;
if (cookie)
p = sched_core_find(rq_i, cookie);
if (!p)
p = idle_sched_class.pick_task(rq_i, rf);
}
rq_i->core_pick = p;
if (p == rq_i->idle) {
if (rq_i->nr_running) {
rq->core->core_forceidle_count++;
// ...
}
} else {
occ++;
}
}
第二步:对于每个线程,检查其初始选择是否匹配核心 cookie。不匹配时:
1. 在该线程的运行队列中查找匹配 cookie 的任务(sched_core_find())
2. 找不到则选择 idle 任务(force idle)
force idle(强制空闲)是 Core Scheduling 的关键机制:当一个硬件线程上没有匹配 cookie 的任务时,该线程被强制空闲,即使有其他任务可以运行。这牺牲了吞吐量来换取安全性。
兄弟 CPU 重调度通知:
// kernel/sched/core.c:6198-6239
for_each_cpu(i, smt_mask) {
rq_i = cpu_rq(i);
if (!rq_i->core_pick)
continue;
// vruntime 更新...
if (i == cpu) {
rq_i->core_pick = NULL;
continue;
}
if (rq_i->curr == rq_i->core_pick) {
rq_i->core_pick = NULL;
continue;
}
resched_curr(rq_i);
}
最后,对于不是当前 CPU 的兄弟线程,如果其当前运行的任务与 core_pick 不同,调用 resched_curr() 发送 IPI 通知其重新调度。
5.4 Proxy Execution 路径
// kernel/sched/core.c:6837-6845
rq_set_donor(rq, next);
rq->next_class = next->sched_class;
if (unlikely(task_is_blocked(next))) {
next = find_proxy_task(rq, next, &rf);
if (!next)
goto pick_again;
if (next == rq->idle)
goto keep_resched;
}
Proxy Execution 允许一个因 mutex 阻塞的任务仍然留在运行队列上,其 mutex 持有者可以作为 proxy 被选择执行。这减少了优先级继承的开销和上下文切换次数。
如果 pick_next_task() 选出的任务实际上是被阻塞的(task_is_blocked(next)),则通过 find_proxy_task() 找到其 proxy(通常是 mutex 的持有者)。找不到时重新选择(goto pick_again),如果找到的是 idle 则保持重调度标志(goto keep_resched)。
5.5 picked 标签后的处理
// kernel/sched/core.c:6847-6850
picked:
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
keep_resched:
rq->last_seen_need_resched_ns = 0;
无论是否发生实际切换,都清除 prev 的 TIF_NEED_RESCHED 标志和 preempt count 中的 PREEMPT_NEED_RESCHED 位。last_seen_need_resched_ns 用于调度延迟统计,重置为 0。
6. context_switch() 详解
6.1 是否需要切换
// kernel/sched/core.c:6852-6897
is_switch = prev != next;
if (likely(is_switch)) {
rq->nr_switches++;
RCU_INIT_POINTER(rq->curr, next);
++*switch_count;
psi_account_irqtime(rq, prev, next);
psi_sched_switch(prev, next, !task_on_rq_queued(prev) ||
prev->se.sched_delayed);
trace_sched_switch(preempt, prev, next, prev_state);
/* Also unlocks the rq: */
rq = context_switch(rq, prev, next, &rf);
} else {
rq_unpin_lock(rq, &rf);
__balance_callbacks(rq, NULL);
raw_spin_rq_unlock_irq(rq);
}
当 prev == next 时(无实际切换),只需释放运行队列锁、执行负载均衡回调和恢复中断。当 prev != next 时,进入 context_switch()。
RCU_INIT_POINTER(rq->curr, next) 以 RCU 安全的方式更新运行队列的当前任务指针。++*switch_count 递增相应的上下文切换计数器。psi_sched_switch() 更新 PSI(Pressure Stall Information)统计。
6.2 context_switch() 函数签名
// kernel/sched/core.c:5239-5243
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next, struct rq_flags *rf)
__releases(__rq_lockp(rq))
此函数标记为 __always_inline(强制内联),__releases 标注表示函数释放运行队列锁。返回值是当前 CPU 的运行队列(因为 prev 可能已被迁移到其他 CPU,需要在 finish_task_switch() 中重新获取)。
6.3 prepare_task_switch() -- 切换前准备
// kernel/sched/core.c:5080-5092
static inline void
prepare_task_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
kcov_prepare_switch(prev); // 代码覆盖率收集准备
sched_info_switch(rq, prev, next); // 调度信息统计
perf_event_task_sched_out(prev, next); // 性能监控事件切出
fire_sched_out_preempt_notifiers(prev, next); // 抢占通知回调
kmap_local_sched_out(); // 临时内核映射切出
prepare_task(next); // 设置 next->on_cpu = true
prepare_arch_switch(next); // 架构相关准备(多数架构为空)
}
各项准备工作的作用:
- kcov_prepare_switch:保存 kcov(代码覆盖率工具)的上下文,防止覆盖率数据跨任务混淆
- sched_info_switch:更新
/proc/<pid>/schedstat中的调度统计(运行时间、等待时间、切换次数) - perf_event_task_sched_out:将 prev 的性能计数器事件(如 CPU 周期、缓存 miss)迁移到 per-CPU 上下文,并切换到 next 的事件
- fire_sched_out_preempt_notifiers:调用注册的抢占通知器,允许子系统(如 KVM)在任务被切换出去前执行清理
- kmap_local_sched_out:保存 prev 通过
kmap_local_page()建立的临时内核映射,这些映射是 per-task 的 - prepare_task:设置
next->on_cpu = true,标志 next 即将在当前 CPU 上运行
6.4 arch_start_context_switch()
// kernel/sched/core.c:5251
arch_start_context_switch(prev);
体系结构相关的上下文切换开始钩子。在 x86 上用于半虚拟化(paravirt),与 switch_to() 中的配对操作结合,将页表重载和上下文切换合并为一个 hypercall 以优化虚拟化环境。
6.5 地址空间切换
这是 context_switch() 的核心部分之一(kernel/sched/core.c:5260-5286)。地址空间切换根据 prev 和 next 是否拥有用户地址空间分为四种情况:
情况一:kernel -> kernel(next 无 mm)
// kernel/sched/core.c:5260-5267
if (!next->mm) { // to kernel
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm) // from user
mmgrab_lazy_tlb(prev->active_mm);
else
prev->active_mm = NULL;
}
当切换到内核线程时,内核线程没有自己的用户地址空间(next->mm == NULL)。它借用 prev 的 active_mm(即 "lazy TLB" 策略):
enter_lazy_tlb()通知体系结构代码进入 lazy TLB 模式。在大多数架构上这是空操作,但在某些架构(如 ARM)中会切换到 init_mm 以避免在内核线程执行期间 prev 的地址空间被修改时收到不必要的 TLB 刷新 IPInext->active_mm = prev->active_mm转移地址空间引用- 如果 prev 是用户进程(
prev->mm != NULL),通过mmgrab_lazy_tlb()增加 mm 的引用计数。这是因为 prev 可能会在被切换出去后 exit 并释放其 mm,而内核线程仍在借用这个 mm - 如果 prev 也是内核线程,只需简单转移(
prev->active_mm = NULL),无需额外引用计数
情况二:kernel/user -> user(next 有 mm)
// kernel/sched/core.c:5268-5286
} else { // to user
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);
if (!prev->mm) { // from kernel
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
当切换到用户进程时,必须执行完整的地址空间切换:
- membarrier_switch_mm:处理
membarrier系统调用的状态更新。如果 prev 的 active_mm 有待处理的 membarrier 请求,此处确保在切换前完成 - switch_mm_irqs_off:执行实际的页表切换。在 x86_64 上,这涉及加载 CR3 寄存器(页表基址)、刷新 TLB(如果需要)、加载 LDT 等。由于调用时中断已禁用,使用
_irqs_off变体避免不必要的中断操作 - lru_gen_use_mm:在 MGLRU(Multi-Gen LRU)中标记 next 的 mm 为"正在使用",影响页面回收决策
如果从内核线程切换到用户进程(!prev->mm),prev 借用的 active_mm 需要延迟释放。将 prev->active_mm 保存到 rq->prev_mm,在 finish_task_switch() 中调用 mmdrop_lazy_tlb_sched() 释放。延迟释放是为了避免在持有运行队列锁时执行可能睡眠的操作。
6.6 mm_cid_switch_to()
// kernel/sched/core.c:5288
mm_cid_switch_to(prev, next);
per-mm CID(Context ID)切换。CID 是一个 per-mm 的紧凑标识符,用于优化某些 per-mm 的操作(如 IPI 发送和 RCU 操作)。在上下文切换时更新当前 CPU 的 CID 到 next 的 mm 的 CID 值。
6.7 rseq_sched_switch_event()
// kernel/sched/core.c:5294
rseq_sched_switch_event(next);
通知 restartable sequences(rseq)子系统发生了调度切换。rseq 是一种用户空间优化机制,允许用户空间代码定义在特定位置被中断后需要重启的临界区。调度切换是这些临界区可能被中断的时机之一,rseq 需要知道以便在用户空间恢复执行时设置重启标志。
注释(kernel/sched/core.c:5290-5293)说明:必须在 mm_cid_switch_to() 之后调用,以确保 TIF 标志正确设置。
6.8 prepare_lock_switch()
// kernel/sched/core.c:5296
prepare_lock_switch(rq, next, rf);
为锁切换做准备。在非 PREEMPT_RT 内核中,这通常设置 next->on_cpu = true 的最终确认。在 PREEMPT_RT 内核中,可能涉及 rt_mutex 相关的状态转移。此函数确保在 switch_to() 之后,finish_task_switch() 能正确释放运行队列锁。
6.9 switch_to() -- 寄存器/栈切换
// kernel/sched/core.c:5299-5300
switch_to(prev, next, prev);
barrier();
switch_to() 是整个上下文切换中最关键的宏。在 x86_64 上展开为(arch/x86/include/asm/switch_to.h:49):
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)
__switch_to_asm() 是汇编编写的函数,执行以下操作:
1. 保存 callee-saved 寄存器(rbx, rbp, r12-r15)到 prev 的内核栈
2. 保存当前栈指针到 prev->thread.sp
3. 加载 next->thread.sp 到栈指针寄存器
4. 恢复 next 的内核栈上的 callee-saved 寄存器
5. 跳转到 __switch_to() C 函数完成体系结构相关的切换
注意 last 参数:switch_to 宏将 __switch_to_asm() 的返回值写入 last。由于栈已经切换,last 实际上是在 next 的栈上写入的,但存储的是 prev 的指针。这使得 next 在恢复执行后能知道它替换了谁(在 finish_task_switch(prev) 中使用)。
barrier() 是编译器屏障,防止编译器将 switch_to() 前后的代码重排。因为 switch_to() 之后,我们已经在 next 的上下文中执行了,编译器必须丢弃之前所有的寄存器假设。
6.10 __switch_to() -- x86_64 架构切换
__switch_to()(arch/x86/kernel/process_64.c:610)在栈切换后执行剩余的体系结构相关工作:
// arch/x86/kernel/process_64.c:610-707
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread;
struct thread_struct *next = &next_p->thread;
int cpu = smp_processor_id();
主要步骤:
-
FPU 切换:
switch_fpu(prev_p, cpu)-- 保存 prev 的浮点/SSE/AVX 状态,准备加载 next 的状态(实际加载延迟到 next 首次使用 FPU 时,即 "lazy FPU" 策略或 eager 切换取决于配置) -
FS/GS 段保存:
save_fsgs(prev_p)-- 保存 prev 的 FS/GS base 地址 -
TLS 加载:
load_TLS(next, cpu)-- 将 next 的 TLS(Thread Local Storage)描述符加载到 GDT(Global Descriptor Table)中 -
段寄存器切换: -
savesegment(es, prev->es)/loadsegment(es, next->es)-- ES 和 DS 段寄存器 -x86_fsgsbase_load(prev, next)-- FS/GS base 地址加载(使用 FSGSBASE 指令如果可用,否则通过 MSR) -
PKRU 切换:
x86_pkru_load(prev, next)-- 加载 next 的 Protection Keys for Userspace 寄存器 -
per-CPU current 指针更新:
c raw_cpu_write(current_task, next_p); raw_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next_p));更新 per-CPU 的 current 指针和栈顶指针。使用raw_cpu_write避免不必要的屏障(因为运行在当前 CPU 上,已经持有 rq 锁)。 -
栈指针更新:
update_task_stack(next_p)-- 更新 TSS(Task State Segment)中的 sp0(内核栈入口点),确保中断/异常发生时使用正确的内核栈 -
其他状态:
switch_to_extra(prev_p, next_p)-- 切换调试寄存器、TS(Task Switched)标志等
7. finish_task_switch() 清理
context_switch() 的返回语句直接调用 finish_task_switch():
// kernel/sched/core.c:5302
return finish_task_switch(prev);
此时,代码已经在 next 的上下文中执行(栈和寄存器已切换),但 prev 指针仍然有效(作为参数保存在 next 的栈帧中)。
7.1 preempt_count 完整性检查
// kernel/sched/core.c:5131-5134
if (WARN_ONCE(preempt_count() != 2*PREEMPT_DISABLE_OFFSET,
"corrupted preempt_count: %s/%d/0x%x\n",
current->comm, current->pid, preempt_count()))
preempt_count_set(FORK_PREEMPT_COUNT);
finish_task_switch() 期望 preempt_count 为 2 * PREEMPT_DISABLE_OFFSET。注释(kernel/sched/core.c:5120-5129)解释了这个值的来源:
schedule()
preempt_disable(); // +PREEMPT_DISABLE_OFFSET (1)
__schedule()
raw_spin_lock_irq(&rq->lock) // +PREEMPT_DISABLE_OFFSET (2)
__schedule_loop() 中的 preempt_disable() 增加一次,rq_lock() 获取自旋锁时再增加一次。如果 preempt_count 不等于此值,说明存在内核 bug(如调度过程中抢占计数被破坏),输出警告并重置为 FORK_PREEMPT_COUNT(新任务的初始值)。
7.2 保存 prev_mm 和 prev_state
// kernel/sched/core.c:5136-5149
rq->prev_mm = NULL;
prev_state = READ_ONCE(prev->__state);
清除 rq->prev_mm(在 context_switch() 中设置,这里取出到局部变量 mm 后清空),重新读取 prev 的状态(因为从 context_switch() 调用到这里,prev 的状态可能已经被其他 CPU 上的操作改变)。
7.3 时间记账和性能监控
// kernel/sched/core.c:5150-5152
vtime_task_switch(prev);
perf_event_task_sched_in(prev, current);
finish_task(prev);
- vtime_task_switch:切换虚拟 CPU 时间记账上下文。将 prev 的 vtime(用于 cgroup 和任务级别的 CPU 时间统计)停止,开始 next 的 vtime
- perf_event_task_sched_in:加载 next 的性能监控事件,开始收集 next 的性能数据
- finish_task(prev):通过
smp_store_release(&prev->on_cpu, 0)清除 prev 的on_cpu标志。这是try_to_wake_up()中smp_cond_load_acquire(&p->on_cpu, !VAL)等待的释放操作
7.4 tick_nohz 和锁释放
// kernel/sched/core.c:5153-5155
tick_nohz_task_switch();
finish_lock_switch(rq);
finish_arch_post_lock_switch();
- tick_nohz_task_switch:在 tickless 模式下处理任务切换相关的定时器管理
- finish_lock_switch:释放
rq->lock(raw_spin_unlock_irq(&rq->lock)),同时恢复中断。这是context_switch()执行期间中断一直被禁用的终止点 - finish_arch_post_lock_switch:体系结构相关的锁释放后处理
7.5 kcov 和 kmap 恢复
// kernel/sched/core.c:5156-5164
kcov_finish_switch(current);
kmap_local_sched_in();
恢复 next 的代码覆盖率收集和临时内核映射。注意 kmap_local_sched_in() 不需要中断禁用,因此放在锁释放之后执行。
7.6 抢占通知器
// kernel/sched/core.c:5166
fire_sched_in_preempt_notifiers(current);
调用注册的抢占通知器的 "sched_in" 回调,通知子系统 next 已经开始在当前 CPU 上运行。KVM 利用此机制来处理虚拟机的抢占状态。
7.7 mm 延迟释放
// kernel/sched/core.c:5179-5182
if (mm) {
membarrier_mm_sync_core_before_usermode(mm);
mmdrop_lazy_tlb_sched(mm);
}
这里处理 context_switch() 中从内核线程切换到用户进程时保存的 rq->prev_mm。注释(kernel/sched/core.c:5168-5177)解释了为什么需要 membarrier_mm_sync_core_before_usermode():
当通过内核线程过渡切换(user -> kernel -> user)时,中间的内核线程没有调用 switch_mm(),因此可能错过 membarrier 的 IPI。这里的 full memory barrier(由 mmdrop_lazy_tlb_sched() 隐式提供)和 sync_core 确保了 membarrier 的正确性。
7.8 TASK_DEAD 处理
// kernel/sched/core.c:5184-5200
if (unlikely(prev_state == TASK_DEAD)) {
if (prev->sched_class->task_dead)
prev->sched_class->task_dead(prev);
sched_ext_dead(prev);
cgroup_task_dead(prev);
put_task_stack(prev);
put_task_struct_rcu_user(prev);
}
当 prev 已经死亡(do_task_dead() 设置的 TASK_DEAD 状态),执行最终的资源释放:
- task_dead:调度类的死亡回调,用于清理调度类内部的资源(如 DL 调度类的带宽回收)
- sched_ext_dead:通知 BPF 调度器扩展该任务已死亡。必须在
cgroup_task_dead()之前调用,防止 cgroup 在 SCX 调度器仍可见该任务时被移除 - cgroup_task_dead:从 cgroup 中移除该任务
- put_task_stack:释放任务的内核栈
- put_task_struct_rcu_user:通过 RCU 延迟释放 task_struct 本身。使用 RCU 是因为其他 CPU 可能仍持有对该 task_struct 的引用(如
/proc文件系统的读取操作)
8. schedule_tail() -- fork 后的首次调度
8.1 函数概述
// kernel/sched/core.c:5209-5234
asmlinkage __visible void schedule_tail(struct task_struct *prev)
__releases(__rq_lockp(this_rq()))
{
finish_task_switch(prev);
trace_sched_exit_tp(true);
preempt_enable();
if (current->set_child_tid)
put_user(task_pid_vnr(current), current->set_child_tid);
calculate_sigpending();
}
schedule_tail() 是新创建的子进程在内核中执行的第一个函数之一。在 fork/clone 系统调用中,子进程的初始栈被设置为从 ret_from_fork_asm() 开始执行,后者调用 ret_from_fork() 并最终到达 schedule_tail()。
8.2 与普通 __schedule() 的区别
新任务从不通过 __schedule() 的正常路径进入调度。相反:
- 父进程在
copy_process()中创建子进程的 task_struct 和内核栈 - 子进程的栈被初始化为包含
ret_from_fork_asm的返回地址 - 当调度器选择子进程时,
switch_to()切换到子进程的栈,子进程从ret_from_fork_asm开始执行 - 子进程的
preempt_count为FORK_PREEMPT_COUNT(等于2 * PREEMPT_DISABLE_OFFSET),与finish_task_switch()的期望值匹配
8.3 finish_task_switch 的特殊处理
子进程调用 finish_task_switch(prev) 释放运行队列锁。preempt_count 检查会通过(因为 FORK_PREEMPT_COUNT == 2 * PREEMPT_DISABLE_OFFSET)。随后的 preempt_enable() 将 preempt_count 减至 0,启用抢占。
8.4 set_child_tid
if (current->set_child_tid)
put_user(task_pid_vnr(current), current->set_child_tid);
如果 clone 时设置了 CLONE_CHILD_SETTID 标志,将子进程的 PID 写入用户空间指定地址。这是 clone() 系统调用规范的一部分。
8.5 calculate_sigpending()
重新计算当前任务的挂起信号状态。新任务可能从父进程继承了某些信号处理状态,需要确保信号掩码正确。
9. 上下文切换的内存屏障保证
9.1 核心需求
membarrier 系统调用要求:在更新 rq->curr 之后、返回用户空间之前,必须有一个全内存屏障。此屏障匹配 membarrier 系统调用入口处的全屏障。
源码注释(kernel/sched/core.c:6860-6881)详细列出了各架构提供此屏障的方式:
9.2 x86 上的屏障保证
在 x86 上,以下操作提供 full barrier:
- 有 mm 切换时:
switch_mm()中的 CR3 加载是一个 serializing 操作,隐式提供 full barrier - 无 mm 切换时(通过内核线程过渡):
mmdrop_lazy_tlb_sched()中的原子操作(atomic_dec_and_test())在 x86 上提供 full barrier(因为使用了lock前缀指令)
9.3 弱序架构上的屏障保证
在弱序架构(如 ARM64)上:
- spin_unlock 是 RELEASE 语义:不足以提供 full barrier
- ARM64 的 switch_to() 包含显式的 full barrier
- 或者 finish_lock_switch() 在某些弱序架构上使用具有 full barrier 语义的自旋锁释放操作
9.4 RISC-V 上的特殊处理
在 RISC-V 上,membarrier_arch_switch_mm() 提供额外的屏障:
// context_switch() 中的 membarrier_switch_mm() 调用
membarrier_switch_mm(rq, prev->active_mm, next->mm);
RISC-V 的 switch_mm() 中调用 membarrier_arch_switch_mm() 来满足 SYNC_CORE 命令的需求(在进程间切换时同步核心流水线)。
9.5 __schedule() 中的屏障链
综合来看,__schedule() 中与内存屏障相关的关键操作形成如下链条:
rq_lock(rq, &rf) // ACQUIRE 语义
smp_mb__after_spinlock() // FULL 屏障
// ... 调度决策 ...
RCU_INIT_POINTER(rq->curr, next) // 更新 curr
// ... context_switch() ...
switch_mm_irqs_off() // FULL 屏障 (x86, s390, sparc, PPC, RISC-V)
// 或
switch_to() // FULL 屏障 (arm64)
// 或
finish_lock_switch() // FULL 屏障 (某些弱序架构的 spin_unlock)
// 或
mmdrop_lazy_tlb_sched() // FULL 屏障 (x86)
这些屏障确保了 membarrier 的正确性:用户空间通过 membarrier() 系统调用发出的内存屏障请求,通过 IPI 和上述屏障链的组合,保证在所有 CPU 上都能观察到此前所有的内存操作。
9.6 finish_task() 的 smp_store_release
// kernel/sched/core.c:5152 (finish_task)
smp_store_release(&prev->on_cpu, 0);
finish_task() 使用 smp_store_release 清除 on_cpu。这个 release 操作与 try_to_wake_up() 中的 smp_cond_load_acquire(&p->on_cpu, !VAL)(kernel/sched/core.c:4228)配对,确保 finish_task_switch() 中对 prev 的所有清理操作在 on_cpu 被观察到为 0 之前完成。这保证了 try_to_wake_up() 在 prev 完全退出调度后才将其唤醒。
9.7 signal_wake_up / wake_up_state 竞争的完整解决方案
回到 __schedule() 的入口处(kernel/sched/core.c:6781-6796),竞争的完整解决方案涉及以下屏障:
- 任务 A:
__set_current_state(TASK_INTERRUPTIBLE)通过smp_store_mb()写入状态并执行 full barrier - 任务 A:
schedule()->__schedule()->rq_lock()+smp_mb__after_spinlock()提供第二个 full barrier - 任务 B(CPU x):
signal_wake_up()->set_tsk_thread_flag()设置 TIF_SIGPENDING ->wake_up_state()-> 获取pi_lock+smp_mb__after_spinlock()
两个 smp_mb__after_spinlock() 的配对确保:
- 如果 signal_wake_up() 先执行,__schedule() 的屏障保证能看到信号标志
- 如果 __schedule() 先获取 rq->lock,signal_wake_up() 的屏障保证能看到新的状态值
这形成了一个经典的 lock-based 同步模式,两个锁(rq->lock 和 pi_lock)通过各自锁后的全屏障保证全局有序性。
10. __schedule() 执行流程总结
将以上分析综合,__schedule() 的完整执行流程如下:
__schedule(sched_mode)
|
+-- 1. 确定 cpu, rq, prev
| cpu = smp_processor_id()
| rq = cpu_rq(cpu)
| prev = rq->curr
|
+-- 2. 调度前准备
| schedule_debug()
| hrtick_clear()
| klp_sched_try_switch()
| local_irq_disable() <-- 中断禁用开始
| rcu_note_context_switch()
| migrate_disable_switch()
|
+-- 3. 获取锁和屏障
| rq_lock() <-- 获取 rq->lock
| smp_mb__after_spinlock() <-- 关键全屏障
| update_rq_clock()
|
+-- 4. 处理 prev 状态
| prev_state = READ_ONCE(prev->__state)
| [SM_IDLE]: nr_running==0 -> idle 快速路径
| [!preempt && prev_state]: try_to_block_task()
|
+-- 5. 选择 next 任务
| pick_next_task()
| [快速路径]: fair only -> pick_next_task_fair()
| [慢速路径]: for_each_active_class
| [Core Scheduling]: SMT cookie 匹配
| [Proxy Execution]: find_proxy_task()
|
+-- 6. 执行上下文切换 (prev != next)
| rq->nr_switches++
| RCU_INIT_POINTER(rq->curr, next)
| ++*switch_count
| psi_sched_switch()
| context_switch()
| |-- prepare_task_switch()
| |-- arch_start_context_switch()
| |-- [地址空间切换]
| | switch_mm_irqs_off() 或 enter_lazy_tlb()
| |-- mm_cid_switch_to()
| |-- rseq_sched_switch_event()
| |-- prepare_lock_switch()
| |-- switch_to() <-- 寄存器/栈切换
| | __switch_to_asm() <-- 保存/恢复 callee-saved 寄存器
| | __switch_to() <-- x86: FPU, TLS, 段寄存器, PKRU
| |-- finish_task_switch()
| preempt_count 检查
| vtime_task_switch()
| perf_event_task_sched_in()
| finish_task() <-- smp_store_release(on_cpu, 0)
| finish_lock_switch() <-- 释放 rq->lock + 恢复中断
| [TASK_DEAD]: 最终资源释放
|
+-- 7. 不需要切换 (prev == next)
rq_unpin_lock()
__balance_callbacks()
raw_spin_rq_unlock_irq() <-- 释放 rq->lock + 恢复中断
整个过程中,从 local_irq_disable()(步骤 2)到 finish_lock_switch() 或 raw_spin_rq_unlock_irq()(步骤 6/7),中断始终禁用。这段时间是上下文切换的核心延迟,通常在几微秒量级,直接贡献了调度延迟(scheduling latency)的主要部分。
13.3 架构相关切换 -- switch_to 宏
上下文切换的核心在于保存旧任务的执行状态、恢复新任务的执行状态。在 Linux 内核中,这个过程由 switch_to 宏完成,其底层实现因处理器架构而异。本节将深入分析 x86_64、ARM64 和 RISC-V 三种架构下 switch_to 的具体实现,剖析寄存器保存与恢复、栈切换、地址空间切换等关键机制。
13.3.1 switch_to 的分层设计
Linux 内核将上下文切换划分为两个层次:汇编层的栈/寄存器切换和 C 语言层的系统寄存器切换。以 x86_64 为例,调用链如下:
context_switch() // kernel/sched/core.c:5239
-> switch_to(prev, next, prev) // 展开为架构相关宏
-> __switch_to_asm(prev, next) // arch/x86/entry/entry_64.S:177 (汇编)
-> __switch_to(prev, next) // arch/x86/kernel/process_64.c:610 (C函数)
context_switch() 函数定义在 kernel/sched/core.c:5239-5303,其完整流程为:
// kernel/sched/core.c:5239
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);
arch_start_context_switch(prev);
// 地址空间切换 (mm)
if (!next->mm) { // 切换到内核线程
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
if (prev->mm)
mmgrab_lazy_tlb(prev->active_mm);
else
prev->active_mm = NULL;
} else { // 切换到用户进程
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);
if (!prev->mm) {
rq->prev_mm = prev->active_mm;
prev->active_mm = NULL;
}
}
mm_cid_switch_to(prev, next);
rseq_sched_switch_event(next);
prepare_lock_switch(rq, next, rf);
// 寄存器和栈切换
switch_to(prev, next, prev); // 关键:此处栈已切换
barrier();
return finish_task_switch(prev);
}
注意 context_switch() 在第 5299 行调用 switch_to() 之后,代码实际上运行在新任务的栈上。barrier() 编译器屏障确保编译器不会将 finish_task_switch() 的调用优化到 switch_to() 之前。
为什么必须分两层
栈切换必须在汇编中完成。C 编译器生成的函数依赖于栈帧:局部变量、函数参数、返回地址都存储在栈上。如果在一个 C 函数内部切换栈指针,编译器生成的所有栈引用将指向错误的内存位置。因此,内核使用一段精心编写的汇编代码来完成栈指针的保存与恢复。
具体来说,汇编层负责:
- 保存 callee-saved 寄存器到旧任务的栈上(调用约定规定这些寄存器由被调用者保存)
- 将当前栈指针保存到
prev->thread.sp - 从
next->thread.sp加载新栈指针 - 从新栈上恢复 callee-saved 寄存器
- 跳转到 C 函数完成其余的系统寄存器切换
C 函数层负责:
- FPU/SIMD 状态切换
- 段寄存器切换(x86 特有)
- TLS 描述符加载
- 调试寄存器切换
- 安全特性相关的 MSR 切换
- per-CPU current 指针更新
13.3.2 x86_64 __switch_to_asm 逐行分析
x86_64 的汇编层上下文切换实现在 arch/x86/entry/entry_64.S:177-217。这段代码遵循 System V AMD64 ABI 调用约定,%rdi 传递 prev 指针,%rsi 传递 next 指针。
callee-saved 寄存器保存
// arch/x86/entry/entry_64.S:177-188
SYM_FUNC_START(__switch_to_asm)
ANNOTATE_NOENDBR
/*
* Save callee-saved registers
* This must match the order in inactive_task_frame
*/
pushq %rbp // 帧指针
pushq %rbx // 通用寄存器
pushq %r12 // callee-saved
pushq %r13
pushq %r14
pushq %r15
x86_64 ABI 规定 callee-saved 寄存器包括 rbp、rbx 和 r12-r15,共 6 个。这些寄存器被压入当前任务的内核栈。注释强调压栈顺序必须与 inactive_task_frame 结构体匹配。inactive_task_frame 定义在 arch/x86/include/asm/switch_to.h 中,用于新创建进程的初始栈帧布局。
为什么不需要保存 rsp?因为 rsp 在下一步通过直接存储到 task_struct 来保存,而不是通过压栈。
栈指针切换
// arch/x86/entry/entry_64.S:190-192
/* switch stack */
movq %rsp, TASK_threadsp(%rdi) // prev->thread.sp = 当前栈指针
movq TASK_threadsp(%rsi), %rsp // 从 next->thread.sp 恢复栈
这是上下文切换的最关键步骤。TASK_threadsp 是 task_struct 结构体中 thread.sp 字段的偏移量。第一条指令将当前栈指针保存到 prev 任务的 thread.sp 字段;第二条指令从 next 任务的 thread.sp 字段加载新的栈指针。
从这一刻起,当前 CPU 的栈已经切换到 next 任务的内核栈。所有后续的 pop 操作将从新栈上恢复寄存器。
Stack Canary 更新
// arch/x86/entry/entry_64.S:194-197
#ifdef CONFIG_STACKPROTECTOR
movq TASK_stack_canary(%rsi), %rbx
movq %rbx, PER_CPU_VAR(__stack_chk_guard)
#endif
当启用栈保护 (CONFIG_STACKPROTECTOR) 时,内核在每个任务的栈底放置一个随机 canary 值。上下文切换时必须更新 per-CPU 的 __stack_chk_guard 为 next 任务的 canary 值。注意这里使用 %rbx 作为临时寄存器,因为此时 callee-saved 寄存器已经保存,%rbx 的值不再需要。
RSB 填充 -- Spectre v2 缓解
// arch/x86/entry/entry_64.S:199-206
/*
* When switching from a shallower to a deeper call stack
* the RSB may either underflow or use entries populated
* with userspace addresses. On CPUs where those concerns
* exist, overwrite the RSB with entries which capture
* speculative execution to prevent attack.
*/
FILL_RETURN_BUFFER %r12, RSB_CLEAR_LOOPS, X86_FEATURE_RSB_CTXSW
Return Stack Buffer (RSB) 是 CPU 分支预测硬件的一部分,用于预测 ret 指令的返回地址。Spectre v2 攻击可以利用 RSB 中的残留条目将推测执行引导到攻击者选择的地址。FILL_RETURN_BUFFER 宏通过执行一系列 call/ret 指令对来覆盖 RSB 中所有旧条目,使用 %r12 作为临时寄存器,循环 RSB_CLEAR_LOOPS 次。此操作仅在支持 X86_FEATURE_RSB_CTXSW 的 CPU 上执行。
callee-saved 恢复与跳转
// arch/x86/entry/entry_64.S:208-216
/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to // 跳转到 C 函数
SYM_FUNC_END(__switch_to_asm)
恢复顺序与保存顺序相反(LIFO)。注意使用 jmp 而不是 call 跳转到 __switch_to C 函数。这是因为 __switch_to 的 ret 指令将直接返回到 context_switch() 中调用 switch_to 的位置 -- 但此时的返回地址位于新任务的栈上,属于新任务之前被换出时压入的地址。
__switch_to C 函数的返回值(prev 指针)通过 %rax 传递,这与 ABI 规定一致。调用 switch_to(prev, next, prev) 的第三个参数 prev 实际上在 __switch_to 返回后通过 %rax 获取,这就是为什么宏定义中有 last = __switch_to(prev, next) 的模式。
13.3.3 x86_64 __switch_to C 函数
汇编层完成栈切换后跳转到 C 函数 __switch_to(),定义在 arch/x86/kernel/process_64.c:610-714。该函数负责所有非通用寄存器的架构状态切换。
// arch/x86/kernel/process_64.c:610
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread;
struct thread_struct *next = &next_p->thread;
int cpu = smp_processor_id();
__notrace_funcgraph 属性禁止 function graph tracer 追踪此函数,因为 function graph tracer 依赖当前任务的栈,而此时栈刚切换完毕,追踪基础设施尚未准备好。
FPU/SSE/AVX 状态管理
// arch/x86/kernel/process_64.c:619
switch_fpu(prev_p, cpu);
switch_fpu() 管理 x87 FPU、SSE、AVX、AVX-512 等扩展寄存器状态。现代 x86 CPU 拥有大量 SIMD 寄存器(AVX-512 有 32 个 512 位 ZMM 寄存器),保存/恢复开销极大。内核使用"延迟 FPU 切换"(lazy FPU switch) 策略:切换时不立即保存 prev 的 FPU 状态,而是设置 TS (Task Switched) 标志,当新任务首次使用 FPU 指令时触发 #NM 异常,在异常处理程序中完成状态切换。
从 Linux 5.0 开始,内核默认使用 eager FPU 切换,即总是立即保存/恢复 FPU 状态,因为现代 CPU 的 xrstors/xsaves 指令已经足够快。具体策略取决于 USE_EAGER_FPU 和 CPU 特性。
FS/GS 段寄存器保存
// arch/x86/kernel/process_64.c:626
save_fsgs(prev_p);
save_fsgs() 定义在 arch/x86/kernel/process_64.c:276-292:
static __always_inline void save_fsgs(struct task_struct *task)
{
savesegment(fs, task->thread.fsindex);
savesegment(gs, task->thread.gsindex);
if (static_cpu_has(X86_FEATURE_FSGSBASE)) {
task->thread.fsbase = rdfsbase();
task->thread.gsbase = __rdgsbase_inactive();
} else {
save_base_legacy(task, task->thread.fsindex, FS);
save_base_legacy(task, task->thread.gsindex, GS);
}
}
FS 和 GS 段寄存器在 x86_64 上有两个组成部分:选择子 (selector) 和基址 (base)。选择子是 GDT/LDT 中的索引,基址是段的线性地址。现代 Linux 使用 FSGSBASE 指令集(rdfsbase/wdfsbase/rdgsbase/wrgsbase)直接读写基址,避免通过 GDT 间接操作的昂贵开销。
FS 寄存器用于线程局部存储 (TLS),GS 寄存器在内核中指向 per-CPU 数据区。必须在 load_TLS() 之前保存,因为某些虚拟化后端(如 Xen)的 load_tls() 可能清除 FS/GS 选择子。
TLS 加载
// arch/x86/kernel/process_64.c:632
load_TLS(next, cpu);
load_TLS() 将 next 任务的 Thread Local Storage 描述符加载到 GDT (Global Descriptor Table) 的条目 6-8 中(对应 TLS 槽位)。TLS 描述符存储在 thread_struct->tls_array 中,每个条目是一个 8 字节的段描述符。GDT 是 per-CPU 的,因此切换任务时必须更新当前 CPU 的 GDT 条目。
半虚拟化上下文切换结束
// arch/x86/kernel/process_64.c:639
arch_end_context_switch(next_p);
在半虚拟化 (paravirtualized) 环境(如 Xen)中,arch_end_context_switch() 刷新所有待处理的 hypercall,确保在加载可能引用新 GDT 条目的段寄存器之前,GDT 更改已经生效。在裸金属系统上,此函数为空操作。
DS/ES 段寄存器切换
// arch/x86/kernel/process_64.c:655-661
savesegment(es, prev->es);
if (unlikely(next->es | prev->es))
loadsegment(es, next->es);
savesegment(ds, prev->ds);
if (unlikely(next->ds | prev->es))
loadsegment(ds, next->ds);
DS 和 ES 段寄存器在 64 位模式下通常为 0(内核使用扁平内存模型),但 Wine 等兼容层可能设置非零值。loadsegment 宏会写入段选择子,这会触发从 GDT/LDT 加载完整描述符。为避免不必要的 GDT 访问,仅当新旧任务的 DS/ES 之一非零时才执行加载。
FS/GS 基址加载
// arch/x86/kernel/process_64.c:663
x86_fsgsbase_load(prev, next);
x86_fsgsbase_load() 定义在 arch/x86/kernel/process_64.c:391-410:
static __always_inline void x86_fsgsbase_load(struct thread_struct *prev,
struct thread_struct *next)
{
if (static_cpu_has(X86_FEATURE_FSGSBASE)) {
if (unlikely(prev->fsindex || next->fsindex))
loadseg(FS, next->fsindex);
if (unlikely(prev->gsindex || next->gsindex))
loadseg(GS, next->gsindex);
wrfsbase(next->fsbase);
__wrgsbase_inactive(next->gsbase);
} else {
load_seg_legacy(prev->fsindex, prev->fsbase,
next->fsindex, next->fsbase, FS);
load_seg_legacy(prev->gsindex, prev->gsbase,
next->gsindex, next->gsbase, GS);
}
}
当 CPU 支持 FSGSBASE 指令集时,使用 wrfsbase 和 __wrgsbase_inactive 直接写入基址。__wrgsbase_inactive 写入"非活动"GS 基址 -- 内核运行在内核态 GS 是活动的,__wrgsbase_inactive 写入的是用户态的 GS 基址。
PKRU 寄存器切换
// arch/x86/kernel/process_64.c:665
x86_pkru_load(prev, next);
x86_pkru_load() 定义在 arch/x86/kernel/process_64.c:374-389:
static __always_inline void x86_pkru_load(struct thread_struct *prev,
struct thread_struct *next)
{
if (!cpu_feature_enabled(X86_FEATURE_OSPKE))
return;
prev->pkru = rdpkru(); // 保存当前 PKRU 值
if (prev->pkru != next->pkru)
wrpkru(next->pkru); // 仅在值不同时写入
}
Protection Keys for Userspace (PKRU) 是 Intel 处理器的特性,允许用户态程序基于页表中的 protection key 位对内存页施加额外的读/写访问控制。PKRU 是一个 32 位 MSR,包含 16 个 key 各 2 位的访问控制位。wrpkru 指令开销较高(约 20-30 个时钟周期),因此只在值实际改变时才写入。
per-CPU current 指针更新
// arch/x86/kernel/process_64.c:670-671
raw_cpu_write(current_task, next_p);
raw_cpu_write(cpu_current_top_of_stack, task_top_of_stack(next_p));
这是两处关键的 per-CPU 数据更新。current_task 是 per-CPU 变量,存储当前运行任务的 task_struct 指针。current 宏通过读取 GS 段基址对应的 per-CPU 区域中的此变量来获取当前任务指针。cpu_current_top_of_stack 存储当前任务内核栈的顶部地址,用于中断/异常入口代码快速定位 pt_regs 结构。
raw_cpu_write 不带任何屏障或检查,是最低开销的 per-CPU 写入操作,因为此时已经禁用了抢占且在正确的 CPU 上运行。
内核栈入口更新
// arch/x86/kernel/process_64.c:674
update_task_stack(next_p);
update_task_stack() 更新当前 CPU 的 sp0(x86 TSS 中的 RSP0 字段)。当 CPU 从用户态通过 syscall/int 指令进入内核态时,硬件自动切换到 sp0 指向的内核栈。对于启用了 CONFIG_X86_5LEVEL 且使用影子栈的任务,此函数还需处理影子栈指针的更新。
switch_to_extra -- TS/IOPL/DEBUGCTLMSR/安全特性
// arch/x86/kernel/process_64.c:676
switch_to_extra(prev_p, next_p);
switch_to_extra() 定义在 arch/x86/kernel/process.h:13-39,是一个内联函数,快速检查是否需要额外的切换工作:
static inline void switch_to_extra(struct task_struct *prev,
struct task_struct *next)
{
unsigned long next_tif = read_task_thread_flags(next);
unsigned long prev_tif = read_task_thread_flags(prev);
if (IS_ENABLED(CONFIG_SMP)) {
if (!static_branch_likely(&switch_to_cond_stibp)) {
prev_tif &= ~_TIF_SPEC_IB;
next_tif &= ~_TIF_SPEC_IB;
}
}
if (unlikely(next_tif & _TIF_WORK_CTXSW_NEXT ||
prev_tif & _TIF_WORK_CTXSW_PREV))
__switch_to_xtra(prev, next);
}
只有当任务的 thread_info flags 包含特定的 CTXSW 标志时,才调用较重的 __switch_to_xtra() 函数(arch/x86/kernel/process.c:717-754)。该函数处理:
- IO 位图切换 (
switch_to_bitmap): 如果任务通过ioperm()设置了 I/O 端口访问权限,需更新 TSS 中的 I/O 位图。 - 单步调试 (
_TIF_BLOCKSTEP): 设置/清除MSR_IA32_DEBUGCTLMSR的 BTF (Branch Trap Flag) 位,启用分支单步调试。 - RDTSC 禁用 (
_TIF_NOTSC): 通过 CR4.TSD 位禁止/允许用户态读取时间戳计数器。 - CPUID 故障注入 (
_TIF_NOCPUID): 允许拦截 CPUID 指令。 - 推测执行控制 (
_TIF_SPEC_FORCE_UPDATE): 更新 IBRS (Indirect Branch Restricted Speculation)、STIBP (Single Thread Indirect Branch Predictors) 等 Spectre 缓解 MSR。
AMD SYSRET SS 属性 bug 修复
// arch/x86/kernel/process_64.c:678-703
if (static_cpu_has_bug(X86_BUG_SYSRET_SS_ATTRS)) {
unsigned short ss_sel;
savesegment(ss, ss_sel);
if (ss_sel != __KERNEL_DS)
loadsegment(ss, __KERNEL_DS);
}
AMD 处理器存在一个硬件缺陷:SYSRET 指令在返回用户态时会设置 SS 选择子,但不更新 SS 描述符的缓存副本。如果内核中 SS 被设为 0(NULL 选择子),则 SYSRET 返回后 SS 看起来是 __USER_DS 但实际上缓存的是 NULL 描述符,导致后续的栈操作触发 #SS 异常。
解决方案是在每次上下文切换时确保 SS 不为 NULL。因为 SYSCALL 指令会设置有效的 SS,只有通过中断进入内核时 SS 才可能为 NULL。由于中断和 SYSRET 不可能在同一任务中连续发生,在上下文切换时修复 SS 就足够了。
RDTSC 和 Cache Allocation
// arch/x86/kernel/process_64.c:706-711
/* Load the Intel cache allocation PQR MSR. */
resctrl_arch_sched_in(next_p);
/* Reset hw history on AMD CPUs */
if (cpu_feature_enabled(X86_FEATURE_AMD_WORKLOAD_CLASS))
wrmsrl(MSR_AMD_WORKLOAD_HRST, 0x1);
return prev_p;
resctl_arch_sched_in() 更新 Intel Cache Allocation Technology (CAT) 的 PQR_ASSOC MSR,用于控制任务的 L3 缓存分配类别。AMD Workload Class 特性重置硬件历史寄存器,用于硬件引导的频率调整。
13.3.4 ARM64 cpu_switch_to 逐行分析
ARM64 的寄存器切换实现在 arch/arm64/kernel/entry.S:823-851。与 x86_64 不同,ARM64 将所有 callee-saved 寄存器保存到 task_struct 的 cpu_context 字段中,而不是栈上。
// arch/arm64/kernel/entry.S:815-851
/*
* Register switch for AArch64. The callee-saved registers need to be saved
* and restored. On entry:
* x0 = previous task_struct (must be preserved across the switch)
* x1 = next task_struct
* Previous and next are guaranteed not to be the same.
*/
SYM_FUNC_START(cpu_switch_to)
禁用中断
// arch/arm64/kernel/entry.S:824
save_and_disable_daif x11
DAIF 是 ARM64 的中断掩码寄存器:Debug、All IRQ mask (SError)、IRQ、FIQ。save_and_disable_daif 宏将当前 DAIF 值保存到 x11,然后禁用所有这四类异步异常。这是必要的,因为上下文切换过程中 CPU 状态不一致,不能被中断打断。x11 中的值在函数末尾通过 restore_irq 恢复。
寄存器保存
// arch/arm64/kernel/entry.S:825-834
mov x10, #THREAD_CPU_CONTEXT // cpu_context 在 task_struct 中的偏移
add x8, x0, x10 // x8 = &prev->cpu_context
mov x9, sp
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 // x29=FP, x9=SP
str lr, [x8] // LR (返回地址)
ARM64 AAPCS64 调用约定规定 callee-saved 寄存器为 x19-x28、x29 (FP)、SP 和 LR (x30)。共 14 个寄存器。
THREAD_CPU_CONTEXT 是 cpu_context 字段在 task_struct 中的偏移量。cpu_context 是一个 struct cpu_context,包含上述所有 callee-saved 寄存器的保存槽位。
stp (Store Pair) 指令一次存储两个 64 位寄存器,是 ARM64 的高效存储指令。后缀 #16 表示"先存储,然后将地址增加 16 字节"(post-index 模式),这正好等于两个 64 位寄存器的大小。7 条 stp 指令 + 1 条 str 指令保存了全部 15 个值(14 个寄存器 + SP)。
注意 SP 不是通用寄存器,不能直接用于 stp 的操作数,所以先通过 mov x9, sp 将 SP 复制到 x9,再与 x29 (FP) 一起存储。
寄存器恢复
// arch/arm64/kernel/entry.S:835-842
add x8, x1, x10 // x8 = &next->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 和 SP
ldr lr, [x8] // 恢复 LR
mov sp, x9 // 切换栈指针
ldp (Load Pair) 与 stp 对称。寄存器恢复完成后,mov sp, x9 完成栈切换。注意此时 SP 的切换发生在所有寄存器恢复之后,这与 x86_64 不同 -- x86_64 在恢复寄存器之前就切换了 SP。
SP_EL0 更新 -- ARM64 的 current 指针
// arch/arm64/kernel/entry.S:844
msr sp_el0, x1 // SP_EL0 = next task_struct
ARM64 使用 SP_EL0 寄存器存储当前任务的 task_struct 指针。SP_EL0 是 EL0 (用户态) 的栈指针寄存器,但在 EL1 (内核态) 运行时,它不会被用户态代码修改(因为用户态有自己的虚拟地址空间),因此可以安全地用于存储内核数据。
ARM64 的 current 宏通过读取 sp_el0 获取当前任务指针,这比 x86_64 通过 GS 段基址 + per-CPU 偏移量的方式更直接高效。
指针认证密钥安装
// arch/arm64/kernel/entry.S:845
ptrauth_keys_install_kernel x1, x8, x9, x10
ARMv8.3 引入的 Pointer Authentication (PAC) 特性使用密钥对指针的高位进行签名。内核态有一组独立的密钥(APIAKey_EL1、APIBKey_EL1、APDAKey_EL1、APDBKey_EL1),上下文切换时需要加载新任务的密钥。ptrauth_keys_install_kernel 宏使用 4 个临时寄存器 (x1, x8, x9, x10) 从 task_struct 中加载密钥值到对应的系统寄存器。
Shadow Call Stack
// arch/arm64/kernel/entry.S:846-847
scs_save x0 // 保存 prev 的 shadow call stack 指针
scs_load_current // 加载 next 的 shadow call stack 指针
Shadow Call Stack (SCS) 是一种控制流完整性保护机制。它使用一个独立的"影子栈"来存储返回地址。函数入口将返回地址同时压入普通栈和影子栈,函数出口从影子栈弹出并与普通栈上的值比较,不匹配则说明发生了栈缓冲区溢出攻击。
scs_save 保存 prev 任务的影子栈指针到 task_struct->scs_shadow_stack。scs_load_current 从 next 任务的 task_struct 中加载影子栈指针到 per-CPU 变量 shadow_call_stack_ptr。
中断恢复与返回
// arch/arm64/kernel/entry.S:848-849
restore_irq x11 // 恢复之前保存的 DAIF 值
ret
restore_irq 恢复在函数开头通过 save_and_disable_daif 保存的 DAIF 值。ret 指令跳转到 LR 寄存器指向的地址。由于此时 LR 已经从 next->cpu_context.lr 中恢复,ret 实际上返回到 next 任务上次被换出时的调用点。
13.3.5 ARM64 __switch_to
ARM64 的 C 层切换函数 __switch_to() 定义在 arch/arm64/kernel/process.c:706-745。与 x86_64 不同,ARM64 将大多数系统寄存器的切换放在 C 函数中,在调用 cpu_switch_to() 汇编函数之前完成。
// arch/arm64/kernel/process.c:705-745
__notrace_funcgraph __sched
struct task_struct *__switch_to(struct task_struct *prev,
struct task_struct *next)
{
struct task_struct *last;
NEON/SVE/SME 状态切换
// arch/arm64/kernel/process.c:711
fpsimd_thread_switch(next);
ARM64 的 FPSIMD (Floating-Point and SIMD) 寄存器包括 32 个 128 位 V 寄存器(V0-V31)以及 FPCR/FPSR 状态寄存器。SVE (Scalable Vector Extension) 进一步扩展到最多 2048 位宽的 Z 寄存器、P (predicate) 寄存器和 FFR (First Fault Register)。SME (Streaming SVE Mode) 引入了 ZT0 等额外状态。
fpsimd_thread_switch() 管理这些状态。由于 SIMD 状态总量可达数千字节,内核使用 lazy 切换策略:切换时不立即保存/恢复,而是在任务首次使用 SIMD 指令时通过陷阱机制触发保存/恢复。
TLS 切换
// arch/arm64/kernel/process.c:712
tls_thread_switch(next);
tls_thread_switch() 定义在 arch/arm64/kernel/process.c:528-540:
static void tls_thread_switch(struct task_struct *next)
{
tls_preserve_current_state();
if (is_compat_thread(task_thread_info(next)))
write_sysreg(next->thread.uw.tp_value, tpidrro_el0);
else
write_sysreg(0, tpidrro_el0);
write_sysreg(*task_user_tls(next), tpidr_el0);
if (system_supports_tpidr2())
write_sysreg_s(next->thread.tpidr2_el0, SYS_TPIDR2_EL0);
}
ARM64 使用两个系统寄存器实现 TLS:TPIDR_EL0 (可读写) 和 TPIDRRO_EL0 (只读)。在 AArch64 模式下,TLS 基址存储在 TPIDR_EL0;在兼容 (AArch32) 模式下,只读 TLS 基址存储在 TPIDRRO_EL0。ARMv8.9 引入的 TPIDR2_EL0 提供第二个 TLS 寄存器。
硬件断点切换
// arch/arm64/kernel/process.c:713
hw_breakpoint_thread_switch(next);
ARM64 的硬件调试寄存器包括最多 6 个断点寄存器 (BKPT) 和 4 个观察点寄存器 (WPT),通过 DBGBCR_EL1/DBGBVR_EL1/DBGWCR_EL1/DBGWVR_EL1 系统寄存器访问。hw_breakpoint_thread_switch() 加载新任务的断点和观察点配置。对于内核调试器 (kgdb) 设置的断点,会在每个任务的上下文中保留。
CONTEXTIDR 切换 -- ETM 追踪
// arch/arm64/kernel/process.c:714
contextidr_thread_switch(next);
CONTEXTIDR_EL1 寄存器存储当前上下文的标识符。CoreSight ETM (Embedded Trace Macrocell) 硬件追踪单元使用此寄存器来关联指令追踪数据与具体的进程。当 CoreSight 追踪启用时,contextidr_thread_switch() 将 next 任务的 PID 写入 CONTEXTIDR_EL1。
entry_task 切换
// arch/arm64/kernel/process.c:715
entry_task_switch(next);
// arch/arm64/kernel/process.c:572-577
DEFINE_PER_CPU(struct task_struct *, __entry_task);
static void entry_task_switch(struct task_struct *next)
{
__this_cpu_write(__entry_task, next);
}
__entry_task 是 per-CPU 变量,存储从用户态进入内核态时的当前任务指针。由于 SP_EL0 在异常入口时被用于保存用户态 SP,需要另一个位置来保存 current 指针。异常入口代码从 __entry_task 恢复 current 指针到 SP_EL0。
SSBS 切换 -- Spectre v4 缓解
// arch/arm64/kernel/process.c:716
ssbs_thread_switch(next);
ssbs_thread_switch() 定义在 arch/arm64/kernel/process.c:546-563。SSBS (Speculative Store Bypass Safety) 是 ARMv8.5 引入的 Spectre v4 缓解特性。当 SSBS 位为 1 时,禁止推测性存储旁路(Speculative Store Bypass),防止 Spectre v4 攻击。在异构系统中(大小核架构不同),SSBS 位可能需要在上下文切换时强制恢复,因为某些核心可能不支持此特性。
计数器访问控制
// arch/arm64/kernel/process.c:717
cntkctl_thread_switch(prev, next);
cntkctl_thread_switch() 定义在 arch/arm64/kernel/process.c:639-647:
static void cntkctl_thread_switch(struct task_struct *prev,
struct task_struct *next)
{
if ((read_ti_thread_flags(task_thread_info(prev)) &
(_TIF_32BIT | _TIF_TSC_SIGSEGV)) !=
(read_ti_thread_flags(task_thread_info(next)) &
(_TIF_32BIT | _TIF_TSC_SIGSEGV)))
update_cntkctl_el1(next);
}
CNTKCTL_EL1 (Counter-timer Kernel Control) 控制用户态对通用定时器的访问权限。32 位兼容模式和设置了 TIF_TSC_SIGSEGV 标志的任务有不同的计数器访问权限。当新旧任务的权限设置不同时,更新 CNTKCTL_EL1。
指针认证用户态密钥
// arch/arm64/kernel/process.c:718
ptrauth_thread_switch_user(next);
切换用户态的指针认证密钥。与前面 cpu_switch_to 中安装的内核态密钥不同,用户态有独立的密钥 (APIAKey_EL1 等的 EL0 版本)。这是 AAPKEY 密钥,用于签名用户态函数指针和栈帧指针。
权限覆盖寄存器
// arch/arm64/kernel/process.c:719
permission_overlay_switch(next);
permission_overlay_switch() 定义在 arch/arm64/kernel/process.c:668。ARMv8.9 引入的 Permission Overlay Extension (POE) 允许动态覆盖页表权限位。POR_EL0 寄存器存储用户态的权限覆盖位图。上下文切换时需要保存 prev 任务的 POR_EL0 值并加载 next 任务的值。
Guarded Control Stack
// arch/arm64/kernel/process.c:720
gcs_thread_switch(next);
gcs_thread_switch() 定义在 arch/arm64/kernel/process.c:586-598。Guarded Control Stack (GCS) 是 ARMv9.4 引入的控制流完整性特性。它使用受硬件保护的影子栈来存储有效的返回地址。GCSPR_EL0 寄存器指向用户态 GCS 的当前位置。GCS 的内存页具有特殊属性,内核不能直接写入,只能通过特定指令 (GCSSTTR 等) 操作。
数据同步屏障
// arch/arm64/kernel/process.c:729
dsb(ish);
dsb(ish) 是 ARM64 的 Data Synchronization Barrier 指令,参数 ish (Inner Shareable) 表示屏障在内部共享域内生效。注释解释了三个原因:
- membarrier 系统调用要求:
membarrier()需要在所有 CPU 上发出全内存屏障。 - TLB 维护:确保任何挂起的 TLB 或缓存维护操作完成。
- 页表遍历器可见性:确保对页表的写操作对硬件页表遍历器 (page table walker) 可见(参见
emit_pte_barriers())。
MTE 标签切换
// arch/arm64/kernel/process.c:736
mte_thread_switch(next);
Memory Tagging Extension (MTE) 是 ARMv8.5 引入的内存安全特性。它为每 16 字节内存分配一个 4 位标签(tag),访问时检查指针标签与内存标签是否匹配。mte_thread_switch() 管理 TCO (Tag Check Override) 和 GCR_EL1 (Tag Control Register) 等寄存器。必须在 dsb(ish) 之后执行,因为 dsb 确保所有异步标签检查故障已记录到 TFSR*_EL1 寄存器。
SCTLR 用户态控制位切换
// arch/arm64/kernel/process.c:738-739
if (prev->thread.sctlr_user != next->thread.sctlr_user)
update_sctlr_el1(next->thread.sctlr_user);
SCTLR_EL1 (System Control Register) 包含大量系统控制位。某些位(如 CPACR_EL1 中的 FPEN 字段)影响用户态行为。sctlr_user 缓存了这些用户态相关的控制位,只在值发生变化时才执行昂贵的 MSR 写入。
实际寄存器切换
// arch/arm64/kernel/process.c:742
last = cpu_switch_to(prev, next);
return last;
}
最后调用汇编函数 cpu_switch_to() 完成寄存器和栈的切换。注意返回值 last -- 这实际上是 prev 任务的指针,因为在 cpu_switch_to 返回时,CPU 已经运行在 next 任务的上下文中,但 x0 寄存器(返回值)保存的是 prev 的指针(cpu_switch_to 保证 x0 不被修改)。
13.3.6 RISC-V __switch_to
RISC-V 的上下文切换在三种架构中最为简洁,整个切换过程在一个汇编函数中完成。实现在 arch/riscv/kernel/entry.S:421-471。
// arch/riscv/kernel/entry.S:421-471
SYM_FUNC_START(__switch_to)
/* Save context into prev->thread */
li a4, TASK_THREAD_RA
add a3, a0, a4 // a3 = &prev->thread
add a4, a1, a4 // a4 = &next->thread
callee-saved 寄存器保存
// arch/riscv/kernel/entry.S:426-439
REG_S ra, TASK_THREAD_RA_RA(a3)
REG_S sp, TASK_THREAD_SP_RA(a3)
REG_S s0, TASK_THREAD_S0_RA(a3)
REG_S s1, TASK_THREAD_S1_RA(a3)
REG_S s2, TASK_THREAD_S2_RA(a3)
REG_S s3, TASK_THREAD_S3_RA(a3)
REG_S s4, TASK_THREAD_S4_RA(a3)
REG_S s5, TASK_THREAD_S5_RA(a3)
REG_S s6, TASK_THREAD_S6_RA(a3)
REG_S s7, TASK_THREAD_S7_RA(a3)
REG_S s8, TASK_THREAD_S8_RA(a3)
REG_S s9, TASK_THREAD_S9_RA(a3)
REG_S s10, TASK_THREAD_S10_RA(a3)
REG_S s11, TASK_THREAD_S11_RA(a3)
RISC-V ABI 规定 callee-saved 寄存器为 ra (x1)、sp (x2)、s0-s11 (x8-x9, x18-x27),共 14 个。REG_S 宏在 RV64 上展开为 sd (Store Doubleword)。
与 ARM64 类似,寄存器保存到 task_struct->thread 结构体的对应字段中,而不是栈上。TASK_THREAD_* 宏是各字段在 task_struct 中的偏移量,在编译时由构建系统生成。
CSR_STATUS SUM 位保存
// arch/riscv/kernel/entry.S:441-443
/* save the user space access flag */
csrr s0, CSR_STATUS
REG_S s0, TASK_THREAD_SUM_RA(a3)
mstatus (Machine mode) 或 sstatus (Supervisor mode) 寄存器的 SUM (Supervisor User Memory access) 位控制内核态是否可以访问用户态虚拟地址。内核在执行 copy_to_user()/copy_from_user() 时需要此位置 1,其他时间为 0。保存 SUM 位确保任务恢复时具有正确的用户空间访问权限。
Shadow Call Stack
// arch/riscv/kernel/entry.S:445-446
/* Save the kernel shadow call stack pointer */
scs_save_current
与 ARM64 类似,scs_save_current 保存当前任务的影子栈指针。
恢复 SUM 位和 callee-saved 寄存器
// arch/riscv/kernel/entry.S:447-465
/* Restore context from next->thread */
REG_L s0, TASK_THREAD_SUM_RA(a4)
li s1, SR_SUM
and s0, s0, s1
csrs CSR_STATUS, s0 // 恢复 SUM 位
REG_L ra, TASK_THREAD_RA_RA(a4)
REG_L sp, TASK_THREAD_SP_RA(a4)
REG_L s0, TASK_THREAD_S0_RA(a4)
REG_L s1, TASK_THREAD_S1_RA(a4)
REG_L s2, TASK_THREAD_S2_RA(a4)
REG_L s3, TASK_THREAD_S3_RA(a4)
REG_L s4, TASK_THREAD_S4_RA(a4)
REG_L s5, TASK_THREAD_S5_RA(a4)
REG_L s6, TASK_THREAD_S6_RA(a4)
REG_L s7, TASK_THREAD_S7_RA(a4)
REG_L s8, TASK_THREAD_S8_RA(a4)
REG_L s9, TASK_THREAD_S9_RA(a4)
REG_L s10, TASK_THREAD_S10_RA(a4)
REG_L s11, TASK_THREAD_S11_RA(a4)
注意恢复顺序:先恢复 SUM 位(CSR 操作),然后恢复通用寄存器。csrs (CSR Set) 指令只设置 s0 中为 1 的位,不影响其他状态位。SUM 位的恢复在 SP 恢复之前,这是安全的因为此时还在使用 prev 的栈。
thread_info 指针更新
// arch/riscv/kernel/entry.S:466-467
/* The offset of thread_info in task_struct is zero. */
move tp, a1
RISC-V 使用 tp (Thread Pointer, x4) 寄存器存储当前任务的 task_struct 指针。注释说明 thread_info 在 task_struct 中的偏移量为 0,因此 tp 既指向 task_struct 也指向 thread_info。
ARM64 使用 SP_EL0 存储此指针,x86_64 使用 GS 段基址 + per-CPU 变量。RISC-V 的方案最直接 -- 使用一个专用的通用寄存器。
Shadow Call Stack 加载
// arch/riscv/kernel/entry.S:468-470
/* Switch to the next shadow call stack */
scs_load_current
ret
加载 next 任务的影子栈指针,然后 ret 返回。由于 ra 已从 next->thread.ra 恢复,ret 返回到 next 任务上次被换出时的调用点。
13.3.7 三架构对比
下表总结了三种架构在上下文切换实现上的关键差异:
| 特性 | x86_64 | ARM64 | RISC-V |
|---|---|---|---|
| callee-saved 寄存器数量 | 6 (rbp, rbx, r12-r15) | 14 (x19-x28, FP, SP, LR) | 14 (ra, sp, s0-s11) |
| 寄存器保存位置 | 内核栈 (push) |
task_struct->cpu_context (stp) |
task_struct->thread (REG_S) |
| 栈切换时机 | 恢复寄存器之前 | 恢复寄存器之后 | 恢复寄存器中间 (SP 恢复较晚) |
| current 指针 | GS段基址 + per_cpu(current_task) |
SP_EL0 + per_cpu(__entry_task) |
tp 寄存器 (x4) |
| 分层设计 | 汇编(__switch_to_asm) + C(__switch_to) |
C(__switch_to) + 汇编(cpu_switch_to) |
纯汇编(__switch_to) |
| 中断禁用 | 调用前已禁用 | save_and_disable_daif |
调用前已禁用 |
| FPU/SIMD 策略 | switch_fpu() + lazy/eager |
fpsimd_thread_switch() + lazy |
硬件陷阱 + lazy |
| 安全特性 | RSB填充, IBPB, L1D flush | PAC, SSBS, MTE, GCS | SCS, SUM 位 |
| 地址空间标识 | PCID (12位, CR3) | ASID (8/16位, TTBR) | ASID (16位, satp) |
current 指针实现对比
三种架构获取 current 指针的方式体现了不同的设计哲学:
x86_64: 通过 GS 段寄存器基址指向 per-CPU 数据区,current_task 是 per-CPU 变量。获取 current 需要一次内存读取:
// 本质上是: this_cpu_read(current_task)
// 通过 GS:offset 寻址
ARM64: SP_EL0 直接存储 task_struct 指针。获取 current 只需读取一个系统寄存器:
// 本质上是: read_sysreg(sp_el0)
但在异常入口时 SP_EL0 被覆盖为用户态 SP,需要通过 __entry_task per-CPU 变量恢复。
RISC-V: tp 寄存器直接存储 task_struct 指针(偏移为 0 即 thread_info)。获取 current 只需读取通用寄存器:
// 本质上是: (struct task_struct *)tp
这是最直接的方案,代价是永久占用一个通用寄存器。
13.3.8 地址空间切换 -- switch_mm
地址空间切换是上下文切换的另一核心部分,在 context_switch() 中通过 switch_mm_irqs_off() 完成。它将 CPU 的页表基址寄存器从旧进程的页全局目录 (PGD) 切换到新进程的 PGD。
x86_64 switch_mm_irqs_off
x86_64 的地址空间切换实现在 arch/x86/mm/tlb.c:783-972。这是内核中最为复杂的函数之一,涉及 ASID 管理、PCID 优化、TLB 刷新策略、多种安全缓解措施。
// arch/x86/mm/tlb.c:783
void switch_mm_irqs_off(struct mm_struct *unused, struct mm_struct *next,
struct task_struct *tsk)
{
struct mm_struct *prev = this_cpu_read(cpu_tlbstate.loaded_mm);
u16 prev_asid = this_cpu_read(cpu_tlbstate.loaded_mm_asid);
// ...
函数读取 per-CPU 的 cpu_tlbstate 结构来获取当前加载的 mm_struct 和 ASID。
同一地址空间优化
// arch/x86/mm/tlb.c:841
if (prev == next) {
/* Not actually switching mm's */
// 更新 LAM (Linear Address Masking) 掩码等
// 不需要写 CR3
return;
}
当 prev 和 next 是同一个 mm_struct(例如同一进程的不同线程之间切换)时,不需要切换页表。这是一个重要的快速路径优化。
内核线程的 Lazy TLB
// kernel/sched/core.c:5260-5267
if (!next->mm) { // 切换到内核线程
enter_lazy_tlb(prev->active_mm, next);
next->active_mm = prev->active_mm;
内核线程没有用户地址空间 (mm == NULL),不需要切换页表。内核使用"lazy TLB"技术:内核线程继续使用前一个用户进程的地址空间 (active_mm),避免写入 CR3 导致 TLB 全部失效。只有当 TLB 刷新 IPI 到达时,才通过 leave_mm() 真正切换到 init_mm。
ASID 选择与 PCID
// arch/x86/mm/tlb.c:942
ns = choose_new_asid(next, next_tlb_gen);
choose_new_asid() 为 next 的 mm_struct 选择一个 ASID (Address Space Identifier)。x86_64 使用 PCID (Process-Context Identifier) -- CR3 寄存器的低 12 位(页对齐后不用的位)作为地址空间标签。
内核维护一个 per-CPU 的 ASID 上下文数组 cpu_tlbstate.ctxs[],每个条目记录一个 ASID 对应的 mm_struct 及其 TLB 生成号。当 mm_struct 的 TLB 生成号与 ASID 条目中的匹配时,说明该 ASID 下的 TLB 条目仍然有效,可以避免 TLB 刷新。
CR3 切换
// arch/x86/mm/tlb.c:947-958
if (ns.need_flush) {
this_cpu_write(cpu_tlbstate.ctxs[ns.asid].ctx_id, next->context.ctx_id);
this_cpu_write(cpu_tlbstate.ctxs[ns.asid].tlb_gen, next_tlb_gen);
load_new_mm_cr3(next->pgd, ns.asid, new_lam, true); // 带 TLB 刷新
} else {
load_new_mm_cr3(next->pgd, ns.asid, new_lam, false); // 不刷新 TLB
}
load_new_mm_cr3() 定义在 arch/x86/mm/tlb.c:562-583:
static void load_new_mm_cr3(pgd_t *pgdir, u16 new_asid, unsigned long lam,
bool need_flush)
{
unsigned long new_mm_cr3;
if (need_flush) {
invalidate_user_asid(new_asid);
new_mm_cr3 = build_cr3(pgdir, new_asid, lam);
} else {
new_mm_cr3 = build_cr3_noflush(pgdir, new_asid, lam);
}
write_cr3(new_mm_cr3); // arch/x86/mm/tlb.c:582
}
build_cr3() 和 build_cr3_noflush() 的区别在于 PCID 的 bit 63。当 bit 63 为 0 时,写入 CR3 会刷新所有非全局 TLB 条目;为 1 时 (noflush),保留现有 TLB 条目。这使得内核可以在切换回一个之前运行过的进程时保留其 TLB 条目,显著减少 TLB miss。
write_cr3() 是一个 serializing 操作,确保在 CR3 写入之前的所有内存操作对 TLB 可见。这也是 membarrier 系统调用正确性的保证。
IBPB -- Spectre v2 缓解
// 在 switch_mm_irqs_off 中间调用
// 当从用户进程切换到另一个用户进程时
if (static_branch_unlikely(&ibpb_enabled)) {
indirect_branch_prediction_barrier();
}
IBPB (Indirect Branch Prediction Barrier) 刷新分支预测器中的间接分支预测条目。当两个不同的用户进程在同一 CPU 上交替运行时,进程 A 的训练数据可能影响进程 B 的间接分支预测,形成 Spectre v2 侧信道。IBPB 通过在地址空间切换时清空间接分支预测器来缓解此攻击。
L1D Flush -- L1TF 缓解
// arch/x86/mm/tlb.c:628-633
if (prev_mm & LAST_USER_MM_L1D_FLUSH)
wrmsrq(MSR_IA32_FLUSH_CMD, L1D_FLUSH);
L1 Terminal Fault (L1TF) 漏洞允许攻击者通过页表项的终端故障来推测读取 L1 数据缓存中的数据。对于选择启用 L1D flush 的任务(通过 prctl(PR_SET_SPECULATION_CTRL, PR_SPEC_L1D_FLUSH)),内核在切换离开时使用 MSR_IA32_FLUSH_CMD 刷新整个 L1 数据缓存。这是一个代价极高的操作(约数千个时钟周期),仅在有明确的 L1TF 风险时使用。
ARM64 地址空间切换
ARM64 的地址空间切换在 arch/arm64/include/asm/mmu_context.h 中实现。ARM64 使用两个页表基址寄存器:TTBR0_EL1(用户态)和 TTBR1_EL1(内核态),实现了内核/用户地址空间的分离。
// arch/arm64/include/asm/mmu_context.h:235-247
static inline void __switch_mm(struct mm_struct *next)
{
if (next == &init_mm) {
cpu_set_reserved_ttbr0();
return;
}
check_and_switch_context(next);
}
check_and_switch_context() 定义在 arch/arm64/mm/context.c:235-271,负责 ASID 分配和 TLB 管理:
// arch/arm64/mm/context.c:240-270
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;
raw_spin_lock_irqsave(&cpu_asid_lock, flags);
asid = atomic64_read(&mm->context.id);
if (!asid_gen_match(asid)) {
asid = new_context(mm);
atomic64_set(&mm->context.id, asid);
}
cpu = smp_processor_id();
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();
if (!system_uses_ttbr0_pan())
cpu_switch_mm(mm->pgd, mm);
ARM64 的 ASID 方案使用版本号机制:ASID = 低 N 位 (实际 ASID) + 高位 (版本号)。当 ASID 耗尽时,递增版本号,使所有旧 ASID 失效,触发全局 TLB 刷新。asid_gen_match() 检查 ASID 的版本号是否与当前版本匹配。匹配时通过 fastpath 快速路径跳过 ASID 分配,直接使用现有 ASID。
arm64_apply_bp_hardening() 应用分支预测硬化(如 BPIALL 指令),是 ARM 特定的 Spectre v2 缓解。
RISC-V 地址空间切换
RISC-V 的地址空间切换在 arch/riscv/mm/context.c:144-224 中实现:
// arch/riscv/mm/context.c:144
static void set_mm_asid(struct mm_struct *mm, unsigned int cpu)
{
unsigned long cntx = atomic_long_read(&mm->context.id);
old_active_cntx = atomic_long_read(&per_cpu(active_context, cpu));
if (old_active_cntx &&
(cntx2version(cntx) == atomic_long_read(¤t_version)) &&
atomic_long_cmpxchg_relaxed(&per_cpu(active_context, cpu),
old_active_cntx, cntx))
goto switch_mm_fast;
raw_spin_lock_irqsave(&context_lock, flags);
cntx = atomic_long_read(&mm->context.id);
if (cntx2version(cntx) != atomic_long_read(¤t_version)) {
cntx = __new_context(mm);
atomic_long_set(&mm->context.id, cntx);
}
if (cpumask_test_and_clear_cpu(cpu, &context_tlb_flush_pending))
need_flush_tlb = true;
atomic_long_set(&per_cpu(active_context, cpu), cntx);
raw_spin_unlock_irqrestore(&context_lock, flags);
switch_mm_fast:
csr_write(CSR_SATP, virt_to_pfn(mm->pgd) |
(cntx2asid(cntx) << SATP_ASID_SHIFT) |
satp_mode);
if (need_flush_tlb)
local_flush_tlb_all();
}
RISC-V 使用 satp (Supervisor Address Translation and Protection) CSR 进行地址空间切换。satp 的格式为:[MODE][ASID][PPN],其中 MODE 选择页表模式 (Sv39/Sv48/Sv57),ASID 为 16 位地址空间标识符,PPN 为页全局目录的物理页号。
RISC-V 的 ASID 管理方案与 ARM64 类似,使用版本号机制。当 ASID 耗尽时递增 current_version,触发全局 TLB 刷新。
// arch/riscv/mm/context.c:200-204
static void set_mm_noasid(struct mm_struct *mm)
{
csr_write(CSR_SATP, virt_to_pfn(mm->pgd) | satp_mode);
local_flush_tlb_all_asid(0);
}
对于不支持 ASID 的系统(如某些早期 RISC-V 实现),每次 switch_mm 都必须刷新整个 TLB,性能开销显著增加。
13.3.9 fork 后的上下文切换入口
新创建的进程通过 fork() 系统调用复制父进程的上下文,然后在首次被调度时从特殊的入口点开始执行。这需要在 copy_thread() 中设置初始的寄存器状态和栈帧。
x86_64 ret_from_fork_asm
// arch/x86/entry/entry_64.S:227-245
SYM_CODE_START(ret_from_fork_asm)
UNWIND_HINT_END_OF_STACK
ANNOTATE_NOENDBR
CALL_DEPTH_ACCOUNT
movq %rax, %rdi /* prev */
movq %rsp, %rsi /* regs */
movq %rbx, %rdx /* fn */
movq %r12, %rcx /* fn_arg */
call ret_from_fork
新进程的初始栈帧由 copy_thread() (arch/x86/kernel/process.c) 设置,布局遵循 inactive_task_frame 结构体:
%rax= prev 任务指针(从__switch_to返回值)%rbx= 内核线程函数指针(用户线程为 NULL)%r12= 内核线程参数
当新进程首次被调度时,__switch_to_asm 从新栈弹出 callee-saved 寄存器,然后 jmp __switch_to 进入 C 函数。__switch_to 返回后(返回 prev 指针),控制流到达 ret_from_fork_asm,因为新进程的返回地址被设置为 ret_from_fork_asm。
ret_from_fork C 函数调用 schedule_tail() 完成调度器的善后工作:
// kernel/sched/core.c:5209
asmlinkage __visible void schedule_tail(struct task_struct *prev)
{
finish_task_switch(prev);
trace_sched_exit_tp(true);
preempt_enable();
if (current->set_child_tid)
put_user(task_pid_vnr(current), current->set_child_tid);
calculate_sigpending();
}
finish_task_switch() 释放 prev 任务持有的 rq 锁、执行 mmdrop() 释放旧地址空间引用、完成 RCU callback 等。preempt_enable() 将 preempt_count 从 FORK_PREEMPT_COUNT 递减到正常值,使新进程可以被抢占。
ARM64 ret_from_fork
// arch/arm64/kernel/entry.S:856-864
SYM_CODE_START(ret_from_fork)
bl schedule_tail
cbz x19, 1f // not a kernel thread
mov x0, x20
blr x19 // 调用内核线程函数
1: get_current_task tsk
mov x0, sp
bl asm_exit_to_user_mode
b ret_to_user
ARM64 的 copy_thread() 将 x19 设置为内核线程函数指针,x20 设置为参数。对于用户线程 (x19 == 0),直接跳转到 asm_exit_to_user_mode 返回用户态。
cpu_context.pc 在 copy_thread() 中被设置为 ret_from_fork 的地址(arch/arm64/kernel/process.c:508):
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
p->thread.cpu_context.sp = (unsigned long)childregs;
p->thread.cpu_context.fp = (unsigned long)&childregs->stackframe;
当 cpu_switch_to() 恢复寄存器后,ret 指令跳转到 ret_from_fork。
新进程的 preempt_count
新创建进程的 preempt_count 初始值为 FORK_PREEMPT_COUNT,定义在 include/linux/preempt.h:76:
#define FORK_PREEMPT_COUNT (2*PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLED)
在 CONFIG_PREEMPT_COUNT 启用时,此值为 2。这意味着新进程在被 schedule_tail() 调用 preempt_enable() 递减之前不能被抢占。值 2 的来源是:
context_switch()调用前:preempt_disable()使 count = 1__schedule()本身:在rq_lock期间隐式持有,count = 2
finish_task_switch() 释放 rq 锁后 count 变为 1,然后 schedule_tail() 中的 preempt_enable() 使 count 降为 0,新进程可以被正常抢占。
13.3.10 切换完成与 finish_task_switch
switch_to 宏返回后,控制流实际上在新任务的上下文中执行 finish_task_switch()。此函数定义在 kernel/sched/core.c 中(通过 context_switch 的返回值调用),负责完成切换的善后工作:
- 释放 prev 任务持有的 rq 锁
- 如果 prev 是用户进程且切换到了内核线程,执行
mmdrop_lazy_tlb()释放active_mm引用 - 如果 prev 进程已死亡 (
TASK_DEAD),释放其task_struct引用 - 完成 RCU context switch 通知
- 触发
sched_outpreempt notifier
整个上下文切换的完整时间线如下:
CPU A (正在运行 prev)
1. __schedule() 获取 rq->lock
2. context_switch()
2a. prepare_task_switch()
2b. arch_start_context_switch()
2c. switch_mm_irqs_off() -- 地址空间切换
2d. switch_to() -- 寄存器/栈切换
---- 此时 CPU 已运行 next ----
2e. finish_task_switch() -- 释放锁、清理 prev
3. __schedule() 返回
在第 2d 步和第 2e 步之间,CPU 的执行上下文已经完全切换到 next 任务。finish_task_switch() 在 next 的栈上执行,清理 prev 任务留下的资源。这种"我来清理你的身后事"的模式是内核上下文切换的经典设计。
13.4 preempt_count 与抢占模型
抢占 (preemption) 是内核调度器的核心机制之一。它决定了内核在什么条件下可以中断当前正在运行的内核路径,切换到另一个任务。Linux 内核通过一个 32 位的 preempt_count 字段精确跟踪 CPU 当前的执行上下文深度,并据此判断是否允许抢占。本节将深入分析 preempt_count 的位域布局、四种抢占模型的设计权衡、以及抢占与中断、锁之间的复杂交互。
13.4.1 preempt_count 布局
preempt_count 是 thread_info 结构体的一个 int 字段,嵌入在每个任务的 task_struct 中。其布局定义在 include/linux/preempt.h:27-48:
31 30 28 27 24 23 16 15 8 7 0
+---+---...----+---...+---...--+---...--+---...--+
| N | unused | NMI | HARD | SOFT | PREEMPT |
| | | | IRQ | IRQ | |
+---+---...----+---...+---...--+---...--+---...--+
具体定义如下(include/linux/preempt.h:33-48):
#define PREEMPT_BITS 8 // bits 0-7: 抢占禁用深度
#define SOFTIRQ_BITS 8 // bits 8-15: 软中断嵌套深度
#define HARDIRQ_BITS 4 // bits 16-19: 硬中断嵌套深度
#define NMI_BITS 4 // bits 20-23: NMI 嵌套深度
#define PREEMPT_SHIFT 0
#define SOFTIRQ_SHIFT (PREEMPT_SHIFT + PREEMPT_BITS) // 8
#define HARDIRQ_SHIFT (SOFTIRQ_SHIFT + SOFTIRQ_BITS) // 16
#define NMI_SHIFT (HARDIRQ_SHIFT + HARDIRQ_BITS) // 20
#define PREEMPT_MASK (__IRQ_MASK(PREEMPT_BITS) << PREEMPT_SHIFT) // 0x000000ff
#define SOFTIRQ_MASK (__IRQ_MASK(SOFTIRQ_BITS) << SOFTIRQ_SHIFT) // 0x0000ff00
#define HARDIRQ_MASK (__IRQ_MASK(HARDIRQ_BITS) << HARDIRQ_SHIFT) // 0x000f0000
#define NMI_MASK (__IRQ_MASK(NMI_BITS) << NMI_SHIFT) // 0x00f00000
此外,bit 31 是 x86_64 特有的 PREEMPT_NEED_RESCHED 标志:
// arch/x86/include/asm/preempt.h:13
#define PREEMPT_NEED_RESCHED 0x80000000
各字段的含义
bits 0-7 (PREEMPT_MASK): 抢占禁用深度。每次调用 preempt_disable() 递增此字段,preempt_enable() 递减。当此字段非零时,不允许抢占。最大深度为 255。PREEMPT_DISABLE_OFFSET 为 1(include/linux/preempt.h:147),即 preempt_disable() 使 count 增加 1。
bits 8-15 (SOFTIRQ_MASK): 软中断嵌套计数。进入软中断处理时 SOFTIRQ_OFFSET (bit 8) 被设置。SOFTIRQ_DISABLE_OFFSET 为 2 * SOFTIRQ_OFFSET(include/linux/preempt.h:55),当调用 local_bh_disable() 禁用软中断时使用。SOFTIRQ_MASK 可以同时表示"正在处理软中断"(bit 8 = 1) 和"软中断被禁用"(bit 9 = 1) 两种状态。
bits 16-19 (HARDIRQ_MASK): 硬中断嵌套深度。进入硬件中断处理程序时递增,退出时递减。虽然现代内核在硬中断处理期间禁用中断不允许嵌套,但某些老旧驱动可能在中断处理程序中重新启用中断,因此保留了多位。
bits 20-23 (NMI_MASK): NMI (Non-Maskable Interrupt) 嵌套深度。进入 NMI 处理程序时递增。NMI 可以打断任何上下文,包括硬中断处理程序。
bit 31 (PREEMPT_NEED_RESCHED): x86_64 的特殊优化标志。这是一个"反转"标志 -- 当该位清零时表示需要重新调度。这使得 x86_64 上的 preempt_enable() 可以用单条 decl 指令同时递减抢占计数并检查是否需要调度,避免条件分支。其他架构将此信息存储在 thread_info.flags 的 TIF_NEED_RESCHED 位中。
各掩码的偏移量
// include/linux/preempt.h:50-53
#define PREEMPT_OFFSET (1UL << PREEMPT_SHIFT) // 0x00000001
#define SOFTIRQ_OFFSET (1UL << SOFTIRQ_SHIFT) // 0x00000100
#define HARDIRQ_OFFSET (1UL << HARDIRQ_SHIFT) // 0x00010000
#define NMI_OFFSET (1UL << NMI_SHIFT) // 0x00100000
preempt_count 的读取
在 x86_64 上,preempt_count() 的读取通过 per-CPU 变量实现:
// arch/x86/include/asm/preempt.h:25-28
static __always_inline int preempt_count(void)
{
return raw_cpu_read_4(__preempt_count) & ~PREEMPT_NEED_RESCHED;
}
注意 & ~PREEMPT_NEED_RESCHED 掩码清除了 bit 31,因为该位是 x86_64 的调度标志,不属于实际的抢占计数。raw_cpu_read_4 是一个无需屏障的快速 per-CPU 读取操作,因为 preempt_count 只会被当前 CPU 修改。
在非 x86 架构上,preempt_count 通常通过 current_thread_info()->preempt_count 读取,PREEMPT_NEED_RESCHED 为 0。
上下文判断函数
内核提供了一系列宏来快速判断当前 CPU 的执行上下文(include/linux/preempt.h:107-141):
#define nmi_count() (preempt_count() & NMI_MASK)
#define hardirq_count() (preempt_count() & HARDIRQ_MASK)
#define softirq_count() (preempt_count() & SOFTIRQ_MASK)
#define in_nmi() (nmi_count()) // NMI 上下文
#define in_hardirq() (hardirq_count()) // 硬中断上下文
#define in_serving_softirq() (softirq_count() & SOFTIRQ_OFFSET) // 正在处理软中断
// 非 RT 内核:
#define in_task() (!(preempt_count() & (NMI_MASK | HARDIRQ_MASK | SOFTIRQ_OFFSET))) // 进程上下文
#define in_interrupt() (preempt_count() & (NMI_MASK | HARDIRQ_MASK | SOFTIRQ_MASK)) // 任何中断上下文
// RT 内核:
// softirq_count() 改为读取 current->softirq_disable_cnt
// in_task() 排除 softirq 因为 RT 上软中断在进程上下文中运行
interrupt_context_level() 函数(include/linux/preempt.h:90-100)返回一个数值表示当前的中断深度:
static __always_inline unsigned char interrupt_context_level(void)
{
unsigned long pc = preempt_count();
unsigned char level = 0;
level += !!(pc & (NMI_MASK)); // +1 如果在 NMI
level += !!(pc & (NMI_MASK | HARDIRQ_MASK)); // +1 如果在硬中断或更深
level += !!(pc & (NMI_MASK | HARDIRQ_MASK | SOFTIRQ_OFFSET)); // +1 如果在软中断或更深
return level;
}
返回值含义:0 = 进程上下文,1 = 软中断,2 = 硬中断,3 = NMI。此函数用于 RCU 等子系统判断当前上下文深度。
PREEMPT_DISABLED 和 FORK_PREEMPT_COUNT
// include/linux/preempt.h:57
#define PREEMPT_DISABLED (PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLED)
// include/linux/preempt.h:65
#define INIT_PREEMPT_COUNT PREEMPT_OFFSET // = 1, 启动期间禁用抢占
// include/linux/preempt.h:76
#define FORK_PREEMPT_COUNT (2*PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLED) // = 2
INIT_PREEMPT_COUNT (值为 1) 是系统启动时 idle 任务的初始 preempt_count。在 start_kernel() -> sched_init() -> init_idle() -> init_idle_preempt_count() 之前,抢占一直被禁用。
FORK_PREEMPT_COUNT (值为 2) 是新创建进程的初始 preempt_count。值 2 反映了调度不变量:在 context_switch() 执行期间,preempt_count 为 2(一次来自 __schedule() 的 preempt_disable(),一次来自 rq_lock 的隐式禁用)。schedule_tail() 中的 finish_task_switch() 释放 rq 锁后递减到 1,然后 preempt_enable() 递减到 0。
13.4.2 抢占禁用与启用
preempt_disable
// include/linux/preempt.h:211-215
#define preempt_disable() \
do { \
preempt_count_inc(); \
barrier(); \
} while (0)
preempt_count_inc() 展开为 preempt_count_add(1),进而调用 __preempt_count_add(1):
// arch/x86/include/asm/preempt.h:78-81
static __always_inline void __preempt_count_add(int val)
{
raw_cpu_add_4(__preempt_count, val);
}
barrier() 是编译器屏障,阻止编译器将 preempt_disable() 之后的内存操作重排到之前。这是必要的,因为 preempt_disable() 本身不包含内存屏障指令(如 mfence 或 lock 前缀指令),但语义上它标记了一个临界区的开始,编译器不能将可能依赖抢占安全的代码移出此临界区。
preempt_enable
// include/linux/preempt.h:228-233 (CONFIG_PREEMPTION 启用时)
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
preempt_count_dec_and_test() 递减 preempt_count 并检查是否变为零:
// arch/x86/include/asm/preempt.h:93-97
static __always_inline bool __preempt_count_dec_and_test(void)
{
return GEN_UNARY_RMWcc("decl", __my_cpu_var(__preempt_count), e,
__percpu_arg([var]));
}
在 x86_64 上,这展开为单条 decl 指令加上条件判断。由于 PREEMPT_NEED_RESCHED (bit 31) 是反转的,当 preempt_count 递减到 0(所有计数清零)且 PREEMPT_NEED_RESCHED 位被清除(表示需要调度)时,decl 设置 ZF 标志,e (equal/zero) 条件为真。
如果需要调度,调用 __preempt_schedule(),在 x86_64 上展开为:
// arch/x86/include/asm/preempt.h:125-129 (CONFIG_PREEMPT_DYNAMIC)
#define __preempt_schedule() \
do { \
__STATIC_CALL_MOD_ADDRESSABLE(preempt_schedule); \
asm volatile ("call " STATIC_CALL_TRAMP_STR(preempt_schedule) : ASM_CALL_CONSTRAINT); \
} while (0)
通过 static call 机制调用 preempt_schedule(),这使得运行时可以动态切换此函数为空操作(在 PREEMPT_NONE/PREEMPT_VOLUNTARY 模式下)。
preempt_enable_no_resched
// include/linux/preempt.h:217-221
#define sched_preempt_enable_no_resched() \
do { \
barrier(); \
preempt_count_dec(); \
} while (0)
preempt_enable_no_resched() 只递减 preempt_count 而不检查是否需要调度。用于那些即将进入睡眠或已经知道会很快调用 schedule() 的路径。例如 schedule_tail() 和 preempt_schedule_common() 中使用此宏,避免在已知即将调度的情况下进行冗余检查。
非抢占内核
当 CONFIG_PREEMPT_COUNT 未启用时,preempt_disable()/preempt_enable() 退化为纯编译器屏障:
// include/linux/preempt.h:284-287
#define preempt_disable() barrier()
#define sched_preempt_enable_no_resched() barrier()
#define preempt_enable_no_resched() barrier()
#define preempt_enable() barrier()
即使在不支持抢占的内核中,preempt_disable()/preempt_enable() 仍然作为编译器屏障存在。这是因为 get_user()/put_user() 等可能引发缺页异常的操作需要确保在正确的上下文中执行。通过将它们包装在 preempt_disable()/preempt_enable() 中,确保编译器不会将这些操作重排到临界区之外。
13.4.3 四种抢占模型
Linux 内核提供四种抢占模型,在编译时通过 CONFIG_* 选项选择,或在运行时通过 PREEMPT_DYNAMIC 动态切换。每种模型在延迟和吞吐量之间做出不同的权衡。
CONFIG_PREEMPT_NONE -- 服务器优化
CONFIG_PREEMPT_NONE 完全禁止内核抢占。任务在内核态运行时不会被其他任务抢占,除非它主动调用 schedule() 或返回用户态。
在这种模型下,CONFIG_PREEMPTION 未定义,preempt_enable() 只是递减 preempt_count,不检查调度:
// include/linux/preempt.h:249-253
#define preempt_enable() \
do { \
barrier(); \
preempt_count_dec(); \
} while (0)
调度只发生在以下时机:
- 返回用户态:系统调用、中断、异常返回用户态前检查
TIF_NEED_RESCHED - 显式调度点:
schedule()、cond_resched()、schedule_timeout() - 睡眠:
wait_event()、mutex_lock()等阻塞操作
PREEMPT_NONE 的优势是最大化的吞吐量和最小的抢占开销。适用于服务器工作负载,延迟不是首要关注点。
CONFIG_PREEMPT_VOLUNTARY -- 桌面优化
CONFIG_PREEMPT_VOLUNTARY 在 PREEMPT_NONE 的基础上增加了自愿抢占点。通过 might_resched() 宏在内核代码的关键位置插入检查:
// 在 CONFIG_PREEMPT_VOLUNTARY 启用时:
#define might_resched() cond_resched()
cond_resched() 检查 TIF_NEED_RESCHED,如果设置则调用 __schedule()。典型的插入位置包括:
- 自旋锁获取 (
spin_lock) - 信号量 down 操作
copy_from_user()/copy_to_user()循环中- 各种
*_interruptible()等待函数
PREEMPT_VOLUNTARY 提供了比 PREEMPT_NONE 更好的桌面响应性,同时保持较低的抢占开销。增加的 cond_resched() 调用引入的开销极小(只是一个标志检查),但对交互式工作负载的延迟改善显著。
CONFIG_PREEMPT (CONFIG_PREEMPT_DYNAMIC) -- 完全抢占
CONFIG_PREEMPT 启用完全内核抢占。任何 preempt_enable() 将 preempt_count 递减到零的操作都可能触发抢占:
// include/linux/preempt.h:228-233
#define preempt_enable() \
do { \
barrier(); \
if (unlikely(preempt_count_dec_and_test())) \
__preempt_schedule(); \
} while (0)
在 CONFIG_PREEMPT_DYNAMIC 启用时,抢占行为可以在运行时切换。内核编译时使用 CONFIG_PREEMPTION=y,但通过 static call / static key 机制在运行时控制具体行为。
抢占检查发生在以下时机:
preempt_enable(): 当preempt_count降为 0 且TIF_NEED_RESCHED已设置- 中断返回内核态:
preempt_schedule_irq()在中断处理完成后检查 - spin_unlock 等: 释放锁时调用
preempt_enable()
PREEMPT 的代价是更大的代码路径开销(更多的 preempt_disable()/preempt_enable() 对)和更复杂的锁定规则。优势是更低的调度延迟,对实时音频处理、低延迟交易等场景至关重要。
CONFIG_PREEMPT_RT -- 实时内核
CONFIG_PREEMPT_RT 是 Linux 内核的实时扩展,在 CONFIG_PREEMPT 的基础上做了更深层次的改造:
自旋锁转换为可抢占锁:所有 spinlock_t 被替换为基于 rt_mutex 的实时锁。持有自旋锁不再隐式禁用抢占,而是可以被打断。这意味着自旋锁保护的临界区可以参与优先级继承 (priority inheritance),避免优先级反转。
// include/linux/preempt.h:155-160
#if !defined(CONFIG_PREEMPT_RT)
#define PREEMPT_LOCK_OFFSET PREEMPT_DISABLE_OFFSET // 非 RT: 自旋锁禁用抢占
#else
#define PREEMPT_LOCK_OFFSET 0 // RT: 自旋锁不禁用抢占
#endif
中断线程化:硬中断处理程序在内核线程上下文中执行,可以被更高优先级的任务抢占。只有少数关键中断(如时钟中断)保持在硬中断上下文。
SM_RTLOCK_WAIT 模式:定义在 kernel/sched/core.c:6483:
#define SM_RTLOCK_WAIT 2
当 RT 内核上的任务在 rt_mutex 上等待时,使用 SM_RTLOCK_WAIT 模式调用 __schedule()。此模式区分"因为等待实时锁而阻塞"和"被抢占"两种情况,影响任务状态管理和 RCU 通知。
// kernel/sched/core.c:7047-7051
void __sched notrace schedule_rtlock(void)
{
__schedule_loop(SM_RTLOCK_WAIT);
}
softirq_count() 的改变:在 RT 内核上,软中断也在进程上下文中运行,softirq_count() 读取 current->softirq_disable_cnt 而非 preempt_count 的软中断位域(include/linux/preempt.h:111)。
13.4.4 PREEMPT_DYNAMIC 动态抢占
CONFIG_PREEMPT_DYNAMIC 允许在运行时切换抢占模型,无需重启系统。实现基于两种机制:
- Static Call (
CONFIG_HAVE_PREEMPT_DYNAMIC_CALL): 通过修改函数指针直接替换函数调用目标 - Static Key (
CONFIG_HAVE_PREEMPT_DYNAMIC_KEY): 通过跳转指令补丁启/禁用代码路径
核心实现在 kernel/sched/core.c:7610-7670:
// kernel/sched/core.c:7610
static void __sched_dynamic_update(int mode)
{
// 首先启用所有可抢占函数,避免 NONE->FULL 切换经过无效的中间状态
preempt_dynamic_enable(cond_resched);
preempt_dynamic_enable(might_resched);
preempt_dynamic_enable(preempt_schedule);
preempt_dynamic_enable(preempt_schedule_notrace);
preempt_dynamic_enable(irqentry_exit_cond_resched);
preempt_dynamic_key_disable(preempt_lazy);
switch (mode) {
case preempt_dynamic_none:
preempt_dynamic_enable(cond_resched); // cond_resched() 有效
preempt_dynamic_disable(might_resched); // might_resched() 为空操作
preempt_dynamic_disable(preempt_schedule); // preempt_schedule() 为空操作
preempt_dynamic_disable(preempt_schedule_notrace);
preempt_dynamic_disable(irqentry_exit_cond_resched);
preempt_dynamic_key_disable(preempt_lazy);
break;
case preempt_dynamic_voluntary:
preempt_dynamic_enable(cond_resched);
preempt_dynamic_enable(might_resched); // 自愿抢占点启用
preempt_dynamic_disable(preempt_schedule); // 抢占仍禁用
preempt_dynamic_disable(preempt_schedule_notrace);
preempt_dynamic_disable(irqentry_exit_cond_resched);
preempt_dynamic_key_disable(preempt_lazy);
break;
case preempt_dynamic_full:
preempt_dynamic_disable(cond_resched); // 不需要自愿点
preempt_dynamic_disable(might_resched);
preempt_dynamic_enable(preempt_schedule); // 抢占启用
preempt_dynamic_enable(preempt_schedule_notrace);
preempt_dynamic_enable(irqentry_exit_cond_resched); // 中断返回时抢占
preempt_dynamic_key_disable(preempt_lazy);
break;
case preempt_dynamic_lazy:
preempt_dynamic_disable(cond_resched);
preempt_dynamic_disable(might_resched);
preempt_dynamic_enable(preempt_schedule);
preempt_dynamic_enable(preempt_schedule_notrace);
preempt_dynamic_enable(irqentry_exit_cond_resched);
preempt_dynamic_key_enable(preempt_lazy); // Lazy preemption 启用
break;
}
preempt_dynamic_mode = mode;
}
函数首先启用所有抢占函数(避免从 NONE 切换到 FULL 时经过中间状态),然后根据目标模式禁用不需要的函数。preempt_dynamic_mutex 保护切换过程,确保不会并发修改。
preempt_dynamic_enable/preempt_dynamic_disable 的实现取决于架构:
- Static Call 机制:直接修改函数调用目标。例如
preempt_dynamic_disable(preempt_schedule)将preempt_schedule()的调用替换为空函数。 - Static Key 机制:通过
static_key_true/false在调用点插入条件跳转。
控制抢占模式的引导参数
// kernel/sched/core.c:7679+
static int __init setup_preempt_mode(char *str)
内核引导参数 preempt= 允许选择运行时的抢占模式:
preempt=none: 无抢占(服务器模式)preempt=voluntary: 自愿抢占(桌面模式)preempt=full: 完全抢占preempt=lazy: Lazy 抢占
13.4.5 TIF_NEED_RESCHED 标志
TIF_NEED_RESCHED 是设置在 thread_info->flags 中的标志位,表示当前任务需要被调度出去。当调度器决定某个更高优先级的任务应该运行时,设置当前任务的此标志。
resched_curr -- 设置重调度标志
// kernel/sched/core.c:1112-1145
static void __resched_curr(struct rq *rq, int tif)
{
struct task_struct *curr = rq->curr;
struct thread_info *cti = task_thread_info(curr);
int cpu;
lockdep_assert_rq_held(rq);
// idle 任务总是立即抢占
if (is_idle_task(curr) && tif == TIF_NEED_RESCHED_LAZY)
tif = TIF_NEED_RESCHED;
// 如果已经设置,无需重复操作
if (cti->flags & ((1 << tif) | _TIF_NEED_RESCHED))
return;
cpu = cpu_of(rq);
if (cpu == smp_processor_id()) {
set_ti_thread_flag(cti, tif);
if (tif == TIF_NEED_RESCHED)
set_preempt_need_resched(); // 清除 bit 31 (x86 反转语义)
return;
}
// 远程 CPU: 需要 IPI 通知
if (set_nr_and_not_polling(cti, tif)) {
if (tif == TIF_NEED_RESCHED)
smp_send_reschedule(cpu); // 发送 RESCHEDULE IPI
}
}
resched_curr() 是面向调度器的公共接口,设置 TIF_NEED_RESCHED:
// kernel/sched/core.c:1154-1157
void resched_curr(struct rq *rq)
{
__resched_curr(rq, TIF_NEED_RESCHED);
}
在 x86_64 上,set_preempt_need_resched() 清除 preempt_count 的 bit 31(因为 PREEMPT_NEED_RESCHED 是反转的):
// arch/x86/include/asm/preempt.h:59-62
static __always_inline void set_preempt_need_resched(void)
{
raw_cpu_and_4(__preempt_count, ~PREEMPT_NEED_RESCHED);
}
重调度检查的位置
TIF_NEED_RESCHED 在以下位置被检查:
1. 返回用户态路径 (所有抢占模型)
在中断、异常、系统调用返回用户态之前,入口/出口代码检查 thread_info.flags。以 x86_64 为例,entry_64.S 中的出口路径检查 _TIF_WORK_MASK,其中包括 TIF_NEED_RESCHED。
2. preempt_enable() (CONFIG_PREEMPT)
在抢占内核中,preempt_enable() 递减 preempt_count 到 0 时检查 TIF_NEED_RESCHED。在 x86_64 上通过 __preempt_count_dec_and_test() 用单条指令完成此检查。
3. 中断返回内核态 (CONFIG_PREEMPT)
preempt_schedule_irq() 在中断处理完成后、返回被中断的内核代码之前检查:
// kernel/sched/core.c:7203-7221
asmlinkage __visible void __sched preempt_schedule_irq(void)
{
enum ctx_state prev_state;
BUG_ON(preempt_count() || !irqs_disabled());
prev_state = exception_enter();
do {
preempt_disable();
local_irq_enable();
__schedule(SM_PREEMPT);
local_irq_disable();
sched_preempt_enable_no_resched();
} while (need_resched());
exception_exit(prev_state);
}
注意此函数在中断上下文中被调用(中断已禁用),因此在 __schedule() 之前启用中断,之后再次禁用。循环检查 need_resched() 确保处理所有待处理的调度请求。
4. cond_resched() (CONFIG_PREEMPT_NONE/VOLUNTARY)
在非抢占或自愿抢占内核中,cond_resched() 是主要的调度检查点:
// 本质上是:
if (need_resched()) {
preempt_disable();
__schedule(SM_NONE);
preempt_enable_no_resched();
}
PREEMPT_NEED_RESCHED 位 (x86 优化)
x86_64 将 TIF_NEED_RESCHED 的信息折叠到 preempt_count 的 bit 31。这是一个精心设计的优化,使得 preempt_enable() 可以用单条 decl 指令完成"递减计数 + 检查是否需要调度"两个操作。
// arch/x86/include/asm/preempt.h:12-19
#define PREEMPT_NEED_RESCHED 0x80000000
#define PREEMPT_ENABLED (0 + PREEMPT_NEED_RESCHED) // 0x80000000
static __always_inline void preempt_count_set(int pc)
{
int old, new;
old = raw_cpu_read_4(__preempt_count);
do {
new = (old & PREEMPT_NEED_RESCHED) |
(pc & ~PREEMPT_NEED_RESCHED);
} while (!raw_cpu_try_cmpxchg_4(__preempt_count, &old, new));
}
PREEMPT_ENABLED 的值为 0x80000000(bit 31 = 1,其余为 0),表示"启用抢占且不需要调度"。当调度器设置 TIF_NEED_RESCHED 时,通过 set_preempt_need_resched() 清除 bit 31,使 preempt_count 的值变为 0x00000000。
此时 __preempt_count_dec_and_test() 执行 decl 指令:
decl [per_cpu__preempt_count] // 0x00000000 - 1 = 0xFFFFFFFF
// ZF = 0 (结果不为零)
等等,这不是正确的场景。正确的场景是:
当 preempt_count 为 0x80000001 (count = 1, PREEMPT_ENABLED) 时,preempt_disable() 将其变为 0x80000002 (count = 2)。调度器设置 NEED_RESCHED 后变为 0x00000002。然后 preempt_enable() 执行 decl,变为 0x00000001,ZF = 0。再执行一次 preempt_enable(),decl 将其变为 0x00000000,ZF = 1,触发调度。
关键点在于:decl 指令将 preempt_count 的 bit 0-30 和 bit 31 作为一个整体递减。当 count 为 1 且 PREEMPT_NEED_RESCHED 为 0(需要调度)时,整个值为 0x00000001,decl 后变为 0x00000000,设置 ZF = 1。当 count 为 1 且 PREEMPT_NEED_RESCHED 为 1(不需要调度)时,整个值为 0x80000001,decl 后变为 0x80000000,ZF = 0,不触发调度。通过这种巧妙的编码,单条指令完成了两个逻辑判断。
should_resched -- 通用检查
// arch/x86/include/asm/preempt.h:102-105
static __always_inline bool should_resched(int preempt_offset)
{
return unlikely(raw_cpu_read_4(__preempt_count) == preempt_offset);
}
should_resched(0) 检查 preempt_count 是否恰好等于 0(x86 上即 PREEMPT_NEED_RESCHED 为 0,所有计数为 0)。参数 preempt_offset 允许指定不同的阈值,例如 PREEMPT_DISABLE_OFFSET 或 SOFTIRQ_LOCK_OFFSET。
13.4.6 抢占与锁的交互
自旋锁隐式禁用抢占
在非 RT 内核中,spin_lock() 隐式调用 preempt_disable():
// 非RT内核的 spin_lock 展开:
preempt_disable(); // preempt_count++
raw_spin_lock(lock);
// ...
raw_spin_unlock(lock);
preempt_enable(); // preempt_count-- + 可能调度
PREEMPT_LOCK_OFFSET 定义了自旋锁对 preempt_count 的贡献:
// include/linux/preempt.h:155-160
#if !defined(CONFIG_PREEMPT_RT)
#define PREEMPT_LOCK_OFFSET PREEMPT_DISABLE_OFFSET // = 1
#else
#define PREEMPT_LOCK_OFFSET 0 // RT: 锁不禁用抢占
#endif
在 RT 内核上,自旋锁基于 rt_mutex 实现,不修改 preempt_count。锁的等待通过优先级继承机制避免优先级反转,而不需要禁用抢占。
spin_lock_bh -- 禁用软中断和抢占
// include/linux/preempt.h:175
#define SOFTIRQ_LOCK_OFFSET (SOFTIRQ_DISABLE_OFFSET + PREEMPT_LOCK_OFFSET)
spin_lock_bh() 同时禁用软中断和抢占。SOFTIRQ_DISABLE_OFFSET = 2 * SOFTIRQ_OFFSET = 0x200,PREEMPT_LOCK_OFFSET = 1,因此 SOFTIRQ_LOCK_OFFSET = 0x201。preempt_count 同时增加 0x201,确保在持有 bh 锁期间既不会处理软中断也不会被抢占。
debug spinlock 与 preempt_count
CONFIG_DEBUG_SPINLOCK 启用时,自旋锁操作会验证 preempt_count 的状态。例如,在 spin_unlock() 时检查 preempt_count 是否非零(确保在锁保护期间抢占确实被禁用)。这有助于发现忘记调用 preempt_disable() 或在锁保护区域内意外调用 preempt_enable() 的 bug。
per-CPU 数据访问与抢占
访问 per-CPU 数据时必须禁用抢占:
this_cpu_ptr(var); // 获取当前 CPU 的 per-CPU 变量地址
// 如果此处发生抢占,任务可能被迁移到另一个 CPU
// 然后 per-CPU 指针指向错误的 CPU 的数据
per_cpu_ptr(var, cpu); // 安全:显式指定 CPU
内核使用 get_cpu_ptr()/put_cpu_ptr() 宏自动处理抢占:
#define get_cpu_ptr(var) \
({ preempt_disable(); \
this_cpu_ptr(var); })
#define put_cpu_ptr(var) \
preempt_enable()
13.4.7 抢占与中断的层次关系
内核的执行上下文形成严格的层次结构:
进程上下文 (task context)
| 抢占禁用: preempt_count & PREEMPT_MASK != 0
| 软中断禁用: preempt_count & SOFTIRQ_MASK != 0
v
软中断上下文 (softirq context)
| 硬中断到达
v
硬中断上下文 (hardirq context)
| NMI 到达
v
NMI 上下文 (nmi context)
preempt_count 各层的递增/递减
进入硬中断处理程序 (arch/x86/kernel/irq.c 等):
irq_enter();
// preempt_count += HARDIRQ_OFFSET (0x10000)
// 设置 bit 16
// 硬中断处理...
irq_exit();
// preempt_count -= HARDIRQ_OFFSET
// 如果没有软中断挂起且不在中断上下文中,检查抢占
进入软中断处理 (kernel/softirq.c):
__do_softirq();
// preempt_count += SOFTIRQ_OFFSET (0x100)
// 设置 bit 8
// 软中断处理...
// preempt_count -= SOFTIRQ_OFFSET
local_bh_disable() 禁用软中断:
local_bh_disable();
// preempt_count += SOFTIRQ_DISABLE_OFFSET (0x200)
// 设置 bit 9
local_bh_enable();
// preempt_count -= SOFTIRQ_DISABLE_OFFSET
// 如果递减后 bit 8 也清零且有挂起的软中断,触发软中断处理
进入 NMI (arch/x86/kernel/nmi.c):
nmi_enter();
// preempt_count += NMI_OFFSET (0x100000)
// 设置 bit 20
// NMI 处理...
nmi_exit();
// preempt_count -= NMI_OFFSET
为什么中断上下文不能 schedule()
在中断上下文(硬中断、软中断、NMI)中调用 schedule() 会导致严重的系统不稳定,原因如下:
-
没有明确的
task_struct: 硬中断处理程序不在任何特定任务的上下文中执行,current指向被中断的任务。如果在中断中调度,被中断任务的preempt_count仍包含中断计数,导致状态不一致。 -
中断返回路径假设不变: 中断返回代码假设返回到被中断的任务,如果发生了调度,栈帧和寄存器状态不匹配。
-
栈溢出风险: 中断可能嵌套在已有栈上,调度会引入额外的栈使用。
内核通过 schedule_debug() 检查是否在非法上下文中调用 schedule():
// kernel/sched/core.c (简化)
static inline void schedule_debug(struct task_struct *prev, bool preempt)
{
if (in_atomic_preempt_off()) {
// preempt_count != PREEMPT_DISABLE_OFFSET
// 意味着在中断或原子上下文中调用了 schedule()
__schedule_bug(prev);
}
}
in_atomic_preempt_off() 定义在 include/linux/preempt.h:190:
#define in_atomic_preempt_off() (preempt_count() != PREEMPT_DISABLE_OFFSET)
当 preempt_count 不等于 1(仅因 preempt_disable() 而为 1 是合法的),说明在中断上下文中调用了 schedule(),触发 BUG() 或警告。
中断返回时的抢占检查
在 CONFIG_PREEMPT 内核中,硬中断返回到内核态时会检查抢占:
硬中断处理完成
-> irq_exit()
-> 如果 !in_interrupt() && local_softirq_pending():
invoke_softirq() // 处理挂起的软中断
-> 如果 returning to kernel:
preempt_schedule_irq() // 检查抢占
preempt_schedule_irq() 使用 SM_PREEMPT 模式调用 __schedule(),确保抢占语义正确。与普通 preempt_schedule() 不同,此函数始终在中断禁用状态下被调用,在 __schedule() 前临时启用中断。
软中断处理后的抢占
软中断处理完成后,如果返回到内核态,也会检查抢占。但这个检查通常已经包含在 irq_exit() 的路径中。
13.4.8 调度器入口点总结
所有调度入口最终都汇聚到 __schedule(),但通过不同的 sched_mode 参数区分调用语义:
// kernel/sched/core.c:6480-6483
#define SM_IDLE (-1) // idle 调度
#define SM_NONE 0 // 主动调度
#define SM_PREEMPT 1 // 被动抢占
#define SM_RTLOCK_WAIT 2 // RT 锁等待
schedule() (kernel/sched/core.c:6975-6988):
asmlinkage __visible void __sched schedule(void)
{
struct task_struct *tsk = current;
if (!task_is_running(tsk))
sched_submit_work(tsk);
__schedule_loop(SM_NONE); // 主动调度
sched_update_worker(tsk);
}
SM_NONE 表示主动调度。__schedule() 中,preempt = (sched_mode > SM_NONE) 为 false,意味着不会将 TASK_RUNNING 以外的状态解释为抢占。当 prev_state != 0(非 RUNNING)时,尝试将任务从运行队列移除。
preempt_schedule() (kernel/sched/core.c:7088-7097):
asmlinkage __visible void __sched notrace preempt_schedule(void)
{
if (likely(!preemptible()))
return;
preempt_schedule_common();
}
preemptible() 检查 preempt_count() == 0 && !irqs_disabled()。如果不满足条件(当前在中断上下文或抢占已禁用),直接返回。
preempt_schedule_irq() (kernel/sched/core.c:7203-7221):
从中断返回内核态时调用。与 preempt_schedule() 的区别在于此函数在中断禁用状态下被调用,BUG_ON(preempt_count() || !irqs_disabled()) 验证这一点。__schedule() 前临时启用中断,避免在中断禁用状态下调度导致死锁。
schedule_idle() (kernel/sched/core.c:7000-7013):
void __sched schedule_idle(void)
{
WARN_ON_ONCE(current->__state);
do {
__schedule(SM_IDLE);
} while (need_resched());
}
SM_IDLE 模式仅在运行队列为空时跳过调度。idle 任务不经过 __schedule_loop() 的抢占检查(不调用 preempt_disable()),因为 idle 任务永远不应该被抢占。
schedule_rtlock() (kernel/sched/core.c:7047-7051, 仅 RT):
void __sched notrace schedule_rtlock(void)
{
__schedule_loop(SM_RTLOCK_WAIT);
}
在 RT 内核上等待 rt_mutex 时使用。SM_RTLOCK_WAIT 告诉调度器这不是普通抢占,而是因为等待实时锁而主动让出 CPU。
__schedule_loop
// kernel/sched/core.c:6966-6973
static __always_inline void __schedule_loop(int sched_mode)
{
do {
preempt_disable();
__schedule(sched_mode);
sched_preempt_enable_no_resched();
} while (need_resched());
}
__schedule_loop() 循环调用 __schedule() 直到不再需要调度。每次迭代前后禁用/启用抢占。使用 sched_preempt_enable_no_resched() 而非 preempt_enable() 避免在循环内部触发嵌套抢占(因为循环条件已经检查了 need_resched())。
13.4.9 Lazy Preemption
Lazy preemption 是 Linux 内核中一种折中的抢占策略,介于完全抢占和非抢占之间。其核心思想是:调度器标记任务需要被抢占时,不立即在内核中触发抢占,而是等到任务返回用户态时才真正执行调度。
TIF_NEED_RESCHED_LAZY 标志
Lazy preemption 引入了 TIF_NEED_RESCHED_LAZY 标志位。x86_64 在 arch/x86/include/asm/thread_info.h:85 中定义:
#define HAVE_TIF_NEED_RESCHED_LAZY
当调度器认为某个任务应该被抢占,但不需要立即抢占时,设置 TIF_NEED_RESCHED_LAZY 而非 TIF_NEED_RESCHED。
resched_curr_lazy
// kernel/sched/core.c:1172-1183
static __always_inline int get_lazy_tif_bit(void)
{
if (dynamic_preempt_lazy())
return TIF_NEED_RESCHED_LAZY;
return TIF_NEED_RESCHED;
}
void resched_curr_lazy(struct rq *rq)
{
__resched_curr(rq, get_lazy_tif_bit());
}
当 lazy preemption 启用时,resched_curr_lazy() 设置 TIF_NEED_RESCHED_LAZY。resched_curr() 仍然设置 TIF_NEED_RESCHED 用于立即抢占。
周期时钟中的 Lazy 标志升级
// kernel/sched/core.c:5571-5572
if (dynamic_preempt_lazy() && tif_test_bit(TIF_NEED_RESCHED_LAZY))
resched_curr(rq);
在调度器的周期时钟中断处理程序 scheduler_tick() 中,如果发现 lazy 标志被设置,将其升级为立即抢占标志 TIF_NEED_RESCHED。这确保了 lazy 标志不会无限期地延迟调度 -- 下一个时钟周期(通常 1ms 或 4ms)一定会触发调度。
idle 任务的立即抢占
// kernel/sched/core.c:1124-1125
if (is_idle_task(curr) && tif == TIF_NEED_RESCHED_LAZY)
tif = TIF_NEED_RESCHED;
idle 任务总是使用 TIF_NEED_RESCHED,因为 idle 任务不应该被延迟调度。当一个非 idle 任务变得可运行时,应该立即停止 idle 循环。
Lazy Preemption 的设计动机
传统的 CONFIG_PREEMPT_NONE 模式虽然吞吐量高,但在某些场景下延迟过高。而 CONFIG_PREEMPT 模式虽然延迟低,但引入了不可忽视的开销(每个 preempt_enable() 都需要检查调度)。
Lazy preemption 提供了中间方案:
- 调度器设置 lazy 标志(而非立即抢占标志)
- 内核代码路径中的
preempt_enable()不检查 lazy 标志 - 只有在返回用户态时才检查 lazy 标志并触发调度
- 周期时钟将 lazy 标志升级为立即抢占,保证最大延迟不超过一个时钟周期
这使得 lazy preemption 的吞吐量接近 PREEMPT_NONE,同时将最坏情况延迟限制在一个时钟周期内(通常 1-4ms)。
13.4.10 抢占与 __schedule 的内部机制
__schedule() 是所有调度入口最终调用的核心函数。它在 kernel/sched/core.c:6748 定义,完整流程涉及抢占计数、任务状态管理和上下文切换。
// kernel/sched/core.c:6748
static void __sched notrace __schedule(int sched_mode)
{
struct task_struct *prev, *next;
bool preempt = sched_mode > SM_NONE;
bool is_switch = false;
unsigned long *switch_count;
unsigned long prev_state;
struct rq_flags rf;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
schedule_debug(prev, preempt);
local_irq_disable();
rcu_note_context_switch(preempt);
migrate_disable_switch(rq, prev);
rq_lock(rq, &rf);
smp_mb__after_spinlock();
调度模式对任务状态的影响
// kernel/sched/core.c:6806-6823
switch_count = &prev->nivcsw;
// 仅将 SM_PREEMPT 视为真正的抢占
preempt = sched_mode == SM_PREEMPT;
prev_state = READ_ONCE(prev->__state);
if (sched_mode == SM_IDLE) {
if (!rq->nr_running && !scx_enabled()) {
next = prev;
rq->next_class = &idle_sched_class;
goto picked;
}
} else if (!preempt && prev_state) {
// 非抢占且任务状态非 RUNNING: 尝试将任务从运行队列移除
// 调用 try_to_block_task() 或 block_task()
}
nivcsw (non-idle voluntary context switches) 和 nvcsw (voluntary context switches) 是任务的上下文切换统计。当调度不是抢占(preempt 为 false)时,更新 nvcsw(主动切换)。
上下文切换执行
// kernel/sched/core.c:6891-6892
/* Also unlocks the rq: */
rq = context_switch(rq, prev, next, &rf);
context_switch() 返回新 CPU 的 rq(在 NUMA 系统上任务可能迁移到另一个 CPU 的运行队列)。在 context_switch() 内部,switch_to() 完成寄存器和栈的切换后,finish_task_switch() 释放 rq 锁。
__schedule 的内存屏障
__schedule() 中有两处关键的内存屏障:
-
smp_mb__after_spinlock()(第 6799 行):在获取rq->lock之后立即执行全内存屏障。这与try_to_wake_up()中的屏障配对,确保prev->state的写入在获取锁之前可见。 -
write_cr3()在switch_mm_irqs_off()中:CR3 写入本身是 serializing 操作,充当membarrier系统调用所需的全内存屏障。
这两处屏障共同保证了 membarrier() 系统调用的正确性:当进程 A 调用 membarrier() 时,它可以确保所有其他 CPU 上的进程在返回用户态之前执行了全内存屏障。
13.4.11 migrate_disable 与 PREEMPT_RT
在 RT 内核上,由于自旋锁不再禁用抢占,持有锁的代码可以被迁移到另一个 CPU。但许多 per-CPU 数据操作假设不会发生迁移。migrate_disable() 解决了这个问题:
// include/linux/preempt.h 中的注释 (370-425 行)
migrate_disable() 禁用任务迁移而不禁用抢占。任务仍然可以被抢占,但不会被推送到另一个 CPU。这使得 RT 内核可以:
- 保持 per-CPU 操作的正确性
- 允许更高优先级的任务抢占当前任务
- 减少优先级反转的持续时间
migrate_disable() 的实现维护一个 per-task 的迁移计数。当计数非零时,调度器不会将任务迁移到其他 CPU。migrate_enable() 递减计数,如果降为零且任务有待处理的迁移请求,则执行迁移。
13.4.12 preempt_count 与调试
内核提供了多个调试配置选项来验证 preempt_count 的正确使用:
CONFIG_DEBUG_PREEMPT: 在 preempt_count_add()/preempt_count_sub() 中添加合法性检查。例如,检查 preempt_count 不会下溢(变为负值),检查在正确的 CPU 上操作 per-CPU 变量。
CONFIG_TRACE_PREEMPT_TOGGLE: 在抢占状态改变时触发 tracepoint,用于 ftrace/trace-cmd 分析抢占延迟。
CONFIG_PROVE_LOCKING: lockdep 验证锁获取/释放路径中 preempt_count 的一致性。例如,验证在中断上下文中没有获取可能睡眠的锁。
in_atomic() 陷阱:
// include/linux/preempt.h:184
#define in_atomic() (preempt_count() != 0)
in_atomic() 检查当前是否在原子上下文中。但此宏的注释明确警告:
"WARNING: this macro cannot always detect atomic context; in particular, it cannot know about held spinlocks in non-preemptible kernels."
在 CONFIG_PREEMPT_NONE 内核中,spin_lock() 不修改 preempt_count,因此持有自旋锁时 in_atomic() 返回 false。这意味着 in_atomic() 不能可靠地用于判断是否可以睡眠。正确的做法是使用 might_sleep() 和 lockdep 进行调试。