Table of contents

Introduction

Every x86 machine that boots from a legacy BIOS goes through the same steps: after the BIOS finishes some hardware checks, it picks a boot device from the boot sequence, and blindly loads the very first sector of that device (512 bytes, the minimum storage unit of a magnetic disk) into memory.

That first sector is called the Master Boot Record (MBR), and it carries two tasks for us:

  • keep track of a small partition table, describing how the disk is organized
  • hold a tiny bootstrap code, whose only purpose is to find which partition is bootable and hand control over to it.

The MBR format has been used for decades. Originally introduced by IBM and intended for use with IBM PC-Compatible systems, it quickly became a standard used by essentially every x86 system3. Today though, most new machines boot through UEFI and GPT instead, and the MBR is mostly a legacy mechanism. We will get back to why at the end of the article.

While explaining how the MBR format works, this article will guide you through the writing of a simple MBR bootloader, written in NASM, and tested with QEMU. Each concept of the MBR scheme will be introduced using pieces of the bootloader’s code.

Note: each term specific to the boot process is briefly introduced where it first shows up, and described in full in the glossary at the end of the article. Every occurrence links back to it, so you never have to hunt for a definition you’ve already passed.

Prerequisites: to follow everything smoothly, you should be comfortable with Assembly (i.e. having already seen it or used it, even if it’s not necessarily x86 assembly). If you have trouble understanding some instructions used, do not hesitate to check the reference6. No prior knowledge of the boot process is assumed.


What the BIOS expects from a bootloader

A bootloader (or bootstrap code) is the very first piece of software that runs on a machine. Its job is simply to get something bigger, usually an operating system, up and running.

Expectations

Before writing a single instruction, it helps to know exactly what we will have to do in order for the BIOS to understand us. When it finds a bootable device, the BIOS does the following steps:

  • reads the first sector (512 bytes) of the device into memory, at physical address 0x7c00,
  • sets the DL register to the drive number it booted from,
  • jumps to 0x7c00 in 16-bit real mode1.

Real mode is the CPU’s original operating mode, the one every x86 processor still starts in: registers are 16-bit, there is no memory protection and no paging, and memory is addressed through segment:offset pairs instead of a flat address space. The CPU resolves such a pair as segment * 16 + offset, so 0x0000:0x7c00 is the physical byte 0x7c00. Both terms are described in full in the glossary.

From this point, everything else will have to be handled by the bootstrap code.

For the BIOS to consider a device bootable, two conditions must be met:

  • the first sector must be 512 bytes long,
  • the last two bytes of that sector must be the boot signature, which is in our case 0x55 0xaa.1,4

If that signature is missing, most BIOSes will refuse to boot the device and move onto the next one in the boot sequence (or print something like No bootable device found).

Constraints

There are a few more constraints that we will have to follow when writing the code:

  • register preservation: DL carries the drive number as we’ve seen in the previous section. We must handle DL carefully not to lose that information.
  • undefined initial states: apart from DL, every other register (and the entire memory) is in an undefined state when we begin. All the registers that we use must be initialized by us before doing anything.

Following these two points, here is how our code will begin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
[bits 16]
[org 0x0600]            ; this is explained in the next section

start:
    cli                 ; we don't want to be interrupted
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7c00
    sti

    mov bl, dl          ; we keep the value of DL in BL for the entire program. We will put it back in DL when loading the VBR.

Note: the reason why we save the value of DL in another register will make sense once we introduce relocation in the next section.


The 512 bytes explained

Let’s now look at what actually is inside those 512 bytes. The MBR sector is split into multiple regions1,4,8:

Offset (bytes) Size (bytes) Content
0 446 Bootstrap code area (our program)
446 16 * 4 Array of four legacy MBR partitions.
510 2 Signature, set to 0x55aa in our case.

One thing that we can clearly notice is that the space we have to write our bootstrap code is 446 bytes, not 512 bytes because the last 66 bytes are reserved for the partition table and the MBR signature. This is also why MBR bootstraps are written in raw assembly rather than in any higher-level language, because compilers are not designed to produce outputs adapted to such constraints.

Each partition table entry follows this 16-byte layout1,4,8:

