Ashok G

Kernel, Traced · 6.12 LTS, x86_64 · Draft

5 — A page fault

A process touches an address that isn’t mapped yet. What does the kernel do between the CPU trapping and the instruction being retried, and why are there three completely different answers depending on what the address is?

Requirements: the machine from chapter 0.

The path

The trap

Step 1. The CPU raises #PF, with the faulting address in CR2 and an error code saying whether the page was present, whether it was a write, and whether it came from user mode.

Step 2. exc_page_fault (arch/x86/mm/fault.c) reads CR2 and calls handle_page_fault, which calls do_user_addr_fault for user addresses. Kernel addresses go to do_kern_addr_fault.

Step 3. do_user_addr_fault makes its checks. A fault with page faults disabled goes to bad_area_nosemaphore, and the comment says simply “we must not take the fault”. Then it finds the VMA. Since 6.4, x86 first tries per-VMA locking with lock_vma_under_rcu, falling back to lock_mm_and_find_vma (fault.c). A write to a read-only mapping becomes SIGSEGV here. Otherwise it calls handle_mm_fault.

The generic handler

Step 4. handle_mm_fault (mm/memory.c) calls __handle_mm_fault. Accounting by mm_account_fault comes after the work, not before (memory.c).

Step 5. __handle_mm_fault walks the page tables from the top: pgd_offset, p4d_alloc, pud_alloc, pmd_alloc. It allocates missing levels as it goes, and that’s how a fresh process’s page tables get built: lazily, on first touch.

Step 6. handle_pte_fault is the fork in the road, decided by the PTE’s state:

PTE stateGoes toMeaning
Emptydo_pte_missingNever existed. Anonymous goes to do_anonymous_page, and file-backed to do_fault
Not present, non-emptydo_swap_pageSwapped out, or a migration or device entry
NUMA hintdo_numa_pageNUMA balancing
Present, read-only, and a writedo_wp_pageCopy-on-write. FAULT_FLAG_UNSHARE can also get here

The three answers

Anonymous memory: do_anonymous_page. A read of untouched memory maps the shared zero page read-only, so calloc of a gigabyte costs nothing until written. A write allocates with alloc_anon_folio, which may allocate a large folio. It zeroes the memory, charges the memcg, adds the rmap and calls set_ptes, so one fault can map several pages. This is where RSS grows.

File-backed memory: do_fault. For a read, do_read_fault first tries do_fault_around, mapping nearby cached pages before anything else. Then __do_fault calls vma->vm_ops->fault, usually filemap_fault. On a page cache miss it reads the page and sleeps until it arrives. That’s a major fault. This is why sequential code faults far less than you’d expect.

Copy-on-write: do_wp_page. A write hit a read-only PTE on a page that may be shared after fork. wp_page_copy allocates a new page, copies the old one with copy_mc_user_highpage, points this process’s PTE at the copy and drops the old reference. If the page turns out to be exclusively owned (wp_can_reuse_anon_folio), it’s made writable in place with no copy. This single decision is why fork() is cheap, and why a 2 GB process that forks and then writes everywhere ends up at 4 GB.

Step 7. The stack unwinds, the exception returns, and the CPU retries the instruction. This time the PTE is there.

Hands-on: count the faults

  1. Control huge pages first, because they change every number. Debian uses always, and Fedora uses madvise:

    cat /sys/kernel/mm/transparent_hugepage/enabled
    echo never > /sys/kernel/mm/transparent_hugepage/enabled
    for f in /sys/kernel/mm/transparent_hugepage/hugepages-*kB/enabled; do echo never > $f; done
  2. Check the names:

    cd /sys/kernel/tracing
    grep -w -E 'do_anonymous_page|do_wp_page|wp_page_copy|handle_mm_fault|exc_page_fault|filemap_fault' available_filter_functions

    Expect: exc_page_fault is missing, because it’s noinstr. wp_page_copy may be missing too, because it’s inlined on some builds.

  3. Count each kind:

    bpftrace -e 'kprobe:do_anonymous_page { @anon[comm] = count(); }
      kprobe:filemap_fault { @file[comm] = count(); }
      kprobe:do_wp_page { @wp[comm] = count(); }
      tracepoint:exceptions:page_fault_user { @all[comm] = count(); }'
  4. Run a workload: python3 -c 'x = bytearray(100_000_000)'.

  5. Expected: with huge pages off, @anon[python3] should be near 24,414 (100,000,000 ÷ 4,096), plus interpreter startup.

