Lesson 30-Comprehensive Project – Writing a Simple Operating System Kernel Module

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 LGDT instruction, setting the PE bit in CR0
  • C programming — because we’re not writing the entire OS in assembly (that way lies madness)
  • Basic toolchain usage — NASM assembler, GCC cross-compiler, ld linker, 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:

LayerWhat It DoesWritten In
Bootloader Stage 1Loads the first 512 bytes, sets up minimal environment, loads Stage 2x86 assembly (16-bit real mode)
Bootloader Stage 2Loads the kernel from disk into memory, switches to protected modex86 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/REPLC
Keyboard DriverReads scancodes from port 0x60, converts to ASCIIAssembly + C
Video DriverWrites text to video memory at 0xB8000Assembly + C
System Call HandlerDispatches INT 0x80 (or syscall) to kernel functionsAssembly + 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:

  1. Runs hardware diagnostics (POST)
  2. Initializes hardware (keyboard controller, video card, etc.)
  3. Reads the first 512 bytes from the boot device (floppy, hard disk, USB, CD-ROM — whichever is configured as the boot device)
  4. Checks the last two bytes of those 512 bytes. If they are 0x55 and 0xAA, the BIOS treats it as a valid boot sector.
  5. Loads those 512 bytes to memory address 0x7C00 (a convention dating back to the original IBM PC)
  6. 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 address 0x07C00).”

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:

  1. Set up a stack (because PUSH/POP need SS:SP to be valid)
  2. Print a “Loading…” message (optional, 3. Use BIOS INT 0x13 to read more sectors from disk into memory (say, at address 0x10000)
  3. 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:

  1. Load the kernel from disk into memory (say, at address 0x100000 — 1MB, which is where protected-mode memory starts being useful)
  2. Set up a minimal GDT (Global Descriptor Table)
  3. Switch to protected mode (set CR0.PE = 1, then far jump)
  4. 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:

  • DL contains the boot drive number (set by BIOS). Save it! If you overwrite DL, you can’t read from the correct disk.
  • ES:BX is the buffer address. We load to 0x1000:0x0000 = physical 0x10000. Why there? Because 0x00000-0x0FFFF is the first 64KB, which is where the BIOS and your bootloader live. 0x10000 is 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:

  1. Load the kernel from disk into memory (we’ll put it at 0x100000 — 1MB physical, which is the start of extended memory)
  2. Set up a minimal GDT (we need something for protected mode to work)
  3. Switch to protected mode
  4. 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:

  1. A null descriptor (index 0, required, unused)
  2. A code segment (base=0, limit=4GB, executable)
  3. 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:

FeatureReal ModeProtected Mode
Addressingsegment * 16 + offsetGDT/LDT lookup → base + offset (with limit check)
Segment registersHold the actual segment valueHold a selector (index into GDT)
Memory protectionNone (any code can write anywhere)Yes (via segment limits, DPL, read/write/execute flags)
Privilege levelsNone (always ring 0)4 rings (0-3), hardware-enforced
InterruptsIVT 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:

  1. Loads CS with selector 0x08 (GDT entry 1, the code segment)
  2. Loads EIP with protected_mode_entry
  3. Flushes the prefetch queue (this is the critical part!)
  4. 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 n looks up the IDT (Interrupt Descriptor Table), not the IVT (Interrupt Vector Table)
  • The BIOS didn’t set up an IDT for you
  • So INT 0x10 will 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:

  1. Write your own disk driver (talks to the IDE/SATA controller directly)
  2. Switch back to real mode, do the BIOS call, switch back to protected mode (the “unreal mode” trick)
  3. 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 = 1010 in binary)
  • D = 1 (32-bit code segment)
  • G = 1 (granularity = 4KB pages, so limit is in 4KB units)
  • So the full access byte = 0x9A (10011010 in binary)

For a data segment:

  • Type = 0x2 (readable, writable, expand-up = 0010 in binary)
  • Full access byte = 0x92 (10010010 in 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 1000 binary = index 1, TI=0, RPL=0
  • 0x10 = 0001 0000 binary = 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:

PortNameDescription
0x60Data PortRead: last scancode / Write: data to keyboard controller
0x64Status/Command PortRead: status register / Write: command to keyboard controller

When you press a key:

  1. The keyboard controller puts a scancode (a number representing the key) into its internal buffer
  2. The keyboard controller asserts IRQ1 (Interrupt Request 1)
  3. The PIC (Programmable Interrupt Controller) sends a signal to the CPU
  4. The CPU jumps to the keyboard interrupt service routine (ISR) — if you’ve set one up in the IDT
  5. 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)

KeyMake (press)Break (release)
A0x1E0x9E
B0x300xB0
Enter0x1C0x9C
Space0x390xB9
Left Shift0x2A0xAA
Right Shift0x360xB6

(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):

ValueColorValueColor
0Black8Dark gray
1Blue9Light blue
2Green10Light green
3Cyan11Light cyan
4Red12Light red
5Magenta13Light magenta
6Brown14Yellow
7Light gray15White

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 — ESP underflows into protected memory
  • Ring transition gone wrong — IRET with 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

FeatureDifficultyWhy It’s Cool
FilesystemMediumLets you load programs from disk (instead of embedding everything in the kernel)
Memory AllocatorMediummalloc() and free() for your OS
Process SchedulerHardMultitasking! (Requires context switching, which needs assembly)
User ModeHardRing 3 — protects the kernel from buggy user programs
VGA Graphics ModeMedium320×200×256 colors — way more fun than text mode
Network DriverHardTCP/IP stack, talk to the internet from your OS
Sound Blaster DriverMediumMake your OS beep (literally)
  1. “Operating System: Three Easy Pieces” (free online) — Best intro to OS concepts
  2. “The little book about OS development” (littleosbook.github.io) — Step-by-step OS dev tutorial
  3. Intel SDM (Software Developer Manual), Volume 3 — The definitive reference for x86 system programming
  4. OSDev Wiki (wiki.osdev.org) — The best community resource for OS development
  5. “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.

Share your love