Build a Mini Operating System From Scratch — A Complete Checklist & Guide

Table of Contents

  1. Understand the Boot Process
  2. Set Up Your Toolchain
  3. Stage 1 Bootloader — BIOS Boot Sector
  4. Stage 2 Bootloader — Loading the Kernel
  5. Entering Protected Mode (32-bit)
  6. The Kernel Entry Point
  7. The VGA Text Driver
  8. The Global Descriptor Table (GDT)
  9. The Interrupt Descriptor Table (IDT) & IRQs
  10. The Keyboard Driver
  11. Physical Memory Management (PMM)
  12. Virtual Memory & Paging
  13. The Heap Allocator (kmalloc / kfree)
  14. Processes & Context Switching
  15. The Scheduler
  16. System Calls
  17. A Simple Filesystem (initrd / FAT12)
  18. A Mini Shell
  19. Test Suites
  20. Recommended Build Order

1. Understand the Boot Process

What happens when you power on a PC

The journey from power button to your kernel running involves many actors handing control to the next stage. Every stage is responsible for setting up just enough environment for the next stage to function.

Power on
   │
   ▼
CPU reset vector (0xFFFF0) → BIOS ROM
   │  BIOS runs POST (Power-On Self-Test)
   │  BIOS scans for bootable devices
   │  BIOS loads first 512 bytes of boot device into RAM at 0x7C00
   ▼
Stage 1 Bootloader (512 bytes, Real Mode 16-bit)
   │  Loads Stage 2 from disk
   ▼
Stage 2 Bootloader (1–32 KB, still Real Mode)
   │  Detects memory map (INT 0x15, E820)
   │  Sets up GDT
   │  Switches CPU to Protected Mode (32-bit)
   ▼
Kernel Entry Point (32-bit Protected Mode)
   │  Sets up stack, BSS, IDT
   │  Initialises drivers (VGA, keyboard, PIT)
   │  Initialises memory manager
   │  Starts scheduler
   ▼
User processes / shell

CPU modes you must understand

Mode Bit Width Address Space Notes
Real Mode 16-bit 1 MB (20-bit addresses) CPU starts here; BIOS lives here
Protected Mode 32-bit 4 GB virtual Segmentation & paging available
Long Mode 64-bit 48-bit virtual Requires identity-mapped PML4; skip for a first OS

Key memory addresses in Real Mode

Address Contents
0x00000–0x003FF Interrupt Vector Table (IVT) — 256 × 4-byte real-mode ISR pointers
0x00400–0x004FF BIOS Data Area
0x07C00–0x07DFF Where BIOS loads your boot sector (512 bytes)
0x07E00–0x7FFFF Free conventional memory (~480 KB) — load your kernel here
0xA0000–0xBFFFF VGA framebuffer memory
0xC0000–0xFFFFF ROM (BIOS, option ROMs)

Checklist

  • Understand Real Mode: 16-bit registers, segment:offset addressing, 1 MB limit.
  • Understand why the boot sector must be exactly 512 bytes ending in 0x55, 0xAA.
  • Understand that the BIOS provides services via software interrupts (INT 0x10 for video, INT 0x13 for disk, INT 0x15 for memory map).
  • Understand Protected Mode: flat 4 GB address space, GDT-based segmentation, privilege rings (Ring 0 = kernel, Ring 3 = user).
  • Choose your target: BIOS (simpler, classic) or UEFI (modern). This guide covers BIOS.

2. Set Up Your Toolchain

Required tools

Tool Purpose
nasm Assembles x86 assembly (bootloader + kernel entry)
gcc (cross) Compiles C kernel — must target i686-elf to avoid host-OS assumptions
ld (cross) Links object files according to a linker script
qemu-system-x86_64 Emulates a PC; boots your OS without real hardware
make Build automation
dd Writes raw bytes to disk images
hexdump / xxd Inspect binary files
objdump Disassemble compiled objects
gdb + gdb-multiarch Debug the kernel inside QEMU

Building the i686-elf cross-compiler

You must cross-compile. The system's native GCC links against your host OS's libraries and makes assumptions that break bare-metal code.

# Install prerequisites (Ubuntu/Debian)
sudo apt install build-essential bison flex libgmp-dev \
                 libmpc-dev libmpfr-dev texinfo

# Download binutils and GCC sources
export PREFIX="$HOME/cross"
export TARGET=i686-elf
export PATH="$PREFIX/bin:$PATH"

# Build binutils
cd /tmp && tar -xf binutils-2.41.tar.xz && cd binutils-2.41
mkdir build && cd build
../configure --target=$TARGET --prefix=$PREFIX \
             --with-sysroot --disable-nls --disable-werror
make -j$(nproc) && make install

# Build GCC (C only for now)
cd /tmp && tar -xf gcc-13.2.0.tar.xz && cd gcc-13.2.0
mkdir build && cd build
../configure --target=$TARGET --prefix=$PREFIX \
             --disable-nls --enable-languages=c,c++ \
             --without-headers
make -j$(nproc) all-gcc all-target-libgcc
make install-gcc install-target-libgcc

Disk image layout

Your OS lives in a raw disk image (a flat file treated as a virtual hard drive):

Disk image: os.img
┌─────────────────────────────────────────────────────────┐
│ Sector 0 (512 bytes)  │ Boot Sector (Stage 1)           │
├───────────────────────┼─────────────────────────────────┤
│ Sectors 1–7           │ Stage 2 Bootloader              │
├───────────────────────┼─────────────────────────────────┤
│ Sectors 8–...         │ Kernel binary                   │
└─────────────────────────────────────────────────────────┘

Build script skeleton (Makefile)

CC      = i686-elf-gcc
AS      = nasm
LD      = i686-elf-ld
CFLAGS  = -m32 -ffreestanding -O2 -Wall -Wextra -fno-stack-protector \
          -fno-builtin -fno-pic -nostdlib -nostdinc

LDFLAGS = -T linker.ld -nostdlib

all: os.img

os.img: boot/stage1.bin boot/stage2.bin kernel/kernel.bin
	dd if=/dev/zero       of=os.img bs=512 count=2880   # 1.44 MB floppy image
	dd if=boot/stage1.bin of=os.img bs=512 seek=0 conv=notrunc
	dd if=boot/stage2.bin of=os.img bs=512 seek=1 conv=notrunc
	dd if=kernel/kernel.bin of=os.img bs=512 seek=8 conv=notrunc

run: os.img
	qemu-system-i386 -drive format=raw,file=os.img -monitor stdio

debug: os.img
	qemu-system-i386 -drive format=raw,file=os.img -s -S &
	gdb -ex "target remote :1234" -ex "symbol-file kernel/kernel.elf"

Checklist

  • Install NASM, QEMU, dd, hexdump.
  • Build the i686-elf-gcc and i686-elf-ld cross-compiler toolchain.
  • Create the Makefile with targets: boot/stage1.bin, boot/stage2.bin, kernel/kernel.bin, and os.img.
  • Verify QEMU can boot an empty image without crashing.
  • Verify GDB can attach to QEMU via -s -S flags.

3. Stage 1 Bootloader — BIOS Boot Sector

What it is

The boot sector is the first 512 bytes of your disk. The BIOS loads exactly these 512 bytes into RAM at physical address 0x7C00 and jumps to it. Your code runs in Real Mode (16-bit). The last two bytes must be 0x55 and 0xAA — this is the boot signature the BIOS checks.

What Stage 1 must do

Because you only have 512 bytes (and some of that is the boot signature and a partition table), Stage 1 has exactly one job: load Stage 2 from disk into RAM and jump to it.

Stage 1 source (boot/stage1.asm)

[BITS 16]
[ORG 0x7C00]          ; BIOS loads us here

STAGE2_LOAD_SEG  equ 0x0000
STAGE2_LOAD_OFF  equ 0x7E00   ; Load Stage 2 right after Stage 1
STAGE2_SECTORS   equ 7        ; How many 512-byte sectors Stage 2 occupies

start:
    ; Normalize segment registers — BIOS may jump to 0x07C0:0x0000 or 0x0000:0x7C00
    cli
    xor ax, ax
    mov ds, ax
    mov es, ax
    mov ss, ax
    mov sp, 0x7C00      ; Stack grows downward from where we're loaded
    sti

    ; Save BIOS drive number (BIOS puts it in DL before jumping to us)
    mov [boot_drive], dl

    ; Load Stage 2 using BIOS INT 0x13, AH=0x02 (Read Sectors)
    mov ah, 0x02        ; Function: read sectors
    mov al, STAGE2_SECTORS   ; Number of sectors to read
    mov ch, 0           ; Cylinder 0
    mov cl, 2           ; Sector 2 (sectors are 1-indexed!)
    mov dh, 0           ; Head 0
    mov dl, [boot_drive]
    mov bx, STAGE2_LOAD_OFF  ; ES:BX = destination buffer
    int 0x13
    jc  disk_error      ; Carry flag set = error

    ; Jump to Stage 2
    jmp 0x0000:STAGE2_LOAD_OFF

