Project 4: The no_std Kernel Core (The Naked Machine)

Project 4: The no_std Kernel Core (The Naked Machine)

“The best way to understand your tools is to understand what they’re built from.” - Linus Torvalds


Project Metadata

  • Main Programming Language: Rust
  • Coolness Level: Level 5: Pure Magic (Super Cool)
  • Difficulty: Level 4: Expert
  • Knowledge Area: Embedded Systems / OS Development
  • Time Estimate: 2 weeks
  • Prerequisites: Basic Rust ownership, structs, and traits; familiarity with hexadecimal and binary

What You Will Build

A minimal bootable kernel in Rust that runs on bare metal (QEMU x86_64). You will disable the standard library, implement a custom panic handler, and write directly to the VGA text buffer. No operating system. No runtime. Just you and the hardware.


Deep Theoretical Foundation

Before writing a single line of code, you must understand the conceptual landscape you are entering. This section builds your mental model from the ground up.

The Rust Library Hierarchy: core, alloc, and std

Rust’s standard library is not a monolith. It is carefully stratified into three layers, each building on the previous:

+------------------------------------------------------------------+
|                             std                                   |
|  The "Full Experience" - Threads, Files, Networking, I/O         |
|  Requires: An Operating System with system calls                  |
+------------------------------------------------------------------+
          |
          | builds on
          v
+------------------------------------------------------------------+
|                            alloc                                  |
|  Heap Allocation - Box, Vec, String, Arc, Rc                     |
|  Requires: A global allocator (malloc/free equivalent)            |
+------------------------------------------------------------------+
          |
          | builds on
          v
+------------------------------------------------------------------+
|                             core                                  |
|  The Foundation - Option, Result, iterators, math, slices        |
|  Requires: NOTHING (no OS, no allocator, no runtime)             |
+------------------------------------------------------------------+

Why this matters for your kernel:

When you write #![no_std], you are telling the compiler: “I am not running on an operating system. Do not assume I have a heap, files, threads, or any OS services. Give me only what I can use on bare metal.”

What you lose without std:

  • println! (requires stdout, which requires OS)
  • String, Vec, Box (require heap allocation)
  • std::thread (requires OS thread scheduler)
  • std::fs, std::net (require OS file system and networking stack)
  • std::io (requires OS I/O subsystem)
  • Panic backtraces (require unwinding, which needs OS support)

What you keep with core:

  • Option<T> and Result<T, E> (just enums!)
  • Iterators and iterator adapters
  • core::fmt for formatting (but no default output destination)
  • Mathematical operations
  • Slice operations (&[T], &str)
  • core::ptr for raw pointer manipulation
  • PhantomData, Copy, Clone, and other marker traits
  • core::mem for memory intrinsics
  • core::sync::atomic for atomic operations

Book Reference: “The Secret Life of Programs” by Jonathan Steinhart, Chapter 5


The Boot Process: From Power Button to Your Code

When you press the power button, a precisely choreographed sequence begins. Understanding this sequence is crucial for writing kernel code:

+-------------------------------------------------------------------------+
|                          THE BOOT SEQUENCE                              |
+-------------------------------------------------------------------------+

[1] POWER ON
    |
    v
+-------------------+
|      BIOS/UEFI    |  <- Firmware burned into ROM on the motherboard
+-------------------+  <- Runs first, initializes hardware, finds bootloader
    |
    | Loads first 512 bytes from disk (MBR) into 0x7C00
    v
+-------------------+
|    BOOTLOADER     |  <- GRUB, rEFInd, or your custom Stage 1/2
+-------------------+  <- Switches CPU from Real Mode (16-bit) to Protected/Long Mode (64-bit)
    |                  <- Loads your kernel binary into memory
    v                  <- Sets up initial page tables, GDT
+-------------------+
|    YOUR KERNEL    |  <- Starts execution at _start entry point
+-------------------+  <- YOU ARE HERE
    |
    | Your kernel eventually starts...
    v
+-------------------+
|       init        |  <- First userspace process (on a full OS)
+-------------------+

The CPU Mode Transitions:

