Aperture Institute GLaDOS: an operating system in Rust, with a language model in the kernel
GLaDOS / Wiki / The kernel

The kernel

A UEFI application that becomes the operating system, running in ring 0 with one address space and nothing above it. This page covers the boot path, what removing the privilege boundary actually buys and costs, what Rust gives and takes away at this level, how the other cores are used, and the interpreter that reads the firmware's own bytecode.

The UEFI application is the kernel

Firmware already delivers long mode, privilege level 0 and an identity map before it hands over. Everything a conventional bootloader exists to arrange is therefore already arranged, so this project does not have one. There is no ELF loading, no relocation, no handoff ABI, and no second binary. The UEFI application is the kernel.

What that deletes is most of a subsystem. What it adds is one constraint that shapes the whole of start-up: the firmware's services, including every filesystem it knows how to read, exist only until ExitBootServices, and that door is one-way. So the model, the tokenizer and the root certificate bundle are all read into memory before it is called, because afterwards there is no filesystem to read them from and no way back.

A memory-map trap worth knowing. Do not take the maximum over every descriptor the firmware hands you. OVMF describes reserved space out to a terabyte, and using that as the limit for the identity map exceeds what one page-directory-pointer table can address. The map then fails silently and the machine keeps running on the firmware's own tables, which map page zero. The null-dereference self-test passed for a while without faulting, which is the most alarming way for a bug to announce itself: by a test succeeding.

After that line the kernel is on its own page tables, its own interrupt table, its own timer, its own heap, and a preemptive scheduler at 100 Hz.

Ring 0, and what removing the boundary means

There is no user/kernel split, no syscalls, no process isolation and one address space. A tool call from the model is a function call, and it costs what any other function call costs.

That is the point of the project. The machinery an AI agent normally needs (marshalling arguments across a boundary, a wire format, a dispatcher, a process to be sandboxed in) exists to cross a boundary that has been removed. What replaces isolation is capability: everything above the kernel is written in a language whose builtins are gated by an allowlist, so what a program may do is a property of the interpreter it was handed.

The cost is stated plainly because it cannot be mitigated. Every interrupt vector but the breakpoint is fatal. A bad pointer halts the machine. There is no process to kill. There is no recovery from a fault and no security boundary of any kind, and the threat model is the word "bugs".

A fault that said nothing. For a long time no fault this kernel took produced a readable report. Printing writes the console first and the serial port second, and painting from inside an interrupt gate takes a general protection fault here, so the first line of every report died in the console before serial was reached, and what a person saw was a machine that simply went quiet. Reports are emitted twice now, whole, serial before console, because serial is a port write that cannot block or fault, and on the target laptop there is no serial port at all and the framebuffer is the only diagnostic there is. A flag makes a fault while reporting print one line and halt, which it did, as an unbroken column of the same exception.

That console fault inside an interrupt gate is a real bug and is not fixed. It belongs to the console, it predates all of this, and it is now visible.

What Rust gives, and four traps that cost real time

Without the standard library there is no allocator until one is built, no threads, no files, no formatting that allocates, and no floating-point helpers. What ownership buys at this level is narrower than the usual pitch and still worth having: a driver that hands a buffer to hardware and then reads it back is a lifetime question the compiler can answer, and the aliasing rules that make a data race a compile error are exactly the rules that make a DMA buffer a compile error when it is used after being surrendered.

Four things cost real time, and each is the kind of mistake that produces working-looking code:

  • extern "C" on this target means Microsoft x64. The context switch is pinned to extern "sysv64" explicitly for that reason. A stub taking no arguments passes under either convention, so only a stub that reads an argument tells them apart.
  • A debug_assert is only checked in debug builds, and this tree is driven in release. One asserted a relationship between two dimensions using the wrong one of them, so every debug build attaching a particular adapter site would have panicked on a claim about the wrong number. It never fired, because nothing runs debug under emulation.
  • A guarded match arm placed after the arms it guards is unreachable. The compiler said so in a warning nobody read, for several commits. Anything added to the shell's dispatch with a guard goes before the bare arm.
  • A feature gate must test the feature the code needs. The AVX2 kernel was gated on AVX being enabled and FMA being present, and never on AVX2 itself.

The heap is a ladder. It is one physically contiguous allocation, the target laptop cannot be tested from the development machine, and a fixed size that a given memory map cannot satisfy is an unbootable system. So boot asks for the largest rung, comes down when the map refuses, prints the size it got, and says when it had to descend.

One core's assumptions, and the two that were retired

Interior mutability here is not a lock. It is a single-core assumption with a name, and that name is the designated search target for the day general multiprocessing arrives. A real spinlock exists alongside it, and every conversion from one to the other is a claim that a second core actually reaches that state. The claims are made one at a time and each is verified, because converting all of them at once produces a kernel where nothing is known to be right. Two have been made, taking the count from 93 to 92: the heap and the console.

The interrupt-safe variant is not optional on either. A lock taken by ordinary code and also by an interrupt handler on the same core deadlocks against itself, and both of those are in that position. Allocation can happen under an interrupt, and the clock task prints from a timer tick. A plain lock there is a hang that appears under load and never in a test.

