A minimal, from-scratch x86_64 operating system kernel written entirely in Rust. RustOS boots directly from BIOS, manages physical memory, runs async tasks in the kernel, and echoes keyboard input — all without an underlying OS, standard library, or runtime beneath it.
- What It Is
- Architecture Overview
- Phase 1 — Exceptions and Interrupts
- Phase 2 — Dynamic Memory
- Phase 3 — Hardware Input
- Phase 4 — Async/Await in the Kernel
- The no_std Constraint
- VGA Text Buffer
- Project Layout
- Getting Started
- Running
- Running Tests
- Roadmap
- References
- License
RustOS is a bare-metal x86_64 kernel that builds on the work of Philipp Oppermann's Writing an OS in Rust series, extended with a cooperative async executor and keyboard-driven async I/O. It exists as a learning platform for low-level systems programming in Rust: how hardware works when there is nothing beneath you, how memory safety guarantees survive the absence of a standard library, and how Rust's type system can be used to make unsafe operations tractable and auditable.
The kernel:
- Boots from a BIOS-compatible bootable disk image produced by
bootimage - Sets up exception and interrupt handlers via an IDT
- Initializes a heap allocator to enable dynamic allocation in the kernel
- Runs a cooperative async executor that polls
Futures natively - Processes PS/2 keyboard input asynchronously using a lock-free queue
┌──────────────────────────────────────┐
│ main.rs │ entry point, init sequence
├──────────────┬───────────────────────┤
│ interrupts │ gdt / memory │ hardware interface layer
├──────────────┴───────────────────────┤
│ allocator.rs │ heap (linked_list_allocator)
├──────────────────────────────────────┤
│ task/ (executor + keyboard) │ async runtime
├──────────────────────────────────────┤
│ vga_buffer.rs │ output
└──────────────────────────────────────┘
Initialization order (strictly sequential at boot):
- GDT + TSS — required before any interrupt can be handled safely
- IDT — catches exceptions; double-fault handler uses a dedicated IST stack from the TSS
- PIC remapping — moves hardware interrupt vectors above the CPU exception range
- Heap initialization — maps physical frames and creates the heap region
- Keyboard interrupt enable — hardware interrupts begin flowing
- Async executor launch — runs the keyboard task loop
The Global Descriptor Table defines memory segments visible to the CPU. In 64-bit mode most segmentation is inactive, but the GDT still matters for two things: privilege levels (ring 0 vs ring 3) and the Task State Segment selector.
The TSS holds the Interrupt Stack Tables (IST) — a set of known-good stack pointers the CPU switches to when handling specific exceptions. This is essential for the double-fault handler: a double fault can occur because the stack is corrupt or overflowed. Without a separate IST stack, the handler itself triple-faults, causing an unrecoverable reset. RustOS assigns IST slot 0 to the double-fault handler.
The Interrupt Descriptor Table maps interrupt vectors to handler functions. RustOS installs handlers for:
| Vector | Exception | Handler action |
|---|---|---|
| 3 | Breakpoint | Print register state, continue |
| 8 | Double fault | Print state, halt (diverging) |
| 14 | Page fault | Print fault address and error code, halt |
| 32 | Timer (PIT) | Acknowledge PIC, return |
| 33 | PS/2 Keyboard | Read scancode, push to queue, acknowledge PIC |
Handler functions are extern "x86-interrupt" functions. The x86-interrupt calling convention instructs the Rust compiler to preserve all caller-saved registers, including the segment registers pushed by the CPU, and to end the handler with iretq instead of a normal return.
x86_64 uses four-level paging: PML4 → PDPT → PD → PT → physical frame. Each level is a 4096-byte table of 512 eight-byte entries. The bootloader maps all physical memory at a fixed virtual offset (configured via bootloader = { map_physical_memory = true }), so any physical address can be accessed by adding the offset.
memory.rs implements a BootInfoFrameAllocator that walks the memory map provided by the bootloader and hands out free physical frames on request.
The heap region is a contiguous range of virtual addresses backed by physical frames allocated at init time. allocator.rs maps a 100 KiB region and hands it to the linked_list_allocator crate, which implements a standard free-list allocator. Once the heap is live, alloc crate types work in the kernel:
use alloc::{boxed::Box, vec::Vec, rc::Rc};
let heap_value = Box::new(41); // heap allocation
let mut v: Vec<u32> = Vec::new(); // growable vector
let rc = Rc::new(42); // reference-counted pointerThis is demonstrated in main.rs at boot to confirm the allocator is functional before the async executor starts.
The 8259 PIC is the legacy interrupt controller on x86. By default it routes hardware IRQs to CPU vectors 0–15, which collide with CPU exceptions (vectors 0–31). RustOS remaps the PIC to vectors 32–47 using the standard initialization sequence, placing hardware interrupts safely above the exception range.
IRQ 0 (PIT timer) → vector 32
IRQ 1 (keyboard) → vector 33
...
IRQ 7 → vector 39
IRQ 8 (RTC) → vector 40
...
IRQ 15 → vector 47
After handling a hardware interrupt, the kernel sends an End-of-Interrupt (EOI) command to the PIC. Without EOI, the PIC stops delivering further interrupts of that line.
The keyboard controller sits at I/O port 0x60. When a key is pressed or released, it asserts IRQ 1, causing the CPU to invoke the keyboard interrupt handler. The handler:
- Reads the raw scancode byte from port
0x60 - Pushes it into a lock-free
crossbeam_queue::ArrayQueue(capacity 100) - Wakes the async keyboard task (via an
AtomicWaker) - Sends EOI to the PIC
Scancodes are translated to DecodedKey values using the pc-keyboard crate in the async task, not in the interrupt handler. The interrupt handler does as little work as possible to minimize time spent with interrupts disabled.
A cooperative executor cannot call thread::sleep or std::sync::Mutex — there are no threads and no OS syscalls. The kernel needs a way to wait for hardware events without spinning.
task/executor.rs implements a SimpleExecutor that polls a set of Futures in a cooperative loop. Futures yield via Poll::Pending when they have nothing to do (e.g., waiting for the next keypress). They are woken by a waker stored in an AtomicWaker, which the interrupt handler calls when new data arrives.
interrupt fires → push scancode to queue → wake AtomicWaker
↓
executor polls keyboard task → task reads from queue → processes keypress
The keyboard task is an infinite async loop:
async fn print_keypresses() {
let mut scancodes = ScancodeStream::new();
let mut keyboard = Keyboard::new(/* ... */);
while let Some(scancode) = scancodes.next().await {
if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
if let Some(key) = keyboard.process_keyevent(key_event) {
match key {
DecodedKey::Unicode(c) => print!("{}", c),
DecodedKey::RawKey(k) => print!("{:?}", k),
}
}
}
}
}ScancodeStream::poll_next checks the queue. If empty, it registers the waker and returns Poll::Pending. The executor then runs other tasks (or halts with hlt) until the interrupt handler wakes it.
#![no_std] at the crate root disables the Rust standard library. This means:
- No heap by default (
Box,Vec,Stringrequire explicit allocator setup) - No OS-level I/O (no
println!— must write directly to the VGA buffer) - No threads or synchronization primitives from
std::sync - No
panic!handler — must define one explicitly - No process model, no
mainfunction signature — must use#[no_mangle] pub extern "C" fn _start()
#![no_main] is also required because the standard entry point setup is OS-specific and unavailable.
The alloc crate (a subset of std that provides heap types) is re-enabled explicitly after the allocator is initialized.
The VGA text buffer is a 25×80 grid of characters mapped at physical address 0xb8000. Each character is two bytes: a code point byte and an attribute byte (foreground color, background color, blink). RustOS maps this as a volatile memory region using the volatile crate to prevent the compiler from optimizing away writes.
vga_buffer.rs implements a Writer that manages a cursor position, handles newlines, and scrolls the buffer when the cursor reaches the bottom row. A global WRITER protected by a spin::Mutex (spinlock — no OS blocking primitives available) implements core::fmt::Write, enabling the print! and println! macros.
RustOS/
├── src/
│ ├── main.rs Entry point, init sequence, async executor launch
│ ├── gdt.rs GDT, TSS, IST stack setup
│ ├── interrupts.rs IDT installation, all exception/IRQ handlers
│ ├── memory.rs Physical frame allocator, page table mapping
│ ├── allocator.rs Heap initialization, linked_list_allocator wrapper
│ ├── vga_buffer.rs VGA text driver, global Writer, print!/println! macros
│ └── task/
│ ├── mod.rs Task and TaskId types
│ ├── executor.rs SimpleExecutor: poll loop, AtomicWaker wakeup
│ └── keyboard.rs ScancodeStream, async keyboard processing loop
├── Cargo.toml
├── rust-toolchain.toml Pins exact nightly version
├── Makefile make run, make test
├── docs/
│ ├── ARCHITECTURE.md Detailed architecture documentation
│ └── ROADMAP.md Planned features and extension points
├── research/ Notes on interrupts, memory management, multitasking
├── CONTRIBUTING.md
├── TROUBLESHOOTING.md
└── LICENSE
The repo includes a rust-toolchain.toml that pins the exact nightly version. Running any cargo command automatically installs and activates it via rustup — no manual rustup override needed.
# Install remaining components
rustup component add rust-src llvm-tools-preview
# Install the bootimage tool
cargo install bootimage --version "^0.10"
# Install QEMU (Debian/Ubuntu)
sudo apt install qemu-system-x86
# macOS
brew install qemu# Build and run in QEMU (via Makefile)
make run
# Or manually:
cargo bootimage
qemu-system-x86_64 -drive format=raw,file=target/x86_64-unknown-none/release/boot-bios-rust-os.imgAt boot you should see:
Hello World!
[allocated heap values]
[async keyboard echo ready]
Type on the keyboard — characters appear on screen. The async executor is running.
make test
# or
cargo testTests run in QEMU with a test runner that communicates pass/fail via I/O port 0xf4. The bootloader handles the harness; individual tests use the #[test_case] attribute.
| Feature | Status |
|---|---|
| GDT + TSS | Done |
| IDT (exceptions + hardware IRQs) | Done |
| Physical frame allocator | Done |
| Heap allocator | Done |
| PS/2 keyboard (async) | Done |
| Cooperative async executor | Done |
| Preemptive multitasking (APIC timer) | Planned |
| Virtual filesystem (FAT32 or custom) | Planned |
| Userspace (ring 3 + syscall interface) | Planned |
| RTL8139 ethernet driver | Planned |
| Basic TCP/IP stack | Planned |
- Philipp Oppermann, Writing an OS in Rust — the foundational series this kernel builds on
- OSDev Wiki: https://wiki.osdev.org
- Intel 64 and IA-32 Architectures Software Developer's Manual
- Peter Gutmann, Secure Deletion of Data from Magnetic and Solid-State Memory, USENIX Security 1996
MIT License — see LICENSE.
Copyright (c) 2025 Dakoda Stemen