8.1 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 src/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:
esp/EFI/BOOT/BOOTX64.efi <- the "removable media" default for x86-64
That's exactly the layout build.zig assembles. It builds src/efi.zig for the
uefi target, installs it to esp/EFI/BOOT/BOOTX64.efi, and drops the kernel ELF
at esp/danos. The run-efi step then points QEMU at OVMF (UEFI firmware for
virtual machines) and presents that esp/ directory to the guest as a FAT drive.
The firmware finds BOOTX64.efi and runs it — that's our main().
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_ACTIVEthenEDID_DISCOVEREDprotocol 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
queryModeandsetModeto 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 file named
danos, seek to the end to learn its size, rewind, and read the whole ELF into a firmware-allocated pool buffer. (readmay return short, so we loop.) - Parse the ELF: validate the
\x7fELFmagic and thex86_64machine type, then walk the program headers. For everyPT_LOADsegment we:- reserve the exact physical pages it's linked at (
p_paddr) viaallocatePages, @memcpythe file-backed bytes to that address,@memsetthe.bsstail (the part wherep_memsz > p_filesz) to zero.
- reserve the exact physical pages it's linked at (
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(danos.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
danos.kernel_abi (defined in src/root.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
danos module: it's a contract both binaries must agree on.
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 src/root.zig provides — imported by both as the danos module:
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 esp/EFI/BOOT/BOOTX64.efi, runs it (our efi.zig main)
-> grab boot services
-> queryFramebuffer (via GOP: EDID native res, setMode, describe fb)
-> loadKernel (read danos 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 (src/main.zig: framebuffer console, then halt)
Bottom line: UEFI's job is to give us a CPU, memory, and a framebuffer, then
disappear. src/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.