Offset (bytes) Size (bytes) Field
0 1 Status (0x80 = active/bootable, 0x00 = inactive)
1 3 CHS address of the first sector
4 1 Partition type (filesystem identifier, e.g. 0x07 for NTFS, 0x83 for Linux)
5 3 CHS address of the last sector
8 4 LBA of the first sector (little-endian)
12 4 Number of sectors in the partition (little-endian)

Two addressing schemes show up in this layout: CHS addresses a sector by its physical cylinder, head and sector position, while LBA replaces that with a single flat sector number. Our bootloader only ever uses the LBA field.

In our code, we can lay this out with a simple structure and reserve the table space directly:

1
2
3
4
5
6
7
8
; this will be at the very end of the file

times 446 - ($ - $$) db 0

partition_table:
    times 64 db 0           ; leave empty space for the partition table

dw 0xaa55

Note: we use 0xaa55 and not 0x55aa. This is not a mistake, x86 uses little-endian, so a 16-bit word 0xaa55 is stored as the byte sequence 0x55, 0xaa, which is exactly the signature we want.

The times 446 - ($ - $$) db 0 line is doing the following: $ is “current address”, $$ is “start of the section”, so $ - $$ is exactly how many bytes of code we’ve written so far. NASM pads the rest with zeroes up to offset 446. If your code exceeds 446 bytes, this line will fail to assemble with a negative times count.


Relocating

The BIOS only ever loads things at one fixed address, 0x7c00. Our bootloader is loaded there first. Later on, we will need to load a second piece of code, the VBR (Volume Boot Record, the first sector of a partition, which we will compare to the MBR and write ourselves in the dedicated section), and that second sector is also loaded at 0x7c00 by convention. We can’t load new code over ourselves while we’re still running from that exact address.

If we did nothing, load_vbr (see below) would read the VBR straight over the exact bytes we’re currently executing, including the code right around the instruction we’re on.

The usual solution is to relocate: before touching the disk, we need to copy our current code somewhere else in the memory, then jump into that copy. The 0x0000:0x0600 address is a classic choice, it is located far from 0x7c00, out of the way of the code we are about to load and above the memory reserved by the BIOS at the bottom. This is a segment:offset pair, with segment 0x0000, so it resolves to the physical address 0x0600. That’s why we simply call it 0x0600 for the rest of the article. Once we’re safely executing from 0x0600, the address 0x7c00 becomes just another empty spot available for writing, freeing it up for the VBR without any risk of stepping on our own feet.

We can add the following code right after the line with mov bl, dl:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	mov si, 0x7c00          ; SI = source, start of our currently running code
	mov di, 0x0600          ; DI = destination, where we want the copy to live
	mov cx, 256             ; 256 words = 512 bytes, the full size of our sector
	cld                     ; clear direction flag, so movsw increments SI/DI
	rep movsw               ; copy CX words from [SI] to [DI], one at a time

	jmp 0x0000:relocated    ; far jump into the copy, CS now points at 0x0000

relocated:
    ; now everything happens from the copy, from here every address is computed against [0x0600]

This is why we set [org 0x0600] at the top of the file, and not 0x7c00. NASM computes every address in the file based on that value. Before the copy, we are still physically running at 0x7c00, so any address computed against 0x0600 would be wrong.

This is also why we keep the boot drive number in BL instead of writing it anywhere in the memory: register values survive the copy (the copy only moves the code and data bytes sitting between 0x7c00 and 0x7c00+512, it doesn’t touch the CPU’s registers), so there’s no timing problem to work around. As long as nothing else in the program needs BL, DL can simply stay in BL from the very first instruction to the very last.


Finding and loading the active partition

Scanning the partition table

The partition table is located at offset 446 in the MBR, now at the physical address 0x0600 + 446. Each entry is 16 bytes, and the status byte (first byte of the entry) tells us if it’s active (0x80) or not.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
find_active_partition:
    mov si, partition_table
    mov cx, 4

.check_entry:
    cmp byte [si], 0x80             ; we check whether the partition is active or not
    je .found
    add si, 16                      ; else, we loop onto the next one in the table
    loop .check_entry

    ; nothing found, we can't do much more
    jmp halt

.found:
    mov di, si                      ; SI points to the chosen entry.

We stop at the first active entry we find. There should only be one, but we have no guarantee that it will be the case. A more careful check would also verify that the partition type is not 0x00 before trusting the status byte.

From this point, we will have the partition entry pointer in DI and the drive number in BL for the entire bootloader as no operation will need those two registers anymore.

