Table of contents
- Introduction
- What the BIOS expects from a bootloader
- The 512 bytes explained
- Relocating
- Finding and loading the active partition
- MBR vs. VBR
- Testing our bootloader with QEMU
- Limitations of the MBR scheme
- Conclusion
- Glossary
- Bibliography
- Full code samples
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
DLregister to the drive number it booted from, - jumps to
0x7c00in 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:
DLcarries the drive number as we’ve seen in the previous section. We must handleDLcarefully 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:
|
|
Note: the reason why we save the value of
DLin 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:
|
|
Note: we use
0xaa55and not0x55aa. This is not a mistake, x86 uses little-endian, so a 16-bit word0xaa55is stored as the byte sequence0x55, 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:
|
|
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.
|
|
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 haltimplies writing a labeled block that handles this case. You could simply writejmp $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 :
|
|
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.
|
|
Note: One of the few constraints of the bootstrap code is that at the end,
DS:SIshould be pointing to the selected partition table entry. That’s whatmov si, didoes.
|
|
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:
|
|
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:
|
|
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:
|
|
We can then launch QEMU with our disk image:
|
|
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
0x7c00again 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 13hread interface (AH=0x02) uses it. - DAP (Disk Address Packet): the structure expected by the extended
int 13h/AH=0x425 function.SIpoints 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 13hinterface (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 from0to0xffff(64 KiB), but real mode can address up to 1 MiB of memory. To reach further, every address is expressed as a pairsegment:offset. The CPU has four segment registers for this:CS(code),DS(data),SS(stack), andES(extra). To turn asegment:offsetpair into an actual physical address, the CPU computessegment * 16 + offset. So0x07c0:0x0000and0x0000:0x7c00both point to the same physical byte,0x7c00.CS:IP(IPbeing the instruction pointer) is the pair the CPU uses to fetch the next instruction to run, so settingCSis 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
- [1] MBR (x86) - OSDev Wiki
- [2] Boot Records Revealed! - thestarman.pcministry.com
- [3] Master Boot Record - Wikipedia
- [4] GPT and MBR Disk Layouts - uefi.org
- [5] Ralf Brown’s Interrupt List
- [6] x86 and amd64 instruction reference
- [7] Intel 8086 - Wikipedia
- [8] Boot Records Revealed! - MBR/EBR Partition Tables
- [9] Volume Boot Record - OSDev Wiki
- [10] Writing an OS in Rust: Bootlader
Full code samples
mbr.asm
|
|
vbr.asm
|
|