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/irqrestore
  • rq:当前 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() 返回后,有两种情况:

  1. 返回 true:任务成功阻塞,已从运行队列移除(p->on_rq = 0)。此时 prev 不会参与 pick_next_task() 的选择。
  2. 返回 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 刷新 IPI
  • next->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();

主要步骤:

  1. FPU 切换:switch_fpu(prev_p, cpu) -- 保存 prev 的浮点/SSE/AVX 状态,准备加载 next 的状态(实际加载延迟到 next 首次使用 FPU 时,即 "lazy FPU" 策略或 eager 切换取决于配置)

  2. FS/GS 段保存:save_fsgs(prev_p) -- 保存 prev 的 FS/GS base 地址

  3. TLS 加载:load_TLS(next, cpu) -- 将 next 的 TLS(Thread Local Storage)描述符加载到 GDT(Global Descriptor Table)中

  4. 段寄存器切换: - savesegment(es, prev->es) / loadsegment(es, next->es) -- ES 和 DS 段寄存器 - x86_fsgsbase_load(prev, next) -- FS/GS base 地址加载(使用 FSGSBASE 指令如果可用,否则通过 MSR)

  5. PKRU 切换:x86_pkru_load(prev, next) -- 加载 next 的 Protection Keys for Userspace 寄存器

  6. 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 锁)。

  7. 栈指针更新:update_task_stack(next_p) -- 更新 TSS(Task State Segment)中的 sp0(内核栈入口点),确保中断/异常发生时使用正确的内核栈

  8. 其他状态: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 状态),执行最终的资源释放:

  1. task_dead:调度类的死亡回调,用于清理调度类内部的资源(如 DL 调度类的带宽回收)
  2. sched_ext_dead:通知 BPF 调度器扩展该任务已死亡。必须在 cgroup_task_dead() 之前调用,防止 cgroup 在 SCX 调度器仍可见该任务时被移除
  3. cgroup_task_dead:从 cgroup 中移除该任务
  4. put_task_stack:释放任务的内核栈
  5. 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() 的正常路径进入调度。相反:

  1. 父进程在 copy_process() 中创建子进程的 task_struct 和内核栈
  2. 子进程的栈被初始化为包含 ret_from_fork_asm 的返回地址
  3. 当调度器选择子进程时,switch_to() 切换到子进程的栈,子进程从 ret_from_fork_asm 开始执行
  4. 子进程的 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),竞争的完整解决方案涉及以下屏障:

  1. 任务 A:__set_current_state(TASK_INTERRUPTIBLE) 通过 smp_store_mb() 写入状态并执行 full barrier
  2. 任务 A:schedule() -> __schedule() -> rq_lock() + smp_mb__after_spinlock() 提供第二个 full barrier
  3. 任务 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 函数内部切换栈指针,编译器生成的所有栈引用将指向错误的内存位置。因此,内核使用一段精心编写的汇编代码来完成栈指针的保存与恢复。

具体来说,汇编层负责:

  1. 保存 callee-saved 寄存器到旧任务的栈上(调用约定规定这些寄存器由被调用者保存)
  2. 将当前栈指针保存到 prev->thread.sp
  3. 从 next->thread.sp 加载新栈指针
  4. 从新栈上恢复 callee-saved 寄存器
  5. 跳转到 C 函数完成其余的系统寄存器切换

C 函数层负责:

  1. FPU/SIMD 状态切换
  2. 段寄存器切换(x86 特有)
  3. TLS 描述符加载
  4. 调试寄存器切换
  5. 安全特性相关的 MSR 切换
  6. 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)。该函数处理:

  1. IO 位图切换 (switch_to_bitmap): 如果任务通过 ioperm() 设置了 I/O 端口访问权限,需更新 TSS 中的 I/O 位图。
  2. 单步调试 (_TIF_BLOCKSTEP): 设置/清除 MSR_IA32_DEBUGCTLMSR 的 BTF (Branch Trap Flag) 位,启用分支单步调试。
  3. RDTSC 禁用 (_TIF_NOTSC): 通过 CR4.TSD 位禁止/允许用户态读取时间戳计数器。
  4. CPUID 故障注入 (_TIF_NOCPUID): 允许拦截 CPUID 指令。
  5. 推测执行控制 (_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) 表示屏障在内部共享域内生效。注释解释了三个原因:

  1. membarrier 系统调用要求:membarrier() 需要在所有 CPU 上发出全内存屏障。
  2. TLB 维护:确保任何挂起的 TLB 或缓存维护操作完成。
  3. 页表遍历器可见性:确保对页表的写操作对硬件页表遍历器 (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(&current_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(&current_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 的返回值调用),负责完成切换的善后工作:

  1. 释放 prev 任务持有的 rq 锁
  2. 如果 prev 是用户进程且切换到了内核线程,执行 mmdrop_lazy_tlb() 释放 active_mm 引用
  3. 如果 prev 进程已死亡 (TASK_DEAD),释放其 task_struct 引用
  4. 完成 RCU context switch 通知
  5. 触发 sched_out preempt 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)

调度只发生在以下时机:

  1. 返回用户态:系统调用、中断、异常返回用户态前检查 TIF_NEED_RESCHED
  2. 显式调度点:schedule()、cond_resched()、schedule_timeout()
  3. 睡眠: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 机制在运行时控制具体行为。

抢占检查发生在以下时机:

  1. preempt_enable(): 当 preempt_count 降为 0 且 TIF_NEED_RESCHED 已设置
  2. 中断返回内核态: preempt_schedule_irq() 在中断处理完成后检查
  3. 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 允许在运行时切换抢占模型,无需重启系统。实现基于两种机制:

  1. Static Call (CONFIG_HAVE_PREEMPT_DYNAMIC_CALL): 通过修改函数指针直接替换函数调用目标
  2. 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() 会导致严重的系统不稳定,原因如下:

  1. 没有明确的 task_struct: 硬中断处理程序不在任何特定任务的上下文中执行,current 指向被中断的任务。如果在中断中调度,被中断任务的 preempt_count 仍包含中断计数,导致状态不一致。

  2. 中断返回路径假设不变: 中断返回代码假设返回到被中断的任务,如果发生了调度,栈帧和寄存器状态不匹配。

  3. 栈溢出风险: 中断可能嵌套在已有栈上,调度会引入额外的栈使用。

内核通过 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() 中有两处关键的内存屏障:

  1. smp_mb__after_spinlock()(第 6799 行):在获取 rq->lock 之后立即执行全内存屏障。这与 try_to_wake_up() 中的屏障配对,确保 prev->state 的写入在获取锁之前可见。

  2. 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 内核可以:

  1. 保持 per-CPU 操作的正确性
  2. 允许更高优先级的任务抢占当前任务
  3. 减少优先级反转的持续时间

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 进行调试。