Create your own
Lesson illustration

Implementing a Page-Fault Handler and Decoding Fault Information

Hello again. You now have a loaded, persistent IDT and a working breakpoint handler. A breakpoint was deliberately triggered and safely returned from; a page fault is more serious. It tells you that the CPU could not complete a memory access under the current page-table mappings and permissions.

In this lesson, you will install IDT vector 14, implement its special two-argument Rust handler, read the faulting virtual address from CR2, and turn the page-fault error-code flags into a useful diagnosis. For now, the handler will log and halt rather than repair the mapping. Later, when you build page-table management and demand allocation, the same diagnostic foundation will let a handler resolve selected faults and retry the interrupted instruction.


What the processor gives a page-fault handler

A page fault is the x86-64 exception #PF, delivered through IDT vector 14. It can occur for two broad reasons:

  1. The virtual page has no valid present mapping.
  2. A mapping exists, but the attempted access violates its permissions, such as writing to a read-only code page or executing a page marked non-executable.

Your kernel is already running with paging enabled: 64-bit mode requires it, and the bootloader established mappings for the kernel. Thus even an ordinary pointer dereference is a virtual-memory operation whose translation and permissions are checked by the CPU.

Introduction to Paging | Writing an OS in Rust

Read the “Implementation” and “Page Faults” portions of Philipp Oppermann’s Writing an OS in Rust. It connects the bootloader’s existing mappings to the first diagnostic page-fault handler you are about to build.

In the “Implementation” section, first read the paging premise. Then continue into the “Page Faults” subsection: begin at the paragraph that proposes causing a page fault, and read through the two experiments, including the write to 0xdeadbeaf and the attempted write to a code address. Focus on the distinction between the accessed address, the error code, and the saved instruction pointer.

The CPU supplies three pieces of evidence that must not be conflated:

EvidenceMeaningHow this handler obtains it
Faulting virtual addressThe address whose translation or permissions failedRead control register CR2
Page-fault error codeA bitfield classifying the attempted access and fault conditionSecond handler argument
Saved instruction pointerThe instruction that was executing when the fault occurredstack_frame.instruction_pointer

The first and third may be quite different. For example, an instruction in kernel code might try to write through a pointer to a data page. CR2 holds the data address; the saved instruction pointer identifies the instruction in code that issued that write.

Unlike the breakpoint exception, a page fault includes a hardware error code. It therefore needs this signature:

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
)

The extern "x86-interrupt" ABI remains essential. It lets the compiler use the architecture’s exception-entry and interrupt-return conventions rather than treating this as a normal Rust function call. The additional PageFaultErrorCode parameter represents the error code that the processor supplied for #PF; it is not an ordinary argument passed by Rust code.

A key behavioral difference also follows. A breakpoint conventionally resumes at the next instruction after int3. A page fault preserves the faulting instruction as the point to retry. If an OS fixes the mapping and returns, the CPU re-executes the memory access. If the handler does not fix the underlying cause, returning simply faults again.


Decoding the error code as a diagnosis

PageFaultErrorCode is a typed bitflag set provided by the x86_64 crate. Do not interpret a single bit as a complete explanation. Each bit describes one independent property, so the diagnosis is the combination.

PageFaultErrorCode in x86_64::structures::idt - Rust

Read the x86_64 crate documentation for PageFaultErrorCode. This is the exact type your handler receives and the source of the named constants used below.

Under “Implementations” and then impl PageFaultErrorCode, read the five associated constants from PROTECTION_VIOLATION through INSTRUCTION_FETCH. Read the flag definitions as one set: they describe fault cause, access kind, privilege level, page-table validity, and instruction fetching rather than mutually exclusive cases.

For the crate version documented here, the principal architectural bits are:

BitPageFaultErrorCode flagSet meansClear means
0PROTECTION_VIOLATIONA present mapping existed but disallowed the accessTranslation encountered a not-present page-table entry
1CAUSED_BY_WRITEThe attempted memory access was a writeThe attempted memory access was a read, unless bit 4 indicates instruction fetch
2USER_MODEThe access occurred at CPL 3, user modeThe access occurred at supervisor privilege
3MALFORMED_TABLEA reserved bit was set in a paging-structure entryNo reserved-bit violation was reported
4INSTRUCTION_FETCHThe access was fetching an instructionThe access was data access

