Back to Technology

x86 Assembly Series Part 19: Bootloader Development

February 6, 2026 Wasil Zafar 40 min read

Master bootloader development: BIOS boot process, MBR structure, real mode assembly, disk I/O with BIOS interrupts, loading a kernel, and transitioning to protected/long mode.

Table of Contents

  1. BIOS Boot Process
  2. MBR Structure
  3. Hello World Bootloader
  4. BIOS Interrupts
  5. Loading the Kernel
  6. Mode Transition
  7. Testing with QEMU

MBR Structure

Reference

Master Boot Record Layout (512 bytes)

Offset   Size    Description
0x000    446     Bootloader code
0x1BE    64      Partition table (4 × 16 bytes)
0x1FE    2       Boot signature (0x55, 0xAA)

Hello World Bootloader

[BITS 16]                   ; 16-bit real mode
[ORG 0x7C00]                ; BIOS loads us here

start:
    ; Set up segment registers
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7C00          ; Stack below bootloader

    ; Print "Hello"
    mov si, message
    call print_string

    ; Halt
    cli                     ; Disable interrupts
    hlt                     ; Halt CPU

print_string:
    mov ah, 0x0E            ; BIOS teletype function
.loop:
    lodsb                   ; Load byte from SI into AL
    test al, al             ; Check for null terminator
    jz .done
    int 0x10                ; BIOS video interrupt
    jmp .loop
.done:
    ret

message: db "Hello from bootloader!", 0

; Pad to 510 bytes and add boot signature
times 510 - ($ - $$) db 0
dw 0xAA55                   ; Boot signature
# Assemble and test
nasm -f bin boot.asm -o boot.bin
qemu-system-x86_64 -drive format=raw,file=boot.bin

BIOS Interrupts

INT 10h (Video Services)

INT 10h provides BIOS video services—your interface to the screen before any OS is loaded.

AHFunctionParametersReturns
0x00Set Video ModeAL = mode (0x03=text, 0x13=VGA)-
0x02Set Cursor PositionBH=page, DH=row, DL=col-
0x03Get Cursor PositionBH=pageDH=row, DL=col
0x0ETeletype OutputAL=char, BL=color-
0x13Write StringES:BP=string, CX=len, DH/DL=pos-
; Video services examples
bits 16
org 0x7C00

; Set 80x25 text mode
mov ah, 0x00
mov al, 0x03            ; 80x25 color text
int 0x10

; Set cursor to row 5, column 10
mov ah, 0x02
mov bh, 0               ; Page 0
mov dh, 5               ; Row
mov dl, 10              ; Column
int 0x10

; Print character 'A' in light green
mov ah, 0x0E
mov al, 'A'
mov bl, 0x0A            ; Light green
int 0x10

; Print string using INT 10h function 13h
mov ah, 0x13
mov al, 1               ; Mode: update cursor
mov bh, 0               ; Page 0
mov bl, 0x0F            ; White on black
mov cx, msg_len         ; String length
mov dh, 10              ; Row 10
mov dl, 20              ; Column 20
mov bp, message         ; ES:BP = string
int 0x10

message: db "Boot OK!"
msg_len equ $ - message

INT 13h (Disk Services)

INT 13h is the BIOS disk interface. Use it to load your kernel from disk into memory.

AHFunctionKey Parameters
0x00Reset DiskDL = drive (0=floppy, 0x80=HDD)
0x02Read SectorsAL=count, CH=cyl, CL=sector, DH=head, DL=drive, ES:BX=buffer
0x03Write SectorsSame as 0x02
0x08Get Drive ParametersDL=drive; Returns geometry
0x42Extended Read (LBA)DS:SI = DAP (Disk Address Packet)
; Read sectors using CHS (Cylinder-Head-Sector)
read_sectors_chs:
    ; Parameters: Read 2 sectors starting from sector 2
    mov ah, 0x02        ; Read sectors function
    mov al, 2           ; Number of sectors
    mov ch, 0           ; Cylinder 0
    mov cl, 2           ; Starting sector (1-based!)
    mov dh, 0           ; Head 0
    mov dl, 0x80        ; Drive 0x80 = first hard disk
    mov bx, 0x1000      ; ES:BX = buffer (0x0000:0x1000)
    int 0x13
    jc .disk_error      ; Carry flag set on error
    cmp al, 2           ; Verify sectors read
    jne .disk_error
    ret
.disk_error:
    ; Handle read failure
    mov si, disk_err_msg
    call print_string
    hlt

; Modern method: LBA Read (supports drives > 8GB)
read_sectors_lba:
    mov ah, 0x42        ; Extended read
    mov dl, 0x80        ; Drive
    mov si, dap         ; DS:SI = Disk Address Packet
    int 0x13
    jc .disk_error
    ret