Real Mode (16-bit)          Protected Mode (32-bit)         Long Mode (64-bit)
     |                             |                              |
     | 8086 compatibility          | Virtual memory available     | 64-bit addresses
     | 1MB address space           | 4GB address space            | 256TB address space
     | No memory protection        | Ring-based protection        | Full protection
     |                             |                              |
     +---- BIOS runs here ----+    +---- Legacy systems ----+     +---- Modern 64-bit ----+
                              |                            |                            |
                              +------ Your bootloader handles these transitions ------+

Why you need a bootloader: Your Rust code compiles to 64-bit machine code, but the CPU starts in 16-bit Real Mode (for backward compatibility with the 1978 Intel 8086). Someone needs to:

  1. Switch the CPU to 64-bit Long Mode
  2. Set up initial page tables (required for Long Mode)
  3. Load your kernel binary from disk into memory
  4. Jump to your kernel’s entry point

We use the bootloader crate, which handles all of this for us.

Book Reference: “Computer Systems: A Programmer’s Perspective” by Bryant & O’Hallaron, Chapter 7


Memory-Mapped I/O (MMIO): Talking to Hardware

In a modern computer, hardware devices don’t have their own instruction set. Instead, they are “mapped” into the CPU’s address space. Writing to a specific memory address doesn’t write to RAM; it sends a command to a device.

+-------------------------------------------------------------------------+
|                     x86 MEMORY MAP AT BOOT                              |
+-------------------------------------------------------------------------+

 0xFFFFFFFF  +------------------+
             |  High Memory     |
             |  (Extended RAM)  |
             |                  |
 0x100000    +------------------+  <- 1MB mark. Your kernel loads here.
             |  BIOS ROM        |
 0xF0000     +------------------+
             |  Video BIOS ROM  |
 0xC0000     +------------------+
             |                  |
             |  VGA MEMORY      |
 0xB8000     +------------------+  <- VGA TEXT BUFFER (4000 bytes)
             |                  |
 0xA0000     +------------------+  <- 640KB mark ("640K ought to be enough")
             |                  |
             |  Conventional    |
             |  Memory (RAM)    |
             |                  |
 0x00000     +------------------+

The VGA Text Buffer:

The VGA text buffer is a 2D array of characters mapped to physical address 0xB8000. It is 80 columns by 25 rows = 2000 characters. Each character takes 2 bytes:

VGA Text Buffer Entry (16 bits)
+--------+--------+
| Byte 0 | Byte 1 |
+--------+--------+
|  ASCII | Attrib |
|  Code  |  utes  |
+--------+--------+

Attribute Byte:
  Bit 7:     Blink (or bright background, depending on mode)
  Bits 6-4:  Background color (0-7)
  Bits 3-0:  Foreground color (0-15)

Color Values:
  0 = Black      4 = Red        8 = Dark Gray    12 = Light Red
  1 = Blue       5 = Magenta    9 = Light Blue   13 = Pink
  2 = Green      6 = Brown      10 = Light Green 14 = Yellow
  3 = Cyan       7 = Light Gray 11 = Light Cyan  15 = White

Example: Writing ‘A’ in green on black at position (0, 0):

Address: 0xB8000 + (0 * 160) + (0 * 2) = 0xB8000
Write:   [0x41, 0x0A]  <- 0x41 = 'A', 0x0A = green (2) on black (0)

Memory at 0xB8000:
+------+------+------+------+------+------+------+------+
| 0x41 | 0x0A | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | ...
+------+------+------+------+------+------+------+------+
   'A'  green   (empty screen positions...)

Why volatile writes are essential:

The compiler is very smart. If it sees you write to a memory location that you never read from, it might “optimize away” the write. But when you write to the VGA buffer, you ARE reading from it; the monitor reads from it! The compiler doesn’t know this.

// BAD: Compiler might optimize this away
let vga_buffer = 0xB8000 as *mut u8;
unsafe { *vga_buffer = 0x41; }  // Compiler: "Nobody reads this. Skip it."

// GOOD: Compiler must perform the write
use core::ptr::write_volatile;
unsafe { write_volatile(vga_buffer, 0x41); }  // Compiler: "I must do this."

Book Reference: “Operating Systems: Three Easy Pieces” by Remzi & Andrea Arpaci-Dusseau, Chapter on Memory-Mapped I/O


Linker Scripts: Controlling Memory Layout

