Article revision Version 1 of 2

How to Create a 16-bit MS-DOS-Style Bootloader with NASM

Originally published by @nibblebits Aug 22, 2026 at 18:40
View current article
Initial version

Original article publication

This is the article as it appeared before any published revisions.

Snapshot

Article at version 1

Operating Systems

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.SYS or MSDOS.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:

  1. The BIOS selects a boot device.
  2. It reads the first 512-byte sector into physical address 0x7C00.
  3. It checks that bytes 510 and 511 contain 0x55 and 0xAA.
  4. It jumps to the loaded machine code in 16-bit real mode.
  5. Register DL identifies 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 16 tells NASM to generate 16-bit instructions.
  • org 0x7C00 makes data labels match the address where the BIOS places the sector.
  • times 510 - ($ - $$) db 0 pads the binary to 510 bytes.
  • dw 0xAA55 emits the final bytes as 55 AA on 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:

  • 0x00 for the first floppy drive.
  • 0x80 for 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:

  1. BIOS Extensions detection with INT 13h, AH=41h.
  2. LBA reads with INT 13h, AH=42h and a Disk Address Packet.
  3. A memory map check before choosing load addresses.
  4. FAT12 or FAT16 parsing so stage two can be stored as a named file.
  5. 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.

osdevbootloaderx86assemblyms-dos