danos/docs/paging.md

79 lines
3.9 KiB
Markdown

# 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](arch.md) 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](frame-allocator.md).
## Building and switching
`paging.init` takes a frame allocator and:
1. Allocates and zeroes a PML4. **Zeroing matters**: the frame is recycled memory,
and any stale non-zero entry would map a bogus region.
2. Walks PML4 → PDPT → PD for each 2 MiB address, creating intermediate tables on
demand (`descend`) and writing the leaf with present + writable + huge.
3. 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](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.