DEV Community

Mr 3
Mr 3

Posted on

Coding a custom Bootloader.

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

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And then, we perform a far jump to reload the code segment (cs) and switch to 32-bit mode:

jmp 08h:init_pm
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)