When you compile a normal program, the linker decides where to put your code and data in memory. But a kernel isn’t a normal program. You must tell the linker exactly where everything goes because the bootloader will load your kernel to a specific address.

+-------------------------------------------------------------------------+
|                      KERNEL BINARY SECTIONS                             |
+-------------------------------------------------------------------------+

.text (Code)
  Contains: Executable instructions
  Loaded at: Usually 0x100000 (1MB mark in x86_64)
  Permissions: Read + Execute

.rodata (Read-Only Data)
  Contains: String literals, constants
  Permissions: Read only

.data (Initialized Data)
  Contains: Global variables with initial values
  Permissions: Read + Write

.bss (Uninitialized Data)
  Contains: Global variables that start as zero
  Permissions: Read + Write
  Note: Does NOT take space in the binary file; the loader zeros this region

+----------------+
|     .text      |  <- Entry point (_start) is here
+----------------+
|    .rodata     |  <- "Hello, World!" string is here
+----------------+
|     .data      |  <- Initialized globals
+----------------+
|     .bss       |  <- Stack pointer starts here (grows downward)
+----------------+

The _start entry point:

Every executable needs an entry point, a symbol that the loader jumps to. In standard Rust programs, this is handled by the main function via the runtime. Without a runtime, you define it yourself:

#[no_mangle]  // Don't mangle the name; linker needs to find "_start"
pub extern "C" fn _start() -> ! {
    // Your kernel starts here
    loop {}  // Return type is `!` (never) because kernels don't return
}

Why extern "C"? The bootloader is written in C (or assembly), so it expects the C calling convention. Rust’s default calling convention is not stable.

Why -> !? A kernel never “returns.” There’s nothing to return to! The ! type (called “never”) tells the compiler this function runs forever.

Book Reference: “Computer Systems: A Programmer’s Perspective” by Bryant & O’Hallaron, Chapter 7


The #[panic_handler] Attribute

In standard Rust, when a panic occurs, the runtime prints a message and unwinds the stack. But without std, there is no runtime. You must define what happens on panic:

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
    // _info contains the panic message and location
    // We could print to VGA here, but for now:
    loop {}  // Halt forever
}

What the panic handler receives:

PanicInfo structure:
+-------------------------------------------+
| message: Option<&Arguments>               |  <- The panic message
| location: Option<&Location>               |  <- File, line, column
+-------------------------------------------+
     |
     +-> Location {
           file: &'static str,    <- "src/main.rs"
           line: u32,             <- 42
           column: u32,           <- 5
         }

Book Reference: “The Rust Programming Language”, Chapter 9


Cross-Compilation and Target Specifications

Your development machine runs Linux, macOS, or Windows with an OS and a standard library. Your kernel runs on bare metal with neither. You must cross-compile.

The target triple:

x86_64-unknown-none
  |       |      |
  |       |      +-- OS (none = freestanding, no OS)
  |       +--------- Vendor (unknown = unspecified)
  +--------------------- Architecture (x86 64-bit)

Custom target JSON:

For bare-metal kernels, we often create a custom target specification:

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

Key settings explained:

Setting Purpose
panic-strategy: abort Don’t try to unwind; just abort on panic
disable-redzone The “red zone” is a 128-byte area below the stack that user programs can use. Interrupts would clobber it, so kernels disable it
features: -mmx,-sse Disable SIMD instructions; kernels shouldn’t use floating point without explicit setup

Learning Objectives

By the end of this project, you will be able to:

  1. Explain the Rust library hierarchy (core, alloc, std) and what each layer requires
  2. Describe the x86 boot sequence from BIOS to kernel entry
  3. Implement a freestanding Rust binary without the standard library
  4. Write a custom panic handler that satisfies the Rust language requirement
  5. Use volatile writes to communicate with memory-mapped hardware
  6. Understand linker scripts and how they control binary layout
  7. Cross-compile Rust for a bare-metal target
  8. Debug a kernel using QEMU and serial port output
  9. Configure the VGA text buffer to display colored text
  10. Appreciate what the operating system does for you every day

Solution Architecture

Before coding, visualize what you are building:

Project Structure

nostd-kernel/
+-- .cargo/
|   +-- config.toml         <- Cross-compilation settings
+-- src/
|   +-- main.rs             <- Entry point and panic handler
|   +-- vga_buffer.rs       <- VGA text buffer abstraction
+-- Cargo.toml              <- Dependencies (bootloader, volatile)
+-- x86_64-custom.json      <- (Optional) Custom target spec