disk_error:
    mov si, error_msg
.print_loop:
    lodsb               ; Load byte at DS:SI into AL, increment SI
    test al, al
    jz .halt
    mov ah, 0x0E        ; BIOS TTY output
    int 0x10
    jmp .print_loop
.halt:
    hlt
    jmp .halt

boot_drive: db 0
error_msg:  db "Disk error!", 0

; Pad to 510 bytes and add boot signature
times 510 - ($ - $$) db 0
dw 0xAA55

Key BIOS INT 0x13 parameters

Register Meaning
AH=0x02 Read sectors function
AL Number of sectors to read
CH Cylinder number (0-indexed)
CL Sector number (1-indexed, bits 6–7 = cylinder high bits)
DH Head number
DL Drive number (0x00 = floppy A, 0x80 = first hard disk)
ES:BX Destination buffer address

Checklist

  • Set up segment registers (DS, ES, SS) to known values explicitly.
  • Set up a stack (SP) before any call or push.
  • Save the BIOS drive number from DL into a memory variable.
  • Load Stage 2 from disk using INT 0x13.
  • Check the carry flag after INT 0x13; print an error and halt on failure.
  • Jump to Stage 2 load address using a far jump (jmp segment:offset).
  • Pad to exactly 510 bytes with times 510 - ($ - $$) db 0.
  • Append boot signature dw 0xAA55 (note: little-endian, so bytes are 0x55, 0xAA).
  • Verify binary is exactly 512 bytes: ls -l boot/stage1.bin.

4. Stage 2 Bootloader — Loading the Kernel

What Stage 2 does

Stage 2 is still in 16-bit Real Mode but has more room (several KB). Its responsibilities are:

  1. Print a status message to confirm it loaded.
  2. Detect the system's memory map using BIOS INT 0x15, EAX=0xE820.
  3. Load the kernel binary from disk into memory.
  4. Set up the GDT.
  5. Switch to Protected Mode.
  6. Jump to the kernel.

Memory map detection (E820)

This is critical — you need to know which RAM regions are usable before writing a memory manager.

; Stores memory map at address 0x1000
; Each entry is 24 bytes: base (8), length (8), type (4), ACPI attrs (4)

detect_memory:
    mov di, 0x1000      ; Store entries starting here
    xor ebx, ebx        ; Continuation value, start at 0
    xor bp, bp          ; Entry count
.loop:
    mov eax, 0xE820
    mov ecx, 24         ; Entry size
    mov edx, 0x534D4150 ; Magic: "SMAP"
    int 0x15
    jc  .done           ; Carry = end or error
    cmp eax, 0x534D4150 ; EAX must be "SMAP" on success
    jne .done
    test ecx, ecx
    jz  .skip_entry
    inc bp              ; Count this entry
    add di, 24
.skip_entry:
    test ebx, ebx       ; EBX=0 means last entry
    jz  .done
    jmp .loop
.done:
    mov [memory_map_count], bp
    ret

Memory map entry types

Type Meaning
1 Usable RAM
2 Reserved (do not use)
3 ACPI Reclaimable
4 ACPI NVS
5 Bad memory

Loading the kernel from disk

KERNEL_LOAD_SEG   equ 0x1000
KERNEL_LOAD_OFF   equ 0x0000   ; Physical address: 0x10000 (64 KB mark)
KERNEL_SECTORS    equ 100      ; Load 100 sectors (50 KB) — adjust as needed
KERNEL_START_SEC  equ 9        ; Kernel starts at sector 9 on disk

load_kernel:
    mov ax, KERNEL_LOAD_SEG
    mov es, ax
    mov bx, KERNEL_LOAD_OFF
    mov ah, 0x02
    mov al, KERNEL_SECTORS
    mov ch, 0
    mov cl, KERNEL_START_SEC   ; Starting sector
    mov dh, 0
    mov dl, [boot_drive]
    int 0x13
    jc  disk_error
    ret

Checklist

  • Print a "Stage 2 loaded" message using INT 0x10 (confirms Stage 1 worked).
  • Detect memory map with E820 and store it at a known address (e.g., 0x1000).
  • Store the entry count at a known memory location for the kernel to read.
  • Load the kernel binary from disk into a known physical address (e.g., 0x10000).
  • Handle loading more than 64 KB (LBA extended read, INT 0x13 AH=0x42) if needed.
  • Set up the GDT (see Step 8 — you define it here, reload it after Protected Mode switch).
  • Disable interrupts (cli) before switching modes.
  • Set bit 0 of CR0 (Protection Enable) to enter Protected Mode.
  • Execute a far jump (jmp 0x08:protected_mode_entry) to flush the instruction pipeline and load the code segment selector.

5. Entering Protected Mode (32-bit)

What changes in Protected Mode

  • Segment registers (CS, DS, etc.) no longer hold raw addresses — they hold selectors that index into the GDT.
  • The address space becomes a flat 4 GB (with a flat GDT).
  • BIOS interrupts (INT 0x10, INT 0x13) no longer work — you must write your own drivers.
  • A32-bit instruction prefix ([BITS 32]) is now active.

The switch sequence

[BITS 16]
switch_to_pm:
    cli                         ; 1. Disable interrupts
    lgdt [gdt_descriptor]       ; 2. Load GDT (see Step 8)
    mov eax, cr0                ; 3. Read CR0
    or  eax, 0x1                ; 4. Set bit 0 (PE = Protection Enable)
    mov cr0, eax                ; 5. Write CR0
    jmp CODE_SEG:pm_entry       ; 6. Far jump to flush pipeline, load CS selector

[BITS 32]
pm_entry:
    ; Reload all data segment registers with the data segment selector
    mov ax, DATA_SEG
    mov ds, ax
    mov ss, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov esp, 0x90000            ; Set up a 32-bit stack
    call KERNEL_LOAD_ADDRESS    ; Jump to kernel

A20 line

The A20 address line must be enabled, otherwise memory wraps around at 1 MB (a quirk from the 8086 era). Without A20, address 0x100000 maps to 0x000000.

; Fast A20 enable via keyboard controller port 0x92
enable_a20:
    in  al, 0x92
    or  al, 0x02        ; Set bit 1
    and al, 0xFE        ; Clear bit 0 (reset line — do NOT set this!)
    out 0x92, al
    ret

Checklist

  • Enable the A20 line (use the fast method via port 0x92 for QEMU; add BIOS INT 0x15 fallback for real hardware).
  • Define a valid GDT with at least: null descriptor, code segment, data segment.
  • Load the GDT with lgdt.
  • Set CR0 bit 0.
  • Execute a far jump to load CS with the code segment selector.
  • Reload DS, ES, SS, FS, GS with the data segment selector.
  • Set up the 32-bit stack pointer (ESP).
  • Do NOT enable interrupts (sti) until you have set up the IDT in the kernel.

6. The Kernel Entry Point

What it does

The kernel entry point is the first C-callable assembly routine. It bridges the bootloader and your C kernel. Its responsibilities are narrow but critical: set up the C runtime environment, then call your C kernel_main().

Kernel entry (kernel/entry.asm)

[BITS 32]
[EXTERN kernel_main]    ; Defined in kernel.c

global _start
_start:
    ; Clear the BSS segment (zero-initialized global variables)
    ; The linker script provides __bss_start and __bss_end symbols
    mov edi, __bss_start
    mov ecx, __bss_end
    sub ecx, edi
    xor eax, eax
    rep stosd           ; Fill ECX dwords with zero starting at EDI

    ; Set up a proper kernel stack (the bootloader stack may be fragile)
    mov esp, kernel_stack_top

    ; Push multiboot/boot info if you passed it from the bootloader
    ; (address of memory map stored at 0x1000 by Stage 2)
    push 0x1000

    ; Call the C kernel
    call kernel_main

    ; kernel_main should never return — but if it does, hang
.hang:
    cli
    hlt
    jmp .hang

section .bss
align 16
kernel_stack_bottom:
    resb 16384          ; 16 KB kernel stack
kernel_stack_top:

Linker script (linker.ld)

The linker script tells the linker where each section lives in memory and provides symbols the kernel can reference.

ENTRY(_start)

SECTIONS {
    . = 0x10000;        /* Kernel is loaded at physical address 64 KB */

    .text ALIGN(4096) : {
        *(.text)
    }

    .rodata ALIGN(4096) : {
        *(.rodata)
    }

    .data ALIGN(4096) : {
        *(.data)
    }

    .bss ALIGN(4096) : {
        __bss_start = .;
        *(.bss)
        *(COMMON)
        __bss_end = .;
    }
}

kernel_main skeleton (kernel/kernel.c)