align 4
dap:                    ; Disk Address Packet (DAP)
    db 16               ; Size of DAP (16 bytes)
    db 0                ; Reserved
    dw 4                ; Number of sectors to read
    dw 0x1000           ; Offset (buffer)
    dw 0x0000           ; Segment (buffer)
    dq 1                ; Starting LBA (sector 1 = second sector)
CHS vs LBA: CHS addressing has geometry limits (~8GB). Use LBA (INT 13h AH=42h) for modern compatibility. Check for LBA support with INT 13h AH=41h.

Loading the Kernel

A real bootloader loads the kernel from disk into memory, then jumps to it. Here's a two-stage approach:

Memory Map During Boot:

  0x00000 - 0x003FF  Interrupt Vector Table (IVT)
  0x00400 - 0x004FF  BIOS Data Area
  0x00500 - 0x07BFF  Free (usable for stack)
  0x07C00 - 0x07DFF  Boot Sector (512 bytes) ← Stage 1
  0x07E00 - 0x7FFFF  Free (usable for Stage 2 + kernel)
  0x80000 - 0x9FFFF  Extended BIOS Data Area
  0xA0000 - 0xBFFFF  Video Memory
  0xC0000 - 0xFFFFF  BIOS ROM

Stage 1: Boot Sector (512 bytes max)

; stage1.asm - Boot sector that loads stage 2
bits 16
org 0x7C00

STAGE2_ADDR equ 0x7E00      ; Load stage 2 right after boot sector
STAGE2_SECTORS equ 16       ; 16 sectors = 8KB stage 2

_start:
    ; Setup 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, loading_msg
    call print_string
    
    ; Load stage 2 from disk
    mov ah, 0x02            ; Read sectors
    mov al, STAGE2_SECTORS  ; Sector count
    mov ch, 0               ; Cylinder 0
    mov cl, 2               ; Start at sector 2 (sector 1 = boot sector)
    mov dh, 0               ; Head 0
    mov dl, [boot_drive]
    mov bx, STAGE2_ADDR     ; ES:BX = destination
    int 0x13
    jc disk_error
    
    ; Jump to stage 2
    jmp STAGE2_ADDR

disk_error:
    mov si, error_msg
    call print_string
    hlt

print_string:
    lodsb
    test al, al
    jz .done
    mov ah, 0x0E
    int 0x10
    jmp print_string
.done:
    ret

boot_drive: db 0
loading_msg: db "Loading stage 2...", 13, 10, 0
error_msg: db "Disk error!", 0

times 510 - ($ - $$) db 0
dw 0xAA55
Save & Compile: stage1.asm

All Platforms (flat binary — no OS-specific linking)

nasm -f bin stage1.asm -o stage1.bin
qemu-system-x86_64 -drive format=raw,file=stage1.bin

Stage 2: Larger Loader

; stage2.asm - More room for kernel loading, A20, etc.
bits 16
org 0x7E00

KERNEL_ADDR equ 0x10000     ; Load kernel at 64KB mark

stage2_start:
    mov si, stage2_msg
    call print_string
    
    ; Enable A20 line (access memory > 1MB)
    call enable_a20
    
    ; Load kernel (example: 64 sectors = 32KB)
    mov ah, 0x02
    mov al, 64
    mov ch, 0
    mov cl, 18              ; After stage 2 sectors
    mov dh, 0
    mov dl, 0x80
    mov bx, KERNEL_ADDR
    int 0x13
    
    ; Setup GDT and enter protected mode...
    ; (See Mode Transition section)
    
    jmp $

enable_a20:
    ; Fast A20 via port 0x92
    in al, 0x92
    or al, 2
    out 0x92, al
    ret

stage2_msg: db "Stage 2 loaded!", 13, 10, 0
Save & Compile: stage2.asm

All Platforms (flat binary — combine with stage1 to run)

nasm -f bin stage2.asm -o stage2.bin
cat stage1.bin stage2.bin > os.img
qemu-system-x86_64 -drive format=raw,file=os.img

Mode Transition

Transitioning from real mode to protected/long mode is the bootloader's most critical task. Here's the complete sequence:

Mode Transition Checklist:

☐ 1. Disable interrupts (CLI)
☐ 2. Enable A20 gate (access > 1MB)
☐ 3. Load Global Descriptor Table (LGDT)
☐ 4. Set CR0.PE = 1 (Protected Mode Enable)
☐ 5. Far jump to flush prefetch queue
☐ 6. Reload segment registers with GDT selectors

For 64-bit Long Mode, also:
☐ 7. Enable PAE (CR4.PAE = 1)
☐ 8. Setup 4-level page tables
☐ 9. Load CR3 with PML4 address
☐ 10. Enable Long Mode (EFER.LME = 1)
☐ 11. Enable Paging (CR0.PG = 1)
☐ 12. Far jump to 64-bit code segment
; Complete Real → Protected Mode transition
bits 16

