Operating Systems · Boot and Firmware
Why Boot Loaders Are Split Into Stages
The 512-byte first sector, the real-mode address space that constrains where a second stage can live, and how chainloading hands control from firmware to a loader to a kernel.
A boot loader is the component that turns a firmware’s generic capability into a specific kernel running with specific parameters. Firmware knows how to read a disk and how to start a program; it does not know which kernel to start, where that kernel’s files live, or what command line it wants. The loader answers those questions. On legacy x86 it also has to answer them from inside 512 bytes, and that constraint is what produced the multi-stage architecture that every subsequent design has had to justify departing from.
The first sector and its arithmetic
The firmware’s last act on a legacy disk boot is to read the first sector into physical address 0x7C00 and jump to it. The address is a convention, not an architectural requirement, but it is fixed by the firmware and by every loader that expects to be loaded by it. A sector is 512 bytes, so the code available at entry is 512 bytes, of which the last two are a signature the firmware checks before jumping.
Everything about a first stage follows from those numbers. Below 0x7C00 there are about 31 KiB of conventional memory down to address zero, where the interrupt vector table sits. Above it, memory continues to roughly 0x9FC00, where the extended BIOS data area begins, and above that the video memory apertures occupy the region from 0xA0000 upward. The stack the firmware left is somewhere in the usable area and its position is not specified, so a loader sets its own before doing anything that calls a subroutine.
512 bytes cannot parse a filesystem. It cannot hold a partition table parser, a menu, a command line editor, and a disk driver. It can hold a handful of instructions that read more sectors from a known location and jump to them, and that is exactly what it does.
The classic layout uses a block list: the first stage contains, alongside its code, a list of (sector, count, destination) triples describing where the second stage lives on disk, written there by the installer at install time. Because the list is data, the first stage’s code is generic and the same binary works for any second-stage size, as long as the list fits. The alternative is to hardcode a contiguous extent, which fails as soon as the second stage is large enough to be fragmented by the filesystem’s allocator.
; stage one, executing at 0x7C00 in real mode
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; stack grows down from just below this code
sti
mov si, dap ; disk address packet for the second stage
mov dl, [boot_drive] ; BIOS leaves the boot drive number in DL
mov ah, 0x42 ; extended read, LBA-addressed
int 0x13
jc fail
jmp 0x0000:0x8000 ; second stage was loaded there
The int 0x13 call is the firmware’s disk service, and it is the only reason the first stage does not need its own driver. It reads by logical block address through a disk address packet, a structure whose first field is its own length: the base form is 16 bytes and addresses up to 32-bit block numbers, and the extended form is 24 bytes and carries a 64-bit block number. The packet also holds a transfer count in 512-byte sectors and a real-mode segment-offset pair for the destination. The second stage is loaded to 0x8000, above the first stage and below the region the firmware’s own data occupies.
Why the second stage is not simply loaded high
It would be natural to load the second stage at 1 MiB or above, out of the way of everything. Real mode cannot address that. Without the A20 line enabled, the 21st address bit is forced to zero, so a segment-offset pair that computes an address above 1 MiB wraps to the corresponding address modulo 1 MiB. Enabling A20 is a chipset-specific operation, done either through the keyboard controller’s output port, through a fast A20 gate in the system control port, or through a firmware call, and the method varies by machine. A first stage that needs the second stage above 1 MiB must therefore enable A20 first, and must do so without knowing which method works.
The conventional answer is to keep the second stage below 1 MiB and accept the space limit. The region from 0x8000 to roughly 0x9FC00 is about 600 KiB, enough for a filesystem driver and a menu. Loaders that need more, or that need to place a kernel at a specific physical address, switch to protected mode in the second stage and use flat addressing from then on. That switch is irreversible without a reset in the simple case, so it happens as late as possible.
What chainloading actually means
Chainloading is the delegation pattern: a loader, having done some work, transfers control to another loader and expects never to regain it. The firmware chainloads the first sector. The first sector chainloads the second stage. The second stage chainloads a partition’s boot record, or an EFI application, or a kernel.
Each hop has a contract. For a legacy partition boot record, the contract is that the record is loaded at 0x7C00, that DL holds the drive number, and that the record’s own code will relocate itself if it needs to, because the partition table occupies bytes 446 onward within the sector and a boot record that overwrites them cannot report a partition layout. For an EFI application, the contract is entirely different: the firmware loads a PE executable, passes it a handle to the loaded image and a pointer to the system table, and the application calls back into firmware services for everything from file access to memory allocation.
Chainloading is also how one loader starts another loader’s menu, which is common when a system has both a primary boot configuration and a secondary one on a different partition. The cost is state: the chainloaded component receives only what its contract specifies, so anything the previous stage learned (a validated kernel path, a measured hash, a chosen console) is lost unless it is passed through a documented structure or left in memory at an agreed address.
The kernel handoff contract
At the end of the chain, the loader must present the kernel with a defined machine state. On x86-64 that state is specified by the kernel’s own boot protocol: a boot_params structure at an address the loader passes in a register, with fields for the memory map, the command line pointer, the initramfs location and size, and a set of flag bits indicating which fields are valid. The protocol’s version is negotiated through a field the loader writes and the kernel checks, so an old loader and a new kernel can agree on a reduced set of features.
The loader is also responsible for the state the kernel does not want to establish itself. Paging must already be enabled with an identity mapping covering the kernel and its initial data, because the kernel’s entry code runs at its link-time virtual address and expects the mapping to exist. Interrupts must be disabled. The direction flag must be clear, so that string operations in the kernel’s early code behave as compiled. The other processors must be held in a known state, either parked in a firmware wait loop or started later by the kernel through a documented mechanism.
The handoff is where loaders differ most. A loader that reads a configuration file, decompresses a kernel image, and builds page tables has done a large amount of work that the firmware could have done; a loader that hands an EFI application’s entry point straight to a kernel stub has done very little. Both are legitimate, and the difference shows up in what the kernel must carry: a kernel booted by a minimal loader needs its own decompressor, its own early console, and its own memory detection fallbacks.
Failure modes at the boundary
The classic failure is a second stage that outgrows the space its first stage reserved. The block list is written at install time; a package upgrade that replaces the second stage with a larger one, without updating the list, produces a load that reads the right number of sectors into a region that is too small, overwriting the first stage’s own code. Loaders mitigate this by storing the list with a length and by refusing to load a second stage larger than the space available.
A second failure is the A20 state being wrong. If the second stage enables A20 and then chainloads a partition boot record that does not expect it, the record’s addressing may behave differently from what its code assumes, and the result is a hang whose cause is invisible. The convention is that A20 is enabled by whoever needs it and left enabled, but not every boot record follows it.
The third is memory-map disagreement: the loader reads the firmware’s map, uses some region for the kernel image, and does not mark that region reserved in the copy it passes on. The kernel then allocates over its own image. The boot protocol’s requirement that the loader update the map it passes is easy to state and easy to forget.
Handing control to a kernel in long mode requires more than a jump; the loader must have built page tables and entered a mode the kernel’s entry code assumes. The ordering constraints in that transition are covered in entering long mode from a boot loader, and the memory the loader describes at handoff is the same map discussed in how firmware describes physical memory.
References
- Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3: System Programming Guide — Intel Corporation
- GNU GRUB Manual — GNU Project
- Unified Extensible Firmware Interface Specification — UEFI Forum