Writing Your Own OS: Issue 1
A beginner-friendly tutorial on writing your own operating system from scratch, covering bootloader basics, BIOS interrupts, real mode programming, and getting your first "Hello World" message on screen.
Writing Your Own OS: Issue 1
Many programmers dream of writing their own operating system. At first glance, this seems like an impossibly complex task. But if you break it down into small steps, it turns out that creating a basic OS is entirely within reach for anyone who knows assembly language fundamentals. In this series of articles, I'll walk you through creating a simple operating system from scratch.
What You'll Need
- NASM — a free assembler for x86 architecture
- A virtual machine — QEMU or VirtualBox for testing
- A disk image utility — dd (on Linux/Mac) or a similar tool
- Basic knowledge of assembly language
- Patience and curiosity
How a Computer Boots
Before writing any code, let's understand what happens when you turn on a computer:
- The CPU starts executing code from a fixed address in ROM — this is the BIOS (Basic Input/Output System).
- The BIOS performs POST (Power-On Self-Test) — checking that basic hardware is functional.
- The BIOS looks for a bootable device (hard drive, floppy, CD, USB).
- The BIOS reads the first sector (512 bytes) from the bootable device into memory at address
0x7C00. - The BIOS checks for the boot signature: the last two bytes of the sector must be
0x55and0xAA. - If the signature is found, the BIOS transfers control to address
0x7C00— and your code starts running!
These 512 bytes are called the boot sector or MBR (Master Boot Record). This is where our OS begins.
Our First Bootloader
Let's write a minimal bootloader that displays "Hello, World!" on the screen. We'll use BIOS interrupt 0x10 for screen output.
[BITS 16] ; We're working in 16-bit real mode
[ORG 0x7C00] ; Our code is loaded at this address
start:
; Set up segment registers
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; Stack grows downward from where we're loaded
; Display the string
mov si, msg ; Point SI to our message
call print_string
; Hang — we have nowhere else to go
jmp $
print_string:
mov ah, 0x0E ; BIOS teletype function
.loop:
lodsb ; Load next byte from SI into AL
cmp al, 0 ; Is it the null terminator?
je .done ; If so, we're done
int 0x10 ; Otherwise, print the character
jmp .loop
.done:
ret
msg db 'Hello, World!', 0
; Pad the rest of the sector with zeros
times 510-($-$$) db 0
; Boot signature
dw 0xAA55Understanding the Code
Let's break down what each part does:
[BITS 16] — tells the assembler we're writing 16-bit code. When the BIOS transfers control to our bootloader, the CPU is in real mode, which is a 16-bit operating mode dating back to the original Intel 8086 processor.
[ORG 0x7C00] — tells the assembler that our code will be loaded at memory address 0x7C00. This is important for correctly calculating memory addresses for our data (like the message string).
Segment registers setup — we zero out all segment registers (DS, ES, SS) to ensure our addressing works correctly. We set the stack pointer (SP) to 0x7C00, meaning the stack will grow downward from where our code is loaded.
print_string — this function uses BIOS interrupt 0x10 with function 0x0E (teletype output). It loads bytes one at a time from the address pointed to by SI, and calls int 0x10 to display each character. The loop continues until it encounters a null byte (0).
jmp $ — an infinite loop. The $ symbol in NASM refers to the current address, so jmp $ jumps to itself forever. This halts the bootloader after printing the message.
Padding and signature — the times directive pads the rest of our 512-byte sector with zeros. The last two bytes are set to 0xAA55 — the magic boot signature that tells the BIOS this is a valid boot sector. Note that x86 is little-endian, so we write 0xAA55 which gets stored as bytes 0x55 0xAA.
Building and Running
To assemble and test our bootloader:
; Assemble the code
nasm -f bin boot.asm -o boot.bin
; Run in QEMU
qemu-system-i386 -fda boot.binIf everything is correct, you should see "Hello, World!" displayed on a black screen in the QEMU window. Congratulations — you've just written and run your first bootloader!
Running on Real Hardware
You can also test this on real hardware by writing the binary to a USB drive:
; WARNING: This will overwrite the first sector of your USB drive!
; Make absolutely sure /dev/sdX is your USB drive!
dd if=boot.bin of=/dev/sdX bs=512 count=1Then boot your computer from the USB drive and you should see the same "Hello, World!" message.
What's Next?
In the next issue, we'll expand our OS to:
- Read additional sectors from disk — because 512 bytes won't be enough for long
- Switch from real mode to protected mode — unlocking 32-bit capabilities and access to more than 1 MB of memory
- Set up a basic GDT (Global Descriptor Table)
- Implement keyboard input handling
The complete source code for this project is available on GitHub. Feel free to experiment with it — try changing the message, adding color output (hint: use different values in BL before calling int 0x10), or even implementing simple keyboard input using BIOS interrupt 0x16.
Writing an OS is a fascinating journey that teaches you more about how computers actually work than almost any other programming project. Even if you never create a production-ready operating system, the knowledge you gain will make you a better programmer in every other domain.