本文是参加2025春夏季开源操作系统训练营时对第二阶段文档第2章做的笔记。
文档:https://learningos.cn/rCore-Camp-Guide-2025S
完整版文档:https://rcore-os.cn/rCore-Tutorial-Book-v3
本章在前一章的基础上,将多个程序打包到一起输入计算机,支持多个程序的自动加载和运行,对可支持运行一批应用程序的执行环境有一个全面和深入的理解。
本章相比上一章的libOS,不同点主要有两个:一是操作系统和用户程序运行在不同的特权级上;二是能够连续自动运行不同的程序。
主要的实现流程为:
应用程序的安全性不能完全信任,可能会破坏硬件环境,引入特权级机制,将应用程序和可信赖的操作系统运行在不同的特权级下,比较安全。
RISC-V定义了四个主要的特权级别,它们分别为:
用户模式(User Mode, U):
超级用户模式(Supervisor Mode, S):
虚拟机模式(如果启用虚拟化):
机器模式(Machine Mode, M):
特权级的切换通过RISC-V 提供的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall
)和一类执行环境返回(Execution Environment Return,简称 eret
)指令来完成。对于本章这样的批处理系统,2种特权级就足够了。
异常(Exception)是RISC-V体系中一种特殊的控制流(即“异常控制流”)。它代表程序在执行时发生了意外的行为或事件,这些事件需要程序中断正常的执行顺序并跳转到某个异常处理程序(例如,操作系统内核的异常处理代码)。
在RISC-V中,异常控制流与常规的程序控制流(如顺序执行、循环、分支、函数调用等)是不同的。程序在异常发生时会中断正常的执行流程,并转到异常处理代码中。处理完异常后,程序通常会恢复原本的执行流。
Trap是RISC-V中一种“中断”机制的统称,包括但不限于异常。在RISC-V架构中,异常属于Trap的一种类型。异常通常发生在指令执行过程中,可能是因为程序错误、系统调用、外部硬件中断等事件。这些事件需要操作系统或硬件进行处理,并在处理完成后恢复程序执行。
断点 (Breakpoint) 和 执行环境调用 (Environment call) 两种异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 陷入 或 trap 类指令,此处的陷入为操作系统中传统概念)是通过在上层软件中执行一条特定的指令触发的:执行
ebreak
这条指令之后就会触发断点陷入异常;而执行ecall
这条指令时候则会随着 CPU 当前所处特权级而触发不同的异常。
当一个特权级切换(通常是因为异常或中断)发生时,CPU会中断当前执行的代码,并切换到更高特权级的代码。处理完异常后,CPU需要返回到被中断的程序,继续从原来暂停的位置恢复执行。这种恢复执行的过程在RISC-V中通常会通过异常返回(Exception Return)机制来实现。它通过存储和恢复当前执行状态来确保程序能够在正确的地方继续执行。
当某些操作或请求需要由更高权限的代码处理时,CPU会从较低权限级切换到较高权限级。这时就需要切换执行环境并执行必要的操作。
本节主要讲解如何设计实现被批处理系统逐个加载并运行的应用程序。假定在用户态下运行。应用程序的设计实现要点是内存布局和设计系统调用。
在 user/src
目录下,bin
里面有多个用户程序文件,user
目录下的 lib.rs
以及它引用的若干子模块作为 bin
目录下的源程序所依赖的用户库,等价于其他编程语言提供的标准库,定义了用户库的入口点,封装了一些syscall
。
系统调用实际上是汇编指令级别的二进制接口,在具体实现的时候,需要使用内联汇编,在指定的寄存器中放入参数。在 RISC-V 调用规范中,约定寄存器 a0~a6
保存系统调用的参数, a0
保存系统调用的返回值, a7
用来传递 syscall ID。
1 | fn syscall(id: usize, args: [usize; 3]) -> isize { |
于是用户库中的 sys_write
和 sys_exit
等系统调用只需将 syscall
进行包装即可:
1 | // user/src/syscall.rs |
从本节开始我们将着手实现批处理操作系统,应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式:
静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。
整体的流程为:操作系统初始化栈和上下文,加载应用程序,切换到用户态,应用程序开始运行,可能会调用系统调用获取操作系统的服务,应用程序退出也使用了一种系统调用,其中会加载下一个应用程序。
应用程序与操作系统运行在不同的特权级,特权级的切换需要操作系统和硬件设施通过某种合作机制共同完成。
切换通常是由中断、异常或系统调用引发的。当操作系统需要执行特权操作时,它会通过中断或异常将当前执行的程序从用户模式(U-mode)切换到超级用户模式(S-mode)。在 RISC-V 架构中,关于 Trap 有一条重要的规则:在 Trap 前的特权级不会高于 Trap 后的特权级。因此如果触发 Trap 之后切换到 S 特权级(下称 Trap 到 S),说明 Trap 发生之前 CPU 只能运行在 S/U 特权级。
RISC-V架构使用控制状态寄存器(CSR,Control and Status Register)来管理特权级别和状态,RISC-V有多个专门的寄存器来处理特权级切换:
sstatus (Supervisor Status Register)
sstatus
寄存器用于保存和管理超级用户模式(S-mode)的状态信息,关于特权级别切换、异常和中断的状态。0
:用户模式(U-mode)1
:超级用户模式(S-mode)sstatus
寄存器帮助操作系统管理特权级别切换,保存当前的中断启用状态,以及控制超级用户模式下的内存访问。sepc (Supervisor Exception Program Counter)
sepc
寄存器保存发生异常或中断时,超级用户模式(S-mode)程序计数器的值,即在 Trap 发生之前正在执行的指令的地址。sepc
的值来知道是哪条指令导致的异常。scause (Supervisor Cause Register)
scause
寄存器用来记录 Trap 发生的原因,它指示是哪种异常或中断触发了 Trap。scause
寄存器帮助操作系统或异常处理程序识别具体的异常类型,以便采取适当的处理措施。stval (Supervisor Trap Value Register)
stval
寄存器保存与 Trap 相关的附加信息。对于不同类型的异常或中断,这个寄存器的含义可能不同。它通常用于存储触发异常的相关数据。stval
可以保存出错的虚拟地址。stval
可能保存指令地址。stval
保存错误相关的具体信息。stvec (Supervisor Trap Vector Register)
stvec
寄存器指定 Trap 处理程序的入口地址,即当发生异常或中断时,CPU 应该跳转到的地址。stvec
直接指定异常处理程序的地址,CPU 在发生 Trap 时会跳转到该地址。stvec
寄存器保存的是 Trap 向量的基地址。在向量模式下,Trap 的类型决定了跳转的具体向量地址。这样可以为不同类型的异常设置不同的处理函数。stvec
存储 Trap 处理程序的基本地址。stvec
的最低位控制。sepc
、sstatus
和其他寄存器。原寄存器被保存在内核栈中。stvec
跳转到 Trap 处理程序。scause
确定 Trap 的类型,并通过 stval
获取更多的信息。在RISC-V架构中,硬件只完成必须要做的工作,给了操作系统更大的灵活性。
构建操作系统时,脚本 os/build.rs
生成了一段汇编代码 link_app.S
,插入了应用程序的二进制镜像,并且各自有一对全局符号 app_*_start
,app_*_end
指示它们的开始和结束位置。
应用管理器 AppManager
是操作系统的核心组件:
1 | // os/src/batch.rs |
声明全局变量的时候,采用 static mut
是一种比较简单自然的方法。但是在 Rust 中,任何对于 static mut
变量的访问控制都是 unsafe 的,而我们要在编程中尽量避免使用 unsafe ,这样才能让编译器负责更多的安全性检查。使用UPSafeCell
对于 RefCell
简单进行封装,向编译器做出保证,无需在意任何多核引发的数据竞争/同步问题。虽然 UPSafeCell
是通过 unsafe
实现的,但它的设计尽量减少了 unsafe
的使用,将其封装在受控的范围内,依赖于程序员保证数据访问的安全性。再使用 lazy_static!
宏进行初始化。
1 | // os/src/sync/up.rs |
在 Rust 中,默认情况下,如果有一个不可变引用(&T
),就不能通过它来修改数据。而如果有一个可变引用(&mut T
),只能拥有该数据的唯一可变引用。RefCell
打破了这种限制,提供了内部可变性,允许在运行时进行可变借用和不可变借用的检查。
lazy_static!
宏用于创建延迟初始化的静态变量。静态变量(static
)在程序生命周期内只有一个实例,并且通常需要在编译时进行初始化。在某些情况下,我们希望静态变量的初始化是在第一次使用时才发生,而不是在程序启动时就完成。这就是 lazy_static!
宏的作用,它提供了懒初始化功能。
加载程序:
1 | unsafe fn load_app(&self, app_id: usize) { |
负责将参数 app_id
对应的应用程序的二进制镜像加载到物理内存以 0x80400000
起始的位置。清空目标内存区域,准备加载应用程序。计算应用程序的长度,根据源内存的起始地址和长度,创建一个不可变的切片 app_src
,表示应用程序的源数据。将 app_src
中的数据复制到 app_dst
中,也就是将应用程序从源内存区域复制到目标内存区域。最后执行一个内存屏障指令,确保在加载应用程序后,处理器能够及时地更新它的指令缓存,避免因为缓存问题导致读取到过时的指令。
1 | // os/src/batch.rs |
KernelStack
和 UserStack
:分别定义了内核和用户栈,每个栈都以 4096 字节对齐,栈的大小是先前定义的常量。data
是一个字节数组,存储栈的数据。#[repr(align(4096))]
:这是一种 Rust 特性,用于指定结构体在内存中的对齐方式,这里设置为 4096 字节对齐。这样做的目的是确保栈内存的对齐符合操作系统的要求。KERNEL_STACK
和 USER_STACK
:这些是全局静态变量,分别保存内核栈和用户栈。它们是不可变的,且只会初始化一次。1 | impl KernelStack { |
get_sp()
:返回当前栈指针(Stack Pointer)。只要返回 data
数组的结尾地址即可。通过获取栈的 data
数组的指针得到起始地址,并加上栈的大小来计算当前栈顶的位置。push_context()
:将一个 TrapContext
推入栈中。这个函数将 TrapContext
结构体推送到栈中,并返回对它的可变引用:TrapContext
结构体的大小来获取正确的内存位置。TrapContext
存储到该位置。unsafe
将指针转换为可变引用并返回。这个操作是危险的,因为它绕过了 Rust 的所有权和借用检查。TrapContext
结构体如下,包含通用寄存器、sstatus
寄存器和sepc
寄存器1 | // os/src/trap/context.rs |
在main.rs
中,首先调用了trap::init()
1 | // os/src/trap/mod.rs |
global_asm!
引入汇编文件 trap.S
,其中包含异常处理相关的汇编代码。init
函数用于初始化 RISC-V CPU 中的 stvec
寄存器。stvec
寄存器的作用是存储异常处理程序的入口地址。init
函数中,使用 stvec::write
设置 stvec
寄存器为 __alltraps
函数的地址,并指定处理模式为 Direct。这样,在发生异常时,CPU 会直接跳转到 __alltraps
来处理所有的异常。trap.S
中主要包含两部分,__alltraps
和 __restore
:
1 | .altmacro |
trap_handler
来处理发生的异常或中断。SAVE_GP n
: 这个宏用于将通用寄存器 x[n]
的值保存到堆栈上。具体地,它会将寄存器 x[n]
的值保存到 sp
(堆栈指针)所指向的位置,偏移量是 n*8
。LOAD_GP n
: 这个宏用于从堆栈加载通用寄存器 x[n]
的值。它会从 sp
指向的位置加载数据,并恢复到 x[n]
。.align 2
: 对齐,保证 __alltraps
的起始地址是 4 字节对齐。csrrw sp, sscratch, sp
: 这条指令将 sp
的值存储到 sscratch
寄存器,并将 sscratch
的值加载到 sp
。sp
寄存器现在指向内核栈,sscratch
寄存器保存了用户栈的地址。.set n, 5
: 设置一个变量 n
为 5,这是从 x5
寄存器开始保存的起始寄存器,.rept 27
: rept
是一个循环指令,表示重复 27 次。每次迭代会调用 SAVE_GP %n
来保存一个寄存器的值。SAVE_GP
宏会将每个寄存器 x5
到 x31
的值保存到堆栈上。前面的 x1
和 x3
寄存器已经手动保存,跳过 x0
和 tp(x4)
寄存器。csrr t0, sstatus
: 读取 sstatus
寄存器的值(保存当前状态信息)并存储到 t0
寄存器。csrr t1, sepc
: 读取 sepc
寄存器的值(保存程序计数器)并存储到 t1
寄存器。csrr t2, sscratch
: 读取 sscratch
寄存器的值(保存原用户栈指针)并存储到 t2
寄存器。__restore
作用是完成异常处理后,恢复用户态的状态:
1 | __restore: |
mv sp, a0
:将 a0
(由 trap_handler
函数传递的参数)中的值赋给 sp
(堆栈指针)。a0
之前保存了异常处理过程中分配的内核栈的起始地址。现在,sp
将指向内核栈的位置,后续的操作将基于这个堆栈来恢复上下文。sstatus
、sepc
和 sscratch
,从堆栈上加载之前保存的值到通用寄存器中,再恢复到对应的CSR寄存器。x0
和 tp(x4)
寄存器。csrrw sp, sscratch, sp
: 将堆栈指针 sp
的值交换到 sscratch
寄存器中,原先保存的用户栈指针恢复到 sp
中。此时,sp
会指向用户空间栈,这样接下来可以继续执行用户程序。然后返回用户态。trap_handler
函数用于分发和处理trap:
1 |
|
使用 match
语句根据 scause
中的具体触发原因(scause.cause()
)来选择如何处理不同类型的异常或中断。
UserEnvCall
)时,表示这是一个系统调用的请求(通常是用户程序请求内核提供某种服务)。sepc
寄存器保存了发生异常时的PC。系统调用会修改 sepc
使得程序跳到内核态代码。此时,sepc
加上 4 是为了跳过系统调用指令(例如 ecall
指令)。调用系统调用处理函数。这里的 syscall
函数是内核中实现的系统调用分发函数,第一个参数是系统调用号(存储在 x[17]
),后面是系统调用的参数(存储在 x[10]
, x[11]
, 和 x[12]
)。stval
)。内核中的系统调用通过 syscall
函数,根据 syscall ID 分发到具体的处理函数,与用户态中对用户态的 syscall
函数的封装类似。