Skip to content

2. Move Under OpenSBI

The first checkpoint ran as machine-mode firmware with -bios none.

That was useful because it exposed the raw machine. It is not the final architecture. From this point on, OpenSBI owns M-mode and the kernel runs in S-mode.

At the end of this chapter:

jikei: booted under OpenSBI
mode: S
hart=0x0
dtb=0x87e00000

The DTB address will vary by QEMU version. The kernel still parks after printing. No allocator, paging, or traps yet.

Four files change. Two stay the same.

FileChange
.cargo/config.toml-bios none becomes -bios default
link.ldorigin moves from 0x80000000 to 0x80200000
src/uart.rsdeleted, replaced by src/sbi.rs
src/main.rsuart becomes sbi, prints DTB address
src/panic.rsuart becomes sbi
boot.Sno change
Cargo.tomlno change

The assembly does not change because OpenSBI uses the same calling convention as QEMU’s direct boot: a0 = hart ID, a1 = device tree pointer. The linker handles the address change.

OpenSBI is firmware that runs in M-mode before the kernel starts. It provides services through ecall:

  • console output
  • timer programming
  • starting secondary harts
  • machine-specific hardware setup

The kernel calls OpenSBI the same way a user process will later call the kernel: by loading arguments into registers and executing ecall. The difference is the privilege boundary. An ecall from S-mode traps into M-mode. An ecall from U-mode traps into S-mode.

Moving the kernel under OpenSBI means giving up direct access to M-mode resources like the UART MMIO. In exchange, the kernel can rely on OpenSBI for hardware details and focus on supervisor-mode work: page tables, traps, processes.

In .cargo/config.toml, change -bios none to -bios default:

[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 default -kernel"
rustflags = [
"-C", "link-arg=-Tlink.ld",
]

With -bios default, QEMU loads OpenSBI as firmware. OpenSBI initializes the machine in M-mode, then jumps to the kernel image in S-mode.

We keep -smp 1 for now. Multiple harts come later.

In link.ld, change the origin address from 0x80000000 to 0x80200000:

ENTRY(_start)
SECTIONS {
. = 0x80200000; /* OpenSBI loads kernel here */
.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 = .;
}
}

OpenSBI occupies 0x80000000. It expects the kernel payload at 0x80200000. If the linker script still says 0x80000000, the kernel image will collide with OpenSBI and neither will work.

Everything else in the linker script is unchanged.

Delete src/uart.rs. Create src/sbi.rs:

pub fn putchar(byte: u8) {
unsafe {
core::arch::asm!(
"ecall",
in("a7") 0x01usize,
in("a0") byte as usize,
);
}
}
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 only function that actually changes is putchar. In Chapter 1, it wrote a byte directly to the UART’s MMIO address:

unsafe { core::ptr::write_volatile(0x1000_0000 as *mut u8, byte); }

Now it asks OpenSBI to write the byte:

unsafe { core::arch::asm!("ecall", in("a7") 0x01usize, in("a0") byte as usize); }

a7 holds the SBI extension ID. 0x01 is the legacy console putchar extension. a0 holds the byte to print. The ecall instruction traps into M-mode, where OpenSBI handles the write and returns.

puts and put_hex are unchanged. They call putchar without caring how it reaches the hardware.

In src/panic.rs, change uart to sbi:

use core::panic::PanicInfo;
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
crate::sbi::puts("kernel panic\n");
loop {
unsafe { core::arch::asm!("wfi") }
}
}

In src/main.rs, change the module name and add the DTB address to the output:

#![no_std]
#![no_main]
mod panic;
mod sbi;
core::arch::global_asm!(include_str!("../boot.S"));
#[unsafe(no_mangle)]
pub extern "C" fn kernel_main(hartid: usize, dtb_ptr: usize) -> ! {
sbi::puts("jikei: booted under OpenSBI\n");
sbi::puts("mode: S\n");
sbi::puts("hart=");
sbi::put_hex(hartid);
sbi::puts("\n");
sbi::puts("dtb=");
sbi::put_hex(dtb_ptr);
sbi::puts("\n");
loop {
unsafe { core::arch::asm!("wfi") }
}
}

The function signature has not changed. OpenSBI passes a0 = hart ID and a1 = device tree pointer, the same convention QEMU used for direct boot. The assembly in boot.S still saves a0 into tp and calls kernel_main with both arguments intact.

The device tree pointer is not parsed yet. We print it to prove it arrived. Chapter 3 will use it to discover RAM.

The assembly from Chapter 1 works without modification. This is worth pausing on.

_start saves the hart ID, sets a stack, clears .bss, and calls Rust. None of those steps depend on whether the caller was QEMU (M-mode) or OpenSBI (S-mode). The linker resolves _boot_stack_top and _bss_start to the right addresses regardless of where the image is placed.

The only thing that changed is who called _start and what privilege mode we are in.

Terminal window
cargo run --release

OpenSBI prints its own boot banner before the kernel starts. Scroll past it. The kernel output follows:

jikei: booted under OpenSBI
mode: S
hart=0x0
dtb=0x87e00000

The DTB address depends on QEMU version and memory size. Any address in the 0x8xxxxxxx range is normal.

If you still see mode: M or the output from Chapter 1, check that .cargo/config.toml has -bios default and that link.ld starts at 0x80200000.

At this checkpoint, the system is:

OpenSBI (M-mode)
-> _start at 0x80200000 (S-mode)
-> boot stack
-> zero .bss
-> kernel_main
-> SBI ecall console output
-> wfi loop

Compare this to Chapter 1:

Chapter 1: kernel IS the firmware (M-mode, 0x80000000)
Chapter 2: kernel RUNS UNDER firmware (S-mode, 0x80200000)

The kernel does less now. It cannot touch the UART directly. It cannot program timers or configure interrupts at the machine level. In exchange, it has a stable firmware interface and the right privilege level for everything that comes next.

The device tree pointer is sitting in a1, unused. The next chapter parses it to find RAM, then builds a frame allocator. Once we can allocate physical pages, we can build page tables.