#include "vga.h"
#include "gdt.h"
#include "idt.h"
#include "keyboard.h"
#include "pmm.h"
#include "paging.h"
#include "heap.h"
#include "scheduler.h"

void kernel_main(uint32_t mmap_addr) {
    vga_clear();
    vga_print("MiniOS kernel booting...\n");

    gdt_install();          // Step 8
    idt_install();          // Step 9
    keyboard_install();     // Step 10
    pmm_init(mmap_addr);    // Step 11
    paging_init();          // Step 12
    heap_init();            // Step 13

    vga_print("All subsystems initialized.\n");

    // Start the shell or first process
    scheduler_start();      // Step 15
}

Checklist

  • Write entry.asm that clears BSS and sets ESP.
  • Write linker.ld with correct load address (0x10000 or wherever Stage 2 loads the kernel).
  • Verify the linker script output: objdump -h kernel.elf — check section VMA addresses.
  • Write a minimal kernel_main() that at minimum prints a string and halts.
  • Ensure GCC compiler flags include -ffreestanding, -nostdlib, -nostdinc, -fno-stack-protector.
  • Strip the ELF binary to a flat binary for the disk image: objcopy -O binary kernel.elf kernel.bin.

7. The VGA Text Driver

What VGA text mode is

In text mode (mode 3), the VGA hardware provides a memory-mapped buffer at physical address 0xB8000. You write character+attribute byte pairs directly into this buffer and text appears on screen. No graphics library needed.

Buffer layout

0xB8000
┌────┬────┬────┬────┬────┬────┐
│ C0 │ A0 │ C1 │ A1 │ C2 │ A2 │ ...  (80 columns × 25 rows = 2000 cells)
└────┴────┴────┴────┴────┴────┘
  char  attr  char  attr  ...

Each cell = 2 bytes:
  Byte 0: ASCII character code
  Byte 1: Attribute byte
    Bits 7:   Blink
    Bits 6-4: Background color (3 bits = 8 colors)
    Bit  3:   Bright/intensity
    Bits 2-0: Foreground color (3 bits = 8 colors)

VGA color constants

typedef enum {
    VGA_BLACK = 0, VGA_BLUE, VGA_GREEN, VGA_CYAN,
    VGA_RED, VGA_MAGENTA, VGA_BROWN, VGA_LIGHT_GREY,
    VGA_DARK_GREY, VGA_LIGHT_BLUE, VGA_LIGHT_GREEN, VGA_LIGHT_CYAN,
    VGA_LIGHT_RED, VGA_LIGHT_MAGENTA, VGA_LIGHT_BROWN, VGA_WHITE
} vga_color_t;

static inline uint8_t vga_attr(vga_color_t fg, vga_color_t bg) {
    return (bg << 4) | fg;
}

VGA driver implementation

#define VGA_WIDTH  80
#define VGA_HEIGHT 25
#define VGA_MEMORY ((volatile uint16_t*)0xB8000)

static uint16_t vga_cursor_x = 0;
static uint16_t vga_cursor_y = 0;
static uint8_t  vga_color    = 0;  // Set in vga_init()

// Write a single character cell to the buffer
static void vga_put_entry(char c, uint8_t attr, int x, int y) {
    VGA_MEMORY[y * VGA_WIDTH + x] = (uint16_t)c | ((uint16_t)attr << 8);
}

// Update hardware cursor position via VGA I/O ports
static void vga_update_cursor(int x, int y) {
    uint16_t pos = y * VGA_WIDTH + x;
    outb(0x3D4, 0x0F); outb(0x3D5, pos & 0xFF);       // Cursor low byte
    outb(0x3D4, 0x0E); outb(0x3D5, (pos >> 8) & 0xFF); // Cursor high byte
}

void vga_scroll() {
    // Move all rows up by one
    for (int y = 0; y < VGA_HEIGHT - 1; y++)
        for (int x = 0; x < VGA_WIDTH; x++)
            VGA_MEMORY[y * VGA_WIDTH + x] = VGA_MEMORY[(y+1) * VGA_WIDTH + x];
    // Clear the bottom row
    for (int x = 0; x < VGA_WIDTH; x++)
        vga_put_entry(' ', vga_color, x, VGA_HEIGHT - 1);
    vga_cursor_y = VGA_HEIGHT - 1;
}

void vga_putchar(char c) {
    if (c == '\n') {
        vga_cursor_x = 0;
        vga_cursor_y++;
    } else if (c == '\b') {
        if (vga_cursor_x > 0) {
            vga_cursor_x--;
            vga_put_entry(' ', vga_color, vga_cursor_x, vga_cursor_y);
        }
    } else {
        vga_put_entry(c, vga_color, vga_cursor_x, vga_cursor_y);
        vga_cursor_x++;
    }
    if (vga_cursor_x >= VGA_WIDTH) { vga_cursor_x = 0; vga_cursor_y++; }
    if (vga_cursor_y >= VGA_HEIGHT) vga_scroll();
    vga_update_cursor(vga_cursor_x, vga_cursor_y);
}

void vga_print(const char* str) {
    while (*str) vga_putchar(*str++);
}

void vga_clear() {
    for (int y = 0; y < VGA_HEIGHT; y++)
        for (int x = 0; x < VGA_WIDTH; x++)
            vga_put_entry(' ', vga_color, x, y);
    vga_cursor_x = vga_cursor_y = 0;
    vga_update_cursor(0, 0);
}

// Port I/O helpers (implement these as inline asm or in a separate file)
static inline void outb(uint16_t port, uint8_t val) {
    __asm__ volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}

Checklist

  • Implement vga_init() setting default color (e.g., light grey on black).
  • Implement vga_putchar(c) handling \n, \r, \b, and regular characters.
  • Implement vga_scroll() when cursor reaches row 25.
  • Implement vga_update_cursor() via I/O ports 0x3D4/0x3D5.
  • Implement vga_print(str) and vga_set_color(fg, bg).
  • Implement a minimal printf-style kprintf(fmt, ...) using va_list (supports %s, %d, %x, %c).
  • Test: print strings, numbers, scroll past 25 lines.

8. The Global Descriptor Table (GDT)

What the GDT is

The GDT is an array of 8-byte segment descriptors that define memory segments in Protected Mode. The CPU's CS, DS, SS registers hold selectors — 16-bit values that index into the GDT.

Segment descriptor format (8 bytes)

Bit  63    56 55 52 51 48 47    40 39   32
     Base[31:24] Flags Limit[19:16] Access  Base[23:16]

Bit  31          16 15          0
     Base[15:0]     Limit[15:0]
Bit range Field Description
0–15 Limit (low) Segment size bits 0–15
16–39 Base (low+mid) Segment base address bits 0–23
40–47 Access byte Present, DPL, Type flags
48–51 Limit (high) Segment size bits 16–19
52–55 Flags Granularity (4KB pages), 32-bit mode
56–63 Base (high) Segment base address bits 24–31

Access byte breakdown

Bit 7: Present (must be 1 for valid descriptor)
Bit 6-5: DPL (Descriptor Privilege Level: 0=kernel, 3=user)
Bit 4: Descriptor type (1=code/data, 0=system)
Bit 3: Executable (1=code segment, 0=data segment)
Bit 2: Direction/Conforming
Bit 1: Readable(code)/Writable(data)
Bit 0: Accessed (CPU sets this; initialize to 0)

Minimal GDT (three entries)

struct gdt_entry {
    uint16_t limit_low;
    uint16_t base_low;
    uint8_t  base_middle;
    uint8_t  access;
    uint8_t  granularity;   // flags + limit_high
    uint8_t  base_high;
} __attribute__((packed));

struct gdt_ptr {
    uint16_t limit;         // sizeof(gdt) - 1
    uint32_t base;          // address of gdt array
} __attribute__((packed));

static struct gdt_entry gdt[3];
static struct gdt_ptr   gdtp;

static void gdt_set(int i, uint32_t base, uint32_t limit,
                    uint8_t access, uint8_t gran) {
    gdt[i].base_low    = base & 0xFFFF;
    gdt[i].base_middle = (base >> 16) & 0xFF;
    gdt[i].base_high   = (base >> 24) & 0xFF;
    gdt[i].limit_low   = limit & 0xFFFF;
    gdt[i].granularity = ((limit >> 16) & 0x0F) | (gran & 0xF0);
    gdt[i].access      = access;
}

void gdt_install() {
    gdt_set(0, 0, 0,          0x00, 0x00);  // Null descriptor (required)
    gdt_set(1, 0, 0xFFFFF,    0x9A, 0xCF);  // Kernel code: base=0, limit=4GB
    gdt_set(2, 0, 0xFFFFF,    0x92, 0xCF);  // Kernel data: base=0, limit=4GB

    gdtp.limit = (sizeof(struct gdt_entry) * 3) - 1;
    gdtp.base  = (uint32_t)&gdt;

    gdt_flush(&gdtp);   // defined in gdt_flush.asm — calls lgdt then reloads segments
}

