← Back to blog

Learning Rust by building an OS

I’ve used Rust here and there for side projects, but I never really learned it, I just wrote enough to get things working. So I picked something that forces me to actually understand the language instead of skimming past it: building an operating system kernel from scratch, following Philipp Oppermann’s Writing an OS in Rust blog.

It’s a good fit for learning Rust properly. There’s no standard library to lean on, no runtime doing things behind your back, and every abstraction has to be built by hand. If something is unsafe, you feel it immediately, because you’re the one who wrote the unsafe block.

I’ve made it through the first two posts so far.

Chapter 1: A Freestanding Rust Binary

The first post is about getting a Rust program to compile without depending on an operating system at all, since an OS kernel obviously can’t lean on the OS underneath it.

That means:

  • #![no_std] to drop the standard library, since it assumes an OS is there to provide threads, files, and heap allocation.
  • Writing your own #[panic_handler], because the default one lives in std.
  • Disabling stack unwinding on panic (panic = "abort" in Cargo.toml), since unwinding needs OS-specific support that isn’t available.
  • Replacing main with #![no_main] and a custom _start entry point, because main only exists as a concept once a language runtime is there to call it.

The part that stuck with me is how much is normally happening before main even runs. Under a normal OS, crt0 sets up the stack and environment, then hands off to the Rust runtime, which finally calls main. Skip the OS and all of that disappears, so you write your own entry point and tell the linker about it directly:

#[unsafe(no_mangle)]
pub extern "C" fn _start() -> ! {
    loop {}
}

The -> ! return type is doing real work here too. The entry point isn’t allowed to return, because there’s no caller to return to. It either loops forever, hands off to something else, or shuts the machine down.

Chapter 2: A Minimal Rust Kernel

The second post turns that freestanding binary into something that actually boots and prints to the screen.

A few things I hadn’t thought about before this:

  • The boot process. BIOS firmware finds a bootable disk, loads a tiny bootloader from the first 512 bytes, and that bootloader is responsible for switching the CPU from 16-bit real mode up to 64-bit long mode before the kernel ever runs.
  • Custom target specs. None of Rust’s built-in target triples describe “x86_64, no operating system,” so you write your own target as a JSON file: disable SIMD, use soft-float, tell the linker there’s no C runtime to link against.
  • build-std. Since core is normally shipped precompiled for known targets, targeting a custom one means recompiling core yourself with an unstable cargo flag.
  • Printing without println!. No println!, obviously, since that’s a std macro. The VGA text buffer sits at a fixed physical address (0xb8000), where each character cell is an ASCII byte plus a color byte. Writing text to the screen means writing raw bytes to that address:
static HELLO: &[u8] = b"Hello World!";

let vga_buffer = 0xb8000 as *mut u8;

for (i, &byte) in HELLO.iter().enumerate() {
    unsafe {
        *vga_buffer.offset(i as isize * 2) = byte;
        *vga_buffer.offset(i as isize * 2 + 1) = 0xb;
    }
}

Then the bootimage tool links the kernel with a bootloader crate to produce a bootable disk image, and QEMU boots it. Watching “Hello World!” show up in a QEMU window, printed by code I wrote with no OS underneath it, was a genuinely good moment.

What’s next

The next post builds a proper safe abstraction over the VGA buffer instead of poking raw pointers around, and adds a println! macro of my own. That’s exactly the kind of thing I was hoping to get out of this: taking something unsafe and turning it into a safe interface, which is a big part of what Rust is actually for.

I’ll keep posting as I work through more of it.