Skip to main content
  1. Languages/
  2. Rust Guides/

Writing a Bare-Metal Operating System Kernel in Rust: A Step-by-Step Guide

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

In the landscape of 2025, Rust has firmly established itself not just as a competitor to C and C++, but as the dominant language for modern systems programming. We’ve seen Rust integrated into the Linux kernel, adopted by major automotive players, and utilized in mission-critical aerospace software.

But for a true systems engineer, using an existing kernel is one thing; creating your own is the ultimate mastery.

Why build a kernel in Rust? Because it offers the Holy Grail of OS development: memory safety without garbage collection. Traditional OS development in C requires an immense mental overhead to avoid undefined behavior. Rust allows us to enforce memory safety at compile time, meaning if your kernel compiles, it is significantly less likely to crash due to memory corruption than its C counterpart.

In this deep-dive tutorial, we are going to strip away the operating system entirely. We will run code directly on the metal (specifically the x86_64 architecture). By the end of this guide, you will have a bootable kernel that can print “Hello World” to the screen, complete with a custom panic handler and a VGA text mode driver.

What You Will Learn
#

  1. How to disable the Rust standard library (no_std) for bare-metal targets.
  2. Understanding the x86 boot process.
  3. Creating a custom target specification file.
  4. Writing a VGA text buffer driver using volatile reads/writes.
  5. Implementing safe global interfaces using lazy_static and spinlocks.

Prerequisites and Environment Setup
#

Before we write a single line of code, we need to prepare our development environment. OS development requires specific tools because we cannot rely on the host OS’s linker or standard library features.

1. Rust Nightly
#

OS development often requires experimental features that haven’t stabilized yet (like specific inline assembly options or ABI features). We will use the Nightly channel.

rustup override set nightly

2. Source Code for Standard Library
#

Even though we are disabling std, we still use core (the subset of std with no dependencies). We need to recompile core for our custom target.

rustup component add rust-src

3. QEMU
#

We will not be rebooting your physical machine to test this. We will use QEMU, a powerful machine emulator.

  • Linux: sudo apt install qemu-system-x86
  • macOS: brew install qemu
  • Windows: Download the installer from the QEMU website.

4. Bootimage Tool
#

To turn our compiled kernel into a bootable disk image, we’ll use a tool written in Rust.

cargo install bootimage

Phase 1: The Freestanding Rust Binary
#

The first step is to create a Rust executable that doesn’t link to the standard library (std). The standard library relies on the OS for things like threads, files, and networking. Since we are the OS, we don’t have those.

Create a new cargo project:

cargo new rust_os --bin
cd rust_os

Disabling the Standard Library
#

Open src/main.rs. We need to add the # ![no_std] attribute. Furthermore, the standard main function assumes an OS runtime calls it. We need to overwrite the entry point using #![no_main].

// src/main.rs
#![no_std] // Don't link the Rust standard library
#![no_main] // Disable all Rust-level entry points

use core::panic::PanicInfo;

/// This function is the entry point, since the linker looks for a function
/// named `_start` by default.
#[no_mangle] // Don't mangle the name of this function
pub extern "C" fn _start()
 -> ! {
    // this function is the entry point, since the linker looks for a function
    // named `_start` by default
    loop {}
}

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Deep Dive: The Panic Handler
#

In a standard Rust program, a panic terminates the thread. In a kernel, there are no threads yet. We must define a function that the compiler invokes when a panic occurs. Since a panic is unrecoverable here, we define the return type as !, which implies the function “never returns” (it must loop forever or reset the machine).


Phase 2: The Target Specification
#

Compiling for your host machine (likely x86_64-unknown-linux-gnu or x86_64-apple-darwin) won’t work. Those targets assume an underlying OS (Linux/macOS). We need to describe a “bare metal” environment to the compiler.

We will create a custom target JSON file.

Create a file named x86_64-blog_os.json in the root of your project:

{
    "llvm-target": "x86_64-unknown-none",
    "data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
    "arch": "x86_64",
    "target-endian": "little",
    "pointer-width": 64,
    "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,+soft-float"
}

Critical Configuration Explained
#

Field Value Reason
os "none" Tells LLVM we are running on bare metal.
panic-strategy "abort" Disables stack unwinding. Unwinding requires complex library support we don’t have yet.
disable-redzone true Crucial. The “Red Zone” is a System V ABI optimization. Interrupt handlers can corrupt this zone if not disabled, leading to bizarre bugs.
features "-mmx,-sse" We disable SIMD (Single Instruction, Multiple Data) registers because using them requires enabling them in the CPU, which causes exceptions if done prematurely.

Phase 3: The Boot Process & Build Strategy
#

How do we get from code to a running kernel? We can’t simply run cargo build. We need to compile our code, link it, and wrap it in a bootloader that the BIOS/UEFI can understand.

The Compilation Flow
#

