danos/docs/os-development/efi.md

11 KiB

EFI / The Boot Process

What EFI is

UEFI (Unified Extensible Firmware Interface) is the software baked into your machine's flash chip that runs the instant it powers on — the modern successor to the legacy BIOS. Its job is to bring the hardware up to a sane state and then find and launch an operating system. From our point of view it's a small runtime that hands us a working CPU, a memory map, and a screen, and then gets out of the way.

The key thing to understand: UEFI is not our OS, it's a stepping stone. It exists to load us. Our boot/efi.zig is a UEFI application — a normal program that the firmware runs — and its entire purpose is to gather what the kernel needs and then jump into the kernel.

How the firmware finds us

UEFI boots by looking for a FAT-formatted partition called the EFI System Partition (ESP) and running a file at a well-known fallback path:

EFI/BOOT/BOOTX64.efi         <- the "removable media" default for x86-64

The boot volume is FHS-shaped (see the repository-layout note in README.md): build.zig installs boot/efi.zig (built for the uefi target) at EFI/BOOT/BOOTX64.efi — the one path UEFI firmware fixes — and lays the rest out by FHS path: the kernel at system/kernel, init at system/services/init, the pre-packed boot capsule at boot/system.img (system-image.md). zig-out mirrors that tree, but what a machine actually boots is the self-contained FAT32 image tools/make-fat-image.py builds from the same files (danos-usb.img). The run-x86-64 step points QEMU at OVMF (UEFI firmware for virtual machines) and attaches that image (its serial-logging twin, built the same way) as a USB mass-storage device on the xHCI bus — the guest never sees zig-out. The firmware finds BOOTX64.efi on the image and runs it — that's our main(), which then loads the kernel and the system binaries from their FHS paths.

Boot services: the firmware's API

While a UEFI app runs, it has access to boot services — a table of function pointers the firmware provides for allocating memory, reading files, locating hardware protocols, and so on. In boot() this is the very first thing we grab:

const bs = uefi.system_table.boot_services orelse return error.NoBootServices;

Everything the firmware offers hangs off tables reachable from uefi.system_table: boot_services, con_out (the text console we log() to), and the various protocols (GOP for graphics, SimpleFileSystem for disk access).

The critical rule: boot services are temporary. They stop existing the moment we call ExitBootServices. So the loader's structure is dictated by one constraint — gather everything the kernel could ever need first, then exit. The comment in boot() says exactly this:

Everything the kernel needs must be gathered before we exit boot services, since afterwards none of these calls are usable.

What our loader actually does

