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:
- The virtual page has no valid present mapping.
- 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:
| Evidence | Meaning | How this handler obtains it |
|---|---|---|
| Faulting virtual address | The address whose translation or permissions failed | Read control register CR2 |
| Page-fault error code | A bitfield classifying the attempted access and fault condition | Second handler argument |
| Saved instruction pointer | The instruction that was executing when the fault occurred | stack_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:
| Bit | PageFaultErrorCode flag | Set means | Clear means |
|---|---|---|---|
| 0 | PROTECTION_VIOLATION | A present mapping existed but disallowed the access | Translation encountered a not-present page-table entry |
| 1 | CAUSED_BY_WRITE | The attempted memory access was a write | The attempted memory access was a read, unless bit 4 indicates instruction fetch |
| 2 | USER_MODE | The access occurred at CPL 3, user mode | The access occurred at supervisor privilege |
| 3 | MALFORMED_TABLE | A reserved bit was set in a paging-structure entry | No reserved-bit violation was reported |
| 4 | INSTRUCTION_FETCH | The access was fetching an instruction | The access was data access |
Two cautions prevent common debugging mistakes:
CAUSED_BY_WRITEdescribes 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 bothCAUSED_BY_WRITEandPROTECTION_VIOLATION.USER_MODEidentifies 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 bits | Interpretation |
|---|---|
0b00010 | Supervisor-mode write to a not-present page |
0b00011 | Supervisor-mode write blocked by a present page’s permissions |
0b00101 | User-mode read blocked by a present page’s permissions |
0b10001 | Instruction 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.
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:
CR2reports0xdeadbeaf.CAUSED_BY_WRITEis set.PROTECTION_VIOLATIONis 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.

When reading output like this image, separate the claims carefully:
- “Accessed Address” comes from
CR2: the target virtual address. PROTECTION_VIOLATION | CAUSED_BY_WRITEsays that the target was present but not writable.instruction_pointeridentifies 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:
- What virtual address was accessed? Read
CR2. - Was the address absent or present-but-disallowed? Check
PROTECTION_VIOLATION. - What operation was attempted? Decode instruction fetch, write, or read.
- Did the access originate in user or supervisor mode? Check
USER_MODE. - Where is the faulting instruction? Inspect the saved instruction pointer in the stack frame.
- 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