← Back to list

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…

Pritin D · 2026-02-21 11:20 · 4 claps · 7.1 min read
#operating-systems #assembly-language #bootloader #x86 #software-development
Open on Medium ↗

Building a toy OS: The bootloader

Photo by Zulfugar Karimov on Unsplash

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

Nanobyte series

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