Article at version 1
A classic PC boot sector is one of the smallest useful programs you can write: the BIOS loads exactly 512 bytes, switches control to your code in 16-bit real mode, and expects the sector to end with the signature 0x55AA.
This tutorial builds a small MS-DOS-era, BIOS-based bootloader with NASM. It first prints a message directly through the BIOS, then grows into a two-stage loader that reads another sector from disk.
Important distinction: MS-DOS is an operating system, not a CPU mode or boot-sector format. The loader below uses the same 16-bit BIOS environment as early DOS machines, but it does not load Microsoft's
IO.SYSorMSDOS.SYS. A real DOS system disk also needs a valid FAT filesystem, a BIOS Parameter Block, and DOS system files.
What happens during legacy BIOS boot?
For a legacy BIOS boot, the simplified sequence is:
- The BIOS selects a boot device.
- It reads the first 512-byte sector into physical address
0x7C00. - It checks that bytes 510 and 511 contain
0x55and0xAA. - It jumps to the loaded machine code in 16-bit real mode.
- Register
DLidentifies the boot device, so the bootloader should save it before making BIOS calls.
The initial register values other than DL should not be trusted. A good boot sector initializes its data, extra, and stack segments before doing useful work.
Modern UEFI-only hardware does not directly execute this kind of sector unless it provides a Compatibility Support Module. QEMU is therefore the safest and most repeatable place to experiment.
Prerequisites
Install:
- NASM to assemble 16-bit x86 code.
- QEMU to emulate a PC with a legacy BIOS.
- A text editor and PowerShell, Bash, or another shell.
Confirm that the tools are available:
nasm -v
qemu-system-i386 --version
Part 1: a one-sector bootloader
Create boot.asm:
bits 16
org 0x7C00
start:
; Interrupts must stay disabled while SS:SP is being replaced.
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
cld
; The BIOS passes the boot-device number in DL.
mov [boot_drive], dl
mov si, banner
call print_string
halt:
cli
hlt
jmp halt
; Print the zero-terminated string at DS:SI.
print_string:
lodsb
test al, al
jz .done
mov ah, 0x0E ; INT 10h teletype output
mov bh, 0x00 ; display page
mov bl, 0x07 ; text attribute on color adapters
int 0x10
jmp print_string
.done:
ret
boot_drive db 0
banner db "16-bit boot sector is running!", 13, 10, 0
; Pad through byte 509, then add the BIOS boot signature.
times 510 - ($ - $$) db 0
dw 0xAA55
The directives at the top matter:
bits 16tells NASM to generate 16-bit instructions.org 0x7C00makes data labels match the address where the BIOS places the sector.times 510 - ($ - $$) db 0pads the binary to 510 bytes.dw 0xAA55emits the final bytes as55 AAon little-endian x86.
Assemble it as a flat binary, not as an ELF, PE, or DOS executable:
nasm -f bin boot.asm -o boot.bin
The result must be exactly 512 bytes. In PowerShell:
(Get-Item .\boot.bin).Length
It should print 512.
Boot the sector directly in QEMU:
qemu-system-i386 -drive format=raw,file=boot.bin
You should see:
16-bit boot sector is running!
No operating system, C runtime, or DOS interrupt API printed that line. The boot sector called BIOS video service INT 10h, function AH=0Eh.
Part 2: load a second stage
A useful bootloader quickly outgrows 512 bytes. The usual solution is to keep a tiny first-stage loader in the boot sector and load a larger second stage elsewhere in memory.
For a simple floppy-style image, sector 1 is the boot sector and sector 2 contains stage two. Replace boot.asm with this version:
bits 16
org 0x7C00
STAGE2_SEGMENT equ 0x1000
STAGE2_SECTORS equ 1
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
sti
cld
mov [boot_drive], dl
mov si, loading_message
call print_string
; Read stage two to 1000:0000, physical address 0x10000.
mov ax, STAGE2_SEGMENT
mov es, ax
xor bx, bx
.read_stage2:
mov ah, 0x02 ; BIOS read-sectors function
mov al, STAGE2_SECTORS
mov ch, 0x00 ; cylinder 0
mov cl, 0x02 ; sector 2 (sector numbers start at 1)
mov dh, 0x00 ; head 0
mov dl, [boot_drive]
int 0x13
jnc .stage2_loaded
; Reset the disk system and retry a few times.
xor ah, ah
mov dl, [boot_drive]
int 0x13
dec byte [retries]
jnz .read_stage2
mov si, disk_error
call print_string
jmp halt
.stage2_loaded:
jmp STAGE2_SEGMENT:0x0000
print_string:
lodsb
test al, al
jz .done
mov ah, 0x0E
mov bh, 0x00
mov bl, 0x07
int 0x10
jmp print_string
.done:
ret
halt:
cli
hlt
jmp halt
boot_drive db 0
retries db 3
loading_message db "Loading stage two...", 13, 10, 0
disk_error db "Disk read failed.", 13, 10, 0
times 510 - ($ - $$) db 0
dw 0xAA55
Now create stage2.asm:
bits 16
org 0x0000
start:
; The far jump set CS to 0x1000. Match DS to it so labels work.
mov ax, cs
mov ds, ax
cld
mov si, message
call print_string
halt:
cli
hlt
jmp halt
print_string:
lodsb
test al, al
jz .done
mov ah, 0x0E
mov bh, 0x00
mov bl, 0x07
int 0x10
jmp print_string
.done:
ret
message db "Stage two is now executing at 0x10000!", 13, 10, 0
; The first stage reads one complete sector.
times 512 - ($ - $$) db 0
Assemble both files:
nasm -f bin boot.asm -o boot.bin
nasm -f bin stage2.asm -o stage2.bin
Create a 1.44 MB floppy image in PowerShell:
$image = New-Object byte[] 1474560
$boot = [IO.File]::ReadAllBytes((Join-Path $PWD "boot.bin"))
$stage2 = [IO.File]::ReadAllBytes((Join-Path $PWD "stage2.bin"))
[Buffer]::BlockCopy($boot, 0, $image, 0, $boot.Length)
[Buffer]::BlockCopy($stage2, 0, $image, 512, $stage2.Length)
[IO.File]::WriteAllBytes((Join-Path $PWD "disk.img"), $image)
On Linux or macOS, the equivalent commands are:
truncate -s 1440K disk.img
dd if=boot.bin of=disk.img conv=notrunc
dd if=stage2.bin of=disk.img bs=512 seek=1 conv=notrunc
Then boot the image:
qemu-system-i386 -drive if=floppy,format=raw,file=disk.img -boot a
The first stage uses BIOS disk service INT 13h, function AH=02h, to read cylinder 0, head 0, sector 2 into physical address 0x10000. The far jump changes CS:IP to 1000:0000, where stage two begins.
Why save DL?
The BIOS normally uses these device numbers:
0x00for the first floppy drive.0x80for the first hard disk.
Hard-coding one of those values can make a loader work in one QEMU command and fail in another. Saving the original DL lets the BIOS tell the loader which device actually supplied the boot sector.
Limits of this simple disk reader
The example deliberately uses the old CHS interface because it is easy to understand. It assumes stage two is adjacent to the boot sector and does not cross a track boundary. For a more capable loader, add:
- BIOS Extensions detection with
INT 13h,AH=41h. - LBA reads with
INT 13h,AH=42hand a Disk Address Packet. - A memory map check before choosing load addresses.
- FAT12 or FAT16 parsing so stage two can be stored as a named file.
- A20-line handling and a Global Descriptor Table before entering 32-bit protected mode.
What makes a boot sector DOS-compatible?
An actual DOS-formatted FAT12 or FAT16 volume reserves fields near the start of the boot sector for the BIOS Parameter Block. Those fields describe bytes per sector, sectors per cluster, FAT count, root-directory size, media type, and disk geometry. A DOS boot sector then finds and loads DOS-specific system files according to the expectations of that DOS version.
That is the next step if your goal is to boot a legally obtained DOS installation. Do not simply paste the raw loader above over an existing DOS disk image: doing so can destroy its BPB and make the filesystem unreadable. Work on a copy, inspect the FAT layout, and test only in an emulator until the loader is reliable.
Common failures
QEMU says the disk is not bootable: Verify that boot.bin is 512 bytes and ends in 55 AA. The NASM expression and final dw 0xAA55 should guarantee both.
The message is garbage or nothing prints: Recheck org 0x7C00, initialize DS, and ensure every string ends with a zero byte.
Stage two never runs: Confirm that stage2.bin begins at byte offset 512 in disk.img, that it is exactly one sector, and that STAGE2_SECTORS matches the number of sectors written.
It works as a floppy but not as a hard disk: CHS geometry is interpreted differently across devices. Preserve DL and move to BIOS LBA extensions for hard-disk images.
Where to go next
Once this two-stage loader works, a natural progression is to add a FAT12 reader, load a kernel by filename, obtain the BIOS memory map with INT 15h, E820h, enable the A20 line, and enter protected mode. At that point you are no longer just printing from a boot sector—you are building the first component of your own operating system.
Originally published by @nibblebits