enter_protected_mode:
    cli                     ; 1. Disable interrupts
    
    ; 2. Enable A20 (keyboard controller method)
    call .wait_ready
    mov al, 0xAD            ; Disable keyboard
    out 0x64, al
    call .wait_ready
    mov al, 0xD0            ; Read output port
    out 0x64, al
    call .wait_data
    in al, 0x60             ; Get current value
    push ax
    call .wait_ready
    mov al, 0xD1            ; Write output port
    out 0x64, al
    call .wait_ready
    pop ax
    or al, 2                ; Set A20 bit
    out 0x60, al
    call .wait_ready
    mov al, 0xAE            ; Enable keyboard
    out 0x64, al
    jmp .a20_done
    
.wait_ready:
    in al, 0x64
    test al, 2
    jnz .wait_ready
    ret
.wait_data:
    in al, 0x64
    test al, 1
    jz .wait_data
    ret
.a20_done:

    ; 3. Load GDT
    lgdt [gdt_descriptor]
    
    ; 4. Enable Protected Mode
    mov eax, cr0
    or eax, 1
    mov cr0, eax
    
    ; 5. Far jump (flushes pipeline, loads CS)
    jmp 0x08:protected_entry

bits 32
protected_entry:
    ; 6. Reload segment registers
    mov ax, 0x10            ; Data segment selector
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    mov esp, 0x90000        ; Stack in free memory
    
    ; Now in 32-bit Protected Mode!
    ; Continue to long mode setup or jump to kernel...
    jmp pm_kernel_entry

; GDT for Protected Mode
align 8
gdt_start:
    dq 0                    ; Null descriptor
gdt_code:
    dw 0xFFFF, 0x0000       ; Limit, Base 0:15
    db 0x00                 ; Base 16:23
    db 10011010b            ; Access: Code, readable
    db 11001111b            ; Flags + Limit 16:19
    db 0x00                 ; Base 24:31
gdt_data:
    dw 0xFFFF, 0x0000
    db 0x00
    db 10010010b            ; Access: Data, writable
    db 11001111b
    db 0x00
gdt_end:

gdt_descriptor:
    dw gdt_end - gdt_start - 1  ; Size - 1
    dd gdt_start                ; Address
Critical: The far jump after setting CR0.PE is mandatory! It flushes the CPU's instruction prefetch queue, which may contain real-mode decoded instructions.

Testing with QEMU

QEMU is essential for bootloader development. It provides fast iteration and powerful debugging capabilities.

Basic Usage

# Build and run bootloader
nasm -f bin bootloader.asm -o boot.bin

# Run in QEMU (legacy BIOS)
qemu-system-x86_64 -drive format=raw,file=boot.bin

# Create proper disk image with kernel
cat boot.bin kernel.bin > os.img
# Pad to floppy size (optional)
truncate -s 1440k os.img
qemu-system-x86_64 -fda os.img

# Hard disk emulation
qemu-system-x86_64 -hda os.img

Debugging with GDB

# Start QEMU paused, waiting for GDB connection
qemu-system-x86_64 -drive format=raw,file=boot.bin -s -S

# In another terminal, connect GDB
gdb -ex "target remote :1234" \
    -ex "set architecture i8086" \
    -ex "break *0x7c00" \
    -ex "continue"

# Useful GDB commands for bootloader debugging:
(gdb) x/10i $pc           # Disassemble at current position
(gdb) info registers      # Show all registers
(gdb) x/16xb 0x7c00       # Examine memory (hex bytes)
(gdb) stepi               # Step one instruction
(gdb) nexti               # Step over (skip calls)
(gdb) break *0x7c20       # Breakpoint at address

QEMU Options Reference

OptionDescription
-sStart GDB server on port 1234
-SPause CPU at startup (wait for GDB)
-d intLog interrupts to console
-d cpu,execLog CPU state and executed blocks
-D qemu.logWrite debug output to file
-nographicDisable graphical output, use serial
-serial mon:stdioRedirect serial to terminal
-m 512MSet RAM size to 512MB
-monitor stdioQEMU monitor in terminal

QEMU Monitor Commands

# Press Ctrl+Alt+2 in QEMU window for monitor, or use -monitor stdio
(qemu) info registers     # Show CPU registers
(qemu) info mem           # Show memory mappings
(qemu) xp /10i 0x7c00     # Disassemble memory
(qemu) x /16xb 0x7c00     # Examine memory
(qemu) gdbserver          # Enable GDB server
(qemu) stop               # Pause emulation
(qemu) cont               # Continue emulation
(qemu) q                  # Quit QEMU
Exercise

Debug Your Bootloader

  1. Create a bootloader that prints "Hello" and loops
  2. Start QEMU with -s -S
  3. Connect GDB, set breakpoint at 0x7C00
  4. Step through each instruction
  5. Examine the boot signature at 0x7DFE (x/2xb 0x7dfe)