danos/docs/efi.md

8.7 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 the FHS-shaped zig-out itself (see the repository-layout note in README.md): build.zig installs boot/efi.zig (built for the uefi target) to zig-out/EFI/BOOT/BOOTX64.efi — the one path UEFI firmware fixes — and lays the rest out by FHS path: the kernel at zig-out/system/kernel, init at zig-out/system/services/init, the initial-ramdisk at zig-out/boot/. The run-x86-64 step points QEMU at OVMF (UEFI firmware for virtual machines) and presents zig-out to the guest as a FAT drive. The firmware finds BOOTX64.efi and runs it — that's our main(), which then loads the kernel and init 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

boot() runs four steps in order:

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's linked 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 load at physical 0x100000 (1 MiB) — set by exe.image_base in build.zig and the linker script. UEFI identity-maps memory, so the physical address the ELF asks for is the address it actually runs at. If a segment's p_paddr collided with firmware-reserved memory, allocatePages would fail and we'd need to move image_base.

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

const kernel: *const fn (*const BootInfo) callconv(boot_handoff.kernel_abi) noreturn =
    @ptrFromInt(entry);
kernel(&boot_info);

We cast the entry address to a function pointer and call it, passing a pointer to the BootInfo we filled in. 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_info in RCX while the kernel looked for it in RDI — and the kernel would read garbage.

So both sides pin the convention explicitly to SysV via the shared boot_handoff.kernel_abi (defined in system/boot-handoff.zig). The loader's function-pointer type and the kernel's _start both reference it, so the pointer lands in the register the kernel expects. This is the whole reason kernel_abi lives in the shared boot-handoff module: 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 (system/devices/device-abi.zig) are separate contracts the bootloader never sees.

  • BootInfo — the top-level struct passed to the kernel (currently just the framebuffer; this is where future handoff data like the memory map will go).
  • Framebuffer, PixelFormat, kernel_abi — the shared field layouts and the calling convention.

Both 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
       -> queryFramebuffer   (via GOP: EDID native res, setMode, describe fb)
       -> loadKernel         (read system/kernel ELF, load PT_LOAD segments to 0x100000)
       -> exitBootServices   (retry until the memory-map key holds)
       -> jump to e_entry, boot_info pointer in RDI
  -> kernel _start           (system/kernel/kernel.zig: framebuffer console, then halt)

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 BootInfo, tears down the firmware, and jumps into the kernel — after which we're on our own.