1. Boot Without OpenSBI
The first checkpoint boots without OpenSBI.
That is not where the final kernel will live. The final kernel will run in supervisor mode under OpenSBI. But starting without firmware makes the machine visible: QEMU loads our image, jumps to _start, and our code writes directly to the UART.
At the end of this chapter:
jikei: booted without OpenSBImode: Mhart=0x0The kernel then parks in a wfi loop.
The Plan
Section titled “The Plan”We will build:
- a freestanding Rust binary
- a linker script that places the image at QEMU’s reset address
- a tiny assembly
_start - a boot stack
- a UART writer
- a panic handler
The first version has no allocator, no paging, no traps, and no user mode.
Create the Crate
Section titled “Create the Crate”Create Cargo.toml:
[package]name = "rv-kernel"version = "0.1.0"edition = "2024"
[[bin]]name = "rv-kernel"path = "src/main.rs"test = falsebench = false
[profile.dev]panic = "abort"
[profile.release]panic = "abort"The important part is not the package name. The important part is that this will build a binary without the standard library.
Configure QEMU
Section titled “Configure QEMU”Create .cargo/config.toml:
[build]target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]runner = "qemu-system-riscv64 -machine virt -m 256M -smp 1 -nographic -serial mon:stdio -bios none -kernel"rustflags = [ "-C", "link-arg=-Tlink.ld",]The key option is -bios none. QEMU will not load OpenSBI. Our image is the first firmware-like code that runs.
We also use -smp 1 for now. Multiple harts are useful later, but they make the first boot harder to explain.
Place the Image
Section titled “Place the Image”Create link.ld:
ENTRY(_start)
SECTIONS { . = 0x80000000;
.text : { _text_start = .; KEEP(*(.text.init)) *(.text .text.*) _text_end = .; }
.rodata : { *(.srodata .srodata.*) *(.rodata .rodata.*) }
.data : { *(.sdata .sdata.*) *(.data .data.*) }
.bss : { . = ALIGN(8); _bss_start = .; *(.sbss .sbss.*) *(.bss .bss.*) . = ALIGN(8); _bss_end = .; }}With -bios none, QEMU’s virt machine starts us at 0x80000000. The linker script must agree, or labels and addresses will be wrong as soon as we use them.
The small-data sections are included because RISC-V toolchains may emit them even for tiny Rust programs. The .bss bounds are aligned so the boot code can clear it with 64-bit stores.
Start in Assembly
Section titled “Start in Assembly”Create boot.S:
.section .text.init
.globl _start_start: mv tp, a0 la sp, _boot_stack_top
la t0, _bss_start la t1, _bss_end1: bgeu t0, t1, 2f sd zero, 0(t0) addi t0, t0, 8 j 1b2: call kernel_main
3: wfi j 3b
.section .bss.stack, "aw", @nobits.balign 16_boot_stack: .skip 4096 * 4_boot_stack_top:This does four things:
- Save the hart ID from
a0intotp. - Set up a 16 KiB boot stack.
- Clear
.bssso Rust globals start as zero. - Call
kernel_main.
If kernel_main returns, _start parks the hart with wfi.
Enter Rust
Section titled “Enter Rust”Create src/main.rs:
#![no_std]#![no_main]
mod panic;mod uart;
core::arch::global_asm!(include_str!("../boot.S"));
#[unsafe(no_mangle)]pub extern "C" fn kernel_main(hartid: usize, _dtb_ptr: usize) -> ! { uart::puts("jikei: booted without OpenSBI\n"); uart::puts("mode: M\n"); uart::puts("hart="); uart::put_hex(hartid); uart::puts("\n");
loop { unsafe { core::arch::asm!("wfi") } }}no_std removes the standard library. no_main tells Rust that our entry point is not the usual hosted main function. The real entry point is _start in boot.S.
The kernel_main signature follows the convention QEMU uses for RISC-V boot code: a0 contains the hart ID, and a1 contains a device tree pointer. We do not parse the device tree yet.
Write to the UART
Section titled “Write to the UART”Create src/uart.rs:
const UART0: *mut u8 = 0x1000_0000 as *mut u8;
pub fn putchar(byte: u8) { unsafe { core::ptr::write_volatile(UART0, byte); }}
pub fn puts(s: &str) { for byte in s.bytes() { putchar(byte); }}
pub fn put_hex(n: usize) { puts("0x");
let mut started = false; let mut shift = usize::BITS as usize;
while shift > 0 { shift -= 4; let digit = ((n >> shift) & 0xF) as u8; if digit != 0 || started || shift == 0 { started = true; putchar(match digit { 0..=9 => b'0' + digit, _ => b'a' + (digit - 10), }); } }}The QEMU virt machine has a UART mapped at physical address 0x1000_0000. Writing a byte there prints a byte on the emulated serial console.
write_volatile matters because this is not normal memory. The compiler must not remove or merge the write.
Add a Panic Handler
Section titled “Add a Panic Handler”Create src/panic.rs:
use core::panic::PanicInfo;
#[panic_handler]fn panic(_info: &PanicInfo) -> ! { crate::uart::puts("kernel panic\n"); loop { unsafe { core::arch::asm!("wfi") } }}A no_std binary must define what panic means. For now, a panic prints one line and parks.
Run It
Section titled “Run It”Run:
cargo run --releaseExpected output:
jikei: booted without OpenSBImode: Mhart=0x0QEMU will keep running because the kernel parks in a loop. Stop it with Ctrl+A, then X, or by terminating the process.
Checkpoint
Section titled “Checkpoint”Record this state in the editable checkpoint notes:
$EDITOR tutorial/checkpoints/chapter-01-boot/README.mdAt this checkpoint, the system is:
QEMU -> _start at 0x80000000 -> boot stack -> zero .bss -> kernel_main -> direct UART writes -> wfi loopWhat Comes Next
Section titled “What Comes Next”We proved that the machine is not magic. We can be the firmware.
Next, we stop being firmware. OpenSBI will take back M-mode, and the kernel will move to S-mode where a supervisor kernel belongs.