Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rust-os

A small x86_64 operating system kernel written in Rust — no standard library, no host OS underneath, no runtime. It boots on bare metal (or QEMU), sets up its own memory management and interrupt handling, and drops you into an interactive shell.

rust-os shell -- type `help` for a list of commands.
> help
available commands:
  help          show this message
  clear         clear the screen
  echo <text>   print <text> back
  date          show the current date and time (UTC)
  uptime        show time since boot
  sleep <ms>    pause for <ms> milliseconds
  spawn         start a busy-loop kernel thread
  threads       list kernel threads
  exec <path>   run an ELF program in ring 3
  ps            list processes
  ls [path]     list a directory on the disk
  cat <path>    print a file from the disk
  disk          show mounted volume info
  about         show kernel info
keys: up/down browse history, PgUp/PgDn scroll output
> ls /bin
  HELLO.ELF        4168
  RING3.ELF        4109
  BSS.ELF          8192
  SYSCALL.ELF      4113
  CRASH.ELF        4103
  EVIL.ELF         4102
6 entries
> exec /bin/hello.elf
Hello from a real ELF process!
process exited with code 0
> exec /bin/crash.elf
user program page fault at VirtAddr(0x0); terminating it
process terminated by a fault
> ps
    1  hello.elf         exited 0
    2  crash.elf         killed by fault
> spawn
spawned thread 1 (busy loop; watch `threads`)
> threads
   0  running (shell/executor)
   1  ready
work counter: 30761266
> date
2026-07-19 14:33:07 UTC
> uptime
up 42.360 s
> sleep 500
slept 500 ms
> echo hello from ring 0
hello from ring 0
>

Attribution, up front: the foundation of this kernel was built by following Philipp Oppermann's excellent Writing an OS in Rust (blog_os) series. That series is responsible for everything in the Foundation section below. Everything in Beyond the tutorial was built after the series ended, without a guide — working from OSDev references and hardware datasheets instead.

Status: actively developed. New features are landing regularly and this README is updated as they do. See the Roadmap.


Beyond the tutorial

These are the features built independently once the tutorial ended. Each one meant reading hardware documentation directly and reasoning about correctness in an environment with no debugger, no println safety net, and no OS to catch mistakes.

Interactive shell

A real command interpreter running as an async task on the kernel's cooperative executor.

  • Line editing with echo and backspace, built on the raw scancode stream
  • Tokenised command dispatch with argument parsing
  • Async command execution — commands can .await, so sleep 2000 suspends the shell task for two seconds without blocking the executor or stalling interrupt handling
  • Command history — up/down arrows recall the last 50 commands, stashing the in-progress line so arrowing back down returns to what was being typed
  • Screen scrollback — PageUp/PageDown page through the last 200 lines of output; any new output or keystroke snaps back to the live view
  • Commands: help, clear, echo, date, uptime, sleep, spawn, threads, exec, ps, ls, cat, disk, about

PIT-backed monotonic clock and async sleep

The Programmable Interval Timer reprogrammed from its default ~18.2 Hz to a known 100 Hz, driving a monotonic tick counter and a proper async timer.

  • sleep(ms) returns a future that registers its waker against a deadline and is woken by the timer interrupt — no busy-waiting, no blocking
  • uptime_ms() / ticks() for monotonic time

Wall-clock time from the CMOS RTC

Real date and time read directly from the real-time clock over the CMOS index/data ports.

Getting this right meant handling three separate hardware quirks that each silently corrupt the result — see Engineering notes.

Kernel-maintained clock

How real kernels keep time: the RTC is read exactly once, at boot, capturing a wall-clock reference alongside the PIT tick count. From then on date is derived from boot time + elapsed ticks — an atomic load and calendar arithmetic, with no port I/O and no spinning on RTC update flags.

  • Unix-seconds ↔ civil-date conversion via Howard Hinnant's days_from_civil / civil_from_days algorithms, handling leap years correctly across century boundaries
  • Calendar math covered by in-kernel unit tests (epoch, leap day, year boundary, known timestamps)

Preemptive multitasking

