Context Switch Mechanics: switch_to and switch_mm
When the scheduler has decided that task
nextshould run in place of taskprev, the abstract decision must become a physical reality: the CPU has to start executingnext’s instructions onnext’s kernel stack, withnext’s address space mapped andnext’s register state restored. That transformation is the context switch, and on Linux/x86-64 it has two distinct halves bridged by one function,context_switch(). First the address space is switched —switch_mm_irqs_off()reloads the page-table base register (CR3) so virtual addresses now resolve throughnext’s page tables. Then the CPU register and stack state is switched —switch_to()→__switch_to_asmsavesprev’s callee-saved registers, swaps the kernel stack pointer (RSP), and__switch_tofinishes the per-task hardware state (segments, TLS, FPU bookkeeping). Critically,context_switch()runs holdingrq->lockwith interrupts disabled, and the matchingfinish_task_switch()runs onnext’s stack to release that lock and clean up afterprev(kernel/sched/core.c v6.12). This note traces the path as of Linux 6.12 LTS (released 2024-11-17, per kernel.org releases), x86-64.
All code here is 6.12 LTS, x86-64. Per-CPU and symbol names (pcpu_hot.current_task, pcpu_hot.top_of_stack, the inactive_task_frame layout) are as they appear in 6.12 and have changed across releases; do not assume they match other kernels.
Mental Model
A context switch is a handoff of a single CPU between two tasks, and the trickiest thing about it is that the function performing the switch returns into a different task than the one that called it. The kernel address space is shared across all tasks, so kernel code keeps running across the boundary — but the user mapping, the stack, and the registers all change at the swap point.
flowchart TB SCHED["__schedule() picks next,<br/>holds rq->lock, IRQs off"] --> CS["context_switch(rq, prev, next)"] CS --> MM{"next->mm == NULL?<br/>(kernel thread?)"} MM -->|"yes: lazy TLB"| LAZY["borrow prev->active_mm,<br/>no CR3 reload"] MM -->|"no: user task"| SWMM["switch_mm_irqs_off():<br/>load CR3 / PCID,<br/>switch address space"] LAZY --> SW["switch_to(prev, next, prev)"] SWMM --> SW SW --> ASM["__switch_to_asm:<br/>push callee-saved regs of prev,<br/>swap RSP (TASK_threadsp),<br/>pop next's callee-saved regs"] ASM --> C["__switch_to (C):<br/>segments, TLS, FPU bookkeeping,<br/>set current/top_of_stack to next"] C --> RESUME["execution now on next's stack<br/>as 'next'; returns into the schedule()<br/>next made long ago"] RESUME --> FIN["finish_task_switch(prev):<br/>recompute this_rq(), drop rq->lock,<br/>mmdrop prev_mm, reap if prev died"]
The two-phase switch. What it shows: the address-space swap (switch_mm_irqs_off, skipped for kernel threads via the lazy-TLB shortcut) precedes the register/stack swap (switch_to → __switch_to_asm → __switch_to); after the stack swap the CPU is running as next, and finish_task_switch() cleans up on next’s behalf. The insight to take: the line switch_to(prev, next, prev) is where one task “becomes” another — the same C statement that runs as prev returns as next (much later, when next is itself switched away from and back).
The Entry: context_switch()
The whole switch is orchestrated by context_switch(), called from the core scheduler (see The Core Scheduler and __schedule). It is entered with rq->lock held and interrupts disabled:
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);
/* ... address-space switch (below) ... */
prepare_lock_switch(rq, next, rf);
switch_to(prev, next, prev); /* register + stack swap */
barrier();
return finish_task_switch(prev);
}From core.c v6.12. prepare_task_switch() runs sched-out notifiers, perf accounting, and rseq bookkeeping. prepare_lock_switch() records that the lock is being handed to next (so lockdep and the on_cpu state stay consistent). Then switch_to() does the irreversible swap, barrier() forbids the compiler from moving memory accesses across it, and finish_task_switch() — running now as next — tidies up.
Phase One: switch_mm and the Address-Space Swap
The address-space decision turns on whether next is a user task or a kernel thread, encoded in next->mm:
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;
} 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;
}
}From core.c v6.12. Two fields drive this: mm is the task’s real address space (NULL for kernel threads, which have no user mappings) and active_mm is the address space currently loaded on the CPU. Per the kernel’s active_mm documentation, the foundational rule is: “for a process with a real address space (ie tsk→mm is non-NULL) the active_mm obviously always has to be the same as the real one.”
Switching to a kernel thread (!next->mm) — the lazy-TLB shortcut. A kernel thread touches only kernel memory, which is mapped identically in every address space. Reloading CR3 would needlessly flush user TLB entries for no benefit, so the kernel borrows the outgoing task’s address space: next->active_mm = prev->active_mm, and no switch_mm is called at all. The mmgrab_lazy_tlb() takes a lightweight reference (on mm_count, not mm_users) so the borrowed mm_struct cannot be freed while the kernel thread “lazily” holds it. This is the lazy TLB mechanism — covered in depth in The mm_struct and Process Address Space (the active_mm borrow) and The Translation Lookaside Buffer and TLB Shootdowns (why skipping the flush is safe and how shootdowns reach lazy-TLB CPUs).
Switching to a user task (next->mm non-NULL). Here switch_mm_irqs_off(prev->active_mm, next->mm, next) does the real address-space switch: on x86-64 it loads next->mm’s top-level page-table physical address into CR3. With PCID (Process-Context Identifiers) the write tags the new address space rather than flushing the whole TLB, so entries for both old and new address spaces can coexist and a later switch back is cheap. The mechanics of CR3/PCID, lazy-TLB tracking, and cross-CPU invalidation belong to The Translation Lookaside Buffer and TLB Shootdowns and are not re-derived here. If the previous task was a kernel thread (!prev->mm), the borrowed active_mm is no longer needed and is stashed in rq->prev_mm to be dropped later, outside the run-queue lock, in finish_task_switch().
The membarrier_switch_mm() call and the comment around it encode an ordering guarantee for the membarrier() syscall: there must be a full memory barrier between updating rq->curr and returning to userspace; switch_mm() supplies it on the switch path, and finish_task_switch()’s mmdrop supplies it when prev->active_mm == next->mm.
Phase Two: switch_to and the Register/Stack Swap
switch_to is a macro, and its three-argument shape is the single most important detail of the whole mechanism:
#define switch_to(prev, next, last) \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)From arch/x86/include/asm/switch_to.h v6.12. Why three arguments and not two? When a task is switched out, its kernel stack — including the local variables of the very schedule() call that switched it away — is frozen in place. When the scheduler later switches it back in, execution resumes exactly there, with those frozen locals restored. But the world has moved on: the task that ran immediately before this resume is almost never the same task this one switched away from. The resumed task needs to know who actually ran last so finish_task_switch() can clean up after the right prev. That is what last carries: __switch_to_asm returns (in %rax) the task that was running just before the resume, and the macro assigns it to last. In context_switch() the call is switch_to(prev, next, prev) — so on resumption the local prev is overwritten with the genuine previous task, and finish_task_switch(prev) then operates on the correct one.
The architecture-specific swap is __switch_to_asm, hand-written assembly:
SYM_FUNC_START(__switch_to_asm)
/* Save callee-saved registers (order matches inactive_task_frame) */
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* switch stack */
movq %rsp, TASK_threadsp(%rdi) /* save prev's RSP into prev->thread.sp */
movq TASK_threadsp(%rsi), %rsp /* load next's RSP from next->thread.sp */
#ifdef CONFIG_STACKPROTECTOR
movq TASK_stack_canary(%rsi), %rbx
movq %rbx, PER_CPU_VAR(fixed_percpu_data + FIXED_stack_canary)
#endif
FILL_RETURN_BUFFER %r12, RSB_CLEAR_LOOPS, X86_FEATURE_RSB_CTXSW
/* restore next's callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
SYM_FUNC_END(__switch_to_asm)From arch/x86/entry/entry_64.S v6.12. %rdi is prev, %rsi is next (System V calling convention). The function:
- Pushes
prev’s callee-saved registers (rbp, rbx, r12–r15) ontoprev’s kernel stack. Only callee-saved registers are saved here; caller-saved ones were already spilled by the C compiler around theswitch_tocall. The push order exactly mirrorsstruct inactive_task_frame, which is what an inactive task’sthread.sppoints at. - Swaps the kernel stack —
movq %rsp, TASK_threadsp(%rdi)parksprev’s stack pointer inprev->thread.sp;movq TASK_threadsp(%rsi), %rsploadsnext’s. This single instruction is the moment the CPU stops beingprevand starts beingnext— from here, every push/pop andretwalksnext’s stack. - Reloads the stack-protector canary for
next(per-task canary) and runsFILL_RETURN_BUFFERto overwrite the Return Stack Buffer, a Spectre-class mitigation applied on context switch. - Pops
next’s callee-saved registers — but these are the values that were pushed whennextwas last switched out, now restored fromnext’s stack. jmp __switch_to— a tail call into the C continuation, which will eventuallyretback to wherevernextwas when it last calledschedule().
The C half, __switch_to, handles the remaining per-task hardware state:
__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
/* ... */
if (!test_tsk_thread_flag(prev_p, TIF_NEED_FPU_LOAD))
switch_fpu_prepare(prev_p, cpu);
save_fsgs(prev_p);
load_TLS(next, cpu);
/* ES/DS, FS/GS base, PKRU ... */
raw_cpu_write(pcpu_hot.current_task, next_p);
raw_cpu_write(pcpu_hot.top_of_stack, task_top_of_stack(next_p));
switch_fpu_finish(next_p);
update_task_stack(next_p); /* reload sp0 (entry-stack pointer) */
/* ... */
return prev_p;
}From arch/x86/kernel/process_64.c v6.12. It saves prev’s FS/GS bases and segment selectors, loads next’s Thread-Local Storage GDT entries (load_TLS), restores FS/GS bases and the PKRU (memory-protection-keys) register, updates the per-CPU current_task and top_of_stack pointers to next, and reloads sp0 (the kernel entry-stack pointer the CPU uses on the next user→kernel transition). The returned prev_p becomes __switch_to_asm’s return value in %rax — feeding the last mechanism described above.
FPU is deferred, not eagerly reloaded. A common misconception is that __switch_to saves and restores the full floating-point/SIMD register file. It does not. switch_fpu_prepare() saves prev’s FPU state to memory only if prev doesn’t already have the TIF_NEED_FPU_LOAD flag set, and switch_fpu_finish() merely sets up so that next’s FPU registers are reloaded lazily on return to userspace, not here. This avoids touching the (large) AVX/AVX-512 state for tasks that never use it before blocking again.
Phase Three: finish_task_switch()
After switch_to returns, the CPU is executing as next, on next’s stack — and next long ago called schedule(), so its restored locals reflect that call site, possibly on a different CPU than where it last ran. finish_task_switch() reconciles all of this:
static struct rq *finish_task_switch(struct task_struct *prev)
__releases(rq->lock)
{
struct rq *rq = this_rq(); /* recompute: prev may be on another CPU */
struct mm_struct *mm = rq->prev_mm;
unsigned int prev_state;
/* ... preempt_count sanity check ... */
rq->prev_mm = NULL;
prev_state = READ_ONCE(prev->__state);
vtime_task_switch(prev);
finish_task(prev); /* clears prev->on_cpu */
tick_nohz_task_switch();
finish_lock_switch(rq); /* drops rq->lock */
/* ... */
if (mm) {
membarrier_mm_sync_core_before_usermode(mm);
mmdrop_lazy_tlb_sched(mm); /* drop the borrowed kernel-thread mm */
}
if (unlikely(prev_state == TASK_DEAD)) {
if (prev->sched_class->task_dead)
prev->sched_class->task_dead(prev);
put_task_stack(prev);
put_task_struct_rcu_user(prev); /* final reference if prev exited */
}
return rq;
}From core.c v6.12. The header comment states the key fact plainly: “The context switch have flipped the stack from under us … ‘prev == current’ is still correct but we need to recalculate this_rq because prev may have moved to another CPU.” So:
this_rq()is recomputed rather than reused — the run queue this code runs on may not be the oneprevwas switched out on.- The run-queue lock handoff completes.
context_switch()was entered withrq->lockheld byprev;finish_task_switch()(running asnext) releases it viafinish_lock_switch(). This is the lock handoff across the switch: one task acquires the lock, a different task releases it.finish_task()clearsprev->on_cpu, the barrier that letsprevbe safely picked up by a wakeup on another CPU. - The deferred
prev_mmis dropped. If phase one stashed a borrowed kernel-threadmminrq->prev_mm,mmdrop_lazy_tlb_sched()releases that reference now — deliberately outside the run-queue lock, becausemmdropcan do real work and “doing it with the lock held can cause deadlocks.” - A dead
previs reaped. IfprevsetTASK_DEADand calledschedule()for the last time, this is where its stack is freed (put_task_stack) and its finaltask_structreference is dropped via RCU. A task literally cannot free its own stack while running on it — so the next task does it. (See Process Exit wait and Zombie Reaping for the surrounding lifecycle.)
Newly Forked Tasks and ret_from_fork
A brand-new task has never run, so it has no frozen schedule() to return into. copy_thread() hand-builds an inactive_task_frame whose ret_addr points at ret_from_fork_asm, so the first time the scheduler switches to the new task, __switch_to_asm’s final ret lands there:
SYM_CODE_START(ret_from_fork_asm)
/* ... */
movq %rax, %rdi /* prev */
movq %rsp, %rsi /* regs */
movq %rbx, %rdx /* fn (kernel-thread function, NULL for user) */
movq %r12, %rcx /* fn_arg */
call ret_from_fork
/* ... */
jmp swapgs_restore_regs_and_return_to_usermode
SYM_CODE_END(ret_from_fork_asm)From entry_64.S v6.12. The new task first calls schedule_tail() (which calls finish_task_switch() for it — completing the lock handoff its first time on a CPU), then either runs a kernel-thread function or returns to userspace. The %rbx/%r12 registers carry the kernel-thread function and argument, planted by kthread_frame_init() (see switch_to.h).
Failure Modes and Subtleties
- Returning into the wrong task. Forgetting the three-argument
lasttrick — usingprevdirectly after the switch instead of the reassigned value — would havefinish_task_switch()clean up the task this one switched away from rather than the one that actually ran before the resume, corruptingon_cpustate and reference counts. The macro exists precisely to prevent this. - Interrupts and the rq lock. The switch runs with IRQs off and
rq->lockheld; the lock is not released untilfinish_task_switch()runs asnext. A bug that takes the rq lock recursively, or enables interrupts mid-switch, deadlocks or corrupts the run queue. preempt_countinvariant.finish_task_switch()WARNs ifpreempt_count() != 2*PREEMPT_DISABLE_OFFSET— the switch is entered with preemption disabled twice (once byschedule()’s wrapper, once by the rq lock); a mismatch signals a leaked or double-decremented count somewhere in the switch path.- Lazy-TLB shootdown window. Because a kernel thread keeps a user
mmloaded inCR3while running, a CPU doing a TLB shootdown for thatmmmust still reach this “lazy” CPU. The accounting inmm_count(and the IPI logic) that makes this correct is detailed in The Translation Lookaside Buffer and TLB Shootdowns. - Cost. A switch between tasks of different address spaces pays the
CR3reload (mitigated by PCID) and potential TLB pressure on top of the register/stack swap; a switch within the same address space (two threads of one process) skips most of phase one. This is why thread switches are cheaper than process switches, and why kernel-thread switches (lazy TLB) are cheaper still.
See Also
- The Core Scheduler and __schedule — the caller;
__schedule()picksnextand then invokescontext_switch() - The Translation Lookaside Buffer and TLB Shootdowns —
CR3/PCID internals, lazy-TLB invalidation, and why skipping the flush for kernel threads is safe - The mm_struct and Process Address Space —
mmvsactive_mm, the lazy-TLBmm_countreference, kernel-thread address spaces - Migration the Stop Class and the Migration Thread — sibling; switching into the stopper that evicts a migrating task is one of these context switches
- Kernel Preemption Models — when and where a switch is allowed to happen (the preemption points that call
schedule()) - Per-CPU Run Queues and struct rq — holds
rq->lock(handed across the switch) andrq->prev_mm(the deferred mm drop) - Process Exit wait and Zombie Reaping — the
TASK_DEADreaping thatfinish_task_switch()performs for an exitingprev - Linux Process Scheduling MOC — parent map