Sector 0: Writing a Minimal Bootloader in x86 Assembly
Bypass the operating system entirely. Learn how the CPU wakes up in 16-bit Real Mode and write a bare-metal bootloader from scratch.
Press the power button and for a brief window, there is no operating system. No memory protection, no filesystem, no libc. The CPU comes out of reset running code from a ROM chip on the motherboard — the BIOS or UEFI firmware — and the first thing it does is look for something to hand control to.
That something is your bootloader.
Every OS kernel ever written went through this moment: a tiny piece of code, loaded at a fixed address, with no runtime support whatsoever, responsible for setting up enough of an environment to get the rest of the system going. Writing one from scratch is the fastest way to understand what all the abstractions above it are actually protecting you from.
One important note before diving in: this article covers the legacy BIOS/MBR boot process, which has been the standard since the IBM PC. Modern systems use UEFI, which loads PE-format executables from a FAT32 partition and starts them in 32-bit or 64-bit protected mode directly — a considerably different process. The BIOS/MBR model is still the right starting point for understanding the fundamentals, and QEMU emulates it perfectly, but if you’re writing a bootloader for actual 2024 hardware you’ll need to target UEFI.
16-Bit Real Mode
Every x86 processor, regardless of whether it’s a modern 64-bit chip, wakes up in 16-bit Real Mode. This is a backward compatibility constraint stretching back to the Intel 8086 from 1978. The entire industry decided that every new x86 processor would boot identically to the original chip, and that decision has held for over four decades.
In Real Mode, there is no virtual memory and no memory protection. Writing to 0xB8000 physically writes to the VGA text buffer in RAM. Writing to the wrong address will corrupt whatever happens to be there — possibly the code you’re currently executing.
Registers are 16 bits wide, which limits a single register to addressing 64 KB. To reach more memory, Intel used segmentation: a physical address is computed from a segment register and an offset:
The multiply-by-16 is a 4-bit left shift — effectively appending a nibble of zeros to the segment value. So segment 0x07C0 and offset 0x0000 gives:
This is the same physical address as segment 0x0000, offset 0x7C00 — both expressions are 0x07C00. This aliasing is deliberate: different segment:offset pairs can point to identical physical locations. It’s a frequent source of confusion when reading bootloader code, because the initial segment register values after BIOS handoff are not guaranteed by the spec and vary by firmware implementation. Explicitly zeroing or setting the segment registers is mandatory, not optional.
Sector 0 and the Boot Signature
The BIOS completes its Power-On Self-Test (POST), then scans storage devices looking for a bootable one. The detection mechanism is minimal: read the first 512 bytes of the disk (Sector 0, the Master Boot Record) into RAM at address 0x7C00, then check the last two bytes. If they are 0x55 and 0xAA in that order — the boot signature — the BIOS jumps to 0x7C00 and execution begins.
The little-endian detail: dw 0xAA55 in NASM emits bytes 0x55 0xAA in memory (low byte first), which is exactly what the BIOS expects at offsets 510–511. The dw value looks backwards compared to the “magic number” description you’ll see in specs, but the on-disk byte order is what matters.
From the moment the BIOS jumps to 0x7C00, the firmware is out of the picture. No safety net. The CPU is executing whatever 512 bytes you put there.
The Bootloader
Our goal: print a string to the screen and halt. Simple enough as a goal; the setup to get there safely is what’s interesting.
[bits 16] ; 16-bit code generation[org 0x7c00] ; Addresses are relative to where we're loaded
start: ; ------------------------------------------------------- ; Step 1: Safe state ; ------------------------------------------------------- cli ; Disable interrupts while configuring the stack. ; An interrupt firing mid-setup would vector through a ; partially initialized IVT and likely hang.
xor ax, ax ; Fastest way to zero a register (2 bytes vs 3 for mov ax, 0) mov ds, ax ; Segment registers can't be loaded with immediates; mov es, ax ; they must go through a general-purpose register. mov ss, ax
; Stack pointer at 0x7C00, growing downward from there. ; Our code grows upward from 0x7C00. As long as our bootloader stays ; well under 512 bytes and stack usage is shallow, they won't collide. ; For deeper stacks, 0x9000 is a safer choice (well clear of both the ; bootloader and the BIOS data area at 0x0400–0x04FF). mov sp, 0x7c00
sti ; Re-enable interrupts.
; ------------------------------------------------------- ; Step 2: Print the message ; ------------------------------------------------------- mov si, msg ; SI points to the first byte of our string
print_loop: lodsb ; AL = [DS:SI], SI++ test al, al ; Set ZF if AL == 0 (null terminator), without modifying AL jz halt ; If ZF set, we're done
mov ah, 0x0E ; BIOS TTY output function mov bh, 0x00 ; Display page 0 mov bl, 0x07 ; Foreground color (light grey) — some BIOSes require this int 0x10 ; BIOS Video Services interrupt
jmp print_loop
; ------------------------------------------------------- ; Step 3: Halt ; -------------------------------------------------------halt: cli ; Disable interrupts. hlt ; CPU enters low-power halt state, waiting for an interrupt. ; With CLI active, maskable interrupts won't wake it. jmp halt ; Non-Maskable Interrupts (NMIs) bypass CLI and can still ; wake the CPU — the loop ensures we re-halt if that happens.
; ------------------------------------------------------- ; Step 4: Data section ; -------------------------------------------------------msg db "Sector 0: Real Mode active. Awaiting further instructions.", 0
; Pad to 510 bytes, then append the boot signature.; ($ - $$) = bytes used so far in this section.times 510 - ($ - $$) db 0dw 0xAA55A Few Assembly Notes
test al, al vs or al, al: Both set the Zero Flag when AL is 0. test is the conventional choice — it performs a bitwise AND and discards the result, leaving AL unchanged. or al, al also leaves AL unchanged (OR of a value with itself is the same value), but test more clearly signals intent.
lodsb: A single-byte instruction that loads [DS:SI] into AL and increments SI. The equivalent mov al, [si]; inc si is three bytes and two instructions. On a 512-byte budget, compactness matters.
Why xor ax, ax instead of mov ax, 0: The XOR encodes in 2 bytes; the MOV with an immediate takes 3. Both produce the same result. x86 assembly tutorials use this idiom constantly — it’s worth recognizing on sight.
The BIOS Interrupt API
Without an OS, writing directly to hardware requires knowing the hardware’s register interface in detail. For the VGA controller, that’s a non-trivial amount of I/O port programming. The BIOS saves us from that by leaving a software API in place: the Interrupt Vector Table (IVT), stored at physical addresses 0x0000–0x03FF. Each 4-byte entry is a real-mode segment:offset address pointing to a BIOS routine in ROM.
int 0x10 triggers interrupt 16 (decimal). The CPU saves state, looks up entry 16 in the IVT, and jumps to the corresponding ROM routine. We communicate with it through registers: AH = 0x0E selects the TTY output subfunction, AL holds the character, and the routine handles the rest before returning to our print_loop.
This is the entire driver model in Real Mode: shared register conventions and a table of function pointers into firmware. It’s primitive by design — sufficient for getting a kernel loaded, not intended for anything more.
Build and Test
Assemble to a flat binary — no ELF headers, no linker, raw machine code:
nasm -f bin bootloader.asm -o bootloader.binVerify the output is exactly 512 bytes and check the signature:
wc -c bootloader.bin # Should print 512hexdump -C bootloader.bin | tail -2 # Last line should show 55 aa at offset 0x1FERun in QEMU without touching any real disk:
qemu-system-x86_64 -drive format=raw,file=bootloader.binExpected output in the QEMU window:
Sector 0: Real Mode active. Awaiting further instructions.What Comes Next
A real bootloader doesn’t stop here. After printing a message, the next job is loading additional sectors from disk into RAM (the BIOS int 0x13 disk services handle this in Real Mode), then transitioning to 32-bit Protected Mode.
That transition requires setting up the Global Descriptor Table (GDT) — a structure in memory that defines memory segment descriptors with base addresses, limits, and privilege levels. Once the GDT is loaded via the lgdt instruction and Protected Mode is enabled by setting bit 0 of the CR0 control register, segmentation works differently and the CPU can address the full 4 GB address space. Virtual memory, ring privilege levels, and hardware memory protection all become available at that point.
The A20 line is another prerequisite often encountered here: on original IBM PC hardware, address line 20 was gated off for compatibility reasons, limiting addressable memory to 1 MB even in protected mode. Enabling A20 (typically through a BIOS call or direct keyboard controller I/O) is a standard step before entering protected mode. Most emulators handle it automatically, but physical hardware may not.
The 512 bytes above are the entry point to all of that. Everything an OS does — process isolation, filesystems, networking — sits on top of this initial handshake between firmware and software. Writing it manually is the clearest way to see exactly where the hardware ends and the software begins.
Author
Varkin Academy