Article revision Version 2 of 2 Current

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

Edited by @nibblebits Aug 22, 2026 at 18:44
View current article
Published change

highlighted for assembly

This permanent snapshot records exactly what @nibblebits published in version 2.

Snapshot

Article at version 2

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
Change set

Changes in version 2

+3−3
article.md
1 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`.
2 2
3 3 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.
4 4
5 5 > 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.
6 6
7 7 ## What happens during legacy BIOS boot?
8 8
9 9 For a legacy BIOS boot, the simplified sequence is:
10 10
11 11 1. The BIOS selects a boot device.
12 12 2. It reads the first 512-byte sector into physical address `0x7C00`.
13 13 3. It checks that bytes 510 and 511 contain `0x55` and `0xAA`.
14 14 4. It jumps to the loaded machine code in 16-bit real mode.
15 15 5. Register `DL` identifies the boot device, so the bootloader should save it before making BIOS calls.
16 16
17 17 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.
18 18
19 19 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.
20 20
21 21 ## Prerequisites
22 22
23 23 Install:
24 24
25 25 - [NASM](https://www.nasm.us/) to assemble 16-bit x86 code.
26 26 - [QEMU](https://www.qemu.org/) to emulate a PC with a legacy BIOS.
27 27 - A text editor and PowerShell, Bash, or another shell.
28 28
29 29 Confirm that the tools are available:
30 30
31 31 ```text
32 32 nasm -v
33 33 qemu-system-i386 --version
34 34 ```
35 35
36 36 ## Part 1: a one-sector bootloader
37 37
38 38 Create `boot.asm`:
39 39
40 ```nasm
40 ```asm
41 41 bits 16
42 42 org 0x7C00
43 43
44 44 start:
45 45 ; Interrupts must stay disabled while SS:SP is being replaced.
46 46 cli
47 47 xor ax, ax
48 48 mov ds, ax
49 49 mov es, ax
50 50 mov ss, ax
51 51 mov sp, 0x7C00
52 52 sti
53 53 cld
54 54
55 55 ; The BIOS passes the boot-device number in DL.
56 56 mov [boot_drive], dl
57 57
58 58 mov si, banner
59 59 call print_string
60 60
61 61 halt:
62 62 cli
63 63 hlt
64 64 jmp halt
65 65
66 66 ; Print the zero-terminated string at DS:SI.
67 67 print_string:
68 68 lodsb
69 69 test al, al
70 70 jz .done
71 71
72 72 mov ah, 0x0E ; INT 10h teletype output
73 73 mov bh, 0x00 ; display page
74 74 mov bl, 0x07 ; text attribute on color adapters
75 75 int 0x10
76 76 jmp print_string
77 77
78 78 .done:
79 79 ret
80 80
81 81 boot_drive db 0
82 82 banner db "16-bit boot sector is running!", 13, 10, 0
83 83
84 84 ; Pad through byte 509, then add the BIOS boot signature.
85 85 times 510 - ($ - $$) db 0
86 86 dw 0xAA55
87 87 ```
88 88
89 89 The directives at the top matter:
90 90
91 91 - `bits 16` tells NASM to generate 16-bit instructions.
92 92 - `org 0x7C00` makes data labels match the address where the BIOS places the sector.
93 93 - `times 510 - ($ - $$) db 0` pads the binary to 510 bytes.
94 94 - `dw 0xAA55` emits the final bytes as `55 AA` on little-endian x86.
95 95
96 96 Assemble it as a flat binary, not as an ELF, PE, or DOS executable:
97 97
98 98 ```text
99 99 nasm -f bin boot.asm -o boot.bin
100 100 ```
101 101
102 102 The result must be exactly 512 bytes. In PowerShell:
103 103
104 104 ```powershell
105 105 (Get-Item .\boot.bin).Length
106 106 ```
107 107
108 108 It should print `512`.
109 109
110 110 Boot the sector directly in QEMU:
111 111
112 112 ```text
113 113 qemu-system-i386 -drive format=raw,file=boot.bin
114 114 ```
115 115
116 116 You should see:
117 117
118 118 ```text
119 119 16-bit boot sector is running!
120 120 ```
121 121
122 122 No operating system, C runtime, or DOS interrupt API printed that line. The boot sector called BIOS video service `INT 10h`, function `AH=0Eh`.
123 123
124 124 ## Part 2: load a second stage
125 125
126 126 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.
127 127
128 128 For a simple floppy-style image, sector 1 is the boot sector and sector 2 contains stage two. Replace `boot.asm` with this version:
129 129
130 ```nasm
130 ```asm
131 131 bits 16
132 132 org 0x7C00
133 133
134 134 STAGE2_SEGMENT equ 0x1000
135 135 STAGE2_SECTORS equ 1
136 136
137 137 start:
138 138 cli
139 139 xor ax, ax
140 140 mov ds, ax
141 141 mov es, ax
142 142 mov ss, ax
143 143 mov sp, 0x7C00
144 144 sti
145 145 cld
146 146
147 147 mov [boot_drive], dl
148 148 mov si, loading_message
149 149 call print_string
150 150
151 151 ; Read stage two to 1000:0000, physical address 0x10000.
152 152 mov ax, STAGE2_SEGMENT
153 153 mov es, ax
154 154 xor bx, bx
155 155
156 156 .read_stage2:
157 157 mov ah, 0x02 ; BIOS read-sectors function
158 158 mov al, STAGE2_SECTORS
159 159 mov ch, 0x00 ; cylinder 0
160 160 mov cl, 0x02 ; sector 2 (sector numbers start at 1)
161 161 mov dh, 0x00 ; head 0
162 162 mov dl, [boot_drive]
163 163 int 0x13
164 164 jnc .stage2_loaded
165 165
166 166 ; Reset the disk system and retry a few times.
167 167 xor ah, ah
168 168 mov dl, [boot_drive]
169 169 int 0x13
170 170 dec byte [retries]
171 171 jnz .read_stage2
172 172
173 173 mov si, disk_error
174 174 call print_string
175 175 jmp halt
176 176
177 177 .stage2_loaded:
178 178 jmp STAGE2_SEGMENT:0x0000
179 179
180 180 print_string:
181 181 lodsb
182 182 test al, al
183 183 jz .done
184 184 mov ah, 0x0E
185 185 mov bh, 0x00
186 186 mov bl, 0x07
187 187 int 0x10
188 188 jmp print_string
189 189 .done:
190 190 ret
191 191
192 192 halt:
193 193 cli
194 194 hlt
195 195 jmp halt
196 196
197 197 boot_drive db 0
198 198 retries db 3
199 199 loading_message db "Loading stage two...", 13, 10, 0
200 200 disk_error db "Disk read failed.", 13, 10, 0
201 201
202 202 times 510 - ($ - $$) db 0
203 203 dw 0xAA55
204 204 ```
205 205
206 206 Now create `stage2.asm`:
207 207
208 ```nasm
208 ```asm
209 209 bits 16
210 210 org 0x0000
211 211
212 212 start:
213 213 ; The far jump set CS to 0x1000. Match DS to it so labels work.
214 214 mov ax, cs
215 215 mov ds, ax
216 216 cld
217 217
218 218 mov si, message
219 219 call print_string
220 220
221 221 halt:
222 222 cli
223 223 hlt
224 224 jmp halt
225 225
226 226 print_string:
227 227 lodsb
228 228 test al, al
229 229 jz .done
230 230 mov ah, 0x0E
231 231 mov bh, 0x00
232 232 mov bl, 0x07
233 233 int 0x10
234 234 jmp print_string
235 235 .done:
236 236 ret
237 237
238 238 message db "Stage two is now executing at 0x10000!", 13, 10, 0
239 239
240 240 ; The first stage reads one complete sector.
241 241 times 512 - ($ - $$) db 0
242 242 ```
243 243
244 244 Assemble both files:
245 245
246 246 ```text
247 247 nasm -f bin boot.asm -o boot.bin
248 248 nasm -f bin stage2.asm -o stage2.bin
249 249 ```
250 250
251 251 Create a 1.44 MB floppy image in PowerShell:
252 252
253 253 ```powershell
254 254 $image = New-Object byte[] 1474560
255 255 $boot = [IO.File]::ReadAllBytes((Join-Path $PWD "boot.bin"))
256 256 $stage2 = [IO.File]::ReadAllBytes((Join-Path $PWD "stage2.bin"))
257 257 [Buffer]::BlockCopy($boot, 0, $image, 0, $boot.Length)
258 258 [Buffer]::BlockCopy($stage2, 0, $image, 512, $stage2.Length)
259 259 [IO.File]::WriteAllBytes((Join-Path $PWD "disk.img"), $image)
260 260 ```
261 261
262 262 On Linux or macOS, the equivalent commands are:
263 263
264 264 ```bash
265 265 truncate -s 1440K disk.img
266 266 dd if=boot.bin of=disk.img conv=notrunc
267 267 dd if=stage2.bin of=disk.img bs=512 seek=1 conv=notrunc
268 268 ```
269 269
270 270 Then boot the image:
271 271
272 272 ```text
273 273 qemu-system-i386 -drive if=floppy,format=raw,file=disk.img -boot a
274 274 ```
275 275
276 276 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.
277 277
278 278 ## Why save `DL`?
279 279
280 280 The BIOS normally uses these device numbers:
281 281
282 282 - `0x00` for the first floppy drive.
283 283 - `0x80` for the first hard disk.
284 284
285 285 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.
286 286
287 287 ## Limits of this simple disk reader
288 288
289 289 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:
290 290
291 291 1. BIOS Extensions detection with `INT 13h`, `AH=41h`.
292 292 2. LBA reads with `INT 13h`, `AH=42h` and a Disk Address Packet.
293 293 3. A memory map check before choosing load addresses.
294 294 4. FAT12 or FAT16 parsing so stage two can be stored as a named file.
295 295 5. A20-line handling and a Global Descriptor Table before entering 32-bit protected mode.
296 296
297 297 ## What makes a boot sector DOS-compatible?
298 298
299 299 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.
300 300
301 301 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.
302 302
303 303 ## Common failures
304 304
305 305 **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.
306 306
307 307 **The message is garbage or nothing prints:** Recheck `org 0x7C00`, initialize `DS`, and ensure every string ends with a zero byte.
308 308
309 309 **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.
310 310
311 311 **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.
312 312
313 313 ## Where to go next
314 314
315 315 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.