Two cautions prevent common debugging mistakes:

  • CAUSED_BY_WRITE describes the attempted access, not necessarily the cause. A non-present page can fault on a write even if the eventual mapping would be writable. Conversely, a write to a present read-only page produces both CAUSED_BY_WRITE and PROTECTION_VIOLATION.
  • USER_MODE identifies where the access originated, not automatically why it failed. Later, it will help distinguish a user task’s invalid pointer from a kernel bug. At this stage your freestanding kernel runs in ring 0, so a normal test will leave this flag clear.

These sample patterns make the combinations concrete:

Raw low bitsInterpretation
0b00010Supervisor-mode write to a not-present page
0b00011Supervisor-mode write blocked by a present page’s permissions
0b00101User-mode read blocked by a present page’s permissions
0b10001Instruction fetch blocked by a present page’s permissions, commonly an execute restriction

The meaning of bits is a contract between the MMU and the operating system. Hardware performs the common, performance-critical translation and permission check; software receives the exceptional case with enough context to decide on policy.

16.2.3 Page Faults

Watch “16.2.3 Page Faults” from MIT OpenCourseWare for a compact hardware-and-OS view of what happens after a non-resident page is referenced. Its pager is more complete than the diagnostic kernel you are writing, but it explains why repaired faults can resume normally.

Watch fault entry to see the MMU transfer control when a page is absent. Then watch repair and retry, focusing on the final step: after the page-table entry is updated, the original instruction runs again. Your current handler deliberately stops before this repair step.


Add vector 14 to the existing IDT

Build on src/interrupts.rs from the breakpoint lesson. Add the imports for Cr2 and PageFaultErrorCode:

use x86_64::registers::control::Cr2;
use x86_64::structures::idt::{
    InterruptDescriptorTable,
    InterruptStackFrame,
    PageFaultErrorCode,
};

Your existing lazy_static! IDT initialization needs one additional registration:

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();

        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt.page_fault.set_handler_fn(page_fault_handler);

        idt
    };
}

As with idt.breakpoint, the typed page_fault field is preferable to manually indexing the table. It documents that this is vector 14 and enforces the handler’s error-code-bearing function shape.

Now add this initial diagnostic handler:

extern "x86-interrupt" fn page_fault_handler(
    stack_frame: InterruptStackFrame,
    error_code: PageFaultErrorCode,
) {
    let fault_address = Cr2::read();

    crate::serial_println!("EXCEPTION: PAGE FAULT");
    crate::serial_println!("Accessed address: {:?}", fault_address);
    crate::serial_println!("Raw error-code bits: {:#07b}", error_code.bits());

    let cause = if error_code.contains(PageFaultErrorCode::MALFORMED_TABLE) {
        "malformed page-table entry"
    } else if error_code.contains(PageFaultErrorCode::PROTECTION_VIOLATION) {
        "protection violation"
    } else {
        "not-present page"
    };

    let access = if error_code.contains(PageFaultErrorCode::INSTRUCTION_FETCH) {
        "instruction fetch"
    } else if error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE) {
        "write"
    } else {
        "read"
    };

    let privilege = if error_code.contains(PageFaultErrorCode::USER_MODE) {
        "user mode"
    } else {
        "supervisor mode"
    };

    crate::serial_println!("Cause: {}", cause);
    crate::serial_println!("Attempted access: {}", access);
    crate::serial_println!("Origin privilege: {}", privilege);
    crate::serial_println!("{:#?}", stack_frame);

    crate::hlt_loop();
}

There are several deliberate design choices here.

Read CR2 before doing substantial work

Cr2::read() gives the virtual address associated with this page fault. Capture it immediately into fault_address, then print or classify it. A fault handler should preserve its primary evidence early, before any logging or future recovery logic makes debugging more complicated.

CR2 is not a general history of memory accesses; it is the architecture-defined register used to report the linear, or virtual, address for a page fault. Treat it as valid evidence for this exception instance.

Decode named flags, not numeric masks

contains asks a direct, readable question:

error_code.contains(PageFaultErrorCode::CAUSED_BY_WRITE)

This is better than scattering literal masks such as error_code.bits() & 2 != 0 through kernel code. You still print the raw bits because they are valuable when comparing serial output with architecture documentation or investigating flags your current crate version does not name.

