Architecture · EL1 / EL0

The shape of the system

Most operating systems put the filesystem, the drivers, and the scheduler together in one privileged blob. Ouroboros draws a hard line instead — and pushes everything it can across it. This is that line, and what lives on each side of it.

2Exception levels
11Task slots
4Servers in userland
0Shared memory

Privilege is the organizing idea

The ARM processor runs code at exception levels. Ouroboros uses two: EL1 for the kernel, EL0 for everything else. EL0 code cannot touch kernel memory, device registers, or privileged system registers. The only way from EL0 into EL1 is a trap — a system call (svc), a fault, or the timer interrupt — caught by the exception vector table. That single crossing is where the whole design lives. Firmware decides which level the kernel is entered at: QEMU and Parallels hand off at EL1, the Raspberry Pi at EL2, and there the kernel drops to EL1 before it relies on any EL1 register, with its tables and vectors prepared from EL2 and an eret into a running MMU.

EL0 Userland unprivileged · isolated tasks
Shell task 0 Idle task 1 ●Filesystem server task 2 Program task 3 Program task 4
↑↓  svc / fault / IRQ  →  exception vectors (VBAR_EL1)
EL1 Kernel privileged · the trusted core
Exception vectors Scheduler Syscall dispatch Per-task MMU IPC · grant/safecopy Timer + GIC Console drivers Block device custody USB (xHCI) · virtio
FIG 1  The privilege stack. Amber marks the boundary; every crossing in this document is a trap through it.

Inside the kernel

The kernel is deliberately small. It owns the things that require privilege — trap handling, memory mapping, the interrupt controller, raw devices — and the mechanisms tasks use to talk to each other. It does not contain policy it can push outward.

Traps

Exception vectors

A 16-entry VBAR_EL1 table catches every syscall, fault, and interrupt. Kernel faults report and halt; EL0 faults are contained — only the faulting task dies.

Scheduling

Round-robin scheduler

Preemptive, no priorities. A 20 ms timer tick switches tasks; a blocking syscall switches immediately. States: unused, runnable, blocked, zombie.

Boundary

Syscall dispatch

Number in x8, arguments in x0–x4, return in x0. A shared syscall-abi crate keeps both sides from drifting.

Memory

Per-task page tables

Each task has its own L0–L3 view. Kernel and device mappings are shared; EL0 access is granted to that task's region only. Switching tasks switches TTBR0, and the slot it switches to is a TaskIndex: a value below NUM_TASKS by construction, so a slot with no view cannot be spelled.

Messaging

IPC · grant/safecopy

Fixed-size messages copied task-to-task through bounded mailboxes, plus a capability for moving bulk data directly between two isolated regions.

Time

Timer + interrupts

