Write your own boot sector in 512 bytes
A complete, working boot sector in NASM — what the BIOS guarantees, what it does not, and how to assemble it into a floppy image you can boot in a browser tab.
The smallest useful program on a PC is 512 bytes long, and about thirty of those bytes exist only to undo the mess the firmware left behind. Everything a boot sector does — printing a character, loading a sector, deciding what happens next — has to fit in what is left. It is the last place in modern computing where you can hold the entire program in your head, and the only place where the hardware talks back to you directly.
TempMV ships one. The catalog entry called TempMV BootROM is a hand-written boot sector — scripts/bootrom/boot.S in the project, assembled and padded into a 1.44 MB floppy image — that prints a line and stops. There is no operating system on that disk. What follows is how to write your own, and how to watch it run.
What the BIOS actually hands you
The BIOS reads the first sector of a candidate device, copies those 512 bytes to physical address 0x7C00, checks that the last two bytes are 0x55 0xAA, and jumps. That is the whole handoff. It is worth being precise about what survives that jump, because almost every first boot sector fails on something in the second list.
- Guaranteed: your 512 bytes are at physical
0x7C00.DLholds the drive code you were loaded from —0x00for the first floppy,0x80for the first hard disk. The CPU is in 16-bit real mode with interrupts on, and the BIOS interrupt vectors are installed and working. - Not guaranteed:
CS:IP. Most BIOSes enter at0000:7C00, but some enter at07C0:0000— the same physical address, a different segment, and any absolute label in your code will be off by 0x7C00 if you assumed wrong. - Not guaranteed:
DS,ES,SSorSP. The OSDev wiki is blunt about it: you may not have a valid stack at all. Whatever the firmware happened to be using last is what you inherit. - Not guaranteed: the direction flag.
lodsband friends will walk backwards through your string ifDFis set and you never cleared it.
Fixing the machine before you use it
The opening of every serious boot sector is the same defensive sequence. A far jump to an explicit 0x0000 segment pins CS to a known value. Then interrupts go off, the data and stack segments are zeroed so that 0x7C00 really means offset 0x7C00, a stack pointer is planted somewhere safe, and interrupts come back on.
Where is safe? 0x7C00 itself. The stack grows downwards, so setting SP to 0x7C00 gives you nearly 30 KB of scratch below your own code before you hit the BIOS data area at 0x0400. Setting it to 0x7C00 and then pushing more than about 30,000 bytes is not a problem you will have in 512 bytes of assembly.
Printing without a driver
You have no operating system, so there is no write(). What you have is the BIOS, still resident, still hooked into the interrupt table. INT 10h with AH = 0x0E is teletype output: it takes one character in AL, writes it at the cursor, advances the cursor and scrolls the screen if it has to. BH selects the display page (0), and BL is the foreground colour, which only matters in graphics modes. It interprets four control characters — bell (7), backspace (8), line feed (0x0A) and carriage return (0x0D) — so you need both 13, 10 for a new line, not just 10.
That is enough for a string. Point SI at a NUL-terminated run of bytes, lodsb to fetch one and advance, stop at zero, int 0x10 on everything else.
The two bytes at offset 510
The signature is 0x55 at offset 510 and 0xAA at offset 511. Because x86 is little-endian, an assembler directive that emits the 16-bit word 0xAA55 produces exactly those bytes in that order — which is why you will see the constant written both ways in different documentation and both are correct.
Getting there means padding. In NASM, times 510-($-$) db 0 is the idiom: $ is the current address, $ is the address of the start of the section, so $-$ is how many bytes you have emitted, and the directive repeats a zero byte for however many are left. If your code overflows 512 bytes the expression goes negative and NASM refuses to assemble, which is a genuinely useful error.
The whole thing
bits 16
org 0x7C00
start:
jmp 0x0000:main ; pin CS to 0, IP becomes 0x7C05
main:
cli ; no interrupts while SS:SP is half-set
xor ax, ax
mov ds, ax ; DS = ES = SS = 0
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; stack grows down, away from us
sti
cld ; string ops move forwards
mov [drive], dl ; the one register the BIOS did set
mov si, msg
call print
hang:
hlt ; idle until an interrupt
jmp hang ; ...then idle again
print: ; SI -> NUL-terminated string
mov ah, 0x0E ; teletype output
xor bx, bx ; page 0, colour 0
.next:
lodsb ; AL = [DS:SI], SI = SI + 1
test al, al
jz .done
int 0x10
jmp .next
.done:
ret
drive: db 0
msg: db "TempMV BootROM: 512 bytes, no OS.", 13, 10, 0
times 510-($-$) db 0 ; pad out to offset 510
dw 0xAA55 ; stored on disk as 55 AA
A few things worth noticing. hlt on its own is not a halt — an interrupt wakes the CPU straight out of it, and the timer fires eighteen times a second, so the jmp hang after it is not decoration. The drive byte is saved even though this program never uses it, because the moment you add a disk read you will need it and DL will long since have been trampled. And print is called, not inlined, only because it costs three bytes and buys you a routine.
Assembling it and making a floppy
# NASM straight to a flat binary -- no linker, no ELF header
nasm -f bin boot.asm -o boot.bin
# Or GNU binutils, which is what TempMV's own BootROM uses.
# Note: AT&T syntax, and '. = _start + 510' replaces NASM's 'times'.
as --32 boot.S -o boot.o
ld -m elf_i386 -Ttext 0x7C00 --oformat binary -o boot.bin boot.o
# Sanity check: exactly 512 bytes, ending 55 aa
ls -l boot.bin
od -A d -t x1 boot.bin | tail -2
# A 1.44 MB floppy is 2880 sectors of 512 bytes = 1474560 bytes.
# Zero it, then drop the boot sector on the front without truncating.
dd if=/dev/zero of=boot.img bs=512 count=2880
dd if=boot.bin of=boot.img conv=notrunc
--oformat binary is the important flag on the ld line: without it you get an ELF file with headers in front of your code, the BIOS loads the headers, and the machine executes the ELF magic number as instructions. conv=notrunc matters just as much — without it the second dd truncates your 1.44 MB image back down to 512 bytes and the floppy controller reports a geometry the emulator will not accept.
To boot it, go to /new, choose a floppy image, and hand it boot.img. Nothing is uploaded — the file is read locally by your browser and copied into the emulator's memory, which is also why the 1.44 MB of mostly zeroes costs you nothing. See how x86 emulation works in the browser for what happens to it after that.
Going further in the same 512 bytes
Reading more sectors
INT 13h with AH = 0x02 reads sectors in CHS geometry. AL is the count, CH is the low eight bits of the cylinder, CL is the sector number in bits 0-5 with the top two cylinder bits in 6-7, DH is the head, DL is the drive, and ES:BX is where the data lands. On return the carry flag is clear on success and AH holds a status code on failure. Two traps: sector numbers are one-based, so the first sector of a track is sector 1, not 0; and a single call must not cross a track boundary. Real bootloaders retry on failure, because floppy reads genuinely do fail and the conventional fix is to reset the controller with AH = 0x00 and try again three times.
Writing to the screen directly
In the standard 80x25 colour text mode, the screen is memory-mapped at 0xB8000. Two bytes per cell: the first is the character in code page 437, the second is an attribute byte with the foreground colour in bits 0-3, the background in bits 4-6, and blink (or bright background, depending on how the card is configured) in bit 7. So 0x1F is white on blue, and writing 0x0F41 to 0xB8000 puts a bright white A in the top-left corner. Faster than INT 10h by a wide margin, and it works after you have left real mode, which the BIOS calls do not.
Reading a key
INT 16h with AH = 0x00 blocks until a key is pressed and returns the BIOS scan code in AH and the ASCII value in AL. AH = 0x01 is the non-blocking version: it clears the zero flag if something is waiting and leaves the key in the buffer. Between those two and INT 10h, you can write a menu in about eighty bytes.
The BIOS calls worth knowing
| Call | Inputs | What it does |
|---|---|---|
INT 10h AH=0x0E | AL = character, BH = page | Teletype write, advances cursor, scrolls |
INT 10h AH=0x00 | AL = mode (0x03 text, 0x13 graphics) | Set video mode; also clears the screen |
INT 13h AH=0x02 | AL count, CH/CL/DH CHS, DL drive, ES:BX buffer | Read sectors. Carry set on error |
INT 13h AH=0x42 | DL drive, DS:SI disk address packet | Read by LBA. Needs INT 13h extensions |
INT 16h AH=0x00 | none | Block for a key; AH = scan code, AL = ASCII |
INT 15h AX=0xE820 | EDX = 0x534D4150, ES:DI buffer, EBX = 0 | Memory map, one region per call |
INT 12h | none | AX = conventional memory in KB |
INT 19h | none | Restart the boot process from the top |
Where 512 bytes runs out
Quite soon, and quite abruptly. A boot sector that has to live on a real filesystem loses bytes immediately: the first 62 bytes of a FAT boot sector are the jump, the OEM name and the BIOS Parameter Block, and an MBR on a partitioned disk gives up 64 more to the partition table plus four for a disk signature, leaving 440 bytes of actual code. Understanding FAT well enough to find a file by name costs more than that on its own.
So every real bootloader splits. Stage one does the minimum — set up segments, read a known run of sectors from a fixed location, jump into them — and stage two, which can be tens of kilobytes, does the filesystem, the menu and the kernel loading. GRUB, SYSLINUX and the FreeDOS boot sector all work this way. The moment your code will not fit, stop compressing it and add a stage.
The other honest limit is the mode you are in. Everything above is 16-bit real mode: 64 KB segments, one megabyte of addressable memory, no protection, and BIOS calls that stop working the instant you leave it. Nothing large can be built here. The way out is to load your real code somewhere above 1 MB, build a descriptor table and switch the processor into protected mode — which is a whole procedure of its own, described in real mode, protected mode and the 16-bit past. If a term above was unfamiliar, the glossary has short definitions.