Why This Project Matters
Let’s be honest for a second. You’ve made it through 29 articles of assembly language programming. You know what a segment register is. You know how LOOP works (and why it’s slow). You know what the LOCK prefix does. You’ve stared at more INT 0x80 examples than any sane person should.
But can you actually build something real?
That’s the question this project answers. Everything you’ve learned up to this point has been preparation. This article is the execution. We’re going to build a minimal operating system kernel module — something that actually boots on a computer (or an emulator like QEMU), handles keyboard input, writes to the screen, and processes system calls.
What You’ll Have at the End
When you finish this project, you’ll have:
- A bootloader (Stage 1 + Stage 2) written in x86 assembly
- A kernel written in a mix of assembly (for the low-level stuff) and C (for the higher-level logic)
- A keyboard driver that reads scancodes and converts them to ASCII
- A video driver that writes text to the screen in text mode
- A system call interface that lets user programs request services from the kernel
- A build system (Makefile + linker script) that ties it all together
- The ability to boot your OS in QEMU and see it actually work
This is not a toy. It’s a real, functional (if minimal) operating system. It won’t run Windows applications. It won’t have a GUI. It won’t have a filesystem (yet). But it boots, it responds to your keyboard, and it writes to the screen. That’s more than most “operating systems” you’ll find on GitHub.
Prerequisites
Before starting this project, you should be comfortable with:
- x86 assembly language (obviously) — registers, instructions, addressing modes
- The boot process — BIOS, POST, boot sector, 0x7C00 load address
- Protected mode concepts — GDT, segment selectors, the
LGDTinstruction, setting thePEbit inCR0 - C programming — because we’re not writing the entire OS in assembly (that way lies madness)
- Basic toolchain usage — NASM assembler, GCC cross-compiler,
ldlinker, QEMU emulator
If any of these sound fuzzy, go back and review Articles 17 (Protected Mode), 26 (Bootloader Development), and 13 (Mixed C and Assembly). I’ll still explain everything as we go, but this moves fast.
Project Architecture and Design Philosophy
Before we write a single line of code, we need to decide what we’re building and how the pieces fit together.
The Big Picture
Here’s what a minimal OS looks like when you zoom out:
┌─────────────────────────────────────────────────────────┐
│ Your OS Image │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ Bootloader │ │ Kernel │ │ User │ │
│ │ Stage 1+2 │ │ (asm + C) │ │ Prog │ │
│ │ (asm) │ │ │ │ (asm) │ │
│ └──────┬───────┘ └──────┬───────┘ └────┬────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Hardware (CPU, RAM, Keyboard, │ │
│ │ Screen, Disk) │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
In our project, the layers are:
| Layer | What It Does | Written In |
|---|---|---|
| Bootloader Stage 1 | Loads the first 512 bytes, sets up minimal environment, loads Stage 2 | x86 assembly (16-bit real mode) |
| Bootloader Stage 2 | Loads the kernel from disk into memory, switches to protected mode | x86 assembly (16-bit → 32-bit) |
| Kernel (Assembly part) | Sets up GDT, IDT, initial stack, then calls kmain() | x86 assembly (32-bit protected mode) |
| Kernel (C part) | The main kernel logic: initializes drivers, runs the shell/REPL | C |
| Keyboard Driver | Reads scancodes from port 0x60, converts to ASCII | Assembly + C |
| Video Driver | Writes text to video memory at 0xB8000 | Assembly + C |
| System Call Handler | Dispatches INT 0x80 (or syscall) to kernel functions | Assembly + C |
Design Decisions (and Why I Made Them)
1. Why x86 instead of ARM or RISC-V?
Look, ARM is the future. RISC-V is the future-er. But x86 is what you can actually run on QEMU without buying a Raspberry Pi, and the documentation for x86 boot sequences is everywhere. Plus, the concepts you learn (bootloader, GDT, IDT, system calls) are universal — they apply to any architecture. Master them on x86, then port to ARM or RISC-V later.
2. Why NASM instead of GAS (GCC assembly)?
NASM uses Intel syntax, which is what every x86 assembly tutorial on the planet uses. GAS uses AT&T syntax (source and destination operands are reversed compared to Intel), and while GAS is more “standard” in the Linux world, NASM is way more readable when you’re learning. Also, NASM works on both Linux and Windows, so you can follow along regardless of your host OS.
3. Why mix C and assembly?
Because writing an entire OS in assembly is a terrible idea. You can do it, and some people have (MikeOS, for example, is written entirely in assembly). But C lets you express higher-level logic (string processing, data structures, algorithms) without burning your eyes out on register allocation. Assembly stays for the parts that need it: context switching, interrupt handling, talking to hardware ports.
4. Why QEMU instead of real hardware?
Because debugging an OS on real hardware involves a lot of rebooting, a lot of USB sticks, and a non-zero chance of accidentally overwriting your hard drive. QEMU gives you:
- Deterministic behavior (the “hardware” never changes)
- GDB integration (you can single-step your OS as it boots!)
- Snapshots (break something? Roll back)
- No risk of bricking your machine
5. Why 32-bit instead of 64-bit?
64-bit (x86-64) is the modern standard, but it adds a lot of complexity: more registers to save/restore, a different calling convention (System V ABI uses RDI, RSI, RDX, RCX, R8, R9 for parameters), and the long mode switching process is more involved. 32-bit protected mode is sufficient to demonstrate all the concepts, and it’s easier to debug. Once you understand 32-bit, 64-bit is a natural extension.
The Boot Process: From Power-On to Your Code
Let’s trace the journey from “you pressed the power button” to “your assembly code is running.”
The BIOS POST (Power-On Self Test)
When the CPU powers on, it’s in real mode (16-bit, 1MB address space, segment:offset addressing). The CPU’s CS:IP is set to 0xFFFF:0x0000, which maps to the BIOS entry point in ROM. The BIOS:
- Runs hardware diagnostics (POST)
- Initializes hardware (keyboard controller, video card, etc.)
- Reads the first 512 bytes from the boot device (floppy, hard disk, USB, CD-ROM — whichever is configured as the boot device)
- Checks the last two bytes of those 512 bytes. If they are
0x55and0xAA, the BIOS treats it as a valid boot sector. - Loads those 512 bytes to memory address
0x7C00(a convention dating back to the original IBM PC) - Jumps to
0x7C00— and at that point, your code is running.
┌──────────────────────────────────────────────────────────────┐
│ x86 Boot Sequence │
│ │
│ Power On │
│ │ │
│ ▼ │
│ CPU enters real mode (CS:IP = FFFF:0000) │
│ │ │
│ ▼ │
│ BIOS runs POST (hardware check) │
│ │ │
│ ▼ │
│ BIOS reads first 512 bytes from boot device │
│ │ │
│ ▼ │
│ BIOS checks last 2 bytes: 0x55, 0xAA? │
│ │ (if not: "No bootable device found") │
│ ▼ │
│ BIOS loads 512 bytes to 0x7C00 │
│ │ │
│ ▼ │
│ BIOS jumps to 0x7C00 ──► YOUR BOOTLOADER RUNS │
│ │
└──────────────────────────────────────────────────────────────┘
Why 0x7C00?
Great question. The answer is “because the original IBM PC BIOS said The IBM PC Technical Reference (1981) says:
“The bootstrap loader is loaded at segment:offset
0x07C0:0x0000(which is physical address0x07C00).”
Why 0x07C0:0x0000 and not just 0x0000:0x7C00? Because the IBM PC had 32KB of RAM (yes, 32 kilobytes), and the BIOS wanted to load the bootloader as high as possible in memory to leave the lower 32KB free for the operating system to use. 0x07C00 is 32,768 in decimal — exactly the size of the original PC’s RAM. So the bootloader sits right at the top of memory.
(Modern computers have way more than 32KB of RAM, of course. But the 0x7C00 convention stuck, because backward compatibility is the x86 way of life.)
Your Constraints in the Boot Sector
You have exactly 510 bytes of useful code space. (The last 2 bytes must be 0x55 and 0xAA, remember?) That’s not much! You can’t fit an entire kernel into 510 bytes. So what do you do?
You write a “Stage 1” bootloader whose only job is to load “Stage 2” from disk.
Stage 1 is the 512-byte boot sector. Its job:
- Set up a stack (because
PUSH/POPneedSS:SPto be valid) - Print a “Loading…” message (optional, 3. Use BIOS
INT 0x13to read more sectors from disk into memory (say, at address0x10000) - Jump to the newly-loaded code
Stage 2 is bigger (you can make it a few KB, or even a few tens of KB). Its job:
- Load the kernel from disk into memory (say, at address
0x100000— 1MB, which is where protected-mode memory starts being useful) - Set up a minimal GDT (Global Descriptor Table)
- Switch to protected mode (set
CR0.PE = 1, then far jump) - Jump to the kernel entry point
Bootloader Stage 1: The 512-Byte Challenge
Alright, let’s write the actual code. I’m going to show you the complete Stage 1 bootloader, then walk through every line.
Complete Stage 1 Bootloader (boot1.asm)
; ============================================================
; boot1.asm — Stage 1 Bootloader
; Assembled with NASM: nasm -f bin boot1.asm -o boot1.bin
; ============================================================
[BITS 16] ; We start in 16-bit real mode
[ORG 0x7C00] ; BIOS loads us at 0x7C00
; ----------------------------------------------------------
; Entry point: BIOS jumps here
; ----------------------------------------------------------
start:
; Disable interrupts during setup (we don't want a timer
; interrupt firing in the middle of setting up the stack!)
CLI
; Set up segment registers
; We use 0x07C0 as the data segment base
MOV AX, 0x07C0
MOV DS, AX
MOV ES, AX
; Set up the stack at 0x7C00 - 512 (i.e., just below us)
; SS:SP = 0x0000:0xFFF0 (wraps around to top of low memory)
MOV AX, 0x0000
MOV SS, AX
MOV SP, 0xFFF0
; Re-enable interrupts
STI
; Print "Loading..." to the screen using BIOS teletype output
MOV SI, loading_msg
CALL print_string
; Now load Stage 2 from disk
; We use BIOS INT 0x13, AH=0x02 (read sectors)
;
; Input registers:
; AH = 0x02 (read sectors function)
; AL = number of sectors to read (we'll read 1 sector = 512 bytes,
; but actually we want Stage 2 to be bigger,
; so let's read 16 sectors = 8KB)
; CH = cylinder number (0 for floppy, 0 for first hard disk partition)
; CL = sector number (Stage 2 is at sector 2, because Stage 1 is at sector 1)
; DH = head number (0)
; DL = drive number (0x80 for first hard disk, 0x00 for first floppy)
; ES:BX = buffer address (where to load the sectors)
;
; IMPORTANT: DL is set by BIOS to the boot drive number.
; We need to preserve it! If we overwrite DL, we can't boot from
; any drive other than the default.
MOV [boot_drive], DL ; Save the boot drive number
MOV AH, 0x02 ; BIOS: read sectors
MOV AL, 16 ; Read 16 sectors (8KB should be enough for Stage 2)
MOV CH, 0 ; Cylinder 0
MOV CL, 2 ; Sector 2 (Stage 1 is sector 1)
MOV DH, 0 ; Head 0
MOV DL, [boot_drive] ; Restore boot drive number
MOV BX, 0x0000 ; ES:BX = 0x1000:0x0000 = physical 0x10000
MOV ES, BX
MOV BX, 0x0000
INT 0x13 ; BIOS disk service
JC disk_error ; If carry flag set, error occurred
; Check if we actually read the sectors we asked for
CMP AL, 16 ; AL = number of sectors actually read
JNE disk_error
; Success! Jump to Stage 2
JMP 0x1000:0x0000
; ----------------------------------------------------------
; print_string: Prints a null-terminated string at DS:SI
; ----------------------------------------------------------
print_string:
PUSHA ; Save all registers
MOV AH, 0x0E ; BIOS teletype function
.loop:
LODSB ; Load byte at DS:SI into AL, increment SI
CMP AL, 0
JE .done
INT 0x10 ; BIOS video service (teletype output)
JMP .loop
.done:
POPA ; Restore all registers
RET
; ----------------------------------------------------------
; disk_error: Print error message and halt
; ----------------------------------------------------------
disk_error:
MOV SI, error_msg
CALL print_string
.hang:
HLT ; Halt the CPU
JMP .hang ; If an NMI wakes us up, halt again
; ----------------------------------------------------------
; Data
; ----------------------------------------------------------
loading_msg DB "Loading Stage 2...", 0
error_msg DB "Disk read error!", 0
boot_drive DB 0 ; Saved boot drive number
; ----------------------------------------------------------
; Pad to 510 bytes, then add the boot signature
; ----------------------------------------------------------
TIMES 510-($-$$) DB 0
DW 0xAA55 ; Boot signature (must be at offset 510-511)
Walkthrough
[BITS 16] and [ORG 0x7C00]
[BITS 16] tells NASM to generate 16-bit code (real mode uses 16-bit instructions). [ORG 0x7C00] tells NASM that our code will be loaded at offset 0x7C00, so all the JMP targets and variable addresses are calculated relative to that base.
Setting up the stack
MOV AX, 0x0000
MOV SS, AX
MOV SP, 0xFFF0
This sets the stack to grow downward from 0x0000:0xFFF0. Real mode addressing: segment * 16 + offset = physical address. So SS:SP = 0x0000:0xFFF0 = physical 0xFFF0. When you PUSH something, SP decreases. When SP underflows 0x0000, it wraps around to 0xFFFF (that’s how x86 real mode works — no memory protection!).
The disk read (INT 0x13)
This is the most error-prone part of any bootloader. The registers have to be exactly right. Key points:
DLcontains the boot drive number (set by BIOS). Save it! If you overwriteDL, you can’t read from the correct disk.ES:BXis the buffer address. We load to0x1000:0x0000= physical0x10000. Why there? Because0x00000-0x0FFFFis the first 64KB, which is where the BIOS and your bootloader live.0x10000is safely out of the way.- We read 16 sectors (8KB). That should be enough for Stage 2. If Stage 2 grows bigger, increase
AL.
The far jump
JMP 0x1000:0x0000
This is a far jump — it loads both CS and IP. We’re jumping to the code we just loaded at 0x1000:0x0000. At this point, we’re still in real mode. Stage 2 will handle the protected mode switch.
Bootloader Stage 2: Loading the Real Kernel
Stage 2 is where things get interesting. It needs to:
- Load the kernel from disk into memory (we’ll put it at
0x100000— 1MB physical, which is the start of extended memory) - Set up a minimal GDT (we need something for protected mode to work)
- Switch to protected mode
- Jump to the kernel entry point
Complete Stage 2 Bootloader (boot2.asm)
; ============================================================
; boot2.asm — Stage 2 Bootloader
; Assembled with: nasm -f bin boot2.asm -o boot2.bin
; Loaded by Stage 1 at physical address 0x10000
; ============================================================
[BITS 16] ; Still in 16-bit real mode (for now!)
; ----------------------------------------------------------
; Entry point (Stage 1 jumps here)
; ----------------------------------------------------------
start:
MOV AX, 0x1000
MOV DS, AX
MOV ES, AX
; Print status message
MOV SI, stage2_msg
CALL print_string
; --------------------------------------------------------
; Step 1: Load the kernel from disk
; --------------------------------------------------------
; The kernel is stored at sector 18 (just after Stage 2)
; We'll read 64 sectors = 32KB (should be enough for our demo kernel)
;
; NOTE: This assumes the kernel fits in 32KB. For a real OS,
; you'd implement a simple filesystem (or at least know where
; on disk the kernelELF binary starts).
MOV AX, 0x1000
MOV ES, AX ; ES = 0x1000 (same segment as us, for simplicity)
MOV AH, 0x02 ; BIOS: read sectors
MOV AL, 64 ; Read 64 sectors (32KB)
MOV CH, 0 ; Cylinder 0
MOV CL, 18 ; Sector 18 (Stage 1 = sector 1, Stage 2 = sectors 2-17)
MOV DH, 0 ; Head 0
MOV DL, [0x7C00 + boot_drive_offset] ; Get boot drive from Stage 1's data
MOV BX, 0x0000 ; ES:BX = 0x1000:0x0000 = physical 0x10000... wait.
; ACTUALLY, we want to load the kernel to 0x100000 (1MB).
; But in real mode, we can only address up to 0xFFFF0 (1MB - 16 bytes)
; using segment:offset. To access memory above 1MB, we need...
; (a) Use the 32-bit address space (protected mode), or
; (b) Use the HMA (High Memory Area) via A20 gate + protected mode trick
;
; Simplest approach: Switch to protected mode FIRST (with a minimal GDT),
; then load the kernel to 0x100000 using 32-bit addressing.
; So let's reorder: GDT setup → protected mode switch → load kernel to 1MB
; --------------------------------------------------------
; Step 2: Set up GDT and switch to protected mode
; --------------------------------------------------------
CLI ; Disable interrupts (IDT not set up yet)
; Load GDT
LGDT [gdt_descriptor]
; Enable A20 line (required for accessing memory above 1MB in protected mode)
CALL enable_a20
; Set CR0.PE (Protection Enable) bit
MOV EAX, CR0
OR EAX, 0x1
MOV CR0, EAX
; Far jump to flush the prefetch queue and enter protected mode
JMP 0x08:protected_mode_entry ; 0x08 = code segment selector
; ----------------------------------------------------------
; enable_a20: Enables the A20 line (required for >1MB access)
; ----------------------------------------------------------
enable_a20:
; Use the "Fast A20" method (write to port 0x92)
IN AL, 0x92
OR AL, 0x02
OUT 0x92, AL
RET
; ----------------------------------------------------------
; GDT Definition
; ----------------------------------------------------------
gdt_start:
; Null descriptor (required by Intel — the CPU will #GP if you use selector 0x00)
DD 0x00000000
DD 0x00000000
; Code segment descriptor
; Base=0x00000000, Limit=0xFFFFF (4GB), Present=1, DPL=0, Type=code (execute/read)
DW 0xFFFF ; Limit [15:0]
DW 0x0000 ; Base [15:0]
DB 0x00 ; Base [23:16]
DB 0x9A ; Access byte: Present=1, DPL=0, Type=code
DB 0xCF ; Flags + Limit [19:16]: Granularity=1 (4KB pages), 32-bit=1
DB 0x00 ; Base [31:24]
; Data segment descriptor
; Base=0x00000000, Limit=0xFFFFF, Present=1, DPL=0, Type=data (read/write)
DW 0xFFFF
DW 0x0000
DB 0x00
DB 0x92 ; Access byte: Present=1, DPL=0, Type=data
DB 0xCF
DB 0x00
gdt_end:
gdt_descriptor:
DW gdt_end - gdt_start - 1 ; GDT limit (size - 1)
DD gdt_start ; GDT base address (physical)
; ----------------------------------------------------------
; We're now in protected mode! (32-bit code)
; ----------------------------------------------------------
[BITS 32]
protected_mode_entry:
; Set up segment registers (now using GDT selectors)
MOV AX, 0x10 ; 0x10 = data segment selector (index 2, TI=0, RPL=0)
MOV DS, AX
MOV ES, AX
MOV FS, AX
MOV GS, AX
MOV SS, AX
; Set up a stack (growing downward from 0x9FFFF — just below video memory)
MOV ESP, 0x9FFFF
; Now we can access memory above 1MB!
; Load the kernel to 0x100000 (1MB physical)
; We'll use a simple BIOS disk read... wait, we can't use BIOS in protected mode!
;
; OH RIGHT. BIOS interrupts don't work in protected mode.
; (BIOS runs in 16-bit real mode. The `INT` instruction in protected mode
; looks up the IDT, not the IVT.)
;
; So we have TWO options:
; (a) Go back to real mode to do the disk read (then switch back to PM)
; (b) Write a protected-mode disk driver (which requires talking to the
; IDE controller directly — way beyond the scope of this article)
;
; For simplicity, let's use option (a): Use "unreal mode" trick.
; Unreal mode = enter protected mode, load GDT, set segment limits to 4GB,
; then switch BACK to real mode. The segment limits "stick"! Now you can
; use 32-bit addressing in real mode to access >1MB memory.
;
; Actually, even simpler: Use GRUB or a proper bootloader instead of
; writing your own. But that defeats the purpose of this exercise.
;
; OK, let's do a proper protected-mode disk read using the BIOS via
; a "BIOS wrapper" that switches to real mode, does INT 0x13, and
; switches back. Yes, it's slow. No, we don't care — it's a demo.
; For this article, let's assume the kernel was already loaded by Stage 1
; (we'll embed it in the disk image right after Stage 2).
; In a real OS, you'd have a filesystem and a proper disk driver.
; For THIS demo: Assume the kernel is already loaded at 0x100000
; (We'll use a tool to stitch Stage 1 + Stage 2 + kernel into one disk image)
; Jump to kernel entry point at 0x100000
MOV EAX, 0x100000
JMP EAX
; ----------------------------------------------------------
; print_string (16-bit version, for Stage 2 before PM switch)
; ----------------------------------------------------------
[BITS 16]
print_string:
PUSHA
MOV AH, 0x0E
.loop:
LODSB
CMP AL, 0
JE .done
INT 0x10
JMP .loop
.done:
POPA
RET
stage2_msg DB "Stage 2 loaded. Switching to protected mode...", 0
; ----------------------------------------------------------
; Pad to make Stage 2 exactly 8KB (16 sectors * 512 bytes)
; ----------------------------------------------------------
TIMES 8192-($-$$) DB 0
Key Concepts in Stage 2
The GDT (Global Descriptor Table)
The GDT is how x86 protected mode implements memory protection. Each entry (called a “segment descriptor”) describes:
- Base address (where the segment starts in physical memory)
- Limit (how big the segment is)
- Access rights (readable? writable? executable? ring 0 only?)
- Granularity (is the limit in bytes or 4KB pages?)
In our minimal GDT, we define:
- A null descriptor (index 0, required, unused)
- A code segment (base=0, limit=4GB, executable)
- A data segment (base=0, limit=4GB, read/write)
With this GDT, we have a flat memory model — every segment covers the entire 4GB address space. This is what modern OSes do (they don’t actually use segmentation; they use paging for protection).
The LGDT instruction
LGDT loads the GDT base address and limit into the CPU’s GDTR register. The CPU needs this to know where to find segment descriptors when your code does MOV DS, AX (the CPU interprets AX=0x10 as “GDT entry at index 2, TI=0, RPL=0”).
The protected mode switch
MOV EAX, CR0
OR EAX, 0x1
MOV CR0, EAX
JMP 0x08:protected_mode_entry
Setting CR0.PE = 1 puts the CPU into protected mode. But the prefetch queue might still contain 16-bit instructions! The far JMP flushes the queue and loads CS with the new selector (0x08 = GDT entry 1, which is our code segment).
The A20 line
The A20 line is a historical artifact. The original 8086 could only address 1MB of memory, but address line A20 (the 21st bit) was wired to wrap around (so accessing 0x100000 would wrap to 0x00000). The 80286 didn’t wrap, which broke backward compatibility. So IBM added a gate (the A20 gate) that you have to explicitly enable to access memory above 1MB. If you don’t enable A20, your 0x100000 access… wraps to 0x00000. Fun times.
Entering Protected Mode (Detailed)
Let me slow down and really explain what happens when you switch to protected mode, because this is one of the most misunderstood topics in x86 assembly.
What Actually Changes When CR0.PE = 1?
When you set the PE (Protection Enable) bit in CR0, the CPU changes its entire addressing model:
| Feature | Real Mode | Protected Mode |
|---|---|---|
| Addressing | segment * 16 + offset | GDT/LDT lookup → base + offset (with limit check) |
| Segment registers | Hold the actual segment value | Hold a selector (index into GDT) |
| Memory protection | None (any code can write anywhere) | Yes (via segment limits, DPL, read/write/execute flags) |
| Privilege levels | None (always ring 0) | 4 rings (0-3), hardware-enforced |
| Interrupts | IVT at 0x0000 (256 entries × 4 bytes) | IDT (Interrupt Descriptor Table), each entry 8 bytes |
The Far Jump That Makes It Real
After setting CR0.PE = 1, you must execute a far jump (a jump that loads both CS and IP). Why?
Because the CPU has a prefetch queue. When it was fetching instructions in real mode, it decoded them as 16-bit instructions. If you don’t flush that queue, the CPU will try to execute 16-bit instructions while in 32-bit mode (or vice versa), and everything explodes.
JMP 0x08:protected_mode_entry
This far jump:
- Loads
CSwith selector0x08(GDT entry 1, the code segment) - Loads
EIPwithprotected_mode_entry - Flushes the prefetch queue (this is the critical part!)
- Starts fetching instructions using the new
CS(which points to a 32-bit code segment)
Why Can’t We Use BIOS Anymore?
BIOS interrupts (INT 0x10, INT 0x13, etc.) are real-mode only. They run in 16-bit mode and expect segment:offset addressing. Once you’re in protected mode:
INT nlooks up the IDT (Interrupt Descriptor Table), not the IVT (Interrupt Vector Table)- The BIOS didn’t set up an IDT for you
- So
INT 0x10will cause a #GP (General Protection fault) because the IDT lookup fails
This is why, if you want to do disk I/O in protected mode, you have to either:
- Write your own disk driver (talks to the IDE/SATA controller directly)
- Switch back to real mode, do the BIOS call, switch back to protected mode (the “unreal mode” trick)
- Use an existing bootloader (GRUB) that does all this for you
For this project, we’ll cheat: we’ll embed the kernel in the disk image right after Stage 2, so Stage 2 doesn’t need to do a disk read in protected mode. The kernel is already in memory by the time we jump to it.
The Global Descriptor Table (GDT)
We touched on the GDT earlier, but let’s really understand what each byte of a GDT entry does. Because if your GDT is wrong, the CPU will #GP fault the instant you try to use a segment register. And since you can’t do anything without segment registers, a bad GDT means the CPU triple-faults and reboots.
GDT Entry Structure (64 bits = 8 bytes)
Bit(s) Field Description
────────────────────────────────────────────────────────────────
0-15 Limit [15:0] Lower 16 bits of segment limit
16-31 Base [15:0] Lower 16 bits of base address
32-39 Base [23:16] Next 8 bits of base address
40-47 Access Byte Type, DPL, Present, etc.
48-51 Limit [19:16] + Flags Upper 4 bits of limit + granularity, 32-bit
52-55 Base [31:24] Upper 8 bits of base address
56-63 (Reserved in x86) Always 0
The Access Byte (bits 40-47)
Bit(s) Field
─────────────────────────────────────
40-43 Type (executable? readable? writable? conforming? expand-down?)
44 Descriptor Type (0 = system, 1 = code/data)
45-46 DPL (Descriptor Privilege Level, 0 = kernel, 3 = user)
47 Present (1 = segment is in memory, 0 = not)
For a code segment (what we used):
- Type =
0xA(executable, readable, non-conforming =1010in binary) - D =
1(32-bit code segment) - G =
1(granularity = 4KB pages, so limit is in 4KB units) - So the full access byte =
0x9A(10011010in binary)
For a data segment:
- Type =
0x2(readable, writable, expand-up =0010in binary) - Full access byte =
0x92(10010010in binary)
Our GDT in Memory (as bytes)
Let me show you exactly what the GDT looks like in memory:
; Null descriptor (8 bytes of zeros — required!)
gdt_null:
DD 0x00000000
DD 0x00000000
; Code segment descriptor
gdt_code:
DW 0xFFFF ; Limit [15:0] = 0xFFFF
DW 0x0000 ; Base [15:0] = 0x0000
DB 0x00 ; Base [23:16] = 0x00
DB 0x9A ; Access: Present=1, DPL=0, Type=code
DB 0xCF ; Flags: G=1, D=1; Limit[19:16] = 0xF
DB 0x00 ; Base [31:24] = 0x00
; Data segment descriptor
gdt_data:
DW 0xFFFF
DW 0x0000
DB 0x00
DB 0x92 ; Access: Present=1, DPL=0, Type=data
DB 0xCF
DB 0x00
With this GDT:
- Selector
0x00= null (invalid, will #GP if used) - Selector
0x08= code segment (index 1, TI=0, RPL=0) - Selector
0x10= data segment (index 2, TI=0, RPL=0)
Why 0x08 and 0x10?
Because selectors are index × 8 + TI + RPL:
0x08=0000 1000binary = index 1, TI=0, RPL=00x10=0001 0000binary = index 2, TI=0, RPL=0
The lower 3 bits are not part of the GDT index! This trips up everyone the first time.
Kernel Entry: The First C Function
Once Stage 2 has switched to protected mode and loaded the kernel, we jump to the kernel entry point. The kernel is written in C (with some assembly for the very low-level parts). Let’s look at how the transition from assembly to C works.
The Kernel Entry Point (assembly part) (kernel_entry.asm)
; ============================================================
; kernel_entry.asm — Kernel entry point (assembly)
; Links with the C kernel (kernel.c)
; ============================================================
[BITS 32] ; We're in 32-bit protected mode
[GLOBAL start] ; Make 'start' visible to the linker
[EXTERN kmain] ; The C function we want to call
start:
; Set up the stack (growing downward from 0x9FFFF)
MOV ESP, 0x9FFFF
MOV EBP, ESP ; Set up frame pointer
; Clear the screen (optional, but nice for debugging)
CALL clear_screen
; Print a "kernel loaded" message (using our video driver)
PUSH dword kernel_msg
CALL print_string_pm
ADD ESP, 4 ; Clean up the stack (cdecl calling convention)
; Call the C entry point: void kmain(void)
CALL kmain
; If kmain() returns, halt the CPU
.hang:
CLI
HLT
JMP .hang
; ----------------------------------------------------------
; clear_screen: Clears the screen (text mode, 80x25)
; ----------------------------------------------------------
clear_screen:
MOV EDI, 0xB8000 ; Video memory starts at 0xB8000 (text mode)
MOV ECX, 2000 ; 80 * 25 = 2000 characters
MOV AX, 0x0720 ; Attribute = 0x07 (light gray on black),
; Character = 0x20 (space)
REP STOSW ; Fill 2000 words (character + attribute)
RET
; ----------------------------------------------------------
; print_string_pm: Prints a null-terminated string at cursor position
; (protected mode version, uses direct video memory access)
; Input: [EBP+8] = pointer to string (virtual address)
; ----------------------------------------------------------
print_string_pm:
PUSH EBP
MOV EBP, ESP
PUSH ESI
PUSH EDI
MOV ESI, [EBP+12] ; ESI = string pointer
MOV EDI, 0xB8000 ; EDI = video memory
; Get current cursor position (we'd need a cursor position variable)
; For simplicity, just print at the start of video memory
; (In a real OS, you'd track cursor position properly)
.loop:
LODSB ; Load byte at [ESI] into AL, increment ESI
CMP AL, 0
JE .done
; Write character + attribute to video memory
MOV AH, 0x07 ; Attribute: light gray on black
STOSW ; Store AX to [EDI], increment EDI by 2
JMP .loop
.done:
POP EDI
POP ESI
MOV ESP, EBP
POP EBP
RET
; ----------------------------------------------------------
; Data
; ----------------------------------------------------------
kernel_msg DB "Kernel loaded successfully. Entering kmain()...", 0
The C Kernel (kernel.c)
// ============================================================
// kernel.c — Main kernel logic (C)
// ============================================================
// We declare these as external (defined in kernel_entry.asm)
extern void print_string_pm(const char* str);
extern void clear_screen(void);
// Our main kernel function
void kmain(void)
{
clear_screen();
print_string_pm("Welcome to MyOS!\n");
print_string_pm("Type something: ");
// Main loop: read keyboard, echo to screen
while (1)
{
char c = keyboard_read_char(); // Defined in keyboard driver
if (c != 0)
{
video_putchar(c); // Defined in video driver
if (c == '\n')
{
print_string_pm("> ");
}
}
}
}
How the Linker Ties It Together
You can’t just compile kernel.c and assemble kernel_entry.asm and expect it to work. You need a linker script that tells ld (the GNU linker):
- Where to put each section (
.text,.data,.bss) - What the entry point is (the first instruction to execute)
- What the memory layout looks like
Here’s a minimal linker script (linker.ld):
/* ============================================================
linker.ld — Linker script for our kernel
============================================================ */
ENTRY(start) /* Entry point = symbol 'start' (in kernel_entry.asm) */
SECTIONS
{
/* The kernel will be loaded at 1MB physical (0x100000) */
. = 0x100000;
.text : {
*(.text) /* All .text sections from all object files */
}
.data : {
*(.data) /* All .data sections */
}
.bss : {
*(.bss) /* All .bss sections (uninitialized data) */
}
/* You'd also want to define symbols for the end of the kernel
so the memory allocator knows where free memory starts */
}
Building the Kernel
# Assemble the entry point
nasm -f elf32 kernel_entry.asm -o kernel_entry.o
# Compile the C kernel (use a cross-compiler! NEVER use your host GCC)
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra
# Link everything together
i686-elf-ld -T linker.ld -o kernel.bin kernel_entry.o kernel.o
# Convert to raw binary (strip ELF headers — the bootloader expects flat binary)
i686-elf-objcopy -O binary kernel.bin kernel.bin.raw
The i686-elf- cross-compiler is essential. Your host GCC (the one that came with your Linux distribution) compiles programs that run on your host OS (which uses system calls, dynamic linking, etc.). Our kernel runs on bare metal — no OS, no libc, no system calls. The -ffreestanding flag tells GCC not to assume libc exists.
Keyboard Driver: Talking to the Hardware
The keyboard is one of the most satisfying I/O devices to program because you can immediately see the results of your code. Press a key, see a character appear on screen. It’s magic.
How the Keyboard Works (Hardware Level)
The keyboard controller (an 8042 chip on most motherboards) communicates with the CPU via port I/O:
| Port | Name | Description |
|---|---|---|
0x60 | Data Port | Read: last scancode / Write: data to keyboard controller |
0x64 | Status/Command Port | Read: status register / Write: command to keyboard controller |
When you press a key:
- The keyboard controller puts a scancode (a number representing the key) into its internal buffer
- The keyboard controller asserts IRQ1 (Interrupt Request 1)
- The PIC (Programmable Interrupt Controller) sends a signal to the CPU
- The CPU jumps to the keyboard interrupt service routine (ISR) — if you’ve set one up in the IDT
- The ISR reads the scancode from port
0x60
The 8042 Keyboard Controller State Machine
The keyboard controller has two modes:
- Scancode Set 1 (used by the original IBM PC/AT keyboard)
- Scancode Set 2 (used by all modern PS/2 keyboards)
- Scancode Set 3 (rare, used by some embedded systems)
The BIOS typically sets the keyboard to Scancode Set 1. In protected mode, if you’re using the 8042 directly, you’ll get Scancode Set 2 by default. For this project, let’s assume Scancode Set 1 (it’s simpler).
Basic Scancode Set 1 Table (Partial)
| Key | Make (press) | Break (release) |
|---|---|---|
A | 0x1E | 0x9E |
B | 0x30 | 0xB0 |
| Enter | 0x1C | 0x9C |
| Space | 0x39 | 0xB9 |
| Left Shift | 0x2A | 0xAA |
| Right Shift | 0x36 | 0xB6 |
(Yes, the “break” code is just the “make” code + 0x80. This pattern holds for most keys.)
Keyboard Driver Code (assembly + C)
// ============================================================
// keyboard.c — Keyboard driver
// ============================================================
#define KEYBOARD_DATA_PORT 0x60
#define KEYBOARD_STATUS_PORT 0x64
// Scancode-to-ASCII lookup table (Scancode Set 1, US QWERTY)
// Index = scancode, value = ASCII character (0 if unknown/non-printable)
static const char scancode_to_ascii[] = {
0, 0, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=', 0x08, /* Backspace */
'\t', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\n', /* Enter */
0, /* Ctrl */ 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '`',
0, /* Left Shift */ '\\', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/',
0, /* Right Shift */ '*', 0, /* Alt */ ' ', /* Space */
// ... (this table continues for all 128 possible scancodes)
};
// Shifted version
static const char scancode_to_ascii_shift[] = {
0, 0, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', 0x08,
'\t', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '\n',
// ...
};
static int shift_pressed = 0;
// Read a scancode from the keyboard controller
static uint8_t keyboard_read_scancode(void)
{
// Wait for the keyboard controller to have data available
// (bit 0 of status port = 1 means output buffer full)
while ((inb(KEYBOARD_STATUS_PORT) & 0x01) == 0)
;
return inb(KEYBOARD_DATA_PORT);
}
// Convert a scancode to an ASCII character
char keyboard_read_char(void)
{
uint8_t scancode = keyboard_read_scancode();
// Check if it's a "break" code (key release)
if (scancode & 0x80)
{
// Key released
if (scancode == 0xAA || scancode == 0xB6)
shift_pressed = 0;
return 0; // Don't return a character for key releases
}
// Check for shift keys
if (scancode == 0x2A || scancode == 0x36)
{
shift_pressed = 1;
return 0;
}
// Convert to ASCII
if (shift_pressed)
return scancode_to_ascii_shift[scancode];
else
return scancode_to_ascii[scancode];
}
// io.h — I/O port access functions (inline assembly)
#ifndef IO_H
#define IO_H
static inline uint8_t inb(uint16_t port)
{
uint8_t ret;
__asm__ __volatile__ ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
static inline void outb(uint16_t port, uint8_t data)
{
__asm__ __volatile__ ("outb %0, %1" : : "a"(data), "Nd"(port));
}
#endif
Setting Up the Keyboard Interrupt (IRQ1)
In protected mode, the keyboard interrupt is routed through the IDT (Interrupt Descriptor Table). IRQ1 (keyboard) is typically mapped to interrupt vector 0x21 (if you’re using the standard PIC remapping where IRQ0 = INT 0x20, IRQ1 = INT 0x21, etc.).
; ----------------------------------------------------------
; IDT entry for keyboard interrupt (IRQ1 = INT 0x21)
; ----------------------------------------------------------
idt_keyboard:
DW keyboard_isr ; Offset [15:0]
DW 0x08 ; Selector = code segment (0x08)
DB 0x00 ; Reserved (always 0)
DB 0x8E ; Type = interrupt gate, present=1, DPL=0
DW 0x0000 ; Offset [31:16] (upper 16 bits of ISR address)
; ----------------------------------------------------------
; keyboard_isr: Keyboard interrupt service routine
; ----------------------------------------------------------
keyboard_isr:
PUSHAD ; Save all general-purpose registers
PUSH DS
PUSH ES
PUSH FS
PUSH GS
; Load kernel data segment into segment registers
MOV AX, 0x10
MOV DS, AX
MOV ES, AX
MOV FS, AX
MOV GS, AX
; Read the scancode (this also acknowledges the keyboard controller)
IN AL, 0x60
; Call the C keyboard handler
PUSH EAX
CALL keyboard_interrupt_handler
ADD ESP, 4
; Send EOI (End of Interrupt) to the PIC
MOV AL, 0x20
OUT 0x20, AL ; Master PIC EOI
POP GS
POP FS
POP ES
POP DS
POPAD
IRET ; Return from interrupt (pops EIP, CS, EFLAGS, ESP, SS)
Video Driver: Painting Text on Screen
In text mode (the default VGA mode when the BIOS boots), the screen is an 80×25 grid of character cells. Each cell is 2 bytes:
Byte 0: ASCII character code
Byte 1: Attribute byte (foreground color + background color)
The attribute byte:
Bit(s) Field
─────────────────────────────
0-3 Foreground color (0-15)
4-6 Background color (0-7)
7 Blink (1 = blink, 0 = no blink)
Color values (standard VGA palette):
| Value | Color | Value | Color |
|---|---|---|---|
| 0 | Black | 8 | Dark gray |
| 1 | Blue | 9 | Light blue |
| 2 | Green | 10 | Light green |
| 3 | Cyan | 11 | Light cyan |
| 4 | Red | 12 | Light red |
| 5 | Magenta | 13 | Light magenta |
| 6 | Brown | 14 | Yellow |
| 7 | Light gray | 15 | White |
Video memory starts at physical address 0xB8000 (in text mode). Character cell (row, col) is at offset (row * 80 + col) * 2.
Video Driver Code
// ============================================================
// video.c — Video driver (text mode)
// ============================================================
#define VIDEO_MEMORY 0xB8000
#define MAX_ROWS 25
#define MAX_COLS 80
static int cursor_row = 0;
static int cursor_col = 0;
// Initialize the video driver (clear screen)
void video_init(void)
{
uint16_t* video = (uint16_t*)VIDEO_MEMORY;
for (int i = 0; i < MAX_ROWS * MAX_COLS; i++)
{
video[i] = (0x07 << 8) | ' '; // Light gray on black, space character
}
cursor_row = 0;
cursor_col = 0;
}
// Put a single character on screen at (row, col)
void video_putchar_at(char c, int row, int col)
{
uint16_t* video = (uint16_t*)VIDEO_MEMORY;
int offset = row * MAX_COLS + col;
video[offset] = (0x07 << 8) | (uint8_t)c;
}
// Put a character at the current cursor position, advance cursor
void video_putchar(char c)
{
if (c == '\n')
{
cursor_row++;
cursor_col = 0;
if (cursor_row >= MAX_ROWS)
video_scroll();
}
else if (c == '\b')
{
if (cursor_col > 0)
cursor_col--;
}
else
{
video_putchar_at(c, cursor_row, cursor_col);
cursor_col++;
if (cursor_col >= MAX_COLS)
{
cursor_col = 0;
cursor_row++;
if (cursor_row >= MAX_ROWS)
video_scroll();
}
}
// Update hardware cursor (optional, requires writing to VGA ports 0x3D4/0x3D5)
update_cursor(cursor_row, cursor_col);
}
// Scroll the screen up by one line
static void video_scroll(void)
{
uint16_t* video = (uint16_t*)VIDEO_MEMORY;
// Move lines 1-24 up to lines 0-23
for (int row = 0; row < MAX_ROWS - 1; row++)
{
for (int col = 0; col < MAX_COLS; col++)
{
video[row * MAX_COLS + col] = video[(row + 1) * MAX_COLS + col];
}
}
// Clear the last line
for (int col = 0; col < MAX_COLS; col++)
{
video[(MAX_ROWS - 1) * MAX_COLS + col] = (0x07 << 8) | ' ';
}
cursor_row = MAX_ROWS - 1;
cursor_col = 0;
}
System Call Dispatch: The User-Kernel Boundary
A system call is how user programs ask the kernel to do something privileged (like read from the keyboard, write to the screen, allocate memory). In our OS, we’ll implement a simple INT 0x80-based system call interface (similar to how Linux does it).
System Call Dispatch Table
// ============================================================
// syscall.c — System call dispatch
// ============================================================
// System call numbers
#define SYS_READ 0
#define SYS_WRITE 1
#define SYS_EXIT 2
// Dispatch table
typedef void (*syscall_handler_t)(void);
static syscall_handler_t syscall_table[] = {
[SYS_READ] = sys_read,
[SYS_WRITE] = sys_write,
[SYS_EXIT] = sys_exit,
};
// System call handler (called from assembly ISR)
void syscall_dispatch(uint32_t syscall_num, uint32_t arg1, uint32_t arg2, uint32_t arg3)
{
if (syscall_num >= sizeof(syscall_table) / sizeof(syscall_table[0]))
{
// Invalid system call number
return;
}
syscall_table[syscall_num](arg1, arg2, arg3);
}
The INT 0x80 ISR (Assembly)
; ----------------------------------------------------------
; syscall_isr: INT 0x80 system call handler
; ----------------------------------------------------------
syscall_isr:
PUSHAD
PUSH DS
PUSH ES
PUSH FS
PUSH GS
; System call number is in EAX
; Arguments are in EBX, ECX, EDX (like Linux)
PUSH EDX ; Arg3
PUSH ECX ; Arg2
PUSH EBX ; Arg1
PUSH EAX ; System call number
CALL syscall_dispatch
ADD ESP, 16 ; Clean up 4 arguments
POP GS
POP FS
POP ES
POP DS
POPAD
IRET
Building and Running: The Complete Toolchain
Now let’s put it all together into a disk image that you can boot in QEMU.
Step 1: Assemble and Compile Everything
# Assemble bootloader Stage 1
nasm -f bin boot1.asm -o boot1.bin
# Assemble bootloader Stage 2
nasm -f bin boot2.asm -o boot2.bin
# Assemble kernel entry point
nasm -f elf32 kernel_entry.asm -o kernel_entry.o
# Compile kernel C files
i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall
i686-elf-gcc -c keyboard.c -o keyboard.o -std=gnu99 -ffreestanding -O2
i686-elf-gcc -c video.c -o video.o -std=gnu99 -ffreestanding -O2
i686-elf-gcc -c syscall.c -o syscall.o -std=gnu99 -ffreestanding -O2
# Link the kernel
i686-elf-ld -T linker.ld -o kernel.elf kernel_entry.o kernel.o keyboard.o video.o syscall.o
# Extract raw binary from ELF
i686-elf-objcopy -O binary kernel.elf kernel.bin
Step 2: Create the Disk Image
# Create an empty disk image (1.44MB floppy = 2880 sectors × 512 bytes)
dd if=/dev/zero of=os-image.bin bs=512 count=2880
# Write Stage 1 to sector 1 (the boot sector)
dd if=boot1.bin of=os-image.bin bs=512 count=1 conv=notrunc
# Write Stage 2 to sectors 2-17 (16 sectors = 8KB)
dd if=boot2.bin of=os-image.bin bs=512 seek=1 count=16 conv=notrunc
# Write the kernel to sector 18 (right after Stage 2)
dd if=kernel.bin of=os-image.bin bs=512 seek=17 conv=notrunc
Step 3: Run in QEMU
qemu-system-i386 -fda os-image.bin -monitor stdio
You should see:
Loading Stage 2...
Stage 2 loaded. Switching to protected mode...
Kernel loaded successfully. Entering kmain()...
Welcome to MyOS!
Type something:
And when you type on your keyboard, characters should appear on the QEMU screen!
Code Walkthrough: Putting It All Together
Let me trace the complete boot sequence with all the code we’ve written, showing exactly how the pieces connect.
Memory Layout at Each Stage
┌─────────────────────────────────────────────────────┐
│ Stage 1 (Boot Sector) │
│ Physical: 0x7C00 – 0x7DFF (512 bytes) │
├─────────────────────────────────────────────────────┤
│ Stage 2 (Loaded by Stage 1) │
│ Physical: 0x10000 – 0x11FFF (8KB) │
├─────────────────────────────────────────────────────┤
│ Kernel (Loaded by Stage 2 or embedded) │
│ Physical: 0x100000 – 0x10FFFF (64KB) │
├─────────────────────────────────────────────────────┤
│ Video Memory (Text Mode) │
│ Physical: 0xB8000 – 0xB8FA0 (4000 bytes) │
├─────────────────────────────────────────────────────┤
│ Stack (grows downward) │
│ Virtual: 0x9FFFF ↓ │
└─────────────────────────────────────────────────────┘
Testing and Debugging Your OS
Writing an OS is hard. Debugging an OS is even harder, because when your OS crashes, you can’t run gdb on it (there’s no OS running gdb!). Here are the techniques that actually work.
Technique 1: QEMU + GDB
QEMU can act as a GDB server, letting you debug your OS as it boots:
# Start QEMU in GDB server mode (wait for GDB to connect)
qemu-system-i386 -fda os-image.bin -s -S
# In another terminal, start GDB
i686-elf-gdb
(gdb) target remote localhost:1234
(gdb) symbol-file kernel.elf
(gdb) break *0x100000 # Break at kernel entry
(gdb) continue
Technique 2: Serial Port Debugging
Write debug messages to the serial port (0x3F8 for COM1). QEMU can redirect the serial port to a file or stdout:
qemu-system-i386 -fda os-image.bin -serial stdio
// debug.c — Print debug messages to serial port
void debug_putchar(char c)
{
// Wait for transmitter holding register to be empty
while ((inb(0x3F8 + 5) & 0x20) == 0)
;
outb(0x3F8, c);
}
void debug_print(const char* str)
{
for (int i = 0; str[i] != '\0'; i++)
debug_putchar(str[i]);
}
Technique 3: Triple Fault Detection
A “triple fault” is when the CPU fails to handle an exception three times in a row. The CPU then resets itself (which looks like your OS rebooting instantly). If your OS reboots instantly in QEMU, it’s probably triple-faulting.
Common causes:
- Bad GDT — the CPU can’t find a valid segment descriptor
- Bad IDT — an interrupt fires and the CPU can’t find the ISR
- Stack overflow —
ESPunderflows into protected memory - Ring transition gone wrong —
IRETwith wrong stack frame
Use QEMU’s -d int flag to see what exceptions are firing:
qemu-system-i386 -fda os-image.bin -d int
Where to Go From Here: Next Steps
You’ve built a minimal OS that boots, handles keyboard input, writes to the screen, and supports system calls. That’s already more than most people ever do with assembly language.
What to Add Next
| Feature | Difficulty | Why It’s Cool |
|---|---|---|
| Filesystem | Medium | Lets you load programs from disk (instead of embedding everything in the kernel) |
| Memory Allocator | Medium | malloc() and free() for your OS |
| Process Scheduler | Hard | Multitasking! (Requires context switching, which needs assembly) |
| User Mode | Hard | Ring 3 — protects the kernel from buggy user programs |
| VGA Graphics Mode | Medium | 320×200×256 colors — way more fun than text mode |
| Network Driver | Hard | TCP/IP stack, talk to the internet from your OS |
| Sound Blaster Driver | Medium | Make your OS beep (literally) |
Recommended Reading
- “Operating System: Three Easy Pieces” (free online) — Best intro to OS concepts
- “The little book about OS development” (littleosbook.github.io) — Step-by-step OS dev tutorial
- Intel SDM (Software Developer Manual), Volume 3 — The definitive reference for x86 system programming
- OSDev Wiki (wiki.osdev.org) — The best community resource for OS development
- “Understanding the Linux Kernel” by Bovet & Cesati — How a real OS (Linux) implements these concepts
Final Thoughts
If you’ve made it this far, you now understand assembly language from the absolute bottom (flip a single bit in a register) to the very top (a complete, bootable operating system kernel). That’s a journey that most programmers never take. You now know exactly what your C compiler is doing, exactly how your CPU talks to hardware, and exactly what happens in the first nanosecond after you press the power button.
Use this power wisely. And welcome to the ranks of people who understand how computers actually work.



