102 lines
5.1 KiB
Markdown
102 lines
5.1 KiB
Markdown
# Paging: the kernel's page tables and VMM
|
|
|
|
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. danos builds its own tables (rather than staying on the firmware's,
|
|
which live in memory we'd like to reclaim and don't control), switches CR3 onto
|
|
them, and — crucially — maps with **real permissions**.
|
|
|
|
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`.
|
|
|
|
## The format
|
|
|
|
x86_64 uses **4 levels**: PML4 → PDPT → PD → PT, each a 512-entry table, with 9
|
|
bits of the virtual address indexing each level and the low 12 bits the offset into
|
|
the final 4 KiB page. Each entry holds a physical address plus flag bits —
|
|
present, writable, and (bit 63) **no-execute**. danos maps everything with 4 KiB
|
|
pages: precise, and the extra table memory is negligible against available RAM.
|
|
|
|
## What gets mapped, and with what permissions
|
|
|
|
The address space is built in three passes (`init`):
|
|
|
|
1. **All RAM, identity-mapped RW + NX.** Every non-MMIO region from the
|
|
[memory map](memory-map.md) is mapped virtual == physical, read-write and
|
|
*non-executable*. Identity mapping keeps everything already running valid across
|
|
the CR3 switch (the frame allocator addresses frames by physical address, page
|
|
tables are reached the same way, the stack stays put).
|
|
2. **The framebuffer and the Local APIC**, the device memory we actually touch,
|
|
also RW + NX. Everything else — unbacked address space, other MMIO — is simply
|
|
left unmapped, so a stray access faults instead of silently succeeding.
|
|
3. **The kernel's own segments, overlaid with their true ELF permissions.** This is
|
|
the interesting part.
|
|
|
|
### W^X from the ELF program headers
|
|
|
|
Blanket RW+NX is fine for data but wrong for the kernel's own code, which must be
|
|
executable — and its code must *not* be writable (W^X: no page is both). We get the
|
|
right permissions per region straight from the kernel ELF: the **loader already
|
|
parses the program headers**, so `efi.zig` records each `PT_LOAD` segment's
|
|
address, size and R/W/X flags into `BootInfo`. Pass 3 re-maps those ranges with
|
|
flags derived from the ELF flags:
|
|
|
|
| segment | ELF flags | mapped as |
|
|
|---------|-----------|-----------|
|
|
| `.text` | R + X | present, **not** writable, **not** NX |
|
|
| `.rodata` | R | present, not writable, NX |
|
|
| `.data`/`.bss` | R + W | present, writable, NX |
|
|
|
|
So code can execute but not be written, and data can be written but not executed.
|
|
(Intermediate table entries are left writable and executable so the *leaf's* bits
|
|
govern — a page is writable only if every level is, and non-executable if any level
|
|
is.) NX itself has to be switched on first via `EFER.NXE`, or the NX bit would be a
|
|
reserved bit and fault.
|
|
|
|
### The null guard
|
|
|
|
Page 0 is deliberately left unmapped. A null (or near-null) pointer dereference now
|
|
takes a page fault instead of quietly reading or writing real memory — turning a
|
|
whole class of silent bugs into an immediate, located crash.
|
|
|
|
## Switching on, and the on-demand API
|
|
|
|
Loading the PML4's physical address into **CR3** switches address spaces and
|
|
flushes the TLB in one step. This works because the firmware's identity map is
|
|
still active *while we build*, so freshly allocated table frames are reachable by
|
|
physical address; afterwards they're covered by pass 1.
|
|
|
|
`init` keeps the PML4 and the frame allocator around and exposes `map(virt, phys,
|
|
writable)` / `unmap(virt)` (with `invlpg` TLB invalidation) — the primitive the
|
|
kernel heap will build on to map pages on demand.
|
|
|
|
## Verifying it
|
|
|
|
Four tests (see [testing.md](testing.md)) pin down the guarantees:
|
|
|
|
- **`vmm`** — map a fresh frame at an unused virtual address, write and read it
|
|
back. Proves `map` works end to end.
|
|
- **`fault-pf`** — an access far above all mapped RAM faults, with the address in
|
|
CR2. Proves we're on our own (deliberately sparse) map.
|
|
- **`fault-nx`** — calling into a data page (NX) faults on the instruction fetch.
|
|
Proves NX is enforced.
|
|
- **`fault-null`** — writing to address 0 faults. Proves the null guard.
|
|
|
|
> Toolchain notes, both hit while writing the tests: a volatile access to a
|
|
> compile-time-*constant* address either trips the self-hosted backend's
|
|
> `mov moffs` gap or (for address 0) Zig's null-pointer safety check — so the
|
|
> null-guard test launders the address through empty asm and uses an `allowzero`
|
|
> pointer to force a real hardware access. And `invlpg`, like `lgdt`, needs its
|
|
> operand staged through a register in inline asm.
|
|
|
|
## What's next (not done here)
|
|
|
|
- **A kernel heap** — the first real user of `map`, giving the kernel dynamic
|
|
allocation. This is the natural next milestone.
|
|
- **A higher-half kernel**: relink the kernel at a high virtual base so a future
|
|
user address space can own the low half.
|
|
- **Per-address-space tables** once there are user processes, and shared/copy-on-
|
|
write mappings.
|
|
- **Uncacheable MMIO**: the APIC/framebuffer pages are mapped writeback-cacheable;
|
|
real hardware wants MMIO marked uncacheable.
|