New Sep 19, 2024

Making a custom Bootloader for a Custom OS.

The Giants All from DEV Community View Making a custom Bootloader for a Custom OS. on dev.to

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

Introduction

So, I was thinking while I was putting this bootloader together: why don't I just grab something pre-made and save myself a week of stress? But then again, where’s the fun in that, right? Have you ever been so deep into coding that you start wondering if your keyboard is secretly judging you? Yeah, that’s kinda how this whole experience felt. Anyway, back to the point – making a bootloader from scratch is the kind of challenge that builds character... or at least keeps you humble. Let's break it down.

Oh, by the way, what got you into OS development? Did you wake up one day and decide that writing a bootloader sounded like a good time? I mean, I get it. It's either that or trying to understand JavaScript promises.

16-bit Real Mode: Where Everything Begins

First, you start in 16-bit real mode. Yeah, it's ancient, but we gotta deal with it. The BIOS loads your bootloader in real mode, which restricts you to 1MB of memory. It’s like the brain freeze you get from chugging ice-cold water – slow, painful, and you just want to get through it so you can get to the good stuff (like protected mode).

You’ve got the usual setup with your segment registers, ds, es, and ss, setting them up so you can handle memory properly. Nothing fancy here, but you have to do it.

xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7c00

That’s the stack setup, sitting at 0x7C00, which is where BIOS plops the bootloader when it loads it into memory. From there, the magic starts happening.

Loading the Kernel: Dealing with Disk Sectors

This is where you’ll be glad you read up on BIOS interrupts. The main job of the bootloader is to load the kernel from disk. We do this by reading sectors using the int 0x13 interrupt.

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)

This reads the kernel off the disk, sector by sector, and loads it into memory at the correct location. You’ve got to make sure the carry flag is checked after each read, otherwise, you might end up loading garbage into memory.

By the way, how do you feel about debugging bootloaders? I swear, it’s like searching for a needle in a haystack... in the dark... with no hands.

Switching to Protected Mode

Once the kernel is loaded, it’s time to switch from 16-bit real mode to 32-bit protected mode. That’s when the CPU really wakes up and you get access to more than just the 1MB of memory.

First, you disable interrupts using cli, because the last thing you need during a mode switch is a stray interrupt messing up your day.

cli   ; Clear interrupts

Then, you set up the Global Descriptor Table (GDT). The GDT tells the CPU how to handle memory segments, which are critical in protected mode.

Setting Up the GDT

The GDT is a fancy table that tells the CPU where each segment starts and how big it is. We define a null descriptor (which is mandatory), a code segment for instructions, and a data segment for, well, data.

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:

Then, we use the lgdt instruction to load the GDT into the CPU.

gdt_descriptor:
    dw gdt_end - gdt_start - 1
    dd gdt_start

lgdt [gdt_descriptor]

The GDT setup is pretty much a "do it right or you’re doomed" situation. If you mess this up, you’re gonna end up in a bootloop or a triple fault.

Switching to Protected Mode: The Jump

To officially switch to protected mode, you need to set the PE (Protection Enable) bit in the CR0 register.

mov eax, cr0
or eax, 0x1   ; Set the PE bit
mov cr0, eax

Now, here’s the trick: after enabling protected mode, you need to do a far jump to reload the code segment (cs). This jump pushes the CPU into 32-bit mode for real.

jmp 08h:protected_mode_start

Once the jump happens, you’re in protected mode, and the CPU is ready to take on your kernel like it’s ready for war. At this point, BIOS interrupts stop working, so everything from here on needs to be set up by you – no more hand-holding.

Bootloader Full Code

Alright, here’s the full bootloader.asm code, stripped of all comments and explanations. It’s clean and simple for My Ctrl+C Ctrl+V people.

[org 0x7c00]
[bits 16]

xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7c00

mov [BOOT_DRIVE], dl

call load_kernel

cli lgdt [gdt_descriptor] mov eax, cr0 or eax, 0x1 mov cr0, eax jmp 08h:init_pm

[bits 32] init_pm: mov ax, 10h mov ds, ax mov ss, ax mov es, ax

mov ebp, 0x90000 mov esp, ebp

jmp 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

BOOT_DRIVE db 0

times 510-($-$$) db 0 dw 0xAA55

Wrapping Up

So, after all the setting up, coding, and messing around with interrupts and disk sectors, the bootloader’s job is done when it hands control to the kernel. A bootloader doesn’t do much – it just gets the system ready and moves on.

This process, while straightforward on paper, is super critical for getting your OS off the ground. Mess up the bootloader, and your system isn’t booting anything. At the end of the day, the satisfaction of seeing the kernel loaded makes it all worth it.

That said, debugging a bootloader is like trying to find that one piece of LEGO under the couch – it’s tough and you’ll step on it a few times before getting it right.

Scroll to top