simplos is an operating system I wrote from scratch. Not a kernel module, not a toy shell on top of Linux — the thing that runs when the firmware hands over control, with nothing underneath it. It sets up its own page tables, installs its own interrupt handlers, talks to the keyboard controller directly, and draws to the screen by writing bytes into video memory.
It does not do very much. That is sort of the point: every byte on that screen is there because something I wrote put it there, and the stack below it is the hardware. This is how it works, and then how it ended up booting inside a browser tab — which turned out to be a longer story than writing the kernel.
What it is
simplos is an x86_64 kernel in Rust: #![no_std], #![no_main], no operating system beneath it, freestanding on the bare target. It boots on real firmware — both UEFI and legacy BIOS — through the rust-osdev bootloader, which gets the machine into 64-bit long mode and hands over a boot info structure.
From there the kernel's own work starts:
- a GDT and IDT, so the CPU knows what to do with an interrupt or a fault;
- the 8259 PIC remapped away from the CPU's own exception vectors, because IRQ 0 and a divide-by-zero would otherwise arrive as the same number;
- a heap, built on the physical memory map the bootloader passes in, so the rest of the kernel can allocate;
- a PS/2 keyboard and mouse driver, talking to the i8042 controller the way PCs have since 1984;
- the HPET, found by walking the ACPI tables, for a clock that is better than counting timer ticks;
- a framebuffer it draws text into, with no graphics driver in sight.
What you see when it boots is a status screen: a frame counter, the boot mode, timer ticks, the real-time clock, the HPET's reading, the CPU's own name, and whatever you type. It is not much of a user interface. It is quite a lot of machine.
From power-on to a pixel
The distance between pressing the power button and running Rust is bigger than it looks. On the BIOS path the firmware loads one 512-byte sector and jumps to it, in 16-bit real mode, as though it were 1981. That sector loads a second stage, which loads a third, which switches to 32-bit protected mode, which sets up page tables and enters long mode, which finally loads the kernel. Four stages before a single line of my code runs.
The bootloader crate does that part, and I would not want to write it twice. But I did end up patching it. The kernel asks for a specific screen resolution, and on the UEFI path the bootloader was choosing the largest mode at or above what I asked for — on this firmware, 2560×1600 — rather than the one I actually wanted. On the BIOS path the resolution was a constant compiled into the second stage, which runs in real mode long before any kernel configuration exists, so it could not be told anything at runtime.
So the resolution now comes from two environment variables read at build time, by both the kernel and the bootloader's second stage. One switch, both firmware paths.
That fixed the mismatch but left me building two images: this page wants 1024×768, because every emulated pixel is paid for twice, and everywhere else wants 1920×1080. So the kernel now also asks the graphics hardware itself, once it is running. QEMU has a device whose whole purpose is handing the guest a few named strings from the command line, and the kernel reads a resolution out of it and sets that mode through the registers the emulated card exposes for it. This page boots a stock release image, the same one anybody can download, and asks it to come down to 1024×768. Either firmware, as it happens — the release ships an image for each, and the page lets you pick which one to boot.
It can only ask for something smaller. The bootloader maps exactly as much video memory as the mode it chose needs, and nothing afterwards maps more — so a larger mode would have the kernel drawing past the end of its own mapping. A request that does not fit is refused and the firmware's mode kept, which is also what happens on real hardware, where those registers do not exist at all.
Either way, the kernel prints what it actually got:
INFO : Jumping to kernel entry point at VirtAddr(0x8000006480)
simplos: boot=BIOS fb=1024x768 stride=1024 bpp=3 fmt=BgrOne line, printed once. It says which firmware booted, what mode the hardware actually gave out, and how those pixels are laid out — and it is worth more than it looks, because it comes back later.
Drawing without a driver
There is no graphics driver. The bootloader asks the firmware for a linear framebuffer and passes its address, and from then on the screen is just memory: write three bytes, get a pixel. The kernel keeps a back buffer in RAM, draws into it with embedded-graphics — the same crate people use for little OLED panels on microcontrollers — and copies the result out to video memory.
Written the obvious way, that loop does something ridiculous. Every frame it cleared the whole screen pixel by pixel, drew the text, then copied the entire back buffer to the framebuffer: at 1920×1080 that is two million iterations and eight megabytes of copying, as fast as the processor can manage, forever. On real hardware with hardware virtualisation you never notice. It is a free-running loop on a machine that has nothing else to do.
It stops being free the moment the CPU is a program. More on that shortly.
Getting it into a browser
I wanted the OS on this site. Not a video of it — the real thing, booting, with a keyboard that works. That means emulating a PC in JavaScript and WebAssembly, and there are only a few ways to do it.
The obvious candidate is v86, which emulates a PC in the browser and recompiles x86 to WebAssembly as it runs. It is about two megabytes, it is fast, it needs nothing special from the page. I tried it first.
The 32-bit dead end
v86 booted the firmware, loaded my bootloader, and then died:
[CPU] cr4 <- 20 ; PAE on
[CPU] rdmsr ecx=c0000080 ; read IA32_EFER
[CPU] Unknown msr: c0000080
panicked at src/rust/cpu/instructions_0f.rs:1332: assertion failed: falseThat MSR is IA32_EFER, the register you write to enable 64-bit mode. v86 does not implement it, because v86 is a 32-bit emulator — it has no long mode at all. My kernel is x86_64 and entering long mode is not a step it can skip.
There is a fork, v64, which adds experimental 64-bit support. It got further — through the firmware, all four bootloader stages, the kernel loaded into memory, the video mode set — and then:
[CPU] wrmsr ecx=c0000080 data=0:100 ; set EFER.LME
[CPU] #gp
[CPU] interp64: double fault while delivering vector 13 at rip=0x1004dc
[CPU] interp64: triple fault at rip=0x1004dcA general protection fault during the switch to long mode, then a double fault trying to handle it, then a triple fault, which on real hardware means the CPU gives up and resets. Its own README is honest about this: it targets 64-bit Linux, and Linux is the only thing verified.
So the lightweight option was out, for a reason that had nothing to do with my code. What was left was QEMU compiled to WebAssembly — real QEMU, with its real device model, built with Emscripten. It is an order of magnitude larger to download and it needs the page itself to be set up a particular way. But it emulates a whole PC properly, long mode and all, which is exactly the thing the small emulators could not do.
Teaching a kernel some manners
Running under an emulated CPU changes what counts as a reasonable thing to do. Every guest instruction becomes a pile of host work, so the free-running redraw loop goes from invisible to ruinous. Three changes, all of which the kernel should arguably have had anyway:
- Only copy what changed. The display tracks which rows of the back buffer were touched and copies just those. The status text is a few hundred rows; the screen is a thousand.
- Only redraw when there is something to redraw. The loop now waits for a timer tick and halts in between, instead of spinning. The counter advances about eighteen times a second — the rate the PIT runs at from power-on — and the host CPU goes idle between frames instead of sitting at 100%.
- Stop asking the CPU its name every frame. The brand string lives in optional CPUID leaves, and the kernel was reading it — and panicking if it was missing — on every single pass through the loop.
That last one deserves more attention than it got, because of what the panic handler did: nothing. It was an empty infinite loop with every print statement commented out. Any failure anywhere in the kernel produced exactly one symptom — a black screen and a pegged core — with no way to tell what had happened. In a browser tab that is indistinguishable from the page being broken.
It now prints the panic and halts. It writes to the serial port through a path that does not take the serial lock, because a panic that happens while that lock is held would deadlock trying to report itself: the one message that explains the failure, lost to the thing it was explaining.
The page it needs to live on
QEMU uses threads, and threads in WebAssembly mean SharedArrayBuffer, and browsers only hand that out to a page that is cross-origin isolated — a pair of headers that tell the browser this document does not share a process with anything untrusted.
The catch is what isolation costs. Once a document is isolated, every cross-origin resource that has not explicitly opted in is blocked, which on this site means the analytics tag and the ad script. So the headers are scoped to this one page rather than the whole site, and this one page carries no ads and no analytics. That is a real trade and I made it deliberately: it is the only way the emulator runs at all, and an OS booting in a tab is worth more to me than an ad impression.
The serial port froze the page twice
With the emulator building and the page isolated, the tab locked up a few seconds after boot. Not slow — wedged: no clicks, no scrolling, nothing. It did it with the graphics on, and it did it with the graphics off and nothing but a text console, which was useful, because it ruled out the whole display path in one step.
What both modes shared was the serial port. This QEMU is linked
against a library that makes a terminal behave like a real one, and to
do that it replaces poll() and read() with versions that are proxied to the browser's main thread. QEMU's
main loop polls continuously. Every iteration became a blocking
round-trip to the thread that also has to draw the page, and the page
lost.
Rebuilding without that library stopped the freeze and immediately broke the other end: plain standard output left QEMU stuck in machine setup, waiting on a standard input a browser does not have. With tracing turned on it never even printed the power-on CPU reset, and only one thread ever started — the virtual CPU was never created.
The answer was to stop treating serial as a terminal at all. The guest's serial port now writes to a file inside the emulator's own filesystem, and the page reads that file once a second. A file has no reader to block and nothing to proxy. Three attempts to make a character device behave, and the thing that worked was not being a character device.
Things that went wrong
The kernel stopped compiling before any of this started. The toolchain had moved on nine months, and the x86_64 crate implements an unstable trait that had gained two new methods — so the crate no longer built, and nor did anything depending on it. One of those dependents was a PS/2 driver whose most recent release pins a version from two majors back, which cannot be built on a current compiler at all. It is vendored now, with one line changed.
The bootloader had rotted in a different way: its BIOS stages describe themselves to the compiler with a JSON file, and the format had changed from strings to numbers. Updating it meant also vendoring it, which turned out fine, because I needed to patch it anyway for the resolution.
And the part I did not see coming: the disk image was eight and a half megabytes, nearly all of it debug symbols for a kernel that prints a clock. The kernel is built as a build-dependency of its own packaging step, which Cargo optimises under a different profile than the one you think you are setting. Pointing that profile at an optimised build took the image to two and a half megabytes — and made the guest meaningfully faster to emulate, which matters more.
One last one, visible rather than fatal: the text came out mushy. The guest renders 1024x768 and the page was stretching that to whatever the column happened to be — 958x718 — so every glyph of a 7x13 bitmap font was resampled by a fraction. The canvas is now sized to exactly its own pixels divided by the display's pixel ratio, so one pixel of the operating system covers one pixel of your screen. A bitmap font has no business being interpolated.
What I'd change next
The kernel still panics on things it could survive. A missing framebuffer, an unsupported pixel format, a PS/2 controller that answers oddly — all of them currently take the whole system down, when the honest response is to carry on without that device and say so over the serial port.
After that: a real scheduler and more than one thread of execution, which is the point at which it stops being a program that draws a clock and starts being an operating system. And paging is set up but barely used — there is no user space, no address space separation, nothing to protect anything from anything else. That is the interesting half, and it is all still ahead.
The source is on GitHub, and the thing itself is one page away: boot it.