The Context Switch
July 16, 2026 ยท View on GitHub
Switching into a process means one of three things: entering it for the very first
time, which drops fresh to ring 3 at its ELF entry point; resuming a user context that
was preempted; or resuming a kernel context, a syscall that blocked and now continues.
The dispatcher decides which, and getting the priority wrong, resuming a stale user
frame when a live kernel continuation exists, is a real bug the code specifically
guards against. This page documents the dispatch, the first-entry path, and the per-CPU
state each touches. The code is under src/arch/x86_64/context/switch/, and it is the
x86_64 realisation of an arch-neutral operation.
The three ways in
A process can be entered three ways, and the PCB carries the state for each. A
first entry consumes pending_user_entry, the frame built at creation. A user resume
restores saved_user_context, the snapshot the trap path wrote when the process was
preempted. A kernel resume restores a saved interrupt context for a pid that was inside
the kernel, typically blocked in a syscall, when it was scheduled out.
The dispatcher
switch_to_user_pcb_x86_64 (src/arch/x86_64/context/switch/dispatch.rs:39) picks the
right one in a fixed order:
switch_to_user_pcb_x86_64(pid):
pcb = find_by_pid(pid), else return
if try_first_entry(pcb, pid) -> done
if pid has a saved interrupt (kernel) context:
resume_kernel_thread(pcb, pid) -> done
if try_resume(pcb, pid) -> done (user context)
resume_kernel_thread(pcb, pid) (fallback)
The order encodes a correctness rule the source states directly. A pid that blocked
inside a syscall can hold both a stale user-mode trap snapshot and a live kernel resume
context at once. The kernel context must win: resuming the stale user frame would
re-enter user mode at some arbitrary old RIP and skip the syscall continuation
entirely. So the dispatcher checks for a saved kernel context before it tries the user
resume, and only falls through to the user path when there is no kernel context to
continue. First entry is tried first because a freshly created process has a
pending_user_entry and nothing else.
First entry
try_first_entry (src/arch/x86_64/context/switch/first_entry.rs:28) is the one-time
transition to ring 3, and it returns false if the process has no pending entry so the
dispatcher moves on:
try_first_entry(pcb, pid):
frame = pcb.pending_user_entry.take(), else return false
kstack = pcb.kernel_stack_top
if kstack == 0 -> state = Terminated(-1), return true
install kstack as the TSS kernel stack for this CPU (gdt::set_kernel_stack)
on failure -> Terminated(-1), return true
set the per-CPU kernel stack
if pcb.cr3 != 0:
switch_to_process_address_space(pid)
on failure (a thread with no ASID entry) load the inherited CR3 directly
state = Running; CURRENT_PID = pid; reset the time slice to DEFAULT
FPU: restore saved state if any, else init_fpu
return_to_usermode(frame) iretq to ring 3
Taking pending_user_entry makes the first entry one-shot: the frame is consumed, so a
later switch into the same process goes through the resume paths instead. The kernel
stack is a precondition, not an option: kernel_stack_top of zero means no kernel stack
was allocated, and rather than enter user mode with nowhere for a syscall or interrupt
to land, the process is marked Terminated(-1). When a stack is present it is installed
as this CPU's TSS kernel stack (RSP0), which is where the CPU switches to on the next
trap from ring 3.
The address-space switch carries a thread special case. switch_to_process_address_space
looks the process up by pid to find its ASID and load its CR3, but a thread shares its
parent's address space and has no ASID entry of its own, so that lookup misses; the code
then loads the inherited CR3 directly. Only after the stack and address space are in
place does the process become Running, become this CPU's current pid, and get a fresh
time slice, and then return_to_usermode executes the iretq that drops to ring 3 at
the frame's entry point.
Floating point
Just before entering user mode, first entry restores the process's saved FPU and SIMD
state if it has any, and otherwise calls init_fpu to bring the unit up from a clean
state (first_entry.rs:61). The clean init matters: a wrong initial MXCSR leaves SSE
exceptions unmasked and can trap every floating-point capsule, so init_fpu sets a
correct control word rather than leaving a zeroed one. The resume paths restore the
per-process FPU state the same way.
Multi-architecture
The dispatch and first-entry logic here are x86_64. The state they consume,
pending_user_entry and saved_user_context, is defined per architecture in the PCB:
on x86_64 the entry is the iretq five-tuple and the snapshot is fifteen general
registers plus that frame; on aarch64 and riscv64 they carry that architecture's entry
and trap shape, and those backends have their own equivalents of return_to_usermode
and the resume path. The scheduler that decides which pid to switch to, and calls into
here, is arch-neutral; this file is where its decision becomes an actual ring transition
on x86_64.
Security analysis
A context switch is a privilege boundary as much as a scheduling event: it decides where the CPU lands next and in which address space, so a mistake here reads or runs one capsule's state under another capsule's authority. Three properties keep it honest.
No privilege escalation on entry. The one path that drops to ring 3 is try_first_entry
(first_entry.rs:28), and it enters user mode only through return_to_usermode, an iretq over a
frame the process was created with, never a synthesised one. Before that iretq it makes the kernel
stack a hard precondition: kernel_stack_top of zero means no landing pad for the next trap, and rather
than enter ring 3 with nowhere for a syscall or interrupt to return, the process is marked
Terminated(-1) and the switch aborts. The TSS RSP0 is set to that stack (gdt::set_kernel_stack)
before the transition, so a trap from the new ring-3 code lands on a kernel stack the kernel chose, not
on whatever the capsule left in RSP.
Address-space isolation is re-established before the process runs. try_first_entry loads the
process's own page-table root, switch_to_process_address_space(pid) for a real address space
(api/address_space.rs:40) or the inherited CR3 loaded directly for a thread, and only then sets state
Running and does the iretq. The CR3 is switched while still in the kernel half, so the incoming
capsule sees only its own low-half user mappings plus the shared high-half kernel; it cannot observe the
outgoing capsule's user pages because the root that mapped them is no longer live. A thread is the one
deliberate exception: it shares its parent's CR3 on purpose, which the resume comment states outright.
The kernel continuation cannot be skipped. The dispatch order in
switch_to_user_pcb_x86_64 (dispatch.rs:39) checks INTERRUPT_SAVED_CONTEXTS for a saved kernel
context before it tries the user resume, because a pid that blocked inside a syscall can hold both a
stale user-mode trap snapshot and a live kernel resume context at once. Resuming the stale user frame
would re-enter ring 3 at an arbitrary old RIP and skip the syscall continuation, which is the bug the
ordering exists to prevent. The honest boundary: this file is the x86_64 realisation only. The
FPU/SIMD restore is state-clearing, not a covert-channel scrub, and cross-CPU consistency of the saved
contexts rests on the map's own locking, not on anything this path adds.
Debugging the context switch
The dispatcher emits a [DISPATCH] serial trace (dispatch.rs:26) that names which of the three ways
in it took for a given pid: kernel ctx, user ctx, or fallback kernel. The trace is gated to a
handful of pids and the first 64 events, so it is a bring-up aid, not a running log, but it is the first
thing to read when a process is entered wrong. A capsule that was mid-syscall and comes back running at
the wrong instruction is the classic symptom of the priority rule being violated: expect kernel ctx
in the trace, and a user ctx there for a pid that should have resumed in the kernel is the bug. A
process that vanishes the instant it is first scheduled, never reaching its ELF entry, is almost always
the kernel-stack precondition firing: try_first_entry marks it Terminated(-1) when
kernel_stack_top is zero or gdt::set_kernel_stack fails, so the process is dead before the iretq,
and the thing to check is whether creation allocated its kernel stack. A fault taken during the switch
itself, a page fault as the new CR3 loads or the iretq frame is read, points at a corrupt
pending_user_entry or a CR3 that does not map the entry point; because the FPU is brought up from a
clean init_fpu when the process has no saved state (first_entry.rs:61), a floating-point capsule
that traps immediately on entry instead points back at the MXCSR init, not at the switch.
Source map
src/arch/x86_64/context/switch/dispatch.rs the dispatch and its priority rule
src/arch/x86_64/context/switch/first_entry.rs the one-time entry to ring 3
src/arch/x86_64/context/switch/resume.rs resuming a preempted user context
src/arch/x86_64/context/switch/kernel_thread.rs resuming a kernel context
src/process/scheduler/selection/switching.rs the scheduler-side switch
Every reference above is verified against those trees. The PCB fields this path consumes,
pending_user_entry, saved_user_context, kernel_stack_top, cr3, are documented on the
PCB page; the scheduler decision that calls in here is on the
selection and preemption pages; and the
address-space root it loads is set up during creation on the lifecycle page.