Skip to content

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 OpenSBI
mode: M
hart=0x0

The kernel then parks in a wfi loop.

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 Cargo.toml:

[package]
name = "rv-kernel"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "rv-kernel"
path = "src/main.rs"
test = false
bench = 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.

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.

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.

Create boot.S:

.section .text.init
.globl _start
_start:
mv tp, a0
la sp, _boot_stack_top
la t0, _bss_start
la t1, _bss_end
1:
bgeu t0, t1, 2f
sd zero, 0(t0)
addi t0, t0, 8
j 1b
2:
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:

  1. Save the hart ID from a0 into tp.
  2. Set up a 16 KiB boot stack.
  3. Clear .bss so Rust globals start as zero.
  4. Call kernel_main.

If kernel_main returns, _start parks the hart with wfi.

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.

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.

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:

Terminal window
cargo run --release

Expected output:

jikei: booted without OpenSBI
mode: M
hart=0x0

QEMU will keep running because the kernel parks in a loop. Stop it with Ctrl+A, then X, or by terminating the process.

Record this state in the editable checkpoint notes:

Terminal window
$EDITOR tutorial/checkpoints/chapter-01-boot/README.md

At this checkpoint, the system is:

QEMU
-> _start at 0x80000000
-> boot stack
-> zero .bss
-> kernel_main
-> direct UART writes
-> wfi loop

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.