danos/docs/memory-map.md

7.5 KiB

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 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 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 system/boot-handoff.zig, the shared loader↔kernel contract:

pub const MemoryKind = enum(u32) {
    usable,       // free RAM the kernel may allocate
    reserved,     // firmware / kernel image / boot stack — real RAM, never hand out
    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:

pub const BootInfo = extern struct {
    framebuffer: Framebuffer,
    memory_map: MemoryMap,
};

The loader side (UEFI)

Two functions in boot/efi.zig, called from exitBootServices:

  • classify maps each UEFI descriptor to a MemoryKind: conventional_memory and boot_services_code/boot_services_data → usable; 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.

    Folding boot-services memory into usable is deliberate: we've already called ExitBootServices, so it's free RAM now, and doing the classification here (in the loader) means the kernel never learns about a UEFI-specific "reclaimable" state — it just sees usable RAM. The one catch is that our stack lives in boot-services memory and the kernel starts out running on it, so convertMemoryMap keeps the single region containing the current stack pointer reserved. All the boot-protocol knowledge stays on the loader side of the boundary; the kernel's frame allocator has no idea any of this happened.

    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 MemoryRegions 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:

const mm = boot_info.memory_map;
const regions = @as([*]const system.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     : 121 MiB - free RAM (incl. reclaimed boot-services memory)
  reserved   : 6 MiB - kernel image, boot stack, ACPI, runtime services
  regions    : 28 - entries in the firmware memory map

usable is ~121 of ~127 MiB because the loader already folded the boot-services memory into it — so the frame allocator gets it all with no special step. The ~6 MiB reserved is the kernel image, the boot stack's region, ACPI, and runtime services. total counts only writeback-cacheable RAM, so the ~12 GiB PCIe address hole is excluded (it's mmio), and the 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 — which already include the reclaimed boot-services memory the loader folded in (see frame-allocator.md). Still to come:

  • Freeing the reserved loader_data (these boot-time buffers) once the kernel is done reading the map.
  • Capturing the ACPI RSDP from the UEFI configuration table before exit (the same "grab it before ExitBootServices" pattern), for when ACPI parsing arrives.

See the roadmap in efi.md for where this sits in the boot flow.