Nothing about the holder is recorded, deliberately: naming a core means reading the local interrupt controller over memory-mapped I/O, which costs more than the lock it would describe, and the allocator takes one on every allocation. A spin that reaches its patience limit panics with the waiter and the lock address, which is what made converting the console safe to attempt, since a paint path that printed would take the lock twice and say so on the first line of boot.

The evidence for the heap is 64 rounds of 4,096 allocations across the cores, each writing a per-chunk pattern through its whole block and reading it back. A heap that handed one block to two cores fails the read-back, one whose free list corrupted fails a later request, and one that lost a block fails the closing check that the heap is exactly where it started.

The other cores

Every application processor the firmware declares is started, walked up to long mode through a trampoline at a fixed low address, and parked. This began as a compute fabric, and it is moving. The extra cores can allocate and print, because those two structures are behind real locks. They still never take an interrupt and never run a task, and the reason is specific: an application processor runs on the trampoline's flat descriptor table with no task-state segment, so its code selector does not match the one the interrupt table's entries name. Preempting a task there needs a per-core descriptor table and TSS, and one TSS cannot be shared. Running tasks cooperatively without a timer needs neither, and is the shorter road if it is wanted.

The whole interface is one call that splits a range across the helpers, and it answers false, meaning do it yourself, when there are no helpers, when another job is in flight, or when the work is too small to be worth it. So it is always an optimisation, and every caller keeps a serial path that still works.

Two things there are easy to get wrong and both have been paid for. The slots are reused by every job, so a worker that caches the chunk count and then has the cursor reset underneath it will claim an index valid for the next job and out of range for the cached one, break out without counting that chunk, and leave the next job's tally never completing. One job cannot reproduce this; the self-test runs 64 back to back. And splitting changes no arithmetic, so the check is exact equality. A forward row and a backward column are computed over the same values in the same order whichever core does them, so any difference at all is an index bug and a tolerance would hide precisely that.

Measuring this under emulation does not work, and the numbers say so: one core reads 4,570 MB/s alone and 3,526 MB/s with seven cores merely idling beside it, so both halves of any comparison are contaminated by the host.

One scheduler rule is load-bearing and worth stating. Yielding disables interrupts across the context switch, because the scheduler stores the current task and then switches stacks; a timer tick landing between those two saves the outgoing stack pointer into the wrong slot and one task becomes unresumable. The interrupt path is safe because a gate clears the flag for it.

Reading the firmware's own bytecode

Battery state on a laptop lives behind AML, the bytecode ACPI firmware ships in its DSDT, and there is no register to poke instead. So the kernel contains an interpreter for it. Two cheaper routes were declined, and the reason was testability: hardcoded embedded-controller offsets are three hundred lines and work on one laptop, and the emulator models no such controller, so that approach would have shipped in the state the wireless driver is in : written, plausible, unproven.

The DSDT is the one table the root list does not point at. It hangs off the fixed description table at offset 40, or offset 140 on a machine whose tables sit above 4 GiB, which is why walking the root list had never found it and this kernel had never printed its address.

The parser must be exact and the evaluator may be partial. Those are opposite obligations and separating them is what turned an open-ended job into a bounded one. AML carries package lengths inside itself, so one misread length does not lose one object. It desynchronises everything after it, and a parser that is ninety per cent right produces a complete-looking namespace full of names the firmware never wrote. The only acceptable result is consuming the table to its last byte, and that is asserted. The evaluator, by contrast, runs only what a caller names, so an opcode with no arm is one method returning an error that carries the opcode and its offset, and the next machine that needs something costs one line.

The walk never enters a method body. Everything that declares a name is package-delimited, so bodies are stepped over by length, which sidesteps the one genuinely hard problem in AML parsing, where a bare name followed by arguments is a call whose argument count depends on a declaration that may live in a table not yet loaded. The reference implementation needs multiple passes for it. By the time the evaluator meets one, the namespace is complete and the arity is simply known.

Three bounds, because this is firmware bytecode executing in ring 0 where a fault outside a guard is fatal. A step budget, since an infinite loop is legal AML and vendor methods contain loops that wait on hardware which may not be there. A depth cap, since a method may call itself and there is no guard page under this stack. And nothing runs unasked: building the namespace executes nothing at all, which is why a top-level store is stepped over even though the specification says it should run at table load.

Region writes are off until explicitly unlocked. Reading a battery needs none, and a stray write to an embedded controller is not a wrong number: it is a fan that stops or a charge threshold that moves, on hardware, permanently.

Test against real firmware. The emulator's DSDT is nine kilobytes with no battery in it. The target laptop's is 575, and dumping it and feeding it to the kernel found four opcodes the small one never exercises: a region offset given as a name, one computed with an addition, a bare top-level store, and the create-field family. Node counts went 70, 2491, 3367, 5110, 5889 as each was fixed. Without that, the parser would have looked finished and failed on hardware.

Once methods evaluate, powering off is four integers and two register writes. The sleep values are a package the board keeps in its own namespace, since there is no standard constant, which is exactly why real shutdown needed an interpreter. The firmware is still asked first; ACPI is the second chance, and "hold the button" is no longer the answer when it declines.