gdt_flush.asm

[BITS 32]
[GLOBAL gdt_flush]

gdt_flush:
    mov eax, [esp+4]    ; Pointer to gdt_ptr struct
    lgdt [eax]
    ; Reload segment registers
    mov ax, 0x10        ; Selector 0x10 = GDT index 2 = data segment
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    mov ss, ax
    jmp 0x08:.flush     ; Far jump: selector 0x08 = GDT index 1 = code segment
.flush:
    ret

Checklist

  • Define gdt_entry and gdt_ptr structs with __attribute__((packed)).
  • Implement gdt_set() to encode base, limit, access, and granularity.
  • Define null descriptor at index 0.
  • Define kernel code segment at index 1 (access=0x9A, gran=0xCF).
  • Define kernel data segment at index 2 (access=0x92, gran=0xCF).
  • Write gdt_flush() in assembly: calls lgdt, performs far jump, reloads data segments.
  • Later add Ring 3 (user mode) code (access=0xFA) and data (access=0xF2) descriptors.
  • Later add a TSS (Task State Segment) descriptor for Ring 0 stack switching.

9. The Interrupt Descriptor Table (IDT) & IRQs

What the IDT is

The IDT is the Protected Mode replacement for the Real Mode IVT. It maps interrupt numbers (0–255) to Interrupt Service Routines (ISRs). There are three categories of interrupts:

Range Type Examples
0–31 CPU exceptions Division by zero (0), Page fault (14), General protection (13)
32–47 Hardware IRQs (via PIC) Timer (32), Keyboard (33), COM1 (36)
48–255 Software interrupts System calls (e.g., INT 0x80)

IDT entry format (8 bytes)

struct idt_entry {
    uint16_t offset_low;   // ISR address bits 0–15
    uint16_t selector;     // Code segment selector (0x08)
    uint8_t  zero;         // Always 0
    uint8_t  type_attr;    // Gate type + DPL + present
    uint16_t offset_high;  // ISR address bits 16–31
} __attribute__((packed));

type_attr for a 32-bit interrupt gate (disables further interrupts): 0x8E (present=1, DPL=0, gate type=1110).

The 8259 PIC (Programmable Interrupt Controller)

The x86 PC has two chained 8259 PICs. By default, hardware IRQs 0–15 are mapped to CPU interrupt vectors 8–15 and 112–119 — which conflict with CPU exceptions. You must remap them to vectors 32–47.

#define PIC1_CMD  0x20
#define PIC1_DATA 0x21
#define PIC2_CMD  0xA0
#define PIC2_DATA 0xA1

void pic_remap(int offset1, int offset2) {
    // Save masks
    uint8_t mask1 = inb(PIC1_DATA);
    uint8_t mask2 = inb(PIC2_DATA);

    // Initialization sequence (ICW1-ICW4)
    outb(PIC1_CMD,  0x11);  // Start init, cascade mode
    outb(PIC2_CMD,  0x11);
    outb(PIC1_DATA, offset1);        // ICW2: map IRQ0-7 to offset1 (32)
    outb(PIC2_DATA, offset2);        // ICW2: map IRQ8-15 to offset2 (40)
    outb(PIC1_DATA, 0x04);  // ICW3: slave PIC at IRQ2
    outb(PIC2_DATA, 0x02);  // ICW3: slave cascade identity
    outb(PIC1_DATA, 0x01);  // ICW4: 8086 mode
    outb(PIC2_DATA, 0x01);

    // Restore masks (or mask all with 0xFF to start)
    outb(PIC1_DATA, mask1);
    outb(PIC2_DATA, mask2);
}

ISR stub pattern (assembly)

Each ISR must save all registers, call a common C handler, restore registers, and iret. To keep the code DRY, use macros:

; Macro for exceptions without an error code
%macro ISR_NOERRCODE 1
global isr%1
isr%1:
    cli
    push dword 0        ; Fake error code for uniform handler signature
    push dword %1       ; Interrupt number
    jmp isr_common_stub
%endmacro

; Macro for exceptions that push an error code automatically
%macro ISR_ERRCODE 1
global isr%1
isr%1:
    cli
    push dword %1
    jmp isr_common_stub
%endmacro

isr_common_stub:
    pusha               ; Push EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
    push ds; push es; push fs; push gs
    mov ax, 0x10        ; Kernel data segment
    mov ds, ax; mov es, ax; mov fs, ax; mov gs, ax
    push esp            ; Pointer to saved registers struct
    call isr_handler    ; C function
    pop eax
    pop gs; pop fs; pop es; pop ds
    popa
    add esp, 8          ; Remove error code and interrupt number
    iret

Common C handler

typedef struct {
    uint32_t gs, fs, es, ds;
    uint32_t edi, esi, ebp, esp, ebx, edx, ecx, eax;
    uint32_t int_no, err_code;
    uint32_t eip, cs, eflags, useresp, ss;
} registers_t;

isr_handler_t interrupt_handlers[256] = {0};

void isr_handler(registers_t regs) {
    if (interrupt_handlers[regs.int_no]) {
        interrupt_handlers[regs.int_no](&regs);
    } else {
        kprintf("Unhandled interrupt %d (err=%d)\n",
                regs.int_no, regs.err_code);
        for(;;);
    }
}

Checklist

  • Define idt_entry and idt_ptr structs.
  • Implement idt_set_gate(n, handler_addr, selector, type_attr).
  • Write ISR stubs in assembly for CPU exceptions 0–31.
  • Write IRQ stubs for hardware interrupts 32–47.
  • Remap the 8259 PIC (IRQ 0–15 → vectors 32–47).
  • Implement the common ISR handler in C with a handler dispatch table.
  • Call sti after IDT and PIC are configured.
  • Implement irq_install_handler(irq_num, handler) for registering drivers.
  • Implement pic_send_eoi(irq) — send End Of Interrupt (outb(0x20, 0x20)) after each IRQ.
  • Handle the critical exceptions: #0 (divide by zero), #13 (GPF), #14 (page fault) with useful panic messages.

10. The Keyboard Driver

How the keyboard works

The keyboard controller (port 0x60) raises IRQ 1 when a key is pressed or released. Reading port 0x60 gives a scan code (not an ASCII character). You must translate scan codes to key events.

Keyboard driver

#define KEYBOARD_DATA_PORT  0x60
#define KEYBOARD_STATUS_PORT 0x64

// US QWERTY scan code set 1 (Make codes only)
static const char scancode_to_ascii[128] = {
    0,  27, '1','2','3','4','5','6','7','8','9','0','-','=', '\b',
    '\t','q','w','e','r','t','y','u','i','o','p','[',']','\n',
    0,  'a','s','d','f','g','h','j','k','l',';','\'','`',
    0,  '\\','z','x','c','v','b','n','m',',','.','/', 0,
    '*', 0, ' ',
    // ... function keys, arrows etc. mapped to 0 or special values
};

static volatile char keyboard_buffer[256];
static volatile uint8_t buf_read  = 0;
static volatile uint8_t buf_write = 0;

void keyboard_handler(registers_t* regs) {
    uint8_t scancode = inb(KEYBOARD_DATA_PORT);
    if (scancode & 0x80) {
        // Key release (bit 7 set): scancode & 0x7F = key that was released
        return;
    }
    char c = scancode_to_ascii[scancode];
    if (c) {
        keyboard_buffer[buf_write++] = c;  // wraps around at 256
    }
    pic_send_eoi(1);
}

char keyboard_getchar() {
    while (buf_read == buf_write) {}  // Busy-wait (replace with sleep later)
    return keyboard_buffer[buf_read++];
}

void keyboard_install() {
    irq_install_handler(1, keyboard_handler);
}

