Developing a Bare-Metal Environment for RISC-V

Modern applications rely on complex execution environments—ranging from standard libraries to operating systems—to manage resources and hardware interactions. To build a kernel from scratch, one must strip away these abstractions and interface directly with the CPU and memory. This process involves navigating the target platform's architecture, memory layout, and boot flow.

Environment Configuration and Compilation

In a standard Rust environment, the compiler targets the host operating system (e.g., x86_64-unknown-linux-gnu). Developing a kernel requires a "bare-metal" approach, which entails disabling the standard library (#![no_std]) and the entry point (#![no_main]). By opting out of the standard library, we bypass external dependencies on system calls, forcing us to define our own panic handling and execusion entry points.

The compiler requires a panic_handler to dictate how the system behaves during unrecoverible errors. A minimal implementation looks like this:

use core::panic::PanicInfo;

#[panic_handler]
fn handle_panic(_info: &PanicInfo) -> ! {
    loop {} // Halt execution
}

The Bootstrapping Sequence

QEMU acts as the target emulator. The boot process is a multi-stage handoff:

  • Stage 1 (Hardware/Firmware): The emulator jumps to a hardcoded address (e.g., 0x80000000).
  • Stage 2 (Bootloader): A binary like RustSBI initializes hardware and locates the kernel.
  • Stage 3 (Kernel): Control is passed to the kernel at a predefined address, such as 0x80200000.

To ensure the kernel is loaded correctly, we must define a linker script (linker.ld) that maps the entry section to the specific memory address expected by the bootloader. ### Managing Execution Context and Memory

A functional kernel requires a stack. We define a memory region within the .bss section to serve as the stack space. The entry point, written in assembly, handles the initial setup:

.section .text.entry
.globl _start
_start:
    la sp, stack_top
    call kernel_main

.section .bss.stack
stack_bottom:
    .space 4096 * 16
stack_top:

Before executing high-level code, we must zero out the .bss section to guarantee that global variables start with a clean state. This is performed by iterating through the memory range defined by the linker symbols sbss and ebss.

Peripheral Interaction via SBI

On RISC-V, system-level tasks (like writing to a console or shutting down) are performed by calling the Supervisor Binary Interface (SBI). By implementing a custom output structure that satisfies the core::fmt::Write trait, we can utilize the familiar println! macro within our kernel:

struct Console;

impl core::fmt::Write for Console {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        for byte in s.bytes() {
            sbi_rt::legacy::console_putchar(byte as usize);
        }
        Ok(())
    }
}

By mapping these low-level SBI calls to Rust's formatting machinery, the kernel gains the ability to report status and debug information, completing the foundation required for further development.

Tags: rust RISC-V kernel qemu embedded

Posted on Mon, 06 Jul 2026 16:13:44 +0000 by learningsql