Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 另给别名:

别名编号用途谁保存
zerox0恒 0—
rax1返回地址调用者/被调用者按需
spx2栈指针被调用者
a0–a7x10–x17参数/返回值调用者
t0–t6x5–x7, x28–x31临时调用者
s0–s11x8–x9, x18–x27被调用者保存被调用者

CSR(control and status register)用 csrrw/csrrs/csrrc 读写:

CSR作用
mstatus全局中断使能、特权级、MPRV 等
mepctrap 时的返回 PC
mtvectrap 入口地址/模式
mcausetrap 原因(中断/异常编号)
mtval附加信息(坏地址/指令)
satp页表根(S 模式)
sstatus/sepc/stvec/scause/stvalS 模式对应物
# 读 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 的硬件动作:

  1. 把当前 PC 写入 sepc(或 mepc);
  2. 把原因写入 scause;
  3. 跳到 stvec/mtvec;
  4. 关中断、切特权级;
  5. 软件在入口保存寄存器、处理、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:

功能EIDFID参数返回值
控制台输出(legacy)0x01—a0=字符无
关机(legacy)0x07—无不返回
发 IPI(legacy)0x08—a0=hart mask—
设置定时器(TIME)0x54494D450a0=绝对时间a0=error
发 IPI(IPI)0x7350490a0=hart mask, a1=maska0=error
系统复位(SRST)0x535253540a0=type(0 关机), a1=reason不返回
调试控制台写(DBCN)0x4442434E2a0=字节数, 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 调用约定速查

寄存器输入输出
a7EID不变
a6FID不变
a0–a5参数 0–5a0=错误码,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:

这段汇编有两个容易忽略的细节:

  1. la 是伪指令,展开成 auipc + addi,得到的是链接地址(0x8020_xxxx),不是当前 PC 的物理地址。因为 OpenSBI 已经把 MMU 关掉(satp=0),链接地址就是物理地址。
  2. 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_10004 KiBSiFive test finisher写 0x5555 让 QEMU 成功退出
0x0200_000064 KiBCLINTmtime/mtimecmp
0x0c00_000064 MiBPLIC外部中断
0x1000_0000256 BUART0(16550A)THR 写字符
0x1000_1000256 BUART1 / virtio-mmio 起点第 1.19 节块设备
0x8000_0000128 MiBDRAM 起始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 64x86-64ARM64
用户 → 内核ecallsyscallsvc #0
内核 → 固件ecall + SBIUEFI runtimesmc/hvc(PSCI)
返回sret/mretsysret/iretqeret
特权级M/S/Uring 0–3EL0–EL3

1.18.6 不用 SBI,直接写 UART:MMIO 与 16550A

SBI 的输出很方便,但它把「设备驱动」藏在了 M 态。理解裸机必须至少手写一次 UART,否则你不知道 console_putchar 背后发生了什么。QEMU virt 的 UART0 是 16550A 兼容设备,基址 0x1000_0000,寄存器间隔 1 字节:

偏移读写本章用到的位
0x00RBR 接收THR 发送写字节即发送
0x01IERIERbit0 接收中断,bit1 发送中断
0x02IIRFCRFCR bit0 开 FIFO,bit1 清接收,bit2 清发送
0x03LCRLCRbit0–1 字长(3=8 位),bit7 DLAB
0x04MCRMCR调制解调器控制,通常不用
0x05LSR—bit5 THRE 发送保持寄存器空,bit6 TEMT 发送器空
0x06MSR—调制解调器状态
0x07SCRSCR暂存

最小初始化: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。

UART 的四个常见错误
  1. 忘了 \r:串口终端通常需要 \r\n 才能回到行首;只发 \n 会出现阶梯状输出。
  2. 初始化顺序反了:先写 THR 再设 LCR,第一个字符可能用错误的帧格式发出去。
  3. 在 panic 里用 println! 又触发 UART 中断:如果 IER 没关,panic 输出可能被中断打断,最后看到的是半行。裸机 panic 路径要把中断全关掉。

1.18.7 trap 入口:保存 32 个寄存器

