An x86-64 UEFI bootloader written from scratch in C: no GRUB, Limine, gnu-efi or libc. It boots three kinds of entries:
- Linux kernels through their EFI stub, with initrds and a command line.
- Other EFI programs: Windows Boot Manager (found automatically), another loader, a UEFI shell.
- Kernels for its own boot protocol: ELF64, with modules, page tables
built for the kernel, and a documented machine state
(
include/bootinfo/bootinfo.h).
Status: the UEFI path is complete and tested in QEMU + OVMF. The legacy BIOS backend is not started. It has not yet been run on real hardware.
Toolchain (Windows): LLVM (clang, lld-link, ld.lld), QEMU, GNU make,
run from Git Bash.
make [CONFIG=debug|balanced|release] # loader + test kernel into build/$CONFIG/esp
make run # boot that ESP in QEMU (window + serial)
make test # one headless boot; passes on exit status 33
make test-all # every scenario in tests/run.sh
make test-fs # filesystem driver on the host (needs Python, 7-Zip)
make test-csme # CSME detection and HBM on the host, simulated device
make iso # build/$CONFIG/myboot.iso, hybrid CD/USB image
make size # binary sizesThe loader prints a boot log in the style of systemd, one tagged line per step:
[ OK ] Read configuration \EFI\MYBOOT\BOOT.CFG (2 entries)
Booting 'Test kernel with self-test module'
[ OK ] Loaded kernel \EFI\MYBOOT\KERNEL.ELF (46 KiB)
[FAILED] cannot open module: \EFI\MYBOOT\NOPE.MOD (not found)
Every build logs the files it loads, errors and config warnings. debug and
balanced also log what the hardware discovery found, and debug adds
detail lines (CPU features, ACPI tables, timings). The loader never waits on
purpose: its lines appear as fast as the work is done.
The kernel receives the same log (loader_log in boot_info). The test
kernel shows it again on its own screen, followed by its checks, one line at
a time when its command line has log_delay=MS (the shipped BOOT.CFG uses
40 ms).
make test-all also chainloads a small test program and, when a Linux
kernel with an EFI stub is available (LINUX_KERNEL, LINUX_INITRD; the
WSL kernel by default), boots Linux with an initrd.
\EFI\BOOT\BOOTX64.EFI the loader (removable-media path), or anywhere else
\EFI\MYBOOT\BOOT.CFG configuration (optional)
\EFI\MYBOOT\KERNEL.ELF kernel used when there is no BOOT.CFG
\EFI\MYBOOT\... kernels and modules BOOT.CFG names
Without BOOT.CFG the loader boots \EFI\MYBOOT\KERNEL.ELF and passes the
firmware boot entry's load options as the command line.
# comment
timeout 3 # seconds the menu waits; 0 or absent = no menu
default 1 # 1-based entry booted when the countdown ends
entry My kernel # title shown in the menu
kernel KERNEL.ELF # relative paths are under \EFI\MYBOOT\
cmdline root=/dev/nvme0n1p2 quiet
module INITRD.IMG initrd # file, then the string the kernel sees with it
entry Known-good kernel
kernel /EFI/MYBOOT/KERNEL.OLD
cmdline root=/dev/nvme0n1p2
entry Linux
linux /EFI/linux/vmlinuz # any distro kernel (EFI stub)
initrd /EFI/linux/initrd.img # several initrd lines are concatenated
cmdline root=UUID=... rw quiet
entry Linux from /boot
volume uuid=0a3f... # the ext4 filesystem holding /boot (see below)
linux /boot/vmlinuz # symlinks such as /boot/vmlinuz are followed
initrd /boot/initrd.img
cmdline root=UUID=0a3f... rw quiet
entry UEFI shell
efi /EFI/tools/shellx64.efi # any EFI program; cmdline = its load options
Each entry has exactly one of kernel, linux or efi. Windows Boot
Manager needs no entry: if \EFI\Microsoft\Boot\bootmgfw.efi exists on any
partition, the menu lists it after your entries (auto_windows no turns
this off). The partitions are scanned only when the menu is shown.
The initrd is passed the way Linux 5.8 and later expect (the LoadFile2
protocol).
Without a volume line, an entry's files are on the partition the loader
was started from (the ESP). volume points the entry at another partition,
by the identifiers blkid and /etc/fstab use:
volume uuid=0a3f1c2e-... # filesystem UUID (ext4), or FAT serial like 1A2B-3C4D
volume partuuid=7e57d15c-... # GPT partition GUID, or MBR "1234abcd-02"
volume label=rootfs # filesystem label (case-sensitive)
Case and dashes in UUIDs do not matter. The loader reads FAT through the
firmware and ext2, ext3 and ext4 with its own read-only driver, so
/boot on a typical Linux install works directly, with no copying to the
ESP after kernel updates. On ext4, names are case-sensitive, symlinks are
followed, and relative paths still mean /EFI/MYBOOT/, so give absolute
paths. If no partition matches, the entry fails and the next one is tried.
The ext4 driver reads extents, ext2/3 block maps, 64-bit and meta_bg
layouts, and htree directories. It does not read encrypted (fscrypt) files
or inline data larger than 60 bytes, and it does not replay the journal: a
/boot that was not cleanly unmounted may show the state of its last
checkpoint. Partitions are scanned only when an entry names a volume or
the menu looks for Windows.
- Up to 9 entries, 8 modules or initrds each. Paths may use
/or\, at most 127 characters, no spaces. The cmdline can be up to 1023 characters, and a module string up to 111. - Fallback: if the chosen entry cannot be loaded (missing file, invalid ELF, out of memory), the loader frees what it loaded and tries the next entry, then the rest in order. Keep a known-good kernel as the last entry. A Linux kernel or EFI program that started and then returned does not fall back; the recovery screen appears instead.
- A line the loader cannot use is reported with its line number and skipped. The rest of the file still works. The errors also appear at the top of the menu.
MYBOOT boot menu
1) My kernel <- highlighted selection
2) Known-good kernel
Firmware setup (only if the firmware supports it)
Reboot
Power off
Return to firmware
Up/Down select, Enter boot, e edit command line, 1-9 boot that entry
Booting the selected entry in 10 s. Press any key to stop.
- Up/Down, Home/End move the selection. Enter activates it. 1-9 boot that entry at once. Any key stops the countdown.
- e edits the selected entry's command line before booting it: Left, Right, Home and End move the cursor, and Backspace and Delete erase. Enter boots with the edited line and Esc goes back. The change applies to this boot only.
- Firmware setup reboots into the firmware's own settings, using the UEFI OsIndications variable. Return to firmware hands control back to the firmware's boot manager, which tries the next boot option.
- With
timeout 0there is no menu. To get it anyway, tap any key while the machine starts. - Recovery screen: if every entry fails to load, the loader shows the errors and the same menu. You can retry an entry, edit its command line, open firmware setup, reboot or power off. After 10 s without a key it returns to the firmware, so an unattended machine still moves on to its next boot option.
On Intel CPUs the loader looks for the Converged Security and Management
Engine through its host interface, HECI1 (PCI 00:16.0). It reports what it
finds in the boot log and in boot_info.csme:
[ OK ] Detected Intel CSME: Tiger Lake-LP (HECI 8086:A0E0), normal, firmware 15.0.42.2235
[ OK ] Talked to Intel CSME over HBM 2.2: 11 clients (MKHI, AMTHI, watchdog, 8 unnamed)
[ OK ] Intel CSME MKHI client answered over HBM: firmware 15.0.42.2235
[ INFO ] Detected Intel CSME: Alder Lake-P (HECI 8086:51E0), normal, mode: soft temporary disable
[ INFO ] No Intel CSME visible (HECI hidden or absent)
[ WARN ] Intel CSME did not answer at MKHI's fixed address: no reply within 1 s
It works in three steps:
- Status, read-only. The loader reads configuration space through ECAM
(from the ACPI
MCFGtable), or through ports0xCF8/0xCFCwhen there is no MCFG. This gives the device ID, which names the chipset, and the firmware status registers HFSTS1-6. From HFSTS1 the loader decodes the working state, operating mode, error code and whether init is complete.boot_info.csme.hfsts[]holds all six raw registers. - Firmware version at MKHI's fixed address. The loader sends one MKHI
GET_FW_VERSIONrequest to address 7, the way firmware does, and reads the reply. - An HBM session. The loader speaks the HECI bus protocol, as Linux's
meidriver does:HOST_START, enumerate the ME clients, read each one's properties (UUID, protocol, message size), then ask the MKHI client for the firmware version through it. A fixed-address client is used directly; any other is connected to with flow control and disconnected afterwards. The session ends withHOST_STOP, whose answer is optional (Arrow Lake sends none). Known client UUIDs are named (MKHI, AMTHI, watchdog, HDCP, PAVP, NFC, DAL). The debug build logs every client on its own line.
Steps 2 and 3 run only if the CSME is in normal state and normal mode, and they have limits:
- They use only a memory BAR the firmware already assigned.
- They use the link only if the firmware left both sides ready and both buffers empty. They never reset the link.
- If memory decoding was off, they turn it on for the exchange and then restore the Command register.
- They run at
TPL_HIGH_LEVEL, so no firmware timer event can use HECI in the middle. A CSME that does not answer holds that for up to 1 s per step.
boot_info.csme records how each went (version_status, hbm_status,
mkhi_status) and how many clients the session found (client_count).
Any failure leaves that part unknown. The log line says why, and booting
continues. To drop steps 2 and 3 and keep only the read-only status, run
make clean and then make CSME_QUERY=0.
Limits:
- "No Intel CSME visible" can mean the CSME is fused off, the firmware hid HECI1 (a common BIOS option), or the platform has none. The loader cannot tell these apart.
- Some firmware closes the MKHI fixed-address client after End-of-POST. On those machines step 2 fails with "request refused" or a timeout, and the version comes from step 3 instead.
- If the firmware itself already started an HBM session and left it open,
the CSME may reject the second
HOST_STARTor reset the link. The loader reports that and does not retry. Linux resets the link when it loadsmei_me, so the kernel is unaffected either way. - HFSTS1 bit 4 means manufacturing mode on older CSMEs and SPI protection
on newer ones, so the loader does not decode it. The raw value is in
hfsts[0]. - Chipset names come from a short table of HECI device IDs from Linux's
hw-me-regs.h. Other IDs show only the hex ID. - QEMU has no HECI device, so
make test-allonly covers the "not visible" path.make test-csmecovers the rest against a simulated device. Results on real hardware are unverified. To check them under Linux, compare with/sys/class/mei/mei0/fw_verandfw_status.
Secure Boot must be off, because the loader is unsigned. To keep Secure Boot
on, you have to enroll your own key in db and sign BOOTX64.EFI with it.
- Format a USB stick as FAT32.
- Copy the contents of
build/release/esp/(orbuild/debug/esp/) to the root of the stick, so it hasEFI\BOOT\BOOTX64.EFI. Alternatively, writebuild/$CONFIG/myboot.isoto the stick with Rufus (DD mode) ordd. - Pick the stick in the firmware's one-time boot menu (often F12, F8 or Esc).
This changes nothing on the internal disk. The test kernel prints its checks on the screen and on COM1. On real hardware it halts afterwards, because it only exits QEMU when it detects a hypervisor.
If the system drive uses BitLocker (it does by default on many laptops),
suspend it first and have the recovery key at hand. A change to the boot
configuration can otherwise trigger the recovery prompt:
Suspend-BitLocker -MountPoint C: -RebootCount 1.
In an administrator PowerShell:
mountvol S: /S # mount the EFI system partition as S:
New-Item -ItemType Directory S:\EFI\MYBOOT
Copy-Item build\release\esp\EFI\BOOT\BOOTX64.EFI S:\EFI\MYBOOT\
Copy-Item build\release\esp\EFI\MYBOOT\* S:\EFI\MYBOOT\
bcdedit /copy "{bootmgr}" /d "MYBOOT" # prints the new entry's {GUID}
bcdedit /set "{GUID}" path \EFI\MYBOOT\BOOTX64.EFI
bcdedit /set "{fwbootmgr}" displayorder "{GUID}" /addlast
mountvol S: /DThis adds MYBOOT as an extra firmware boot entry, last in the order, so Windows still boots by default. Choose MYBOOT from the firmware boot menu.
- Never overwrite
\EFI\BOOT\BOOTX64.EFIor\EFI\Microsoft\on a disk Windows boots from. - To uninstall:
bcdedit /delete "{GUID}", then mount the ESP again and deleteS:\EFI\MYBOOT.
On Linux, the equivalent of the bcdedit steps is
efibootmgr -c -d /dev/nvme0n1 -p 1 -L MYBOOT -l '\EFI\MYBOOT\BOOTX64.EFI'.
include/bootinfo/bootinfo.h is the whole
contract: the kernel ELF requirements, machine state at entry, address space,
memory map types, modules, the raw EFI memory map for runtime services, and
the compatibility rule (fields are only appended; check size).
kernel/kernel.c is a working example that checks every
promise in the contract.
In short:
- ELF64
ET_EXEC, linked in the top 2 GiB (-mcmodel=kernel), at most 1 GiB. - Entry in long mode with interrupts off, no IDT, and
RDI=struct boot_info *. - All physical memory is reachable at
hhdm_base + phys. - SSE is on, and with XSAVE so are AVX and AVX-512 where the CPU has them:
xcr0andxsave_sizesay what to save on a context switch. - PAT is Linux's layout (entry 1 is write-combining), and the framebuffer is
mapped write-combining when it lies above the direct map (
BOOT_F_FB_WC). - Every other CPU in the ACPI MADT is already started and parked in 64-bit
mode on the kernel's page tables.
smp_cpuslists them. To release one, write a function address to itsgoto_address: it jumps there with its own 16 KiB stack andRDI= its entry, as in the test kernel'scheck_smp.
After ExitBootServices the firmware no longer owns the APs, so the loader
starts them itself, as Limine does. Before exiting it reads the MADT,
reserves a 16 KiB stack per AP, and copies a small trampoline
(src/arch/x86_64/smp_tramp.S) to a page
below 1 MiB. After exiting it follows the SDM's own sequence: an INIT to
"all processors excluding self", a wait, then two start-up IPIs (SIPIs) the
same way, pointing at that page. The wait is the SDM's 10 ms only on old
CPUs; like Linux, the loader waits 10 us on Intel Core 2 and later and AMD
family 10h and later. No IPI names a destination, so none can
reach the boot CPU, and there is no INIT de-assert (on real hardware it can
reach every processor, the boot CPU included). Each AP then goes from real
mode through protected mode to long mode on the kernel's page tables, loads
the same PAT and XCR0 as the boot CPU, finds its own entry by APIC ID, and
spins on goto_address. All APs run the trampoline at once, and the loader
waits at most 100 ms for them to park. A CPU that does not answer is left
BOOT_SMP_OFF. make test runs QEMU with 4 CPUs (QEMU_SMP).
When AP startup hangs on real hardware, debug builds help find where:
green squares in the top-left corner mark each step after
ExitBootServices, then come a row per CPU and three rows counting the
APs that reached the trampoline's 16-, 32- and 64-bit code. An exception
on the boot CPU (an NMI, a machine check) shows as a row of red squares,
one more than the vector number. smpstop=N on the command line halts
every AP at stage N (1: 16-bit, 2: 32-bit, 3: 64-bit, 4: before parking)
and keeps the squares up for 5 seconds.