███████╗████████╗██╗███╗ ██╗██╗ ██╗ ██████╗ ███████╗
██╔════╝╚══██╔══╝██║████╗ ██║██║ ██╔╝██╔═══██╗██╔════╝
███████╗ ██║ ██║██╔██╗ ██║█████╔╝ ██║ ██║███████╗
╚════██║ ██║ ██║██║╚██╗██║██╔═██╗ ██║ ██║╚════██║
███████║ ██║ ██║██║ ╚████║██║ ██╗╚██████╔╝███████║
╚══════╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝
An x86 PC operating system. Written from scratch.
Boots real hardware. No libc. No regrets.
boot sector → kernel → PAE+W^X paging → multitasking → COW fork → syscalls → VFS → ring 3 (C + Rust) · zero external dependencies
What it is · What it does · Run it · Syscalls · Write an app · Roadmap · Contribute
A real, 32-bit x86 operating system. Not a Linux distro with a custom theme. Not a tutorial that stops at "Hello, kernel!"
StinkOS boots a PC (or QEMU). It enters protected mode, sets up paging, launches user programs in Ring 3 with their own isolated address spaces, and lets them call back into the kernel through a syscall ABI. Same shape as a real OS — just smaller in every direction.
Everything in this repo was written from a blank disk image. The bootloader. The kernel. The drivers. The filesystem. The userland C library. The apps. No libc. No prebuilt kernel. No imported bootloader. Just boot.s, a kernel in C, a handful of drivers, and a flat-file filesystem I cobbled together.
Yeah, the name is a joke. The code isn't.
Freedoom1 running as a userland app — ring 3, no GRUB, no Multiboot loader. Bootblock → ELF kernel → doomgeneric.
help / mem / ps / netinfo — DHCP bound from QEMU's user-mode SLIRP, free-page count via SYS_MEMINFO.
Middle-right: SYS_MAP_FB in action — the framebuffer mapped read-write into the user address space, so every pixel write is one store, zero syscalls.
Bottom-left: rs-life running Conway's Game of Life in pure Rust. Gosper glider gun seed, 128×96 toroidal grid, diff-painting between generations. Zero external crates.
Bottom-right: multiprocess proof — shell forks anim into the background, then runs ps against itself; the listing shows TWO procs alive (parent shell + ANIM child), each with its own page directory.
On boot, StinkOS will:
- Load itself from disk via the BIOS, enable A20, build a GDT, and switch the CPU into 32-bit protected mode. (
boot.s) - Set a VBE linear framebuffer at 1024×768×32 — pixels go straight to video memory. (
vbe.c,fb.c) - Wire up interrupts — IDT, remap the PIC, configure the PIT at 100 Hz, drive the PS/2 keyboard. (
trap.c,keyboard.c) - Enable PAE paging with W^X. 3-level page tables (PDPT → PD → PT, 8-byte entries, NX bit 63). Kernel
.textis R-X,.rodataR-NX,.data/.bssRW-NX. Userland ELFs honourp_flagsper segment. (paging.c,pmm.c,kentry.s) - Locate ACPI tables (RSDP scan → RSDT/XSDT → FADT + MADT).
sys_shutdowndoes a real ACPI S5 port-write; CPU + IOAPIC topology gets logged. (acpi.c) - Install a TSS and drop into a graphical start menu. (
menu.c,gdt.c) - Load and run apps in Ring 3 with per-process page directories.
sys_forkis COW (refcount-based, write-faults privatize).sys_execswaps in a fresh pgdir; multiple ring-3 apps live at once (round-robin scheduler at 100 Hz). The kernel reads a named ELF file from StinkFS (orB:mount), copies it into a fresh user address space, andirets into user code. The app talks to the kernel only throughint 0x80. When it exits or faults, control returns to the menu — a misbehaving app cannot take down the system. (elf.c,usermode_asm.s,proc.c)
There's also a filesystem of my own design (fs.c — "StinkFS") behind a VFS multi-mount table (A:/B: DOS-style prefixes route to mount slots), a PC speaker + SB16 driver, an ATA disk driver (ata.c), and a serial console used for debug output (visible in the QEMU stdio log). The boot path ends with a BIOS-style POST screen reporting each subsystem's status (bootdiag.c), and a network stack (e1000 NIC → ARP/IP/ICMP/UDP/TCP/DHCP/DNS) backs the shell's netinfo and ping commands.
Rust userland is wired up: a nightly toolchain + custom i686-stinkos target + shared libstink rlib lets no_std Rust apps link with the existing C crt0.s and call syscalls through extern "C" shims. Five Rust apps ship today: rs-hello (toolchain proof), rs-alloc (Box/Vec over libstink K&R malloc), rs-stdio (println! / eprintln! / alloc::format!), rs-json (~390 LOC recursive-descent JSON parser, zero crates), rs-life (Conway's Game of Life on the framebuffer).
You need an i386-elf cross-compiler and qemu-system-i386.
# one-time: build the cross-toolchain (~30-60 min)
bash tools/build-cross-toolchain.sh
source ~/.bashrc
# build and boot in QEMU
make runWant to verify it boots without staring at it?
make test-headless # boots in qemu, asserts via serial logOther useful targets: make (build only), make hex (hexdump the disk image), make dall (disassemble), make clean.
Userland talks to the kernel via int 0x80. eax = syscall number, args in ebx/ecx/edx/esi, return value in eax.
| # | Name | Arguments | Effect |
|---|---|---|---|
| 1 | log |
ebx=str |
Write a line to the debug serial console |
| 2 | draw |
ebx=x ecx=y edx=rgb |
Plot a pixel |
| 3 | getkey |
— | Return next keypress (0 if none) |
| 4 | alloc |
— | Hand back a fresh page of user memory |
| 5 | exit |
ebx=code |
Terminate; parent observes code via wait |
| 6 | ticks |
— | Timer ticks since boot (10 ms each) |
| 7 | sound |
ebx=hz |
Beep at frequency (0 = silence) |
| 8 | fwrite |
ebx=name ecx=buf edx=size |
Write a file in StinkFS |
| 9 | fread |
ebx=name ecx=buf edx=max |
Read a file from StinkFS |
| 10 | fcount |
— | Number of files in StinkFS |
| 11 | finfo |
ebx=idx ecx=name_buf |
Name of the i-th file |
| 12 | fdelete |
ebx=name |
Delete a file |
| 13 | fappend |
ebx=name ecx=buf edx=size |
Append to an existing file |
| 83 | fork |
— | COW fork; parent gets child pid, child gets 0 |
| 84 | mount |
ebx=(slot<<8)|drive ecx=dir_lba edx=data_lba esi=data_end |
Mount StinkFS at B: |
| 86 | exit_code |
ebx=code |
Set exit code (libstink exit(n) wrapper) |
The table above is a representative slice; the ABI is ~80 calls today — file
and VFS I/O, on-screen text and blits, audio, TCP/UDP sockets, DNS, netinfo
and ping, exec, fork, wait/waitpid, signal raise/poll, mount,
and direct framebuffer mapping. C apps don't write int 0x80 by hand —
lib/libstink.h wraps each call as a static inline. Drop it in and call
sys_draw(x, y, 0xff00ff).
Rust apps go through lib/libstink_syms.c (non-inline shims so extern "C" resolves at link time) and apps/rust/libstink/ (an rlib with println!, eprintln!, read_line, exit(code), a default #[panic_handler], and a #[global_allocator] shim over the K&R malloc).
Apps are independent ELF binaries. They live as named ELF files in StinkFS and show up in the start menu at boot. ~28 C/asm apps + 5 Rust apps today:
| Name | What it is |
|---|---|
HELLO / BOX |
Assembly demos — log to serial, paint pixels |
FAULT |
Deliberately touches kernel memory, gets killed cleanly |
GAME |
Keyboard-controlled block (assembly) |
HI / ANIM |
First C apps — prove crt0.s + libstink.h work |
BEEP |
Plays notes through the PC speaker |
SAVE / FILES / LS / DEL |
StinkFS demos: write, list, read, delete |
PLAY |
A tiny collector game that persists its high score in StinkFS |
SHELL |
The shell — ps, bg, kill, mem, netinfo, ping, dns, ... |
EDIT |
Full-screen text editor over the VFS |
SNAKE / PONG / ASTEROIDS |
Keyboard games over the framebuffer |
DOOM1 / DOOM2 / FREEDM |
Vendored doomgeneric port over the SB16 mixer |
STINKPKG |
Package manager (stink-pkg install / upgrade / verify) |
INSTALLER |
Real MBR installer onto a second disk image |
WXATTACK |
Adversarial app proving W^X kills .data execution |
COWTEST / MOUNTTEST / EXITCODE |
End-to-end smokes for fork-COW / VFS-mounts / exit codes |
RS-HELLO / RS-ALLOC / RS-STDIO / RS-JSON / RS-LIFE |
Rust apps — toolchain, heap, stdio shim, JSON parser, Conway's Life |
#include "libstink.h"
void main(void) {
sys_log("hello from ring 3");
for (int y = 100; y < 300; y++)
for (int x = 100; x < 500; x++)
sys_draw(x, y, 0x00ffaa);
while (sys_getkey() != 27) { /* wait for ESC */ }
sys_exit();
}Add a build rule beside the others in the Makefile, list the resulting .elf
in the make-stinkfs.py arguments at the end of the os target so it gets
written into StinkFS as a named file, and make run. The menu picks it up
automatically.
The kernel sources are grouped by subsystem under kernel/, with the boot
assembly and link script in boot/. Object files build flat into build/;
the Makefile resolves a bare source or #include "foo.h" to the right
subdirectory via VPATH and -I include paths. Broad consumers (the boot
path, the syscall dispatch) include kernel/defs.h, an umbrella that pulls in
every subsystem header so they need a single include instead of a long list.
| Area | Files |
|---|---|
| Boot, protected-mode entry | boot/boot.s, boot/bootmain.c, boot/bootblock.ld, boot/kernel.ld, kernel/arch/kentry.s, kernel/arch/multiboot.s |
| Kernel entry | kernel/main.c (kmain) |
| Interrupts (IDT, PIC, PIT) | kernel/sys/trap.c, boot/interrupts_asm.s |
Syscall dispatch (int 0x80) |
kernel/sys/syscall.c |
| GDT + TSS | kernel/arch/gdt.c, boot/gdt_asm.s |
| Paging, physical memory | kernel/arch/paging.c, kernel/arch/pmm.c |
| Video (VBE + framebuffer + font) | kernel/drivers/video/{vbe,fb,font}.c |
| Input (keyboard, mouse) | kernel/drivers/input/{keyboard,mouse}.c |
| Storage (ATA), audio, misc | kernel/drivers/storage/ata.c, kernel/drivers/audio/, kernel/drivers/misc/{serial,rtc}.c |
| Network stack | kernel/drivers/net/ (e1000, ethernet, arp, ipv4, icmp, udp, tcp, dhcp, dns, pci) |
StinkFS + VFS (multi-mount, A:/B: routing) |
kernel/fs/{fs,vfs,mbr}.c |
| ACPI (RSDP/RSDT/XSDT/FADT/MADT, S5 shutdown) | kernel/arch/acpi.c |
| Process model (fork-COW, exec, wait, signals) | kernel/sys/proc.c, kernel/arch/paging.c |
| ELF loader + Ring 3 entry | kernel/sys/elf.c, kernel/ui/menu.c, boot/usermode_asm.s |
| Userland C library | lib/libstink.h, lib/libstink_syms.c, apps/crt0.s |
| Rust userland (libstink rlib + apps) | apps/rust/libstink/, apps/rust/rs-*/, apps/rust/i686-stinkos.json |
Deep dives live under docs/:
| File | Covers |
|---|---|
docs/ARCHITECTURE.md |
Kernel layout, paging, process model, app lifecycle |
docs/SYSCALLS.md |
Full int 0x80 number table, args, returns |
docs/NETWORK.md |
TCP/IP layering, DHCP boot timing, TCP state graph |
docs/STINKFS.md |
On-disk filesystem format |
docs/PACKAGING.md |
How to author a .stinkpkg |
docs/MEMORY.md |
Memory accounting + leak-sweep methodology |
docs/TUTORIAL.md |
Build from scratch in 10 steps |
docs/TESTING.md |
Host-mirror unit test contract + how to add one |
CREDITS.md |
Third-party code shipped (Doom, Freedoom, cross-toolchain) |
The host gcc assumes it's producing programs for the host OS. Forcing it to emit bare-metal code works for tiny examples and becomes a source of subtle bugs as the OS grows. The i386-elf cross-compiler carries none of those assumptions — it produces ELF binaries for a freestanding i386 target, period. It's the boring, correct foundation.
tools/build-cross-toolchain.sh builds binutils + gcc for the i386-elf target into ~/opt/cross. Once, then forget it exists.
Done:
- 32-bit protected mode (LBA load, A20, GDT, TSS)
- VBE linear framebuffer, font + text rendering
- Interrupts: IDT, PIC remap, PIT timer, PS/2 keyboard
- PAE paging + W^X (v0.5) — PDPT/PD/PT 8-byte entries, NX bit 63, kernel + user per-segment permissions,
wxattackapp proves.dataexecution dies at #PF - Multitasking proper (v0.4) — per-process page directory,
sys_fork+sys_exec, round-robin scheduler at PIT 100 Hz - COW fork (v0.7) —
PG_COWPTE bit 9 + 8 KiB PMM refcount table; first writer takes #PF, kernel privatizes - ACPI static tables (v0.6) — RSDP scan → RSDT/XSDT → FADT (S5 soft-off via PM1a_CNT_BLK) + MADT (CPU/IOAPIC topology)
- VFS multi-mount (v0.8) —
fs_mount+fs_opsdispatch, DOS-styleA:/B:prefix routing,sys_mountregisters a second StinkFS slot - Rust userland (v0.9, v0.10) — nightly + custom
i686-stinkostarget + sharedlibstinkrlib; apps:rs-hello,rs-alloc,rs-stdio,rs-json,rs-life - Userland (Ring 3), isolated address space, fault isolation
- ELF program loader (3-PHDR
app.ld, per-segment perms) - StinkFS (flat named files, persisted to disk; 80 file slots)
- Sound — PC speaker + Sound Blaster 16 (DMA-driven output, 8-channel mixer)
- Start menu, graphical shell, full-screen text editor
- PS/2 mouse driver + cursor, exposed to apps via syscall
- Networking — e1000 NIC, ARP/IP/ICMP/UDP/TCP, DHCP/DNS,
netinfo/ping -
sys_map_fb— apps blit by writing the framebuffer directly - Package manager (
stink-pkg) with SHA-256 integrity verification - Boot-time POST diagnostic with per-subsystem status
- Doom port —
DOOM1/DOOM2/FREEDMover doomgeneric + SB16 mixer
Up next (in no particular order):
- Multi-TTY console (Alt+Fn switch between framebuffer back-buffers)
- AML interpreter — needed for ACPI on real laptops (today's S5 path is hard-coded
SLP_TYPa=5) - Doom music (OPL/MIDI synth backend)
- SMP bring-up — IOAPIC + LAPIC + atomic ops in PMM refcount + per-CPU lock for COW
- More Rust apps that exploit Rust's safety wins (parsers, protocol decoders)
Yeah, Doom. Phase 1, Phase 2 and FreeDM all run.
One-time setup — get the WADs:
bash tools/fetch-wads.sh # downloads freedoom-0.13.0 + freedm into wads/That pulls ~33 MB from the official Freedoom GitHub release. The wads/ directory is gitignored — those binaries aren't part of the source.
If you have a commercial doom.wad or doom2.wad you'd rather use, drop it under wads/ and the build will pick it up by overriding the file path:
make FREEDOOM1_WAD=path/to/doom.wadBuild and run:
make runThe boot menu picks up three new entries — DOOM1, DOOM2, FREEDM. Pick one with arrows, Enter, and the engine fires up against the matching IWAD. The disk image grows to ~100 MiB because the WADs live in StinkFS alongside the kernel.
Controls (vanilla Doom set):
| Key | Action |
|---|---|
W / arrows up |
walk forward |
S / arrows down |
walk back |
A / D |
strafe |
| ← / → | turn |
| Left Ctrl | fire |
| Space | open door / use |
| Shift | run |
1–9 |
select weapon |
| Tab | automap |
| Esc | menu |
What works: all 36 Doom 1 maps, all 32 Doom 2 maps, all monsters, weapons, power-ups, save/load (via StinkFS), automap, intermissions, cheats (IDDQD, IDKFA, etc.), and sound effects through the Sound Blaster 16 mixer.
What doesn't (yet): music (needs an OPL/MIDI synth backend StinkOS doesn't have yet), and network multiplayer (the stack exists; the engine's netgame layer isn't wired).
How the port works under the hood: vendored doomgeneric (Chocolate Doom derivative) lives in apps/doom/; apps/doom-shims/ provides the POSIX headers Doom expects, and lib/libstink_{alloc,stdio,printf,posix,setjmp} are the corresponding runtime backends. The platform layer is apps/doom/doomgeneric_stink.c (~200 lines). One source tree, three ELFs — each compiled with a different -DSTINKDOOM_IWAD so the DOOM1 / DOOM2 / FREEDM menu entries auto-load the right WAD.
License notice (Doom): the Doom engine source under apps/doom/ is © id Software, released under the GNU General Public License v2.0 — see apps/doom/COPYING. The doomgeneric port is © Jakub Świątek, same licence. See also apps/doom/README.md and CREDITS.md. Any binary StinkOS image you ship that bundles Doom carries that GPLv2 obligation: source must be made available to recipients. The IWADs themselves (Freedoom 1, Freedoom 2, FreeDM) are under their own permissive BSD-3-Clause; the original commercial DOOM.WAD / DOOM2.WAD files are not redistributable -- buy them or use the Freedoom replacements tools/fetch-wads.sh downloads.
PRs welcome. Start with CONTRIBUTING.md — it's short. For anything bigger than a bug fix, open an issue first so we can agree on shape before you spend a weekend on it.
By participating you agree to the Code of Conduct. Found a security issue? See SECURITY.md.
StinkOS exists because @suwateru got me into low-level back in mid-2024 — my first C, my first segfaults, my first time the stack pointer actually made sense. The project is based on her original StinkOS; what's here now is two years of self-study built on what she started.
GPLv3. Use it, fork it, learn from it, run it on weird hardware, write a thesis about it — but if you distribute something derived from this code, the source has to stay open. That's the deal.
Honestly? Because the first version was held together with duct tape and prayers, and the name kind of stuck. By the time it was good enough to not stink, the joke was funnier than any "serious" name I could come up with. Embrace the bit.





