Teaching It to Fail Gracefully
Milestone 4 of my from-scratch Rust kernel: the IDT and exception handling — where the machine stops silently rebooting on a mistake and starts telling me what went wrong. The whole thing lives or dies on assembly and Rust agreeing about the shape of the stack.
Contents
Milestone 4 — the GDT/IDT and exception handling. Where the kernel stops silently rebooting on a mistake and starts telling you what went wrong.
Before writing a line of code, I pinned down what this milestone was actually for: understanding how a CPU hands control to the kernel when something interrupts normal execution — the IDT and the interrupt calling convention. “Done” had to be observable behavior, not vibes: the kernel deliberately triggers a breakpoint (int3), prints that it caught it, and keeps running. And an unhandled fault prints a named report instead of rebooting.
The smallest version that still teaches the lesson: an IDT wired for all 256 vectors, but only a handful of handlers with real logic — breakpoint, page fault, and a generic fatal reporter. The int3 self-test doubles as the guard against accidentally working: if any link in the chain — table layout, stub, dispatch, iretq — is wrong, the machine faults or hangs instead of printing “survived the breakpoint.” And I named the thing most likely to stall me for days up front: a mismatch between the register order the assembly stub pushes and the Rust struct that reads it. Get that wrong and every field is silently wrong, with nothing to tell you so. Naming it meant I checked it explicitly.
The scoping decision
Interrupts are foundational, not creep — every later milestone (keyboard, timer, preemption) depends on them. But I made one deliberate reduction: skip the TSS/IST double-fault stack for now. It’s the “robust” answer, but it adds a second fiddly descriptor format to a milestone whose lesson is the IDT. Deferred, with the ist field already stubbed for when it comes.
The design
Approach. A 256-entry IDT built in Rust (src/interrupts.rs). Per-vector entry stubs generated in assembly (boot/isr.asm), because stable Rust can’t express the iretq entry/exit shape. Stubs normalise every fault into one InterruptContext layout and call one Rust dispatcher.
Machine-state preconditions. Runs in 64-bit long mode with the boot GDT still loaded (handlers use its code selector 0x08). Interrupts stay disabled the whole milestone — we never sti — so only synchronous exceptions can fire. That’s deliberate: no hardware IRQ can surprise us until the keyboard milestone.
Edge cases named. Error-code vs. no-error-code vectors (uniformised with a dummy push); the split-across-three-fields handler address in an IDT entry; 16-byte stack alignment at the call into Rust; the page-fault address living in CR2, not on the stack.
What got built
boot/isr.asm— a nasm macro emits all 256 stubs plus a sharedisr_commontrampoline and anisr_stub_tablethe Rust reads. The error-code-bearing vectors (8, 10–14, 17, 21, 29, 30) are handled distinctly from the rest.src/interrupts.rs— theIdtEntry/Idttypes,init()that fills andlidts the table, andinterrupt_dispatch, which decodes the vector:#BPreports and resumes;#PFreports CR2 + a decoded error code and halts; everything else reports and halts legibly.kernel_mainnow callsinterrupts::init()and fires theint3self-test.
What I verified — and what I didn’t
Assembles, compiles, and links cleanly — valid Multiboot2 image, no undefined symbols, no warnings. The interactive behavior (breakpoint recovery, page-fault report) is exercised by CI’s headless QEMU boot, not yet on real hardware. When it is run and something inevitably misbehaves, the debugging story goes here — that’s the honest, interesting part, and it’s not invented in advance.
What the next person should take away
The subtle, transferable idea isn’t “call lidt.” It’s that an interrupt is a contract written in two languages: assembly agrees to leave the machine state in a precise shape on the stack, and Rust agrees to read exactly that shape. The whole milestone lives or dies on those two descriptions matching. Everything else is bit-packing you can look up.
Newsletter
Liked this? Get the next one.
One essay or short note every other week — privacy-first software, AI, security, and the occasional dispatch from the trail. No filler.
More writing
Multiple things at once, sort of
I gave my from-scratch kernel a timer and its first scheduler. Two tasks take turns on one CPU — first politely, then by force — and the preemption step turned out to be a concurrency trap wearing a simple mechanism's face.
ReadIt Listens Now
Milestone 5 of my from-scratch Rust kernel: PS/2 keyboard input — the first time the machine reacts to the outside world instead of just talking at it. Remapping the PIC, enabling interrupts for the very first time, and learning why forgetting one EOI makes the keyboard die after exactly one keypress.
ReadYou Can Boot It in Your Browser Now
The real 64-bit kernel — not a screenshot, not a screen recording — running live in a tab you can click. What that took, and the two bugs that had been hiding in plain sight until I moved the specimen to a new enclosure.
Read