A small L4-style microkernel for x86_64, written in C. The kernel does the minimum that must run in ring 0; everything else (drivers, file system, network stack, process management policy) runs as isolated user-space servers that talk over IPC (inter-process communication) and hold capabilities (unforgeable tokens that grant access to one kernel object).
The property we are building toward and will demo: a driver can crash, be restarted by a supervisor, and the rest of the system keeps running.
| Decision | Choice | Why |
|---|---|---|
| Kernel model | Microkernel, user-space servers | Real fault isolation; restartable drivers |
| Language | C (gnu11), minimal assembly | Matches OS-course material, smallest toolchain |
| Architecture | x86_64 only (arch code isolated) | Most relevant ISA; QEMU q35 as reference machine |
| Bootloader | Limine (base revision 3) | Enters 64-bit long mode and maps the kernel in the higher half for us; gives the memory map and HHDM (higher-half direct map). Skips the 32→64-bit trampoline that teaches little |
| Reference machine | QEMU q35, 256 MiB, serial on COM1 |
Modern chipset: PCIe (PCI Express) with ECAM (memory-mapped config space), AHCI (SATA disk controller), MSI (message-signaled interrupts) |
| Kernel address | 0xffffffff80000000 (-mcmodel=kernel) |
Standard higher-half layout |
| Physical memory access | Limine HHDM (phys + 0xffff8000_00000000) |
Kernel can touch any frame without temporary mappings |
| FPU/SIMD (floating-point unit / vector instructions) in kernel | Forbidden (-mgeneral-regs-only) |
No FPU state to save on kernel entry; lazy/eager save only for user threads |
- Traps: GDT (Global Descriptor Table), TSS (Task State Segment),
IDT (Interrupt Descriptor Table), exception handlers,
syscall/sysretentry. - Memory: physical frame allocator, 4-level page tables per address space, kernel heap for kernel objects.
- Threads & scheduling: thread control blocks, context switch, preemptive fixed-priority round-robin driven by the LAPIC (local Advanced Programmable Interrupt Controller) timer.
- IPC: synchronous endpoints + asynchronous notifications.
- Capabilities: per-process capability table; every kernel object is reached only through a capability.
- Interrupt routing: IOAPIC (I/O APIC) or MSI → notification to the driver thread that holds the IRQ (interrupt request line) capability.
Not in the kernel: device drivers (except the early debug serial and the timer the scheduler needs), file systems, networking, ELF (executable file format) loading of anything but the root task, process-management policy, POSIX (the Unix API standard).
┌──────────────────────── ring 3 ───────────────────────────────┐
│ root task/supervisor │ console │ pci │ blk │ fs │ net │ apps │
└──────────▲────────────────────────────────────────────▲───────┘
│ syscalls: IPC + capability ops │
┌──────────┴────────────────────────────────────────────┴───────┐
│ core/: sched · ipc · cap · vm · irq-routing (portable C) │
├────────────────────────────────────────────────────────────────┤
│ arch/x86_64/: boot (Limine) · gdt/idt/tss · paging · lapic · │
│ context switch · syscall entry │
└────────────────────────────────────────────────────────────────┘
kernel/
arch/x86_64/ everything x86-specific, incl. the only Limine-aware file;
headers used only inside arch/ (gdt.h, idt.h, trap.h,
lapic.h) live here
core/ portable kernel logic (main, panic, cmdline, pmm, slab,
kstack, sched, process, elf, syscall, object, cap, ipc,
self-tests)
lib/ freestanding libc pieces (string, kprintf)
include/arch/ interface core/ uses to reach arch code
include/kernel/ core interfaces
linker.ld
include/abi/ what the kernel and user programs share: syscall numbers,
rights, error codes, test protocol (no kernel internals)
user/
lib/ user runtime: crt0 (_start), syscall and IPC wrappers, printf
root/ the root task (also runs the IPC test server and client)
linker.ld user programs load at 0x400000
scripts/test.sh boot-and-check test runner (`make test`)
docs/DESIGN.md
limine.conf also loads user/root.elf as a boot module
Rule: core/ never includes <limine.h> or uses inline asm; it goes through
include/arch/. Boot data (memory map, command line) is copied into
struct boot_info once, so the rest of the kernel is bootloader-neutral.
- GDT order is fixed by
syscall/sysret: null, kernel code0x08, kernel data0x10, user data0x18, user code0x20, TSS0x28(16 bytes).sysretderives user SS and CS from one base, so user data must sit directly before user code. - IST (Interrupt Stack Table): double fault, NMI (non-maskable
interrupt) and machine check each run on their own 16 KiB stack from the
TSS. A fault caused by a broken stack (overflow, bad RSP) therefore still
gets reported instead of escalating to a triple fault, which silently
resets the machine.
selftest=doublefaultproves this. - Entry stubs:
isr.Sgenerates 256 stubs. Each pushes a dummy error code where the CPU doesn't, then the vector number, so every trap reachestrap_dispatch()with the samestruct trap_framelayout. - Policy today:
#BP(breakpoint) prints and resumes; any other exception prints the cause, registers, control registers and a frame-pointer backtrace, then panics. Of vectors 32–255, only the timer (0x30) and spurious interrupts are expected (see M3); any other one panics. From M9, faults in user threads go to the supervisor instead. - RSP0, the kernel stack the CPU loads when a ring-3 thread traps, is set in the TSS on every context switch (M3/M4).
make test runs scripts/test.sh, which boots the kernel in QEMU once per
case with a different kernel command line (CMDLINE, passed through
limine.conf) and checks the serial log and QEMU's exit status. QEMU's
isa-debug-exit device turns qemu_exit(code) into exit status
code*2+1: 1 = normal finish, 3 = panic, 124 = timeout (hang or reboot).
panic() calls qemu_exit(1) so failing cases end immediately.
Destructive tests are selected with selftest=<name> (memory tests in
core/selftest.c, scheduler tests in core/selftest_sched.c, user tests
in core/selftest_user.c, IPC tests in core/selftest_ipc.c, CPU tests
in arch/x86_64/selftest.c). The user and IPC tests are the exception:
the kernel must survive them, so they end with status 1.
Every boot also runs the non-destructive tests:
- from
kmain: anint3round trip, and PMM, paging (including switching to a second address space) and slab checks; - from the init thread: the scheduler checks (priority order, sleep length, preemption of two threads that never yield, leak-free thread exit);
- the root task, which checks its own privilege level and syscall error handling, then computes for 10^9 cycles without syscalls while a kernel thread competes for the CPU; its exit code and a leak check on its memory are verified;
- the IPC test (see IPC below): a server and a client process, checking both exit codes, the number of direct switches, and that every frame came back.
- Virtual layout (per address space):
0x0000_0000_0000_0000 – 0x0000_7fff_ffff_ffffuser half (empty in the kernel's own address space, so a stray low pointer always faults)0xffff_8000_0000_0000 – …HHDM: direct map of all RAM-like regions (usable, bootloader-reclaimable, kernel, ACPI tables), read/write, NX0xffff_ffff_8000_0000 – …kernel image
- Kernel image permissions follow W^X (write xor execute):
.textis read + execute,.rodataread-only,.data/.bssread/write + NX. The linker script page-aligns each section so they can be mapped separately.selftest=write-textandselftest=exec-dataprove both directions. - Shared kernel half:
paging_init()creates all 256 kernel-half PDPTs (page directory pointer tables) up front, costing 1 MiB. The kernel's PML4 entries then never change, so a new address space just copies them, and nothing has to be synced later. Kernel-half pages are marked global (CR4.PGE) so a CR3 switch doesn't flush them from the TLB (translation lookaside buffer). - Page sizes: 2 MiB pages where physical and virtual addresses are both aligned (most of the HHDM), 4 KiB elsewhere. 1 GiB pages aren't used.
- Frames: PMM (physical memory manager) is a bitmap with one bit per 4 KiB frame and a next-fit search. Frame 0 is never handed out, so 0 means "no memory". Bootloader-reclaimable memory (Limine's page tables, stack and boot data) is handed to the PMM once the kernel runs on its own stacks, at the start of the init thread. A buddy allocator comes later if a driver needs physically contiguous buffers.
- Kernel objects: slab caches, one per object type (thread, endpoint,
capability table). One slab = one 4 KiB page with a header at the start,
so freeing finds the slab by rounding down. Free objects carry a magic
word, which catches double frees. Empty slabs go straight back to the
PMM. There is no general-purpose
kmalloc, so user requests can never make the kernel allocate odd-sized blocks. - User memory: granted as frame capabilities and mapped with
map(). Nommapor demand paging in the kernel; a user-space pager can add that. - Device memory (LAPIC, IOAPIC, HPET) is not in the HHDM; whoever needs it
maps it uncached (
PAGE_UNCACHED).
- Tick: the LAPIC timer in periodic mode at 100 Hz (
SCHED_HZ), calibrated at boot against PIT (programmable interval timer) channel 2, polled through port 0x61 so no interrupt is needed for calibration. The legacy 8259 PIC is remapped to vectors 0x20–0x2f and fully masked; its spurious interrupts are ignored. Timer = vector 0x30, LAPIC spurious = 0xff. - Threads:
struct threadcomes from a slab cache. Each thread has a 16 KiB kernel stack in a dedicated region (PML4 slot 510), one 32 KiB slot per stack: 16 KiB unmapped guard below 16 KiB of stack. An overflow hits the guard, the CPU can't push the page-fault frame there, and the double fault is reported on its IST stack (selftest=stack-overflow). - Context switch (
switch.S) saves only the callee-saved registers on the old stack and swaps RSP. A new thread's stack is pre-built so the first switch "returns" into a trampoline that callsthread_entry(). - Policy: 256 fixed priorities (255 highest). The highest-priority
ready thread runs; equal priorities round-robin in 2-tick (20 ms)
timeslices. A thread that becomes ready with higher priority than the
running one (created, or woken by the timer) preempts it immediately.
Run queues are one FIFO per priority plus a 256-bit bitmap, so picking
the next thread is constant time. The idle thread (priority 0, never
queued) runs
sti; hlt. - Where preemption happens: only at the end of an interrupt, in
sched_preempt_if_needed(). The interrupted thread's trap frame stays on its own kernel stack, so when it's picked again it simply returns from the handler andiretqs. Voluntary switches happen inthread_yield,thread_sleepandthread_exit. - Locking: one CPU, so disabling interrupts (
irq_save/irq_restore) is the lock. Every switch happens with interrupts off; each thread gets its own interrupt state back when it resumes. - Exit: a dead thread can't free the stack it's running on, so the next
thread frees it right after the switch (
finish_switch). - Per-CPU data:
struct cpu(current, idle, need_resched, counters) is reached through the GS base viathis_cpu(), so adding CPUs later means onestruct cpueach plus real locks, not a rewrite. - Boot handoff:
sched_start()switches from Limine's stack to theinitthread and never returns. Init then hands Limine's leftover memory (its page tables, stack and boot data, about 1 MiB here) to the PMM. - Sleeping threads sit on one unsorted list scanned each tick. Fine for tens of threads; a sorted list or timer wheel can come later.
- Processes: a process is an address space plus (for now) one thread.
thread->proctells the scheduler which page-table root to load; kernel threads run on whatever is loaded, since the kernel half is the same everywhere, which avoids needless TLB flushes. - Loading: the root task is an ordinary static ELF that Limine loads as a boot module. The kernel's ELF loader treats it as untrusted: every offset and size is bounds-checked, segments must lie in the user half (above the never-mapped null page), and a segment that is both writable and executable is refused, so W^X holds for user code too. Each page gets a fresh zeroed frame. The user stack is 16 KiB just below the top of the user half, with unmapped pages on both sides.
- Entering ring 3:
arch_enter_user()builds aniretqframe (user CS 0x23, SS 0x1b, interrupts on), zeroes every register but the argument so no kernel values leak, and doesswapgs. - GS discipline: in the kernel, GS base points at
struct cpu; in user mode it is the user's own (0). Every entry from ring 3 (interrupt stubs check the saved CS;syscallalways comes from ring 3) doesswapgs, and every return to ring 3 swaps back. - System calls:
syscall→syscall_entryswitches to the thread's kernel stack (kept instruct cpunext to TSS.RSP0), builds the samestruct trap_framean interrupt would, enables interrupts (the kernel is preemptible inside syscalls) and calls the portablesyscall_handle(). The return path usesiretq, notsysretq: slightly slower, but it works for any frame and avoids thesysretqnon-canonical-RIP bug that faults in ring 0 on Intel CPUs. - User pointers: never dereferenced before
paging_user_range_ok()confirms every page is present, user-accessible and (if needed) writable; otherwise the call returns-ERR_FAULT. Safe without locks while each process has one thread. - Faults: an exception from ring 3 prints the usual report (minus the
backtrace: user frame pointers are untrusted), kills only that process
and frees its memory; the kernel carries on. Tests:
user-pagefault,user-privileged(cli→ #GP),user-kernel-read. - Teardown: a process's memory is freed when its thread is reaped, i.e. after the scheduler has switched to another address space; if the next thread is a kernel thread, the scheduler loads the kernel's root first so the dying root is never the active one.
- FPU/SSE: the kernel doesn't save FPU/SSE registers on a switch yet,
so user programs are compiled with
-mgeneral-regs-onlytoo. Saving them (fxsave/xsave) is needed before running arbitrary compiled code. - Waiting for a process to exit blocks on a wait queue (since M5).
- Endpoints are synchronous rendezvous points: a sender waits until a receiver takes its message and vice versa. Each endpoint has one queue of waiting senders and one of waiting receivers; at most one is non-empty.
- Messages are 4 words in registers, the sender's badge, and at most one capability. Message words are copied once, straight into the receiver's syscall results; there are no kernel message buffers.
- Calls:
send,recv,call(send, then wait for the reply),reply, andreply_recv(reply, then wait for the next message: a server's whole main loop is one syscall). - Replies go to an implicit per-thread reply slot: receiving a
callrecords the caller asreply_to, so a server can only answer the client that called it, exactly once. This is the "one-shot reply capability" without a table slot. - Fast path: when a
callfinds a server already waiting, the kernel blocks the caller and switches straight to the server (sched_block_and_switch), skipping the run queue;reply_recvswitches straight back. The test counts these: ~2000 direct switches for 1000 round trips. (Raw cycle counts under QEMU's emulator say nothing about real hardware, so they're printed but not judged.) - Death: a server that dies or calls
recvwithout replying makes its pending caller'scallreturn-ERR_DEADinstead of hanging forever (selftest=ipc-server-dies). Clients queued on the endpoint stay queued: whoever receives on it next (a restarted server, in M9) serves them. - Notifications: a word of bits.
notifyORs in bits and the cap's badge and never blocks;waitreturns and clears the word, blocking if it is zero. Meant for interrupts and events. - Locking: every IPC path runs with interrupts off, which on one CPU makes each operation atomic.
- Blocked threads hold a reference to the object they wait on, so an endpoint or notification can never be freed while anyone is queued.
process_waitin the kernel now blocks on a wait queue instead of polling once per tick.
- Capability tables: each process has a flat table of 64 slots
(
struct cspace), embedded instruct process. A handle is a slot number; slot 0 is never used. A slot holds{object, rights, badge}. - Rights: READ (recv/wait), WRITE (send/call/notify), GRANT (capabilities may travel in messages over this endpoint cap; for a reply, the caller's cap must have GRANT, so a client decides whether it accepts capabilities back).
- Operations:
cap_copyderives a copy with a subset of the rights, and can stamp a badge on an unbadged cap ("mint"), never re-badge one;cap_deletedrops one. Sending a cap in a message copies it into the receiver's table; the sender keeps its own. - Badges let a server tell clients apart: every message sent through a badged endpoint cap carries that badge, and the receiver sees it.
- Objects (
struct kobj) are reference-counted: each capability and each thread blocked on the object holds one; the lastkobj_putfrees it. Types so far: endpoint, notification, factory. - Factories replace seL4's untyped memory with something simpler: creating a kernel object requires a factory capability, and each factory has a budget. Freeing an object refunds its factory (which stays alive until all its objects are gone). So there is no ambient authority to consume kernel memory: a process can only create as many objects as the factory it was given allows. The tests give a factory to the server only.
- Not yet: revocation (deleting every capability derived from one). It needs a derivation tree per object; it comes with supervision in M9, where the supervisor must be able to take capabilities back.
- x86-specific (later): I/O-port capabilities enforced through the TSS I/O permission bitmap.
- A "module" is a user-space server binary.
- The root task is the first and only process the kernel loads (from a Limine module). It reads a manifest, starts each server, and hands it exactly the capabilities it needs (e.g. the console server gets the COM1 I/O-port cap and IRQ 4).
- Supervision: the root task holds a notification bound to each child's fault handler. On a fault it tears the server down, restarts it, and re-hands its caps. Clients see an IPC error and reconnect.
- Interfaces: each server's message protocol is a versioned header in
proto/shared by server and clients. No IDL until hand-written protocols hurt. - PCI: a user-space PCI server owns ECAM, enumerates devices and hands out per-device config/BAR/MSI caps to drivers.
Entered with syscall; number in rax, args in rdi, rsi, rdx, r10, r8, r9,
result in rax (negative = -ERR_*); rcx and r11 are clobbered. IPC
calls also return the message in the argument registers. Numbers, rights,
object types and error codes live in include/abi/syscall.h.
| # | Call | Notes |
|---|---|---|
| 0 | debug_write(buf, len) -> len |
debug only; goes away once the console server exists |
| 1 | exit(code) |
|
| 2 | yield() |
|
| 3 | send(ep, w0..w3, cap) |
needs WRITE (GRANT to send a cap) |
| 4 | recv(ep) -> msg |
needs READ |
| 5 | call(ep, w0..w3, cap) -> reply |
needs WRITE |
| 6 | reply(w0..w3, cap) |
to the last caller received |
| 7 | reply_recv(ep, w0..w3, cap) -> msg |
the server loop |
| 8 | notify(ntfn, bits) |
needs WRITE |
| 9 | wait(ntfn) -> bits |
needs READ |
| 10 | cap_copy(slot, rights, badge) -> slot |
rights must be a subset |
| 11 | cap_delete(slot) |
|
| 12 | obj_create(factory, type) -> slot |
endpoint or notification; uses budget |
| later | map, unmap, thread_create, process_create, cap_revoke |
M6/M9 |
| Milestone | Done when | |
|---|---|---|
| M0 | Boot via Limine, serial output, memory map | kernel boots in QEMU and prints the memory map ✅ |
| M1 | GDT/TSS, IDT, exception handlers | a deliberate #PF prints a register dump; a #DF on a broken stack is caught via IST ✅ |
| M2 | Frame allocator, page tables, kernel slab heap | kernel runs on its own PML4 (top-level page table) with W^X; second address space works ✅ |
| M3 | LAPIC timer, kernel threads, scheduler | two kernel threads that never yield share the CPU; stack overflow caught by guard page ✅ |
| M4 | Ring 3, syscall, root task loaded from an ELF module |
root task prints via a syscall; preempted in ring 3 and resumed; a faulting process is killed and the kernel survives ✅ |
| M5 | Endpoints, notifications, capability tables | two processes talk only through capabilities they were given: 1000 call/reply round trips on the fast path, a capability passed in a reply, a cross-process notification; a dying server returns -ERR_DEAD to its caller ✅ |
| M6 | Root task spawns servers from a manifest | console server running in ring 3 |
| M7 | User-space serial console driver (IRQ via notification) | kernel stops printing directly |
| M8 | PCI server + virtio-blk/AHCI driver + tiny FS server | cat a file from disk |
| M9 | Fault supervision + restart | kill the disk driver, system survives |
| M10 | virtio-net + user-space TCP/IP stack (stretch) | ping the guest |
#PF = page fault, #DF = double fault.
POSIX, SMP, graphics, swapping, ACPI beyond what's needed to find the IOAPIC/HPET, UEFI runtime services, security hardening beyond the capability model (KASLR, SMAP/SMEP come later and are cheap to add).
seL4 (capabilities, derivation trees), the L4 family (IPC fast path), Zircon (handles, ports), Minix 3 (driver restart / reincarnation server), xv6 (clean small-kernel code). This project borrows freely; its purpose is learning and a working demo, not novelty.