1.18 RISC-V 裸机:SBI、三特权级与 trap
RISC-V 的 M/S/U 三个特权级各能做什么?`mstatus/mepc/mtvec/satp` 这些 CSR 在 trap 时怎么变?SBI 是什么、为什么内核第一行代码要通过它打印?一个 `#![no_std]` 的 Rust 裸机程序怎样链接到 `0x80200000` 并在 QEMU 里跑起来?
写出最小 RISC-V 裸机入口与 trap 处理;解释 `ecall/sret/mret` 的切换;用 SBI 输出与关机;用 QEMU/gdb 单步。
本章参考清华 rCore 教程的章节组织与 RISC-V 概念,不摘录其正文、不复制其代码;rCore 为 GPLv3,完整实现请到原仓库学习。本章示例为独立编写。
1.18.1 RISC-V 寄存器与 CSR
RISC-V 64 有 32 个通用寄存器(x0–x31),其中 x0 恒为 0;ABI 另给别名:
| 别名 | 编号 | 用途 | 谁保存 |
|---|---|---|---|
zero | x0 | 恒 0 | — |
ra | x1 | 返回地址 | 调用者/被调用者按需 |
sp | x2 | 栈指针 | 被调用者 |
a0–a7 | x10–x17 | 参数/返回值 | 调用者 |
t0–t6 | x5–x7, x28–x31 | 临时 | 调用者 |
s0–s11 | x8–x9, x18–x27 | 被调用者保存 | 被调用者 |
CSR(control and status register)用 csrrw/csrrs/csrrc 读写:
| CSR | 作用 |
|---|---|
mstatus | 全局中断使能、特权级、MPRV 等 |
mepc | trap 时的返回 PC |
mtvec | trap 入口地址/模式 |
mcause | trap 原因(中断/异常编号) |
mtval | 附加信息(坏地址/指令) |
satp | 页表根(S 模式) |
sstatus/sepc/stvec/scause/stval | S 模式对应物 |
# 读 mcause 到 a0
csrr a0, mcause
# 写 mtvec(直接模式)
la t0, trap_entry
csrw mtvec, t0
1.18.2 三特权级与 trap
U (用户) --ecall--> S (内核) --ecall--> M (固件/SBI)
^ | |
+------ sret ------+------ mret -----+
- M 模式:最高特权,通常跑 OpenSBI/固件;负责时钟、IPI、关机等。
- S 模式:Linux/内核跑在这里;通过 SBI 向 M 模式请求服务。
- U 模式:应用;
ecall进入 S 模式。
一次 trap 的硬件动作:
- 把当前 PC 写入
sepc(或mepc); - 把原因写入
scause; - 跳到
stvec/mtvec; - 关中断、切特权级;
- 软件在入口保存寄存器、处理、
sret返回。
# 最小 trap 入口(S 模式,保存 caller-saved)
trap_entry:
addi sp, sp, -256
sd ra, 0(sp)
sd t0, 8(sp)
...
csrr a0, scause
csrr a1, sepc
call trap_handler
ld ra, 0(sp)
...
addi sp, sp, 256
sret
1.18.3 SBI:内核与固件的契约
SBI(Supervisor Binary Interface)是 S 模式调用 M 模式服务的 ABI:
| 功能 | EID | FID | 参数 | 返回值 |
|---|---|---|---|---|
| 控制台输出(legacy) | 0x01 | — | a0=字符 | 无 |
| 关机(legacy) | 0x07 | — | 无 | 不返回 |
| 发 IPI(legacy) | 0x08 | — | a0=hart mask | — |
| 设置定时器(TIME) | 0x54494D45 | 0 | a0=绝对时间 | a0=error |
| 发 IPI(IPI) | 0x735049 | 0 | a0=hart mask, a1=mask | a0=error |
| 系统复位(SRST) | 0x53525354 | 0 | a0=type(0 关机), a1=reason | 不返回 |
| 调试控制台写(DBCN) | 0x4442434E | 2 | a0=字节数, a1=物理地址, a2=偏移 | a0=error, a1=已写 |
注意两个容易记错的编号:legacy 关机是 0x07,不是 0x08;0x08 是 legacy 发 IPI。现代关机用 SRST 扩展 0x53525354(ASCII "SRST"),成功时不返回。
# legacy putchar: a7=EID=1, a0=字符
li a7, 0x01
li a0, 'A'
ecall
SBI 的返回值约定是:a0 是错误码,a1 是值(SBI_SUCCESS=0,失败为负)。不要写成「返回值在 a0,错误在 a1」——正好相反。现代 SBI 用 sbi_call(eid, fid, arg0...) 统一封装,FID 放在 a6。
1.18.3.1 调用约定速查
| 寄存器 | 输入 | 输出 |
|---|---|---|
a7 | EID | 不变 |
a6 | FID | 不变 |
a0–a5 | 参数 0–5 | a0=错误码,a1=值 |
ecall 从 S 态进入 M 态时 scause=9;OpenSBI 处理完把 mepc += 4 再 mret。所以 S 态看起来像普通函数调用,但寄存器约定必须严格遵守。完整 EID 表见 1.18.3。
1.18.4 最小 Rust 裸机程序:链接脚本、_start 与 SBI 输出
现在把上面的碎片拼成一个能跑的程序。目标:在 QEMU virt 上,由 OpenSBI 加载,S 态打印 hello, myrcore,然后通过 SBI 关机。整个程序只有四个文件:
链接脚本
链接脚本决定「哪个段放在哪个物理地址」。QEMU -kernel 加载 ELF 时按程序头(program header)装载,所以入口地址和段地址必须匹配链接脚本:
/* linker.ld:myrcore 在 QEMU virt + OpenSBI 下的内存布局 */
OUTPUT_ARCH(riscv)
ENTRY(_start)
BASE_ADDRESS = 0x80200000; /* OpenSBI 占用 0x8000_0000..0x8020_0000 */
SECTIONS
{
. = BASE_ADDRESS;
skernel = .;
.text : {
*(.text.entry) /* _start 必须最先执行 */
*(.text .text.*)
}
. = ALIGN(4K);
srodata = .;
.rodata : {
*(.rodata .rodata.*)
*(.srodata .srodata.*)
}
. = ALIGN(4K);
sdata = .;
.data : {
*(.data .data.*)
*(.sdata .sdata.*)
}
. = ALIGN(4K);
.bss : {
*(.bss.stack) /* 启动栈单独放,避免被清零循环覆盖 */
sbss = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
ebss = .;
}
. = ALIGN(4K);
ekernel = .;
/DISCARD/ : {
*(.eh_frame)
*(.comment)
}
}
几个关键点:
ENTRY(_start)只影响 ELF 的入口字段,不改变段地址;QEMU 用 ELF 入口跳转。.text.entry放在.text最前面,保证_start在0x8020_0000。sbss/ebss是清零循环的边界;它们必须是符号,不能在 Rust 里用数组长度代替。.bss.stack放在sbss之前,清零循环就不会碰栈。你也可以把栈放在.bss之外,效果一样。
入口 _start
_start 是唯一一段必须在任何 Rust 代码之前运行的汇编。它要做三件事:清 .bss、设 sp、跳到 Rust:
# entry.asm:OpenSBI 跳到 0x8020_0000 后执行的第一段代码
.section .text.entry
.globl _start
_start:
# 1) 清 .bss:sbss..ebss,按 8 字节写 0
la t0, sbss
la t1, ebss
1:
bgeu t0, t1, 2f
sd zero, 0(t0)
addi t0, t0, 8
j 1b
2:
# 2) 设置启动栈(栈从高地址向低地址增长)
la sp, boot_stack_top
# 3) 进入 Rust;不返回
call rust_main
3:
wfi
j 3b
# 启动栈放在 .bss.stack,大小 16 KiB
.section .bss.stack, "aw", @nobits
.align 12
boot_stack:
.space 4096 * 4
boot_stack_top:
这段汇编有两个容易忽略的细节:
la是伪指令,展开成auipc+addi,得到的是链接地址(0x8020_xxxx),不是当前 PC 的物理地址。因为 OpenSBI 已经把 MMU 关掉(satp=0),链接地址就是物理地址。call rust_main使用ra保存返回地址;rust_main如果返回,会跳回3:的wfi。我们让rust_main返回!,所以这条路径只是保险。
Rust 主逻辑
Rust 侧只需要三样东西:#![no_std]、#![no_main]、一个 panic_handler。global_asm! 把 entry.asm 嵌进目标文件,链接器才能看到 _start。
#![allow(unused)]
fn main() {
// src/main.rs:最小 S 态裸机程序
#![no_std]
#![no_main]
use core::arch::global_asm;
use core::fmt::{self, Write};
use core::panic::PanicInfo;
global_asm!(include_str!("entry.asm"));
const SBI_CONSOLE_PUTCHAR: usize = 0x01;
const SBI_SRST: usize = 0x5352_5354; // "SRST"
#[inline(always)]
fn sbi_call(eid: usize, fid: usize, arg0: usize, arg1: usize) -> (isize, usize) {
let (err, val): (isize, usize);
unsafe {
core::arch::asm!(
"ecall",
in("a7") eid,
in("a6") fid,
inlateout("a0") arg0 => err,
inlateout("a1") arg1 => val,
options(nostack),
);
}
(err, val)
}
struct Console;
impl Write for Console {
fn write_str(&mut self, s: &str) -> fmt::Result {
for b in s.bytes() {
let _ = sbi_call(SBI_CONSOLE_PUTCHAR, 0, b as usize, 0);
}
Ok(())
}
}
pub fn print_fmt(args: fmt::Arguments) {
let mut c = Console;
c.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print {
($($arg:tt)*) => { $crate::print_fmt(format_args!($($arg)*)) };
}
#[macro_export]
macro_rules! println {
() => { $crate::print!("\n") };
($($arg:tt)*) => { $crate::print!("{}\n", format_args!($($arg)*)) };
}
fn shutdown(failure: bool) -> ! {
let _ = sbi_call(SBI_SRST, 0, 0, failure as usize);
loop {
core::hint::spin_loop();
}
}
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
println!("hello, myrcore");
shutdown(false)
}
#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
println!("[panic] {}", info);
shutdown(true)
}
}
这段代码的关键是 inlateout("a0") 把输入输出绑在同一寄存器,符合 SBI 约定;panic_handler 返回 !,裸机下只能打印后关机。
Cargo.toml 与 .cargo/config.toml
panic = "abort" 是必须的:no_std 没有 unwinding 运行时,默认 unwind 会链接失败。Cargo.toml 只需 edition = "2021" 和两处 panic = "abort"。
编译、反汇编、运行
# 1) 安装目标(只需一次)
rustup target add riscv64gc-unknown-none-elf
# 2) 编译
cargo build --release
# 3) 看入口和段布局
llvm-readelf -h -l target/riscv64gc-unknown-none-elf/release/hello
llvm-objdump -d --no-show-raw-insn target/riscv64gc-unknown-none-elf/release/hello | head -40
# 4) 在 QEMU 上运行(OpenSBI 由 QEMU 自带)
qemu-system-riscv64 \
-machine virt \
-nographic \
-bios default \
-kernel target/riscv64gc-unknown-none-elf/release/hello
预期输出:
hello, myrcore
sstatus = 0x...
最后一行 sstatus 的具体值取决于 OpenSBI 的初始化,但通常能看到 SPP=0(sret 后清 0)、SIE=0(还未开中断)。如果你看到 hello 但 QEMU 不退出,说明 shutdown 的 SRST 参数或 EID 写错了;如果什么都没看到,先用 -s -S 加 gdb 看 PC 是否停在 0x8020_0000。
1.18.5 启动链、内存地图与 C/汇编版本
RISC-V 没有规定必须有三层固件,但 QEMU virt 的启动链是:
上电 → ZSBL(M) → OpenSBI(M) → 你的内核(S) → 用户程序(U)
│ mret │ sret │ ecall
└─────────────┴─────────────┘
OpenSBI 用 mret 把 M 降到 S;内核用 sret 把 S 降到 U;用户用 ecall 把 U 升到 S;内核再用 ecall 把 S 升到 M(SBI)。每一次「上升」都不是函数调用,而是硬件保存返回地址、切换特权级、跳到 trap 向量。
QEMU virt 的物理内存地图
| 物理地址 | 大小 | 设备 | 本章用途 |
|---|---|---|---|
0x0000_1000 | 4 KiB | SiFive test finisher | 写 0x5555 让 QEMU 成功退出 |
0x0200_0000 | 64 KiB | CLINT | mtime/mtimecmp |
0x0c00_0000 | 64 MiB | PLIC | 外部中断 |
0x1000_0000 | 256 B | UART0(16550A) | THR 写字符 |
0x1000_1000 | 256 B | UART1 / virtio-mmio 起点 | 第 1.19 节块设备 |
0x8000_0000 | 128 MiB | DRAM 起始 | OpenSBI 固件 |
0x8020_0000 | — | 内核加载地址 | -kernel 默认入口 |
外设是 MMIO:读写某个物理地址等于读写设备寄存器,不需要 in/out。DRAM 从 0x8000_0000 开始,所以 BASE_ADDRESS 是 0x8020_0000(给 OpenSBI 留 2 MiB)或 0x8000_0000(-bios none)。
C 版本
start.S 负责清 .bss、设栈、跳 kmain;hello.c 只做输出和关机。链接脚本与 Rust 版共用。
/* start.S */
.section .text.entry
.globl _start
_start:
la t0, sbss
la t1, ebss
1: bgeu t0, t1, 2f
sd zero, 0(t0)
addi t0, t0, 8
j 1b
2: la sp, boot_stack_top
call kmain
3: wfi
j 3b
.section .bss.stack, "aw", @nobits
.align 12
boot_stack:
.space 4096 * 4
boot_stack_top:
/* hello.c */
#include <stdint.h>
#define SBI_PUTCHAR 0x01UL
#define SBI_SRST 0x53525354UL
static long sbi_call(long eid, long fid, long a0, long a1, long *value)
{
register long r_a0 __asm__("a0") = a0;
register long r_a1 __asm__("a1") = a1;
register long a7 __asm__("a7") = eid;
register long a6 __asm__("a6") = fid;
__asm__ volatile("ecall" : "+r"(r_a0), "+r"(r_a1)
: "r"(a7), "r"(a6) : "memory");
if (value) *value = r_a1;
return r_a0;
}
static void putc(char c) { (void)sbi_call(SBI_PUTCHAR, 0, (unsigned char)c, 0, 0); }
static void puts_(const char *s)
{
while (*s) { if (*s == '\n') putc('\r'); putc(*s++); }
}
static void shutdown(int failure)
{
(void)sbi_call(SBI_SRST, 0, 0, failure, 0);
for (;;) __asm__ volatile("wfi");
}
void kmain(void) { puts_("hello from C\n"); shutdown(0); }
clang --target=riscv64-unknown-elf -march=rv64gc -mabi=lp64d \
-ffreestanding -nostdlib -T linker.ld start.S hello.c -o hello-c.elf
qemu-system-riscv64 -machine virt -nographic -bios default -kernel hello-c.elf
纯汇编版本
# hello.S 的关键片段;完整入口与 linker.ld 同上
_start:
la sp, boot_stack_top
la a0, msg
call puts
li a7, 0x53525354
li a6, 0
li a0, 0
li a1, 0
ecall
3: wfi
j 3b
puts:
li t0, 0x10000000
1: lbu t1, 0(a0)
beqz t1, 2f
lbu t2, 5(t0)
andi t2, t2, 0x20
beqz t2, 1b
sb t1, 0(t0)
addi a0, a0, 1
j 1b
2: ret
.section .rodata
msg: .asciz "hello from asm\n"
同样的入口,在别的 ISA 上叫什么
| 机制 | RISC-V 64 | x86-64 | ARM64 |
|---|---|---|---|
| 用户 → 内核 | ecall | syscall | svc #0 |
| 内核 → 固件 | ecall + SBI | UEFI runtime | smc/hvc(PSCI) |
| 返回 | sret/mret | sysret/iretq | eret |
| 特权级 | M/S/U | ring 0–3 | EL0–EL3 |
1.18.6 不用 SBI,直接写 UART:MMIO 与 16550A
SBI 的输出很方便,但它把「设备驱动」藏在了 M 态。理解裸机必须至少手写一次 UART,否则你不知道 console_putchar 背后发生了什么。QEMU virt 的 UART0 是 16550A 兼容设备,基址 0x1000_0000,寄存器间隔 1 字节:
| 偏移 | 读 | 写 | 本章用到的位 |
|---|---|---|---|
0x00 | RBR 接收 | THR 发送 | 写字节即发送 |
0x01 | IER | IER | bit0 接收中断,bit1 发送中断 |
0x02 | IIR | FCR | FCR bit0 开 FIFO,bit1 清接收,bit2 清发送 |
0x03 | LCR | LCR | bit0–1 字长(3=8 位),bit7 DLAB |
0x04 | MCR | MCR | 调制解调器控制,通常不用 |
0x05 | LSR | — | bit5 THRE 发送保持寄存器空,bit6 TEMT 发送器空 |
0x06 | MSR | — | 调制解调器状态 |
0x07 | SCR | SCR | 暂存 |
最小初始化:LCR=0x03(8N1)、FCR=0x01(开 FIFO)、IER=0x00(关中断)。然后轮询 LSR.THRE,为 1 时写 THR。
#![allow(unused)]
fn main() {
const UART0: usize = 0x1000_0000;
#[inline(always)]
unsafe fn mmio_write(addr: usize, value: u8) {
core::ptr::write_volatile(addr as *mut u8, value);
}
#[inline(always)]
unsafe fn mmio_read(addr: usize) -> u8 {
core::ptr::read_volatile(addr as *const u8)
}
pub fn uart_init() {
unsafe {
mmio_write(UART0 + 0x03, 0x03); // LCR: 8 位,无校验,1 停止位
mmio_write(UART0 + 0x02, 0x01); // FCR: 开 FIFO
mmio_write(UART0 + 0x01, 0x00); // IER: 关中断
}
}
pub fn uart_putc(c: u8) {
unsafe {
while mmio_read(UART0 + 0x05) & 0x20 == 0 {
core::hint::spin_loop(); // 等 THRE
}
mmio_write(UART0, c);
}
}
pub fn uart_puts(s: &str) {
for b in s.bytes() {
if b == b'\n' {
uart_putc(b'\r');
}
uart_putc(b);
}
}
}
# uart_putc: a0 = 字符;轮询 LSR.THRE 后写 THR
uart_putc:
li t0, 0x10000000 # UART0 基址
1:
lbu t1, 5(t0) # LSR
andi t1, t1, 0x20 # THRE
beqz t1, 1b
sb a0, 0(t0) # THR
ret
这三个版本的语义完全一样:轮询 LSR 的第 5 位,直到发送保持寄存器空,再写 THR。volatile 是关键:编译器不能把「连续读同一个地址」优化成一次读,因为硬件会在两次读之间改变 LSR。Rust 的 read_volatile/write_volatile 和 C 的 volatile 都只是告诉编译器「不要优化掉」,不提供内存屏障;在多核场景下还需要 fence。
- 忘了
\r:串口终端通常需要\r\n才能回到行首;只发\n会出现阶梯状输出。 - 初始化顺序反了:先写
THR再设LCR,第一个字符可能用错误的帧格式发出去。 - 在
panic里用println!又触发 UART 中断:如果IER没关,panic 输出可能被中断打断,最后看到的是半行。裸机 panic 路径要把中断全关掉。
1.18.7 trap 入口:保存 32 个寄存器
现在进入本章最硬的部分:写一个能保存/恢复完整现场的 trap 入口。没有它,时钟中断一来,被中断的代码就会看到寄存器被 handler 改得面目全非。
TrapFrame 的布局
我们用一块连续的内存保存现场。RV64 下 32 个通用寄存器 + sepc + sstatus,共 34 个 8 字节槽:
| 偏移 | 内容 | 说明 |
|---|---|---|
0 | x0 | 恒为 0,占位,方便按索引访问 |
8 | x1/ra | 返回地址 |
16 | x2/sp | 用户栈指针(入口时从 sscratch 取) |
24 | x3/gp | 全局指针 |
32 | x4/tp | 线程指针 |
40–56 | x5–x7 | t0–t2 |
64–72 | x8–x9 | s0–s1 |
80–136 | x10–x17 | a0–a7 |
144–216 | x18–x27 | s2–s11 |
224–248 | x28–x31 | t3–t6 |
256 | sepc | 返回地址 |
264 | sstatus | 返回时的状态 |
这个布局不是随便定的:它让汇编可以用 sd xN, N*8(sp) 一条指令保存一个寄存器,也让 Rust 可以用 #[repr(C)] 结构体按字段访问。两者必须严格一致,否则你会在「为什么 a0 变成了 sepc」上浪费一个下午。
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Clone, Copy, Debug, Default)]
pub struct TrapFrame {
pub x: [usize; 32], // x[0] 恒 0;x[2] 是用户 sp
pub sepc: usize,
pub sstatus: usize,
}
impl TrapFrame {
pub fn a0(&self) -> usize { self.x[10] }
pub fn a7(&self) -> usize { self.x[17] }
pub fn sp(&self) -> usize { self.x[2] }
}
}
入口与出口汇编
入口要换栈、保存 30 个寄存器(x0 不存,x2 从 sscratch 取)、保存 sepc/sstatus、把 sp 作为参数传给 Rust、调用 handler;出口反向操作。
# trap.asm:直接模式 stvec 入口
.section .text
.globl __s_trap_entry
.align 4
__s_trap_entry:
# 1) 交换栈:sp = 内核栈,sscratch = 用户 sp
csrrw sp, sscratch, sp
# 2) 分配 TrapFrame(34*8 = 272 字节)
addi sp, sp, -272
# 3) 保存通用寄存器;x0 跳过,x2 单独从 sscratch 取
sd x1, 8(sp)
csrr t0, sscratch
sd t0, 16(sp)
sd x3, 24(sp)
sd x4, 32(sp)
sd x5, 40(sp)
sd x6, 48(sp)
sd x7, 56(sp)
sd x8, 64(sp)
sd x9, 72(sp)
sd x10, 80(sp)
sd x11, 88(sp)
sd x12, 96(sp)
sd x13, 104(sp)
sd x14, 112(sp)
sd x15, 120(sp)
sd x16, 128(sp)
sd x17, 136(sp)
sd x18, 144(sp)
sd x19, 152(sp)
sd x20, 160(sp)
sd x21, 168(sp)
sd x22, 176(sp)
sd x23, 184(sp)
sd x24, 192(sp)
sd x25, 200(sp)
sd x26, 208(sp)
sd x27, 216(sp)
sd x28, 224(sp)
sd x29, 232(sp)
sd x30, 240(sp)
sd x31, 248(sp)
# 4) 保存 sepc / sstatus
csrr t0, sepc
sd t0, 256(sp)
csrr t0, sstatus
sd t0, 264(sp)
# 5) 调用 Rust handler:a0 = &TrapFrame
mv a0, sp
call s_trap_handler
# 6) 恢复 sepc / sstatus
ld t0, 256(sp)
csrw sepc, t0
ld t0, 264(sp)
csrw sstatus, t0
# 7) 恢复通用寄存器(x2 不在这里恢复)
ld x1, 8(sp)
ld x3, 24(sp)
ld x4, 32(sp)
ld x5, 40(sp)
ld x6, 48(sp)
ld x7, 56(sp)
ld x8, 64(sp)
ld x9, 72(sp)
ld x10, 80(sp)
ld x11, 88(sp)
ld x12, 96(sp)
ld x13, 104(sp)
ld x14, 112(sp)
ld x15, 120(sp)
ld x16, 128(sp)
ld x17, 136(sp)
ld x18, 144(sp)
ld x19, 152(sp)
ld x20, 160(sp)
ld x21, 168(sp)
ld x22, 176(sp)
ld x23, 184(sp)
ld x24, 192(sp)
ld x25, 200(sp)
ld x26, 208(sp)
ld x27, 216(sp)
ld x28, 224(sp)
ld x29, 232(sp)
ld x30, 240(sp)
ld x31, 248(sp)
# 8) 释放 TrapFrame,换回用户栈
addi sp, sp, 272
csrrw sp, sscratch, sp
sret
这段汇编依赖三个约定:sscratch 存内核栈顶;x2 通过 sscratch 交换而不是从 frame 恢复;handler 的 ABI 是 extern "C" fn(&mut TrapFrame),a0 传 frame 指针。
从入口到分发
入口只保存现场;分发在 Rust 里:读 scause 最高位区分中断/异常,ecall 用 a7 作为系统调用号,返回前 sepc += 4。完整实现在 P2 的 s_trap_handler,见下文。
- 保存了
x0又恢复它:x0是硬连线 0,ld x0, 0(sp)不报错但也没用;更糟的是有人用x0当临时寄存器,结果值永远是 0。 - 忘记保存
t0–t6:Rust handler 会自由使用临时寄存器;不保存就会破坏被中断代码的中间值。 sepc += 4用在中断上:时钟中断返回后跳过了一条无辜的指令,程序行为会变得随机。sstatus恢复顺序错:先sret再恢复sstatus已经来不及;必须先把sstatus写回,再sret。sscratch初值没设:第一次 trap 时csrrw sp, sscratch, sp会把sp换成 0,然后addi sp, sp, -272变成非法地址,trap 嵌套,最后 triple fault。
三个特权级的完整演示
把上面的 trap 入口、SBI、M 态 handler 拼起来,就是本章的第二个完整程序:M 态启动 → 委托 ecall-from-U 给 S → mret 到 S → sret 到 U → U 执行 ecall → S 处理并打印 → U 再 ecall 请求关机 → S 通过 SBI 回到 M → M 用 test finisher 退出 QEMU。
这个程序比 P1 长,但它把本章所有机制串成了一条可验证的路径。它需要四个文件:entry-p2.asm(入口,链接到 0x8000_0000)、trap.asm(S/M 两个入口)、linker-p2.ld(BASE_ADDRESS = 0x80000000)、main.rs(下面)。注意 P2 用 -bios none 直接跑在 M 态,不需要 OpenSBI;关机用 QEMU 的 test finisher,而不是 SRST。
#![allow(unused)]
fn main() {
// P2: M -> S -> U -> trap -> M(SBI)
#![no_std]
#![no_main]
use core::arch::{asm, global_asm};
use core::panic::PanicInfo;
global_asm!(include_str!("entry-p2.asm"));
global_asm!(include_str!("trap.asm"));
const UART0: usize = 0x1000_0000;
const TEST_FINISHER: usize = 0x0010_0000;
const SBI_PUTCHAR: usize = 0x01;
const SBI_SRST: usize = 0x5352_5354;
#[repr(C)]
pub struct TrapFrame {
pub x: [usize; 32],
pub sepc: usize,
pub sstatus: usize,
}
#[repr(C)]
pub struct MTrapFrame {
pub a: [usize; 8], // a0..a7,必须全部保存/恢复
}
extern "C" {
static s_stack_top: u8;
static u_stack_top: u8;
fn __s_trap_entry();
fn m_trap_entry();
}
// ---- UART(M 态直接输出) ----
unsafe fn uart_putc(c: u8) {
while (core::ptr::read_volatile((UART0 + 5) as *const u8) & 0x20) == 0 {}
core::ptr::write_volatile(UART0 as *mut u8, c);
}
fn uart_puts(s: &str) {
for b in s.bytes() {
if b == b'\n' { unsafe { uart_putc(b'\r') }; }
unsafe { uart_putc(b) };
}
}
fn test_exit(code: u16) -> ! {
let v = if code == 0 { 0x5555u32 } else { 0x3333 | ((code as u32) << 16) };
unsafe { core::ptr::write_volatile(TEST_FINISHER as *mut u32, v) };
loop { core::hint::spin_loop(); }
}
// ---- S 态 SBI 调用(ecall 到 M) ----
fn sbi_call(eid: usize, fid: usize, a0: usize, a1: usize) -> (isize, usize) {
let (err, val): (isize, usize);
unsafe {
asm!("ecall", in("a7") eid, in("a6") fid,
inlateout("a0") a0 => err, inlateout("a1") a1 => val,
options(nostack));
}
(err, val)
}
// ---- M 态:配置 PMP、委托 ecall-from-U、降到 S ----
#[no_mangle]
pub extern "C" fn m_main() -> ! {
uart_puts("M: hello from M-mode\n");
unsafe {
asm!("csrw mtvec, {}", in(reg) m_trap_entry as usize);
asm!("csrw medeleg, {}", in(reg) 1usize << 8);
asm!("csrw pmpaddr0, {}", in(reg) 0x3fff_ffff_ffff_ffffusize);
asm!("csrw pmpcfg0, {}", in(reg) 0x0fusize);
let mut ms: usize;
asm!("csrr {}, mstatus", out(reg) ms);
ms &= !(1 << 3); // MIE = 0
ms = (ms & !(3 << 11)) | (1 << 11) | (1 << 7); // MPP=S, MPIE=1
asm!("csrw mstatus, {}", in(reg) ms);
asm!("csrw mepc, {}", in(reg) s_main as usize);
asm!("mret", options(noreturn));
}
}
// ---- S 态:换内核栈,降到 U ----
#[no_mangle]
pub extern "C" fn s_main() -> ! {
let s_top = unsafe { &s_stack_top as *const u8 as usize };
let u_top = unsafe { &u_stack_top as *const u8 as usize };
unsafe {
asm!("mv sp, a0; csrw sscratch, sp", in("a0") s_top, options(nostack));
asm!("csrw stvec, {}", in(reg) __s_trap_entry as usize);
let mut ss: usize;
asm!("csrr {}, sstatus", out(reg) ss);
ss &= !(1 << 1); // SIE = 0
ss = (ss & !(1 << 8)) | (1 << 5); // SPP=U, SPIE=1
asm!("csrw sstatus, {}", in(reg) ss);
asm!("mv sp, a0", in("a0") u_top, options(nostack));
asm!("csrw sepc, {}", in(reg) u_main as usize);
asm!("sret", options(noreturn));
}
}
// ---- U 态:两次 ecall ----
#[no_mangle]
pub extern "C" fn u_main() -> ! {
unsafe {
asm!("ecall", in("a7") 1usize, in("a0") 'U' as usize, options(nostack));
asm!("ecall", in("a7") 2usize, options(nostack));
}
loop { core::hint::spin_loop(); }
}
// ---- S 态 trap:ecall-from-U ----
#[no_mangle]
pub extern "C" fn s_trap_handler(tf: &mut TrapFrame) {
let scause: usize;
unsafe { asm!("csrr {}, scause", out(reg) scause) };
if scause == 8 {
match tf.x[17] { // a7
1 => { let _ = sbi_call(SBI_PUTCHAR, 0, tf.x[10], 0); }
2 => { let _ = sbi_call(SBI_SRST, 0, 0, 0); }
_ => {}
}
tf.sepc += 4;
} else {
uart_puts("S: unexpected trap\n");
test_exit(1);
}
}
// ---- M 态 trap:迷你 SBI ----
#[no_mangle]
pub extern "C" fn m_trap_handler(tf: &MTrapFrame) {
match tf.a[7] {
SBI_PUTCHAR => unsafe { uart_putc(tf.a[0] as u8) },
SBI_SRST => {
uart_puts("M: SRST -> test finisher\n");
test_exit(0);
}
_ => uart_puts("M: unknown SBI\n"),
}
unsafe {
let mepc: usize;
asm!("csrr {}, mepc", out(reg) mepc);
asm!("csrw mepc, {}", in(reg) mepc + 4);
}
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { test_exit(2) }
}
M 态入口的汇编只保存 a0–a7,因为 handler 只读这八个参数;返回时恢复它们并 mret。它与 S 态入口一起放在 trap.asm 里:
# m_trap_entry:保存 a0..a7,SBI 约定要求除 a0/a1 外保持不变
.section .text
.globl m_trap_entry
.align 4
m_trap_entry:
addi sp, sp, -80
sd ra, 64(sp)
sd a0, 0(sp)
sd a1, 8(sp)
sd a2, 16(sp)
sd a3, 24(sp)
sd a4, 32(sp)
sd a5, 40(sp)
sd a6, 48(sp)
sd a7, 56(sp)
mv a0, sp
call m_trap_handler
ld a0, 0(sp)
ld a1, 8(sp)
ld a2, 16(sp)
ld a3, 24(sp)
ld a4, 32(sp)
ld a5, 40(sp)
ld a6, 48(sp)
ld a7, 56(sp)
ld ra, 64(sp)
addi sp, sp, 80
mret
# P2 的链接脚本把 BASE_ADDRESS 改成 0x80000000
sed 's/0x80200000/0x80000000/' linker.ld > linker-p2.ld
rustc --edition 2021 -O --target riscv64gc-unknown-none-elf \
-C panic=abort -C link-arg=-Tlinker-p2.ld \
src/main.rs -o p2.elf
qemu-system-riscv64 -machine virt -nographic -bios none -kernel p2.elf
预期输出:
M: hello from M-mode
U
M: SRST -> test finisher
如果只看到 M: SRST 而没有 U,说明 medeleg 没设或 U 态的 ecall 没进 S;用 GDB 在 s_trap_handler 下断点确认。如果连 M: hello 都没有,说明 -bios none 没有从 0x80000000 取指,检查链接脚本和 ELF 入口。
M 态默认不受 PMP 限制,但 S/U 态的所有访存都要经过 PMP 检查。如果 pmpcfg0=0(没有任何条目),S/U 态访问任何地址都会触发 access fault。pmpaddr0=0x3fff_ffff_ffff_ffff + pmpcfg0=0x0f 配置了一个 NAPOT 区域,覆盖全部物理地址,权限 RWX。真实内核不会这么干:它会用 PMP 把 S 态限制在内核区域,把 U 态限制在用户区域,这也是「没有 MMU 的隔离」的最后一道防线。
1.18.8 时钟中断:从 CLINT 到 SBI 再到抢占
裸机程序如果只会 ecall,仍然是「批处理」:一个程序跑完才轮到下一个。要让内核有「时间」的概念,必须有时钟中断。RISC-V 的定时器分两层:
- M 态:CLINT 的
mtime(只读,64 位,频率 10 MHz)和mtimecmp(每 hart 一个)。mtime >= mtimecmp时置位mip.MTIP,若mie.MTIE和mstatus.MIE都为 1,则进入 M 态 trap(cause 7)。 - S 态:
sip.STIP是 S 态定时器中断挂起位,sie.STIE是使能位。S 态不能直接写mtimecmp,只能通过 SBIset_timer请求 M 态代写;M 态写完后会清MTIP并置STIP。
在 QEMU virt 上,CLINT 的地址是:
| 寄存器 | 地址 | 说明 |
|---|---|---|
mtimecmp[hart0] | 0x0200_4000 | 8 字节,写绝对时间 |
mtimecmp[hartN] | 0x0200_4000 + 8*N | 每 hart 一个 |
mtime | 0x0200_BFF8 | 8 字节,只读,10 MHz |
但不要直接写 mtimecmp:你的内核运行在 S 态,写 M 态 MMIO 会被 PMP 拒绝。正确做法是 SBI TIME(EID 0x54494D45):先 rdtime 读当前时间,加上间隔,再 ecall。rdtime 在 S 态是否可读,取决于 mcounteren.TM。OpenSBI 通常会放行;如果没放行,rdtime 会触发 illegal instruction,此时只能用 SBI 的 TIME 扩展或 get_time 旧接口。
打开 S 态定时器中断
设定时器只是「什么时候响」,还要「让不让你听见」:
这四步的顺序很重要:先设 stvec,再开 sie.STIE,最后开 sstatus.SIE。如果先开全局中断再设 stvec,中间来一个中断就会跳到旧的/空的向量。这是所有中断驱动代码的通用规则:先准备好入口,再开门。
第三个完整程序:tick 计数与自动关机
把 P1 的 SBI 输出、P2 的 trap 入口、上面的定时器拼起来,就是 P3。它每 10 ms 收一次时钟中断,打印 tick,数到 5 次后关机。
#![allow(unused)]
fn main() {
// src/main.rs(P3):S 态定时器中断 demo
#![no_std]
#![no_main]
use core::arch::{asm, global_asm};
use core::fmt::{self, Write};
use core::panic::PanicInfo;
global_asm!(include_str!("entry.asm"));
global_asm!(include_str!("trap.asm"));
static mut TICKS: usize = 0;
extern "C" {
fn __s_trap_entry();
}
#[inline(always)]
fn sbi_call(eid: usize, fid: usize, a0: usize, a1: usize) -> (isize, usize) {
let (err, val): (isize, usize);
unsafe {
asm!("ecall", in("a7") eid, in("a6") fid,
inlateout("a0") a0 => err, inlateout("a1") a1 => val,
options(nostack));
}
(err, val)
}
struct Console;
impl Write for Console {
fn write_str(&mut self, s: &str) -> fmt::Result {
for b in s.bytes() { let _ = sbi_call(0x01, 0, b as usize, 0); }
Ok(())
}
}
pub fn print_fmt(args: fmt::Arguments) {
let mut c = Console;
c.write_fmt(args).unwrap();
}
#[macro_export]
macro_rules! print {
($($arg:tt)*) => { $crate::print_fmt(format_args!($($arg)*)) };
}
#[macro_export]
macro_rules! println {
() => { $crate::print!("\n") };
($($arg:tt)*) => { $crate::print!("{}\n", format_args!($($arg)*)) };
}
fn shutdown(failure: bool) -> ! {
let _ = sbi_call(0x5352_5354, 0, 0, failure as usize);
loop { core::hint::spin_loop(); }
}
fn set_next_timer() {
let now: u64;
unsafe { asm!("rdtime {}", out(reg) now) };
let _ = sbi_call(0x5449_4D45, 0, (now + 100_000) as usize, 0);
}
#[no_mangle]
pub extern "C" fn rust_main() -> ! {
unsafe {
// 内核栈顶存入 sscratch;trap 入口会与用户 sp 交换
asm!("csrw sscratch, sp");
asm!("csrw stvec, {}", in(reg) __s_trap_entry as usize);
asm!("csrs sie, {}", in(reg) 1usize << 5); // STIE
asm!("csrs sstatus, {}", in(reg) 1usize << 1); // SIE
}
set_next_timer();
println!("timer armed");
loop { core::hint::spin_loop(); }
}
#[no_mangle]
pub extern "C" fn s_trap_handler(tf: &mut TrapFrame) {
let scause: usize;
unsafe { asm!("csrr {}, scause", out(reg) scause) };
if scause >> 63 == 1 && (scause & 0xff) == 5 {
set_next_timer();
unsafe {
TICKS += 1;
let n = TICKS;
println!("tick {}", n);
if n >= 5 { shutdown(false); }
}
} else {
println!("unexpected trap: {:#x} sepc={:#x}", scause, tf.sepc);
shutdown(true);
}
}
#[repr(C)]
pub struct TrapFrame {
pub x: [usize; 32],
pub sepc: usize,
pub sstatus: usize,
}
#[panic_handler]
fn panic(_: &PanicInfo) -> ! { shutdown(true) }
}
预期输出(时间戳取决于 QEMU 速度):
timer armed
tick 1
tick 2
tick 3
tick 4
tick 5
这个程序只有 100 行,但它已经具备了一个抢占式内核的雏形:硬件定时器 → trap → 保存现场 → 内核处理 → 恢复现场 → 继续。下一章要做的,只是把「打印 tick」换成「切换任务」,把「关机」换成「调度」。
- 只开
sie.STIE没开sstatus.SIE:中断挂起但永远不进入 trap,表现为「定时器不响」。 - 忘了重新
set_timer:第一次中断后mtimecmp不再前进,只有一次 tick。 - 在 handler 里打印太多:UART 轮询很慢,10 ms 的 tick 可能被打印拖成 100 ms;教学 demo 可以,真实调度器不能这么干。
rdtime被mcounteren禁止:S 态执行rdtime触发 illegal instruction,而你的 handler 又用rdtime打印,形成递归。
1.18.9 QEMU、GDB 与 objdump 实验
实验一:确认入口与段布局
ELF=target/riscv64gc-unknown-none-elf/release/hello
llvm-readelf -h "$ELF" | grep -E 'Entry|Machine|Class'
llvm-readelf -S "$ELF" | grep -E '\.text|\.rodata|\.data|\.bss'
llvm-objdump -d --no-show-raw-insn "$ELF" | sed -n '1,30p'
Entry point address: 0x80200000
Machine: RISC-V
Class: ELF64
[ 1] .text PROGBITS 0000000080200000 ...
如果 Entry 不是 0x80200000,先查链接脚本的 ENTRY 和 .text.entry;如果 .text 的地址是 0,说明 -Tsrc/linker.ld 没生效,检查 .cargo/config.toml 是否在项目根目录。
实验二:QEMU + GDB 单步启动
# 终端 1:QEMU 冻结在第一条指令,等待 GDB
qemu-system-riscv64 -machine virt -nographic -bios default \
-kernel target/riscv64gc-unknown-none-elf/release/hello \
-s -S
# 终端 2:连接并检查
gdb-multiarch target/riscv64gc-unknown-none-elf/release/hello \
-ex 'set architecture riscv:rv64' \
-ex 'target remote :1234' \
-ex 'info registers pc sp' \
-ex 'x/6i $pc' \
-ex 'break rust_main' \
-ex 'continue'
1.18.10 本章出口检查与陷阱清单
8 条陷阱(按「现象 → 原因 → 修复」)
- 现象:QEMU 无输出,PC 停在
0x80000000。原因:用了-bios default但链接到0x80000000,镜像覆盖了 OpenSBI。修复:链接到0x8020_0000。 - 现象:QEMU 反复 reset。原因:入口地址处不是合法指令,触发异常后 OpenSBI 重启。修复:检查
ENTRY与.text.entry,用objdump看第一条指令。 - 现象:打印第一个字符后卡死。原因:UART 轮询
LSR.THRE时读错偏移,或没初始化LCR。修复:确认LSR=0x05、THR=0x00,先写LCR=0x03。 - 现象:
println!只输出一半。原因:panic里又触发中断,UART 被重入。修复:panic 路径关中断,或用自旋锁保护输出。 - 现象:
ecall后程序回到同一条ecall,死循环。原因:忘了sepc += 4。修复:在系统调用返回前加 4。 - 现象:时钟中断只来一次。原因:handler 里没有重新
set_timer。修复:每次 tick 重新设mtimecmp。 - 现象:定时器完全不响。原因:只开了
sie.STIE,没开sstatus.SIE;或stvec没设。修复:按「stvec → STIE → SIE」顺序初始化。 - 现象:S 态读
mtime触发 illegal instruction。原因:mcounteren.TM=0。修复:让 OpenSBI 放行,或改用 SBITIME。 - 现象:S 态写
0x0200_4000触发 store access fault。原因:CLINT 是 M 态设备,PMP 不允许 S 访问。修复:用 SBIset_timer,不要直接写。
母本对应
| 母本 | 章节/小节 | 本书落点 | 程度 |
|---|---|---|---|
| rCore 教程 | 第 1 章「应用程序执行环境与平台支持」「移除标准库依赖」「内核第一条指令」「为内核支持函数调用」「基于 SBI 服务完成输出和关机」 | 1.18.4、1.18.5、1.18.6 | 概念对照,代码独立重写 |
| rCore 教程 | 第 2 章「RISC-V 特权级架构」「实现应用程序」「实现批处理操作系统」「实现特权级的切换」 | 1.18.1–1.18.3、1.18.7 | 概念对照,trap frame 自设计 |
| rCore 教程 | 第 3 章「任务切换」「多道程序与协作式调度」「分时多任务」 | 1.18.8(时钟中断铺垫) | 只取定时器机制 |
| rCore 教程 | 附录 D「RISCV 汇编相关」「RISCV 硬件相关」 | 1.18.1、1.18.9 | 工具链与 CSR 参考 |
| RISC-V 特权级规范 | Volume II: Privileged Architecture, Ch. 3–4(machine/supervisor) | 1.18.1–1.18.3 | 以规范为准 |
| RISC-V 非特权规范 | Volume I: Unprivileged ISA, Ch. 2(RV64I) | 1.18.1 | 指令编码 |
| SBI 规范 | v1.0/v2.0, Ch. 3–5、10(调用约定、legacy、SRST) | 1.18.3 | 以规范为准 |
| QEMU 文档 | qemu-system-riscv64 -machine virt 的内存地图 | 1.18.5 | 地址以 QEMU 源码 hw/riscv/virt.c 为准 |
下一站:1.19 把这套裸机骨架扩展成一个「像内核的内核」:任务、地址空间、文件系统。你会看到 rCore 的 chapter1–8 主线如何一步步把「能打印」变成「能跑多个程序、能隔离内存、能打开文件」,以及 Rust 的所有权/借用检查器在哪些地方帮你、在哪些地方逼你写
unsafe。