Kernel threads with their own stacks, switched by the timer interrupt — the leap from "tasks politely take turns" to "the kernel decides who runs."

  • Hand-written context switch in a dedicated assembly file (threads.s): callee-saved registers pushed, stack pointers swapped, execution resumes mid-function in another thread
  • Round-robin scheduling on every PIT tick (10 ms quantum); a thread that never yields still can't monopolise the CPU
  • New threads bootstrapped by hand-crafting their initial stack frame so the first context switch "returns" into a trampoline that enables interrupts and calls the entry function
  • The boot flow (and the async executor with the shell) runs on as thread 0 — cooperative async tasks within a thread, preemptive threads around them
  • Proven by an integration test: a busy-loop thread that never yields, watched by a main flow that never yields either — only timer preemption can interleave them (tests/preemption.rs)
  • Shell: spawn starts a busy worker; threads lists thread states live

User mode (ring 3) + system calls

Code running with user privileges, talking to the kernel only through a syscall interface — the kernel/userspace boundary that everything else in an OS is built around.

  • Ring-3 GDT segments and a TSS privilege stack (RSP0) so interrupts arriving during user code switch to a kernel stack safely
  • int 0x80 syscall gate (DPL 3) with a register-based ABI: rax = number, rdi/rsi/rdx = args — syscalls: exit, write, uptime_ms
  • write validates that user pointers lie inside the user region before touching them
  • User pages mapped USER_ACCESSIBLE — including widening the pre-existing parent page-table entries, the step everyone forgets (see Engineering notes)
  • setjmp/longjmp-style entry: entering user mode saves the kernel context; the exit syscall (or a fault) restores it as if the call had returned, carrying the exit code
  • Fault containment: a page fault or GPF from ring 3 kills the user program and returns to the shell — a crashing user program cannot take the kernel down
  • Preemption keeps working while user code runs: kernel threads and ring 3 interleave

Processes: ELF loading and per-process address spaces

Real executables, read off the disk and run in isolation — the point where the pieces become an operating system rather than a collection of subsystems.

  • ELF64 loader — validates the header and machine type, walks the program headers, and maps each PT_LOAD segment at its virtual address with per-segment permissions
  • .bss support: memory beyond a segment's file size is zero-filled, because every frame is zeroed at map time
  • A fresh page table per process. The kernel's entries are copied in (so interrupts arriving during user code still find the IDT, handlers, and kernel stacks), while one reserved P4 slot holds the process's private mappings. Two processes cannot see each other's memory
  • Address spaces are torn down on exit — the private subtree is walked and every frame returned to a free list, so processes can be run indefinitely without leaking (a test runs 40 in a row)
  • Loader-level security: a segment asking to be mapped outside the user region is rejected before a single frame is allocated, so a crafted ELF cannot ask to be loaded over the kernel
  • Fault containment: a process that dereferences null is killed; the kernel keeps running and the next exec works
  • Programs are built by tools/mkelf.py and live in /bin on the FAT volume; each encodes its result in its exit code so tests/process.rs can assert on it — ring3.elf exits with cs & 3, which is 3 only if it genuinely ran in ring 3
  • Shell: exec <path>, ps

Filesystem: ATA disk driver + FAT16

Real files on a real (virtual) disk, read by the kernel's own driver and filesystem parser — no firmware calls, no libraries.

  • ATA PIO driver — task-file registers, LBA28 addressing, IDENTIFY and READ SECTORS, polling mode with every hardware wait bounded
  • Read-only FAT16 — BPB parsing and validation, the fixed root-directory region, subdirectory cluster chains, 8.3 short names, case-insensitive path resolution
  • Zero allocation in the whole filesystem stack — the kernel heap is 100 KiB, so cat on a file of any size streams through a single 512-byte stack buffer and a callback rather than buffering the file
  • Untrusted-input discipline — the BPB is validated before use, cluster numbers are range-checked, and every chain walk is bounded so a corrupt or cyclic FAT cannot hang the kernel
  • Probes both ATA drives and mounts whichever holds a real FAT16 volume, so the boot disk (whose sector 0 also ends in 0x55AA) is correctly rejected
  • Shell: ls [path], cat <path>, disk
  • The volume is built by tools/mkfatimg.py and verified end-to-end by tests/filesystem.rs, which asserts on exact file contents, sizes, and a multi-cluster chain

VGA text driver improvements

  • backspace() and clear_screen() for interactive editing
  • Hardware cursor control — enabling the cursor and moving it to follow typed output, by programming the VGA CRT controller's cursor-shape and cursor-position registers
  • Scrollback ring buffer — rows scrolling off the top are archived into a fixed 200-line ring (no heap allocation on the write path), with a paged history view that snapshots and restores the live screen

Foundation

Built by following blog_os. Credit for the design of this layer goes to that series.