The ARM generic timer drives preemption; the GIC (v2 or v3, discovered from ACPI's MADT) routes its interrupt. Real IRQ delivery on QEMU and Parallels.

Console

Console: discovery + emergency

Five discovery paths tried in order (a GOP framebuffer wins on Apple Silicon), a minimal emergency console for boot and fault reporting, and dumb framebuffer pixel primitives. The rendering itself moved out — see below.

Devices

Raw hardware

A from-scratch xHCI USB stack (keyboard and mass storage) and a virtio transport with block, network, console and entropy drivers — the parts that genuinely touch MMIO and DMA.

Storage

Block-device custody

The kernel holds the raw disk and hands out one 512-byte sector at a time — but only to the filesystem server. Any other caller is denied.

Not here

The filesystem → userland

FAT32, exFAT and ext2 parsing, partition tables, directories, paths, permissions — none of it lives in the kernel. It moved out to a task (fsd), reached only over IPC.

Not here

The console → userland

Font, cursor, line wrap, scrolling, ANSI parsing — the console rendering moved out to a task (cond) too, driving the kernel's dumb pixel primitives over gated syscalls.

Not here

The network → userland

ARP, IPv4, ICMP, UDP, DNS and TCP are a userland task (netd) — the whole protocol stack, plus an HTTP server and the cluster's 9P gateway. The kernel keeps only the DMA-owning NIC driver, gated to that one task.

Not here

Accounts → userland

Password changes go to a task (accountd) rather than a setuid binary — which could not work here, since the loader is an ordinary program and "this is setuid" would be a claim by a task the capability model distrusts.

FIG 2  Kernel responsibilities. The dashed cards are what was deliberately removed from the kernel.

The tasks

Eleven scheduler slots. The first six have fixed roles — the shell, idle, and the four servers; the remaining five are filled at runtime, when the shell runs a command or builds a pipeline. Each is an ordinary EL0 program — the shell is not special kernel code, it is just the program loaded into slot 0, and neither is any server.

SlotTaskResponsibilityStatus
0 Shell The interactive program loaded from disk at boot. Owns the keyboard. Every disk command is an IPC request to the filesystem server. own view
1 Idle A bare busy-spin — runs when nothing else can. (A real spin, not wfe: EL0 wfe hangs under the Apple hypervisor.) protected
2 FS server Owns FAT32, exFAT, ext2, a synthetic /proc, partition discovery and permission enforcement. The only task allowed the block-device syscalls. Boot-loaded and supervised — the kernel restarts it from a kept image if it crashes or wedges. protected
3 Console server Owns the steady-state console. Userland output flows to it over IPC; it renders glyphs and a cursor to the framebuffer (or forwards to a UART). Boot-loaded, protected, and supervised like the FS server: restarted if it crashes or wedges (confirmed live on real Parallels). protected
4 Network server The entire protocol stack in userland — ARP, IPv4, ICMP, UDP, DNS, TCP — plus an HTTP server, the cluster's 9P export gateway, and remote execution. The only task allowed the NIC syscalls; the DMA-owning driver stays in the kernel because there is no IOMMU. protected
5 Account server Changes passwords for callers who may not write /etc/shadow themselves. It holds no device capability at all — its privilege is policy, and it authorizes on the sender credential the kernel bound when the message was sent, never on who occupies a slot now. protected
6–10 Spawnable Five slots filled by running a command — a /bin program, a background job, or the stages of a pipeline running concurrently. Own memory, own view, reaped when they exit. Job control (fg, kill, wait) and IPC let them cooperate. runtime
FIG 3  The task table. Slots 0–5 are protected from exit/kill; 6–10 cycle as programs come and go. The guard derives from one constant, so a new server is protected the moment it is added.

How the tasks are kept apart

Isolation here is enforced by the hardware MMU, not by good behavior. Three mechanisms compose: separate address-space views, message passing by copy, and a capability for the cases where copying through a message is too small.

01 · separation

Per-task page tables

Every task runs under its own translation view. From inside any task, another's memory is simply not mapped for EL0 — touching it faults, and the fault kills only the toucher. Proven by injection: the same read succeeds under a shared map and faults under views. The same mechanism guards a task from itself: a page mapped EL1-only sits between its heap and its stack, so a stack overflow faults cleanly instead of quietly corrupting what is below it — it has caught real overflows three times.

02 · communication

Messages by copy

Tasks never share memory. A message is copied — out of the sender, into the kernel, into the destination's mailbox. Small, bounded, safe by construction: no pointer from one task is ever dereferenced by another. And a task may only message the endpoints its per-slot capability mask allows — a spawned program can reach the servers it legitimately needs and the shell, not the device drivers' hardware and not arbitrary tasks — so isolation is topological, not just memory-level. The mask is a baseline: a task holding a capability may hand it onward at runtime, which is how the shell wires one program's output straight into the next, and how any command gets to speak to the network server at all.

03 · bulk data

Grant & safecopy

To move file contents, a client grants the server an exact buffer, in one direction. The server may copy only those bytes, only while the client's call is in flight, never a third task's — a capability the kernel checks, not a trust it assumes.

EL0 Shell · task 0
its own region — rw
fsd region
cond region
netd region
kernel region
kernel copies
at EL1
the only path
between them
EL0 Filesystem · task 2
its own region — rw
shell region
kernel region
FIG 4  Two tasks. Each sees only its own memory; the kernel is the single trusted courier between them.

One file read, end to end

Because the filesystem is a separate process, a plain cat crosses the privilege boundary several times. This is the cost the microkernel split pays — and, right beside it, exactly what it buys.

EL0 Userland tasks
Kernel EL1
01 · shell
You type cat /notes.txt. The shell grants the server a read buffer, then calls it.
svc · msg_call
02 · kernel
Copies the request into the server's mailbox, blocks the shell, switches to task 2.
03 · fs server
Walks FAT32 to find the file. It needs a disk sector, so it asks for one.
svc · block_read
04 · kernel
Only task 2 may. The virtio / USB driver reads the 512-byte sector from hardware and returns it.
05 · fs server
Has the file's bytes. It safecopies them into the shell's granted buffer, then replies.
svc · safecopy
06 · kernel
Delivers the reply, wakes the shell, switches back to task 0.
07 · shell
Prints the file. For a large file, steps 3–6 simply repeat, one chunk at a time.
What it costs: several boundary crossings per read, where a monolithic kernel would have none. What it buys: a filesystem bug can corrupt only its own memory and its own mount — it cannot scribble on the shell, and if it crashes or wedges, the kernel restarts it from a kept image (the same supervision covers the console server). The disk is never reachable except through the one supervised task.
FIG 5  cat as a sequence of traps. Light cards are EL0; dark panels are the kernel.
The payoff

An EL0 fault is contained, not fatal. A wild pointer in any userland program used to halt the whole system. Now the kernel reports the fault, tears down just that task, and resumes the rest. If the dead task is the filesystem server, it is reloaded from an image kept at boot and remounts from disk — because all of its state was always derivable from the disk. That is the whole reason the filesystem was worth moving out.

From firmware to EL0

There is no separate bootloader — the kernel is a UEFI application, the only boot path that works on both the QEMU dev loop and the real Parallels target. It sets itself up while still inside firmware, then leaves that world for good.

01
UEFI init
Logging, allocator, protocols — all still firmware-backed.
02
Find console
Devicetree → ACPI → PCI → framebuffer.
03
Load programs
The shell and the four servers, off the ESP; then the boot identity, a persisted per-boot counter and firmware entropy that session keys are made from.
04
Exit boot services
Firmware left behind, permanently.
05
Install vectors
VBAR_EL1, before anything can fault.
06
Own the MMU
Swap in the kernel's own page tables.
07
GIC + timer
Arm the 20 ms preemption tick.
08
Build tasks
Initial context for shell, idle, servers.
09
Drop to EL0
tasks::start — and never returns.
FIG 6  The boot sequence. After step 9, every return to the kernel is a trap through the vector table.

Go deeper

This page is the overview. The reference documents in the repository go all the way down: architecture.md for the full mechanics, processes.md for the userland-program model, and the postmortems for the debugging that found this design on real hardware.