Every process on a modern OS runs on a fiction: it owns the whole address space, all 48 bits of it. The MMU enforces that fiction, and the page fault is the moment fiction and physics meet — when the CPU wants a virtual page that has no physical page behind it.
Step 1 — TLB miss and the hardware walk
Your code does mov (%rbx), %eax. The CPU checks the TLB (translation lookaside buffer, ~1-5ns on hit). On a miss, the hardware walks the page table: 4 levels for a 48-bit address (PML4 → PDPT → PD → PT → PTE), each level a memory load that usually hits L2/L3. That walk costs ~20-100ns on cache hits.
Step 2 — the exception
The walk finds a PTE whose present bit is 0. The MMU can't resolve it, so it raises a page fault: on x86, the CPU pushes the faulting address into CR2 and a fault error code onto the stack, then jumps to the kernel's page_fault handler. This is an exception, not an interrupt — the kernel knows exactly which instruction caused it.
Step 3 — VMA lookup and access check
do_page_fault → handle_mm_fault → find_vma(), a lookup in the process's VMA (virtual memory area) red-black tree: is this address mapped at all? Is the access type legal (read on read-only, write on writable)? Three outcomes:
- Address in no VMA →
SIGSEGV. - Write to a read-only, non-COW VMA →
SIGSEGV. - Otherwise → we have work to do.
Note the lock: mmap_lock (formerly mmap_sem) is taken for the VMA lookup, which is why multi-threaded mmap churn under a page-fault storm becomes a bottleneck.
Step 4 — minor fault
The page exists conceptually but not physically. For anonymous memory, the kernel maps the shared zero page first (read-only), then on first write does a COW fault (step 6) that allocates a real, zeroed page. For file-backed memory, the page may already be in the page cache — the kernel wires it into the PTE. Cost: ~0.5-2µs, zero I/O. This is why "minor faults" are mostly harmless — they're the steady-state of lazily-mapped memory.
Step 5 — major fault
The page isn't anywhere in RAM. For a file mapping: allocate a page, submit a bio to the block layer (with readahead — the kernel speculatively reads the next 16-32 pages), wait on the I/O completion (interrupt-driven wakeup), then wire the page in. For anonymous memory swapped out: find the swap slot in the swap cache, read from the swap device, insert the page into the LRU. Cost: 100µs to 10ms — orders of magnitude more than the fault machinery itself. The process sleeps on the page lock while disk works.
Step 6 — copy-on-write
A write fault on a page shared via COW (from fork(), or the zero page): the kernel checks whether the PTE has the COW bit (dirty/accessed bookkeeping + _PAGE_COW on x86-64). If the page has exactly one reference, the kernel just flips it writable — the "refcount one" fast path. Otherwise: allocate a new page, copy 4KB (or 2MB for THP), install a private writable PTE, drop the old reference. Cost: ~1-2µs plus the copy — the price of every fork()-and-write.
Step 7 — return and re-execute
The handler returns via iret; the CPU re-executes the exact instruction that faulted — now it finds the PTE present and the TLB is filled by the walk. To the process, nothing happened at all: no error, no return value, just a few hundred nanoseconds (or milliseconds) of invisible time.
What it costs
- Minor fault: ~0.5-2µs (zeroing, PTE install, TLB fill).
- COW fault: ~1-3µs, dominated by the 4KB copy.
- Major fault: 100µs-10ms — disk latency dominates.
- Fault storms:
mmap/munmapchurn serializes onmmap_lock; a single thread faulting in a 1GB map can starve other threads' address-space operations.
$ /usr/bin/time -v ./cold-start 2>&1 | grep -E "faults"
Minor (reclaiming a frame) page faults: 84,311
Major (requiring I/O) page faults: 1284k minor faults at ~1µs each ≈ 84ms of invisible time; the 12 major faults could be anything from 1ms to a full disk seek. The rule of thumb: minor faults are noise, major faults are the thing you should be optimizing away.