本文不会将原本rCore文档的内容重复太多, 主要是补充学习过程中遇到的知识点, 因此还需结合原文使用, 原文在后面的链接中
之前的章节仅仅是一个裸机运行的应用程序, 这一章节通过特权级的引入实现了S态下的OS和U态下的app, app通过系统调用访问OS, OS通过SBI提供的服务完成系统调用, 不过这一章节没有实现进程或线程切换, 而是将程序一个接一个地运行直到结束。
完整版官方文档: https://rcore-os.cn/rCore-Tutorial-Book-v3/chapter2/index.html
精简版文档: https://learningos.cn/rCore-Tutorial-Guide-2023A/chapter2/index.html
1 整体流程
本章的目的是实现批处理系统,文档中称为邓氏鱼OS, 其内容包括:
- 编写
Rust应用程序, 并使用链接脚本调整内存布局 - 为
OS实现系统调用 - 将应用程序从
efl转化为binary, 和OS的代码链接到一起 - 实现批处理的任务调度
- 引入用户栈和内核栈
2 特权级
2.1 特权级的概念
下面这摘自官方文档张图展示了riscv中不同的特权级:

RISC-V 定义了以下四个特权级别:
用户级别(User-Level or U-Mode):
就是图中的App所在的级别, 用户级别是最低的特权级别,普通的应用程序在这个级别上运行。在这个级别上,程序不能直接访问硬件资源,如控制I/O和管理内存等。用户级别的代码需要通过系统调用(syscalls)与更高特权级别的软件交互来请求服务。而syscalls就是应用程序二进制接口, 图中的ABI。程序在用户级别也称为用户态监督者级别(Supervisor-Level or S-Mode):
监督者级别是操作系统内核通常运行的特权级别。它允许直接控制和管理硬件资源,包括内存管理单元(MMU)、中断处理等。大多数操作系统的内核,如Linux,会在S-Mode下运行。程序在用户级别也称为内核态。操作系统在态下其实也需要想=向更低一级的机器模式提出函数请求,这就是SBI所做的事情机器级别(Machine-Level or M-Mode):
机器级别是最高的特权级别,提供对RISC-V硬件的完全控制。它用于引导系统、处理最底层的中断和异常,以及配置系统的安全和保护设置。固件和监控程序,如我们使用的RustSBI(通常在M-Mode下运行。超级用户级别(Hypervisor-Level or H-Mode):
超级用户级别是为虚拟化环境设计的特权级别,在RISC-V体系结构中是一个可选的特权级别。它允许运行一个超级监控器(hypervisor),在单个物理硬件平台上虚拟化和管理多个独立的操作系统实例。rCore中不涉及这个级别
2.2 特权级的切换
官网中的这张图清晰地说明了应用程序如何进行特权级切换:

这张图其实还揭示了另一个细节: 不同特权级的内存空间通常是不一样的, 这就和我们常说的用户栈和内核栈联系起来了
2.3 特权级切换指令和寄存器
2.3.1 什么时候会发生特权级切换?
在RISC-V中,特权级切换通常在以下场景中发生:
系统调用(System Calls):当用户程序需要操作系统提供的服务时,如文件操作、内存分配等,它会执行一个
ecall指令来触发一个异常,导致处理器从用户模式(U-Mode)切换到监督者模式(S-Mode)或机器模式(M-Mode),这样操作系统可以安全地提供这些服务。中断(Interrupts):当外部设备需要处理器的注意时,它会发送一个中断信号。处理器响应中断信号也会导致特权级切换,通常是从较低的特权级别切换到机器模式(
M-Mode),以便中断服务程序可以运行并处理中断。异常(Exceptions):当程序执行非法操作(如除以零、访问无权限的内存区域)时,或者出现硬件错误,就会发生异常。这将导致从当前特权级别切换到更高的特权级别,以便异常处理程序可以被执行来处理这些问题。
特权级返回(Return from Trap):当中断或异常处理完成后,通过执行
mret、sret或uret指令返回到发生中断或异常之前的特权级别。如果异常无法被正常处理, 则可能退出不会返回用户态, 而是在更高的特权级中尽显处理(关机蓝屏等就是这些更改特权级处理异常的方式)
通过上述情形可以我们可以看出, 异常控制流(区别与一般的函数控制)和特权级切换有下面的好处:
- 保护系统和硬件不收错误的程序的损坏
- 提供一层抽象, 便于开发
2.3.2 特权级切换指令和寄存器
特权级切换指令
指令 描述 ecall从用户态或监督者态触发一个环境调用异常,请求操作系统服务 ebreak触发一个断点异常,用于调试 mret从机器模式退出中断或异常处理程序并返回到之前的特权级别 sret从监督者模式退出中断或异常处理程序并返回到之前的特权级别 uret从用户模式退出中断或异常处理程序并返回到之前的特权级别 特权级切换相关寄存器
寄存器 描述 mstatus保存机器模式的全局状态,包括全局中断使能位和特权级切换的状态 ustatusmstatus的子集,用于保存用户模式的状态信息mtvec保存中断和异常处理例程的基地址(机器模式) utvec保存用户模式下中断和异常处理例程的基地址 mepc保存发生异常时的程序计数器值(机器模式) uepc保存用户模式下发生异常时的程序计数器值 mcause保存最后一次异常或中断的原因(机器模式) ucause保存用户模式下最后一次异常或中断的原因 sstatusmstatus的子集,用于保存监督者模式的状态信息scause保存监督者模式下最后一次异常或中断的原因 sepc保存监督者模式下发生异常时的程序计数器值 stval给出 Trap附加信息stvec保存监督者模式下中断和异常处理例程的基地址
3 特权级切换
3.1 系统调用
riscv中的系统调用很简单, 相关的代码我们之前也已经见到过:
- 把系统调用的参数按照顺序放在
a0~a6寄存器后 - 把系统调用号放在
a7寄存器 - 调用
ecall触发系统调用 - 在
a0处获得系统调用的返回值
系统调用可以使用Rust内联汇编实现:
1 | use core::arch::asm; |
如果上述汇编代码看不懂, 可以看我的上一篇rCode的笔记中关于内联汇编的介绍
3.2 特权级切换
系统调用会发生特权级切换, 特权级切换由于执行环境发生了变化, 要求我们在恢复原来的特权级时(例如从内核态返回用户态), 恢复执行环境的上下文。
发生特权级切换(执行ecall), 此处以陷入S态为例, 时硬件会帮我们做如下工作:
sstatus的SPP字段会被修改为CPU当前的特权级sepc会被修改为Trap处理完成后默认会执行的下一条指令的地址。scause/stval分别会被修改成这次Trap的原因以及相关的附加信息。CPU会跳转到stvec所设置的Trap处理入口地址,并将当前特权级设置为S,然后从Trap处理入口地址处开始执行。
上述是硬件自动完成的, 如果有其他的寄存器由于陷入内核态后会被使用, 需要提前被保存, 通常会手动保存在栈里, 这些工作可能是stvec一开始就执行的工作
当使用sret返回用户态时, 系统会帮我们做下面的工作:
CPU会将当前的特权级按照sstatus的SPP字段设置为U或者S;CPU会跳转到sepc寄存器指向的那条指令,然后继续执行。
这里特别说明一下sstatus 的 SPP字段如何设置:
调用
ecall时:- 当从用户模式(U模式)执行
ecall并陷入到监督者模式(S模式)时,sstatus寄存器的SPP字段会被设置为 0,表示异常发生前处于用户模式。 - 如果是从其他特权级别执行
ecall(例如,在 RISC-V 中还有机器模式 M),SPP字段会被设置为对应于那个特权级别的值。
- 当从用户模式(U模式)执行
调用
sret时:sret指令用于从监督者模式(S模式)返回到之前的特权级别。在执行sret指令时,SPP字段的值会被用来决定返回到哪个特权级别(U模式或S模式),并且执行sret之后,SPP字段会被清零。- 如果
SPP是 0,则在执行sret后 CPU 返回到用户模式(U模式)。 - 如果
SPP是 1,则在执行sret后 CPU 返回到监督者模式(S模式)。
4 用户代码
4.1 用户代码概览
用户代码很简单, 项目源码中有4个用户程序:
ch2b_bad_address.rs1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extern crate user_lib;
/// 由于 rustsbi 的问题,该程序无法正确退出
/// > rustsbi 0.2.0-alpha.1 已经修复,可以正常退出
pub fn main() -> isize {
unsafe {
(0x0 as *mut u8).write_volatile(0);
}
panic!("FAIL: T.T\n");
}该程序向0地址处写入, 预期会触发
page fault并退出ch2b_bad_instructions.rs1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern crate user_lib;
/// 由于 rustsbi 的问题,该程序无法正确退出
/// > rustsbi 0.2.0-alpha.1 已经修复,可以正常退出
pub fn main() -> ! {
unsafe {
core::arch::asm!("sret");
}
panic!("FAIL: T.T\n");
}该程序在
U模式下使用sret, 应当是非法指令ch2b_bad_register.rs1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
extern crate user_lib;
/// 由于 rustsbi 的问题,该程序无法正确退出
/// > rustsbi 0.2.0-alpha.1 已经修复,可以正常退出
pub fn main() -> ! {
let mut sstatus: usize;
unsafe {
core::arch::asm!("csrr {}, sstatus", out(reg) sstatus);
}
panic!("(-_-) I get sstatus:{:x}\nFAIL: T.T\n", sstatus);
}该程序在
U模式下访问csr寄存器sstatus, 应当也是非法指令ch2b_hello_world.rs1
2
3
4
5
6
7
8
9
10
11
12
13
14
extern crate user_lib;
/// 正确输出:
/// Hello world from user mode program!
fn main() -> i32 {
println!("Hello, world from user mode program!");
0
}该程序是唯一正常输出的程序
4.2 用户库user_lib
上述的代码都使用了user_lib库, 也就是user/src下的rust项目, 其作用就是封装了了多个系统调用函数, 每个系统调用都使用对应的系统调用号、参数或地址调用3 系统调用中的syscall函数, 例如:
1 | ... |
5 内核代码
5.1 内核代码和用户代码的链接
这章的内核系统是和用户代码链接到一起的, 也就是说不存在从文件系统加载elf文件这样的步骤, 毕竟在学习OS的起步阶段, 文件系统离我们还挺遥远的, 具体而言, os/src/main.rs中的这句代码实现了用户代码的链接:
1 | global_asm!(include_str!("link_app.S")); |
link_app.S是构建脚本build.rs生成的, 其将用户仓库的编译文件夹bin目录下的二进制文件整整合到一起, 名创建各个app的符号
5.2 内核的调度
5.2.1 调度器的数据结构
内核调度是按照顺序一个接一个地调用用户应用, 其主要数据结构为:
1 | struct AppManager { |
文档中还介绍了Rust相关的语法知识, 后面的章节会整理出来
5.2.2 加载程序
最重要的调度函数是load_app, 其功能就是加载程序, 实际上并不是从文件系统加载, 而是从内核的某一个区段进行复制:
1 | unsafe fn load_app(&self, app_id: usize) { |
可以从代码看出, 所有的app都是在APP_BASE_ADDRESS地址处运行的, 每个app运行前都需要将其从其二进制代码的地址处复制到APP_BASE_ADDRESS地址处, 这也是load_app的核心工作
5.3 Trap上下文切换
5.3.1 用户栈和内核栈
正如之前的3 特权级切换中说明, 特权级切换时需要用栈来保存上下文信息, 因此需要定义内核栈和用户栈:
1 | // os/src/batch.rs |
5.3.2 上下文信息
本来不打算分析代码, 但这里的代码贯穿了整个OS, 所以特别介绍一下:
1 | use riscv::register::sstatus::{self, Sstatus, SPP}; |
这段 Rust 代码定义了TrapContext 的结构体,它用于保存程序的上下文,即在发生异常或中断时需要保存的状态信息,以便之后能够恢复执行, 包括通用寄存器、特殊控制状态寄存器(如 sstatus 和 sepc)等。同时实现了每个app的上下文初始化方法
5.3.3 app调度
上面的上下文在什么时候回被访问呢? 首先看看调度app的函数:
1 | pub fn run_next_app() -> ! { |
可以看到, load_app已经通过load_app将指定的内容加载到了内存的固定位置, 然后运行这个程序调用的是__restore, 这是什么? 这是trap.S中用汇编代码写的上下文切换时的保存和回复寄存器的函数, 接下来将仔细解读
5.3.4 Trap恢复上下文
首先, 还是贴出trap.S:
1 | .altmacro |
该 trap.S用于处理中断和异常。代码中定义了两个全局入口点:__alltraps 用于在中断或异常发生时保存上下文,__restore 用于恢复上下文并返回到用户态或继续执行应用程序。
__alltraps入口点csrrw sp, sscratch, sp:交换sscratch和sp的值。sscratch通常用来暂存用户栈指针,在发生异常时切换到内核栈。addi sp, sp, -34*8:在内核栈上分配TrapContext结构体所需的空间。- 保存通用寄存器到内核栈上。跳过
sp(x2)和tp(x4),因为sp会在后面单独保存,而tp(线程指针)可能不被应用使用。 - 使用
csrr指令读取sstatus和sepc寄存器的值,并保存到栈上。 - 从
sscratch寄存器读取用户栈指针,保存到内核栈上。 - 将栈指针
sp的值移到a0寄存器,作为trap_handler函数的参数(cx: &mut TrapContext)。 - 调用
trap_handler函数处理异常。
__restore入口点mv sp, a0:恢复sp寄存器的值,a0中包含了指向TrapContext的指针。- 从内核栈上加载
sstatus、sepc和用户栈指针到临时寄存器t0、t1和t2。 - 使用
csrw指令恢复sstatus、sepc和sscratch寄存器的值。 - 从内核栈上恢复其他通用寄存器的值(除了
sp和tp)。 addi sp, sp, 34*8:释放在内核栈上分配的TrapContext空间。- 再次交换
sscratch和sp的值,恢复用户栈指针到sp。 - 执行
sret指令返回到用户态或应用程序。
另一个值得注意的点是, __restore 在两种情况下被使用,它既是异常处理完毕后恢复应用程序状态的入口点,也是应用程序第一次开始执行时的入口点。在应用程序第一次开始执行时,__restore 这一步并不是在 “恢复” 任何先前的状态,因为此时还没有任何状态可以恢复。相反,它是在初始化应用程序的执行环境, 具体而言需要再栈中压入构造的Trap Context。在这种情况下,栈上加载的内容(如 sstatus、sepc 和 sscratch)是由操作系统预先设定好的,而不是由之前的应用程序执行状态保存的。这些值会设置为允许应用程序在用户模式下执行的正确状态,并确保了程序计数器(sepc)指向应用程序的入口点。
5.4 Trap Handler
还是先贴出代码
1 | // os/src/trap/mod.rs |
这里的trap handler根据scause分类处理, 目前实现了:
- 系统调用陷入
S态时执行系统调用 - 非法指令和页错误直接运行下一个程序
- 其余情况直接
panic
分发的系统调用目前实现还比较简单, 因为此时还没有页表, 我们的地址都是物理地址, 因此不需要地址转换, 所以没啥好说的, 看项目代码就是了
5.5 流程图
下面是我整理的一个示意图, 展示了特权级切换的流程:

红色表示S态的函数, 蓝色表示U态的函数
6 补充知识
6.1 Rust补充知识
6.1.1 RefCell
在项目代码中, 我们封装了RefCell形成了UPSafeCell, 那么RefCell是什么?
官方的描述是: RefCell<T> 提供了内部可变性。这意味着即使在 RefCell<T> 的引用是不可变的情况下,也可以改变它所包含的值。这违反了 Rust 的借用规则——即通常情况下,不能同时拥有可变和不可变引用,以及不可变引用不能用来改变值。
简单来说,就是Rust的编译器检查太严格了, 当我们编写底层代码时, 编译时想绕过不可变借用的检查就可以使用RefCell。尤其是这样一个场景: 当需要在一个不可变的引用上修改数据时。
不过绕过编译器检查还有运行期检查, RefCell<T> 使用运行时检查来确保借用规则,这是与编译时检查相对的(如通过 & 和 &mut 引用实现的)。从 RefCell<T> 中借用值时,如果违反了借用规则(例如,尝试进行两个可变借用或同时进行一个可变借用和任意数量的不可变借用),它会导致程序在运行时 panic。
下面是 RefCell<T> 的一个简单例子:
1 | use std::cell::RefCell; |
6.1.2 bitflags
我们看到项目代码中使用bitflags!宏来创建各种掩码并进行掩码操作,官方地说就是创建一个或多个位标志的集合。这些位标志通常用于表示一组开关或状态,每个开关或状态可以独立开启或关闭,通常用于配置选项或权限设置等场景。`
bitflags是社区包,需要在Cargo.toml文件中添加bitflags crate作为依赖。
1 | [dependencies] |
下面是一个标志位使用的案例:
1 | // 导入`bitflags`宏 |
在这个例子中:
Flags是一个包含四个位标志的新类型:FLAG_A、FLAG_B、FLAG_C和FLAG_D。- 每个标志都赋予了不同的位模式,使得它们可以独立设置和清除。
Flags::empty()创建了一个没有任何标志设置的Flags实例。insert方法用来设置特定的标志。contains方法用来检查特定的标志是否已经设置。remove方法用来清除特定的标志。toggle方法用来切换特定标志的状态。
6.1.3 lazy_static
lazy_static主要用于这样的需求: 想要创建一个全局变量, 但其初始化的值在编写代码时还不知道, 需要稍后初始化。在Rust中, 如果创建全局变量后再初始化会很繁琐, lazy_static简化了以上需求的操作难度
使用 lazy_static 创建的静态变量是线程安全的,并且保证只初始化一次。初始化发生在变量首次被访问的时候,并且如果初始化过程中发生了 panic,后续尝试访问该变量将会导致 panic。
下面是一个 lazy_static 的简单例子:
1 | [dependencies] |
1 |
|
在这个例子中,MY_MAP 是一个 HashMap,它在首次被 main 函数中的 println! 宏访问时被创建和初始化。由于 lazy_static 保证了线程安全和只初始化一次,MY_MAP 可以在程序的任何地方安全地使用,就像其他静态变量一样。
6.1.4 drain
drain是String类型的方法,用来移除并遍历字符串的一部分内容。drain 方法会在原地修改 String,并返回一个迭代器,该迭代器提供被移除部分的字符。
使用 drain 方法时,需要指定一个范围来表明想要从字符串中移除哪些字符。范围是采用字节索引而非字符索引,这意味着必须确保范围的边界落在有效的UTF-8字符边界上,否则程序会在运行时panic
以下是一个使用 drain 方法的例子:
1 | fn main() { |
6.1.5 项目代码中使用过的宏
下面是我整理的项目代码中使用的rust宏的含义, 这些宏大概了解其作用就可以, 在需要用时知道查什么关键字即可:
以下是对所提供的 Rust 属性和宏的解释,整理成 Markdown 表格的形式:
| 宏/属性 | 解释 |
|---|---|
#[repr(align(4096))] |
设置结构体或枚举的内存对齐方式为 4096 字节。 |
#![feature(panic_info_message)] |
允许使用实验性的 panic_info_message 功能,此功能允许访问 panic 信息中的消息内容。 |
#[macro_use] |
允许在当前作用域中使用外部 crate 中定义的宏。 |
use core::arch::global_asm; |
引入 global_asm! 宏,允许在 Rust 代码中嵌入全局汇编指令。 |
#[path = "boards/qemu.rs"] mod board; |
指定模块文件的路径,这里是将模块文件定位到 boards/qemu.rs。 |
#[no_mangle] |
禁用名称修饰,确保编译器生成的函数名称与在 Rust 中声明的名称相同。 |
#[inline(always)] |
告诉编译器总是内联一个函数,无论编译器优化策略如何。 |
#[linkage = "weak"] |
指定符号的链接强度为弱链接,允许在多个对象文件中定义相同的全局符号而不会导致链接错误,链接器将选择其中一个定义。 |
#[link_section = ".text.entry"] |
指定函数或静态变量应该放置在特定的链接段中,在这个例子中是一个名为 .text.entry 的段。 |
#[repr(C)] |
设置结构体或枚举的内存布局为 C 语言风格,这在与 C 代码交互时非常有用,因为它能保证字段在内存中的布局与 C 结构体相同。 |
#![feature(linkage)] |
允许使用实验性的 linkage 属性,这个属性用于控制符号的链接方式。 |
#![feature(alloc_error_handler)] |
允许自定义全局内存分配错误处理器。 |
#![no_std] |
表明当前的程序或库不会链接到 Rust 的标准库 std,通常用于裸机或嵌入式编程中,其中资源受限,只能依赖核心库 core。 |
#![no_main] |
禁用 Rust 默认的入口点,这通常用在裸机或操作系统开发中,因为在这些情况下开发者需要自定义入口点。 |
6.1.6 build.rs构建脚本
Rust 中,build.rs 是一个特殊的脚本,被称为”构建脚本”(build script)。它在项目构建过程的开始阶段被 Cargo执行。构建脚本通常用于编译时计算或生成代码、构建或链接非 Rust 代码(例如 C 库),或自动生成 Rust 代码之前的某些配置。
- 构建脚本是可选的,只有当项目需要在编译前执行特定的任务时才会使用到。这个脚本必须是一个有效的
Rust程序,Cargo会将其编译并执行。 - 当运行
cargo build或相关的Cargo构建命令时,Cargo会在编译项目的其余部分之前运行build.rs脚本。如果构建脚本执行成功,它可能会通过创建一个OUT_DIR环境变量来生成文件,Rust 代码可以在编译时通过include!或其他宏来访问这些文件。 - 如果
build.rs脚本生成了编译器指令,它们将作为标准输出打印,并被 Cargo 捕获。这些指令可以包括链接库、设置环境变量、传递编译器标志等。
在本项目中, build.rs用于生成link_app.S汇编文件, 将user项目编译的二进制文件引入进来:
6.2 riscv汇编代码补充知识
6.2.1 变量声明与二进制文件嵌入
这次新增的riscv汇编代码主要是link_app.S这个文件:
1 | .align 3 |
上述汇编代码定义类每一个app的起始位置的符号, 设计的新的语法如下:
.quad
用于定义一个或多个8字节大小的数据元素。每个.quad表示一个64位(即8字节)的值。这个指令常被用于分配内存空间,并初始化数据段中的常量值。这段代码中,_num_app位置后的quad记录了这7个app的开始位置.incbin
直接在当前位置包含(嵌入)一个二进制文件的内容。还可以指定文件中的位置和长度, 例如:这句的含义是: 包含1
.incbin "data.bin", 100, 50
data.bin文件从第 100 个字节开始的接下来的 50 个字节
6.2.2 宏定义
我们在trap.S中看到了下面的宏定义代码
1 | .altmacro |
.altmacro 指令用于开启或关闭“替代宏语法模式”(alternate macro syntax mode)当altmacro 指令出现在文件中时,它会切换当前的宏处理模式。如果在 .altmacro 出现之前是标准宏模式,那么之后就会切换到替代宏模式;反之亦然。
在替代宏模式下,可以使用 \() 来对参数进行求值,允许宏内部对参数进行算术运算。此外,还可以使用更复杂的字符串处理功能,比如连接字符串或使用条件表达式。
在这里,.altmacro 可能是用来确保宏定义中的 \n 参数可以正常地被替换和计算。在 .altmacro 模式下,宏 SAVE_GP 和 LOAD_GP 中的 \n 会在宏展开时被实际传递的参数值所替换,并计算出正确的偏移量。
例如,如果使用 SAVE_GP 2,替代宏模式会确保宏展开为 sd x2, 16(sp),这会将 x2 寄存器的内容保存到栈指针(sp)地址加上 16 字节处的内存位置(因为 2 * 8 = 16)。同理,LOAD_GP 宏则用于从相同的内存位置将数据加载回 x2 寄存器。
6.3 stvec的模式
stvec 存储处理器在发生异常或中断时跳转到的异常处理基地址(trap handler的入口地址)。stvec 寄存器有两种模式:
Direct Mode(直接模式)
在直接模式下,当异常发生时,处理器会跳转到stvec寄存器中设置的地址开始执行异常处理程序。所有的异常和中断都会导致处理器跳转到这个单一的入口点,然后由异常处理程序根据异常原因码来处理不同的情况。Vectored Mode(向量模式)
在向量模式下,stvec寄存器中的地址是中断向量表的基地址。当异常发生时,处理器会根据异常类型的不同来计算跳转地址。每种异常类型有一个固定的偏移量,处理器会将这个偏移量加到基地址上,计算得到对应的异常处理程序的地址,并跳转到该地址执行。