Skip to content

Toy Kernel Project: Build a Minimal x86-64 Kernel

A complete project guide for building a minimal x86-64 kernel from scratch. By the end, the kernel boots in QEMU, outputs text to serial and VGA, handles hardware interrupts, manages physical and virtual memory, and can run a userspace process. This project is the best possible complement to reading about kernel internals — you will understand every layer because you built it.


Project Overview

Phase What You Build Milestone
Phase 1: Booting Multiboot2 entry, GDT, long mode, VGA text "Hello from kernel" on screen
Phase 2: Interrupts IDT, PIC, keyboard, timer Keyboard input echoes to screen
Phase 3: Memory Buddy allocator, page tables, kmalloc kmalloc(4096) works correctly
Phase 4: Userspace Ring 3 entry, syscall, simple shell /bin/hello runs in userspace

Estimated time: 3–6 months of evenings/weekends

Reference implementations: - xv6 (MIT): https://github.com/mit-pdos/xv6-riscv (conceptual reference — different arch) - OSDev Wiki: https://wiki.osdev.org (your primary reference for x86 specifics) - Writing an OS in Rust (blog series): https://os.phil-opp.com (excellent Rust alternative)


Toolchain Setup

Required Tools

# Install cross-compiler targeting x86_64 bare metal (no OS, no libc)
sudo apt install gcc-multilib nasm qemu-system-x86_64 grub-pc-bin xorriso

# Build cross-compiler from source (recommended for serious work)
# Using osdev cross-compiler guide: https://wiki.osdev.org/GCC_Cross-Compiler
export TARGET=x86_64-elf
export PREFIX="$HOME/opt/cross"
export PATH="$PREFIX/bin:$PATH"

# After building cross-compiler:
x86_64-elf-gcc --version

Build System Setup

# Top-level Makefile
TARGET   := x86_64-elf
CC       := $(TARGET)-gcc
AS       := nasm
LD       := $(TARGET)-ld
OBJCOPY  := $(TARGET)-objcopy

CFLAGS   := -O0 -g -ffreestanding -fno-stack-protector -mno-red-zone \
            -mno-mmx -mno-sse -mno-sse2 -Wall -Wextra \
            -I src/include

ASFLAGS  := -f elf64

LDFLAGS  := -T src/link.ld -nostdlib

OBJS     := build/boot.o build/kernel_main.o build/vga.o \
            build/gdt.o build/idt.o build/isr.o \
            build/pic.o build/mm.o build/vmm.o build/kmalloc.o

.PHONY: all qemu clean

all: kernel.elf iso/boot/kernel.elf
    grub-mkrescue -o myos.iso iso/

kernel.elf: $(OBJS)
    $(LD) $(LDFLAGS) -o $@ $^

build/%.o: src/%.asm
    $(AS) $(ASFLAGS) -o $@ $<

build/%.o: src/%.c
    $(CC) $(CFLAGS) -c -o $@ $<

qemu: all
    qemu-system-x86_64 -cdrom myos.iso \
        -serial stdio \
        -m 128M \
        -d int,cpu_reset \
        -no-reboot

clean:
    rm -f build/*.o kernel.elf myos.iso

Linker Script (src/link.ld)

ENTRY(_start)
SECTIONS {
    . = 1M;          /* Load at 1 MB physical — standard convention */

    .text ALIGN(4K) : {
        *(.multiboot2)   /* Multiboot header must be in first 32KB */
        *(.text)
    }

    .rodata ALIGN(4K) : { *(.rodata*) }

    .data ALIGN(4K) : { *(.data) }

    .bss ALIGN(4K) : {
        *(COMMON)
        *(.bss)
    }

    /* Symbols for kernel end — used by memory manager */
    kernel_start = LOADADDR(.text);
    kernel_end   = .;
}

Phase 1: Booting

Step 1.1: Multiboot2 Header and Assembly Entry

The bootloader (GRUB) loads your kernel and jumps to _start. Before that, GRUB scans the first 32 KB of your binary for the Multiboot2 magic number.

