DEV Community

Harrison Guo
Harrison Guo

Posted on Originally published at harrisonsec.com

Building a Bootloader from Scratch — Just Assembly, No OS

Everything you write at work stands on a tower of things that were already set up for you: a heap, a stack, threads, a filesystem, a C library, an operating system underneath all of it. Writing a bootloader is what it feels like when none of that exists yet. The BIOS copies 512 bytes off the disk to memory address 0x7c00, jumps to it in 16-bit real mode, and steps back. No OS, no libc, no memory manager. Whatever happens next, you write. This walks a hand-written Stage-1 boot sector line by line — the code from NanoBoot — because every constraint it hits is one of those abstractions becoming visible.

The rules the hardware makes for you

A boot sector isn't a program in any normal sense. Three hard constraints define it, and they come from the machine, not from you:

[bits 16]            ; the CPU starts in 16-bit real mode
[org 0x7c00]         ; BIOS loads this sector to physical 0x7c00
; ... exactly 512 bytes ...
    times 510-($-$$) db 0   ; pad the rest with zeros
    dw 0xAA55               ; the last two bytes MUST be this signature
Enter fullscreen mode Exit fullscreen mode

The sector is exactly 512 bytes. Its final two bytes must be 0xAA55 or the BIOS refuses to boot it — that signature is the entire handshake. It loads at 0x7c00 and runs in 16-bit real mode, where a physical address is segment << 4 + offset and there is no memory protection at all. [org 0x7c00] tells the assembler to compute label addresses from that base, because nothing relocates you — the address the BIOS chose is the address you live at.

First moves: build your own ground

The BIOS hands you the CPU in an indeterminate state. Before anything else, you establish the world your code assumes exists:

start:
    cli                 ; no interrupts until we have a stack and segments
    cld                 ; string ops count upward
    xor ax, ax
    mov ds, ax          ; data segment = 0
    mov es, ax          ; extra segment = 0
    mov ss, ax          ; stack segment = 0
    mov bp, 0x7c00
    lea sp, [bp-0x20]   ; put a stack just below our own code
    sti
Enter fullscreen mode Exit fullscreen mode

Every line here is something an OS would have done for you. There is no stack until you point SS:SP at some memory you've decided is free. There's no notion of "the data segment" until you load one. You turn interrupts off (cli) precisely because there is no interrupt handling set up yet, do the setup, then turn them back on. This is the layer where "the runtime" is just you, by hand.

Your only library is the BIOS

With no OS, the one API you have is BIOS interrupt services. Printing a string means calling the teletype service, int 0x10, one character at a time:

print_string:
    mov ah, 0x0E        ; BIOS teletype output
.loop:
    lodsb               ; AL = [SI++]
    test al, al         ; NUL terminator?
    jz .done
    int 0x10            ; print AL
    jmp .loop
.done:
    ret
Enter fullscreen mode Exit fullscreen mode

That is the entire implementation of "print." No printf, no buffering, no stdout — a loop, a register, and a firmware call. Reading from disk is the same shape, through int 0x13.

The 512-byte wall, and the jump that gets you past it

Here's where the constraint bites: 512 bytes is nowhere near enough to do anything real. So a boot sector's actual job is to bootstrap something bigger. Stage-1 uses the BIOS disk service to pull more sectors off the disk into memory, then hands control over:

    ; load Stage2 to 0x6000 using BIOS int 13h
    mov ax, 0x6000
    mov es, ax
    xor bx, bx
    mov ah, 0x02        ; read sectors
    mov al, 16          ; 16 sectors
    mov ch, 0           ; cylinder 0
    mov cl, 2           ; from sector 2 (sector 1 is us)
    mov dh, 0           ; head 0
    mov dl, [boot_drive]
    int 0x13
    jc  load_error      ; carry set = read failed

    jmp 0x6000:0        ; far jump into Stage2
Enter fullscreen mode Exit fullscreen mode

That last instruction, jmp 0x6000:0, is a real-mode far jump — it reloads CS and continues execution in the freshly loaded second stage. It's also the exact instruction that breaks GDB's symbol resolution when you debug across it, which is its own field note. And Stage-2, now with room to breathe, is where the switch out of real mode into protected mode via the GDT and IDT happens. The 512-byte sector exists to reach that point.

Why do this if you'll never ship one

You won't hand-write a boot sector at work. The value is in what it makes visible:

  • Every abstraction you normally stand on, absent. No heap, no stack, no library, no scheduler — you build each one or do without. Once you've felt where they aren't, you understand what they actually do when they're there.
  • The trust chain starts here. "Secure boot," firmware trust, and the whole idea of a chain of custody from power-on to kernel begin at this 512-byte sector and its signature. You can't reason clearly about boot-level security if the boot process is a black box.
  • BIOS/firmware is a real attack and reliability surface. Knowing that the first code to run is unprivileged-by-nothing, in a mode with no memory protection, reframes how you think about what "the bottom of the stack" even is.

The deeper payoff is calibration. After you've made a computer print one character with nothing but a firmware call and a byte in a register, "hello world" stops looking simple and starts looking like the top of a very tall stack — which is exactly what it is.

Related

Top comments (0)