Booting a Minimal Kernel in Rust With Bootloader and Qemu
Boot a minimal Rust kernel in QEMU: a no_std crate, the bootloader crate, a disk image, a panic handler and writing text to the serial port in a few dozen lines.

The shortest path from "I have never written a kernel" to "my own Rust code is running on a virtual PC with no operating system" is three pieces: a freestanding Rust binary, a bootloader that gets it into 64-bit mode with memory mapped, and QEMU to run the result. This guide builds all three and ends with a kernel that prints to the serial port, reports how much memory the machine has, and exits QEMU with a status code your shell can read. It also builds the failure you will meet on day one, a panic that never prints, and fixes it. Everything here was run: the kernel was compiled with stable Rust 1.99, the disk image with nightly rustc 1.101 (the bootloader crate requires it), and executed in QEMU 11.1 on an Apple M1 Max, emulating an x86_64 PC. Expect about 30 minutes, most of it the first compile of the bootloader.
What you are building
A kernel on an x86_64 PC does not start by itself. When the machine powers on, firmware (BIOS or UEFI) loads a small program from the disk, and that program, the bootloader, switches the CPU through its legacy modes into 64-bit long mode, sets up page tables, finds out where the memory is, loads your kernel image into it and jumps to your entry point. Writing that is a project of its own, so you use the bootloader crate, which provides a BIOS bootloader (and a UEFI one) and hands your kernel a BootInfo structure describing the machine. Your kernel is therefore a normal Rust crate with three unusual properties: it links no standard library, it has no operating system under it, and its entry point receives the boot information.
You need two crates, and the reason is worth understanding. The kernel crate is built for the target x86_64-unknown-none, a bare-metal target that ships with Rust. The image builder is an ordinary program for your host machine that takes the built kernel and wraps it with the bootloader into a bootable disk image. It is a separate crate because it runs on your laptop, not inside the virtual machine, and because its dependency (bootloader) compiles the real-mode boot stages from source in a build script that needs nightly Rust features.
Install the pieces
rustup target add x86_64-unknown-none
rustup toolchain install nightly
rustup +nightly component add rust-src llvm-tools-preview
rustup +nightly target add x86_64-unknown-none
brew install qemu # or: apt install qemu-system-x86Stable Rust builds the kernel, so you only need the target there. Nightly is needed solely to build the bootloader's real-mode code with -Z build-std, which is why the second toolchain also wants rust-src, the standard library source, and llvm-tools-preview. If you skip these, the failure is a build-script panic in the bootloader crate that says "the -Z flag is only accepted on the nightly channel", and the fix is to build the image builder with cargo +nightly.
The kernel crate
[package]
name = "kernel"
version = "0.1.0"
edition = "2024"
[dependencies]
bootloader_api = "0.11"
uart_16550 = "0.3"
spin = "0.9"
x86_64 = { version = "0.15", default-features = false, features = ["instructions"] }
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"Each dependency has a job. bootloader_api defines BootInfo and the entry_point! macro. uart_16550 drives the serial port that QEMU can connect to your terminal. spin gives a mutex that works without an operating system, because it spins instead of asking a scheduler to sleep the thread. x86_64 wraps CPU instructions and I/O ports; the default features are switched off because the default set includes an interrupt-handler feature that needs nightly, and you only want the plain instructions here. panic = "abort" removes unwinding: there is no runtime to unwind into.
#![no_std]
#![no_main]
use bootloader_api::{entry_point, BootInfo};
use core::fmt::Write;
use core::panic::PanicInfo;
use spin::Mutex;
use uart_16550::SerialPort;
static SERIAL: Mutex<Option<SerialPort>> = Mutex::new(None);
fn serial_init() {
let mut port = unsafe { SerialPort::new(0x3F8) };
port.init();
*SERIAL.lock() = Some(port);
}
macro_rules! kprintln {
($($arg:tt)*) => {{
if let Some(port) = SERIAL.lock().as_mut() {
let _ = writeln!(port, $($arg)*);
}
}};
}
fn qemu_exit(code: u32) -> ! {
use x86_64::instructions::port::Port;
unsafe { Port::new(0xf4).write(code) };
loop {
x86_64::instructions::hlt();
}
}
entry_point!(kernel_main);
fn kernel_main(boot_info: &'static mut BootInfo) -> ! {
serial_init();
kprintln!("kernel started");
let usable: u64 = boot_info
.memory_regions
.iter()
.filter(|r| r.kind == bootloader_api::info::MemoryRegionKind::Usable)
.map(|r| r.end - r.start)
.sum();
kprintln!("usable memory: {} KiB", usable / 1024);
qemu_exit(0x10);
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
kprintln!("PANIC: {info}");
qemu_exit(0x11);
}Read it top to bottom. The two attributes remove the standard library and the normal main. entry_point!(kernel_main) generates the real _start symbol the bootloader jumps to, with a type-checked signature so that a wrong parameter is a compile error. The serial port lives at I/O port 0x3F8, the standard address of the first COM port on a PC; SerialPort::new is unsafe because writing to an arbitrary I/O port can do anything, and init programs the baud rate and FIFO. The kprintln! macro formats through core::fmt, which works without an allocator, and holds the lock only for the write.
qemu_exit is a QEMU convenience, not a PC feature: with the flag -device isa-debug-exit,iobase=0xf4,iosize=0x04, writing a value to port 0xf4 makes QEMU quit with the process exit status (value << 1) | 1. That is how a kernel reports success or failure to a test script. Writing 0x10 gives exit status 33, and 0x11 gives 35. The status can never be 0, so the shell sees "QEMU exited by itself" and "the kernel asked it to exit" differently, which is exactly the property a test harness needs. The memory map comes from the bootloader: it is a list of regions tagged usable, reserved or used by the bootloader itself, and the kernel adds up the usable ones.
The image builder
[package]
name = "mkimage"
version = "0.1.0"
edition = "2024"
[dependencies]
bootloader = { version = "0.11", default-features = false, features = ["bios"] }use std::path::PathBuf;
fn main() {
let mut args = std::env::args().skip(1);
let kernel = PathBuf::from(args.next().expect("usage: mkimage <kernel-elf> <out.img>"));
let out = PathBuf::from(args.next().expect("usage: mkimage <kernel-elf> <out.img>"));
bootloader::BiosBoot::new(&kernel).create_disk_image(&out).expect("create disk image");
println!("wrote {}", out.display());
}The bios feature builds the legacy BIOS boot path, which QEMU supports out of the box; the alternative is a UEFI image, which also works but needs an OVMF firmware file for QEMU. BiosBoot::new takes the compiled kernel ELF and create_disk_image produces a raw disk image containing the boot sector, the later boot stages, and a FAT partition holding your kernel.
Build, wrap, run
cd kernel
cargo build --target x86_64-unknown-none
cd ../mkimage
cargo +nightly run -- ../kernel/target/x86_64-unknown-none/debug/kernel ../kernel.img
cd ..
qemu-system-x86_64 \
-drive format=raw,file=kernel.img \
-serial stdio -display none \
-device isa-debug-exit,iobase=0xf4,iosize=0x04 \
-no-rebootThe first run of mkimage took about half a minute because it compiles the bootloader stages. The flags matter: -serial stdio connects the guest's COM1 to your terminal, -display none skips the window since the kernel draws nothing, the isa-debug-exit device enables the exit port, and -no-reboot stops QEMU from restarting the machine when the kernel crashes, which would otherwise loop forever. Here is the output with the bootloader's own log lines trimmed to the ones that explain the boot:
INFO : Framebuffer info: FrameBufferInfo { byte_len: 2764800, width: 1280, height: 720, pixel_format: Bgr, bytes_per_pixel: 3, stride: 1280 }
INFO : BIOS boot
INFO : virtual_address_offset: 0x10000000000
INFO : Entry point at: 0x10000003490
INFO : Creating GDT at PhysAddr(0x11ee000)
INFO : Create Memory Map
INFO : Create bootinfo
INFO : Jumping to kernel entry point at VirtAddr(0x10000003490)
kernel started
usable memory: 128531 KiBThe last two lines are yours. Everything above them is the bootloader talking over the same serial line. The sequence is worth reading once:
- Framebuffer info. The BIOS stages asked the graphics hardware for a 1280 by 720 mode with 3 bytes per pixel. The kernel gets a pointer to it in
BootInfoif it wants to draw. - Kernel loaded. The ELF was read to physical address
0x1000000(16 MiB) and its loadable segments mapped. The log shows each program header: a read-only segment, an executable one, a writable data one, and a zero-initialised.bssthat the bootloader maps and clears. - Entry point. The kernel is mapped at a high virtual address,
0x10000003490here, because the bootloader places everything at an offset (virtual_address_offset: 0x10000000000), leaving the lower half free for later use. - Jump. The bootloader creates the GDT, the page tables and the boot info, and jumps. After that, nothing but your code is running.
The memory figure follows QEMU's default of 128 MiB of RAM: the kernel reported 128,531 KiB usable, a little under 128 MiB because the first megabyte and the bootloader's own regions are reserved. Run again with -m 512M and the same kernel prints 521,747 KiB. A kernel that reads the memory map instead of assuming a size adapts without recompilation, which is the first lesson of the memory-management chapter of any OS course.
QEMU exited with status 33. Check it yourself with echo $? right after the run: 33 is (0x10 << 1) | 1, meaning "kernel finished normally".
The failure to expect on day one: a panic that never prints
Make the kernel do something wrong on purpose: index an array past its end after the memory line, and print the value with kprintln!("{}", v[idx]). Build, wrap and run it. The output stops at the last line before the panic and QEMU never exits; you have to kill it. There is no panic message and no error. This is the deadlock that every first kernel hits. The expression v[idx] is evaluated inside the macro, after SERIAL.lock() has been taken. The index panics while the lock is held. The panic handler calls kprintln! to report it, which locks SERIAL again; the spin mutex is not reentrant, so it spins forever, with the one piece of code that could explain the problem blocked behind the problem.
Two fixes, and you want both. Compute the argument before taking the lock, so the macro never evaluates user code under the lock. And make the panic handler dependable against a held lock, because the panic may come from anywhere:
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
// The panic may have happened while the serial lock was held.
unsafe { SERIAL.force_unlock() };
kprintln!("PANIC: {info}");
qemu_exit(0x11);
}force_unlock is unsafe because it breaks the lock's guarantee, which is acceptable here: we are about to stop the machine and nobody will use the port again. With that change the same crash prints:
kernel started
usable memory: 127891 KiB
about to index 3
PANIC: panicked at src/main.rs:49:21:
index out of bounds: the len is 3 but the index is 3and QEMU exits with status 35, the panic code. That gives you a feedback loop: the exit status distinguishes pass, panic and hang (a timeout), and the message tells you where. Add a timeout 30 in front of the QEMU command in scripts, so a hang cannot freeze your CI. Make the panic handler the first thing you test, before you build anything on top of it.
Looking at what the bootloader gives you
The kernel above summed the usable memory and moved on. Before you build anything on the memory map, print it and read it once. Add a loop to kernel_main, and ask the bootloader to map all physical memory into the kernel's address space, which you will need for paging later, through a BootloaderConfig passed to entry_point!:
use bootloader_api::config::Mapping;
use bootloader_api::{entry_point, BootInfo, BootloaderConfig};
pub static BOOTLOADER_CONFIG: BootloaderConfig = {
let mut config = BootloaderConfig::new_default();
config.mappings.physical_memory = Some(Mapping::Dynamic);
config
};
entry_point!(kernel_main, config = &BOOTLOADER_CONFIG);
fn kernel_main(boot_info: &'static mut BootInfo) -> ! {
serial_init();
kprintln!("kernel started");
for r in boot_info.memory_regions.iter() {
kprintln!("{:#012x}..{:#012x} {:?}", r.start, r.end, r.kind);
}
match boot_info.physical_memory_offset.into_option() {
Some(offset) => kprintln!("all physical memory is mapped at {:#x}", offset),
None => kprintln!("physical memory is not mapped"),
}
if let Some(fb) = boot_info.framebuffer.as_mut() {
let info = fb.info();
kprintln!("framebuffer {}x{}, {} bytes per pixel", info.width, info.height, info.bytes_per_pixel);
let stride = info.stride * info.bytes_per_pixel;
let buffer = fb.buffer_mut();
for y in 0..info.height {
for x in 0..info.width {
let i = y * stride + x * info.bytes_per_pixel;
buffer[i] = (x * 255 / info.width) as u8;
buffer[i + 1] = (y * 255 / info.height) as u8;
buffer[i + 2] = 128;
}
}
kprintln!("painted a gradient");
}
qemu_exit(0x10);
}With the default 128 MiB machine, the kernel printed this map (the bootloader's own log lines omitted):
kernel started
0x0000000000..0x000009fc00 Usable
0x000009fc00..0x00000a0000 UnknownBios(2)
0x00000f0000..0x0000100000 UnknownBios(2)
0x0000100000..0x0001000000 Usable
0x0001000000..0x0001272000 Bootloader
0x0001272000..0x00016a1000 Bootloader
0x00016a1000..0x0007fe0000 Usable
0x0007fe0000..0x0008000000 UnknownBios(2)
0x00fffc0000..0x0100000000 UnknownBios(2)
0xfd00000000..0x10000000000 UnknownBios(2)
all physical memory is mapped at 0x28000000000
framebuffer 1280x720, 3 bytes per pixel
painted a gradientThis is a real PC memory map and it teaches the layout. The first region, from address zero to just under 640 KiB, is the conventional memory of the original PC, usable. The slice from 0x9fc00 to 0xa0000 and the block at 0xf0000 are BIOS data and ROM, which the bootloader marks UnknownBios(2) (type 2 is "reserved" in the BIOS memory map call), and you must never use them as RAM. Usable memory resumes at 1 MiB. The two Bootloader regions starting at 16 MiB hold your kernel image and the structures the bootloader built (page tables, boot info, the stack): they are memory you must not hand out until you decide you are done with them. The big usable block from about 23 MiB to 127.9 MiB is where a heap and page frames will come from. The regions near 4 GiB and above are device memory, the firmware flash window and the framebuffer.
The total of usable regions, 123,771 KiB in this build, is smaller than the earlier 128,531 KiB because this kernel is larger and the image it was loaded from reserves more. Never hard-code the figure. Finally, Mapping::Dynamic asked the bootloader to map every physical address at a virtual offset it picks (here 0x28000000000), so that the kernel can read and write any physical page, including page tables, by adding the offset. Without it, a kernel that wants to edit its own page tables has no way to address them. The framebuffer loop writes one blue-green-red gradient pixel at a time through a plain slice; run QEMU without -display none and you will see the picture. Two details are worth stealing: the pixel format and stride come from the framebuffer info, never assumed (the stride can be larger than the width), and the per-pixel arithmetic in a debug build takes noticeable time, which is a gentle introduction to why kernels are built with optimisation.
Making the loop repeatable
Three commands per run is too many. Put them in a script and make the exit code mean something, so that a test can tell a pass from a panic from a hang:
#!/bin/sh
set -e
(cd kernel && cargo build --target x86_64-unknown-none)
(cd mkimage && cargo +nightly run -q -- ../kernel/target/x86_64-unknown-none/debug/kernel ../kernel.img)
set +e
timeout 30 qemu-system-x86_64 \
-drive format=raw,file=kernel.img \
-serial stdio -display none \
-device isa-debug-exit,iobase=0xf4,iosize=0x04 \
-no-reboot
status=$?
case $status in
33) echo "PASS" ;;
35) echo "kernel panicked"; exit 1 ;;
124) echo "timed out (hang or deadlock)"; exit 1 ;;
*) echo "unexpected exit status $status"; exit 1 ;;
esacStatus 124 is what the timeout command returns when it kills its child, 33 and 35 are the two exit codes from the kernel. A pass is therefore three conditions at once: QEMU exited, it exited through the debug device, and the code was the success one. This shape grows into a test framework: the blog_os style of kernel testing runs each test as its own tiny kernel image with the same exit convention, and a test runner loops over them.
Other failures, in the order people meet them
| Symptom | Cause | Fix |
|---|---|---|
Build script panics: "the -Z flag is only accepted on the nightly channel" | Building the image builder with stable | cargo +nightly run in mkimage |
"can't find crate for core" | Target not installed on that toolchain | rustup +nightly target add x86_64-unknown-none |
#![feature] may not be used on the stable release channel, inside x86_64 | Default features of the x86_64 crate need nightly | Use default-features = false with the features you need |
| QEMU prints nothing | Missing -serial stdio, or the kernel never initialised COM1 | Check the flag and that serial_init() runs first |
| QEMU never exits | Forgot the isa-debug-exit device, or the kernel hangs | Add the device; use timeout; look for a held lock |
| Boots, then resets in a loop | Triple fault (an exception while handling a double fault) | Add -no-reboot -d int to see the exceptions; usually a bad stack or page table |
| Image is huge compared with the kernel | Debug build of the kernel (1.9 MB) inside a 4.7 MB image | Normal; the release kernel is under 10 KB |
That last row has numbers worth knowing. The debug build of this kernel is 1,890,256 bytes because it carries debug information; the release build of the same code is 9,992 bytes on disk, of which about 4.4 KB is code. The disk image is 4,686,848 bytes either way, because it contains the bootloader stages and a fixed-size partition. A kernel that prints a line and exits is smaller than this paragraph.
Debugging a kernel that misbehaves
Add -s -S to the QEMU command and it pauses before executing the first instruction and opens a GDB server on port 1234. Attach with a debugger that understands ELF files and the remote protocol, load the kernel ELF for symbols, set a breakpoint on kernel_main and continue. For quicker questions, -d int,cpu_reset logs every interrupt and CPU reset to stderr, which turns a mysterious reboot loop into a list of exception numbers: 8 is a double fault and 14 is a page fault, and the faulting address is in the log. The techniques overlap with what debugging Rust teaches for ordinary programs, with the difference that your breakpoints are in code that no operating system helps you run.
Where this stops, and what comes next
This kernel does not handle interrupts, so the first exception it takes becomes a triple fault and a reboot. It has no heap, so no Vec or String. It has no keyboard, no timer and no scheduler, and it runs in a single address space with no user programs. Each of those is a chapter: interrupts and exceptions first, because without them every other bug looks like the machine rebooting; then memory (paging and a heap), then devices, then tasks. The ordering and the dependencies between those chapters are set out in the kernel roadmap, which extends this exact crate stage by stage. If your goal is a production kernel for a real board, start from an existing project and the ARM-side guides, such as the Pico guide, rather than from a PC: real hardware adds firmware quirks that QEMU hides. For the freestanding-Rust rules this crate lives under, see no_std Rust explained, and for the panic handler's required shape, the no_std panic handler.


