Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Freestanding Rust Binary

The first real step is creating a Rust executable that does not link to the standard library. That way we can run Rust code on bare metal without an underlying operating system.

This means we will not have threads, files, heap memory, the network, random numbers, standard output, or any features requiring OS abstractions or specific hardware. We have to make all of these on our own.

However, there are some things we can use: iterators, closures, pattern matching, Option and Result, string formatting, and the ownership system. These features make it possible to write a kernel in a very expressive, high-level way without worrying about undefined behavior or memory safety.

Disabling the Standard Library

By default, all Rust crates link the standard library, which depends on the operating system for threads, files, networking, and the C standard library libc.

Creating a new project:

cargo new kfs --bin --edition 2024

To break that down:

  • kfs — name of the crate
  • --bin — creates an executable binary (not a library)
  • --edition 2024 — use the 2024 edition of Rust

To disable the standard library, add #![no_std] at the top of src/main.rs:

#![no_std]

fn main() {
    println!("Hello, world!");
}

Running cargo build will now error because println! requires the standard library. That’s expected — we’re breaking free.

Panic Implementation

The standard library provides its own panic handler. In a no_std environment, we define our own:

#![allow(unused)]
fn main() {
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}
}

The PanicInfo parameter contains the file, line, and optional panic message. The ! return type means this function is diverging — it never returns. For now, we just loop forever.

Disabling Unwinding

Rust can unwind the stack on panic, walking frames to produce a backtrace. In a kernel, this requires OS support we don’t have. We disable it in Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

This sets the panic strategy to abort for both profiles — no unwinding, just halt.

What Do You Do Without a main()?

A typical Rust binary has a runtime that runs before main() — it initialises the garbage collector (if any), sets up stack overflow guards, etc. We have none of that.

We need to:

  1. Tell the Rust compiler we don’t want the normal entry point chain with #![no_main]
  2. Define our own entry point
#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

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

#[no_mangle] — Disables Rust’s name mangling. Without this, the compiler generates a cryptic symbol name and the linker can’t find the entry point.

extern "C" — Uses the C calling convention. Required so the assembly bootloader can call into Rust correctly.

-> ! — The function is diverging. The entry point is not called by any other function — it’s invoked directly by the bootloader. There’s nothing to return to.

Building for Bare Metal

By default, Rust compiles for your current host system. We need to cross-compile for a bare-metal i386 target with no OS:

rustup run nightly cargo build -Z build-std=core --target i386-unknown-none.json

The custom target file i386-unknown-none.json:

{
  "llvm-target": "i386-unknown-none",
  "data-layout": "e-m:e-p:32:32-...",
  "arch": "x86",
  "target-endian": "little",
  "target-pointer-width": 32,
  "target-c-int-width": 32,
  "os": "none",
  "executables": true,
  "linker-flavor": "ld.lld",
  "linker": "rust-lld",
  "panic-strategy": "abort",
  "disable-redzone": true,
  "features": "-mmx,-sse"
}

Key points:

  • "os": "none" — No operating system
  • "disable-redzone": true — Required for interrupt handlers (the red zone is a 128-byte region below RSP that interrupts would clobber)
  • "-mmx,-sse" — Disable SIMD; the kernel shouldn’t use floating point registers

Cargo.toml

[package]
name = "kfs"
version = "0.1.0"
edition = "2021"

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

[lib]
crate-type = ["staticlib"]

crate-type = ["staticlib"] builds the Rust code as a static library (.a file) that the linker will combine with the assembly object files.

Why Rust?

  • Memory safety without garbage collection
  • Zero-cost abstractions
  • Pattern matching and type safety
  • No undefined behavior (in safe code)
  • Great tooling for cross-compilation

The challenge: No standard library means no String, no Vec, no Box. Translating C kernel examples to Rust requires understanding what each C construct really does at the memory level — which turns out to be a fantastic forcing function for actually learning it.