rCore 第二章学习笔记:特权级与系统调用

2025/2/271911 字

最近终于完成了 rCore 第二章 的学习,这一章的内容相较于第一章更加深入,主要围绕特权级的分级以及系统调用的实现展开。以下是我对这一章内容的总结和梳理。

一、特权级分级

在实现了第一章在裸机上运行程序的基础上,第二章引入了特权级的分级机制。RISC-V 定义了四种特权级:用户/应用模式 (U, User/Application)、监督模式 (S, Supervisor)、虚拟监督模式 (H, Hypervisor) 和机器模式 (M, Machine)。由于我们的实验不涉及虚拟机的实现,因此用不上 H 级别。我们的操作系统内核运行在 S 模式,而应用程序运行在 U 模式。

特权级的架构图如下所示:

  • 应用程序运行在 U 模式。
  • 运行在 M 模式上的软件被称为监督模式执行环境 (SEE, Supervisor Execution Environment)。
  • 我们实现的操作系统内核属于 S 模式。

二、特权级模式的切换

特权级模式的切换是通过异常(trap)实现的。RISC-V 提供了两种有意为之的异常指令:ebreak(断点)和 ecall(执行环境调用)。这两种指令被称为陷入(trap)类指令,因为它们是通过在上层软件中执行特定指令触发的。

RISC-V 的硬件层面为特权级提供了相应的支持,主要包括以下控制状态寄存器 (CSR, Control and Status Register):

  • sstatus:SPP 字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息。
  • sepc:当 Trap 是一个异常时,记录 Trap 发生之前执行的最后一条指令的地址。
  • scause:描述 Trap 的原因。
  • stval:给出 Trap 附加信息。
  • stvec:控制 Trap 处理代码的入口地址。

通过这些 CSR,我们可以实现用户程序在系统调用时特权级的切换。具体来说,RISC-V 只负责特权级状态的记录和代码跳转,而具体的异常处理需要我们在操作系统中实现。

三、异常处理流程

1. 设置 Trap 处理函数

在操作系统初始化时,我们需要通过修改 stvec 寄存器的值将 Trap 处理函数定位到我们自己实现的处理函数上(rCore 原文使用的是 __alltraps)。

2. 用户程序触发异常

用户程序需要在通用寄存器 x0-x31 中设置系统调用对应的参数,然后使用 ecall 指令发出异常。此时,CPU 会自动完成以下操作:

  • 修改 sstatus 的 SPP 字段为 CPU 当前的特权级(U/S)。
  • 修改 sepc 为 Trap 处理完成后默认会执行的下一条指令的地址。
  • 修改 scause 和 stval 分别为这次 Trap 的原因及相关的附加信息。
  • 跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S,然后从 Trap 处理入口地址处开始执行。

3. 保存上下文

在 Trap 触发的一瞬间,CPU 会切换到 S 特权级并跳转到 stvec 所指示的位置。在正式进入 S 特权级的 Trap 处理之前,我们需要保存原控制流的寄存器状态,这一般通过内核栈来保存。注意,我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。

具体实现请看 os/src/trap/trap.S:

assembly
.macro SAVE_GP n
    sd x\n, \n*8(sp)
.endm

.align 2
__alltraps:
    csrrw sp, sscratch, sp
    # now sp->kernel stack, sscratch->user stack
    # allocate a TrapContext on kernel stack
    addi sp, sp, -34*8
    # save general-purpose registers
    sd x1, 1*8(sp)
    # skip sp(x2), we will save it later
    sd x3, 3*8(sp)
    # skip tp(x4), application does not use it
    # save x5~x31
    .set n, 5
    .rept 27
        SAVE_GP %n
        .set n, n+1
    .endr
    # we can use t0/t1/t2 freely, because they were saved on kernel stack
    csrr t0, sstatus
    csrr t1, sepc
    sd t0, 32*8(sp)
    sd t1, 33*8(sp)
    # read user stack from sscratch and save it on the kernel stack
    csrr t2, sscratch
    sd t2, 2*8(sp)
    # set input argument of trap_handler(cx: &mut TrapContext)
    mv a0, sp
    call trap_handler