Note: jmp halt implies writing a labeled block that handles this case. You could simply write jmp $ instead, which will just replace the routine call with an infinite loop, but here we want to do it properly. A cleaner way to do it is to:

  • disable interrupts
  • halt CPU
  • loop on itself in case an NMI (a high priority hardware interrupt, that cannot be disabled with cli) wakes the CPU up from the halt

The code for halt would look like this :

1
2
3
4
halt:
	cli
	hlt
	jmp halt

Loading the partition sector

Once we have the partition entry, we need its starting LBA to know where to read from. Reading sectors from disk goes through BIOS interrupt 0x135.

There are two ways of doing this: the historical CHS interface (AH=0x02), or the newer LBA extended interface (AH=0x42). We use the LBA interface (AH=0x42), since QEMU’s BIOS supports it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
load_vbr:
	mov eax, [di + 8]               ; LBA start, from the partition entry
	mov [disk_read.lba], eax        ; disk_read is explained just below

	mov si, disk_read
	mov dl, bl                      ; drive number, still in BL
	mov ah, 0x42
	int 0x13
	jc  disk_error

	mov si, di
	mov dl, bl
	jmp 0x0000:0x7c00               ; hand control to the VBR

Note: One of the few constraints of the bootstrap code is that at the end, DS:SI should be pointing to the selected partition table entry. That’s what mov si, di does.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
disk_read:
	db 0x10
    db 0
	dw 1
    dw 0x7c00 
    dw 0x0000

.lba:  dd 0 
	dd 0

disk_error:
    jmp halt

Here, disk_read is a DAP (Disk Address Packet), the structure expected by the extended read function: SI points to it, and the BIOS reads it to know what to load and where. It’s made up of:

Offset (bytes) Size (bytes) Field Value we use
0 1 Packet size 0x10 (16 bytes, fixed for this DAP version)
1 1 Reserved 0
2 2 Number of sectors to read 1
4 2 Destination buffer offset 0x7c00
6 2 Destination buffer segment 0x0000
8 4 Starting LBA (low 32 bits) patched at runtime with the active partition’s LBA
12 4 Starting LBA (high 32 bits) 0, we’re well under the 2^32 sector limit

The destination is 0x0000:0x7c00 again, the same address the BIOS used to load the MBR. The VBR is itself a 512 byte bootstrap, and expects to start running from 0x7c00. This is exactly why we relocated ourselves earlier: without it, this read would have overwritten our own running code.

Faking a partition entry for testing

find_active_partition only works if at least one entry in the partition table has its status byte set to 0x80. If we leave the table empty (times 64 db 0), no entry will ever match, and the bootloader will silently jump straight to halt without reading anything from disk.

Since we don’t have an actual formatted disk for our tests, we need to write a partition entry by hand, following the same 16-byte layout described earlier:

Offset (bytes) Size (bytes) Field Value we use
0 1 Status 0x80 (active)
1 3 CHS first sector 0, 0, 0 (unused, we rely on LBA)
4 1 Partition type 0x83 (arbitrary, Linux)
5 3 CHS last sector 0, 0, 0 (unused)
8 4 LBA of first sector 1
12 4 Number of sectors 1

Only the status byte and the LBA actually matter for our bootloader, since we never read the CHS fields or the partition type, but it’s good practice to fill them with something plausible.

This replaces the placeholder times 64 db 0 we used earlier:

1
2
3
4
5
6
7
8
partition_table:
	db 0x80                 ; status: active
	db 0, 0, 0              ; CHS first sector, unused
	db 0x83                 ; partition type, unused by us
	db 0, 0, 0              ; CHS last sector, unused
	dd 1                    ; LBA of the first sector
	dd 1                    ; number of sectors
	times 16 * 3 db 0       ; the 3 remaining (inactive) entries

The LBA 1 here means that the VBR is expected right after the MBR, at sector 1, exactly where we will put it when we will test. If your test image places the VBR somewhere else, you will need to adjust the LBA value accordingly.


MBR vs. VBR

The MBR and the VBR are both 512 byte, signature-terminated sectors, but they don’t do the same job.

MBR VBR
Location Sector 0 of the disk First sector of a partition
Scope Disk-wide, knows about the partition table Partition-local, knows about a filesystem
Job Pick which partition to boot Load the actual OS loader for that filesystem