The four milestones below are the spine of boot(). Along the way it also captures the ACPI RSDP from the UEFI configuration table (while boot services are still up), loads the system binaries into an in-RAM initial ramdisk (loadSystemTree — normally a single read of the pre-packed boot\system.img capsule, which already is the ramdisk wire format; it falls back to opening each manifest-listed path, and walks the /system and /test trees only as a last resort for hand-assembled sticks — the capsule's format, builder, and fallback chain are documented in system-image.md. Best-effort either way — a kernel-only volume still boots), and builds the bootstrap page tables the kernel starts life on (buildBootstrapTables), all before the jump:

1. Query the framebuffer (queryFramebuffer)

We ask the firmware for the Graphics Output Protocol (GOP), which describes the linear framebuffer — its address, resolution, pitch, and pixel format — and, when we can, switch the display to its native resolution first:

  • Locate GOP via its handle (not locateProtocol), because the same handle also carries the display's EDID.
  • Read the EDID (trying the EDID_ACTIVE then EDID_DISCOVERED protocol on each GOP handle) and parse the first Detailed Timing Descriptor — by convention the panel's preferred (native) resolution. This is best-effort: firmware installs these protocols inconsistently, and OVMF with QEMU's stdvga doesn't expose them at all.
  • If we got a native resolution, enumerate the GOP modes with queryMode and setMode to the one that matches it (must be a linear 32bpp layout we can paint into). If EDID gave us nothing, we keep the firmware's current default mode rather than guess — with a valid EDID the firmware normally defaults to the native mode itself, so its choice beats second-guessing it with, say, the largest advertised mode.
  • Copy the resulting address/resolution/pitch/format into our own Framebuffer.

All of this must happen now, because after exit there's no GOP to ask. (See framebuffer.md for what those fields mean.)

2. Load the kernel (loadKernel + loadElf)

  • Use the LoadedImage protocol to discover which device we booted from, then SimpleFileSystem to open that volume.
  • Open the kernel ELF at its FHS path (system\kernel), seek to the end to learn its size, rewind, and read the whole ELF into a firmware-allocated pool buffer. (read may return short, so we loop.)
  • Parse the ELF: validate the \x7fELF magic and the x86_64 machine type, then walk the program headers. For every PT_LOAD segment we:
    • reserve the exact physical pages it asks to be loaded at (p_paddr) via allocatePages,
    • @memcpy the file-backed bytes to that address,
    • @memset the .bss tail (the part where p_memsz > p_filesz) to zero.

The kernel is linked to run in the higher half (virtual base 0xFFFFFFFF80000000; exe.image_base in build.zig is the virtual address 0xFFFFFFFF80100000) but is loaded low: the linker script's AT() clauses give every segment a low physical load address (p_paddr, with .text at 0x100000, 1 MiB), which is what the loader allocates and copies into. The bootstrap page tables built before the jump map the high link addresses onto those low physical pages. If a segment's p_paddr collided with firmware-reserved memory, allocatePages would fail and we'd need to move the load addresses.

loadElf returns e_entry, the kernel's entry-point address.

3. Exit boot services (exitBootServices)

This is the handoff's trickiest step. To exit, the firmware demands the current memory map and its key — proof that we've seen the latest state of memory. But allocating the buffer to hold the memory map can itself change the map, invalidating the key. So it's a retry loop:

get map info -> allocate buffer (+ spare descriptors) -> get map
             -> try exit with map.key
             -> if it failed, the map moved: free, retry

Once exitBootServices succeeds, the firmware's services are gone for good — we must never touch bs, con_out, or any protocol again. The machine is now entirely ours.

4. Jump to the kernel

handoff(cr3, entry, &boot_information);

handoff is a single inline-asm block — cli, load the bootstrap page tables' cr3, place the boot_information pointer in RDI, then callq *entry — so nothing runs between the CR3 load and the jump. This never returns.

The ABI subtlety: RCX vs RDI

There's a deliberate detail worth calling out. A UEFI binary is compiled with the Microsoft x64 calling convention (first argument in register RCX). Our kernel is freestanding and uses the SysV AMD64 convention (first argument in RDI). If we let each side use its target's default, the loader would place boot_information in RCX while the kernel looked for it in RDI — and the kernel would read garbage.

So the convention is pinned explicitly to SysV via the shared boot_handoff.kernel_abi (defined in system/boot-handoff.zig). The kernel's kmainEntry declares callconv(kernel_abi); the loader honours the same contract by loading RDI by hand in handoff's inline asm rather than trusting its own Microsoft-x64 default. kernel_abi lives in the shared boot-handoff module because it's a contract both binaries must agree on. See sysv.md for what "SysV" means and where else it shows up.

The handoff contract

The loader and kernel are two separate binaries built for two different targets, so everything they exchange must have an identically-defined memory layout. That's what system/boot-handoff.zig provides — imported by both as the boot-handoff module. It is only the handoff: the kernel↔user ABI (system/abi.zig) and the device types (library/device/model/device-abi.zig) are separate contracts the bootloader never sees.

  • BootInformation — the top-level struct passed to the kernel: the framebuffer, the memory map, the kernel's own PT_LOAD segments (kernel_segments + kernel_segment_count, so the kernel can re-map itself with correct permissions), the ACPI RSDP address, and the initial-ramdisk base/length.
  • Framebuffer, PixelFormat, MemoryMap/MemoryRegion, KernelSegment, kernel_abi — the shared field layouts and the calling convention.

The structs are extern struct, giving them a stable, C-compatible layout so the bytes the loader writes are the bytes the kernel reads.

The whole flow at a glance

power on
  -> UEFI firmware initialises hardware
  -> finds EFI/BOOT/BOOTX64.efi on the FHS volume, runs it   (our efi.zig main)
       -> grab boot services (+ the ACPI RSDP from the configuration table)
       -> queryFramebuffer   (via GOP: EDID native res, setMode, describe fb)
       -> loadKernel         (read system/kernel ELF, load PT_LOAD segments low, .text at 0x100000)
       -> loadSystemTree     (read the boot\system.img capsule as the in-RAM initial ramdisk;
                              fallbacks: manifest-listed paths, then a /system + /test tree walk)
       -> buildBootstrapTables (identity + physmap + higher-half kernel mappings)
       -> exitBootServices   (retry until the memory-map key holds)
       -> handoff: load bootstrap CR3, jump to e_entry, boot_information pointer in RDI
  -> kernel _start           (architecture/x86_64/isr.s: switch to a kernel-owned stack,
                              call kmainEntry -> paging, heap, device discovery,
                              scheduler, SMP, user space)

Bottom line: UEFI's job is to give us a CPU, memory, and a framebuffer, then disappear. boot/efi.zig is the thin bridge that collects those gifts into a BootInformation, tears down the firmware, and jumps into the kernel — after which we're on our own.