Hello. The previous two lessons focused on how x86-64 crosses the userspace–kernel boundary and what state the processor preserves when it does. We now establish a much smaller but crucial environment: a kernel that boots without Linux beneath it and can report what it is doing to the host.
The goal is not yet to manage memory, install interrupts, or enter user mode. It is to create a reliable development loop:
- compile Rust for a target with no operating system;
- package the resulting kernel into a bootable image;
- boot that image in QEMU; and
- receive diagnostic text through an emulated serial port.
By the end, a line printed in your host terminal will be evidence that the CPU reached your freestanding Rust entry point.
What “freestanding” changes
An ordinary Rust program assumes a host environment. The standard library expects services such as allocation, files, threads, process startup, and system-call support. A kernel has none of these when it first begins execution.
So a freestanding kernel explicitly takes responsibility for the pieces normally hidden by the runtime:
#![no_std]removes the standard library dependency.#![no_main]declines Rust’s normal program startup path.- A symbol such as
_startbecomes the code entry point. - A
#[panic_handler]specifies what happens when Rust panics. - The kernel must never return from its entry point, because there is no caller to return to.
The following short segment of rust runs on EVERYTHING by Low Level gives a useful platform-independent introduction to these requirements. It uses ARM and a Raspberry Pi, not x86-64 and QEMU, so focus on the freestanding Rust concepts rather than copying its target or linker configuration.
rust runs on EVERYTHING (no operating system, just Rust)
Watch “rust runs on EVERYTHING (no operating system, just Rust)” by Low Level for the core distinction between a hosted Rust program and a bare-metal one.
Watch the freestanding setup. Focus on why a target with no OS cannot use the standard library, why the entry-point symbol must be visible to the linker, and why a panic handler is mandatory.
A bootable kernel involves more than the Rust executable. Cargo produces an ELF file: a structured executable containing program sections, symbols, and metadata. QEMU’s virtual firmware needs a bootable disk image with a bootloader that knows how to load the kernel and transfer control to it.
For this lesson, use the Rust bootimage workflow and its x86-64 bootloader rather than writing a boot protocol yourself. That lets us concentrate on the kernel boundary while still producing a real bootable image.
The build contract: target, compiler, and boot image
Create a project directory with this shape:
kernel/
├── .cargo/
│ └── config.toml
├── src/
│ ├── main.rs
│ └── serial.rs
├── Cargo.toml
└── x86_64-kernel.json
You need a nightly Rust toolchain because rebuilding Rust’s low-level core crate for a custom target uses unstable Cargo support. You also need QEMU and the bootimage tool installed on the host. A typical preparation sequence is:
rustup toolchain install nightly
rustup component add rust-src llvm-tools-preview --toolchain nightly
cargo +nightly install bootimage
The kernel target definition in x86_64-kernel.json tells Rust that the output will execute directly on a 64-bit x86 processor rather than within Linux:
{
"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",
"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"
}
Several lines are kernel-specific rather than mere build trivia:
"os": "none"prevents the compiler from assuming a hosted OS ABI."panic-strategy": "abort"avoids requiring stack unwinding infrastructure that does not exist yet."disable-redzone": truedisables the x86-64 ABI’s 128-byte red zone belowRSP. An interrupt or exception can overwrite that area, so kernel code cannot safely treat it as scratch storage.- Disabling SSE and MMX avoids compiler-generated SIMD use before the kernel has established a policy for floating-point and extended-register state.
Then configure Cargo in .cargo/config.toml:
[build]
target = "x86_64-kernel.json"
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]
The important distinction is that core is not std. core supplies essentials such as integer operations, slices, formatting traits, and Option, without assuming an allocator, filesystem, or OS services. The compiler_builtins component provides low-level routines the compiler may need, including memory operations.
Use a deliberately small Cargo.toml:
[package]
name = "kernel"
version = "0.1.0"
edition = "2021"
[dependencies]
bootloader = "0.9"
uart_16550 = "0.6.0"
spin = "0.9"
lazy_static = { version = "1.5", default-features = false, features = ["spin_no_std"] }
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
The bootimage and bootloader ecosystem has evolved across Rust releases. If Cargo reports an incompatibility, keep the bootimage tool, bootloader dependency, and nightly toolchain within a mutually compatible tutorial-era set rather than randomly upgrading one component. The architectural contract remains the same: a bootloader loads a no-OS kernel and calls its entry point.
A kernel entry point that cannot return
Put this in src/main.rs:
#![no_std]
#![no_main]
mod serial;
use core::panic::PanicInfo;
#[no_mangle]
pub extern "C" fn _start() -> ! {
serial_println!("kernel: entered _start");
serial_println!("kernel: serial logger is ready");
loop {
core::hint::spin_loop();
}
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
serial_println!("kernel panic: {}", info);
loop {
core::hint::spin_loop();
}
}
There are several contracts packed into this small file.
#[no_mangle] preserves the symbol name _start. Rust normally mangles names so that symbols encode crate and type information; a bootloader and linker cannot guess a mangled name. extern "C" selects a stable, conventional calling ABI. The return type ! means “never returns,” which accurately describes both the normal kernel loop and the panic path.
The code prints before the infinite loop. That order matters diagnostically:
- If QEMU never begins, the issue is in host tooling or the image path.
- If QEMU starts but no serial text appears, the issue is likely boot packaging, the entry point, or serial configuration.
- If the first message appears, the bootloader reached
_startand your Rust code is executing. - If only the first message appears, inspect the code immediately after it.
At this stage, the infinite loop is intentional. A real kernel eventually replaces passive spinning with interrupt-driven idle behavior, but no interrupts have been configured yet.
Serial output: your first dependable kernel log
A QEMU graphics window and a serial console are different output paths. The screenshot below shows a kernel that visibly printed to QEMU’s emulated VGA text display. It is a valid boot confirmation, but serial logging is usually more useful because it remains in the host terminal, can be captured to a file, and works when QEMU runs without a visible display.