The MBR doesn’t know anything about filesystems. It only cares about the partition table. Once it finds the active partition and loads its first sector, the VBR takes over, and it’s the VBR’s job to understand the filesystem well enough to find and load the rest of the OS.

This is also why the MBR passes DL to the VBR: the VBR has no other way of knowing which drive it was loaded from.

Our own bootloader stops right after the jump. To actually observe this hand-off, we test it against a disk image containing a minimal VBR stub that just prints a message and halts.

An example VBR code would look like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[bits 16]
[org 0x7c00]

    mov si, msg
    mov ah, 0x0e
    mov bh, 0

.loop:
    lodsb
    or al, al
    jz .done
    int 0x10
    jmp .loop

.done:
    jmp $

msg: db "Hello VBR!", 13, 10, 0

times 510 - ($ - $$) db 0

dw 0xaa55

Note that this is a minimal VBR that prints Hello VBR! once it is loaded. A real VBR, however, would be filesystem-aware.


Testing our bootloader with QEMU

To test our bootloader, we have to first assemble the mbr.asm and vbr.asm into flat 512 byte binaries, and then we can build a small disk image, write our MBR to its first sector, mark one partition active and add our VBR at that partition’s first sector:

1
2
3
4
5
6
$ nasm -f bin mbr.asm -o mbr.bin
$ nasm -f bin vbr.asm -o vbr.bin

$ dd if=/dev/zero of=disk.img bs=512 count=2880 status=none
$ dd if=mbr.bin of=disk.img conv=notrunc status=none
$ dd if=vbr.bin of=disk.img bs=512 seek=1 conv=notrunc status=none

We can then launch QEMU with our disk image:

1
$ qemu-system-i386 -drive format=raw,file=disk.img

Booting this image exercises the whole process:

  • the BIOS loads our MBR at 0x7c00,
  • the bootloader relocates itself to 0x0600,
  • it scans the partition table and finds the active entry,
  • it reads its first sector through BIOS interrupt (in our case, int 13h),
  • and finally it jumps to 0x7c00 again and lets the VBR print its own message.

We should see the message Hello VBR! printed on the screen. It works!


Limitations of the MBR scheme

Building an MBR bootloader makes it easier for us to see why the MBR format has been slowly replaced by GPT and UEFI4:

  • only 4 primary partitions: the partition table only has enough space for 4 entries1,3,8,
  • 2 TiB limit: the logical block fields in a partition entry are 32 bits wide, addressing 512 byte sectors. This limits our disk size to 2 TiB, which modern drives often exceed,
  • no redundancy: a single corrupted byte in the partition table and the disk becomes unbootable.
  • 446 bytes: the space we have for the bootstrap code is enough to find a partition and load a sector, but not much more.

The GPT partition scheme solves most of these issues as it has bigger partition table, checksums and many more than 4 partitions (128 by default on most implementations4), and it’s specifically adapted for UEFI firmware that can boot directly without needing an MBR-style bootstrap.

The MBR format is still used. Some media, dual-boot setups, embedded systems or emulators (like QEMU) still use it, mostly because it’s simple enough to be implemented from scratch.


Conclusion

The MBR bootstrap code (even the simplified version that we just wrote) is a good example of how much can be done in a constrained environment with limited resources.

What we built here remains a very limited example (our bootstrap code, before the padding, comes out to about 102 bytes, nowhere near the 446 byte limit). It only loads the first active partition it finds and there is no logic if a read fails, no fallback, and no real handling of a broken partition table or any other kind of problem that could occur.

Going further

To go further and see some other examples of more detailed MBR bootstrap codes, you would not want to miss what’s available in the Boot Records Revealed! series2 as it is a really good source of information on everything related to MBR format. You will be able to find and read documentation about multiple MBRs written over the ages, and that implement a lot of functionalities that we just skipped in our implementation.

Beyond the MBR itself, here is roughly what a real boot chain would need next:

  • a real VBR, filesystem-aware instead of just printing a message
  • filesystem parsing (FAT32, ext2, NTFS…) to locate files on the partition
  • a second-stage loader, since 446/510 bytes is nowhere near enough for real logic
  • switching to protected mode (or long mode), to leave the 16-bit real mode constraints behind
  • loading the actual kernel or OS loader (e.g. GRUB-style chainloading)
  • proper error handling, retries on disk reads, and fallback partitions instead of a single halt

