This is the first part of my "Multi Part series of articles" about making my own custom OS
Building a custom bootloader from scratch can feel like solving a puzzle with pieces that barely fit together. The bootloader is the first step in getting your operating system up and running, and it does this by loading your kernel into memory and switching the CPU from 16-bit real mode to 32-bit protected mode. This process involves a lot of low-level work, but here’s the detailed breakdown of everything you need to know.
Table of Contents
- 16-bit Real Mode: Where Everything Begins
- Loading the Kernel: Dealing with Disk Sectors
- Switching to Protected Mode
- Setting Up the GDT
- Switching to Protected Mode: The Jump
- Bootloader Full Code
- Wrapping Up
16-bit Real Mode: Where Everything Begins
The first hurdle in this whole journey is starting in 16-bit real mode. I know, it’s like being stuck in the Stone Age of computing, but it’s what we have to work with when the BIOS loads up your bootloader. The BIOS loads everything into real mode, where we’re restricted to using just 1MB of memory. It's not pretty, but it's where we all start eh?
The first thing we do is set up the stack, pointing it to 0x7c00
– the address where the BIOS dumps our bootloader.
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00
After that, we’ve got to initialize the segment registers to work with memory properly. Once we’ve got that sorted, we move on to the real task at hand – loading the kernel.
Loading the Kernel: Dealing with Disk Sectors
One of the main jobs of the bootloader is to read the kernel from disk. This is where BIOS interrupts come in clutch. We use int 0x13
to read the sectors and load the kernel into memory.
mov ah, 0x02 ; BIOS read sectors function
mov al, 1 ; Number of sectors to read
mov ch, 0 ; Cylinder number
mov dh, 0 ; Head number
mov dl, [BOOT_DRIVE] ; Drive number (0x00 for floppy, 0x80 for hard drive)
int 0x13 ; Call BIOS to read sector
jc disk_error ; Jump if there’s a carry flag (read error)
Each sector is loaded into memory, with the kernel being placed at 0x1000
(our kernel offset). If something goes wrong, we handle the error by checking the carry flag after each read.
After loading the kernel, we throw in some string printing just to show the kernel is loaded successfully.
mov si, MSG_KERNEL_LOADED
call print_string
By the way, the process of getting BIOS interrupts to cooperate feels like finding a needle in haystack.
Switching to Protected Mode
Once we’ve got the kernel loaded, it’s time to switch to protected mode. Now, this is where things get spicy. Protected mode unlocks the full potential of the CPU, giving us access to more memory and advanced features, but it also means saying goodbye to BIOS interrupts.
The first thing we do is disable interrupts using cli
. This ensures no pesky interrupts get in our way while we’re making the switch.
cli ; Clear interrupts
Next, we set up the Global Descriptor Table (GDT), which is crucial for handling memory in protected mode.
Setting Up the GDT
The GDT (Global Descriptor Table) is what tells the CPU how to handle memory segments in protected mode. We set up a null descriptor (because it’s required), a code segment for instructions, and a data segment for handling memory. Here’s how it looks:
gdt_start:
dq 0x0 ; Null descriptor (required)
gdt_code:
dw 0xFFFF ; Limit (low)
dw 0x0000 ; Base (low)
db 0x00 ; Base (middle)
db 10011010b ; Access byte (32-bit code segment)
db 11001111b ; Flags (4 KB granularity)
db 0x00 ; Base (high)
gdt_data:
dw 0xFFFF ; Limit (low)
dw 0x0000 ; Base (low)
db 0x00 ; Base (middle)
db 10010010b ; Access byte (data segment)
db 11001111b ; Flags (4 KB granularity)
db 0x00 ; Base (high)
gdt_end:
Once the GDT is set up, we load it using lgdt
:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
lgdt [gdt_descriptor]
Switching to Protected Mode, The Jump
Here’s the moment of truth. To officially switch into protected mode, we set the PE (Protection Enable) bit in the CR0 register.
mov eax, cr0
or eax, 0x1 ; Set the PE bit
mov cr0, eax
And then, we perform a far jump to reload the code segment (cs
) and switch to 32-bit mode:
jmp 08h:init_pm
Once this jump happens, congratulations! You’re in protected mode, my friend. And from here on, everything is running in 32-bit mode.
Bootloader Full Code
Here’s the full bootloader code without comments for all my Ctrl+C, Ctrl+V folks.
[org 0x7c00]
[bits 16]
KERNEL_OFFSET equ 0x1000
boot_start:
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00
mov [BOOT_DRIVE], dl
mov si, MSG_REAL_MODE
call print_string
call load_kernel
mov si, MSG_KERNEL_LOADED
call print_string
call switch_to_pm
jmp $
load_kernel:
mov si, MSG_LOAD_KERNEL
call print_string
mov bx, KERNEL_OFFSET
mov dh, 30
mov dl, [BOOT_DRIVE]
call disk_load
ret
switch_to_pm:
mov si, MSG_SWITCH_PM
call print_string
cli
lgdt [gdt_descriptor]
mov eax, cr0
or eax, 0x1
mov cr0, eax
jmp CODE_SEG:init_pm
[bits 32]
init_pm:
mov ax, DATA_SEG
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ebp, 0x90000
mov esp, ebp
call KERNEL_OFFSET
gdt_start:
dq 0x0
gdt_code:
dw 0xFFFF
dw 0x0
db 0x0
db 10011010b
db 11001111b
db 0x0
gdt_data:
dw 0xFFFF
dw 0x0
db 0x0
db 10010010b
db 11001111b
db 0x0
gdt_end:
gdt_descriptor:
dw gdt_end - gdt_start - 1
dd gdt_start
CODE_SEG equ gdt_code - gdt_start
DATA_SEG equ gdt_data - gdt_start
disk_load:
pusha
push dx
mov ah, 0x02
mov al, dh
mov cl, 0x02
mov ch, 0x00
mov dh, 0x00
int 0x13
jc disk_error
pop dx
cmp al, dh
jne sectors_error
popa
ret
disk_error:
mov si, DISK_ERROR
call print_string
jmp disk_loop
sectors_error:
mov si, SECTORS_ERROR
call print_string
disk_loop:
jmp $
print_string:
pusha
mov ah, 0x0E
.loop:
lodsb
cmp al, 0
je .done
int 0x10
jmp .loop
.done:
popa
ret
BOOT_DRIVE db
0
MSG_REAL_MODE db "Started in 16-bit real mode", 0
MSG_LOAD_KERNEL db "Loading kernel into memory", 0
MSG_KERNEL_LOADED db "Kernel loaded successfully", 0
MSG_SWITCH_PM db "Switching to protected mode", 0
DISK_ERROR db "Disk read error", 0
SECTORS_ERROR db "Incorrect number of sectors read", 0
times 510-($-$$) db 0
dw 0xAA55
Wrap Up
So, after writing, rewriting, and then rewriting again (six times, if you’re keeping count), we finally got the bootloader working the way it should. Going from real mode to protected mode isn’t easy, but it’s doable with the right setup.
If you’re trying to build something similar, just keep at it. You’ll hit roadblocks, but that’s part of the process. And trust me, when you see the kernel finally load, it’s all worth it.
The project is on: GitHub
Top comments (0)