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: a device interrupt → notification to the driver thread that holds the IRQ (interrupt request line) capability. Legacy lines go through the 8259 PIC today (PCI devices' legacy INTx lines too); the IOAPIC (I/O APIC) and MSI come later.
Not in the kernel: device drivers (except the timer the scheduler needs and a write-only serial log for the kernel's own messages), file systems, networking, process-management policy, POSIX (the Unix API standard). One exception for now: ELF (executable file format) loading stays in the kernel until user space can map memory (see Starting the system).
┌──────────────────────── 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,
spawn, irq, memory, self-tests)
lib/ freestanding libc pieces (string, kprintf)
include/arch/ interface core/ uses to reach arch code (incl. irq.h and
ioport.h: interrupt lines and I/O-port permissions)
include/kernel/ core interfaces
linker.ld
include/abi/ what the kernel and user programs share: syscall numbers,
rights, error codes, initial capability slots (boot.h),
server protocols (proto.h, console.h, pci.h), test
protocol (no kernel internals)
user/
lib/ user runtime: crt0 (_start), syscall and IPC wrappers,
printf/snprintf, console client, port I/O (io.h)
root/ the root task: starts the system from a manifest (init.c);
also runs the kernel's user-mode and IPC tests
console/ the console server: the COM1 serial driver
pci/ the PCI server
blk/ the virtio-blk server (legacy PCI virtqueue and DMA)
fs/ read-only single-file filesystem
cat/ file reader using only the filesystem and console
net/ legacy virtio-net driver and Ethernet/ARP/IPv4/ICMP stack
disk/ source file for the generated disk image
hello/ a console client
*.manifest which programs the root task starts, and with what
linker.ld user programs load at 0x400000
scripts/test.sh boot-and-check test runner (`make test`)
scripts/net-test.py host Ethernet peer and ICMP integration test
docs/DESIGN.md
limine.conf loads the user programs and manifests as boot modules
make also creates build/disk.img, the disk QEMU attaches as a virtio
block device.
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; in user mode it kills the process instead (M4). Of vectors 32–255, only the timer (0x30), the PIC's device lines (0x20–0x2f, M7) and spurious interrupts are expected; any other one panics. Since M9 the supervisor also receives a notification after user-process teardown; the report remains on the kernel log. - 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, system
tests in core/selftest_system.c, CPU tests in arch/x86_64/selftest.c).
The user, IPC and system 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;
- the system itself (see Starting the system below): the root task runs
system.manifest, and the kernel checks it exited cleanly and that every frame came back. A named system test (app-crash,console-input) runs its own manifest instead.
console-input needs typed input: the script runs QEMU with a named pipe
as its stdin and writes a line into it once the guest prints that it is
waiting. Text sent earlier could arrive before the kernel sets up the
UART, which discards it.
- 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. Physically contiguous runs (DMA buffers, since M8) are found by a linear scan of the bitmap; a buddy allocator would make that faster if it ever matters.
- Allocator locking: the PMM and the slab caches run with interrupts off. System calls are preemptible, so before M8 two threads could in principle be inside an allocator at once; nothing triggered it, but M8's user-driven allocations made it worth closing.
- 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 (since M8): granted as frame capabilities and mapped
with
map(see Memory and PCI below). 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 (until M7 unmasks lines for drivers); 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. - Bound notifications (since M7): a thread can bind one notification
to itself (
ntfn_bind, one per thread and per notification). If it is signalled while the thread is blocked inrecvorreply_recv, the call returnsIPC_NOTIFIEDwith the bits instead of a message; pending bits are taken before waiting senders, so clients can't starve a driver's interrupts. One server thread can therefore serve clients and its device at once, as in seL4. - 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, module and process (since M6), I/O-port range and IRQ line (since M7), frame and IRQ control (since M8). - 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.
Since M8 a factory also has a page budget for frames, and can be split:
factory_splitcarves a smaller factory out of a bigger one, and the budget goes back when the smaller one is freed. - 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.
- What the kernel hands over: the root task is the only process the
kernel creates by itself. It starts at priority 250 with a factory
(slot 1: 64 objects and 512 pages), every I/O port (slot 2), the IRQ
control capability (slot 3) and one read-only capability per boot
module (slots 4 and up, in load order). That is all the authority in the
system; everything else is derived from it (
include/abi/boot.h). - Manifest: a text file loaded as a boot module, one program per line:
name, image (a module), priority, the service it provides (or
-), the services it uses (comma-separated, or-), the devices it drives (or-; since M7), its quota asobjects/pages(or-; since M8), and an argument formain. The root task reads it withmodule_read. - What each program gets: a server gets a receive capability
(READ|GRANT) for its own endpoint in slot 1; a client gets a send
capability (WRITE|GRANT) for each service it uses, in slots 2, 3, ... in
the order listed, each with a badge unique to that client; a program
with a quota gets its own factory in slot 7; a driver gets a
notification in slot 8 and its devices' capabilities from slot 9.
Nothing else.
hellochecks every one of its 63 slots to prove it. A device can be given to only one program. Device capabilities are carved out of the root task's own:com1is ports 0x3f8-0x3ff plus IRQ 4;pciis ports 0xcf8-0xcff, ports 0x1000-0xffff and IRQ control. - Order: the root task first creates an endpoint per service, then the processes (handing out capabilities before any of them runs), then starts them all. Everything runs below the root task's priority, so nothing starts running until the root task blocks.
- Process objects:
proc_createreturns a process capability. WRITE lets the holder give the process capabilities (proc_give, which uses the same derivation rules ascap_copy, into a chosen slot) and start it; READ lets it wait for the exit code (proc_wait, or-ERR_DEADif the process was killed). A process can't be created above its creator's priority. If the last capability to a process that was never started is dropped, the process is destroyed. - The root task's own capability is unbadged: every client's cap is
badged (>= 1), so a request with badge 0 can only come from the root
task. Servers accept
PROTO_SHUTDOWNonly from badge 0. The root task shuts servers down in reverse order once every application has exited. - Console: see Devices (M7).
- Crash isolation:
selftest=app-crashrunscrash.manifest, in which one application crashes (since M7, by writing to the serial port it doesn't own). The root task sees-ERR_DEADfromproc_wait, the other application and the console carry on, and shutdown proceeds normally. - Known compromises:
- The kernel still loads ELF images (
proc_createtakes a module capability), because user space can't map memory into another address space yet. In seL4 the root task does this itself from untyped memory and frame capabilities; here that waits formap/unmap. - A process costs one unit of factory budget, the same as an endpoint, although it uses far more memory (page tables, stack, image). Object budgets count objects, not bytes; only frames are charged by the page.
- Servers are started by the root task, not restarted: supervision is M9.
- The kernel still loads ELF images (
- I/O ports: an I/O-port capability names a range of ports.
ioport_enablesets those bits in the process's permission bitmap; on every context switch the scheduler copies that bitmap into the TSS I/O permission bitmap, and the CPU checks everyin/outin ring 3 against it. A refused port is a #GP and the process is killed (selftest=app-crashdoes exactly that). The permission stays with the process for its lifetime: deleting the capability doesn't revoke it (revocation is M9). Since M8 the bitmap covers all 65536 ports (8 KiB, allocated only for processes that enable a port); each process records which bytes of it are in use, so a switch rewrites only those (one byte for COM1, 16 for a virtio disk) instead of 8 KiB.ioport_submakes a capability for part of a range, which is how the root task and the PCI server hand out exactly one device's ports. - Interrupt lines are claimed from the IRQ control capability
(
irq_get, since M8), once each: while any copy of the resulting IRQ capability exists, nobody else can claim that line. Lines 0 (the old PIT timer) and 2 (the PIC cascade) can't be claimed. - Interrupts: an IRQ capability names one line.
irq_bindconnects it to a notification and unmasks the line. When it fires, the kernel masks it, acknowledges the controller and signals the notification; it never touches the device. The driver handles the device, thenirq_ackunmasks the line. A line with no binding stays masked. - Interrupt controller: the legacy 8259 PIC, reached through the LAPIC's LINT0 in "virtual wire" mode (ExtINT). That's enough for the ISA lines (COM1 = IRQ 4) and for PCI devices' legacy INTx lines, which the firmware routes to PIC lines (the disk gets IRQ 11), and it needs no ACPI tables. The IOAPIC (found through ACPI's MADT) and MSI replace it later.
- Console server = serial driver: the root task gives it
com1(the ports 0x3f8-0x3ff and IRQ 4). It enables receive interrupts, binds its notification to itself and waits inrecvfor clients and interrupts alike. Output is written by polling the transmitter (a few microseconds per byte at 115200 baud; instant under QEMU): an interrupt-driven transmit queue only pays off for far more output than a console produces. Input lines are echoed as[console] input: .... - Naming: the root task registers a name for each badge
(
CONSOLE_SET_NAME, accepted from badge 0 only), so every line the console prints starts with[program], just like the kernel's debug output did. - Who can print:
debug_writeis now refused (-ERR_PERM) to every process the root task starts; they can only print through the console.hellochecks this. The root task and the kernel's self-test processes (started by the kernel itself) keep it. The root task uses it until the console is up and again after shutting it down, and prints through the console in between. - The kernel still writes its own log to COM1: boot messages, fault reports and panics must work with no user space at all. The kernel never reads the port or enables its interrupts. Kernel lines and console lines can interleave between lines (not within one in practice, since the console writes a line in one go at priority 200). Moving the kernel log to a second port (COM2) would separate them.
- Input typed before the kernel initializes the UART is lost (the initialization clears its buffers).
- Frames:
frame_allocmakes a frame of 1-64 physically contiguous, zeroed pages, charged to a factory's page budget.mapputs it at a page-aligned address in the caller's own address space (between 4 GiB and 0x7f00_0000_0000, so it can't collide with the program or the stack), writable only if the capability has WRITE, never executable.unmapundoes it. Each mapping holds a reference to the frame, so the memory lives until the last mapping and the last capability are gone. A process can have 16 mappings; they're undone when it dies, before its own pages are freed. A frame capability can be sent to another process, which maps the same memory: shared memory. - Physical addresses:
frame_infotells the holder where the frame is in physical memory, because a device doing DMA (direct memory access) needs that. Without an IOMMU (I/O memory management unit) a driver that can program DMA can reach any memory anyway, so it is trusted with the whole machine whatever the kernel does; an IOMMU is a non-goal for now. - Quotas: the manifest's quota column gives a program its own factory
(
factory_splitof the root task's), so a driver's DMA buffers and notifications come out of its own budget, not everyone's. - PCI server: owns configuration space (through the legacy port pair
0xcf8/0xcfc), the I/O range 0x1000-0xffff where firmware puts devices'
registers, and IRQ control. At start it scans all 256 buses and prints
every function. A driver finds its device (
PCI_FINDby vendor and device ID), claims it (PCI_CLAIM, one client per device; turns on I/O decoding and bus mastering), then gets each resource as a capability in a reply:PCI_BARreturns a capability for exactly that BAR's ports,PCI_IRQone for the device's interrupt line. A second client gets-ERR_EXIST/-ERR_PERM;blkchecks both, using a second badge. - The disk: QEMU attaches a 1 MiB raw image as a virtio-blk device in
transitional mode (
disable-legacy=off), so it offers the legacy virtio interface (registers in an I/O BAR) next to the modern one.blkclaims it, gets ports 0xc000-0xc07f and IRQ 11 (as routed by the firmware), enables the ports, and reads the capacity register: 2048 sectors. M8b adds the virtqueue and filesystem described below. - Known compromises:
- Configuration access uses the legacy ports, which reach the first 256 bytes of each function's configuration space. ECAM (the memory-mapped mechanism, 4 KiB per function) needs ACPI's MCFG table and device-memory mapping; nothing needs it yet.
- Only I/O-port BARs are handed out. Memory-mapped BARs (which modern virtio and most real devices use) need device-memory capabilities with uncached mappings.
- Legacy interrupts only (INTx, routed by the firmware to a PIC line). MSI needs the IOAPIC/LAPIC path and is deferred with it.
- Device reset and DMA-pin cleanup support legacy virtio block and network devices; automatic restart policy currently covers only the disk. Other devices keep their claims until the PCI server exits.
The block server negotiates no optional virtio features and sets up legacy
queue 0 in a contiguous frame from its page quota. The used ring starts
on a 4 KiB boundary. A three-descriptor chain contains a request header,
a 512-byte buffer, and device-written status. One request is outstanding
at a time; memory barriers order publication and completion. An IRQ wakes
wait on the driver's notification; reading the ISR deasserts INTx before
irq_ack unmasks the PIC line. The device is reset before normal shutdown
so it stops accessing the frame before process teardown.
A boot self-test reads the last sector, writes a pattern, reads it back,
and restores the original contents. Clients get a read-only block protocol:
24-byte slices of a sector, with capacity and bounds checks. No arbitrary
physical addresses or client pointers are accepted. See
include/abi/storage.h for both storage protocols. Queue layout follows the
OASIS virtio legacy transport specification.
scripts/mkdisk.py builds a 1 MiB image from user/disk/hello.txt.
Sector 0 holds a versioned magic, file size and bounded filename; file data
starts at sector 1, with the last sector reserved for the DMA test. The
filesystem validates this metadata and serves stat and byte reads, splitting
requests that cross sector boundaries. It has only console and block
capabilities. The cat process has only console and filesystem capabilities.
The root task stops the filesystem before the block server and PCI server.
This is deliberately a single-file, read-only filesystem with no directory or write API. The driver supports QEMU's legacy port interface, queues of 3..256 descriptors (power of two), and coherent x86 DMA. Read-only disks and hot unplug remain future work. Requests have one-second completion deadlines; a persistent device failure ends in explicit I/O errors. M9 adds pinned-buffer teardown during recovery.
- A "module" is a user-space program image (or a manifest) loaded by the bootloader. The root task starts the programs (see above).
- Supervision: the root task watches every child's exit through a notification. For a disk-driver fault it resets the device, restarts the process and re-hands its capabilities. The filesystem retries the call using its existing endpoint capability (details below).
- Interfaces: each server's message protocol is a versioned header in
include/abi/shared by server and clients (proto.hfor the common rules,console.h, ...). No IDL until hand-written protocols hurt. - PCI: a user-space PCI server enumerates devices and hands out per-device port and IRQ capabilities to drivers (since M8, see above).
proc_watch(proc, notification, bits) registers a notification before a
process is started. It needs READ on the process and WRITE on the
notification. The kernel holds a reference until teardown and signals after
address-space and capability cleanup, for both ordinary exits and faults.
proc_poll returns -ERR_AGAIN while running, -ERR_DEAD after a fault,
or the normal exit code. Notifications may coalesce; status stays readable.
A never-started process can still be discarded without producing an exit.
Root now waits on a shared child-exit notification, using one bit per
manifest entry. It polls signalled children, counts completed applications,
and allows up to three automatic restarts of a faulted block server or one
that exits with the designated I/O-failure/timeout code. Other unexpected
server exits are errors. The restarted driver gets the same service endpoint
and the same client badges, with a fresh process, quota and event notification.
This keeps queued calls reachable without redistributing endpoint capabilities.
The filesystem retries a transport -ERR_DEAD up to three times per read; it remains the same process.
DMA memory must outlive a faulting driver. Before enabling its virtqueue the
driver transfers a frame capability to the PCI server using PCI_PIN_DMA.
The PCI server keeps that frame alive even after process teardown. Only root
can call PCI_RECOVER, and does so after observing the old process's death.
For the supported legacy virtio block and network devices, recovery disables PCI bus
mastering, resets device status and confirms reset, masks/unbinds the IRQ,
then drops the pinned frame and releases the claim. The replacement can
claim the device again. PCI command writes use 16-bit accesses to avoid
writing back the adjacent write-one-to-clear status register.
selftest=driver-restart injects a null-pointer fault after submitting a
file-sector DMA request. The filesystem's in-flight call receives -ERR_DEAD,
root recovers the disk, and the retry queues until the replacement receives.
cat finishes the multi-sector file. Both the ordinary boot and restart
test verify that all frames return after shutdown, including pinned buffers
and the old IRQ notification. Permission tests cover process observation,
IRQ unbinding and supervisor-only device recovery.
Completion waits use wait_until(notification, absolute_tick). The timer
tracks these waiters on a separate list, so a timeout can remove a thread
from its notification FIFO without damaging either queue. Normal notification
delivery disarms the timer. Both paths run with interrupts disabled, so a
waiter wakes once. Pending bits take precedence over an expired deadline;
errors leave the user's output bits unchanged. clock() exposes monotonic
scheduler ticks at CLOCK_HZ (100 Hz), with a build-time frequency check.
Using one absolute request deadline, checked independently of pending
notification bits, prevents unrelated IRQs from extending it indefinitely. A timeout or invalid completion terminates the driver without
reusing the potentially active frame; PCI retains it until supervisor reset.
The restart budget applies across the whole boot, including failures on
separate reads. Once exhausted, root still resets the device and releases
its buffers, then receives on the stable block endpoint itself and replies
-ERR_IO. Its bound child notification interrupts receive so it can keep
supervising applications. This drains late or already queued retries without
starting another driver. Applications report failure and the remaining
servers shut down normally. Read retries are safe because the client-facing
block API has no writes; ambiguous writes must not be retried blindly.
Additional boot tests cover expired deadlines, real blocked expiration,
pending-bit precedence, consumption and capability checks. disk-timeout
suppresses a virtqueue kick to simulate a request with no completion.
driver-repeated injects fault → timeout → fault on consecutive instances,
then completes the file. driver-exhausted times out all four instances,
with the last failure delayed to the second file sector so a new retry has
no remaining restart budget. It verifies a terminal I/O reply, application
failure, clean server shutdown and reclamation of all frames. disk-boot-timeout
exercises a disk that never completes its startup probe: filesystem mounting
fails, but the filesystem stays available to return I/O errors and accept
shutdown, so the client can fail cleanly rather than queue indefinitely.
disk-noisy-timeout injects a notification on every wait iteration to prove
pending bits cannot postpone the request deadline. Fault injection
does not emulate every possible hardware or interrupt-controller failure.
Stall injection also ignores a racing completion from queue activation;
otherwise the supposedly stalled startup read could reach a scratch write
before timing out. The write self-test reads back its restored sector, and
the host checks the whole disk image hash after each boot case.
Limits: this is bounded restart of this disk driver, not general capability revocation or arbitrary server supervision. PCI and the supervisor are trusted; recovery must not run while the old driver is alive. No IOMMU or protection against a malicious bus-mastering driver is provided. The CPU watchdog below covers a driver spinning without syscalls. A driver blocked in unrelated IPC or kernel code still needs cancellation/watchdog mechanisms beyond the disk-completion deadline.
The ping milestone uses net.manifest: console, PCI and one isolated net
process containing the driver and minimal IP stack. Storage uses its original
manifest. All packet processing runs in ring 3; the kernel has no networking
code or new networking syscalls. The PCI server grants the NIC's BAR and IRQ
only after claim, and pins the driver's single DMA frame until device reset,
just as for the disk. The supervised network manifests use the same bounded device-reset/restart
policy as the disk; finite ping-only runs retain their original lifecycle.
The legacy virtio-net interface has two queues, RX (0) and TX (1). Only the MAC feature is negotiated; checksumming is done in software and no GSO or merged buffers are accepted. Each descriptor chain separates the 10-byte legacy network header from the Ethernet payload, as required without ANY_LAYOUT. Sixteen RX chains are posted and recycled after processing; TX permits one outstanding chain and waits up to one second for completion. The finite demo has a thirty-second overall deadline; persistent service mode has no demo deadline. Ten-tick notification waits also drive TCP timers. Queue memory and all packet buffers occupy one contiguous, zeroed frame within the driver's 32-page quota. The used indices, IDs and lengths are checked before accessing buffers. Both normal and failed exits reset the NIC before teardown; PCI retains the frame through its own reset and IRQ unbind at shutdown.
The stack in user/net/stack.c parses bytes without packed-structure or
alignment assumptions. It implements Ethernet, ARP request/reply and IPv4
ICMP echo request/reply at static IP 10.0.2.15, with MTU 1500. It validates
Ethernet destinations, ARP layout and sender addresses, IP and ICMP checksums,
packet lengths, TTL, protocol, fragment flags and destination IP. Echo replies
preserve identifier, sequence and payload and recalculate checksums; minimum
Ethernet frames are zero padded. Unsupported or malformed frames are dropped.
UDP and TCP echo services were added after the ping milestone (see below).
DHCP, DNS, IPv6, IP options, fragmentation/reassembly and routing remain
unimplemented. Driver and stack currently share one process.
make net-demo builds a network-test ISO and runs scripts/net-test.py.
Python and QEMU share a local stream socket pair, carrying length-prefixed
Ethernet frames; no Internet, TAP setup or raw-socket privileges are needed.
The host sends ARP, validates the exact reply, rejects malformed frames,
exercises UDP and TCP (including lost replies and connection exhaustion),
and then checks 260 ICMP replies byte for byte. Payloads
include zero bytes, odd lengths and the MTU limit. The run reuses all RX
buffers and crosses the 256-entry ring positions. The guest exits after
260 echo replies; root shuts down PCI and console, and the kernel checks
that every frame was reclaimed. make test runs this after the existing
21 boot regressions and sanitizer tests, followed by native socket tests.
Logs are in build/net-demo.log and build/net-sockets.log. This peer is a small
host ping implementation, not the host OS's ping command.
The driver layout follows the OASIS virtio legacy network specification. The host link uses QEMU's socket network backend. A TAP backend could connect the same packets to a host network, but this demo does not configure one. The legacy PIC path cannot share an IRQ between independent drivers, so simultaneous disk/NIC use needs distinct interrupt lines or future interrupt-routing support.
make net-serve selects selftest=net-serve, uses serve.manifest and starts
a persistent supervised network server. It uses the same static IPv4
address, with QEMU user networking forwarding host loopback TCP 2222 and
UDP 2223 to guest port 7. The finite net-demo mode still exits after its
ping count to verify teardown and DMA frame reclamation.
UDP validates the pseudo-header checksum, datagram length and port, accepts the IPv4 zero-checksum convention, and generates a nonzero response checksum. The echo preserves complete datagrams, including empty and MTU-sized ones. Unsupported UDP ports are dropped. This follows RFC 768.
TCP in user/net/tcp.c implements a passive echo listener: SYN/SYN-ACK/ACK,
stream echo, FIN half-close and reset processing. A fixed four-entry table
bounds allocation under connection floods; each entry caches at most one
unacknowledged segment. MSS negotiation and the peer's advertised window
bound each echo. The receive window closes while the echo is unacknowledged,
then a window update allows more data. Sequence comparisons are modular;
duplicates are trimmed and out-of-order bytes are acknowledged without
being queued. Invalid checksums, header/options lengths, ACKs beyond sent
sequence space and inexact RSTs cannot advance/destroy connection state.
Closed TCP ports return reset segments.
Timer polling retransmits SYN-ACK, echo data and FIN, with exponential backoff, a bounded retry count and thirty-second idle cleanup. Connections that stall are reset and their slots reclaimed. Peer FIN may be held while the last echo is acknowledged. These mechanisms follow the relevant packet and sequence rules in RFC 9293, but this deliberately small implementation is not a complete conforming TCP: there is no active open, POSIX socket API, receive reassembly queue, SACK, timestamps, window scaling, urgent-data support, dynamic RTT estimator, persist probing, or full congestion control. One outstanding segment per connection limits throughput; this is intended for the local functional services, not public Internet exposure. Initial sequence numbers are not cryptographically unpredictable.
make net-sockets-test uses the host OS's real TCP/UDP sockets through QEMU
user networking, checks binary streams of more than 32 KiB over three
concurrent sessions, performs half-closes, and verifies continued service
past the old ten-second demo lifetime. The packet-level harness separately
checks loss/retransmission of SYN-ACK, data and FIN, duplicates, out-of-order
segments, sequence wrap, invalid ACK/RST behavior and table exhaustion.
make net-stack-test uses ASan, UBSan and LeakSanitizer to exercise exact
truncated allocation boundaries and 20,000 deterministic mutations, plus
virtual-clock retransmission/idle expiration. The finite QEMU demo verifies
full frame reclamation; the native persistent-service test ends by stopping
QEMU after functional checks rather than claiming guest teardown.
A supervisor may call proc_watchdog(proc, ticks) before start, with WRITE
on the process, to bound continuous userspace execution between syscalls.
Zero disables it; the maximum is sixty seconds. The default manifests arm
a two-second watchdog on disk and network driver processes. Syscall entry
checks the budget, pauses it during kernel execution/blocking, and starts
a fresh budget on return to user mode. The budget uses the thread's own
scheduled tick count, so preemption by another process cannot consume it.
Timer interrupts from ring 3 check
the same deadline and kill an expired process through ordinary process
teardown. A preempted kernel stack is never discarded midway through IPC
or allocation. selftest=driver-watchdog spins after disk DMA submission;
root observes the kill, resets the device, releases the pinned frame,
restarts the driver and completes the filesystem read without leaks.
This CPU watchdog does not time blocked IPC or kernel execution, and a process making frequent syscalls can satisfy it without useful work. Timed IPC, safe process stopping and network supervision are described next. SMP, shared IRQ routing and recovery from a kernel deadlock remain future work.
include/abi/net.h specifies version 1 of the guest API, implemented by
user/net/sockets.c. Eight socket slots, four per endpoint badge, bound
memory consumption. UDP binds a local port, receives datagrams in 16-byte
chunks (reply word 1 reports bytes remaining in the datagram), and replies
to the last accepted peer. UDP application queues are bounded to 256 bytes;
oversize datagrams and datagrams arriving while that queue is occupied are
dropped. Port-7 built-in UDP echo still supports MTU-sized datagrams.
There is no active UDP peer discovery/ARP resolution API yet.
TCP sockets support bind, listen, accept, receive, queued send and close.
Accepted streams have 256-byte receive/transmit queues; TCP advertises their
available receive space and sends window updates after application reads.
The existing sequence, checksum and retransmission engine handles packets.
Closing after peer EOF flushes queued data and completes passive FIN close;
closing before EOF aborts the connection with reset. A failed/reset connection
reports I/O error after buffered data is consumed. Unaccepted connections
are reclaimed on reset. This is a passive socket API, not active connect or
POSIX compatibility. Operations return -ERR_AGAIN rather than parking the
single network server; clients retry within their own deadlines.
Handles include an incarnation, slot counter and slot index. Every request
also checks the kernel-stamped client badge. A stale-incarnation handle gets
-ERR_STALE; a closed/reused slot gets -ERR_BADCAP; another badge gets
-ERR_PERM. Slot counters never wrap within an incarnation. Application
socket state is lost on server restart, so clients query NET_INFO, reopen,
rebind and reaccept connections. Kernel endpoint capabilities remain stable.
The server multiplexes timed endpoint receive, bound IRQ notifications, TCP
timers and application transmit queues on one thread.
sockets.manifest starts a separate netsock application using only console
and badged network endpoints. guest-sockets-test validates host UDP and a
1025-byte TCP stream through that application's IPC reads/writes, plus badge
isolation, clean server shutdown and complete frame reclamation. The restart
variant deliberately blocks the network server in a private notification
wait. The application times out its call and uses a test-only explicitly
granted process capability to request stop; ordinary socket clients do not
receive this authority. Root waits for teardown, resets the NIC and unbinds
its IRQ, revokes the old per-process grants, and starts a fresh incarnation
with the same endpoint and badges. The application verifies stale-handle
rejection and serves new UDP/TCP connections. Existing TCP connections must
be reestablished; transparent stream replay is not promised.
serve.manifest supplies the persistent supervised daemon used by
make net-serve. Root continues watching even without an application to
wait for. Faults or designated I/O failures allow up to three restarts; after
exhaustion the NIC remains reset and root answers socket requests with I/O
errors. The ping-only manifest still supports its finite acceptance test.
call_until(ep, words, deadline) bounds both endpoint rendezvous and reply
wait using one absolute scheduler-tick deadline. This variant sends no
capability (a reply may still transfer one). recv_until bounds receive;
the previous notification deadline API is unchanged. Callers and receivers
hold authority references on an active-operation list. Timer expiration,
revocation and supervisor stop remove the exact thread from its object
queue, discard any undelivered transferred capability, disarm its timeout
and sever both ends of the implicit reply relationship before wakeup.
A late server reply therefore cannot reach a reused/freed thread. Timeout
does not roll back work already delivered to the server: applications must
not blindly retry non-idempotent writes.
proc_stop requires WRITE on a live process capability. A target blocked
in IPC is canceled, then killed on its syscall return path. A runnable target
is stopped at its next user boundary. The kernel never discards a preempted
kernel stack. Stopping an unstarted process is invalid (delete its handle
instead); stopping an exited process reports DEAD. A process blocked in a
kernel wait not represented by IPC currently reports BUSY. This is not a
mechanism to interrupt a kernel deadlock. Privileged supervisors can use a
client's deadline failure as a reason to stop and restart a server; a general
automatic wall-clock lease policy is not implemented.
Capabilities now reference bounded derivation nodes. Copy, process give and
IPC transfer preserve ancestry; I/O subranges descend from the parent port
capability. GRANT permits cap_revoke, which advances that node's generation:
all old descendants, including copies in another process or a queued
message, become invalid; the caller and unrelated sibling authorities remain
valid. New derivation from the retained caller capability is allowed. Lookup
checks the generation chain, and revoked slots remain occupied/deletable,
with metadata and object references retained until owners delete them or
exit. Deleting an ancestor does not erase revocation history. This is logical
revocation, not automatic reference reclamation. New objects allocated from
factories are independent roots; revoking a factory copy does not revoke
previously allocated objects.
The kernel limits ancestry to depth 32 and all live derivation metadata to 4096 nodes. Allocation failure is explicit; there is no unbounded history chain. Revocation refuses live descendant resource uses with BUSY before changing generations: mappings, enabled port grants, notification bindings and exit watches are retained as resource authority references. IRQ guards are conservative while a line is bound. No successful revocation silently leaves a descendant CPU mapping or I/O bitmap enabled. This design does not forcibly unmap memory or reset bus-mastering devices; stop/unmap/unbind and reset must precede device-grant retirement. DMA pinning and trusted device supervision remain necessary without an IOMMU.
Root retains a private branch for each service grant it delegates to a process, plus the process's receive and quota grants. Recovery revokes and deletes those old branches after process/device teardown, without revoking the stable service endpoint or other clients. Tests verify permission checks, ancestry and sibling invalidation, deleted-ancestor history, frame/device BUSY guards, timed queue removal and cross-process blocked-call cancellation. Existing boot and leak checks cover metadata reclamation. Sanitizer tests also cover socket quota, ownership, incarnation and closed-handle behavior.
Next priorities are active UDP/TCP connect, a supervisor request API for client-reported outages, timed cancellation of non-IPC kernel waits, shared interrupt routing, and stronger resource reclamation for hostile capability holders. SMP and broader device recovery remain outside this increment.
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; refused to processes the root task starts |
| 1 | exit(code) |
|
| 2 | yield() |
|
| 3 | send(ep, w0..w3, cap) |
needs WRITE (GRANT to send a cap) |
| 4 | recv(ep) -> msg |
needs READ; IPC_NOTIFIED + bits if the bound notification fired |
| 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 |
| 13 | module_info(mod, name_buf, len) -> size |
needs READ; copies the name out |
| 14 | module_read(mod, offset, buf, len) -> bytes |
needs READ |
| 15 | proc_create(factory, mod, prio, name, len) -> slot |
ELF from a module; prio <= caller's; uses budget |
| 16 | proc_give(proc, slot, dst_slot, rights, badge) |
needs WRITE; cap_copy rules, into another table |
| 17 | proc_start(proc, arg) |
needs WRITE; once |
| 18 | proc_wait(proc) -> code |
needs READ; -ERR_DEAD if killed |
| 19 | ioport_enable(ioport) |
needs WRITE; lasts for the process's lifetime |
| 20 | irq_bind(irq, ntfn, bits) |
needs WRITE on both; unmasks the line |
| 21 | irq_ack(irq) |
needs WRITE; unmasks the line again |
| 22 | ntfn_bind(ntfn) |
needs READ; recv can now return IPC_NOTIFIED |
| 23 | frame_alloc(factory, pages) -> slot |
1-64 contiguous zeroed pages; uses page budget |
| 24 | frame_info(frame) -> phys |
needs READ; pages in rdi |
| 25 | map(frame, vaddr, writable) |
needs READ (and WRITE if writable); own address space |
| 26 | unmap(vaddr) |
undoes the map that started at vaddr |
| 27 | factory_split(factory, objects, pages) -> slot |
a quota carved out of a factory |
| 28 | ioport_sub(factory, ioport, base, count) -> slot |
part of a port range |
| 29 | irq_get(factory, irq_control, line) -> slot |
each line claimed once |
| 30 | proc_watch(proc, ntfn, bits) |
register exit notification before start; READ process, WRITE notification |
| 31 | proc_poll(proc) -> code |
nonblocking status; -ERR_AGAIN while running, -ERR_DEAD after a fault |
| 32 | irq_unbind(irq) |
WRITE; mask and release the old notification |
| 33 | wait_until(ntfn, absolute_tick) |
notification wait with deadline; -ERR_TIMEDOUT on expiration |
| 34 | clock() -> ticks |
monotonic clock, CLOCK_HZ ticks per second |
| 35 | proc_watchdog(proc, ticks) |
WRITE; configure continuous user run budget before start, 0 disables |
| 36 | call_until(ep, w0..w3, absolute_tick) |
timed rendezvous and reply, no outgoing cap |
| 37 | recv_until(ep, absolute_tick) |
receive deadline; bound notifications still interrupt |
| 38 | proc_stop(proc) |
WRITE; cancel blocked IPC and stop at a safe boundary |
| 39 | cap_revoke(cap) |
GRANT; invalidate descendants, BUSY for live resource uses |
| later | thread_create |
future work |
| 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 | the root task starts a console server and a client from system.manifest, each holding only the capabilities its line grants; one crashing application leaves the rest running ✅ |
| M7 | User-space serial console driver (IRQ via notification) | the console server drives COM1 with its own I/O ports and IRQ 4; programs the root task starts can only print through it; a line typed on the serial port reaches it by interrupt; a program touching the port without permission is killed ✅ |
| M8a | Memory capabilities + PCI server | a driver gets a frame from its own quota and maps it; the PCI server hands it exactly its disk's ports and IRQ; it reads the disk's size from the device ✅ |
| M8b | virtio-blk driver + tiny FS server | cat prints hello.txt from disk; DMA read/write, IRQ completion and leak checks pass ✅ |
| M9 | Fault supervision + restart | disk driver faults after DMA submission; root resets and restarts it, filesystem retries, cat finishes, no frames leak ✅ |
| M10 | virtio-net + minimal user-space IP stack (ping subset) | host resolves ARP and validates 260 guest echo replies, malformed frames are dropped, and no frames leak ✅; UDP/TCP echo transports subsequently added (see limitations above) |
#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.