Loading content...
Building a kernel from scratch is the ultimate systems programming project. It pulls together everything: hardware interaction, memory management, process scheduling, interrupt handling, and device drivers—all without the safety net of an existing OS.
This project creates a minimal kernel that boots, enters protected mode, handles interrupts, and runs multiple tasks. It's the foundation from which real operating systems grow.
By the end of this page, you will understand the boot process, protected mode setup, Global Descriptor Table (GDT), Interrupt Descriptor Table (IDT), keyboard input, basic task switching, and how to test with QEMU.
This project requires x86 assembly knowledge, C programming, understanding of memory layout, and familiarity with hardware concepts. You'll need: NASM assembler, GCC cross-compiler, QEMU emulator, and GNU Make.
When a computer powers on, control flows through several stages:
The bootloader's job:
We'll use a two-stage bootloader: Stage 1 fits in 512 bytes, Stage 2 does the heavy lifting.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
; Stage 1 Bootloader - Must fit in 512 bytes; Loaded at 0x7C00 by BIOS [BITS 16][ORG 0x7C00] KERNEL_OFFSET equ 0x1000 ; Where we'll load the kernel start: ; Set up segment registers xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7C00 ; Stack grows down from bootloader ; Save boot drive number mov [BOOT_DRIVE], dl ; Print loading message mov si, MSG_LOADING call print_string ; Load kernel from disk call load_kernel ; Switch to protected mode call switch_to_pm jmp $ ; Should never reach here ; Load kernel sectors from diskload_kernel: mov bx, KERNEL_OFFSET ; Load to ES:BX = 0:0x1000 mov dh, 32 ; Read 32 sectors (16KB) mov dl, [BOOT_DRIVE] mov ah, 0x02 ; BIOS read sectors function mov al, dh ; Number of sectors mov ch, 0 ; Cylinder 0 mov cl, 2 ; Start from sector 2 mov dh, 0 ; Head 0 int 0x13 ; BIOS disk interrupt jc disk_error ret disk_error: mov si, MSG_DISK_ERR call print_string jmp $ ; Print null-terminated string at SIprint_string: pusha mov ah, 0x0E.loop: lodsb cmp al, 0 je .done int 0x10 jmp .loop.done: popa ret ; Switch to 32-bit protected modeswitch_to_pm: cli ; Disable interrupts lgdt [gdt_descriptor] ; Load GDT ; Set protected mode bit in CR0 mov eax, cr0 or eax, 1 mov cr0, eax ; Far jump to flush pipeline and load CS jmp CODE_SEG:init_pm [BITS 32]init_pm: ; Set up segment registers for protected mode mov ax, DATA_SEG mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, 0x90000 ; Stack at 576KB ; Jump to kernel call KERNEL_OFFSET jmp $ ; GDT (Global Descriptor Table)gdt_start: ; Null descriptor (required) dq 0 gdt_code: dw 0xFFFF ; Limit (bits 0-15) dw 0 ; Base (bits 0-15) db 0 ; Base (bits 16-23) db 10011010b ; Access: present, ring 0, code, readable db 11001111b ; Flags: 4KB granularity, 32-bit db 0 ; Base (bits 24-31) gdt_data: dw 0xFFFF dw 0 db 0 db 10010010b ; Access: present, ring 0, data, writable db 11001111b db 0 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; Size dd gdt_start ; Address CODE_SEG equ gdt_code - gdt_startDATA_SEG equ gdt_data - gdt_start ; DataMSG_LOADING db "Loading kernel...", 13, 10, 0MSG_DISK_ERR db "Disk error!", 0BOOT_DRIVE db 0 ; Boot sector padding and magic numbertimes 510 - ($ - $$) db 0dw 0xAA55Once in protected mode, we jump to our C kernel. The first task: prove we're alive by writing to the screen.
VGA text mode maps video memory to physical address 0xB8000. Each character cell is 2 bytes: character + attribute (color).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
// Kernel entry point and basic VGA driver #include <stdint.h> #define VGA_ADDRESS 0xB8000#define VGA_WIDTH 80#define VGA_HEIGHT 25 // VGA colorsenum vga_color { VGA_BLACK = 0, VGA_BLUE = 1, VGA_GREEN = 2, VGA_CYAN = 3, VGA_RED = 4, VGA_MAGENTA = 5, VGA_BROWN = 6, VGA_LIGHT_GREY = 7, VGA_DARK_GREY = 8, VGA_LIGHT_BLUE = 9, VGA_LIGHT_GREEN = 10, VGA_LIGHT_CYAN = 11, VGA_LIGHT_RED = 12, VGA_LIGHT_MAGENTA = 13, VGA_YELLOW = 14, VGA_WHITE = 15,}; static uint16_t *vga_buffer = (uint16_t *)VGA_ADDRESS;static int cursor_x = 0;static int cursor_y = 0;static uint8_t current_color = (VGA_BLACK << 4) | VGA_LIGHT_GREY; // Create a VGA character entrystatic inline uint16_t vga_entry(char c, uint8_t color) { return (uint16_t)c | ((uint16_t)color << 8);} // Clear the screenvoid clear_screen(void) { for (int i = 0; i < VGA_WIDTH * VGA_HEIGHT; i++) { vga_buffer[i] = vga_entry(' ', current_color); } cursor_x = 0; cursor_y = 0;} // Scroll the screen up by one linestatic void scroll(void) { for (int y = 0; y < VGA_HEIGHT - 1; y++) { for (int x = 0; x < VGA_WIDTH; x++) { vga_buffer[y * VGA_WIDTH + x] = vga_buffer[(y + 1) * VGA_WIDTH + x]; } } // Clear last line for (int x = 0; x < VGA_WIDTH; x++) { vga_buffer[(VGA_HEIGHT - 1) * VGA_WIDTH + x] = vga_entry(' ', current_color); } cursor_y = VGA_HEIGHT - 1;} // Print a single charactervoid putchar(char c) { if (c == '\n') { cursor_x = 0; cursor_y++; } else if (c == '\r') { cursor_x = 0; } else if (c == '\t') { cursor_x = (cursor_x + 8) & ~7; } else { vga_buffer[cursor_y * VGA_WIDTH + cursor_x] = vga_entry(c, current_color); cursor_x++; } if (cursor_x >= VGA_WIDTH) { cursor_x = 0; cursor_y++; } if (cursor_y >= VGA_HEIGHT) { scroll(); }} // Print a null-terminated stringvoid print(const char *str) { while (*str) { putchar(*str++); }} // Print a string with newlinevoid println(const char *str) { print(str); putchar('\n');} // Kernel main function - entry point from bootloadervoid kernel_main(void) { clear_screen(); println("====================================="); println(" Welcome to MiniOS Kernel! "); println("====================================="); println(""); println("Boot successful. Protected mode active."); println("VGA driver initialized."); println(""); // Initialize subsystems println("Initializing IDT..."); idt_init(); println("Initializing keyboard..."); keyboard_init(); println(""); println("System ready. Type something!"); // Halt - interrupts will drive the system for (;;) { asm volatile("hlt"); }}Interrupts are how hardware communicates with the CPU. The IDT maps interrupt numbers to handler functions.
Interrupt types:
We need to:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
#include <stdint.h> #define IDT_ENTRIES 256 // IDT entry structuretypedef struct { uint16_t base_low; // Lower 16 bits of handler address uint16_t selector; // Kernel code segment selector uint8_t zero; // Always 0 uint8_t type_attr; // Type and attributes uint16_t base_high; // Upper 16 bits of handler address} __attribute__((packed)) idt_entry_t; // IDT pointer structuretypedef struct { uint16_t limit; uint32_t base;} __attribute__((packed)) idt_ptr_t; static idt_entry_t idt[IDT_ENTRIES];static idt_ptr_t idt_ptr; // External assembly handlersextern void isr0(void); // Divide by zeroextern void isr13(void); // General protection faultextern void isr14(void); // Page faultextern void irq0(void); // Timerextern void irq1(void); // Keyboard // Set an IDT entrystatic void idt_set_gate(int num, uint32_t handler, uint16_t selector, uint8_t type_attr) { idt[num].base_low = handler & 0xFFFF; idt[num].base_high = (handler >> 16) & 0xFFFF; idt[num].selector = selector; idt[num].zero = 0; idt[num].type_attr = type_attr;} // Remap the PIC to not conflict with CPU exceptionsstatic void pic_remap(void) { // ICW1: Initialize outb(0x20, 0x11); // Master PIC outb(0xA0, 0x11); // Slave PIC // ICW2: Vector offsets outb(0x21, 0x20); // Master: IRQs 0-7 -> interrupts 32-39 outb(0xA1, 0x28); // Slave: IRQs 8-15 -> interrupts 40-47 // ICW3: Cascade outb(0x21, 0x04); // Master: Slave at IRQ2 outb(0xA1, 0x02); // Slave: Cascade identity // ICW4: 8086 mode outb(0x21, 0x01); outb(0xA1, 0x01); // Mask all interrupts except keyboard (IRQ1) outb(0x21, 0xFD); // 11111101 - only IRQ1 enabled outb(0xA1, 0xFF); // All slave IRQs disabled} // Initialize the IDTvoid idt_init(void) { idt_ptr.limit = sizeof(idt) - 1; idt_ptr.base = (uint32_t)&idt; // Clear all entries for (int i = 0; i < IDT_ENTRIES; i++) { idt_set_gate(i, 0, 0, 0); } // Set exception handlers idt_set_gate(0, (uint32_t)isr0, 0x08, 0x8E); // Divide by zero idt_set_gate(13, (uint32_t)isr13, 0x08, 0x8E); // GP fault idt_set_gate(14, (uint32_t)isr14, 0x08, 0x8E); // Page fault // PIC remapping before setting IRQ handlers pic_remap(); // Set IRQ handlers idt_set_gate(32, (uint32_t)irq0, 0x08, 0x8E); // Timer idt_set_gate(33, (uint32_t)irq1, 0x08, 0x8E); // Keyboard // Load IDT asm volatile("lidt %0" : : "m"(idt_ptr)); asm volatile("sti"); // Enable interrupts}The keyboard controller sends scancodes on IRQ1. We translate scancodes to ASCII characters and display them.
This demonstrates the interrupt-driven I/O model: the CPU halts, keyboard generates interrupt, our handler runs, CPU resumes waiting.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
#include <stdint.h> // Keyboard ports#define KEYBOARD_DATA_PORT 0x60#define KEYBOARD_STATUS_PORT 0x64 // US keyboard scancode to ASCII mappingstatic const char scancode_ascii[] = { 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, // Enter, Left Ctrl 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '\`', 0, '\\', // Left Shift, Backslash 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 0, '*', 0, ' ', 0 // Right Shift, Keypad *, Left Alt, Space, Caps}; static int shift_pressed = 0; // Port I/O functions static inline uint8_t inb(uint16_t port) { uint8_t result; asm volatile("inb %1, %0" : "=a"(result) : "Nd"(port)); return result; } static inline void outb(uint16_t port, uint8_t data) { asm volatile("outb %0, %1" : : "a"(data), "Nd"(port));} // Keyboard interrupt handlervoid keyboard_handler(void) { uint8_t scancode = inb(KEYBOARD_DATA_PORT); // Key release (bit 7 set) if (scancode & 0x80) { scancode &= 0x7F; if (scancode == 0x2A || scancode == 0x36) { shift_pressed = 0; } return; } // Key press if (scancode == 0x2A || scancode == 0x36) { shift_pressed = 1; return; } if (scancode < sizeof(scancode_ascii)) { char c = scancode_ascii[scancode]; if (c) { if (shift_pressed && c >= 'a' && c <= 'z') { c -= 32; // Uppercase } putchar(c); } }} void keyboard_init(void) { // Keyboard is already enabled via PIC // Could configure repeat rate, LEDs, etc. here} The build process:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
# Cross - compiler prefix(build with osdev toolchain)CC = i686 - elf - gccLD = i686 - elf - ldASM = nasm CFLAGS = -ffreestanding - O2 - Wall - Wextra - fno - exceptions - fno - rttiLDFLAGS = -T link.ld - nostdlib # Source filesC_SOURCES = $(wildcard *.c)ASM_SOURCES = $(wildcard *.asm)HEADERS = $(wildcard *.h) # Object filesOBJ = ${C_SOURCES:.c =.o } ${ ASM_SOURCES:.asm =.o } # OutputKERNEL = kernel.binBOOTLOADER = boot.binOS_IMAGE = os.img all: $(OS_IMAGE) # Create bootable disk image$(OS_IMAGE): $(BOOTLOADER) $(KERNEL) cat $(BOOTLOADER) $(KERNEL) > $@ # Pad to make it a proper disk imagetruncate - s 1440K $ @ # Bootloader$(BOOTLOADER): boot.asm$(ASM) - f bin $ < -o $ @ # Kernel binary$(KERNEL): kernel_entry.o $(filter - out kernel_entry.o, $(OBJ))$(LD) $(LDFLAGS) - o $ @$ ^ # Kernel entry point(must be first)kernel_entry.o: kernel_entry.asm$(ASM) - f elf32 $ < -o $ @ # Generic rules %.o: %.c $(HEADERS)$(CC) $(CFLAGS) - c $ < -o $ @ %.o: %.asm$(ASM) - f elf32 $ < -o $ @ # Run in QEMUrun: $(OS_IMAGE)qemu - system - i386 - fda $(OS_IMAGE) # Debug with QEMU and GDBdebug: $(OS_IMAGE)qemu - system - i386 - s - S - fda $(OS_IMAGE) & gdb - ex "target remote localhost:1234" - ex "symbol-file kernel.bin" clean: rm - f *.o *.bin $(OS_IMAGE) .PHONY: all run debug cleanContinue with: paging and virtual memory, context switching and multitasking, system calls, userspace programs, simple shell, disk driver and filesystem, networking stack. Each addition builds on this foundation.