3.9 KiB
Paging: the kernel's own page tables
Every memory access the CPU makes goes through the page tables: hardware walks them to translate a virtual address into a physical one, and faults if there's no valid mapping. Up to now danos ran on the firmware's page tables — which live in memory we'd like to reclaim and which we don't control. This step builds the kernel's own tables and switches onto them.
It's x86_64-specific (the 4-level table format is an Intel/AMD thing), so it lives
behind the arch boundary in src/arch/x86_64/paging.zig.
What we map, and why identity
x86_64 uses 4 levels: PML4 → PDPT → PD → PT, each a 512-entry table, with 9 bits of the virtual address indexing each level. A leaf can be a 4 KiB page (at the PT level) or, with the "huge" bit, a 2 MiB page straight from the PD.
For this first set of tables we identity-map the low 4 GiB — virtual address equals physical address. Identity mapping is the pragmatic bootstrap: it keeps everything that's already running valid across the CR3 switch without relocating a single thing. The kernel image (at 1 MiB), the stack, the frame allocator's bitmap, the framebuffer (at 2 GiB), and device MMIO all sit in the low 4 GiB, so one flat identity range covers the lot.
Using 2 MiB pages makes the whole map tiny: 4 GiB ÷ 2 MiB = 2048 leaves, which is one PML4, one PDPT, and four page directories — six frames total, from the frame allocator.
Building and switching
paging.init takes a frame allocator and:
- Allocates and zeroes a PML4. Zeroing matters: the frame is recycled memory, and any stale non-zero entry would map a bogus region.
- Walks PML4 → PDPT → PD for each 2 MiB address, creating intermediate tables on
demand (
descend) and writing the leaf with present + writable + huge. - Loads the PML4's physical address into CR3, which both switches address spaces and flushes the TLB in one instruction.
This all works because the firmware's identity map is still active while we build, so a freshly allocated frame's physical address is usable directly as a pointer. After the CR3 load we're on our own tables — and since those page-table frames came from low RAM, they remain mapped (and thus editable) for later.
Verifying it
Two temporary tests confirmed both that we switched tables and that faults are caught with the right detail:
-
Touch an address above 4 GiB (
0xdeadbeef000). Under the firmware's tables this didn't fault (they map a huge range); under ours it does:CPU EXCEPTION: page fault (vector 14) error code : 0x2 (write, page not present) CR2 (addr) : 0x00000deadbeef000 (the faulting address)A #PF at exactly the unmapped address is proof we're on our own, deliberately smaller, map — and that CR2 reporting works (see interrupts.md).
-
The console keeps working after the switch, confirming the framebuffer, kernel code, and stack are all still mapped.
Toolchain note: a volatile store to a compile-time-constant address tripped a codegen bug in the Zig self-hosted x86_64 backend ("no encoding for mov moffs"). Computing the address in a runtime variable sidesteps it (register-relative store). Worth remembering when poking fixed MMIO addresses.
What's next (not done here)
- A higher-half kernel: map the kernel at a high virtual base (e.g.
0xffffffff80000000) so user address space can own the low half later. - Real permissions: today every page is writable and executable. Map code read-execute, data read-write + no-execute (needs setting EFER.NXE and the NX bit), and leave a guard page unmapped to catch null-ish dereferences.
- 4 KiB pages / a general
map(virt, phys, flags)for fine-grained mappings, and unmapping with TLB invalidation (invlpg). - Per-address-space tables once there are user processes.