Context Switch Mechanics: switch_to and switch_mm

When the scheduler has decided that task next should run in place of task prev, the abstract decision must become a physical reality: the CPU has to start executing next’s instructions on next’s kernel stack, with next’s address space mapped and next’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 through next’s page tables. Then the CPU register and stack state is switched — switch_to()__switch_to_asm saves prev’s callee-saved registers, swaps the kernel stack pointer (RSP), and __switch_to finishes the per-task hardware state (segments, TLS, FPU bookkeeping). Critically, context_switch() runs holding rq->lock with interrupts disabled, and the matching finish_task_switch() runs on next’s stack to release that lock and clean up after prev (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 tskmm 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:

  1. Pushes prev’s callee-saved registers (rbp, rbx, r12–r15) onto prev’s kernel stack. Only callee-saved registers are saved here; caller-saved ones were already spilled by the C compiler around the switch_to call. The push order exactly mirrors struct inactive_task_frame, which is what an inactive task’s thread.sp points at.
  2. Swaps the kernel stackmovq %rsp, TASK_threadsp(%rdi) parks prev’s stack pointer in prev->thread.sp; movq TASK_threadsp(%rsi), %rsp loads next’s. This single instruction is the moment the CPU stops being prev and starts being next — from here, every push/pop and ret walks next’s stack.
  3. Reloads the stack-protector canary for next (per-task canary) and runs FILL_RETURN_BUFFER to overwrite the Return Stack Buffer, a Spectre-class mitigation applied on context switch.
  4. Pops next’s callee-saved registers — but these are the values that were pushed when next was last switched out, now restored from next’s stack.
  5. jmp __switch_to — a tail call into the C continuation, which will eventually ret back to wherever next was when it last called schedule().

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:

  1. this_rq() is recomputed rather than reused — the run queue this code runs on may not be the one prev was switched out on.
  2. The run-queue lock handoff completes. context_switch() was entered with rq->lock held by prev; finish_task_switch() (running as next) releases it via finish_lock_switch(). This is the lock handoff across the switch: one task acquires the lock, a different task releases it. finish_task() clears prev->on_cpu, the barrier that lets prev be safely picked up by a wakeup on another CPU.
  3. The deferred prev_mm is dropped. If phase one stashed a borrowed kernel-thread mm in rq->prev_mm, mmdrop_lazy_tlb_sched() releases that reference now — deliberately outside the run-queue lock, because mmdrop can do real work and “doing it with the lock held can cause deadlocks.”
  4. A dead prev is reaped. If prev set TASK_DEAD and called schedule() for the last time, this is where its stack is freed (put_task_stack) and its final task_struct reference 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 last trick — using prev directly after the switch instead of the reassigned value — would have finish_task_switch() clean up the task this one switched away from rather than the one that actually ran before the resume, corrupting on_cpu state and reference counts. The macro exists precisely to prevent this.
  • Interrupts and the rq lock. The switch runs with IRQs off and rq->lock held; the lock is not released until finish_task_switch() runs as next. A bug that takes the rq lock recursively, or enables interrupts mid-switch, deadlocks or corrupts the run queue.
  • preempt_count invariant. finish_task_switch() WARNs if preempt_count() != 2*PREEMPT_DISABLE_OFFSET — the switch is entered with preemption disabled twice (once by schedule()’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 mm loaded in CR3 while running, a CPU doing a TLB shootdown for that mm must still reach this “lazy” CPU. The accounting in mm_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 CR3 reload (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