.cargo/config.toml

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

[build]
target = "x86_64-unknown-none"

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

The Entry Point (_start)

#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[no_mangle]
pub extern "C" fn _start() -> ! {
    // Write "HELLO" to VGA buffer at 0xB8000
    let vga_buffer = 0xb8000 as *mut u8;
    let hello = b"HELLO";

    for (i, &byte) in hello.iter().enumerate() {
        unsafe {
            core::ptr::write_volatile(vga_buffer.add(i * 2), byte);
            core::ptr::write_volatile(vga_buffer.add(i * 2 + 1), 0x0b); // cyan
        }
    }

    loop {}
}

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

VGA Buffer Writer Struct

pub struct VgaWriter {
    column: usize,
    row: usize,
    color: u8,
    buffer: *mut u8,
}

impl VgaWriter {
    pub const fn new() -> Self {
        Self {
            column: 0,
            row: 0,
            color: 0x0f,  // white on black
            buffer: 0xb8000 as *mut u8,
        }
    }

    pub fn write_byte(&mut self, byte: u8) {
        match byte {
            b'\n' => self.new_line(),
            byte => {
                if self.column >= 80 {
                    self.new_line();
                }
                let offset = (self.row * 80 + self.column) * 2;
                unsafe {
                    core::ptr::write_volatile(self.buffer.add(offset), byte);
                    core::ptr::write_volatile(self.buffer.add(offset + 1), self.color);
                }
                self.column += 1;
            }
        }
    }

    fn new_line(&mut self) {
        self.column = 0;
        self.row += 1;
        if self.row >= 25 {
            self.scroll();
        }
    }

    fn scroll(&mut self) {
        // Move all lines up by one
        for row in 1..25 {
            for col in 0..80 {
                let src = ((row * 80 + col) * 2) as isize;
                let dst = (((row - 1) * 80 + col) * 2) as isize;
                unsafe {
                    let c = core::ptr::read_volatile(self.buffer.offset(src));
                    let a = core::ptr::read_volatile(self.buffer.offset(src + 1));
                    core::ptr::write_volatile(self.buffer.offset(dst), c);
                    core::ptr::write_volatile(self.buffer.offset(dst + 1), a);
                }
            }
        }
        // Clear the last row
        for col in 0..80 {
            let offset = (24 * 80 + col) * 2;
            unsafe {
                core::ptr::write_volatile(self.buffer.add(offset), b' ');
                core::ptr::write_volatile(self.buffer.add(offset + 1), self.color);
            }
        }
        self.row = 24;
    }
}

Phased Implementation Guide

Phase 1: Create the Freestanding Binary

Goal: Get a Rust binary that compiles without the standard library.

Steps:

  1. Create a new project: cargo new nostd-kernel
  2. Add to Cargo.toml:
    [package]
    name = "nostd-kernel"
    version = "0.1.0"
    edition = "2021"
    
    [profile.dev]
    panic = "abort"
    
    [profile.release]
    panic = "abort"
    
  3. Replace src/main.rs:
    #![no_std]
    #![no_main]
    
    use core::panic::PanicInfo;
    
    #[no_mangle]
    pub extern "C" fn _start() -> ! {
        loop {}
    }
    
    #[panic_handler]
    fn panic(_info: &PanicInfo) -> ! {
        loop {}
    }
    
  4. Create .cargo/config.toml:
    [unstable]
    build-std = ["core", "compiler_builtins"]
    build-std-features = ["compiler-builtins-mem"]
    
    [build]
    target = "x86_64-unknown-none"
    
  5. Build: cargo build --release

Checkpoint: The build should succeed with no errors.


Phase 2: Set Up the Bootloader

Goal: Create a bootable image that QEMU can run.

Steps:

  1. Add dependencies to Cargo.toml:
    [dependencies]
    bootloader = "0.9"
    
    [dependencies.bootloader]
    version = "0.9"
    features = []
    
  2. Install bootimage: cargo install bootimage
  3. Install QEMU:
    • macOS: brew install qemu
    • Ubuntu: apt install qemu-system-x86
  4. Add runner to .cargo/config.toml:
    [target.'cfg(target_os = "none")']
    runner = "bootimage runner"
    
  5. Build the bootable image: cargo bootimage
  6. Run in QEMU: qemu-system-x86_64 -drive format=raw,file=target/x86_64-unknown-none/debug/bootimage-nostd-kernel.bin

