前言
正常用户程序是运行在OS上的,而我们的OS是运行在SBI上,所以不能使用std库,而是使用core库并且环境也需要我们自己去进行配置
|------|-----------------------------------------|-------------------------------------|
| 环节 | 普通用户程序 | 裸机程序 |
| 标准库 | 依赖 std 库,std 库依赖 OS 的系统调用 | 禁用 std 库,只用不依赖 OS 的 core 库 |
| 运行时 | 有 std 提供的运行时,负责初始化栈、调用 main 函数、处理 panic | 无任何运行时,必须自己实现入口、栈初始化、panic 处理 |
| 目标平台 | 编译为宿主平台(x86_64/arm64)的可执行文件 | 交叉编译为 riscv64 平台的裸机 ELF 文件,不依赖任何 OS |
| 链接环节 | 用系统默认的链接脚本,适配 OS 的程序加载规则 | 必须自己写链接脚本,指定内存布局、入口地址、段的位置 |
构建kernel可执行机器码步骤:
entry.asm(kernel基本布局) -> entry.o(目标文件) -> os(ELF文件) -> os.bin(二进制文件)
开发环境搭建
这里系统我自己有两台电脑,暂时用Ubuntu,另一个是Arch的。
1.配置rustup
安装RISCV交叉编译所需的Rust组件:
bash
# 使用nighty发行版
rustup update nightly
# 添加riscv64目标平台支持
rustup target add riscv64gc-unknown-none-elf
# 安装编译所需的工具组件
rustup component add rust-src llvm-tools-preview
cargo install cargo-binutils
补充每个组件的作用:
- riscv64gc-unknown-none-elf:RISC-V 64 位目标平台的编译后端,让 rustc 能生成 RISC-V 架构的指令;
- rust-src:提供 Rust 核心库的源码,用于no_std环境下编译 core 库;
- llvm-tools-preview:提供rust-objcopy、rust-objdump等工具,用于 ELF 转 BIN、反汇编;
- cargo-binutils:让 cargo 可以直接调用 llvm 工具链,简化交叉编译操作。
2. 安装QEMU
这里我选择手动编译QEMU,觉得麻烦的可以直接使仓库一体式安装:
bash
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential git qemu-system-misc gcc-riscv64-linux-gnu gdb-multiarch
安装qeMu编译需要的依赖和工具:
bash
sudo apt-get update
sudo apt-get install -y curl git python3 wget
sudo apt-get install -y autoconf automake autotools-dev curl libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf libtool patchutils bc zlib1g-dev libexpat1-dev git ninja-build pkg-config libglib2.0-dev libpixman-1-dev libsdl2-dev
安装完后下载qeMu然后解压和编译然后安装:
bash
# 这里QEMU_VERSION填写你自己想下载的版本
sudo wget https://download.qemu.org/qemu-${QEMU_VERSION}.tar.xz
# 我下载的是7.0.0
sudo tar xvJf qemu-7.0.0.tar.xz
cd qemu-7.0.0/
# 设置configura,我们只需要riscv的
./configure --target-list=riscv64-softmmu,riscv64-linux-user
# 开始编译
sudo make -j$(nproc) && make install
# 检验是否安装成功
qemu-system-risv64 --version
qemu-riscv64 --version
编译工具链
在前面已经设置了rustc的目标平台,这样就会将代码交叉编译到riscv平台,接下来就是要配置编译工具链。
交叉编译与目标三元组
目标三元组是riscv64gc-unknown-none-elf,每一段的含义直接决定了编译行为:
- riscv64gc:CPU 架构,riscv64 位,g代表通用扩展(IMAFD),c代表压缩指令扩展,是 RISC-V 64 位的标准配置;
- unknown(目标厂商):无特定厂商
- none(目标操作系统):无操作系统,也就是裸机环境
- elf(可执行文件格式):ELF 格式
这就是为什么我们能编译出不依赖 Linux、不依赖任何 OS 的 RISC-V 程序。
ELF文件格式
ELF(可执行与可链接格式)是编译后生成的文件格式,核心分为 4 部分,每一部分都和程序生死相关:
- ELF头:记录文件的目标架构(riscv64)、入口地址、程序头表 / 节头表的位置
- 程序头表:告诉加载器(QEMU 的 loader),哪些段需要加载到内存里、加载到什么地址,裸机程序的内存布局,核心靠程序头表决定;
- 节(Section):程序的核心内容,裸机里最关键的 4 个节
- .text:代码段,存我们写的汇编、Rust 编译出的 CPU 指令
- .rodata:只读数据段,存字符串常量(比如 "hello world")
- .data:数据段,存已初始化的全局变量、静态变量
- .bss:未初始化数据段,存未初始化的全局 / 静态变量,必须在启动时手动清零,否则会出现未定义行为(PPT 完全没提这个新手必踩的坑)
- 节头表:记录每个节的位置、大小、属性。
LLVM编译器
Rust目前使用用LLVM架构,核心是「前端 - IR - 后端」的解耦,这也是 Rust 做交叉编译这么简单的核心原因:
- 前端:把 Rust 源代码解析、生成 LLVM IR(中间表示),和目标平台无关;
- 优化器:对 LLVM IR 做平台无关的优化;
- 后端:把优化后的 IR,翻译成目标平台(riscv64)的汇编指令,最终生成机器码。
这就是为什么我们只需要加一个 target,就能从 x86 主机编译出 RISC-V 的程序,不需要自己改编译器。
QEMU+SBI启动流程
在写代码之前,必须先明白我们的程序是怎么被执行的:
- 执行 QEMU 启动命令,QEMU 会先加载我们指定的 BIOS(RustSBI/OpenSBI 固件)
- SBI 固件会初始化 RISC-V 硬件,CPU 进入 M 模式(机器模式,最高特权级)执行SBI代码
- SBI 初始化完成后,会把 CPU 切换到 S 模式(监管者模式),然后跳转到指定的内核加载地址(0x80200000)
程序入口(_start),必须放在 0x80200000 这个地址,CPU从这里开始执行内核代码。
编写汇编入口代码
Rust 代码的执行,依赖合法的栈空间(函数调用会压栈、用栈存局部变量),而 CPU 刚跳转到我们的程序时,sp(栈指针寄存器)的值是随机的,没法执行Rust代码
必须先用汇编做2件最基础的事:
- 设置合法的栈空间,给
sp寄存器赋一个正确的地址 - 跳转到 Rust 代码(rust_main),让Rust代码能正常执行
在src目录下创建entry.asm文件
TypeScript
# 定义一个代码段,名为.text.entry,专门放入口代码
.section .text.entry
# 把_start(程序入口)符号导出为全局符号,让链接器能看到
.globl _start
_start:
la sp, boot_stack_top
call rust_main
# 定义bss段,专门放栈空间(bss段是未初始化的内存,不会占用ELF文件体积)
.section .bss.stack
# 导出栈的下界符号
.globl boot_stack_lower_bound
boot_stack_lower_bound:
.space 4096 * 16
# 导出栈的上界符号
.globl boot_stack_top
boot_stack_top:
- .section .text.entry:把这段代码放到.text.entry段里,后续我们会在链接脚本里,把这个段放在最开头的 0x80200000 地址,确保 CPU 跳过来第一行就执行_start
- .globl _start:_start是链接器默认的程序入口符号,必须导出为全局,否则链接器找不到入口地址
- la sp, boot_stack_top:RISC-V 的栈是向下增长的(从高地址向低地址压栈),所以栈指针必须初始化为栈空间的最高地址(boot_stack_top),而不是最低地址。如果设反了,栈一压栈就会访问非法内存,直接崩溃
- .space 4096 * 16:预留 64KB 的连续内存作为栈空间,.space指令会在bss段里预留指定大小的内存,初始值为 0
- 为什么栈放在.bss段?因为 bss 段的内容不会被存到 ELF 文件里,只会记录大小,编译出来的文件体积更小,而且启动时会统一清零 bss 段
当你执行cargo build时,Rust 的构建系统会调用汇编器(as) 处理entry.asm:
- 汇编器逐行解析entry.asm里的汇编指令(比如la、call、.section);
- 把每一条汇编指令翻译成对应的 RISC-V 机器码(比如la sp, boot_stack_top会被翻译成0x00000013这类二进制数);
- 生成目标文件(entry.o) ------ 包含机器码、符号表(比如_start、boot_stack_top这些符号对应的地址)、段信息(比如.text.entry、.bss.stack)。
关键补充:
- 你不用手动执行汇编命令,Cargo 会通过
build.rs(构建脚本)或rustflags自动处理.asm文件; - 如果想手动验证,可以执行:
bash
# 把entry.asm汇编成riscv64的目标文件
riscv64-linux-gnu-as entry.asm -o entry.o
# 查看目标文件里的符号(能看到_start、boot_stack_top的地址)
riscv64-linux-gnu-nm entry.o
编写链接脚本
编译器默认的链接脚本,是给运行在 OS 上的用户程序用的,默认的内存地址、入口布局完全不符合 RISC-V 裸机的要求,会导致:
- 程序入口不在 0x80200000 地址,CPU 跳过来执行不到我们的代码
- 代码段、数据段、bss 段的布局混乱,内存访问出错
- 找不到我们定义的_start入口、栈符号
在项目根目录下创建linker.ld文件,写入以下完整的链接脚本,逐行带注释:
TypeScript
/* 指定目标CPU架构 */
OUTPUT_ARCH(riscv)
/* 指定程序的入口符号,就是我们汇编里写的_start */
ENTRY(_start)
/* 定义目标架构的内存地址:QEMU virt平台的物理内存从0x80000000开始 */
/* 我们的内核加载地址是0x8020000,前面的0x80000000-0x80200000留给SBI固件用 */
BASE_ADDRESS = 0x80200000;
/* 核心:段的布局定义 */
SECTIONS
{
/* 把所有段的起始地址,设置为BASE_ADDRESS=0x80200000 */
. = BASE_ADDRESS;
skernel = .;
stext = .;
/* 代码段:放所有的CPU指令 */
.text : {
/* 先放我们的入口代码.text.entry,确保入口在0x80200000的起始位置 */
*(.text.entry)
/* 再放其他所有的代码段 */
*(.text .text.*)
}
/* 4K对齐,和内存页大小一致 */
. = ALIGN(4K);
etext = .;
srodata = .;
/* 只读数据段:放字符串常量、只读的全局变量 */
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
. = ALIGN(4K);
erodata = .;
sdata = .;
/* 数据段:放已初始化的全局/静态变量 */
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}
. = ALIGN(4K);
edata = .;
/* BSS段:放未初始化的全局/静态变量,包括我们的栈空间 */
.bss : {
*(.bss.stack)
/* 先记录bss段的起始地址,后续清零用 */
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*) /* 栈空间就在这里 */
}
. = ALIGN(4K);
/* 记录bss段的结束地址 */
ebss = .;
/* 记录内核的结束地址,后续内存管理会用到 */
ekernel = .;
/* 把不需要的段都丢弃,比如用户程序用的.comment、note段等 */
/DISCARD/ : {
*(.eh_frame)
}
}
先放.text.entry:确保入口汇编代码,就在 0x80200000 这个起始地址,CPU 跳过来第一行就执行_start。
ALIGN(4K): 让每个段都按 4KB 对齐,和 RISC-V 的页大小一致,后续做内存管理、页表映射时不会出问题
sbss和ebss: 记录了 bss 段的起始和结束地址,可以在 Rust 代码里,用这两个符号把整个 bss段清零,解决未初始化变量的未定义行为
链接器(ld)会读取你写的linker.ld链接脚本,做两件核心事:
1.合并所有目标文件:把entry.o(汇编机器码)和Rust 编译出的main.o(Rust 转的机器码)合并
2.按链接脚本分配地址:
- 把.text.entry段(_start的机器码)放在BASE_ADDRESS=0x80200000;
- 把.bss.stack段(栈空间)分配到后续的内存地址;
- 把_start标记为程序入口;
- 生成最终的ELF 可执行文件(target/riscv64gc-unknown-none-elf/debug/os)------ 包含所有机器码、内存地址映射、符号表。
**关键:**这一步后,_start对应的机器码就被固定在0x80200000地址上,boot_stack_top也有了具体的内存地址(比如0x80204000)
cargo配置
创建.cargo目录,在里面创建config.toml文件,写入以下配置:
rust
[build]
# 指定默认的编译目标,不用每次都加--target
target = "riscv64gc-unknown-none-elf"
[target.riscv64gc-unknown-none-elf]
# 指定链接器参数,用我们写的linker.ld作为链接脚本
rustflags = [
"-Clink-arg=-Tsrc/linker.ld", "-Cforce-frame-pointers=yes"
]
函数部分
这里函数部分就是内核源代码了,内核通过调用SBI来进行执行硬件操作。
SBI调用规则
RISC-V 的 SBI 调用,有严格的寄存器约定
触发指令:ecall指令,会触发环境调用异常,CPU 从 S 模式陷入 M 模式,SBI 固件处理这个异常
参数传递:用 RISC-V 的通用寄存器a0-a7(对应 x10-x17)传递参数:
- a7寄存器:传递EID(扩展 ID,代表要调用哪一类 SBI 服务)
- a6寄存器:传递FID(功能 ID,代表该类服务里的具体功能)
- a0-a5寄存器:传递调用的具体参数
返回值:SBI 调用完成后,返回值存在两个寄存器里:
- a0:错误码,0 代表成功,非 0 代表错误类型
- a1:返回的数值
实现基础的SBI调用函数,创建一个sbi.rs:
rust
use core::arch::asm;
const SBI_CONSOLE_PUTCHAR: usize = 1;
/// 通用SBI调用函数
#[inline(always)]
fn sbi_call(which: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
let mut ret;
unsafe {
asm!(
"li x16, 0",
"ecall",
inlateout("x10") arg0 => ret,
in("x11") arg1,
in("x12") arg2,
in("x17") which,
);
}
ret
}
/// sbi打印指令
/// EID = 1,c(a0)为需要打印的参数
pub fn console_putchar(c: usize) {
sbi_call(SBI_CONSOLE_PUTCHAR, c, 0, 0);
}
use crate::board::QEMUExit;
/// use sbi call to shutdown the kernel
pub fn shutdown() -> ! {
crate::board::QEMU_EXIT_HANDLE.exit_failure();
}
实现 print!和 println!宏
有了console_putchar,就可以实现 Rust 里的格式化输出宏,和 std 里的print!()用法一致
rust
//! SBI console driver, for text output
use crate::sbi::console_putchar;
use core::fmt::{self, Write};
struct Stdout;
/// 实现Write trait,才能支持格式化输出
impl Write for Stdout {
/// 字符输出,调用SBI putchar输出格式化后的字符
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
console_putchar(c as usize);
}
Ok(())
}
}
/// 格式化输出函数
pub fn print_fmt(args: fmt::Arguments) {
Stdout.write_fmt(args).unwrap();
}
/// 编写宏定义
#[macro_export]
macro_rules! print {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print_fmt(format_args!($fmt $(, $($arg)+)?))
}
}
/// 实现println宏,自动换行
#[macro_export]
macro_rules! println {
($fmt: literal $(, $($arg: tt)+)?) => {
$crate::console::print_fmt(format_args!(concat!($fmt, "\n") $(, $($arg)+)?))
}
}
在main.rs中添加bss 段清零、rust_main 函数:
rust
#![deny(missing_docs)]
#![deny(warnings)]
#![no_std]
#![no_main]
#![feature(panic_info_message)]
use core::arch::global_asm;
use log::*;
#[macro_use]
mod console;
mod lang_items;
mod logging;
mod sbi;
#[path = "boards/qemu.rs"]
mod board;
global_asm!(include_str!("entry.asm"));
/// 清空BSS段
pub fn clear_bss() {
// 导入连接脚本中的函数
extern "C" {
fn sbss();
fn ebss();
}
// 清空地址 遍历每一个字节置为0
(sbss as usize..ebss as usize).for_each(|a| unsafe { (a as *mut u8).write_volatile(0) });
}
/// the rust entry-point of os
#[no_mangle]
pub fn rust_main() -> ! {
// 导入段
extern "C" {
fn stext(); // begin addr of text segment
fn etext(); // end addr of text segment
fn srodata(); // start addr of Read-Only data segment
fn erodata(); // end addr of Read-Only data ssegment
fn sdata(); // start addr of data segment
fn edata(); // end addr of data segment
fn sbss(); // start addr of BSS segment
fn ebss(); // end addr of BSS segment
fn boot_stack_lower_bound(); // stack lower bound
fn boot_stack_top(); // stack top
}
clear_bss();
// 使用自己实现的宏定义
println!("[kernel] Hello, world!");
use crate::board::QEMUExit;
crate::board::QEMU_EXIT_HANDLE.exit_success(); // CI autotest success
//crate::board::QEMU_EXIT_HANDLE.exit_failure(); // CI autoest failed
}
QEMU运行与调试
现在程序已经写好了,接下来要编译成 QEMU 能加载的 BIN 文件,然后用 QEMU 运行
- ELF 文件:带元信息的可执行文件,包含 ELF 头、程序头、节头、代码和数据。它需要一个能解析 ELF 格式的加载器,才能把代码和数据加载到内存的正确位置。
- BIN 文件 :纯二进制镜像,只包含代码、数据的原始字节,和内存里应该存放的内容完全一致。QEMU 的
-device loader参数,会直接把 BIN 文件的内容,原封不动地拷贝到我们指定的物理地址(0x80200000),不需要解析任何格式,简单高效,适合裸机场景。
手动转换与运行
ELF转BIN
bash
rust-objcopy -O binary <输入ELF文件> <输出BIN文件>
rust-objcopy --binary-architecture=riscv64 --strip-all -O binary /target/riscv64gc-unknown-none-elf/debug/os os.bin
--strip-all:去掉所有符号信息,减小文件体积;-O binary:指定输出格式为纯二进制 BIN 文件。
准备 SBI 固件
需要 RustSBI 的固件,作为 QEMU 的 BIOS,先下载预编译的固件:
bash
# 在项目根目录下,创建bootloader目录
mkdir ../bootloader
cd ../bootloader
# 下载RustSBI的virt平台预编译固件,版本自己选择
wget https://github.com/rustsbi/rustsbi-qemu/releases/download/rustsbi-qemu-0.2.0/rustsbi-qemu-release.bin
mv rustsbi-qemu-release.bin rustsbi-qemu.bin
cd ../os
QEMU运行程序
bash
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios ../bootloader/rustsbi-qmenu.bin \
-device loader,file=os.bin,addr=0x80200000
运行结果:
bash
tibbers@HOME:~/rCore-Tutorial-Code-2025S/os$ qemu-system-riscv64 -machine virt -nographic -bios ~/rCore-Tutorial-Code-2025S/bootloader/rustsbi-qemu.bin -device loader,file=os.bin,addr=0x80200000
[rustsbi] RustSBI version 0.3.0-alpha.4, adapting to RISC-V SBI v1.0.0
.______ __ __ _______.___________. _______..______ __
| _ \ | | | | / | | / || _ \ | |
| |_) | | | | | | (----`---| |----`| (----`| |_) || |
| / | | | | \ \ | | \ \ | _ < | |
| |\ \----.| `--' |.----) | | | .----) | | |_) || |
| _| `._____| \______/ |_______/ |__| |_______/ |______/ |__|
[rustsbi] Implementation : RustSBI-QEMU Version 0.2.0-alpha.2
[rustsbi] Platform Name : riscv-virtio,qemu
[rustsbi] Platform SMP : 1
[rustsbi] Platform Memory : 0x80000000..0x88000000
[rustsbi] Boot HART : 0
[rustsbi] Device Tree Region : 0x87000000..0x87000ef2
[rustsbi] Firmware Address : 0x80000000
[rustsbi] Supervisor Address : 0x80200000
[rustsbi] pmp01: 0x00000000..0x80000000 (-wr)
[rustsbi] pmp02: 0x80000000..0x80200000 (---)
[rustsbi] pmp03: 0x80200000..0x88000000 (xwr)
[rustsbi] pmp04: 0x88000000..0x00000000 (-wr)
[kernel] Hello, world!
MakeFile进行自动构建
Lua
# 目标平台
TARGET := riscv64gc-unknown-none-elf
# 编译模式:debug/release
MODE := release
# 内核ELF文件路径
KERNEL_ELF := target/$(TARGET)/$(MODE)/os
# 内核BIN文件路径
KERNEL_BIN := $(KERNEL_ELF).bin
# asm
DISASM_TMP := target/$(TARGET)/$(MODE)/asm
# Building mode argument
ifeq ($(MODE), release)
MODE_ARG := --release
endif
# BOARD
BOARD := qemu
# SBI架构
SBI ?= rustsbi
# SBI路径
BOOTLOADER := ../bootloader/$(SBI)-$(BOARD).bin
# 内核entry地址
KERNEL_ENTRY_PA := 0x80200000
# 工具链
OBJDUMP := rust-objdump --arch-name=riscv64
OBJCOPY := rust-objcopy --binary-architecture=riscv64
# Disassembly
DISASM ?= -x
build: env $(KERNEL_BIN)
env:
(rustup target list | grep "riscv64gc-unknown-none-elf (installed)") || rustup target add $(TARGET)
cargo install cargo-binutils
rustup component add rust-src
rustup component add llvm-tools-preview
$(KERNEL_BIN): kernel
@$(OBJCOPY) $(KERNEL_ELF) --strip-all -O binary $@
kernel:
@echo Platform: $(BOARD)
@cargo build $(MODE_ARG)
clean:
@cargo clean
disasm: kernel
@$(OBJDUMP) $(DISASM) $(KERNEL_ELF) | less
disasm-vim: kernel
@$(OBJDUMP) $(DISASM) $(KERNEL_ELF) > $(DISASM_TMP)
@vim $(DISASM_TMP)
@rm $(DISASM_TMP)
run: run-inner
run-inner: build
@qemu-system-riscv64 \
-machine virt \
-nographic \
-bios $(BOOTLOADER) \
-device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA)
# 调试模式:启动QEMU,开启GDB调试,暂停执行
debug: build
@tmux new-session -d \
"qemu-system-riscv64 -machine virt -nographic -bios $(BOOTLOADER) -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) -s -S" && \
tmux split-window -h "riscv64-unknown-elf-gdb -ex 'file $(KERNEL_ELF)' -ex 'set arch riscv:rv64' -ex 'target remote localhost:1234'" && \
tmux -2 attach-session -d
gdbserver: build
@qemu-system-riscv64 -machine virt -nographic -bios $(BOOTLOADER) -device loader,file=$(KERNEL_BIN),addr=$(KERNEL_ENTRY_PA) -s -S
gdbclient:
@riscv64-unknown-elf-gdb -ex 'file $(KERNEL_ELF)' -ex 'set arch riscv:rv64' -ex 'target remote localhost:1234'
.PHONY: build env kernel clean disasm disasm-vim run-inner gdbserver gdbclient
通过make run直接构建和启动。
GDB 调试裸机程序
1.启动GDB,连接QEMU
bash
gdb-multiarch target/riscv64gc-unknown-none-elf/debug/os
进入 GDB 的交互界面后,执行以下命令:
bash
# 连接QEMU的调试端口
target remote :1234
# 在rust_main函数打个断点
break rust_main
# 继续执行,会停在rust_main的入口
continue
# 接下来就可以用调试命令了:
# ni:单步执行一条汇编指令
# n:单步执行一行Rust代码
# s:步入函数
# backtrace:查看函数调用栈
# info registers:查看所有寄存器的值
# x/10i $pc:查看当前指令指针后面的10条汇编指令
这样就可以调试kernel了。