Checklist

  • Register keyboard_handler for IRQ 1.
  • Read scan code from port 0x60 on each IRQ.
  • Translate make codes (key press) to ASCII using a scan code table.
  • Track modifier key state: Left/Right Shift, Ctrl, Alt, Caps Lock.
  • Implement shifted characters (!@#$%^&*() etc.) by checking shift state.
  • Implement a circular ring buffer for keyboard input.
  • Implement keyboard_getchar() (blocking) and keyboard_getchar_nonblocking().
  • Send EOI to PIC after handling the interrupt.

11. Physical Memory Management (PMM)

What the PMM does

The Physical Memory Manager tracks which 4 KB pages of RAM are free and which are in use, using a bitmap (one bit per page: 0=free, 1=used).

Memory layout assumptions

After boot, the memory map from E820 (stored at 0x1000 by Stage 2) tells you which regions are usable. You must mark everything as used, then mark usable regions as free, then mark the kernel itself and the bitmap as used again.

PMM implementation

#define PAGE_SIZE 4096

static uint32_t  pmm_bitmap[32768];   // Tracks 32768*32 = 1M pages = 4 GB
static uint32_t  pmm_total_pages;
static uint32_t  pmm_free_pages;

// Mark a physical page as used
void pmm_set_page(uint32_t addr) {
    uint32_t page = addr / PAGE_SIZE;
    pmm_bitmap[page / 32] |= (1 << (page % 32));
}

// Mark a physical page as free
void pmm_clear_page(uint32_t addr) {
    uint32_t page = addr / PAGE_SIZE;
    pmm_bitmap[page / 32] &= ~(1 << (page % 32));
}

// Check if a page is free
int pmm_test_page(uint32_t addr) {
    uint32_t page = addr / PAGE_SIZE;
    return pmm_bitmap[page / 32] & (1 << (page % 32));
}

// Allocate one free physical page; returns physical address
uint32_t pmm_alloc_page() {
    for (uint32_t i = 0; i < pmm_total_pages / 32; i++) {
        if (pmm_bitmap[i] == 0xFFFFFFFF) continue;  // All used, skip
        for (int bit = 0; bit < 32; bit++) {
            if (!(pmm_bitmap[i] & (1 << bit))) {
                uint32_t page = i * 32 + bit;
                pmm_set_page(page * PAGE_SIZE);
                pmm_free_pages--;
                return page * PAGE_SIZE;
            }
        }
    }
    return 0;  // Out of memory
}

void pmm_free_page(uint32_t addr) {
    pmm_clear_page(addr);
    pmm_free_pages++;
}

void pmm_init(uint32_t mmap_addr) {
    // 1. Mark everything as used
    memset(pmm_bitmap, 0xFF, sizeof(pmm_bitmap));
    pmm_free_pages = 0;

    // 2. Parse E820 memory map and mark usable regions as free
    e820_entry_t* entry = (e820_entry_t*)mmap_addr;
    uint16_t count = *(uint16_t*)(mmap_addr - 2);  // stored by Stage 2
    for (int i = 0; i < count; i++, entry++) {
        if (entry->type == 1) {  // Usable RAM
            uint32_t start = (uint32_t)entry->base;
            uint32_t len   = (uint32_t)entry->length;
            for (uint32_t addr = start; addr < start + len; addr += PAGE_SIZE)
                pmm_free_page(addr);
        }
    }

    // 3. Mark first 1 MB (BIOS, IVT, VGA) and kernel as used
    for (uint32_t addr = 0; addr < 0x100000; addr += PAGE_SIZE)
        pmm_set_page(addr);
    // Mark kernel pages (from linker symbols)
    extern uint32_t __kernel_start, __kernel_end;
    for (uint32_t addr = (uint32_t)&__kernel_start;
         addr < (uint32_t)&__kernel_end; addr += PAGE_SIZE)
        pmm_set_page(addr);
}

Checklist

  • Define e820_entry_t struct (base uint64, length uint64, type uint32, attrs uint32).
  • Implement bitmap-based PMM with pmm_alloc_page() and pmm_free_page().
  • Initialise bitmap by marking everything used, then free usable E820 regions.
  • Mark the kernel image, the bitmap itself, and the first 1 MB as used.
  • Implement pmm_alloc_pages(n) for contiguous multi-page allocations.
  • Export pmm_get_free_pages() and pmm_get_total_pages().

12. Virtual Memory & Paging

What paging does

Paging provides each process with its own virtual address space. The CPU translates virtual addresses to physical addresses via a two-level structure: a Page Directory (1024 entries) → Page Tables (1024 entries each) → physical page.

Address translation

Virtual address (32-bit):
  Bits 31-22: Page Directory Index (10 bits)  → selects Page Directory Entry
  Bits 21-12: Page Table Index    (10 bits)  → selects Page Table Entry
  Bits 11-0:  Offset              (12 bits)  → byte within the 4 KB page

Physical address = PTE.frame << 12 | offset

Page Directory/Table Entry format

Bit 31-12: Frame address (physical page address >> 12)
Bit 11-9:  Available (OS use)
Bit 8:     Global
Bit 7:     Page size (0=4KB, 1=4MB)
Bit 6:     Dirty (CPU sets when written)
Bit 5:     Accessed (CPU sets when read/written)
Bit 4:     Cache disabled
Bit 3:     Write-through
Bit 2:     User/supervisor (0=kernel only, 1=user accessible)
Bit 1:     Read/write (0=read only, 1=writable)
Bit 0:     Present (1=valid mapping)

Paging implementation

#define PAGE_PRESENT    0x1
#define PAGE_WRITABLE   0x2
#define PAGE_USER       0x4

typedef uint32_t page_dir_t[1024]  __attribute__((aligned(4096)));
typedef uint32_t page_table_t[1024] __attribute__((aligned(4096)));

static page_dir_t kernel_page_dir;

// Map virtual address to physical address in a page directory
void paging_map(page_dir_t* dir, uint32_t virt, uint32_t phys, uint32_t flags) {
    uint32_t dir_idx   = virt >> 22;
    uint32_t table_idx = (virt >> 12) & 0x3FF;

    if (!((*dir)[dir_idx] & PAGE_PRESENT)) {
        // Page table doesn't exist yet — allocate one
        uint32_t table_phys = pmm_alloc_page();
        memset((void*)table_phys, 0, PAGE_SIZE);
        (*dir)[dir_idx] = table_phys | PAGE_PRESENT | PAGE_WRITABLE | (flags & PAGE_USER);
    }

    page_table_t* table = (page_table_t*)((*dir)[dir_idx] & ~0xFFF);
    (*table)[table_idx] = (phys & ~0xFFF) | (flags | PAGE_PRESENT);
}

void paging_init() {
    memset(kernel_page_dir, 0, sizeof(kernel_page_dir));

    // Identity-map first 4 MB (kernel + bootloader + VGA)
    for (uint32_t addr = 0; addr < 0x400000; addr += PAGE_SIZE)
        paging_map(&kernel_page_dir, addr, addr, PAGE_PRESENT | PAGE_WRITABLE);

    // Enable paging: load CR3 and set bit 31 of CR0
    __asm__ volatile (
        "mov %0, %%cr3\n"        // Load page directory base
        "mov %%cr0, %%eax\n"
        "or  $0x80000000, %%eax\n"  // Set PG bit
        "mov %%eax, %%cr0\n"
        :: "r"((uint32_t)kernel_page_dir) : "eax"
    );
}

TLB Invalidation

When you change a page mapping, the CPU's Translation Lookaside Buffer (TLB) may cache the old mapping. Flush it:

static inline void tlb_flush_page(uint32_t virt) {
    __asm__ volatile ("invlpg (%0)" :: "r"(virt) : "memory");
}

Checklist

  • Define page_dir_t and page_table_t as aligned arrays of uint32_t.
  • Implement paging_map(dir, virt, phys, flags).
  • Implement paging_unmap(dir, virt).
  • Implement paging_init(): identity-map the kernel, enable CR3 and CR0.
  • Handle the Page Fault exception (ISR #14): print the faulting address from CR2.
  • Implement tlb_flush_page(virt) using invlpg.
  • Implement paging_clone_dir(dir) to clone a page directory for new processes (copy-on-write is a nice addition).

13. The Heap Allocator (kmalloc / kfree)

What the heap allocator does

The PMM only allocates full 4 KB pages. The heap allocator provides fine-grained allocation (any number of bytes) on top of pages. The simplest approach is a linked-list allocator.

Block header format

typedef struct block_header {
    uint32_t size;               // Size of this block (excluding header)
    uint8_t  is_free;            // 1 = free, 0 = allocated
    struct block_header* next;   // Next block in the list
    struct block_header* prev;   // Previous block
} block_header_t;

#define BLOCK_MAGIC 0xDEADBEEF

Linked-list allocator

static block_header_t* heap_start = NULL;
static uint32_t heap_current_end  = 0;

#define HEAP_START 0xC0000000   // Place heap in high memory (after paging maps it)
#define HEAP_MIN   (4 * 1024 * 1024)  // Start with 4 MB

void heap_init() {
    heap_current_end = HEAP_START;
    // Map initial heap pages
    for (uint32_t i = 0; i < HEAP_MIN; i += PAGE_SIZE) {
        uint32_t phys = pmm_alloc_page();
        paging_map(&kernel_page_dir, HEAP_START + i, phys,
                   PAGE_PRESENT | PAGE_WRITABLE);
    }
    heap_start = (block_header_t*)HEAP_START;
    heap_start->size    = HEAP_MIN - sizeof(block_header_t);
    heap_start->is_free = 1;
    heap_start->next    = NULL;
    heap_start->prev    = NULL;
    heap_current_end = HEAP_START + HEAP_MIN;
}

void* kmalloc(uint32_t size) {
    size = (size + 3) & ~3;  // Align to 4 bytes
    block_header_t* cur = heap_start;
    while (cur) {
        if (cur->is_free && cur->size >= size) {
            // Split if the leftover is large enough
            if (cur->size >= size + sizeof(block_header_t) + 4) {
                block_header_t* new_block =
                    (block_header_t*)((uint8_t*)cur + sizeof(block_header_t) + size);
                new_block->size    = cur->size - size - sizeof(block_header_t);
                new_block->is_free = 1;
                new_block->next    = cur->next;
                new_block->prev    = cur;
                if (cur->next) cur->next->prev = new_block;
                cur->next = new_block;
                cur->size = size;
            }
            cur->is_free = 0;
            return (void*)((uint8_t*)cur + sizeof(block_header_t));
        }
        cur = cur->next;
    }
    return NULL;  // OOM
}

void kfree(void* ptr) {
    if (!ptr) return;
    block_header_t* hdr = (block_header_t*)((uint8_t*)ptr - sizeof(block_header_t));
    hdr->is_free = 1;
    // Coalesce with next block if free
    if (hdr->next && hdr->next->is_free) {
        hdr->size += sizeof(block_header_t) + hdr->next->size;
        hdr->next = hdr->next->next;
        if (hdr->next) hdr->next->prev = hdr;
    }
    // Coalesce with previous block if free
    if (hdr->prev && hdr->prev->is_free) {
        hdr->prev->size += sizeof(block_header_t) + hdr->size;
        hdr->prev->next = hdr->next;
        if (hdr->next) hdr->next->prev = hdr->prev;
    }
}

Checklist

  • Implement heap_init() that maps initial pages and sets up the first free block.
  • Implement kmalloc(size) using first-fit strategy.
  • Implement block splitting to avoid wasting space.
  • Implement kfree(ptr) with forward and backward coalescing.
  • Implement kcalloc(n, size) (kmalloc + memset to zero).
  • Implement krealloc(ptr, new_size).
  • Add heap expansion: call pmm_alloc_page() + paging_map() when heap is exhausted.
  • Add optional heap integrity check: walk list and verify headers for debugging.

14. Processes & Context Switching

What a process is

A process (or task) has its own: register state, kernel stack, page directory (virtual address space), and metadata (PID, state, priority).

Process Control Block (PCB)

typedef enum { TASK_RUNNING, TASK_READY, TASK_BLOCKED, TASK_ZOMBIE } task_state_t;

typedef struct task {
    uint32_t    pid;
    task_state_t state;
    uint32_t    esp;            // Saved stack pointer (when not running)
    uint32_t    ebp;
    uint32_t    eip;            // Saved instruction pointer
    uint32_t*   page_dir;       // Physical address of page directory
    uint8_t*    kernel_stack;   // Per-task kernel stack base
    uint32_t    priority;
    struct task* next;          // Linked list of all tasks
} task_t;

Context switch (assembly)

A context switch saves the current task's CPU state onto its kernel stack and restores the next task's state from its stack.

; context_switch(task_t* current, task_t* next)
; esp+4 = current task pointer, esp+8 = next task pointer
[GLOBAL context_switch]
context_switch:
    ; Save current task's registers
    push ebp
    push esi
    push edi
    push ebx

    mov eax, [esp+20]       ; current task pointer
    mov [eax + TASK_ESP], esp   ; Save ESP into current->esp

    mov eax, [esp+24]       ; next task pointer
    mov esp, [eax + TASK_ESP]   ; Restore ESP from next->esp

    ; Switch page directories (if different)
    mov ecx, [eax + TASK_PAGEDIR]
    mov cr3, ecx

    ; Restore next task's registers
    pop ebx
    pop edi
    pop esi
    pop ebp
    ret                     ; Returns to next task's saved EIP

Creating a new task

task_t* task_create(void (*entry)(), uint32_t priority) {
    task_t* task    = (task_t*)kmalloc(sizeof(task_t));
    task->pid       = next_pid++;
    task->state     = TASK_READY;
    task->priority  = priority;
    task->page_dir  = clone_kernel_page_dir();

    // Allocate a kernel stack and set it up so context_switch will work
    task->kernel_stack = (uint8_t*)kmalloc(4096) + 4096;  // grows down
    uint32_t* sp = (uint32_t*)task->kernel_stack;
    *--sp = (uint32_t)entry;  // EIP: where task will start
    *--sp = 0;                // EBX
    *--sp = 0;                // EDI
    *--sp = 0;                // ESI
    *--sp = 0;                // EBP
    task->esp = (uint32_t)sp;

    return task;
}

Checklist

  • Define task_t (PCB) with all saved register fields, stack pointer, page directory.
  • Implement task_create(entry, priority) -> task_t*.
  • Implement context_switch(current, next) in assembly.
  • Ensure each task has its own kernel stack.
  • Implement task_exit() that marks the task as ZOMBIE and triggers a reschedule.
  • Implement task_sleep(ms) that blocks the task and sets a wakeup timer.

15. The Scheduler

What the scheduler does

The scheduler decides which task runs next. The simplest policy is round-robin: run each task for one timer tick, then switch to the next. The PIT (Programmable Interval Timer) fires IRQ 0 at a configurable frequency to drive scheduling.

Configure the PIT (IRQ 0)

#define PIT_CMD  0x43
#define PIT_CH0  0x40
#define PIT_HZ   100    // 100 Hz = 10 ms per tick

void pit_init() {
    uint32_t divisor = 1193182 / PIT_HZ;  // PIT base frequency = 1,193,182 Hz
    outb(PIT_CMD, 0x36);                   // Channel 0, lo/hi byte, square wave
    outb(PIT_CH0, divisor & 0xFF);
    outb(PIT_CH0, (divisor >> 8) & 0xFF);
}

Round-robin scheduler

static task_t* current_task = NULL;
static task_t* task_list_head = NULL;

void scheduler_tick(registers_t* regs) {
    if (!current_task) return;
    pic_send_eoi(0);

    task_t* prev = current_task;

    // Find next READY task
    task_t* next = prev->next ? prev->next : task_list_head;
    while (next != prev && next->state != TASK_READY)
        next = next->next ? next->next : task_list_head;

    if (next == prev && next->state != TASK_READY) return; // Nothing to run

    current_task = next;
    context_switch(prev, next);
}

void scheduler_start() {
    pit_init();
    irq_install_handler(0, scheduler_tick);
    // Create idle task and initial shell task
    task_create(idle_task, 0);
    task_create(shell_main, 1);
    sti();
    for (;;) {}  // Idle loop (the idle task runs here)
}

Checklist

  • Implement pit_init() to configure IRQ 0 at a fixed frequency.
  • Register the scheduler tick handler on IRQ 0.
  • Implement round-robin scheduling (or priority-based as an extension).
  • Implement task_add(task) to add a task to the run queue.
  • Implement task_block(task, reason) and task_unblock(task).
  • Implement task_yield() (voluntarily give up CPU).
  • Ensure context_switch is called with interrupts disabled.
  • Implement sleep queue: tasks blocked until a specific tick count.

16. System Calls

What system calls are

System calls are the controlled interface between user-mode processes (Ring 3) and the kernel (Ring 0). A user program triggers a software interrupt (INT 0x80) that transfers control to the kernel.

System call mechanism

User process (Ring 3)
  │  mov eax, SYSCALL_NUMBER
  │  mov ebx, arg1
  │  mov ecx, arg2
  │  int 0x80             ← raises privilege to Ring 0
  ▼
Kernel syscall handler (Ring 0)
  │  Reads EAX for call number, EBX/ECX/EDX for arguments
  │  Dispatches to the appropriate kernel function
  │  Puts return value in EAX
  │  iret                 ← returns to Ring 3

Syscall table

#define SYSCALL_EXIT    0
#define SYSCALL_WRITE   1
#define SYSCALL_READ    2
#define SYSCALL_OPEN    3
#define SYSCALL_CLOSE   4
#define SYSCALL_GETPID  5

typedef int (*syscall_fn_t)(registers_t*);

static syscall_fn_t syscall_table[] = {
    [SYSCALL_EXIT]   = sys_exit,
    [SYSCALL_WRITE]  = sys_write,
    [SYSCALL_READ]   = sys_read,
    [SYSCALL_OPEN]   = sys_open,
    [SYSCALL_CLOSE]  = sys_close,
    [SYSCALL_GETPID] = sys_getpid,
};

void syscall_handler(registers_t* regs) {
    uint32_t num = regs->eax;
    if (num < sizeof(syscall_table)/sizeof(syscall_table[0])
        && syscall_table[num]) {
        regs->eax = syscall_table[num](regs);
    } else {
        regs->eax = -1;  // ENOSYS
    }
}

// Register INT 0x80 with DPL=3 so user processes can call it
void syscall_init() {
    idt_set_gate(0x80, (uint32_t)isr128, 0x08, 0xEE);  // 0xEE = present, DPL=3, int gate
    irq_install_handler(0x80, syscall_handler);
}

Minimal syscall implementations

int sys_write(registers_t* r) {
    // EBX = fd, ECX = buf, EDX = len
    // For now: fd 1 = stdout → write to VGA
    char* buf = (char*)r->ecx;
    uint32_t len = r->edx;
    if (r->ebx == 1)
        for (uint32_t i = 0; i < len; i++) vga_putchar(buf[i]);
    return len;
}

int sys_exit(registers_t* r) {
    current_task->state = TASK_ZOMBIE;
    task_yield();
    return 0;
}

int sys_getpid(registers_t* r) {
    return current_task->pid;
}

Checklist

  • Register ISR for INT 0x80 with DPL=3 (so Ring 3 can trigger it).
  • Implement syscall_handler that dispatches by EAX.
  • Implement sys_write (write to VGA or a file descriptor).
  • Implement sys_read (read from keyboard).
  • Implement sys_exit.
  • Implement sys_getpid.
  • Create a user-space library wrapper (optional): int write(int fd, void* buf, int len) { ... syscall ... }.

17. A Simple Filesystem (initrd / FAT12)

Option A: initrd (RAM disk)

The simplest approach: embed a tar-like archive directly into the kernel binary. The bootloader places it in RAM; the kernel parses it.

// A flat initrd: array of (filename[32], size[4], data[size]) records
typedef struct {
    char     name[32];
    uint32_t size;
    uint8_t  data[];       // Variable-length
} initrd_file_t;

typedef struct {
    uint32_t num_files;
    initrd_file_t files[];
} initrd_header_t;

static initrd_header_t* initrd = NULL;

void initrd_init(uint32_t addr) {
    initrd = (initrd_header_t*)addr;
}

initrd_file_t* initrd_find(const char* name) {
    initrd_file_t* f = initrd->files;
    for (uint32_t i = 0; i < initrd->num_files; i++) {
        if (strcmp(f->name, name) == 0) return f;
        f = (initrd_file_t*)((uint8_t*)f + sizeof(initrd_file_t) + f->size);
    }
    return NULL;
}

Option B: FAT12

FAT12 is used by 1.44 MB floppy disks and is simpler than FAT32. Key structures:

Disk layout:
  Sector 0:       Boot sector (BPB: BIOS Parameter Block)
  Sectors 1–9:    FAT table 1 (12-bit entries per cluster)
  Sectors 10–18:  FAT table 2 (backup)
  Sectors 19–32:  Root directory (14 sectors × 16 entries = 224 entries)
  Sectors 33+:    Data clusters

FAT12 directory entry (32 bytes)

typedef struct {
    char     name[8];       // Padded with spaces
    char     ext[3];
    uint8_t  attributes;    // 0x20=archive, 0x10=directory, 0x01=read-only
    uint8_t  reserved[10];
    uint16_t time;
    uint16_t date;
    uint16_t first_cluster;
    uint32_t size;
} __attribute__((packed)) fat12_dirent_t;

Checklist (initrd path)

  • Define the initrd binary format.
  • Write a host-side tool (Python/C) to create an initrd image from a directory.
  • Implement initrd_init(addr) and initrd_find(name).
  • Mount initrd as the root filesystem.
  • Implement a VFS (Virtual File System) layer: vfs_open, vfs_read, vfs_write, vfs_close.

Checklist (FAT12 path)

  • Parse the BPB from the boot sector.
  • Read and traverse the FAT chain to find file clusters.
  • Read root directory entries.
  • Implement fat12_open, fat12_read, fat12_readdir.

18. A Mini Shell

What the shell does

The shell is a user-space program (or kernel task) that reads commands from the keyboard, parses them, and executes programs or built-in commands.

Shell implementation

#define MAX_CMD_LEN 256
#define MAX_ARGS    16

static char  cmd_buf[MAX_CMD_LEN];
static char* args[MAX_ARGS];

void shell_main() {
    vga_print("\nMiniOS Shell v0.1\nType 'help' for commands.\n\n");
    for (;;) {
        vga_set_color(VGA_LIGHT_GREEN, VGA_BLACK);
        vga_print("$ ");
        vga_set_color(VGA_WHITE, VGA_BLACK);
        shell_readline(cmd_buf, MAX_CMD_LEN);
        if (cmd_buf[0] == '\0') continue;

        int argc = shell_parse(cmd_buf, args, MAX_ARGS);
        shell_exec(args[0], argc, args);
    }
}

void shell_readline(char* buf, int max) {
    int i = 0;
    for (;;) {
        char c = keyboard_getchar();
        if (c == '\n' || c == '\r') { buf[i] = '\0'; vga_putchar('\n'); return; }
        if (c == '\b' && i > 0)    { i--; vga_putchar('\b'); }
        else if (c >= 32 && i < max-1) { buf[i++] = c; vga_putchar(c); }
    }
}

int shell_parse(char* line, char** argv, int max_args) {
    int argc = 0;
    char* p = line;
    while (*p) {
        while (*p == ' ') p++;  // Skip spaces
        if (!*p) break;
        argv[argc++] = p;
        while (*p && *p != ' ') p++;
        if (*p) *p++ = '\0';
        if (argc >= max_args) break;
    }
    argv[argc] = NULL;
    return argc;
}

void shell_exec(const char* cmd, int argc, char** argv) {
    if      (!strcmp(cmd, "help"))    cmd_help();
    else if (!strcmp(cmd, "clear"))   vga_clear();
    else if (!strcmp(cmd, "ls"))      cmd_ls();
    else if (!strcmp(cmd, "cat"))     cmd_cat(argc, argv);
    else if (!strcmp(cmd, "echo"))    cmd_echo(argc, argv);
    else if (!strcmp(cmd, "mem"))     cmd_meminfo();
    else if (!strcmp(cmd, "reboot"))  cmd_reboot();
    else {
        vga_print("Unknown command: ");
        vga_print(cmd);
        vga_putchar('\n');
    }
}

Built-in commands to implement

Command Action
help List available commands
clear Clear the VGA screen
ls List files in initrd/FAT12
cat <file> Print file contents
echo <text> Print arguments
mem Show free/total physical memory
reboot Triple-fault or use ACPI reset
run <prog> Load and execute a binary from the filesystem

Checklist

  • Implement shell_readline() with backspace and echo support.
  • Implement shell_parse() to split input into argc/argv.
  • Implement command dispatch table.
  • Implement help, clear, echo, mem.
  • Implement ls and cat backed by the filesystem from Step 17.
  • Implement reboot (write 0xFE to keyboard controller port 0x64 for reset).
  • Add command history (up/down arrow keys) as an extension.

19. Test Suites

Test Suite 1: Boot & Core Subsystems

These tests use QEMU with its -monitor stdio and -d debug flags to verify boot correctness. They are run as integration tests — you boot the OS in QEMU and observe output.

# test_boot.py — uses pexpect to interact with QEMU's serial output

import subprocess, pexpect, os, tempfile, time

QEMU = "qemu-system-i386"
IMAGE = "os.img"

def boot_qemu(timeout=10):
    """Boot QEMU and return a pexpect child connected to its serial port."""
    child = pexpect.spawn(
        f"{QEMU} -drive format=raw,file={IMAGE} "
        f"-serial stdio -display none",
        timeout=timeout, encoding='utf-8'
    )
    return child

# ---- Test 1: OS boots and kernel_main prints expected banner ----

def test_kernel_boots():
    child = boot_qemu()
    child.expect("MiniOS kernel booting", timeout=5)
    child.expect("All subsystems initialized", timeout=5)
    child.close()
    print("PASS: kernel boots and initializes")

# ---- Test 2: Shell prompt appears ----

def test_shell_prompt():
    child = boot_qemu()
    child.expect(r"\$", timeout=5)
    child.close()
    print("PASS: shell prompt appears")

# ---- Test 3: 'help' command produces output ----

def test_shell_help():
    child = boot_qemu()
    child.expect(r"\$")
    child.sendline("help")
    child.expect("help", timeout=3)   # help output contains "help"
    child.close()
    print("PASS: help command works")

# ---- Test 4: 'echo' command works correctly ----

def test_shell_echo():
    child = boot_qemu()
    child.expect(r"\$")
    child.sendline("echo hello world")
    child.expect("hello world", timeout=3)
    child.close()
    print("PASS: echo works")

# ---- Test 5: 'mem' command shows memory info ----

def test_shell_mem():
    child = boot_qemu()
    child.expect(r"\$")
    child.sendline("mem")
    child.expect(r"\d+ KB free", timeout=3)
    child.close()
    print("PASS: mem command shows memory")

# ---- Test 6: Unknown command gives error ----

def test_unknown_command():
    child = boot_qemu()
    child.expect(r"\$")
    child.sendline("foobar")
    child.expect("Unknown command", timeout=3)
    child.close()
    print("PASS: unknown command error reported")

# ---- Test 7: 'clear' does not crash ----

def test_clear_does_not_crash():
    child = boot_qemu()
    child.expect(r"\$")
    child.sendline("clear")
    child.expect(r"\$", timeout=3)  # Prompt reappears after clear
    child.close()
    print("PASS: clear command doesn't crash")

if __name__ == "__main__":
    for test in [test_kernel_boots, test_shell_prompt, test_shell_help,
                 test_shell_echo, test_shell_mem,
                 test_unknown_command, test_clear_does_not_crash]:
        try:
            test()
        except Exception as e:
            print(f"FAIL: {test.__name__}: {e}")

Test Suite 2: Memory, Paging & Scheduler (Unit Tests)

These unit tests run inside the kernel itself — enable a --test mode that runs tests at boot and prints results to VGA before starting the shell.

// kernel/tests.c — included in kernel build, invoked from kernel_main in test mode

#include "pmm.h"
#include "heap.h"
#include "string.h"
#include "vga.h"

static int tests_passed = 0;
static int tests_failed = 0;

#define ASSERT(cond, msg) do { \
    if (cond) { \
        kprintf("  PASS: %s\n", msg); tests_passed++; \
    } else { \
        kprintf("  FAIL: %s (line %d)\n", msg, __LINE__); tests_failed++; \
    } \
} while(0)

// ---- Test 1: PMM allocates non-overlapping pages ----

void test_pmm_no_overlap() {
    kprintf("Testing PMM...\n");
    uint32_t p1 = pmm_alloc_page();
    uint32_t p2 = pmm_alloc_page();
    uint32_t p3 = pmm_alloc_page();
    ASSERT(p1 != p2, "PMM: p1 != p2");
    ASSERT(p2 != p3, "PMM: p2 != p3");
    ASSERT(p1 != p3, "PMM: p1 != p3");
    ASSERT(p1 % 4096 == 0, "PMM: p1 is page-aligned");
    ASSERT(p2 % 4096 == 0, "PMM: p2 is page-aligned");
    pmm_free_page(p1);
    pmm_free_page(p2);
    pmm_free_page(p3);
}

// ---- Test 2: PMM free returns page to pool ----

void test_pmm_free_and_reuse() {
    kprintf("Testing PMM free/reuse...\n");
    uint32_t before_free = pmm_get_free_pages();
    uint32_t page = pmm_alloc_page();
    ASSERT(pmm_get_free_pages() == before_free - 1, "PMM: alloc decrements count");
    pmm_free_page(page);
    ASSERT(pmm_get_free_pages() == before_free, "PMM: free restores count");
    uint32_t page2 = pmm_alloc_page();
    ASSERT(page2 == page, "PMM: freed page is reused (first-fit)");
    pmm_free_page(page2);
}

// ---- Test 3: kmalloc returns aligned non-null pointer ----

void test_kmalloc_basic() {
    kprintf("Testing kmalloc...\n");
    void* p = kmalloc(64);
    ASSERT(p != NULL, "kmalloc(64) != NULL");
    ASSERT((uint32_t)p % 4 == 0, "kmalloc: result is 4-byte aligned");
    kfree(p);
}

// ---- Test 4: kmalloc returns distinct pointers ----

void test_kmalloc_distinct() {
    kprintf("Testing kmalloc distinct...\n");
    void* ptrs[10];
    for (int i = 0; i < 10; i++) ptrs[i] = kmalloc(128);
    for (int i = 0; i < 10; i++)
        for (int j = i+1; j < 10; j++)
            ASSERT(ptrs[i] != ptrs[j], "kmalloc: pointers are distinct");
    for (int i = 0; i < 10; i++) kfree(ptrs[i]);
}

// ---- Test 5: kfree + kmalloc reuses memory ----

void test_kmalloc_reuse_after_free() {
    kprintf("Testing kmalloc reuse...\n");
    void* p1 = kmalloc(256);
    kfree(p1);
    void* p2 = kmalloc(256);
    ASSERT(p1 == p2, "kmalloc: reuses freed block of same size");
    kfree(p2);
}

// ---- Test 6: kfree coalesces adjacent blocks ----

void test_kmalloc_coalesce() {
    kprintf("Testing heap coalescing...\n");
    void* a = kmalloc(64);
    void* b = kmalloc(64);
    void* c = kmalloc(64);
    kfree(a);
    kfree(b);
    // After coalescing a+b, we should be able to allocate 128 bytes contiguously
    void* ab = kmalloc(128);
    ASSERT((uint32_t)ab <= (uint32_t)a, "Coalesced block reused for larger alloc");
    kfree(ab);
    kfree(c);
}

// ---- Test 7: Write to allocated memory without crash ----

void test_kmalloc_write() {
    kprintf("Testing kmalloc write...\n");
    char* buf = (char*)kmalloc(512);
    ASSERT(buf != NULL, "kmalloc: allocation succeeds");
    for (int i = 0; i < 512; i++) buf[i] = (char)(i & 0xFF);
    int ok = 1;
    for (int i = 0; i < 512; i++) if (buf[i] != (char)(i & 0xFF)) { ok = 0; break; }
    ASSERT(ok, "kmalloc: written data reads back correctly");
    kfree(buf);
}

// ---- Test 8: String functions work correctly ----

void test_string_functions() {
    kprintf("Testing string functions...\n");
    char dst[64];
    memset(dst, 0, 64);
    ASSERT(dst[0] == 0 && dst[63] == 0, "memset: zeros buffer");

    memcpy(dst, "hello", 6);
    ASSERT(strcmp(dst, "hello") == 0, "memcpy + strcmp: works");

    ASSERT(strlen("MiniOS") == 6, "strlen: correct");

    strcat(dst, " world");
    ASSERT(strcmp(dst, "hello world") == 0, "strcat: works");
}

// ---- Run all tests ----

void run_kernel_tests() {
    vga_set_color(VGA_LIGHT_CYAN, VGA_BLACK);
    kprintf("\n=== MiniOS Kernel Self-Tests ===\n\n");
    vga_set_color(VGA_WHITE, VGA_BLACK);

    test_pmm_no_overlap();
    test_pmm_free_and_reuse();
    test_kmalloc_basic();
    test_kmalloc_distinct();
    test_kmalloc_reuse_after_free();
    test_kmalloc_coalesce();
    test_kmalloc_write();
    test_string_functions();

    vga_set_color(VGA_LIGHT_GREEN, VGA_BLACK);
    kprintf("\nResults: %d passed, ", tests_passed);
    vga_set_color(tests_failed ? VGA_LIGHT_RED : VGA_LIGHT_GREEN, VGA_BLACK);
    kprintf("%d failed\n\n", tests_failed);
    vga_set_color(VGA_WHITE, VGA_BLACK);
}

Work in this sequence to always have a bootable (partial) system at each stage:

Phase 1 — You Can Boot
  [x] Step 2: Toolchain + Makefile + QEMU
  [x] Step 3: Stage 1 bootloader (prints nothing, loads Stage 2)
  [x] Step 4: Stage 2 (loads kernel, enables A20, enters PM)
  [x] Step 5: Protected Mode switch
  [x] Step 6: Kernel entry + linker script + kernel_main prints "Hello"

Phase 2 — You Can See and Handle Events
  [x] Step 7: VGA text driver + kprintf
  [x] Step 8: GDT (reload in kernel)
  [x] Step 9: IDT + PIC (handle exceptions + IRQs)
  [x] Step 10: Keyboard driver (type characters to screen)

Phase 3 — You Have Memory
  [x] Step 11: PMM (E820 memory map parsing, page bitmap)
  [x] Step 12: Paging (identity map, enable CR0.PG)
  [x] Step 13: Heap allocator (kmalloc / kfree)

Phase 4 — You Can Run Programs
  [x] Step 14: Processes + context switching
  [x] Step 15: Scheduler (PIT IRQ0 + round-robin)
  [x] Step 16: System calls (INT 0x80)

Phase 5 — You Have a Complete Mini OS
  [x] Step 17: Filesystem (initrd or FAT12)
  [x] Step 18: Shell (readline, command dispatch)

Quick Reference: Key Addresses & Constants

Constant Value Meaning
Boot sector load address 0x7C00 Where BIOS places the 512-byte boot sector
Stage 2 load address 0x7E00 Directly after Stage 1 in conventional memory
Kernel load address 0x10000 64 KB mark — safe, below 1 MB limit
VGA text buffer 0xB8000 80×25 character cells, 2 bytes each
PIC1 command 0x20 Master PIC command port
PIC2 command 0xA0 Slave PIC command port
Keyboard data 0x60 Read scan codes here
PIT channel 0 0x40 Timer channel 0 data port
Page size 0x1000 4096 bytes
Boot signature 0xAA55 Last 2 bytes of boot sector (LE: 55 AA)
Ring 0 code selector 0x08 GDT index 1 × 8
Ring 0 data selector 0x10 GDT index 2 × 8
Syscall vector 0x80 Software interrupt for system calls

Useful Resources