Checkpoint: QEMU should boot and display a blank screen (no crash).


Phase 3: Write to the VGA Buffer

Goal: Display text on the screen without any library support.

Steps:

  1. Update _start:
    #[no_mangle]
    pub extern "C" fn _start() -> ! {
        let vga_buffer = 0xb8000 as *mut u8;
    
        for (i, &byte) in b"RUST KERNEL LIVES!".iter().enumerate() {
            unsafe {
                core::ptr::write_volatile(vga_buffer.add(i * 2), byte);
                core::ptr::write_volatile(vga_buffer.add(i * 2 + 1), 0x0a); // green
            }
        }
    
        loop {}
    }
    
  2. Rebuild and run

Checkpoint: “RUST KERNEL LIVES!” appears in green at the top-left.


Phase 4: Create the VGA Writer Abstraction

Goal: Build a reusable writer that handles newlines and scrolling.

Steps:

  1. Create src/vga_buffer.rs with the VgaWriter struct from the solution architecture
  2. Add mod vga_buffer; to main.rs
  3. Implement core::fmt::Write for VgaWriter:
    use core::fmt::{self, Write};
    
    impl Write for VgaWriter {
        fn write_str(&mut self, s: &str) -> fmt::Result {
            for byte in s.bytes() {
                self.write_byte(byte);
            }
            Ok(())
        }
    }
    
  4. Use write! macro in _start:
    use core::fmt::Write;
    
    let mut writer = vga_buffer::VgaWriter::new();
    write!(writer, "Welcome to RustOS v0.1\n").unwrap();
    write!(writer, "Memory: {} bytes\n", 128 * 1024 * 1024).unwrap();
    

Checkpoint: Formatted output with numbers works correctly.


Phase 5: Implement a Better Panic Handler

Goal: Display panic messages on screen instead of just halting.

Steps:

  1. Create a static global writer (requires spin crate for mutex):
    # Cargo.toml
    [dependencies]
    spin = "0.9"
    
  2. Create a global writer:
    use spin::Mutex;
    use lazy_static::lazy_static;
    
    lazy_static! {
        pub static ref WRITER: Mutex<VgaWriter> = Mutex::new(VgaWriter::new());
    }
    
  3. Update panic handler:
    #[panic_handler]
    fn panic(info: &PanicInfo) -> ! {
        use core::fmt::Write;
    
        let mut writer = crate::vga_buffer::WRITER.lock();
        writer.set_color(0x4f);  // white on red
        write!(writer, "\n!!! KERNEL PANIC !!!\n").unwrap();
    
        if let Some(location) = info.location() {
            write!(writer, "  at {}:{}\n", location.file(), location.line()).unwrap();
        }
    
        loop {}
    }
    

Checkpoint: panic!("test") displays a red error message.


Phase 6: Add Serial Port Output (For Debugging)

Goal: Output text to QEMU’s serial console for easier debugging.

Steps:

  1. Add the uart_16550 crate:
    [dependencies]
    uart_16550 = "0.2"
    
  2. Create src/serial.rs:
    use uart_16550::SerialPort;
    use spin::Mutex;
    use lazy_static::lazy_static;
    
    lazy_static! {
        pub static ref SERIAL: Mutex<SerialPort> = {
            let mut port = unsafe { SerialPort::new(0x3F8) };
            port.init();
            Mutex::new(port)
        };
    }
    
    #[macro_export]
    macro_rules! serial_println {
        () => ($crate::serial_print!("\n"));
        ($($arg:tt)*) => ($crate::serial_print!("{}\n", format_args!($($arg)*)));
    }
    
    #[macro_export]
    macro_rules! serial_print {
        ($($arg:tt)*) => {
            $crate::serial::_print(format_args!($($arg)*));
        };
    }
    
    pub fn _print(args: core::fmt::Arguments) {
        use core::fmt::Write;
        SERIAL.lock().write_fmt(args).unwrap();
    }
    
  3. Run QEMU with serial output:
    qemu-system-x86_64 -drive format=raw,file=bootimage.bin -serial stdio
    