; src/boot.asm
section .multiboot2
header_start:
    dd 0xe85250d6                          ; Multiboot2 magic
    dd 0                                   ; Architecture: x86 protected mode
    dd header_end - header_start           ; Header length
    dd -(0xe85250d6 + 0 + (header_end - header_start))  ; Checksum

    ; End tag (required)
    dw 0    ; type
    dw 0    ; flags
    dd 8    ; size
header_end:

section .bss
align 16
stack_bottom:
    resb 16384          ; 16 KB initial stack
stack_top:

section .text
global _start
_start:
    ; GRUB left us in 32-bit protected mode
    ; Disable interrupts immediately
    cli

    ; Set up stack
    mov esp, stack_top

    ; Save multiboot info pointer (ebx) before we clobber registers
    push ebx
    push eax    ; magic = 0x36d76289 for Multiboot2

    ; Check for CPUID support, SSE, and set up for 64-bit
    ; (detailed steps: enable PAE, set up page tables, enable long mode)
    call setup_long_mode    ; sets up minimal page tables + enables long mode

    ; Load 64-bit GDT
    lgdt [gdt64.pointer]

    ; Far jump to flush pipeline and enter 64-bit code segment
    jmp gdt64.code:long_mode_start

    ; Never reached
    hlt

; --- 64-bit code begins here ---
bits 64
long_mode_start:
    ; Initialize segment registers to 64-bit data segment
    mov ax, gdt64.data
    mov ss, ax
    mov ds, ax
    mov es, ax

    ; Pop multiboot args and call C kernel entry
    pop rdi    ; arg1 = multiboot magic
    pop rsi    ; arg2 = multiboot info pointer
    call kernel_main

    ; If kernel_main returns, halt
.halt:
    cli
    hlt
    jmp .halt

Step 1.2: GDT Setup

/* src/gdt.c */
#include "gdt.h"

/* 64-bit GDT: null descriptor, code segment, data segment */
struct gdt_entry {
    uint16_t limit_low;
    uint16_t base_low;
    uint8_t  base_mid;
    uint8_t  access;
    uint8_t  granularity;
    uint8_t  base_high;
} __attribute__((packed));

struct gdt_pointer {
    uint16_t limit;
    uint64_t base;
} __attribute__((packed));

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