现在进入本章最硬的部分:写一个能保存/恢复完整现场的 trap 入口。没有它,时钟中断一来,被中断的代码就会看到寄存器被 handler 改得面目全非。

TrapFrame 的布局

我们用一块连续的内存保存现场。RV64 下 32 个通用寄存器 + sepc + sstatus,共 34 个 8 字节槽:

偏移内容说明
0x0恒为 0,占位,方便按索引访问
8x1/ra返回地址
16x2/sp用户栈指针(入口时从 sscratch 取)
24x3/gp全局指针
32x4/tp线程指针
40–56x5–x7t0–t2
64–72x8–x9s0–s1
80–136x10–x17a0–a7
144–216x18–x27s2–s11
224–248x28–x31t3–t6
256sepc返回地址
264sstatus返回时的状态

这个布局不是随便定的:它让汇编可以用 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,见下文。

trap 现场的五个坑
  1. 保存了 x0 又恢复它:x0 是硬连线 0,ld x0, 0(sp) 不报错但也没用;更糟的是有人用 x0 当临时寄存器,结果值永远是 0。
  2. 忘记保存 t0–t6:Rust handler 会自由使用临时寄存器;不保存就会破坏被中断代码的中间值。
  3. sepc += 4 用在中断上:时钟中断返回后跳过了一条无辜的指令,程序行为会变得随机。
  4. sstatus 恢复顺序错:先 sret 再恢复 sstatus 已经来不及;必须先把 sstatus 写回,再 sret。
  5. 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 入口。

例 1.18.1 · 为什么 P2 要设 PMP?

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,只能通过 SBI set_timer 请求 M 态代写;M 态写完后会清 MTIP 并置 STIP。

在 QEMU virt 上,CLINT 的地址是:

寄存器地址说明
mtimecmp[hart0]0x0200_40008 字节,写绝对时间
mtimecmp[hartN]0x0200_4000 + 8*N每 hart 一个
mtime0x0200_BFF88 字节,只读,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」换成「切换任务」,把「关机」换成「调度」。

时钟中断的六个坑
  1. 只开 sie.STIE 没开 sstatus.SIE:中断挂起但永远不进入 trap,表现为「定时器不响」。
  2. 忘了重新 set_timer:第一次中断后 mtimecmp 不再前进,只有一次 tick。
  3. 在 handler 里打印太多:UART 轮询很慢,10 ms 的 tick 可能被打印拖成 100 ms;教学 demo 可以,真实调度器不能这么干。
  4. 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 条陷阱(按「现象 → 原因 → 修复」)

本章陷阱总表
  1. 现象:QEMU 无输出,PC 停在 0x80000000。原因:用了 -bios default 但链接到 0x80000000,镜像覆盖了 OpenSBI。修复:链接到 0x8020_0000。
  2. 现象:QEMU 反复 reset。原因:入口地址处不是合法指令,触发异常后 OpenSBI 重启。修复:检查 ENTRY 与 .text.entry,用 objdump 看第一条指令。
  3. 现象:打印第一个字符后卡死。原因:UART 轮询 LSR.THRE 时读错偏移,或没初始化 LCR。修复:确认 LSR=0x05、THR=0x00,先写 LCR=0x03。
  4. 现象:println! 只输出一半。原因:panic 里又触发中断,UART 被重入。修复:panic 路径关中断,或用自旋锁保护输出。
  5. 现象:ecall 后程序回到同一条 ecall,死循环。原因:忘了 sepc += 4。修复:在系统调用返回前加 4。
  6. 现象:时钟中断只来一次。原因:handler 里没有重新 set_timer。修复:每次 tick 重新设 mtimecmp。
  7. 现象:定时器完全不响。原因:只开了 sie.STIE,没开 sstatus.SIE;或 stvec 没设。修复:按「stvec → STIE → SIE」顺序初始化。
  8. 现象:S 态读 mtime 触发 illegal instruction。原因:mcounteren.TM=0。修复:让 OpenSBI 放行,或改用 SBI TIME。
  9. 现象:S 态写 0x0200_4000 触发 store access fault。原因:CLINT 是 M 态设备,PMP 不允许 S 访问。修复:用 SBI set_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。