Case Study 28-2: UEFI Booting — The Modern Alternative
How UEFI Boot Works
The BIOS/MBR boot mechanism dates from 1981. It works, but its constraints (512-byte boot sector, 1MB real mode, legacy PIC/PIT assumptions) are increasingly irrelevant to modern hardware. UEFI (Unified Extensible Firmware Interface) is the modern replacement, used by virtually every x86-64 system manufactured after 2012.
Understanding UEFI booting reveals how modern operating systems actually start, and introduces the PE32+ executable format, UEFI services, and the very different boot environment that modern OS kernels expect.
The UEFI Boot Environment
When UEFI firmware boots a system:
1. Firmware initializes hardware (much like BIOS POST, but more sophisticated)
2. Firmware loads an EFI application from the EFI System Partition (ESP) — a FAT32 partition on a GPT disk
3. The EFI application runs in 64-bit mode (or 32-bit on some systems) with paging enabled
4. The EFI application calls UEFI services through function pointer tables (the EFI System Table)
5. The OS bootloader (GRUB, Windows Boot Manager, rEFInd) uses these services to load the kernel, then calls ExitBootServices to take full control
Key Differences from BIOS
| BIOS/MBR | UEFI |
|---|---|
| Real mode (16-bit) at handoff | 64-bit mode at handoff |
| 512-byte MBR constraint | FAT32 application, any size |
| Software interrupts for BIOS calls | Function pointer tables (protocols) |
| MBR partition table | GPT (GUID Partition Table) |
| No Secure Boot | Secure Boot (signature verification) |
| No network boot built-in | PXE network boot built-in |
| Read disk sectors only | Full FAT32 filesystem driver |
The UEFI Application Binary Format
UEFI applications use the PE32+ format — the same as Windows executables. This is technically straightforward to produce: NASM can output object files, and the PE format is well-documented.
# Build a minimal UEFI application
# Using LLVM (Clang) to produce PE32+ from C is the practical approach
# Pure NASM PE32+ is possible but verbose
# Method 1: Use a cross-compiler toolchain
apt-get install gnu-efi
# Provides UEFI headers and link scripts for building EFI applications with gcc
# Method 2: EDK2 (UEFI reference implementation)
# Full toolchain for building UEFI applications
A Minimal UEFI Application in C
The "Hello, UEFI" example demonstrates the API:
// hello_efi.c — A minimal UEFI application
// Build: see below
// Tests in QEMU with OVMF UEFI firmware
#include <efi.h>
#include <efilib.h>
// EFI entry point: called by firmware with SystemTable
// Parameters are standard UEFI ABI (Microsoft x64 calling convention!)
EFI_STATUS EFIAPI efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) {
// Initialize GNU-EFI library
InitializeLib(ImageHandle, SystemTable);
// Print to UEFI console (uses UTF-16 strings — note L"" prefix)
Print(L"Hello from UEFI!\r\n");
Print(L"We are in 64-bit mode with UEFI services available.\r\n");
// Get memory map (equivalent to BIOS E820, but more detailed)
UINTN MapSize = 0, MapKey, DescriptorSize;
UINT32 DescriptorVersion;
EFI_MEMORY_DESCRIPTOR *MemoryMap = NULL;
// First call: get required buffer size
EFI_STATUS Status = SystemTable->BootServices->GetMemoryMap(
&MapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion);
// Status = EFI_BUFFER_TOO_SMALL, MapSize = required size
// Allocate and get the real map
MapSize += 2 * DescriptorSize; // add slack for AllocatePool changing the map
SystemTable->BootServices->AllocatePool(EfiLoaderData, MapSize, (VOID**)&MemoryMap);
Status = SystemTable->BootServices->GetMemoryMap(
&MapSize, MemoryMap, &MapKey, &DescriptorSize, &DescriptorVersion);
// Print memory map
EFI_MEMORY_DESCRIPTOR *Desc = MemoryMap;
UINTN EntryCount = MapSize / DescriptorSize;
for (UINTN i = 0; i < EntryCount; i++) {
if (Desc->Type == EfiConventionalMemory) {
Print(L"RAM: 0x%016lx - 0x%016lx (%lu MB)\r\n",
Desc->PhysicalStart,
Desc->PhysicalStart + Desc->NumberOfPages * 4096,
Desc->NumberOfPages / 256);
}
Desc = (EFI_MEMORY_DESCRIPTOR*)((UINT8*)Desc + DescriptorSize);
}
// Hand off to OS kernel
// 1. Load kernel from ESP
// 2. Set up page tables as needed
// 3. ExitBootServices(ImageHandle, MapKey) — firmware services unavailable after this
// 4. Jump to kernel entry point
// For demo: just wait for keypress and return
SystemTable->ConIn->Reset(SystemTable->ConIn, FALSE);
EFI_INPUT_KEY Key;
while (SystemTable->ConIn->ReadKeyStroke(SystemTable->ConIn, &Key) == EFI_NOT_READY);
return EFI_SUCCESS;
}
# Build with gnu-efi
gcc -I/usr/include/efi -I/usr/include/efi/x86_64 \
-fpic -ffreestanding -fno-stack-protector -fno-stack-check \
-fshort-wchar -mno-red-zone \
-Wall -std=c11 -c hello_efi.c -o hello_efi.o
ld -shared -Bsymbolic \
-L/usr/lib -T/usr/lib/elf_x86_64_efi.lds \
/usr/lib/crt0-efi-x86_64.o hello_efi.o -o hello_efi.so \
-lefi -lgnuefi
objcopy -j .text -j .sdata -j .data -j .dynamic \
-j .dynsym -j .rel -j .rela -j .reloc \
--target=efi-app-x86_64 --subsystem=10 \
hello_efi.so hello_efi.efi
# Test in QEMU with OVMF (UEFI firmware for QEMU)
apt-get install ovmf
# Create ESP disk image
dd if=/dev/zero of=esp.img bs=1M count=64
mkfs.vfat -F 32 esp.img
mkdir -p /tmp/esp/EFI/BOOT
cp hello_efi.efi /tmp/esp/EFI/BOOT/BOOTX64.EFI
mount esp.img /tmp/esp
cp hello_efi.efi /tmp/esp/EFI/BOOT/BOOTX64.EFI
umount /tmp/esp
# Run in QEMU with OVMF
qemu-system-x86_64 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=OVMF_VARS.fd \
-drive format=raw,file=esp.img \
-net none
The EFI System Table: The UEFI API
The EFI_SYSTEM_TABLE pointer passed to efi_main is the UEFI API. It contains:
- ConIn: keyboard input
- ConOut: text output (supports ANSI-like cursor control)
- ConErr: error output
- RuntimeServices: services that persist after ExitBootServices (time, variable storage)
- BootServices: services available only during boot phase (memory allocation, file I/O, network)
BootServices is the rich API: AllocatePages, AllocatePool, FreePool, LoadImage, StartImage, GetMemoryMap, LocateProtocol, and many more. This is everything the BIOS lacked.
What Happens at ExitBootServices
ExitBootServices is the point of no return. After calling it:
- UEFI boot services become unavailable
- The OS now owns all memory and hardware
- The firmware has exited all its interrupt handlers
- The OS must set up its own IDT, paging, and drivers
The Linux boot protocol requires that the kernel receive:
1. A valid memory map (from GetMemoryMap)
2. The MapKey value (used in the ExitBootServices call)
3. The UEFI runtime services pointer (for runtime access to firmware variables and time)
4. The framebuffer address (from the GOP — Graphics Output Protocol)
The MinOS UEFI bootloader would do all of this before jumping to the kernel entry point. The difference from BIOS: the kernel starts in 64-bit mode with a rich memory map, known-good page tables, and no need to handle the real-mode-to-protected-mode transition.
⚡ Performance Note: UEFI boot is measurably faster than BIOS boot on modern systems, primarily because UEFI firmware initializes hardware in parallel and has better power-on self-test implementations. The actual bootloader code runs slower (more functionality), but the overall time from power-on to "OS kernel running" is shorter.