OS development from scratch
Contract
| Field | Bound contract |
|---|---|
| Trigger | Building a minimal operating system from scratch on x86-64: bootloader choice, GDT and page tables for long mode, IDT and interrupt dispatch, PIC or APIC, serial and keyboard input, a frame allocator, or cooperative context switching. |
| Authority | Read-only. Emits architecture decisions, code patterns, and boot commands; the code is written in the user's tree. No file writes, no rollback needed. No remote mutation. |
| Side effect | An architecture plan and runnable command sequences in chat. Nothing is written. |
| Done | A boot path reaches serial output, each stage's acceptance command passes, and the next milestone is named. |
Inputs
- Target ISA and firmware (required): x86-64 with BIOS/legacy or UEFI; xv6-RISC-V as a reference port.
- Host toolchain (required): a cross toolchain and QEMU available or installable.
- Milestone (optional): boot, long mode, interrupts, or scheduling.
Procedure
Pick the boot path and stick to it.
BIOS path (legacy) +-- POST +-- MBR boot sector loads stage 2 +-- GRUB or another multiboot loader loads the kernel ELF +-- kernel entry (_start) UEFI path (modern) +-- UEFI firmware +-- EFI loader (limine, systemd-boot) loads kernel from the ESP +-- handoff with the memory mapMultiboot keeps the first milestone short; UEFI is the path for real hardware. Done when: one path is named and the loader's load address is known.
Stand up the freestanding cross toolchain. Use
x86_64-elf-gccand binutils, not the host compiler: the host toolchain injects startup files and libc assumptions that break a kernel.x86_64-elf-gcc -ffreestanding -nostdlib -c kernel.c -o kernel.o x86_64-elf-ld -T linker.ld kernel.o -o kernel.elfENTRY(_start) SECTIONS { . = 0x100000; /* 1 MiB, the multiboot convention */ .text : { *(.text .text.*) } .rodata : { *(.rodata .rodata.*) } .data : { *(.data .data.*) } .bss : { *(.bss .bss.*) } }The linker script's load address must match what the loader promises; a mismatch is the classic relocation triple fault. Done when:
kernel.elflinks andreadelf -hshows the expected entry and load address.Boot under QEMU with serial as the first output.
qemu-system-x86_64 -kernel kernel.elf -serial stdio -m 128M -no-reboot -no-shutdown-kernelloads an ELF carrying a multiboot header; the header must sit in the first 8 KiB of the image. For the UEFI path, run under OVMF with a disk image the loader has claimed:qemu-system-x86_64 -bios /usr/share/ovmf/OVMF.fd -drive file=disk.img,format=raw -serial stdioDone when: the first
serial_putc('K')from the entry path shows in the terminal.Enter long mode through PAE and 4-level paging. The path is: protected mode, enable PAE, build PML4/PDPT/PD, set
EFER.LME, then enable paging.uint64_t pml4[512] __attribute__((aligned(4096))); uint64_t pdpt[512] __attribute__((aligned(4096))); uint64_t pd[512] __attribute__((aligned(4096))); void setup_paging(void) { for (int i = 0; i < 512; i++) pd[i] = (uint64_t)i * 0x200000 | 0x83; /* 2 MiB pages, present|rw|PS */ pdpt[0] = (uint64_t)pd | 0x03; pml4[0] = (uint64_t)pdpt | 0x03; __asm__ volatile("mov %0, %%cr3" :: "r"(pml4)); }This identity-maps the first GiB with 2 MiB pages; the kernel stack must be valid before any interrupt can fire. Done when: code runs at 64-bit with serial output intact.
Load the IDT and dispatch by vector. Each gate points at an assembly stub that pushes a trap frame; the C handler switches on the vector.
struct idt_entry { uint16_t offset_low; uint16_t selector; uint8_t ist; uint8_t type_attr; uint16_t offset_mid; uint32_t offset_high; uint32_t zero; } __attribute__((packed));void interrupt_handler(struct trap_frame *f) { if (f->vector == 14) /* page fault: cr2 holds the address */ handle_page_fault(f->cr2, f->error_code); else if (f->vector == 33) /* IRQ1 keyboard after PIC remap */ keyboard_handler(); }Done when: a deliberate page fault is caught, reported over serial, and execution continues or halts cleanly.
Remap the PIC before unmasking anything. The 8259 pair delivers IRQs at vectors 32 through 47 after remap; the APIC (LAPIC plus I/O APIC, discovered from the ACPI MADT) is the modern controller and its LAPIC timer is the preemption source.
outb(0x20, 0x11); outb(0xA0, 0x11); /* ICW1: init, cascade */ outb(0x21, 0x20); outb(0xA1, 0x28); /* ICW2: vector offsets 0x20 and 0x28 */Send EOI (0x20 to port 0x20) at the end of every handled IRQ. Done when: the timer or keyboard IRQ fires exactly once per event.
Write serial first, keyboard second. Poll COM1's line status for transmit-empty; the keyboard is a scancode port with an EOI.
void serial_putc(char c) { while ((inb(0x3F8 + 5) & 0x20) == 0) ; outb(0x3F8, c); } void keyboard_handler(void) { uint8_t sc = inb(0x60); char c = scancode_to_ascii[sc]; if (c) serial_putc(c); outb(0x20, 0x20); /* EOI */ }Done when: typed keys echo over serial.
Build the frame allocator from the firmware memory map. Take usable regions from the multiboot or UEFI memory map, mark one bitmap bit per 4 KiB frame, and return frame addresses on first fit.
#define PAGE_SIZE 4096 uint64_t alloc_frame(void) { for (uint64_t i = 0; i < total_frames; i++) if (!test_bit(frame_bitmap, i)) { set_bit(frame_bitmap, i); return i * PAGE_SIZE; } return 0; /* out of frames: the caller must see this */ }A linear scan is fine at this scale; replace it with a free list only when allocation shows up in a profile. Done when: every handed-out frame is later reclaimed exactly once.
Switch contexts cooperatively before adding preemption. Save callee-saved registers per the System V AMD64 ABI, restore the next task's, and return into its saved rip.
struct context { uint64_t rbx, rbp, r12, r13, r14, r15, rsp, rip; }; void switch_context(struct context *old, struct context *new);Caller-saved registers live in the stack frame already, so only the callee-saved set plus stack pointer and return address need storing. Add a timer-IRQ preemption tick only after cooperative switching is stable. Done when: two tasks alternate under a yield call without corrupting either stack.
Keep xv6-RISC-V open as the reference.
git clone https://github.com/mit-pdos/xv6-riscv && cd xv6-riscv && make qemuxv6 component Concept kernel/vm.cPage table management kernel/trap.cTrap dispatch kernel/proc.cContext switch and scheduler kernel/plic.cInterrupt controller user/usys.plSystem call stubs The concepts port directly to x86-64; the register and controller details do not. Done when: each new milestone reads the corresponding xv6 file first.
Failure and recovery
| Symptom | Cause | Fix |
|---|---|---|
| Triple fault on boot | Invalid GDT or IDT, or no stack before interrupts | Set up a stack before sti, validate GDT/IDT contents |
| QEMU black screen | No serial output configured | -serial stdio, initialize COM1 first |
| Page fault in kernel | Unmapped address | Identity-map the kernel, check cr3 |
| IRQ never fires | IDT not loaded or IRQ masked | lidt, unmask in the PIC, send EOI |
| Timer silent | LAPIC not initialized | Parse the ACPI MADT, calibrate the LAPIC timer |
| Linker relocation fault | Load address mismatch | Match linker.ld to the loader's promise |
| Failure class | Behavior |
|---|---|
| Stage change bricks the boot | Bisect stages: keep the last working QEMU invocation and revert one stage at a time. |
| Interrupt storm after enabling the PIC | A handler missed its EOI; audit every registered handler before the next enable. |
| Corrupted state after a switch | The context struct or the assembly save set is incomplete; compare against the ABI's callee-saved list. |
| Cannot tell hang from crash | Run QEMU with -d int -no-reboot and read the injected interrupt log. |
Output
- The chosen boot path and load address.
- Per-stage code patterns with the acceptance command for each.
- The next milestone named.