Chapter 12: BIOS and Bootloaders
Introduction
The boot process is the sequence of events that occurs from pressing the power button to loading the operating system. Understanding bootloaders and firmware (BIOS/UEFI) is essential for system programmers because the bootloader is the first code you control when building an OS. This chapter explores how computers boot and how to write your own bootloader.
Why This Matters
Every operating system needs a bootloader. Whether you're creating a custom OS, embedded system firmware, or just want to understand what happens before your OS starts, bootloader knowledge is fundamental. The bootloader bridges the gap between firmware and your kernel, loading it into memory and transferring control.
How to Study This Chapter
- Test in emulators - Never test bootloaders on real hardware first!
- Understand constraints - Bootloaders run in 16-bit real mode with strict size limits
- Follow the chain - Firmware → bootloader → kernel
- Read specifications - BIOS interrupts and UEFI are well-documented
The Boot Process Overview
Power-On to OS
1. Press Power Button
↓
2. CPU Reset → Starts at fixed address
↓
3. Firmware (BIOS/UEFI) Initializes Hardware
↓
4. Firmware Searches for Bootable Device
↓
5. Firmware Loads Bootloader (First 512 bytes)
↓
6. Bootloader Runs (16-bit Real Mode)
↓
7. Bootloader Loads Kernel into Memory
↓
8. Bootloader Switches to Protected/Long Mode
↓
9. Bootloader Jumps to Kernel Entry Point
↓
10. Kernel Initializes and Takes Control
BIOS (Basic Input/Output System)
What is BIOS?
BIOS is firmware stored in ROM/flash on the motherboard. It:
- Initializes hardware (CPU, RAM, devices)
- Provides runtime services (disk I/O, video output)
- Loads the first 512 bytes from bootable device
POST (Power-On Self Test)
When computer starts, BIOS performs POST:
- Test CPU
- Check RAM
- Initialize devices (keyboard, video, disk)
- Beep codes indicate errors
BIOS Boot Process
1. CPU starts at address 0xFFFFFFF0 (reset vector)
2. BIOS code runs from ROM
3. BIOS loads MBR (Master Boot Record) from disk
- First 512 bytes of disk
- Loaded at address 0x7C00
- Must end with signature 0x55AA
4. BIOS jumps to 0x7C00
5. Bootloader executes
BIOS Interrupts
BIOS provides services via software interrupts (INT instruction).
Common BIOS Interrupts:
INT 0x10- Video servicesINT 0x13- Disk servicesINT 0x16- Keyboard services
Example - Print character:
mov ah, 0x0E ; Teletype output
mov al, 'A' ; Character to print
int 0x10 ; Call BIOS
Master Boot Record (MBR)
The MBR is the first sector (512 bytes) of a bootable disk.
MBR Structure
+----------------------+ Offset 0
| Boot Code |
| (446 bytes) |
+----------------------+ Offset 446
| Partition Table |
| (4 entries × 16 bytes)|
+----------------------+ Offset 510
| Boot Signature |
| 0x55 0xAA |
+----------------------+ 512 bytes total
Partition Table Entry
struct partition_entry {
uint8_t status; // 0x80 = bootable
uint8_t first_chs[3]; // CHS address of first sector
uint8_t type; // Partition type
uint8_t last_chs[3]; // CHS address of last sector
uint32_t lba_first; // LBA of first sector
uint32_t num_sectors; // Number of sectors
} __attribute__((packed));
Writing a Simple Bootloader
Minimal Bootloader (prints a message)
; boot.asm - Minimal bootloader
; Compile: nasm -f bin -o boot.bin boot.asm
; Create bootable image: dd if=boot.bin of=disk.img bs=512 count=1
BITS 16 ; 16-bit real mode
ORG 0x7C00 ; BIOS loads us here
start:
; Set up segments
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows downward from bootloader
; Print message
mov si, message
call print_string
; Halt
cli
hlt
print_string:
lodsb ; Load byte from SI into AL, increment SI
or al, al ; Check if zero (null terminator)
jz .done
mov ah, 0x0E ; BIOS teletype function
int 0x10 ; Print character
jmp print_string
.done:
ret
message db 'Hello, Bootloader!', 13, 10, 0 ; 13=CR, 10=LF, 0=null
; Pad to 510 bytes and add boot signature
times 510-($-$$) db 0
dw 0xAA55 ; Boot signature (little-endian)
Testing with QEMU
# Assemble bootloader
nasm -f bin -o boot.bin boot.asm
# Create disk image
dd if=/dev/zero of=disk.img bs=512 count=2880 # 1.44MB floppy size
dd if=boot.bin of=disk.img bs=512 count=1 conv=notrunc
# Run in QEMU
qemu-system-x86_64 -drive file=disk.img,format=raw
Loading from Disk (BIOS INT 0x13)
Reading Sectors
; Read sectors using INT 0x13, AH=0x02
; Returns: CF=0 on success, CF=1 on error
load_sectors:
mov ah, 0x02 ; Read sectors function
mov al, [num_sectors] ; Number of sectors to read
mov ch, 0 ; Cylinder 0
mov cl, 2 ; Start from sector 2 (sector 1 is MBR)
mov dh, 0 ; Head 0
mov dl, [drive_number] ; Drive number (0x00=floppy, 0x80=HDD)
mov bx, 0x1000 ; Destination buffer (ES:BX)
int 0x13 ; BIOS disk interrupt
jc .error ; Jump if carry flag set (error)
ret
.error:
mov si, disk_error_msg
call print_string
cli
hlt
disk_error_msg db 'Disk read error!', 13, 10, 0
num_sectors db 5
drive_number db 0x80
LBA to CHS Conversion
Modern disks use LBA (Logical Block Addressing), but BIOS INT 0x13 (AH=02) requires CHS.
; Convert LBA to CHS
; Input: AX = LBA sector number
; Output: CH = cylinder, CL = sector, DH = head
lba_to_chs:
push bx
push ax
; Sector = (LBA % sectors_per_track) + 1
xor dx, dx
div word [sectors_per_track]
inc dx ; Sectors are 1-based
mov cl, dl ; CL = sector
; Head = (LBA / sectors_per_track) % heads
xor dx, dx
div word [heads]
mov dh, dl ; DH = head
; Cylinder = (LBA / sectors_per_track) / heads
mov ch, al ; CH = cylinder (low 8 bits)
pop ax
pop bx
ret
sectors_per_track dw 18 ; Typical for 1.44MB floppy
heads dw 2
Extended Read (LBA Support)
; INT 0x13, AH=0x42 - Extended Read (LBA)
; More modern, supports LBA directly
extended_read:
mov ah, 0x42 ; Extended read function
mov dl, [drive_number] ; Drive number
mov si, dap ; DS:SI = Disk Address Packet
int 0x13
jc .error
ret
.error:
; Handle error
ret
; Disk Address Packet (DAP)
dap:
db 0x10 ; Size of DAP (16 bytes)
db 0 ; Reserved
dw 1 ; Number of sectors to read
dw 0x1000 ; Offset of buffer
dw 0 ; Segment of buffer
dq 1 ; LBA start sector (sector 1)
Real Mode to Protected Mode
Bootloaders must switch from 16-bit real mode to 32-bit protected mode.
Memory Map in Real Mode
+-------------------+ 0x100000 (1 MB)
| Extended Mem |
+-------------------+ 0xC0000
| Video BIOS |
+-------------------+ 0xA0000
| Video Memory |
+-------------------+ 0x9FC00
| EBDA |
+-------------------+ 0x7E00
| Free |
+-------------------+ 0x7C00
| Bootloader | (512 bytes)
+-------------------+ 0x7A00
| Free |
+-------------------+ 0x0500
| BIOS Data Area |
+-------------------+ 0x0000
| IVT |
+-------------------+
Enabling A20 Line
The A20 line must be enabled to access memory above 1 MB.
enable_a20:
; Try BIOS method first
mov ax, 0x2401
int 0x15
jnc .done
; Try keyboard controller method
call wait_8042
mov al, 0xD1 ; Command: Write output port
out 0x64, al
call wait_8042
mov al, 0xDF ; Enable A20
out 0x60, al
call wait_8042
.done:
ret
wait_8042:
in al, 0x64
test al, 2 ; Check if input buffer full
jnz wait_8042
ret
Setting Up GDT (Global Descriptor Table)
; GDT for protected mode
gdt_start:
; Null descriptor
dq 0
; Code segment descriptor
dw 0xFFFF ; Limit (low)
dw 0x0000 ; Base (low)
db 0x00 ; Base (middle)
db 10011010b ; Access: present, ring 0, code, exec/read
db 11001111b ; Flags: 4KB granularity, 32-bit
db 0x00 ; Base (high)
; Data segment descriptor
dw 0xFFFF ; Limit (low)
dw 0x0000 ; Base (low)
db 0x00 ; Base (middle)
db 10010010b ; Access: present, ring 0, data, read/write
db 11001111b ; Flags: 4KB granularity, 32-bit
db 0x00 ; Base (high)
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; Size
dd gdt_start ; Address
Switching to Protected Mode
BITS 16
switch_to_pm:
cli ; Disable interrupts
lgdt [gdt_descriptor] ; Load GDT
; Enable protected mode
mov eax, cr0
or eax, 1 ; Set PE (Protection Enable) bit
mov cr0, eax
; Far jump to flush pipeline and enter protected mode
jmp 0x08:protected_mode ; 0x08 = code segment selector
BITS 32
protected_mode:
; Set up segment registers
mov ax, 0x10 ; 0x10 = data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov ebp, 0x90000 ; Set up stack
mov esp, ebp
; Call kernel (loaded at 0x1000)
call 0x1000
; If kernel returns, halt
cli
hlt
Complete Two-Stage Bootloader
Stage 1: MBR Bootloader
; stage1.asm - MBR bootloader (fits in 512 bytes)
BITS 16
ORG 0x7C00
start:
; Set up segments
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
; Save boot drive
mov [boot_drive], dl
; Print loading message
mov si, msg_loading
call print_string
; Load stage 2 from disk
mov ah, 0x02 ; Read sectors
mov al, 8 ; Read 8 sectors (4 KB)
mov ch, 0 ; Cylinder 0
mov cl, 2 ; Start at sector 2
mov dh, 0 ; Head 0
mov dl, [boot_drive]
mov bx, 0x7E00 ; Load stage2 right after stage1
int 0x13
jc disk_error
; Jump to stage 2
jmp 0x7E00
disk_error:
mov si, msg_error
call print_string
cli
hlt
print_string:
lodsb
or al, al
jz .done
mov ah, 0x0E
int 0x10
jmp print_string
.done:
ret
boot_drive db 0
msg_loading db 'Loading...', 13, 10, 0
msg_error db 'Disk error!', 13, 10, 0
times 510-($-$$) db 0
dw 0xAA55
Stage 2: Extended Bootloader
; stage2.asm - Stage 2 bootloader (can be larger)
BITS 16
ORG 0x7E00
stage2_start:
mov si, msg_stage2
call print_string
; Enable A20
call enable_a20
; Load kernel from disk (LBA sector 10)
mov si, msg_loading_kernel
call print_string
mov ah, 0x42 ; Extended read
mov dl, [boot_drive]
mov si, dap
int 0x13
jc kernel_load_error
; Switch to protected mode
cli
lgdt [gdt_descriptor]
mov eax, cr0
or eax, 1
mov cr0, eax
jmp 0x08:protected_mode_start
kernel_load_error:
mov si, msg_kernel_error
call print_string
cli
hlt
enable_a20:
; (implementation from earlier)
ret
print_string:
; (implementation from earlier)
ret
boot_drive db 0x80
msg_stage2 db 'Stage 2 loaded', 13, 10, 0
msg_loading_kernel db 'Loading kernel...', 13, 10, 0
msg_kernel_error db 'Kernel load error!', 13, 10, 0
dap:
db 0x10 ; Size
db 0 ; Reserved
dw 32 ; Number of sectors (16 KB kernel)
dw 0x1000 ; Offset
dw 0 ; Segment
dq 10 ; LBA start sector
gdt_start:
dq 0 ; Null descriptor
; Code segment
dw 0xFFFF, 0x0000
db 0x00, 10011010b, 11001111b, 0x00
; Data segment
dw 0xFFFF, 0x0000
db 0x00, 10010010b, 11001111b, 0x00
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
BITS 32
protected_mode_start:
mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000
; Jump to kernel entry point
jmp 0x1000
UEFI (Unified Extensible Firmware Interface)
UEFI is the modern replacement for BIOS.
UEFI vs BIOS
| Feature | BIOS | UEFI |
|---|---|---|
| Boot Mode | 16-bit real mode | 32-bit or 64-bit protected mode |
| Disk Format | MBR | GPT (GUID Partition Table) |
| Bootloader Size | 512 bytes (MBR) | No limit (EFI executable) |
| File System | N/A | FAT32/FAT16 |
| Services | INT interrupts | Function calls (API) |
| Graphical UI | Limited | Full GUI support |
UEFI Boot Process
1. Power On
↓
2. UEFI Firmware Initializes
↓
3. Firmware Reads GPT Partition Table
↓
4. Finds EFI System Partition (ESP)
↓
5. Loads EFI Bootloader (e.g., \EFI\BOOT\BOOTX64.EFI)
↓
6. Bootloader Runs (Already in Protected/Long Mode!)
↓
7. Bootloader Loads Kernel
↓
8. Bootloader Calls ExitBootServices()
↓
9. Jumps to Kernel
Simple UEFI Bootloader (C)
#include <efi.h>
#include <efilib.h>
EFI_STATUS EFIAPI efi_main(EFI_HANDLE ImageHandle, EFI_SYSTEM_TABLE *SystemTable) {
InitializeLib(ImageHandle, SystemTable);
Print(L"Hello from UEFI Bootloader!\n");
// Load kernel file
EFI_STATUS status;
EFI_FILE_PROTOCOL *root, *kernel_file;
EFI_LOADED_IMAGE *loaded_image;
EFI_SIMPLE_FILE_SYSTEM_PROTOCOL *file_system;
// Get loaded image protocol
status = uefi_call_wrapper(BS->HandleProtocol, 3,
ImageHandle,
&LoadedImageProtocol,
(void**)&loaded_image);
// Open file system
status = uefi_call_wrapper(BS->HandleProtocol, 3,
loaded_image->DeviceHandle,
&FileSystemProtocol,
(void**)&file_system);
// Open root directory
status = uefi_call_wrapper(file_system->OpenVolume, 2,
file_system, &root);
// Open kernel file
status = uefi_call_wrapper(root->Open, 5,
root,
&kernel_file,
L"\\kernel.bin",
EFI_FILE_MODE_READ,
0);
if (EFI_ERROR(status)) {
Print(L"Failed to open kernel!\n");
return status;
}
// Read kernel into memory
UINTN kernel_size = 1024 * 1024; // 1 MB
void *kernel_buffer;
status = uefi_call_wrapper(BS->AllocatePool, 3,
EfiLoaderData,
kernel_size,
&kernel_buffer);
status = uefi_call_wrapper(kernel_file->Read, 3,
kernel_file, &kernel_size, kernel_buffer);
Print(L"Kernel loaded at 0x%lx (%lu bytes)\n", kernel_buffer, kernel_size);
// Exit boot services
UINTN map_key;
status = uefi_call_wrapper(BS->ExitBootServices, 2,
ImageHandle, map_key);
// Jump to kernel
void (*kernel_entry)(void) = (void (*)(void))kernel_buffer;
kernel_entry();
return EFI_SUCCESS;
}
Key Concepts
- BIOS is legacy firmware providing 16-bit services
- MBR is the first 512 bytes, contains boot code and partition table
- Bootloader runs in real mode, loads kernel, switches to protected mode
- INT 0x13 provides BIOS disk I/O
- A20 line must be enabled to access > 1 MB memory
- GDT defines segments for protected mode
- UEFI is modern firmware with 32/64-bit mode and full OS-like environment
- Two-stage bootloaders overcome 512-byte MBR size limit
Common Mistakes
- Forgetting boot signature - Must be 0x55AA at offset 510
- Wrong ORG directive - BIOS loads at 0x7C00
- Stack not initialized - Set SP before using stack
- Segments not set - Initialize DS, ES, SS in bootloader
- Not saving drive number - DL contains boot drive on entry
- Testing on real hardware - Always test in emulator first!
- Exceeding 512 bytes - Stage 1 must fit in MBR
Debugging Tips
- Use QEMU with -d - Enable debug logging
- Add print statements - Print characters to verify execution
- Check boot signature -
hexdump -C boot.bin | tail - Verify disk reads - Print loaded data
- Single-step in debugger -
qemu -s -S+ GDB - Test each stage - Verify stage 1 before writing stage 2
- Use Bochs - Better debugging features than QEMU
Mini Exercises
- Write a bootloader that prints your name
- Modify bootloader to read keyboard input
- Load a second sector from disk and execute it
- Implement color text output using video memory (0xB8000)
- Create a bootloader that loads a 4 KB kernel
- Write code to detect available memory
- Implement a simple bootloader menu
- Switch to protected mode and print a message
- Create a UEFI bootloader that prints to console
- Parse and display MBR partition table
Review Questions
- What is the boot signature and where is it located?
- At what address does BIOS load the MBR?
- What is the A20 line and why must it be enabled?
- What's the difference between BIOS and UEFI?
- How do you switch from real mode to protected mode?
Reference Checklist
By the end of this chapter, you should be able to:
- Explain the boot process from power-on to kernel
- Understand BIOS and its interrupts
- Know the structure of MBR
- Write a minimal bootloader
- Load sectors from disk using INT 0x13
- Enable the A20 line
- Set up GDT for protected mode
- Switch from real mode to protected mode
- Understand UEFI boot process
- Test bootloaders in QEMU
Next Steps
Now that you understand bootloaders and how to load a kernel into memory, the next chapter explores emulation and QEMU. You'll learn how to use QEMU for kernel development, debugging, and testing system software safely without risking hardware.
Key Takeaway: Bootloaders bridge firmware and operating systems. Understanding the boot process and writing bootloaders teaches you about real mode, protected mode, and the lowest-level system initialization.