static void gdt_set_entry(int i, uint32_t base, uint32_t limit,
                          uint8_t access, uint8_t gran)
{
    gdt[i].base_low    = base & 0xFFFF;
    gdt[i].base_mid    = (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_init(void)
{
    gdt_set_entry(0, 0, 0, 0, 0);                  /* null */
    gdt_set_entry(1, 0, 0xFFFFF, 0x9A, 0xA0);      /* 64-bit code: L=1, D=0 */
    gdt_set_entry(2, 0, 0xFFFFF, 0x92, 0xC0);      /* 64-bit data */

    gdt_ptr.limit = sizeof(gdt) - 1;
    gdt_ptr.base  = (uint64_t)&gdt;

    /* Reload GDT and segment registers */
    asm volatile("lgdt %0" : : "m"(gdt_ptr));
}

Step 1.3: VGA Text Output

/* src/vga.c */
#include <stdint.h>
#include <stddef.h>

#define VGA_ADDR  0xB8000
#define VGA_COLS  80
#define VGA_ROWS  25

static uint16_t *const vga = (uint16_t *)VGA_ADDR;
static int col = 0, row = 0;

static inline uint16_t vga_char(char c, uint8_t color) {
    return (uint16_t)c | ((uint16_t)color << 8);
}

void vga_clear(void) {
    for (int i = 0; i < VGA_ROWS * VGA_COLS; i++)
        vga[i] = vga_char(' ', 0x07);
    col = row = 0;
}

void vga_putchar(char c) {
    if (c == '\n') { col = 0; row++; return; }
    if (col >= VGA_COLS) { col = 0; row++; }
    if (row >= VGA_ROWS) { /* TODO: scroll */ row = VGA_ROWS - 1; }
    vga[row * VGA_COLS + col] = vga_char(c, 0x0F);  /* white on black */
    col++;
}

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

Phase 1 Kernel Main

/* src/kernel_main.c */
#include "vga.h"
#include "gdt.h"
#include "serial.h"

void kernel_main(uint32_t mb_magic, void *mb_info)
{
    vga_clear();
    gdt_init();

    vga_print("Kernel booted!\n");
    vga_print("Multiboot2 magic: ");
    /* print hex magic... */

    serial_init();          /* UART 8250 — for GDB and logging */
    serial_print("Hello from kernel (serial)\n");

    /* Halt */
    for (;;) asm("hlt");
}

Milestone 1 Test:

make qemu
# QEMU window should show: "Kernel booted!"
# stdio should show: "Hello from kernel (serial)"

Phase 2: Interrupts

Step 2.1: IDT Setup

/* src/idt.c — abbreviated */
struct idt_entry {
    uint16_t offset_low;
    uint16_t selector;      /* kernel code segment */
    uint8_t  ist;           /* interrupt stack table — 0 for now */
    uint8_t  type_attr;     /* 0x8E = interrupt gate, ring 0, present */
    uint16_t offset_mid;
    uint32_t offset_high;
    uint32_t zero;
} __attribute__((packed));

#define IDT_SIZE 256
static struct idt_entry idt[IDT_SIZE];

void idt_set_gate(int n, uint64_t handler, uint16_t sel, uint8_t flags)
{
    idt[n].offset_low  = handler & 0xFFFF;
    idt[n].offset_mid  = (handler >> 16) & 0xFFFF;
    idt[n].offset_high = (handler >> 32) & 0xFFFFFFFF;
    idt[n].selector    = sel;
    idt[n].type_attr   = flags;
    idt[n].ist = idt[n].zero = 0;
}

Step 2.2: PIC (8259A) Initialization

/* src/pic.c */
#define PIC1_CMD  0x20
#define PIC1_DATA 0x21
#define PIC2_CMD  0xA0
#define PIC2_DATA 0xA1
#define PIC_EOI   0x20

void pic_init(void)
{
    /* ICW1: start init sequence, edge-triggered, cascade */
    outb(PIC1_CMD, 0x11);
    outb(PIC2_CMD, 0x11);
    /* ICW2: remap to IRQ 32-47 (avoid conflict with CPU exceptions 0-31) */
    outb(PIC1_DATA, 32);    /* IRQ0-7  → INT 32-39 */
    outb(PIC2_DATA, 40);    /* IRQ8-15 → INT 40-47 */
    /* ICW3: cascade identity */
    outb(PIC1_DATA, 0x04);
    outb(PIC2_DATA, 0x02);
    /* ICW4: 8086 mode */
    outb(PIC1_DATA, 0x01);
    outb(PIC2_DATA, 0x01);
    /* Unmask all IRQs */
    outb(PIC1_DATA, 0x00);
    outb(PIC2_DATA, 0x00);
}

void pic_send_eoi(uint8_t irq)
{
    if (irq >= 8) outb(PIC2_CMD, PIC_EOI);
    outb(PIC1_CMD, PIC_EOI);
}

Milestone 2 Test: After loading the IDT and enabling interrupts (sti), pressing a key on the QEMU keyboard should call your keyboard IRQ handler (IRQ1 = INT 33).


Phase 3: Memory Management

Step 3.1: Physical Memory Manager (Bitmap Allocator)

/* src/pmm.c — physical memory manager */
#include <stdint.h>
#include <stddef.h>

#define PAGE_SIZE    4096
#define BITMAP_BITS  (1024 * 1024)  /* manage 4 GB = 1M pages */

static uint8_t bitmap[BITMAP_BITS / 8];
static uint64_t total_pages;
static uint64_t free_pages;

static inline void bitmap_set(uint64_t page) {
    bitmap[page / 8] |= (1 << (page % 8));
}

static inline void bitmap_clear(uint64_t page) {
    bitmap[page / 8] &= ~(1 << (page % 8));
}

static inline int bitmap_test(uint64_t page) {
    return (bitmap[page / 8] >> (page % 8)) & 1;
}

void pmm_init(uint64_t mem_size_bytes, uint64_t kernel_end_phys)
{
    /* Mark all memory as used initially */
    total_pages = mem_size_bytes / PAGE_SIZE;
    for (uint64_t i = 0; i < total_pages / 8; i++)
        bitmap[i] = 0xFF;

    /* Mark available memory as free (parse Multiboot2 memory map) */
    /* For simplicity, mark everything above kernel_end as free */
    uint64_t free_start = (kernel_end_phys + PAGE_SIZE - 1) / PAGE_SIZE;
    for (uint64_t i = free_start; i < total_pages; i++)
        bitmap_clear(i);

    free_pages = total_pages - free_start;
}

uint64_t pmm_alloc_page(void)
{
    for (uint64_t i = 0; i < total_pages; i++) {
        if (!bitmap_test(i)) {
            bitmap_set(i);
            free_pages--;
            return i * PAGE_SIZE;
        }
    }
    return 0;   /* out of memory */
}

void pmm_free_page(uint64_t phys_addr)
{
    uint64_t page = phys_addr / PAGE_SIZE;
    bitmap_clear(page);
    free_pages++;
}

Step 3.2: Virtual Memory (4-Level Page Tables)

/* src/vmm.c — virtual memory manager */
#define PML4_INDEX(va) (((va) >> 39) & 0x1FF)
#define PDPT_INDEX(va) (((va) >> 30) & 0x1FF)
#define PD_INDEX(va)   (((va) >> 21) & 0x1FF)
#define PT_INDEX(va)   (((va) >> 12) & 0x1FF)

#define PAGE_PRESENT  (1ULL << 0)
#define PAGE_WRITABLE (1ULL << 1)
#define PAGE_USER     (1ULL << 2)

typedef uint64_t page_table_t[512];

extern page_table_t pml4;   /* set up in boot.asm */

void vmm_map_page(uint64_t virt, uint64_t phys, uint64_t flags)
{
    uint64_t *pml4e = &pml4[PML4_INDEX(virt)];

    /* Allocate PDPT if not present */
    if (!(*pml4e & PAGE_PRESENT)) {
        uint64_t pdpt_phys = pmm_alloc_page();
        memset((void *)pdpt_phys, 0, PAGE_SIZE);
        *pml4e = pdpt_phys | PAGE_PRESENT | PAGE_WRITABLE;
    }

    /* Walk down: PDPT → PD → PT */
    uint64_t *pdpt = (uint64_t *)(*pml4e & ~0xFFF);
    uint64_t *pdpte = &pdpt[PDPT_INDEX(virt)];
    if (!(*pdpte & PAGE_PRESENT)) {
        uint64_t pd_phys = pmm_alloc_page();
        memset((void *)pd_phys, 0, PAGE_SIZE);
        *pdpte = pd_phys | PAGE_PRESENT | PAGE_WRITABLE;
    }

    uint64_t *pd = (uint64_t *)(*pdpte & ~0xFFF);
    uint64_t *pde = &pd[PD_INDEX(virt)];
    if (!(*pde & PAGE_PRESENT)) {
        uint64_t pt_phys = pmm_alloc_page();
        memset((void *)pt_phys, 0, PAGE_SIZE);
        *pde = pt_phys | PAGE_PRESENT | PAGE_WRITABLE;
    }

    uint64_t *pt = (uint64_t *)(*pde & ~0xFFF);
    pt[PT_INDEX(virt)] = phys | flags | PAGE_PRESENT;

    /* Flush TLB entry */
    asm volatile("invlpg (%0)" : : "r"(virt) : "memory");
}

Milestone 3 Test:

/* In kernel_main, test the allocator */
uint64_t page1 = pmm_alloc_page();
uint64_t page2 = pmm_alloc_page();
vga_print("page1: "); print_hex(page1);
vga_print("page2: "); print_hex(page2);
/* They should be different pages, 4096 apart */
pmm_free_page(page1);
uint64_t page3 = pmm_alloc_page();
/* page3 should equal page1 — reused the freed page */

Phase 4: Userspace

Step 4.1: Enter Ring 3

To jump to ring 3, use iretq with a fabricated interrupt frame:

/* src/userspace.c */
#define USER_CODE_SEG 0x1B   /* selector: GDT index 3, RPL=3 */
#define USER_DATA_SEG 0x23   /* selector: GDT index 4, RPL=3 */
#define RFLAGS_IF     0x200  /* interrupt enable flag */

void enter_userspace(uint64_t rip, uint64_t rsp)
{
    /* iretq pops: RIP, CS, RFLAGS, RSP, SS in that order */
    asm volatile(
        "push %[ss]    \n"  /* SS */
        "push %[rsp]   \n"  /* RSP (user stack) */
        "push %[rfl]   \n"  /* RFLAGS */
        "push %[cs]    \n"  /* CS */
        "push %[rip]   \n"  /* RIP (entry point) */
        "iretq         \n"
        :
        : [ss]  "r"((uint64_t)USER_DATA_SEG),
          [rsp] "r"(rsp),
          [rfl] "r"((uint64_t)RFLAGS_IF),
          [cs]  "r"((uint64_t)USER_CODE_SEG),
          [rip] "r"(rip)
    );
}

Step 4.2: Syscall Handler

/* src/syscall.c */
#define SYSCALL_WRITE  1
#define SYSCALL_EXIT   60

void syscall_handler(uint64_t number, uint64_t arg1, uint64_t arg2, uint64_t arg3)
{
    switch (number) {
    case SYSCALL_WRITE:
        /* arg1 = fd, arg2 = buf, arg3 = len */
        /* For now: write to VGA */
        vga_print((const char *)arg2);
        break;
    case SYSCALL_EXIT:
        vga_print("\n[process exited]\n");
        for (;;) asm("hlt");
        break;
    default:
        vga_print("[unknown syscall]\n");
    }
}

Milestone 4 Test: A userspace program compiled and embedded in the kernel image should execute and print "Hello from userspace!" via the write syscall.


Debugging Workflow

GDB with QEMU

# Start QEMU with GDB stub
qemu-system-x86_64 -cdrom myos.iso -s -S -m 128M -serial stdio

# In another terminal
x86_64-elf-gdb kernel.elf
(gdb) target remote :1234
(gdb) break kernel_main
(gdb) continue
# Execution stops at kernel_main
(gdb) info registers
(gdb) x/10i $rip    # disassemble next 10 instructions
(gdb) x/8gx $rsp    # dump stack

Common Boot Failures

Symptom Cause Debug Step
QEMU shows "Booting from CD-ROM" then blank Multiboot header not found Check header is in first 32KB; verify checksum
Immediate triple fault GDT not set up before long mode jump Use QEMU -d int,cpu_reset to see fault vector
Triple fault after IDT load Handler addresses wrong in IDT entries Dump IDT entries with GDB before lidt
Page fault on first memory access Page tables not covering accessed address Map wider range in boot page tables
General protection fault entering ring 3 Segment descriptor flags wrong Verify DPL=3 in user code/data segments

Milestone Checkpoints

Milestone How to Verify
M1: Boots QEMU window shows "Kernel booted!"
M2: Interrupts Keypress echoes to screen; timer increments counter
M3: Memory pmm_alloc_page returns non-overlapping addresses; vmm_map_page doesn't triple fault
M4: Userspace Embedded user_hello.elf runs and prints via syscall

Suggested Extensions

Extension Skills Developed
Implement fork() Copy page tables, duplicate process state
Implement exec() Parse ELF binary, load segments into fresh address space
Add a simple round-robin scheduler timer_handler switches current_task
Implement read() + keyboard buffer Blocking syscall, interrupt-driven wakeup
Add ext2 read-only filesystem on a disk image Block driver (ATA PIO), inode parsing