Checkpoint: serial_println!("Debug message") appears in your terminal.


Testing Strategy

Unit Tests in Hosted Environment

Create a separate library crate that can be tested on your host machine:

// src/lib.rs (compiled for host)
#[cfg(test)]
mod tests {
    #[test]
    fn test_vga_offset_calculation() {
        let row = 5;
        let col = 10;
        let offset = (row * 80 + col) * 2;
        assert_eq!(offset, 820);
    }

    #[test]
    fn test_color_byte() {
        let fg = 0x0a;  // green
        let bg = 0x00;  // black
        let color = (bg << 4) | fg;
        assert_eq!(color, 0x0a);
    }
}

Run with: cargo test --target x86_64-unknown-linux-gnu

Integration Tests with QEMU

Create a test framework that uses QEMU’s exit device:

// tests/should_panic.rs
#![no_std]
#![no_main]
#![feature(custom_test_frameworks)]
#![test_runner(test_runner)]
#![reexport_test_harness_main = "test_main"]

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

fn test_runner(tests: &[&dyn Fn()]) {
    serial_println!("Running {} tests", tests.len());
    for test in tests {
        test();
    }
    exit_qemu(QemuExitCode::Success);
}

#[test_case]
fn trivial_assertion() {
    assert_eq!(1, 1);
}

Serial Port Debugging

The serial port is your primary debugging tool. Use it liberally:

serial_println!("[DEBUG] Entering function foo");
serial_println!("[DEBUG] value = {:#x}", value);

Common Pitfalls and Debugging

Pitfall 1: Forgetting #![no_main]

Symptom: Linker error about undefined reference to main

Solution: Add #![no_main] to your crate root

Pitfall 2: Missing #[no_mangle] on _start

Symptom: Kernel boots but immediately crashes or does nothing

Solution: Add #[no_mangle] to preserve the _start symbol name

Pitfall 3: Not Using Volatile Writes

Symptom: VGA writes work in debug mode but not in release mode

Solution: Use core::ptr::write_volatile instead of *ptr = value

Pitfall 4: Stack Overflow Before Memory is Set Up

Symptom: Immediate crash or strange behavior at boot

Solution: Keep _start simple; avoid large stack allocations before initializing the allocator

Pitfall 5: Forgetting Panic = Abort

Symptom: Linker errors about eh_personality or unwinding symbols

Solution: Add panic = "abort" to [profile.dev] and [profile.release] in Cargo.toml

Pitfall 6: Red Zone Not Disabled

Symptom: Random crashes when interrupts fire

Solution: Use a target that disables the red zone, or add "disable-redzone": true to your custom target JSON

Pitfall 7: Using Floating Point Without Setup

Symptom: Illegal instruction exception when using f32 or f64

Solution: Disable SSE in your target: "features": "-mmx,-sse,+soft-float", or explicitly enable and save/restore SSE state


Extensions and Challenges

Once your basic kernel works, try these extensions:

Extension 1: Interrupt Handling

Set up the Interrupt Descriptor Table (IDT) to handle:

  • Page faults (you’ll need these for memory management)
  • Timer interrupts (for preemptive multitasking)
  • Keyboard interrupts (for input)
use x86_64::structures::idt::{InterruptDescriptorTable, InterruptStackFrame};

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        idt
    };
}

extern "x86-interrupt" fn breakpoint_handler(stack_frame: InterruptStackFrame) {
    serial_println!("BREAKPOINT\n{:#?}", stack_frame);
}

Extension 2: Basic Heap Allocator

Implement a simple bump allocator:

use alloc::alloc::{GlobalAlloc, Layout};

struct BumpAllocator {
    heap_start: usize,
    heap_end: usize,
    next: AtomicUsize,
}

unsafe impl GlobalAlloc for BumpAllocator {
    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
        // Align next pointer
        // Bump and return
    }

    unsafe fn dealloc(&self, _ptr: *mut u8, _layout: Layout) {
        // Bump allocators don't deallocate individually
    }
}

#[global_allocator]
static ALLOCATOR: BumpAllocator = BumpAllocator::new();

Extension 3: Keyboard Driver

Read scan codes from the PS/2 keyboard controller:

use x86_64::instructions::port::Port;

fn read_scancode() -> u8 {
    let mut port = Port::new(0x60);
    unsafe { port.read() }
}

Extension 4: Simple Shell

Once you have keyboard input, create a command prompt:

RustOS> help
Available commands:
  help    - Show this help
  clear   - Clear the screen
  reboot  - Reboot the system

RustOS> _

Real-World Connections

Your work connects to real operating systems and projects:

Redox OS

A Unix-like operating system written entirely in Rust. Studies in microkernel architecture.

  • Website: https://www.redox-os.org/
  • Source: https://gitlab.redox-os.org/redox-os/redox

Theseus OS

A research OS exploring new OS structuring techniques using Rust’s type system.

  • Paper: “Theseus: An Experiment in Operating System Structure and State Management”
  • Source: https://github.com/theseus-os/Theseus

Writing an OS in Rust (Blog)

Philipp Oppermann’s excellent tutorial series that goes far deeper than this project.

  • URL: https://os.phil-opp.com/
  • This is the definitive resource for continuing your OS journey.

Linux Kernel Rust Support

As of Linux 6.1, Rust is an officially supported language for kernel modules.

  • Documentation: https://docs.kernel.org/rust/

The Interview Questions They Will Ask

  1. “What is the difference between core and alloc crates?”
    • core provides types and traits that require no runtime support.
    • alloc adds heap-allocated types like Box, Vec, String but requires a global allocator.
  2. “How do you implement a global allocator in a no_std environment?”
    • Implement the GlobalAlloc trait with alloc and dealloc methods.
    • Mark your allocator with #[global_allocator].
    • The allocator manages a memory region (usually defined by the bootloader).
  3. “What is the purpose of the #[panic_handler] attribute?”
    • It marks the function the compiler should call when a panic occurs.
    • Without std, there’s no default; you must provide one.
    • It must diverge (return !) because panics don’t return.
  4. “What is a ‘freestanding’ binary?”
    • A binary that doesn’t depend on an OS or runtime.
    • It defines its own entry point, panic handler, and memory management.
    • It’s compiled with #![no_std] and #![no_main].
  5. “Why do we need volatile writes for memory-mapped I/O?”
    • The compiler optimizes away writes it thinks are unused.
    • Memory-mapped I/O “reads” happen via hardware, not code.
    • write_volatile prevents the optimization and guarantees the write occurs.

Real World Outcome

When complete, you will have:

$ cargo bootimage
   Compiling nostd-kernel v0.1.0
    Finished release [optimized] target(s) in 2.34s
Building bootable disk image...
   Created bootimage

$ qemu-system-x86_64 -drive format=raw,file=target/x86_64-unknown-none/release/bootimage-nostd-kernel.bin

[QEMU Window Opens]
+------------------------------------------------------------------+
|  RUST KERNEL v0.1.0                                              |
|  Hardware initialized.                                           |
|  Memory: 128MB detected.                                         |
|  VGA Buffer Initialized at 0xb8000.                             |
|  Writing 'Hello, World!' to screen...                           |
|  > _                                                             |
|                                                                  |
|                                                                  |
+------------------------------------------------------------------+

A .bin file that you can boot in QEMU, displaying your custom text on the screen without any OS running underneath. You are now one of the few developers who has written code that runs directly on the CPU with no operating system support.


Books That Will Help

Topic Book Chapter
Startup & Linking “Computer Systems: A Programmer’s Perspective” Ch. 7
VGA & Hardware “How Computers Really Work” Ch. 8
Embedded Rust “The Secret Life of Programs” Ch. 5
OS Fundamentals “Operating Systems: Three Easy Pieces” Part II

Summary

This project strips away everything you take for granted, revealing the raw substrate upon which all software runs. You have learned:

  • The Rust library hierarchy and what remains when you remove the OS
  • The x86 boot sequence from power-on to kernel entry
  • How to write code that runs with no runtime support
  • Memory-mapped I/O and volatile access
  • Cross-compilation for bare-metal targets
  • The satisfaction of seeing YOUR code run on the naked machine

You are now equipped to understand operating systems at their most fundamental level. The next steps, interrupt handling, memory management, and process scheduling, will build on this foundation.

Welcome to the world of systems programming. The machine is now yours.