flowchart TD A[Rust Source Code] -->|cargo build| B(Compiled Kernel .elf) B -->|bootimage tool| C{Bootloader} C -->|Links Kernel| D[Bootable Disk Image .bin] D -->|QEMU| E[Running OS] style A fill:#f9f,stroke:#333,stroke-width:2px style E fill:#9f9,stroke:#333,stroke-width:2px

Configuring Cargo
#

To make building easier, create a .cargo/config.toml file. This tells Cargo to use our JSON target and use the correct runner (bootimage) automatically.

# .cargo/config.toml

[build]
target = "x86_64-blog_os.json"

[unstable]
build-std = ["core", "compiler_builtins", "alloc"]
build-std-features = ["compiler-builtins-mem"]

[target.'cfg(target_os = "none")']
runner = "bootimage runner"

Adding Dependencies
#

Update your Cargo.toml to include the bootloader crate. This crate does the heavy lifting of switching the CPU from Real Mode (16-bit) to Long Mode (64-bit) and mapping memory for us.

# Cargo.toml

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

[dependencies]
bootloader = "0.9.23"
volatile = "0.2.6"
spin = "0.9.8"
lazy_static = "1.4.0"

[package.metadata.bootimage]
test-args = ["-device", "isa-debug-exit,iobase=0xf4,iosize=0x04"]

Now, try running:

cargo run

If everything is set up correctly, QEMU should launch and show a black screen. It’s boring, but it means your kernel successfully booted and entered the infinite loop!


Phase 4: VGA Text Mode (Hello World)
#

To print to the screen, we can’t use printf or std::cout. We have to write bytes directly into the video memory of the graphics card.

The VGA text buffer is a memory-mapped I/O region located at address 0xb8000. It supports a 25-row by 80-column grid. Each character cell consists of 2 bytes:

  1. Byte 1: The ASCII character code.
  2. Byte 2: The attribute byte (foreground and background color).

Creating the VGA Module
#

Create a new file src/vga_buffer.rs and add mod vga_buffer; to src/main.rs.

1. Colors
#

First, let’s represent the colors with a Rust enum. We use #[repr(u8)] to ensure it compiles down to a single byte.

// src/vga_buffer.rs

#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Color {
    Black = 0,
    Blue = 1,
    Green = 2,
    Cyan = 3,
    Red = 4,
    Magenta = 5,
    Brown = 6,
    LightGray = 7,
    DarkGray = 8,
    LightBlue = 9,
    LightGreen = 10,
    LightCyan = 11,
    LightRed = 12,
    Pink = 13,
    Yellow = 14,
    White = 15,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
struct ColorCode(u8);

impl ColorCode {
    fn new(foreground: Color, background: Color) -> ColorCode {
        ColorCode((background as u8) << 4 | (foreground as u8))
    }
}

2. The Text Buffer Structures
#

We need structures to represent a screen character and the buffer itself.

Important: We use volatile::Volatile. Why? If we write to the buffer but never read from it, the Rust compiler (LLVM) optimizes the write away, thinking it’s useless code. But here, the “side effect” (pixels appearing on screen) is the whole point. Volatile prevents this optimization.

// src/vga_buffer.rs

use volatile::Volatile;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(C)] // Guarantees C-style struct layout
struct ScreenChar {
    ascii_character: u8,
    color_code: ColorCode,
}

const BUFFER_HEIGHT: usize = 25;
const BUFFER_WIDTH: usize = 80;

#[repr(transparent)]
struct Buffer {
    chars: [[Volatile<ScreenChar>; BUFFER_WIDTH]; BUFFER_HEIGHT],
}

3. The Writer
#

Now we implement the logic to write to this buffer. We need to handle newlines (\n) by moving the cursor down and eventually scrolling the text up.

// src/vga_buffer.rs

pub struct Writer {
    column_position: usize,
    color_code: ColorCode,
    buffer: &'static mut Buffer,
}

impl Writer {
    pub fn write_byte(&mut self, byte: u8) {
        match byte {
            b'\n' => self.new_line(),
            byte => {
                if self.column_position >= BUFFER_WIDTH {
                    self.new_line();
                }

                let row = BUFFER_HEIGHT - 1;
                let col = self.column_position;

                let color_code = self.color_code;
                self.buffer.chars[row][col].write(ScreenChar {
                    ascii_character: byte,
                    color_code,
                });
                self.column_position += 1;
            }
        }
    }

    fn new_line(&mut self) {
        for row in 1..BUFFER_HEIGHT {
            for col in 0..BUFFER_WIDTH {
                let character = self.buffer.chars[row][col].read();
                self.buffer.chars[row - 1][col].write(character);
            }
        }
        self.clear_row(BUFFER_HEIGHT - 1);
        self.column_position = 0;
    }

    fn clear_row(&mut self, row: usize) {
        let blank = ScreenChar {
            ascii_character: b' ',
            color_code: self.color_code,
        };
        for col in 0..BUFFER_WIDTH {
            self.buffer.chars[row][col].write(blank);
        }
    }
    