Halt because no mapping has been repaired

crate::hlt_loop() is the correct terminal action for this diagnostic stage. The faulting access remains invalid, so iretq would restore state and retry the same instruction, causing another page fault. That cycle can obscure the original output and potentially destabilize debugging.

A future handler will take a more selective policy:

  • Reject impossible or unsafe faults.
  • Allocate or locate a backing physical frame for a permitted non-present address.
  • Install a page-table mapping with the required permissions.
  • Invalidate any stale translation when necessary.
  • Return so that the processor retries the original instruction.

That recovery requires page-table access and a physical-frame allocator, which belong to the next module. Do not attempt to return from this handler yet.


Produce two intentional faults

Use a controlled fault in your kernel entry function, after kernel::init() has loaded the IDT. Run one experiment per boot, because the handler intentionally never returns.

First, test a write to an address that should not be mapped by the bootloader:

kernel::init();

crate::serial_println!("about to write an unmapped address");

let ptr = 0xdeadbeaf as *mut u8;
unsafe {
    *ptr = 42;
}

crate::serial_println!("unreachable");

The expected diagnosis is:

  • CR2 reports 0xdeadbeaf.
  • CAUSED_BY_WRITE is set.
  • PROTECTION_VIOLATION is clear, indicating a not-present mapping rather than a write-permission failure.
  • The saved instruction pointer identifies the store instruction in your kernel.

Next, in a separate boot, test a protection violation. Use an address on one of your executable code pages, such as the instruction pointer printed by a prior exception frame. The address is build-dependent, so do not copy an address from a screenshot or another machine.

let code_address = 0x2031b2 as *mut u8; // Replace with your own code address.

unsafe {
    let value = *code_address;
    crate::serial_println!("read from code page succeeded: {}", value);

    *code_address = 42;
}

crate::serial_println!("unreachable");

The read should succeed if the address truly belongs to a mapped code page. The subsequent write should fault with both PROTECTION_VIOLATION and CAUSED_BY_WRITE set. This isolates a permission error from an absent-page error.

A QEMU serial console showing a page fault at virtual address `0x2031b2` with `PROTECTION_VIOLATION | CAUSED_BY_WRITE`; the interrupt stack frame separately records the instruction pointer and other interrupted CPU state.

When reading output like this image, separate the claims carefully:

  • “Accessed Address” comes from CR2: the target virtual address.
  • PROTECTION_VIOLATION | CAUSED_BY_WRITE says that the target was present but not writable.
  • instruction_pointer identifies where execution was when the fault was raised.
  • The lack of a later success message is expected because the handler halts.

If QEMU resets or reports a triple fault instead of printing your diagnosis, first verify that kernel::init() runs before the intentional bad access, that idt.page_fault.set_handler_fn(...) is present, and that the handler has both arguments in the extern "x86-interrupt" signature.


A practical fault-reporting checklist

For every page-fault report, answer these questions in order:

  1. What virtual address was accessed? Read CR2.
  2. Was the address absent or present-but-disallowed? Check PROTECTION_VIOLATION.
  3. What operation was attempted? Decode instruction fetch, write, or read.
  4. Did the access originate in user or supervisor mode? Check USER_MODE.
  5. Where is the faulting instruction? Inspect the saved instruction pointer in the stack frame.
  6. Can the kernel safely repair this exact fault? Until you can establish a valid mapping and permissions, halt rather than return.

This is a small diagnostic protocol, but it scales directly to real operating-system work. An eventual browser renderer crash, an invalid database buffer access, or a broken kernel mapping all become more tractable when the fault report distinguishes target address, access type, privilege origin, page-table condition, and faulting instruction.


You have now extended your IDT with a real page-fault entry, used CR2 to retrieve the faulting virtual address, and decoded the error-code flags into a meaningful report. Most importantly, you can distinguish an absent mapping from a present-but-protected page and understand why returning is unsafe until the mapping is repaired.

This completes the kernel-entry and exception-control module. Next, you will move beneath the handler’s diagnostic surface: manually walk four-level x86-64 page tables to discover exactly how a virtual address is translated and where a failed mapping breaks down.

Can't find a good explanation? Sign up and we'll make it for you

Sign up