[Rust Kernel] Building a Kernel from Scratch
Before We Begin
We will build our custom kernel on top of rCore-Tutorial-v3.
We develop using the Docker environment:
make docker
You should be familiar with:
- Operating system concepts
- Rust programming
- RISC-V ISA
0x00 How an OS Binary Works
In simple terms, an OS can run "bare-metal"—it directly interacts with hardware without depending on any standard library.
root@dd6bc06ddb03:/mnt/novaos# rustc --version -v
rustc 1.80.0-nightly (f705de596 2024-04-30)
... existing version output ...
What is the standard library? You can think of it as another layer of abstraction between OS and application. For example, the "gnu" in x86_64-unknown-linux-gnu
stands for the C standard library (libc) on top of the Linux kernel, providing wrappers and checks for system calls.
"All problems in computer science can be solved by another level of indirection." – David Wheeler
For instance, Rust’s stdio.rs
on Unix and on Windows handle low-level I/O differently. Above that, different runtime libraries such as GNU
and musl
add another layer, each with their own conventions.
Therefore, if we want to write an operating system, we cannot use the runtime library or target any existing OS—this means most of Rust’s usual abstractions are unavailable. Fortunately, Rust provides the core
library, which is almost OS-agnostic and implements basic arithmetic, error handling, and iterator traits.
When developing a Rust OS, we must disable the standard library:
#![no_std]
0x01 My First Bare-Metal Binary
We will target RISC-V. First, add the RISC-V toolchain and configure Cargo:
rustup target add riscv64gc-unknown-none-elf
mkdir .cargo
cat << 'EOF' > .cargo/config.toml
[build]
target = "riscv64gc-unknown-none-elf"
EOF
riscv64gc-unknown-none-elf
breaks down as:
riscv64gc
: 64-bit RISC-V with G (IMAFD) + C extensionsunknown
: unknown CPU vendornone
: no underlying OSelf
: no runtime library
Now create a new OS project:
cargo new --bin novaos
Remove the println!
macro and add #![no_std]
:
#![no_std]
fn main() {}
Compilation fails:
error: `#[panic_handler]` function required, but none was found
We need to implement our own panic handler. Copy the signature and simply loop forever:
#[panic_handler]
fn panic_handler(_info: &core::panic::PanicInfo) -> ! {
loop {}
}
Include it in main.rs
and disable the default entrypoint:
#![no_std]
#![no_main]
mod lang_items;
#[no_mangle]
fn _start() -> ! {
loop {}
}
Compile again—it succeeds (though it does nothing).
0x02 Running on QEMU
After power-on, the firmware jumps to a fixed address for the bootloader, which then jumps to the kernel entry point. We use rustsbi as our SBI (Supervisor Binary Interface).
Next, write a quick assembly loop to increment t0
:
#![no_std]
#![no_main]
core::arch::global_asm!(r#"
.section .text
.global _start
_start:
li t0,0
1: addi t0,t0,1
j 1b
"#);
Inspect with readelf
to find .text
at offset 0x11158
. QEMU loads the kernel at 0x80200000
, so we must update the linker script:
OUTPUT_ARCH(riscv)
ENTRY(_start)
SECTIONS {
. = 0x80200000;
.text : { *(.text._start) *(.text*) }
}
Configure Cargo to pass the linker script:
[build]
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
rustflags = ["-Clink-arg=-Tsrc/linker.ld"]
Now compile and verify .text
is at 0x80200000
:
readelf -S target/riscv64gc-unknown-none-elf/release/novaos
Create a simple Makefile to run and debug:
run:
qemu-system-riscv64 -M virt -nographic -bios ../bootloader/rustsbi-qemu.bin \
-kernel target/riscv64gc-unknown-none-elf/release/novaos -s -S
dbg:
riscv64-unknown-elf-gdb \
-ex 'file target/riscv64gc-unknown-none-elf/release/novaos' \
-ex 'set arch riscv:rv64' \
-ex 'target remote localhost:1234'
Break at *0x80200000
and single-step to confirm.
At this point, our minimal kernel runs—but it’s all assembly! Let’s add real functionality.
0x03 Introducing a Stack
To support function calls, we need a stack. Allocate and initialize the stack at startup:
.section .text._start
.globl _start
_start:
la sp, boot_stack_top
call novaos_start
.section .data.stack
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 1024*64
.globl boot_stack_top
boot_stack_top:
Include this in main.rs
:
#![no_std]
#![no_main]
mod lang_items;
core::arch::global_asm!(include_str!("entry.s"));
#[no_mangle]
fn novaos_start() -> ! {
first_try();
}
fn first_try() -> ! {
// clear stack
extern "C" {
static mut boot_stack_lower_bound: usize;
static mut boot_stack_top: usize;
}
unsafe {
let lo = &boot_stack_lower_bound as *const _ as usize;
let hi = &boot_stack_top as *const _ as usize;
(lo..hi).for_each(|addr| (addr as *mut u8).write_volatile(0));
}
loop {}
}
Adjust the linker script to align .stack
after .text
.
Force frame pointers to verify the stack is set up correctly:
rustflags = ["-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"]
0x04 Basic Console I/O
Install a kernel-friendly GDB extension (gef) and use SBI to print characters:
pub fn console_putchar(c: u8) {
sbi_rt::console_write_byte(c);
}
Loop to print "NOVA" and confirm it appears in QEMU. If the legacy SBI call works but console_write_byte
doesn’t, update the bootloader to the latest rustsbi-qemu
.
Use a simple string loop:
loop {
for &b in b"谁家 OS 还不支持中文啊\n" {
console_putchar(b);
}
}
0x05 Implementing println!
Leverage core::fmt::Write
to build a console writer:
use core::fmt::{self, Write};
use crate::sbi::console_putchar;
struct Stdout;
impl Write for Stdout {
fn write_str(&mut self, s: &str) -> fmt::Result {
for &b in s.as_bytes() {
console_putchar(b);
}
Ok(())
}
}
pub fn print(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print { ... }
#[macro_export]
macro_rules! println { ... }
Then call:
println!("{} {}", "世界的答案", 42);
0x06 Testing Framework
Enable custom test frameworks:
#![feature(custom_test_frameworks)]
#![test_runner(crate::test_runner)]
#![reexport_test_harness_main = "test_main"]
Implement test_runner
and use QEMU as the Cargo test runner.
0x07 Shutting Down Gracefully
Add a shutdown SBI call:
pub fn shutdown(failure: bool) -> ! { … }
Use it in panic_handler
to exit QEMU cleanly.
0x08 User-Mode Support & Syscalls
Isolate user and supervisor mode. Build a user runtime library (usr/
) with its own linker script:
#![no_std]
#![feature(linkage)]
#[link_section = ".text._start"]
#[no_mangle]
pub extern "C" fn _start() -> ! {
main();
panic!("Unreachable");
}
#[linkage = "weak"]
#[no_mangle]
pub extern "C" fn main() -> i32 {
panic!("User main not implemented.");
}
Implement syscall
via ecall
, wrap sys_write
, and replace user-mode console_putchar
to call the write syscall.
User Application Example
In usr/src/bin/first.rs
:
#![no_std]
#![no_main]
use usr_rt::*;
#[no_mangle]
fn main() -> i32 {
println!("Hello, world!");
0
}
Run with QEMU’s user-mode emulation to verify.
This Content is generated by LLM and might be wrong / incomplete, refer to Chinese version if you find something wrong.