Area What it does
Freestanding binary #![no_std], custom target spec, no runtime, custom entry point
VGA text output Memory-mapped text buffer driver with print!/println! macros
Serial output UART 16550 driver, used to report test results to the host
Testing Custom test framework running integration tests inside QEMU
CPU exceptions Interrupt Descriptor Table, breakpoint and page-fault handlers
Double faults GDT + TSS with an Interrupt Stack Table, so stack overflows fault safely
Hardware interrupts 8259 PIC configuration, timer and keyboard IRQs
Paging Virtual memory, page table traversal, physical frame allocator from the bootloader memory map
Heap allocation Mapped kernel heap backed by a linked-list allocator, enabling alloc
Async/await Cooperative task executor with proper Waker support

Engineering notes

The parts that were genuinely tricky, and why:

Interrupt-safe locking. On a single core, if a task holds a spinlock when an interrupt fires and the handler tries to take the same lock, the kernel deadlocks permanently. Every lock shared with an interrupt handler is therefore acquired inside without_interrupts, which makes the critical section atomic with respect to the handler.

No allocation in interrupt context. The timer handler runs on every tick and must never allocate or block. The sleeper registry is structured so that waking a task only pushes an ID onto a pre-allocated queue.

Closing a lost-wakeup race. A timer tick landing between "check the deadline" and "register the waker" would strand a sleeping task forever. Sleep::poll re-checks the deadline after registering, which closes the window.

Reading the RTC without getting garbage. Three independent hazards, each of which produces plausible-looking but wrong timestamps:

  • The chip can be mid-update when read, tearing a timestamp across a tick — handled by waiting out the update-in-progress flag, then reading until two consecutive reads agree.
  • Values are usually BCD, not binary, so 0x25 means 25 — a naive read reports hour 37.
  • In 12-hour mode the high bit of the hour register is a PM flag, which must be stripped before BCD conversion or it corrupts the digits.

Bounded hardware waits. Every spin loop against hardware has an iteration ceiling, so a misbehaving or absent device degrades instead of hanging the kernel.

An interrupt you don't want is still an interrupt you must handle. The first disk read double-faulted, apparently deep inside a port write. The cause was neither the port nor the stack — the ATA controller asserts IRQ 14 on command completion, and an interrupt whose IDT entry is absent escalates into a double fault. A polling driver still has to account for the interrupts it is ignoring. Fixed at both ends: the driver sets nIEN so devices stop asserting the line, and the IRQ is given a handler anyway, because a stray interrupt should be logged and acknowledged rather than fatal. (Acknowledging matters independently: an un-EOI'd IRQ blocks every lower-priority one, the timer included.)

When "always disable interrupts around a shared lock" is the wrong answer. Every other lock in this kernel that an interrupt handler touches is taken inside without_interrupts. The disk lock deliberately is not: a sector read can spin for milliseconds, and holding interrupts off that long would stall the timer, stopping both the clock and preemption. No handler touches ATA, so a plain spinlock held with interrupts enabled is correct — contention resolves through preemption, exactly like the heap allocator's lock. The rule is "match the lock discipline to who actually contends," not "disable interrupts everywhere."

The page-table flag everyone forgets. Mapping a user page USER_ACCESSIBLE isn't enough: every parent level of the page-table walk (P4 → P3 → P2) must carry the flag too, or the CPU faults the walk at that level. The mapping API only applies flags to tables it creates, so when user pages lived in the kernel's own address space the bootloader's pre-existing entries had to be widened by hand. Per-process page tables later made that unnecessary — every table on a process's path is now created by the loader — but the rule is the same, just satisfied by construction. The debugging session behind it (user rip faulting on a read of address 0x30) also surfaced a classic Intel-syntax assembler trap: mov rsi, symbol assembles as a load from that address, not the constant — it needs offset.

Two ways to get a rip-relative displacement wrong. The hand-assembled user programs are built by a small Python assembler, and mov byte [rip + flag], 5 initially wrote to the byte after flag. rip means "address of the next instruction", and this encoding puts an immediate after the displacement field — so those trailing bytes count toward the distance. Caught by disassembling the generated ELF with llvm-objdump and noticing that the load and the store named addresses one apart. Verifying generated machine code against a real disassembler costs minutes; debugging a one-byte-off store inside ring 3 does not.

