146 lines
8.0 KiB
Markdown
146 lines
8.0 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 [architecture](architecture.md) boundary in `system/kernel/architecture/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 nearly everything with
|
|
4 KiB pages: precise, and the extra table memory is negligible against available
|
|
RAM. (The physmap has since become the one exception: 2 MiB-aligned RAM there is
|
|
mapped with **2 MiB huge pages** — a PS-bit leaf at the PD level — with 4 KiB
|
|
pages filling the unaligned edges, so the table footprint scales sanely with big
|
|
RAM. Kernel segments, heap, user space, and on-demand MMIO stay 4 KiB.)
|
|
|
|
## Higher half: the address-space layout
|
|
|
|
danos is a **higher-half kernel**. The kernel is linked to run at
|
|
`0xFFFF_FFFF_8000_0000` but loaded low (the linker script's `AT()` gives each
|
|
segment a physical load address at 1 MiB up; the bootloader maps the high link
|
|
address to the low load address in its bootstrap tables and jumps in). The entire
|
|
**low canonical half is reserved for user space**; the kernel lives in the top half
|
|
alongside a **physmap** — a straight window onto all of physical memory at
|
|
`physmap_base + phys`. Wherever the kernel needs to touch a physical address (a
|
|
page-table frame, an ACPI table, a device register), it adds that constant:
|
|
`boot_handoff.physicalToVirtual(phys)`. The layout constants live in `system/boot-handoff.zig`:
|
|
|
|
| region | virtual base | PML4 slot |
|
|
|--------|--------------|-----------|
|
|
| user image + stack | `0x0000_7000_0000_0000` | 224 (low half) |
|
|
| kernel heap | `0xFFFF_8000_0000_0000` | 256 |
|
|
| physmap (all RAM + MMIO windows) | `0xFFFF_8800_0000_0000` + phys | 272 |
|
|
| kernel image | `0xFFFF_FFFF_8000_0000` | 511 |
|
|
|
|
The bootloader builds temporary **bootstrap tables** (identity + a 4 GiB physmap +
|
|
the high kernel) so it can switch CR3 and jump to the high entry; the kernel then
|
|
builds its own precise tables below and abandons them. Because both use the same
|
|
`physmap_base`, any physmap pointer minted before the switch stays valid after it.
|
|
|
|
## What gets mapped, and with what permissions
|
|
|
|
The address space is built in four passes (`init`):
|
|
|
|
1. **All RAM in the physmap, RW + NX.** Every non-MMIO region from the
|
|
[memory map](memory-map.md) is mapped at `physicalToVirtual(phys)`, read-write and
|
|
*non-executable*. There is **no low/identity mapping** — the low half is user
|
|
space. (Frames the kernel touches while still building these tables are reached
|
|
through the loader's bootstrap physmap, which covers the low 4 GiB; both the
|
|
frame allocator and the table builder scan low-address-up, so those frames stay
|
|
under that limit.)
|
|
2. **The framebuffer and the Local APIC**, the device memory the kernel touches
|
|
directly, as physmap windows (RW + NX). Other MMIO is mapped on demand by
|
|
`mapMmio`, also into the physmap; everything else is left unmapped, so a stray
|
|
access faults instead of silently succeeding.
|
|
3. **The kernel's own segments, overlaid with their true ELF permissions**, at
|
|
their high link addresses mapped to their low physical load addresses. This is
|
|
the interesting part.
|
|
4. **Every higher-half PML4 entry pre-created** (an empty PDPT where none exists
|
|
yet). The kernel half is then a fixed set of top-level slots, so a per-process
|
|
address space can share it by copying `PML4[256..512)` once — growth beneath
|
|
those slots (heap, on-demand MMIO) propagates to every address space because
|
|
they share the PDPTs. `init` asserts no new higher-half PML4 entry appears
|
|
afterward.
|
|
|
|
### 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 `BootInformation`. 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
|
|
|
|
The whole low half is unmapped except for explicit user mappings, so page 0 (and
|
|
every near-null address) is unmapped by construction. A null (or near-null) pointer
|
|
dereference in the kernel 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 *while we build* the kernel is
|
|
still running on the loader's bootstrap tables, whose 4 GiB physmap makes freshly
|
|
allocated table frames reachable at the same `physmap_base + phys` addresses;
|
|
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 (mostly done since)
|
|
|
|
- **A kernel heap** — done, built on `map` exactly as anticipated
|
|
([heap.md](heap.md)).
|
|
- **A higher-half kernel** — done: the kernel is linked at
|
|
`0xFFFFFFFF80000000` (`linker.ld`), loaded low and running high, and user
|
|
processes own the low half.
|
|
- **Per-address-space tables** — done: each user process gets its own root with
|
|
the kernel half shared, and refcounted shared-memory mappings exist
|
|
([ipc.md](../device-driver-development/ipc.md)). Copy-on-write remains unbuilt — nothing has needed it yet.
|
|
- **Uncacheable MMIO** — half done: user-space device and DMA mappings are
|
|
strong-uncacheable and the framebuffer is write-combining via the PAT, but the
|
|
kernel's own `mapMmio` path is still writeback — the LAPIC included (see
|
|
[device-interrupts.md](../device-driver-development/device-interrupts.md)).
|