danos/docs/memory-map.md

161 lines
6.9 KiB
Markdown

# The memory map
Before a kernel can manage memory, it has to *know what memory exists*: which
physical address ranges are real RAM it may use, and which are firmware, hardware
registers, or already occupied. That inventory is the **memory map**, and the
firmware is the only thing that knows it. This page covers how danos gets that map
from the firmware and hands it to the kernel — deliberately without dragging UEFI
into the kernel.
## Why not just pass UEFI's map through?
UEFI hands the loader a perfectly good memory map. The tempting shortcut is to
forward it to the kernel as-is. We don't, for two reasons:
1. **It would tie the kernel to UEFI.** The kernel would compare against UEFI's
memory-type numbers and walk the array using UEFI's variable descriptor stride.
That's UEFI vocabulary bleeding across the handoff — and danos wants to boot on
systems that have no UEFI at all (a Raspberry Pi describes its memory with a
*device tree* instead). See [arch.md](arch.md) for the same "keep the kernel
platform-agnostic" principle applied to CPU code.
2. **We already established the better pattern.** The loader doesn't hand the
kernel a raw UEFI GOP either — [`queryFramebuffer`](gop.md) converts it to
danos's own `Framebuffer`. The memory map follows the same discipline.
So the boundary is: **each boot path translates its native memory description into
danos's own neutral format, and the kernel only ever sees that.**
## The neutral format
Defined in `src/root.zig`, the shared loader↔kernel contract:
```zig
pub const MemoryKind = enum(u32) {
usable, // free RAM the kernel may allocate
reserved, // firmware / kernel image — real RAM, but never hand out
reclaimable, // usable once boot-time structures are done with
acpi_tables, // parse, then reclaim
acpi_nvs, // preserve across sleep
mmio, // device registers / reserved address space — not RAM at all
};
pub const MemoryRegion = extern struct {
base: u64, // physical start
pages: u64, // length in page_size (4 KiB) units
kind: MemoryKind,
_pad: u32 = 0,
};
pub const MemoryMap = extern struct {
regions: usize, // pointer to a [len]MemoryRegion
len: usize,
};
```
`MemoryKind` is danos's *own* vocabulary — not UEFI's ~15 types, just the
distinctions the kernel actually acts on. And because danos defines `MemoryRegion`
itself, `@sizeOf` is authoritative: the kernel walks a plain `[]MemoryRegion` with
no variable-stride subtlety (that stride problem is a UEFI-ism, and it stays in the
loader).
`BootInfo` carries it alongside the framebuffer:
```zig
pub const BootInfo = extern struct {
framebuffer: Framebuffer,
memory_map: MemoryMap,
};
```
## The loader side (UEFI)
Two functions in `src/efi.zig`, called from `exitBootServices`:
- **`classify`** maps each UEFI descriptor to a `MemoryKind`:
`conventional_memory → usable`; `boot_services_code`/`boot_services_data →
reclaimable` (free once we've exited); `acpi_reclaim_memory → acpi_tables`;
`acpi_memory_nvs → acpi_nvs`; **everything else → reserved** (the safe default).
Our own `loader_data` — the kernel image and these buffers — falls into
`reserved`, so it won't be handed out until the kernel deliberately reclaims it.
One subtlety: **a region that isn't writeback-cacheable (the descriptor's `wb`
attribute) is classified `mmio` regardless of type.** UEFI overloads
`reserved_memory_type` for both reserved RAM *and* reserved address-space windows
(PCIe config space, device BARs); the cache attribute is what actually tells them
apart, since only real RAM is writeback-cacheable. Without this, a QEMU q35 guest
reports ~12 GiB of "reserved" that is really a PCIe address hole near the 1 TB
mark — not memory at all.
- **`convertMemoryMap`** walks the UEFI descriptors (striding by
`descriptor_size`, *not* `@sizeOf`), classifies each, and writes danos
`MemoryRegion`s into an output buffer, coalescing adjacent same-kind regions.
### The ordering that makes it correct
This is the fiddly part, dictated by two UEFI rules: you can only allocate memory
*before* `ExitBootServices`, and the memory map is only final *at* the moment you
exit (its "key" proves you've seen the latest state). So `exitBootServices` does,
per attempt:
1. `getMemoryMapInfo` to size things, then `allocatePool` **two** LoaderData
buffers — one for the raw UEFI map, one for the converted regions. Allocating
now, before exit, is mandatory.
2. `getMemoryMap` then `exitBootServices(key)`. If either fails (allocating can
perturb the map and invalidate the key), free both buffers and retry.
3. **After** the exit succeeds, convert. Conversion is pure computation on memory
we already hold — no boot-services calls — so it's safe once services are gone.
Both buffers are `LoaderData`, which survives `ExitBootServices`, so the converted
array the kernel is pointed at stays valid. (The raw UEFI buffer is just scratch
for the conversion.)
## The kernel side
The kernel receives a plain array and reads it with zero UEFI knowledge:
```zig
const mm = boot_info.memory_map;
const regions = @as([*]const danos.MemoryRegion, @ptrFromInt(mm.regions))[0..mm.len];
for (regions) |r| {
if (r.kind == .usable) usable_pages += r.pages;
}
```
`kmain` summarises the map to prove the handoff works. Booted in QEMU with
128 MiB, it reports:
```
danos: physical memory
total RAM : 0.12 GiB (127 MiB) - RAM the firmware reported
usable : 77 MiB - free now; owned by the frame allocator
reclaimable: 44 MiB - UEFI boot-services memory, free after exit
reserved : 6 MiB - kernel image, ACPI, runtime services
regions : 35 - entries in the firmware memory map
```
The `usable` figure is only ~77 of ~127 MiB because most of the rest is
`reclaimable` boot-services memory — real RAM we'll take back once we implement
reclaiming, not memory that's gone. `total` counts only writeback-cacheable RAM,
so the ~12 GiB PCIe address hole is excluded (it's `mmio`), and the three RAM
categories summing back to the firmware's total is the sanity check that nothing
was dropped.
## How Raspberry Pi will fit
No UEFI there, but the boundary is unchanged. The Pi's firmware jumps into the
kernel with a **device-tree blob**; the AArch64 entry code will parse its
`/memory` and `/reserved-memory` nodes and produce the *same* `MemoryRegion`
array. The kernel's memory code — the frame allocator and everything above it —
never knows the difference.
## What's next
This page is plumbing plus classification only. The map's first consumer, the
**physical frame allocator**, is built directly on the `usable` regions here —
see [frame-allocator.md](frame-allocator.md). Still to come after that:
- Reclaiming `reclaimable` regions, and carefully freeing `reserved` `loader_data`
(kernel image, these buffers) once the kernel is done reading them.
- Paging / the kernel's own page tables, then a heap.
See the roadmap in [efi.md](efi.md) for where this sits in the boot flow.