For serial logging, the kernel writes bytes to the emulated first serial device, conventionally called COM1. On x86 its base I/O port is 0x3F8. This is port-mapped I/O, not a normal RAM address: writes compile into privileged x86 I/O instructions rather than ordinary memory stores.
Read the serial-port and QEMU-redirection sections of Philipp Oppermann’s Testing | Writing an OS in Rust. The code uses the uart_16550 crate and demonstrates the exact QEMU serial redirection we need.
Testing | Writing an OS in Rust
Read “Testing | Writing an OS in Rust” by Philipp Oppermann to connect the UART hardware abstraction, a no-std formatting interface, and QEMU’s host-side serial redirection.
In the “Printing to the Console” section, read the serial-port discussion, including the SERIAL1 initialization and the serial_print macros. Then continue to the “QEMU Arguments” section and read the QEMU redirection paragraph. Focus on the role of COM1 at port 0x3F8 and why -serial stdio makes guest bytes visible on the host.
Now add src/serial.rs:
use core::fmt;
use core::fmt::Write;
use lazy_static::lazy_static;
use spin::Mutex;
use uart_16550::{backend::PioBackend, Config, Uart16550Tty};
lazy_static! {
static ref SERIAL1: Mutex<Uart16550Tty<PioBackend>> = Mutex::new(
unsafe {
Uart16550Tty::new_port(0x3F8, Config::default())
.expect("failed to initialize UART")
}
);
}
#[doc(hidden)]
pub fn _print(args: fmt::Arguments) {
SERIAL1
.lock()
.write_fmt(args)
.expect("serial output failed");
}
#[macro_export]
macro_rules! serial_print {
($($arg:tt)*) => {
$crate::serial::_print(format_args!($($arg)*));
};
}
#[macro_export]
macro_rules! serial_println {
() => {
$crate::serial_print!("\n");
};
($fmt:expr) => {
$crate::serial_print!(concat!($fmt, "\n"));
};
($fmt:expr, $($arg:tt)*) => {
$crate::serial_print!(concat!($fmt, "\n"), $($arg)*);
};
}
The uart_16550 crate knows the device’s register protocol, while PioBackend tells it to use x86 port I/O. Creating the UART is unsafe because Rust cannot prove that port 0x3F8 names a real, safe device. In QEMU’s default x86 machine, it does.
The Mutex is a spinlock: it prevents two kernel contexts from interleaving their output bytes. Although this tiny kernel has only one execution path, the design matters once exceptions and interrupts can also log messages. Later you will need to consider carefully whether an interrupt may attempt to acquire a lock already held by interrupted code; that is a possible deadlock. For now, interrupts are not enabled, so the lock gives a clean interface without introducing that hazard.
format_args! and core::fmt::Write provide familiar Rust formatting without std. The macros are intentionally thin wrappers; serial_println!("value: {}", value) should feel like ordinary logging, but every byte ultimately crosses the virtual serial device boundary.
Build the image and boot it under QEMU
Build the bootable disk image:
cargo +nightly bootimage
A successful build produces an artifact resembling:
target/x86_64-kernel/debug/bootimage-kernel.bin
The precise package and target names determine the final path. If unsure, locate the generated image:
find target -name 'bootimage-*.bin'
Then boot it directly with QEMU. Substitute the image path if your artifact name differs:
qemu-system-x86_64 \
-drive format=raw,file=target/x86_64-kernel/debug/bootimage-kernel.bin \
-display none \
-serial stdio
-display none suppresses the graphical VGA window. -serial stdio connects the guest’s COM1 UART to your terminal’s standard input and output. The expected host output is:
kernel: entered _start
kernel: serial logger is ready
QEMU will continue running because the kernel deliberately loops forever. Stop it from the terminal with the QEMU console escape sequence, commonly Ctrl-A followed by X, or terminate the QEMU process from another terminal.
A clean terminal log is stronger evidence than “QEMU opened.” It verifies, in order:
- QEMU accepted the disk image.
- Its firmware booted the image.
- The bootloader loaded your kernel.
- Control reached the
_startsymbol. - The UART was initialized at the expected I/O port.
- QEMU redirected serial bytes to the host terminal.
That is the first usable observability channel for the kernel. Keep it available whenever you make changes involving entry code, memory setup, or exceptions.
Common early failures
| Symptom | Likely boundary to inspect |
|---|---|
can't find crate for core | Nightly, rust-src, or [unstable] build-std configuration |
| Linker errors mentioning host libraries or startup objects | The custom no-OS target is not being selected |
| QEMU reports it cannot open the drive file | The bootimage artifact path is wrong |
| QEMU runs but terminal stays blank | _start was not reached, UART configuration is wrong, or -serial stdio is missing |
| Rust reports no panic handler | #![no_std] requires your #[panic_handler] |
| QEMU exits or resets immediately | Inspect the earliest entry code; a fault before serial initialization leaves no log yet |
For this lesson, resist the temptation to add paging, allocation, or interrupts to “improve” the kernel. A small bootable artifact with dependable serial output is the baseline that makes those later additions debuggable.
You now have a freestanding Rust kernel that QEMU can boot and a serial-console path from kernel code to the host terminal. The essential ideas are that no_std removes hosted assumptions, _start replaces the normal runtime entry point, the bootloader turns a kernel executable into a bootable image, and the emulated 16550 UART provides early logging through port 0x3F8.
Next, you will attach GDB to a running QEMU instance and inspect the kernel’s instructions, registers, and stack directly. Serial text tells you that the kernel reached a point; the debugger will show the exact machine state at that point.
Can't find a good explanation? Sign up and we'll make it for you
Sign up