可以看到,在记录了用户程序上下文后,才真正进入了内核态,使用自定义的 trap_handler 函数对异常进行处理。

4. 异常处理函数

os/src/trap/mod.rs 中的 trap_handler 函数实现了对异常的具体处理:

rust
#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
        }
        Trap::Exception(Exception::StoreFault) |
        Trap::Exception(Exception::StorePageFault) => {
            println!("[kernel] PageFault in application, kernel killed it.");
            run_next_app();
        }
        Trap::Exception(Exception::IllegalInstruction) => {
            println!("[kernel] IllegalInstruction in application, kernel killed it.");
            run_next_app();
        }
        _ => {
            panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval);
        }
    }
    cx
}

当用户程序使用 ecall 触发异常时,对应到 Exception::UserEnvCall。我们先将返回地址设置到 ecall 的下一个指令,然后将用户设置的通用寄存器的值传入系统调用函数中。

5. 系统调用实现

os/src/syscall/mod.rs 中的 syscall 函数实现了对系统调用的具体处理:

rust
pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
    match syscall_id {
        SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
        SYSCALL_EXIT => sys_exit(args[0] as i32),
        _ => panic!("Unsupported syscall_id: {}", syscall_id),
    }
}

我们可以通过用户向特定寄存器中传入的值判断用户需要什么系统调用,并且实现对应的操作。

6. 系统调用返回

系统调用完成后,通过调用 sret 指令切换回用户模式,返回到用户程序继续运行。

四、用户程序的加载与运行

用户程序的实现主要是将使用系统调用的过程编译成二进制文件。目前的关键点是链接时使用的地址是固定的,都是 0x80400000,因为目前我们的操作系统还只能将应用程序加载到固定位置。

用户程序的加载通过以下操作实现:

rust
global_asm!(include_str!("link_app.S"));

os/src/link_app.S 中定义了应用程序的加载地址和数据:

assembly
.align 3
.section .data
.global _num_app
_num_app:
    .quad 5
    .quad app_0_start
    .quad app_1_start
    .quad app_2_start
    .quad app_3_start
    .quad app_4_start
    .quad app_4_end

.section .data
.global app_0_start
.global app_0_end
app_0_start:
    .incbin "../user/target/riscv64gc-unknown-none-elf/release/00hello_world.bin"
app_0_end:

.section .data
.global app_1_start
.global app_1_end
app_1_start:
    .incbin "../user/target/riscv64gc-unknown-none-elf/release/01store_fault.bin"
app_1_end:

.section .data
.global app_2_start
.global app_2_end
app_2_start:
    .incbin "../user/target/riscv64gc-unknown-none-elf/release/02power.bin"
app_2_end:

.section .data
.global app_3_start
.global app_3_end
app_3_start:
    .incbin "../user/target/riscv64gc-unknown-none-elf/release/03priv_inst.bin"
app_3_end:

.section .data
.global app_4_start
.global app_4_end
app_4_start:
    .incbin "../user/target/riscv64gc-unknown-none-elf/release/04priv_csr.bin"
app_4_end:

通过读取 _num_app,我们可以知道应用程序的相关数据,然后将应用程序的代码加载到内存的指定位置。

五、总结

通过第二章的学习,我了解了 rCore 操作系统中特权级分级的实现原理和系统调用的具体流程。从用户程序触发异常到操作系统内核处理异常,再到系统调用的实现和返回用户模式,整个过程涉及了 RISC-V 架构的硬件支持和操作系统的软件实现。这一章的内容让我对操作系统的核心机制有了更深入的理解,也为后续的学习打下了坚实的基础。

💬 评论区

写评论

暂无评论,来发表第一条吧。

© 2026 Corn 的自留地