Sharing a kernel between address spaces. Every process page table starts as a copy of the kernel's, because an interrupt can arrive at any instruction of user code and the handler must find the IDT, its own code, and a stack at their usual addresses. Copying only a "higher half" would not do here: this kernel's image, physical map, and heap sit in scattered P4 slots. The flip side is teardown — freeing an address space must walk only the process's private slot, since every other entry points at live kernel tables shared with everyone else.

Context switching from inside an interrupt handler. Three ordering rules make preemption sound: the PIC gets its end-of-interrupt before the switch (the next thread runs with the old thread's interrupt frame still parked on its stack — an unacknowledged PIC would silently stop all future preemption); the scheduler lock is released before the switch (or the next thread deadlocks on its first spawn); and the scheduler never touches the heap in interrupt context — its run queue and finished-thread list are fixed-capacity, and dead threads' stacks are freed later, from thread context, because a thread can't free the stack it's standing on.


Layout

src/
├── main.rs           # kernel entry point
├── lib.rs            # kernel library, init sequence, test harness
├── vga_buffer.rs     # VGA text driver, hardware cursor
├── serial.rs         # UART 16550, host-side output
├── gdt.rs            # GDT + TSS, double-fault stack
├── interrupts.rs     # IDT, PIC, exception and IRQ handlers
├── memory.rs         # paging, page table walk, frame allocator
├── allocator.rs      # kernel heap
├── time.rs           # PIT clock, tick counter, async sleep
├── rtc.rs            # CMOS real-time clock driver
├── clock.rs          # kernel wall clock: RTC-seeded, PIT-advanced
├── threads.rs        # preemptive kernel threads: scheduler, spawn/exit/reap
├── threads.s         # context switch + thread entry trampoline (assembly)
├── usermode.rs       # ring 3: user page mapping, syscall dispatch, run/exit
├── usermode.s        # syscall entry stub, iretq entry/exit, demo program
├── ata.rs            # ATA PIO disk driver (polling, LBA28)
├── fat.rs            # read-only FAT16: BPB, directories, cluster chains
├── elf.rs            # ELF64 parsing and validation
└── process.rs        # address spaces, ELF loading, process table
└── task/
    ├── mod.rs            # task abstraction
    ├── executor.rs       # waker-based cooperative executor
    ├── simple_executor.rs
    ├── keyboard.rs       # scancode stream
    └── shell.rs          # interactive shell
tests/                # integration tests, each booted in QEMU
tools/mkfatimg.py     # builds disk.img, the FAT16 volume the kernel mounts
tools/mkelf.py        # assembles the ring-3 ELF programs that land in /bin
x86_64-rust-os.json   # custom bare-metal target specification

Building and running

Prerequisites

  • Rust nightly (pinned by rust-toolchain) — the kernel relies on unstable features and builds core/alloc from source for a custom target
  • QEMU (qemu-system-x86_64) on your PATH
  • Python 3 (only to generate disk.img, the FAT16 volume QEMU attaches)
  • The bootimage tooling:
rustup component add llvm-tools-preview
cargo install bootimage

Run it

python tools/mkfatimg.py   # once: generate disk.img (QEMU needs it to boot)
cargo run          # build a bootable image and boot it in QEMU
cargo build        # build the kernel only
cargo test         # boot each integration test in QEMU, report via serial

QEMU is given a second disk, disk.img — the FAT16 volume the kernel mounts. The image is not checked in; generate it once after cloning (and again after changing its contents):

python tools/mkfatimg.py

The custom target (x86_64-rust-os.json) disables the red zone, disables SSE/MMX and uses soft-float — floating-point state can't be assumed safe inside interrupt handlers — and sets panic = "abort", since unwinding needs runtime support the kernel doesn't have.


Roadmap

Planned work, roughly in order of ambition:

  • Command history and scrollback in the shell
  • Kernel-maintained clock (seed from the RTC once at boot, advance with PIT ticks)
  • Preemptive multitasking — kernel threads with separate stacks and timer-driven context switching, moving past the current cooperative model
  • User mode (ring 3) and system calls
  • A filesystem — ATA block driver plus FAT16
  • ELF loader and real processes — per-process address spaces, programs loaded from disk
  • Networking — NIC driver and a minimal TCP/IP stack

Acknowledgements

  • Philipp Oppermann for Writing an OS in Rust, which this kernel is built on top of.
  • The OSDev Wiki for hardware documentation on the PIT, CMOS RTC, and VGA CRT controller.
  • The x86_64 crate and the wider Rust embedded/OSDev ecosystem.

About

A small operating system kernel written in Rust

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages