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_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 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. (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 asks to be loaded 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 asks to be loaded at (
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 ownPT_LOADsegments (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.