You may also want to take a look at this article that takes a different approach and uses Rust to boot an ARM64 Kernel through the use of a custom written bootloader that differs from the traditional x86 PC boot process10.


Glossary

Every unfamiliar term the article introduces is explained here. Each occurrence in the text links to its entry, so you can come back whenever one is unclear.

  • Bootloader (or bootstrap code): the very first piece of software that runs on a machine. Its job is simply to get something bigger, usually an operating system, up and running.
  • CHS (Cylinder-Head-Sector): the older way of addressing disk sectors by their physical cylinder, head, and sector position, instead of a flat sector number. The historical int 13h read interface (AH=0x02) uses it.
  • DAP (Disk Address Packet): the structure expected by the extended int 13h/AH=0x425 function. SI points to it, and the BIOS reads it to know what to load and where. Its layout is detailed in Loading the partition sector.
  • LBA (Logical Block Addressing): a way of addressing disk sectors with a single flat number, instead of the old cylinder/head/sector scheme. The extended int 13h interface (AH=0x42) uses it.
  • MBR (Master Boot Record): the very first sector of a disk (512 bytes). It holds the partition table describing how the disk is organized, and the bootstrap code that picks the bootable partition.
  • NMI (Non-Maskable Interrupt): a high priority hardware interrupt that cannot be disabled with cli.
  • Real mode: the CPU’s original operating mode, the one every x86 processor still starts in, even today’s with modern 64-bits chips. In this mode, the CPU behaves like the very first Intel 8086 chip: registers are 16-bit, memory is addressed through segments instead of a flat address space, there’s no memory protection, and no paging. Any code can read or write anywhere in memory, including the BIOS’s own code.7
  • Segmented memory (segment:offset): with only 16-bit registers, a single register can only hold addresses from 0 to 0xffff (64 KiB), but real mode can address up to 1 MiB of memory. To reach further, every address is expressed as a pair segment:offset. The CPU has four segment registers for this: CS (code), DS (data), SS (stack), and ES (extra). To turn a segment:offset pair into an actual physical address, the CPU computes segment * 16 + offset. So 0x07c0:0x0000 and 0x0000:0x7c00 both point to the same physical byte, 0x7c00. CS:IP (IP being the instruction pointer) is the pair the CPU uses to fetch the next instruction to run, so setting CS is how we control from where code executes.
  • VBR (Volume Boot Record)9: the first sector of a partition. Like the MBR, it is a 512 byte, signature-terminated sector, but it is partition-local and filesystem-aware: its job is to load the actual OS loader for that filesystem.

Bibliography


Full code samples

mbr.asm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
[bits 16]
[org  0x0600]

start:
	cli
	xor ax, ax
	mov ds, ax
	mov es, ax
	mov ss, ax
	mov sp, 0x7c00
	sti

	mov bl, dl

	mov si, 0x7c00
	mov di, 0x0600
	mov cx, 256
	cld
	rep movsw

	jmp 0x0000:relocated

relocated:

find_active_partition:
	mov si, partition_table
	mov cx, 4

.check_entry:
	cmp  byte [si], 0x80
	je   .found
	add  si, 16
	loop .check_entry

	
	jmp halt

.found:
	mov di, si

load_vbr:
	mov eax, [di + 8]
	mov [disk_read.lba], eax

	mov si, disk_read
	mov dl, bl
	mov ah, 0x42
	int 0x13
	jc  disk_error

	mov si, di
	mov dl, bl
	jmp 0x0000:0x7c00

disk_read:
	db 0x10
	db 0
	dw 1
	dw 0x7c00
	dw 0x0000

.lba:
	dd 0
	dd 0

disk_error:
	jmp halt

halt:
	cli
	hlt
	jmp halt

times 446 - ($ - $$) db 0

partition_table:
	db    0x80
	db    0, 0, 0
	db    0x83
	db    0, 0, 0
	dd    1
	dd    1
	times 16 * 3 db 0

	dw 0xaa55

vbr.asm

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[bits 16]
[org 0x7c00]

    mov si, msg
    mov ah, 0x0e
    mov bh, 0

.loop:
    lodsb
    or al, al
    jz .done
    int 0x10
    jmp .loop

.done:
    jmp $

msg: db "Hello VBR!", 13, 10, 0

times 510 - ($ - $$) db 0

dw 0xaa55