Introduction: The Bootloader’s Critical Role
In the intricate world of operating system development, the bootloader stands as the unsung hero, the very first piece of software to execute and set the stage for your kernel. For x86 systems, this process involves a carefully choreographed dance from the BIOS to a minimal boot sector, ultimately transitioning the CPU from a restricted real mode into the powerful protected mode required by modern kernels. This article will guide you through building a basic x86 bootloader from scratch, focusing specifically on the crucial steps of setting up protected mode and handing off control to your micro-kernel.
Understanding the x86 Boot Process
When an x86 machine powers on, the BIOS (Basic Input/Output System) performs POST (Power-On Self-Test) and then searches for a bootable device. Upon finding one, it loads the first 512 bytes of that device (the Master Boot Record or MBR, or a Volume Boot Record for partitions) into memory address 0x7c00 and jumps to it. This 512-byte sector is our bootloader’s initial entry point, executing in 16-bit real mode. Real mode offers direct access to physical memory but is limited to 1MB and lacks crucial features like memory protection and paging. To build a robust micro-kernel, we must transition to 32-bit protected mode.
Phase 1: The Real Mode Boot Sector
Our boot sector’s primary responsibilities in real mode are to initialize basic registers, enable the A20 line (to access memory beyond 1MB), load the kernel into memory, and prepare for the mode switch. Here’s a simplified assembly snippet for a boot sector:
org 0x7c00 ; Bootloader starts at 0x7c00
bits 16 ; We are in 16-bit real mode
jmp short start ; Jump to actual code
nop
start:
mov ax, 0x0000 ; Set up data segments
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00 ; Stack grows downwards from bootloader start
; --- Enable A20 Line (various methods, here using BIOS) ---
; A more robust method would involve Keyboard Controller
mov ax, 0x2401 ; AX = Enable A20
int 0x15
jc .a20_error
; --- Load Kernel (example: loading from disk) ---
; For simplicity, assume kernel is 1 sector after bootloader (LBA 1)
; And it's loaded to address 0x100000 (1MB)
mov ah, 0x42 ; AH = Extended Read
mov dl, 0x80 ; Drive number (e.g., first hard disk)
mov si, boot_packet ; DS:SI points to Disk Address Packet
int 0x13 ; Call BIOS disk service
jc .disk_error
; --- Setup GDT and Switch to Protected Mode ---
cli ; Disable interrupts
lgdt [gdt_descriptor] ; Load GDT register
mov eax, cr0 ; Enable protected mode bit
or eax, 0x1
mov cr0, eax
; Far jump to flush instruction pipeline and enter protected mode
jmp 0x08:protected_mode_start ; Selector 0x08 (code segment) to protected_mode_start
.a20_error:
; Handle A20 error
jmp $
.disk_error:
; Handle disk read error
jmp $
; --- Data Structures ---
boot_packet:
dw 16 ; Size of DAP (16 bytes)
dw 1 ; Number of sectors to read
dd 0x100000 ; Target memory address (1MB)
dd 1 ; LBA start address (sector 1)
; --- GDT Setup (defined later) ---
gdt_start:
dq 0x0 ; Null descriptor
gdt_code:
dw 0xffff ; Limit 0-1MB (low)
dw 0x0000 ; Base 0-1MB (low)
db 0x00 ; Base (middle)
db 10011010b ; Access: P=1, DPL=0, S=1, Type=Code, R/W=1, A=0
db 11001111b ; Granularity=4K, Size=32bit, Limit (high), Base (high)
gdt_data:
dw 0xffff
dw 0x0000
db 0x00
db 10010010b ; Access: P=1, DPL=0, S=1, Type=Data, R/W=1, A=0
db 11001111b
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1 ; GDT Limit
dd gdt_start ; GDT Base Address
times 510 - ($ - $$) db 0 ; Fill with zeros until 510 bytes
dw 0xaa55 ; Boot signature
Phase 2: Entering Protected Mode
Protected mode provides 32-bit (or 64-bit) addressing, memory segmentation and paging, and hardware-enforced protection. The transition primarily involves setting up a Global Descriptor Table (GDT) and manipulating the `CR0` control register. The GDT defines memory segments, including their base address, limit, and access rights. Our example GDT defines two segments: a 32-bit code segment and a 32-bit data segment, both covering the entire 4GB address space for simplicity.
bits 32 ; Now in 32-bit protected mode
protected_mode_start:
mov ax, 0x10 ; Load data segment selector (0x10 for gdt_data)
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, 0x90000 ; Setup a new stack in high memory
; --- Jump to Kernel Entry Point ---
; The kernel is assumed to be loaded at 0x100000
call KERNEL_ENTRY_POINT ; Call the kernel's main function
; If kernel returns, halt system
hlt
In this 32-bit segment, we load the segment registers with our data segment selector (0x10 refers to the 2nd entry in the GDT, `gdt_data`). We also set up a new stack in a higher memory region, crucial since the real mode stack at 0x7c00 will be overwritten or too low. Finally, we make a `call` to our kernel’s entry point, which we’ve arbitrarily named `KERNEL_ENTRY_POINT` for now.
Phase 3: Loading Your Micro-Kernel
The `int 0x13` BIOS call in the real mode boot sector is used to load our kernel. In a more advanced bootloader, you’d parse a file system (like FAT32 or Ext2) to find and load your kernel binary. For this example, we’re assuming the kernel is a raw binary (`.bin` file) placed immediately after the bootloader on the disk, loaded to a specific high memory address (e.g., 0x100000 or 1MB).
Your kernel’s entry point should be designed to handle the state of the CPU after the hand-off:
- CPU is in 32-bit protected mode.
- GDT is loaded.
- Segment registers (CS, DS, ES, FS, GS, SS) are configured.
- Stack pointer (ESP) is initialized.
- Interrupts are disabled (`cli` was used before GDT load).
A minimal C kernel might look like this:
// kernel.c
void kmain(void)
{
char* video_memory = (char*)0xb8000; // Text mode video memory
*video_memory = 'H';
*(video_memory + 1) = 0x0F; // White on black
video_memory += 2;
*video_memory = 'e';
*(video_memory + 1) = 0x0F;
video_memory += 2;
*video_memory = 'l';
*(video_memory + 1) = 0x0F;
video_memory += 2;
*video_memory = 'l';
*(video_memory + 1) = 0x0F;
video_memory += 2;
*video_memory = 'o';
*(video_memory + 1) = 0x0F;
while(1) { ; } // Kernel halts
}
To make this C function the `KERNEL_ENTRY_POINT`, you need to compile it and link it at the specified load address. The `call KERNEL_ENTRY_POINT` in assembly should refer to the address of `kmain` in your kernel binary.
Phase 4: The Hand-off
The `call KERNEL_ENTRY_POINT` instruction is the ultimate hand-off. It pushes the return address onto the stack (the address of the `hlt` instruction in our bootloader) and jumps to the kernel’s entry point. From this moment, your kernel code is executing, taking full control of the system. The kernel can then initialize its own data structures, set up an Interrupt Descriptor Table (IDT), configure paging, and begin managing system resources.
Building and Testing Your OS
Here’s how you can compile and link these components:
- **Compile Bootloader**: Use NASM to assemble the bootloader.
nasm -f bin bootloader.asm -o bootloader.bin - **Compile Kernel**: Use GCC (cross-compiler recommended) for your kernel.
i686-elf-gcc -m32 -ffreestanding -c kernel.c -o kernel.o - **Link Kernel**: Link the kernel object file. Ensure the entry point and base address are correct.
i686-elf-ld -m elf_i386 -Ttext 0x100000 --oformat binary kernel.o -o kernel.bin(The `0x100000` matches our load address). - **Create Disk Image**: Combine the bootloader and kernel.
cat bootloader.bin kernel.bin > os.img - **Run in QEMU**: Test your creation in a virtual machine.
qemu-system-i386 -fda os.img
If all goes well, QEMU will launch, and you should see
Android Mobile Specs & Compare Directory
Are you researching mobile hardware properties, processor SoCs, GPU chipsets, or RAM configurations? Access our complete specs catalog to compare up to 5 devices side-by-side!
Compare Devices Specs →