Building a toy OS: The bootloader
Building an OS and customizing it is quite a fun and interesting project to do. So, I am building a toy OS for fun and to tinker with. I…
Building a toy OS: The bootloader
Photo by Zulfugar Karimov on Unsplash
Building an OS and customizing it is quite a fun and interesting project to do. So, I am building a toy OS for fun and to tinker with. I will be writing the stages of the development along with a few problems that I encounter. This first development focuses on the creation of the bootloader, the foundation that brings the system to life.
Introduction:
A bootloader is a small program responsible for loading the operating system into memory after the BIOS/UEFI performs its initial hardware checks. The main steps of the bootlader is to initialize the CPU registers and stack, enabling access to storage, transition CPU modes (in x86) and then finally load the kernel binary and jump to it.
A few resources that I went for during development:
https://wiki.osdev.org/Boot_Sequence
https://wiki.osdev.org/Rolling_Your_Own_Bootloader
Step 1: Building the origin (The MBR):
The transition from hardware to the first line of software is defined by a handshake, which is again governed by the legacy standards used. The first 512 bytes show the life of the OS. The OS life begins at the very first sector (Sector 0) of the bootable drive, which in this case using a 1.44 Mb floppy drive. Upon powering up, the BIOS scans hardware devices in a specific order which is handled in the MakeFile, and simply looks for a valid boot sector to hand off control. To determine if a sector is actually bootable, the BIOS checks the final two bytes of the 512-byte chunk. It looks specifically for the hexadecimal values 0x55 and 0xAA. Once the signature is verified, the BIOS copies that 512-byte sector into physical memory at address 0x7C00. It then sets the Instruction Pointer (IP) to that address. And Voila! the code finally starts executing.
org 0x7C00
bits 16
; rest of the code
times 510-($-$$) db 0 ; Fill the rest of the sector with zeros
dw 0AA55h ; The Magic Boot Signature
Step 2: Declaring the BPB:
Immediately following the entry point, the code contains a block of data that looks like a configuration header. This is the BIOS Parameter Block, it identifies the disk’s physical properties like sector size and all the geometry.
As we are using QEMU for our emulation, it expects to be FAT12 system and if this header is missing, the BIOS will assume the disk is corrupt and refuse to perform disk operations, causing the bootloader to fail silently. By hardcoding these values, it is effectively faking a valid FAT12 file system header so the BIOS treats the raw binary disk as a legitimate floppy drive.
The parameters and the values can be found here : https://thestarman.pcministry.com/asm/mbr/GRUBbpb.htm
; Header for BIOS Parameter Block (BPB)
; This is needed so BIOS knows the disk geometry (standard 1.44MB Floppy)
jmp short start
nop
bdb_oem: db 'MSWIN4.1' ; 8 bytes
bdb_bytes_per_sector: dw 512
bdb_sectors_per_cluster: db 1
bdb_reserved_sectors: dw 1
bdb_fat_count: db 2
bdb_dir_entries_count: dw 0E0h
bdb_total_sectors: dw 2880 ; 2880 * 512 = 1.44MB
bdb_media_descriptor_type: db 0F0h ; F0 = 3.5" floppy
bdb_sectors_per_fat: dw 9
bdb_sectors_per_track: dw 18
bdb_heads: dw 2
bdb_hidden_sectors: dd 0
bdb_large_sector_count: dd 0
Step 3: Setting up segments and stack:
When transitioning from BIOS control to our own code, the state of the CPU is unpredictable. Stabilizing the memory environment is the first priority.
The x86 CPU in Real Mode calculates addresses using Segmentation to address 1MB of RAM: Phy Addr = (Segment 16) + offset*. When the BIOS jumps to the code, the segment registers (DS, ES, SS) often contain leftover values. If it is not explicitly set to zero, any attempt to read data or variables will result in the CPU looking at a completely different physical address than intended.
On x86, the stack grows down, so if we don’t initialize the stack it might overwrite the bootloader. By setting the Stack Pointer (SP)to 0x7C00 (the very start of the loaded code), the stack grows away from the program into the empty memory addresses below it
main:
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows down from 0x7C00
Step 4: LBA to CHS conversion:
This is the most complex logic in the bootloader. We want to read the disk linearly (Sector 0, Sector 1, Sector 2…). This is called Logical Block Addressing (LBA). However, the legacy BIOS Interrupt (int 13h) does not understand LBA. It interacts with the physical mechanics of the spinning disk:
- Cylinder: How far out is the arm?
- Head: Is it reading the top or bottom platter?
- Sector: Which rotational slice is under the head?
The function performs the mathematical conversion to translate the request ("Give me Sector 1") into physical coordinates ("Cylinder 0, Head 0, Sector 2").
https://thejat.in/learn/disk-addressing
; ------------------------------------------------------------------------------
; Function: lba_to_chs
; Converts Logical Block Address (LBA) to Cylinder-Head-Sector (CHS)
; Formula:
; Sector = (LBA % sectors_per_track) + 1
; Head = (LBA / sectors_per_track) % heads
; Cylinder = (LBA / sectors_per_track) / heads
; Params: ax = LBA address
; Returns: cx [bits 0-5]: sector number
; cx [bits 6-15]: cylinder
; dh: head
; ------------------------------------------------------------------------------
lba_to_chs:
push ax
push dx
xor dx, dx ; dx = 0
div word [bdb_sectors_per_track] ; ax = LBA / SectorsPerTrack
; dx = LBA % SectorsPerTrack
inc dx ; Sector = (LBA % SectorsPerTrack) + 1
mov cx, dx ; cx = Sector
xor dx, dx ; dx = 0
div word [bdb_heads] ; ax = (LBA / SectorsPerTrack) / Heads = Cylinder
; dx = (LBA / SectorsPerTrack) % Heads = Head
mov dh, dl ; dh = Head
mov ch, al ; ch = Cylinder (lower 8 bits)
shl ah, 6
or cl, ah ; put upper 2 bits of cylinder in CL
pop ax
mov dl, al ; restore DL
pop ax
ret
Step 5: The loader:
With the math in place, the bootloader performs its primary function: reading the rest of the OS from the disk.
Since we don’t have disk drivers yet, you rely on BIOS Interrupt 0x13, Function 02h. This is the standard legacy interface for reading Sectors from a physical drive into RAM. To tell the BIOS where to put the data, a pointer made of two registers are used. ES (Extra Segment) and BX (Base Register) combine to form the final memory address. Once the BIOS confirms the sectors are loaded successfully, the bootloader simply jumps to 0x1000, to the kernel.
mov ax, 0 ; Segment 0
mov es, ax
mov bx, 0x1000 ; Destination: 0x1000
mov ax, 1 ; Start at LBA 1 (Sector 2)
mov cl, 1 ; Read 1 sector
call disk_read ; Execute BIOS Interrupt
The completely refactored code with comments:
org 0x7C00
bits 16
%define ENDL 0x0D, 0x0A
; Header for BIOS Parameter Block (BPB)
jmp short start
nop
bdb_oem: db 'MSWIN4.1' ; 8 bytes
bdb_bytes_per_sector: dw 512
bdb_sectors_per_cluster: db 1
bdb_reserved_sectors: dw 1
bdb_fat_count: db 2
bdb_dir_entries_count: dw 0E0h
bdb_total_sectors: dw 2880 ; 2880 * 512 = 1.44MB
bdb_media_descriptor_type: db 0F0h ; F0 = 3.5" floppy
bdb_sectors_per_fat: dw 9
bdb_sectors_per_track: dw 18
bdb_heads: dw 2
bdb_hidden_sectors: dd 0
bdb_large_sector_count: dd 0
; Extended Boot Record
ebr_drive_number: db 0 ; 0x00 usually floppy, 0x80 usually hdd
db 0 ; reserved
ebr_signature: db 29h
ebr_volume_id: db 12h, 34h, 56h, 78h
ebr_volume_label: db 'MY OS ' ; 11 bytes
ebr_system_id: db 'FAT12 ' ; 8 bytes
start:
jmp main
; ------------------------------------------------------------------------------
; Function: puts
; Prints a null-terminated string to the screen
; Params: ds:si points to string
; ------------------------------------------------------------------------------
puts:
push si
push ax
push bx
.loop:
lodsb ; load byte from ds:si into al, increment si
or al, al ; check if al is 0 (null terminator)
jz .done
mov ah, 0x0e ; tty output
mov bh, 0 ; page 0
int 0x10
jmp .loop
.done:
pop bx
pop ax
pop si
ret
lba_to_chs:
push ax
push dx
xor dx, dx ; dx = 0
div word [bdb_sectors_per_track] ; ax = LBA / SectorsPerTrack
; dx = LBA % SectorsPerTrack
inc dx ; Sector = (LBA % SectorsPerTrack) + 1
mov cx, dx ; cx = Sector
xor dx, dx ; dx = 0
div word [bdb_heads] ; ax = (LBA / SectorsPerTrack) / Heads = Cylinder
; dx = (LBA / SectorsPerTrack) % Heads = Head
mov dh, dl ; dh = Head
mov ch, al ; ch = Cylinder (lower 8 bits)
shl ah, 6
or cl, ah ; put upper 2 bits of cylinder in CL
pop ax
mov dl, al ; restore DL
pop ax
ret
; ------------------------------------------------------------------------------
; Function: disk_read
; Reads sectors from disk into memory
; Params: ax = LBA address
; cl = number of sectors to read
; dl = drive number
; es:bx = memory location to store data
; ------------------------------------------------------------------------------
disk_read:
push ax
push bx
push cx
push dx
push di
push cx ; temporarily save CL (number of sectors to read)
call lba_to_chs ; convert LBA to CHS
pop ax ; restore number of sectors to AL
mov ah, 0x02 ; BIOS int 13h "Read Sectors"
mov di, 3 ; retry count (floppies are unreliable, try 3 times)
.retry:
pusha ; save registers, int 13h might mess them up
stc ; set carry flag
int 0x13 ; Call BIOS
jnc .done ; jump if no carry (success)
; if failed, reset disk controller and try again
popa
call disk_reset
dec di
test di, di
jnz .retry
.fail:
; panic here
jmp floppy_error
.done:
popa
pop di
pop dx
pop cx
pop bx
pop ax
ret
disk_reset:
pusha
mov ah, 0
stc
int 0x13
jc floppy_error
popa
ret
floppy_error:
mov si, msg_read_failed
call puts
hlt
jmp floppy_error
; ------------------------------------------------------------------------------
; MAIN
; ------------------------------------------------------------------------------
main:
; setup data segments
mov ax, 0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00
mov si, msg_hello
call puts
; Setup where to write the data (ES:BX)
; We will load the kernel to address 0x1000 (ES=0, BX=0x1000)
mov ax, 0 ; Segment 0
mov es, ax
mov bx, 0x1000 ; Offset 0x1000 (kernel)
; Call disk_read
mov ax, 1 ; LBA=1 (start reading from 2nd sector, 1st is bootloader)
mov cl, 1 ; Read 1 sector
mov dl, [ebr_drive_number] ; Drive number (passed by BIOS in DL, saved in BPB)
call disk_read
mov si, msg_loaded
call puts
hlt
.halt:
jmp .halt
msg_hello: db "Booting OS...", ENDL, 0
msg_read_failed: db "Disk read failed!", ENDL, 0
msg_loaded: db "Sector loaded to 0x1000", ENDL, 0
times 510-($-$$) db 0
dw 0AA55h
Conclusion:
Use this MakeFile to build and run the bootloader, and then run it in qemu:
ASM=nasm
SRC_DIR=src
BUILD_DIR=build
$(BUILD_DIR)/bootloader_floppy.img: $(BUILD_DIR)/bootloader.bin
cp $(BUILD_DIR)/bootloader.bin $(BUILD_DIR)/bootloader_floppy.img
truncate -s 1440k $(BUILD_DIR)/bootloader_floppy.img
$(BUILD_DIR)/bootloader.bin: $(SRC_DIR)/bootloader.asm
mkdir -p $(BUILD_DIR)
$(ASM) $(SRC_DIR)/bootloader.asm -f bin -o $(BUILD_DIR)/bootloader.bin
The next stage is to move to protected mode and build an entry point for the kernel to test.
메타데이터
- post_id
- d0705e3efcdf
- slug
- building-a-toy-os-the-bootloader-d0705e3efcdf
- url
- https://medium.com/@pritindra.d/building-a-toy-os-the-bootloader-d0705e3efcdf
- canonical_url
- https://medium.com/@pritindra.d/building-a-toy-os-the-bootloader-d0705e3efcdf
- author_url
- https://medium.com/@pritindra.d
- status
- ok
- fetched_at
- 2026-06-14 11:28:49