    pub fn write_string(&mut self, s: &str) {
        for byte in s.bytes() {
            match byte {
                // Printable ASCII byte or newline
                0x20..=0x7e | b'\n' => self.write_byte(byte),
                // Not part of printable ASCII range, print a box
                _ => self.write_byte(0xfe), 
            }
        }
    }
}

Phase 5: Global Interface and Formatting
#

We have a Writer, but creating a new instance of it every time is awkward. We want a global WRITER we can use anywhere.

In Rust, static mutables are unsafe. To share WRITER safely, we need:

  1. Lazy Initialization: The Writer needs a reference to 0xb8000, which isn’t a compile-time constant. lazy_static solves this by initializing it on first access.
  2. Interior Mutability: We need to modify the writer (change cursor position) from an immutable static reference. We use a Spinlock. A spinlock is like a Mutex, but instead of sleeping the thread (we have no scheduler yet!), it spins in a tight loop until the lock is free.
// src/vga_buffer.rs

use core::fmt;
use lazy_static::lazy_static;
use spin::Mutex;

lazy_static! {
    pub static ref WRITER: Mutex<Writer> = Mutex::new(Writer {
        column_position: 0,
        color_code: ColorCode::new(Color::Yellow, Color::Black),
        buffer: unsafe { &mut *(0xb8000 as *mut Buffer) },
    });
}

// Support for Rust's formatting macros
impl fmt::Write for Writer {
    fn write_str(&mut self, s: &str) -> fmt::Result {
        self.write_string(s);
        Ok(())
    }
}

Creating println!
#

Finally, let’s implement the familiar macros so we can use println! in our kernel.

// src/vga_buffer.rs

#[macro_export]
macro_rules! print {
    ($($arg:tt)*) => ($crate::vga_buffer::_print(format_args!($($arg)*)));
}

#[macro_export]
macro_rules! println {
    () => ($crate::print!("\n"));
    ($($arg:tt)*) => ($crate::print!("{}\n", format_args!($($arg)*)));
}

#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
    use core::fmt::Write;
    // Lock the global writer and write to it
    WRITER.lock().write_fmt(args).unwrap();
}

Phase 6: Bringing It All Together
#

Now, update src/main.rs to use our new VGA driver.

// src/main.rs
#
![no_std]
#![no_main]

mod vga_buffer;

use core::panic::PanicInfo;

#[no_mangle]
pub extern "C" fn _start()
 -> ! {
    println!("Hello World{}", "!");
    println!("We are running on bare metal Rust in 2026!");
    
    // Let's verify our panic handler prints to screen too
    // panic!("Some panic message"); 

    loop {}
}

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    println!("{}", info);
    loop {}
}

Run cargo run again. You should see “Hello World!” and your subsequent messages printed in bright yellow text on a black background inside the QEMU window.

Congratulations! You have successfully built a freestanding Rust OS kernel.


Performance & Production Considerations
#

Writing a kernel is a long journey. Here are some critical considerations as you move beyond “Hello World”:

1. Deadlocks and Interrupts
#

We used a Spinlock for our VGA buffer. Once you enable hardware interrupts (e.g., keyboard input or timer ticks), you must be careful. If the kernel locks the VGA buffer, an interrupt fires, and the interrupt handler also tries to print, you will deadlock the system. Solution: Disable interrupts while holding the spinlock.

2. Memory Management
#

Currently, we are using the stack and static memory. We have no Heap. To use Box, Vec, or String, you must implement a memory allocator (e.g., a bump allocator or a linked-list allocator) and map the physical memory pages.

3. Testing
#

Testing a kernel is hard because cargo test relies on the host OS. You need to implement a custom test runner that sends success/failure codes to the QEMU I/O port, causing QEMU to exit with specific status codes that your CI pipeline can read.

Feature Comparison: Rust vs C for Kernels
#

Feature C Kernel Rust Kernel
Memory Safety Manual (High Risk) Enforced by Compiler
Undefined Behavior Common (Buffer overflows) Minimized (Unsafe blocks only)
Tooling Makefiles, Linker scripts Cargo, Bootimage, Build.rs
Abstractions Zero-cost? Mostly. Zero-cost abstractions (Iterators/Closures)

Conclusion
#

We have traversed the gap between high-level programming and raw hardware. By disabling the standard library, configuring a custom target, and managing memory directly, we’ve laid the foundation for a secure Operating System.

Where to go next?

  1. Interrupts: Set up the IDT (Interrupt Descriptor Table) to handle keyboard input.
  2. Memory Paging: Move beyond physical addresses to virtual memory.
  3. Multitasking: Implement cooperative or preemptive multitasking to run multiple processes.

The path to a complete OS is long, but with Rust, you have the best safety gear available.

Check out the full source code for this tutorial on our GitHub repository.


If you found this deep dive helpful, subscribe to Rust DevPro for Part 2, where we will tackle Hardware Interrupts and Keyboard Drivers!