danos/docs/os-development/frame-allocator.md

135 lines
7.2 KiB
Markdown

# The physical frame allocator
Once the kernel knows what RAM exists ([memory-map.md](memory-map.md)), it needs a
way to *hand out* that RAM: give me a free page of physical memory, and later,
here's one back. That's the **physical frame allocator** (a "physical memory
manager", hence `system/kernel/pmm.zig`). It deals only in fixed 4 KiB **frames** — the
natural unit because that's the granularity the CPU's paging hardware maps — and
it is the primitive everything above it stands on: page tables, the kernel heap,
per-process memory all ultimately ask the frame allocator for pages.
It's **generic kernel code**: it operates on the neutral `boot_handoff.MemoryRegion`
array, so there's no UEFI in it and nothing architecture-specific beyond the 4 KiB
page. (Contrast [architecture.md](architecture.md), which is where CPU-specific code lives.)
## Why a bitmap
There are a few classic designs; danos starts with the simplest that still
supports freeing:
- **Bitmap** (chosen): one bit per frame, `1 = used`, `0 = free`. Freeing is
trivial (clear a bit), it's very compact, and you can later extend it to
allocate *contiguous* runs by scanning for consecutive zero bits. Allocation is
a linear scan, but that's cheap and easy to reason about.
- **Intrusive free-list / stack**: store the "next free frame" pointer inside each
free frame; O(1) alloc and free. Elegant, but it can't satisfy contiguous
multi-frame requests and can't answer "is *this* frame free?".
- **Buddy allocator**: great for contiguous power-of-two blocks, but more
machinery than a first allocator needs.
Compactness matters less than clarity here, but it's a nice property: 128 MiB of
RAM is 32768 frames — a **4 KiB bitmap, a single frame**. Even 64 GiB needs only
2 MiB of bitmap.
## How it works
State lives in `system/kernel/pmm.zig`: the `bitmap` slice, `total_frames`, `used_frames`,
and a `next_hint` marking where the next allocation scan should start.
### init(map) — building it from the memory map
1. **Size it.** Find the highest address across all RAM regions — every kind
*except* `mmio` — so reserved and ACPI spans sit *inside* the bitmap, marked
used but trackable (e.g. so the boot buffers can be freed later);
`total_frames = highest / page_size`. Only MMIO (device address space —
remember the ~12 GiB of it from [memory-map.md](memory-map.md)) sits outside
the bitmap and is simply never allocatable.
2. **Place it (the bootstrap).** The bitmap needs storage before an allocator
exists — a chicken-and-egg. Solution: pick the first `usable` region big enough
to hold the bitmap and put it there, addressing it through the **physmap**
(`boot_handoff.physicalToVirtual`). The loader's bootstrap page tables already
provide the physmap and the kernel's own tables keep it, so the pointer stays
valid across the paging switch.
3. **Mark, then free.** Set the whole bitmap to `used` (`0xff`), then walk the
`usable` regions clearing their bits. Doing it in that direction means every
gap, reserved span, and hole is unallocatable *by default* — we only ever hand
back memory the firmware explicitly called usable.
4. **Take back the essentials.** Re-reserve the frames the bitmap itself occupies
(they're inside a usable region we just freed), plus **frame 0**, so an address
of `0` can keep meaning "no frame".
### alloc() → ?u64
Scan the bitmap from `next_hint` (wrapping once) for the first free bit, mark it
used, advance the hint, and return `frame * page_size`. Returns `null` when no
frame is free — genuine out-of-memory. The hint avoids rescanning the low,
long-since-allocated frames on every call.
### free(addr)
Clear the frame's bit and, if it's below `next_hint`, pull the hint back so the
reclaimed frame gets reused soon. Bogus or double frees (a frame already marked
free, or one out of range) are ignored rather than corrupting the used count.
## Correctness points worth remembering
- **Generic walk.** Because `MemoryRegion` is danos's own type, the map is a plain
slice — none of the variable descriptor-stride from the raw UEFI map.
- **Physmap addressing.** The bitmap (and the region array) is reached through
the physmap via `boot_handoff.physicalToVirtual`, which both the loader's
bootstrap tables and the kernel's own tables provide — no remapping is needed
when danos switches to its own paging. The one invariant: the bitmap must sit
under the bootstrap physmap's reach (4 GiB), which holds because the placement
scan (step 2 above) runs from the lowest usable region up and takes the first
one big enough — on the supported configurations that lands well under 4 GiB.
- **Frame 0 is reserved** so `0` stays a safe "none" sentinel — and the bitmap is
never placed there. (An early bug did exactly that: a `usable` region at physical
address 0 collided with a `0`-means-not-found sentinel and tripped a panic. The
fix was an optional plus starting the bitmap at least one page in.)
- **Everything non-usable is unallocatable by construction** — the "mark all used,
then free usable" order gives that for free, so the kernel image, the loader's
buffers, MMIO and firmware memory can never be handed out.
## Verifying it
`kmain` brings the allocator up and self-tests it. Booted in QEMU with 128 MiB:
```
/system/kernel: frame allocator online
free frames: 30520 (119 MiB) <- matches the map's 119 MiB usable
alloc x3 : 0x3000 0x4000 0x5000 <- frame 0 reserved, bitmap at 0x1000, AP trampoline at 0x2000
after free : 30520 frames free <- three freed, count restored
```
(The AP-trampoline page is claimed with `allocBelow` right after `init`, before
the demo allocations — hence they start at 0x3000.)
The `free frames` MiB agreeing with the memory map's `usable RAM`, the three
distinct consecutive addresses, and the count returning to its start after freeing
are the three signals that init, alloc and free are all correct.
## Boot-services memory comes pre-reclaimed
The UEFI boot-services memory (~44 MiB) is defunct and free once
`ExitBootServices` runs, taking usable RAM from ~76 MiB up to ~119 MiB. The frame
allocator does **nothing special** to get it: the loader already classified it as
`usable` (see [memory-map.md](memory-map.md)), so it's just part of the `usable`
regions `init` frees. Keeping that boot-protocol knowledge on the loader side is
deliberate — the kernel has no notion of "reclaimable" or of UEFI at all.
The one live piece in that memory is the boot stack the kernel starts on; the loader
leaves the single region containing it `reserved`, so `init` won't hand it out. A
later step will move task 0 onto a kernel-owned stack, freeing that last ~1 MiB
region too (and giving user mode the clean stack it wants).
## What's next (partly done since)
- **Contiguous allocation** — done: `allocContiguous` scans for a run of clear
bits, with an optional physical ceiling for DMA (`dma_alloc` is its user), and
`allocBelow` serves the SMP trampoline.
- **A kernel stack for task 0** — still open: the boot processor's idle task runs
on the boot stack to this day, so that region can't be freed.
- **Freeing the `reserved` `loader_data`** (the boot-time map buffers) — still
open: the bitmap deliberately tracks those frames so they *can* be freed, but
nothing frees them yet.