Build a Mini Operating System From Scratch — A Complete Checklist & Guide
Table of Contents
- Understand the Boot Process
- Set Up Your Toolchain
- Stage 1 Bootloader — BIOS Boot Sector
- Stage 2 Bootloader — Loading the Kernel
- Entering Protected Mode (32-bit)
- The Kernel Entry Point
- The VGA Text Driver
- The Global Descriptor Table (GDT)
- The Interrupt Descriptor Table (IDT) & IRQs
- The Keyboard Driver
- Physical Memory Management (PMM)
- Virtual Memory & Paging
- The Heap Allocator (
kmalloc/kfree) - Processes & Context Switching
- The Scheduler
- System Calls
- A Simple Filesystem (initrd / FAT12)
- A Mini Shell
- Test Suites
- 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 0x10for video,INT 0x13for disk,INT 0x15for 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-gccandi686-elf-ldcross-compiler toolchain. - Create the Makefile with targets:
boot/stage1.bin,boot/stage2.bin,kernel/kernel.bin, andos.img. - Verify QEMU can boot an empty image without crashing.
- Verify GDB can attach to QEMU via
-s -Sflags.
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
DLinto 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 are0x55, 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:
- Print a status message to confirm it loaded.
- Detect the system's memory map using BIOS
INT 0x15, EAX=0xE820. - Load the kernel binary from disk into memory.
- Set up the GDT.
- Switch to Protected Mode.
- 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
0x92for 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
CR0bit 0. - Execute a far jump to load
CSwith the code segment selector. - Reload
DS,ES,SS,FS,GSwith 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.asmthat clears BSS and setsESP. - Write
linker.ldwith correct load address (0x10000or 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 ports0x3D4/0x3D5. - Implement
vga_print(str)andvga_set_color(fg, bg). - Implement a minimal
printf-stylekprintf(fmt, ...)usingva_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_entryandgdt_ptrstructs 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: callslgdt, 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](®s);
} else {
kprintf("Unhandled interrupt %d (err=%d)\n",
regs.int_no, regs.err_code);
for(;;);
}
}
Checklist
- Define
idt_entryandidt_ptrstructs. - 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
stiafter 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_handlerfor IRQ 1. - Read scan code from port
0x60on 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) andkeyboard_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_tstruct (base uint64, length uint64, type uint32, attrs uint32). - Implement bitmap-based PMM with
pmm_alloc_page()andpmm_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()andpmm_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_tandpage_table_tas aligned arrays ofuint32_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)usinginvlpg. - 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+memsetto 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)andtask_unblock(task). - Implement
task_yield()(voluntarily give up CPU). - Ensure
context_switchis 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_handlerthat dispatches byEAX. - 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)andinitrd_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 intoargc/argv. - Implement command dispatch table.
- Implement
help,clear,echo,mem. - Implement
lsandcatbacked by the filesystem from Step 17. - Implement
reboot(write0xFEto keyboard controller port0x64for 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);
}
20. Recommended Build Order
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
- OSDev Wiki (the primary reference): https://wiki.osdev.org
- Intel 64 and IA-32 SDM (CPU manual): https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- Writing a Simple OS from Scratch (Nick Blundell, PDF): https://www.cs.bham.ac.uk/~exr/lectures/opsys/10_11/lectures/os-dev.pdf
- James Molloy's kernel tutorial: http://www.jamesmolloy.co.uk/tutorial_html/
- xv6 source (simple Unix-like OS, great reference): https://github.com/mit-pdos/xv6-public
- NASM documentation: https://nasm.us/doc/
- QEMU debugging guide: https://wiki.osdev.org/QEMU
- Bare Bones tutorial (OSDev): https://wiki.osdev.org/Bare_Bones