Source: https://oliviercouthaud.com/aetheros · Status: Prototype · 2026 · Tags: C, Assembly, x86-64, QEMU # AetherOS and AquOS Two operating systems I wrote from scratch in C and assembly, run in QEMU: first a windowed desktop, then a Unix-style shell with real processes. [Image: AetherOS desktop in QEMU: file browser, text editor and terminal windows over a starfield, taskbar along the bottom, terminal showing the output of ls.] Figure: AetherOS in QEMU. The terminal has just run ls on the filesystem, which lives in memory. ## Why I built it, and what it is I had built VOS before this. Despite the name it was never an operating system, just a "virtual OS" in the sense of an assistant made of cooperating AI agents ([it has its own page](https://oliviercouthaud.com/vos)). I kept wanting to build a completely from-scratch one instead, and to see how far I could take it starting from C and assembly. That is where these two come from. They are two separate attempts at an x86-64 kernel, in C and NASM assembly, written between 1 and 4 April 2026. Both run in virtual machines (QEMU); neither has been on real hardware. AetherOS goes after the visible half of an operating system, a windowed desktop. AquOS drops the GUI and goes after the Unix half: user-mode processes, fork and exec, pipes, a shell. AquOS is a fresh source tree, not a continuation of the AetherOS code. ## How AetherOS works It boots from a GRUB ISO (Multiboot2), switches to 64-bit mode and identity-maps the first 4 GB with 2 MB pages, which is what makes the framebuffer at 0xfd000000 reachable. The kernel then brings up an interrupt table, a bitmap page allocator, four-level paging, a heap, PS/2 keyboard and mouse, PCI enumeration and a 100 Hz timer. About 4,900 lines of C and 420 of assembly, headers not counted. **What does identity-mapped mean?** Paging lets the CPU translate the addresses a program uses into real memory addresses. Identity-mapped means the translation changes nothing: address X maps to physical address X. It is the simplest setup, and it lets the kernel touch any physical address, the video memory included, directly. The price is that nothing separates one piece of code from another. Multitasking is a round-robin scheduler. On every second timer tick (20 ms) it saves the interrupt frame of the running task, picks the next ready one and overwrites the frame, so the return from the interrupt lands in the other task. The four apps are such tasks: functions linked into the kernel, running in ring 0, not separate programs. Each one owns a window with a pixel buffer and a draw callback. The window manager itself runs in the kernel's main loop, which is the scheduler's idle task, and that is where the draw callbacks get called. **How can overwriting a frame switch tasks?** When the timer interrupt fires, the CPU and the entry code push the running task's registers onto its stack, and the handler normally pops them again on the way out. If the scheduler copies another task's saved registers over that frame first, the same pop restores the other task's registers and its instruction pointer, and execution simply continues over there. **What are ring 0 and ring 3?** x86 processors have privilege levels. Ring 0 may do anything: touch any memory, run privileged instructions, talk to hardware. Ring 3 is restricted, and a program there has to ask the kernel for anything sensitive through a system call. AetherOS apps run in ring 0, so there is no wall between them and the kernel. AquOS programs run in ring 3. Figure: Diagram. How AetherOS draws and schedules. The app tasks only create their window and mark it dirty. wm_loop_tick runs in the kernel main loop (kmain, the idle task the scheduler also switches to), and its composite() calls each dirty window's draw callback. A window has a dirty flag, but any dirty window makes the manager redraw the whole screen. The window manager draws a pre-rendered starfield, composites windows in z-order into a full-screen back buffer, draws the taskbar, copies the result to video memory and draws the mouse cursor on top. The four apps are system info, file browser, text editor and a terminal. The terminal is a built-in command interpreter (ls, cd, cat, echo, mkdir, rm, pwd, clear, help), not a shell that launches programs. The filesystem, AetherFS, lives in memory: 128 inodes, 32 entries per directory. ## How AquOS works AquOS (its boot banner says Cove) boots the same way, then does something different with its files. The Makefile compiles every userspace program to an ELF, embeds them in the kernel with incbin, and at each boot the kernel formats a blank 8 MB RAM disk (AquaFS, 4 KB blocks, 512 inodes) and writes the programs into /bin. The first kernel thread then execs /bin/tide. Figure: Diagram. The AquOS boot path. The 18 programs are the shell plus 17 others. User programs run in ring 3 and enter the kernel through SYSCALL and SYSRET. Fork copies the program's mapped pages (from the load address up to 16 MB past it, or to the heap end if that is higher) and the used part of the stack, from the current stack pointer up, into a new address space; there is no copy-on-write. Exec loads the ELF64 segments into a fresh address space. The scheduler has three priority queues and a 10-tick slice, 100 ms at 100 Hz. Pipes are 4096-byte kernel ring buffers (4095 usable) that block the reader when empty and the writer when full. **What is copy-on-write?** Fork has to give the child a copy of the parent's memory. The simple way, which AquOS uses, is to copy every page right away. Copy-on-write shares the pages instead, marked read-only, and makes a real copy of a page only when one side tries to write to it. It is much cheaper, particularly when the child calls exec straight after, as a shell's children do. The shell, tide, has code for pipes, redirects, background jobs, variables, if/for/while, functions, history and tab completion; so far I have checked that it boots. A small libc supports it, and 17 programs sit beside it: cat, grep, wc, ps, top, a line editor and others. AquOS also has one feature of its own, Ripple events: emit name data wakes every process blocked in listen name. About 7,500 lines of C and 900 of assembly, headers not counted. Output is a text console drawn on the framebuffer. The best way to see how the pieces fit is a pipeline. Step through echo foo | wc below, or click a box in the diagram to jump to the first step that uses it. Both stages are forked children, even builtins. ### Follow echo foo | wc through AquOS - I type echo foo | wc. The shell splits the line at the bar into two commands and decides it needs a pipeline. - It calls pipe(). The kernel allocates a 4096-byte ring buffer and hands back two file descriptors, one to read, one to write. - The shell forks child 1. The kernel copies the pages, with no copy-on-write. In the child, dup2 points the write end of the pipe at standard output. - Child 1 runs echo as a shell builtin, so nothing is exec'd. It writes foo and a newline into the pipe and exits. If the pipe were full, the write would block. - The shell closes its copy of the write end and forks child 2. There, dup2 points the read end at standard input and execv("/bin/wc") makes the kernel load the ELF from AquaFS into a fresh address space. - wc reads standard input until end of file. An empty pipe blocks it; closing the write end wakes it with end of file. For foo plus a newline, the code works out to 1 1 4: lines, words, bytes. - The shell calls waitpid on both children, and the prompt comes back. The 1 1 4 in step 6 is what the wc code works out to for that input; I have not seen it come out of a QEMU run. The order in which the two children get the processor is up to the scheduler. ## What it did when I ran it **AetherOS, headless QEMU run with 256 MB and a standard VGA graphics card, driven by my test script. Pixel counts are over 1280 by 720 = 921,600 pixels.** | Check | Result | |---|---| | Boot steps printing OK in the serial log | 12 | | Windows created | 4 | | PCI devices found | 6 | | Ten captures about 1 s apart: neighbouring pairs differing by 128 px | 7 of 9 | | Neighbouring pairs differing by 186 px | 2 of 9 | | Change between the first screenshot and the one after clicking the Terminal taskbar button and typing ls (includes the focus change, not only the ls text) | 25,757 px | The 128-pixel change is an 8 by 16 box where the terminal cursor sits. The two 186-pixel cases are that same box plus 58 pixels at the mouse pointer, which is missing from one capture, so the pointer does blink out briefly. Apart from the cursor and that pointer, the windows look steady rather than flickering. This compares pixels between captures, so a flicker that falls between two captures would not show. Here are the ten captures, cropped to those two spots. ### Ten captures, about a second apart [Image: Ten pairs of crops, one per capture. Top row: the area around the terminal cursor, which is a solid block in the odd-numbered captures and absent in the even ones. Bottom row: the mouse pointer, present in every capture except number 7.] The strip is the ten captures in order, left to right: the cursor spot on top, the pointer spot underneath. Real crops of the real screenshots. [Image: AetherOS right after boot: terminal showing the AetherOS Terminal v1.0 banner and an empty prompt, text editor with a two-line sample file, file browser behind them, taskbar with four buttons.] Figure: AetherOS right after boot, before any input. The terminal cursor happens to be drawn in this frame. The taskbar lists System Info but its window is not visible: it sits underneath the others. [Image: A black-on-white map of the pixels that differ between the first screenshot and the one after typing ls: the terminal's title bar and outline, a few lines of text inside it, and two taskbar buttons.] Figure: Every pixel that differs between the first screenshot and the one after clicking Terminal and typing ls, in black. The terminal takes the focus (title bar and outline), its text changes, and the highlight in the taskbar moves from System Info to Terminal. The 25,757 pixels in the table are this picture. Here are the lines AquOS prints while booting: a freshly formatted filesystem, the 18 programs written to /bin, and the shell prompt. Selected lines: [PMM] Total: 12543 MB, Free: 246 MB [AquaFS] No filesystem found - formatting [AquaFS] Formatted: 2039 data blocks, 512 inodes [INIT] wrote /bin/tide (34432 bytes) [INIT] wrote /bin/cat (21112 bytes) [INIT] Filesystem populated [AquOS] init process created [AquOS] Starting scheduler After that, the shell draws its banner, a ring of tildes around C O V E and the line "where the water is still", then a prompt. I wrote scripts that type commands such as echo foo | wc into QEMU, and a scripting test inside the image; what I have seen so far is the shell reaching its prompt. ## What I ran into One bug I hit was address spaces. A new space starts as a copy of the boot page table, so a child shared page-table pages with its parent and mapping into one changed the other. The workarounds are in the code: the ELF loader copies argv into kernel memory before it maps anything, and fork gives the child its own tables for the user ranges. I would expect more bugs of this kind. The memory line in the log is not the RAM size: "Total: 12543 MB" comes from adding up every entry in the boot memory map, reserved ranges included. Isolation is minimal. The kernel trusts user pointers directly, because everything is identity-mapped, and the AetherOS apps run in ring 0, so a bug in one can take the kernel down. The AquOS disk is a RAM disk reformatted on every boot, so nothing persists. Ripple events have no queue. If nothing is blocked in listen when an event is emitted, the event is dropped. My pipe test types echo foo | wc -c, but this wc does not parse flags. Given any argument it treats every one as a file name, so wc -c looks for a file called -c and prints "not found" without reading the pipe. Plain wc reads standard input. The AetherOS test script clicks at the bottom right, where I expected the System Info window. It is actually at 100,100 under the other windows, so the third capture is pixel-identical to the second and that window is not yet exercised. The names are inconsistent: the shell's source is still ripple.c but installs as tide, and the kernel strings say AquOS while the banner and ISO say Cove. ## Where it stands AquOS has no GUI, AetherOS's apps run inside the kernel, and the two have not been merged. Next on the list: a check of what the shell does beyond booting, and copy-on-write fork. Both are prototypes, and booting them on a real machine is still to try. - **Stack**: C, NASM, GRUB ISO (Multiboot2), QEMU x86-64. AquOS is built with native gcc under WSL; the AetherOS Makefile expects an x86_64-elf-gcc cross-compiler. Both use nasm and grub-mkrescue. Tests are Python and PowerShell scripts that drive the QEMU monitor. - **Runs on**: Virtual machines (QEMU); not tried on real hardware - **Written**: 1 to 4 April 2026 - **Status**: Prototype - **Code**: Not public [← All projects](https://oliviercouthaud.com/projects)