Hands-on: watch one fault

You can’t root the graph at exc_page_fault, so use handle_mm_fault:

cd /sys/kernel/tracing
echo handle_mm_fault > set_graph_function
echo 10 > max_graph_depth
echo function_graph > current_tracer
~/one-touch
cp trace /tmp/fault.txt; echo nop > current_tracer; echo > set_graph_function

Here one-touch is your own small program that touches one fresh page. Look for: the page table allocation and the path you read above.

Hands-on: make copy-on-write visible

  1. Create cow.c:

    #include <stdio.h>
    #include <string.h>
    #include <sys/mman.h>
    #include <sys/wait.h>
    #include <unistd.h>
    #define SZ (64UL << 20)
    
    int main(void) {
        char *p = mmap(NULL, SZ, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
        if (p == MAP_FAILED) { perror("mmap"); return 1; }
        madvise(p, SZ, MADV_NOHUGEPAGE);
        memset(p, 1, SZ);
        sleep(2);
        pid_t c = fork();
        if (c == 0) {
            for (size_t i = 0; i < SZ; i += 4096) p[i] = 2;
            _exit(0);
        }
        waitpid(c, NULL, 0);
        sleep(1);
        for (size_t i = 0; i < SZ; i += 4096) p[i] = 3;
        return 0;
    }
  2. Build it with gcc -O0 -o cow cow.c, then trace it in another terminal:

    bpftrace -e 'kprobe:do_wp_page /comm == "cow"/ { @wp[pid] = count(); }
                 kprobe:wp_page_copy /comm == "cow"/ { @copy[pid] = count(); }'
  3. Expected: the child shows at least 16,384 do_wp_page calls and about as many copies. The parent shows about 16,384 more do_wp_page calls but few copies, because those pages are exclusively its own again.

  4. Expected: you’ve just observed the reference-count decision inside do_wp_page.

Hands-on: add a tracepoint

A kprobe on an internal function can break next release. A tracepoint gives you a stable event, and this walkthrough also works if wp_page_copy wouldn’t attach.

  1. Create include/trace/events/cow.h:

    #undef TRACE_SYSTEM
    #define TRACE_SYSTEM cow
    #if !defined(_TRACE_COW_H) || defined(TRACE_HEADER_MULTI_READ)
    #define _TRACE_COW_H
    #include <linux/tracepoint.h>
    TRACE_EVENT(mm_cow_copy,
        TP_PROTO(unsigned long addr),
        TP_ARGS(addr),
        TP_STRUCT__entry(__field(unsigned long, addr)),
        TP_fast_assign(__entry->addr = addr;),
        TP_printk("addr=%lx", __entry->addr));
    #endif
    #include <trace/define_trace.h>
  2. In mm/memory.c, add this just after the kmem.h include:

    #define CREATE_TRACE_POINTS
    #include <trace/events/cow.h>
  3. In wp_page_copy(), before the first allocation, add:

    trace_mm_cow_copy(vmf->address);
  4. Rebuild, boot and trace it. TRACE_SYSTEM must match the file name, so the event is under cow:

    bpftrace -e 'tracepoint:cow:mm_cow_copy { @[comm] = count(); }'
  5. Compare with the do_wp_page counts. At function entry, this counts attempts, including any that later fail.

If you only want the kprobe, the alternative is to mark wp_page_copy as noinline and rebuild.

If it fails

  • set_graph_function rejects exc_page_fault: that’s expected.
  • @anon is far too low: huge pages are on.
  • wp_page_copy fails to attach: it’s inlined. Use the tracepoint.
  • The parent copies more than expected: a folio reference held elsewhere makes reuse refuse.

The takeaway

Laziness with accounting. Nothing in this path happens until the moment it is needed: page tables are built on first touch, anonymous pages exist only when written, file pages come from a cache shared with every other reader, and fork() copies nothing until someone writes. Every one of these deferrals is paid for with bookkeeping (reference counts, rmap entries, memcg charges), so that the deferred work can be done correctly later. That trade is the heart of the memory subsystem and of most high-performance systems: do the minimum now, keep enough state to do the rest right. The failure mode is also instructive: the bookkeeping is the part that gets you when it’s wrong.