实现特权级的切换¶
RISC-V特权级切换¶
特权级切换的起因¶
批处理操作系统为了建立好应用程序的执行环境,需要在执行应用程序前进行一些初始化工作, 并监控应用程序的执行,具体体现在:
启动应用程序时,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
应用程序发起系统调用后,需要切换到批处理操作系统中进行处理;
应用程序执行出错时,批处理操作系统要杀死该应用并加载运行下一个应用;
应用程序执行结束时,批处理操作系统要加载运行下一个应用。
这些处理都涉及到特权级切换,因此都需要硬件和操作系统协同提供的特权级切换机制。
特权级切换相关的控制状态寄存器¶
本章中我们仅考虑当 CPU 在 U 特权级运行用户程序的时候触发 Trap, 并切换到 S 特权级的批处理操作系统进行处理。
CSR 名 |
该 CSR 与 Trap 相关的功能 |
---|---|
sstatus |
|
sepc |
当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
scause |
描述 Trap 的原因 |
stval |
给出 Trap 附加信息 |
stvec |
控制 Trap 处理代码的入口地址 |
特权级切换的具体过程一部分由硬件直接完成,另一部分则需要由操作系统来实现。
特权级切换的硬件控制机制¶
当 CPU 执行完一条指令并准备从用户特权级 陷入( Trap
)到 S 特权级的时候,硬件会自动完成如下这些事情:
sstatus
的SPP
字段会被修改为 CPU 当前的特权级(U/S)。sepc
会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。scause/stval
分别会被修改成这次 Trap 的原因以及相关的附加信息。CPU 会跳转到
stvec
所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。
注解
stvec 相关细节
在 RV64 中, stvec
是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:
MODE 位于 [1:0],长度为 2 bits;
BASE 位于 [63:2],长度为 62 bits。
当 MODE 字段为 0 的时候, stvec
被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2
, CPU 会跳转到这个地方进行异常处理。本书中我们只会将 stvec
设置为 Direct 模式。而 stvec
还可以被设置为 Vectored 模式,
而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret
来完成,这一条指令具体完成以下功能:
CPU 会将当前的特权级按照
sstatus
的SPP
字段设置为 U 或者 S ;CPU 会跳转到
sepc
寄存器指向的那条指令,然后继续执行。
用户栈与内核栈¶
在 Trap 触发的一瞬间, CPU 会切换到 S 特权级并跳转到 stvec
所指示的位置。
但是在正式进入 S 特权级的 Trap 处理之前,我们必须保存原控制流的寄存器状态,这一般通过栈来完成。
但我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。
我们声明两个类型 KernelStack
和 UserStack
分别表示用户栈和内核栈,它们都只是字节数组的简单包装:
两个栈以全局变量的形式实例化在批处理操作系统的 .bss
段中。
我们为两个类型实现了 get_sp
方法来获取栈顶地址。由于在 RISC-V 中栈是向下增长的,
我们只需返回包裹的数组的结尾地址,以用户栈类型 UserStack
为例:
1impl UserStack {
2 fn get_sp(&self) -> usize {
3 self.data.as_ptr() as usize + USER_STACK_SIZE
4 }
5}
换栈是非常简单的,只需将 sp
寄存器的值修改为 get_sp
的返回值即可。
接下来是 Trap 上下文,即在 Trap 发生时需要保存的物理资源内容,定义如下:
1// os/src/trap/context.rs
2
3#[repr(C)]
4pub struct TrapContext {
5 pub x: [usize; 32],
6 pub sstatus: Sstatus,
7 pub sepc: usize,
8}
可以看到里面包含所有的通用寄存器 x0~x31
,还有 sstatus
和 sepc
。
对于通用寄存器而言,两条控制流(应用程序控制流和内核控制流)运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在 Trap 控制流中只是会执行 Trap 处理 相关的代码,但依然可能直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存。既然如此我们就只能全部保存了。但这里也有一些例外, 如
x0
被硬编码为 0 ,它自然不会有变化;还有tp(x4)
寄存器,除非我们手动出于一些特殊用途使用它,否则一般也不会被用到。虽然它们无需保存, 但我们仍然在TrapContext
中为它们预留空间,主要是为了后续的实现方便。对于 CSR 而言,我们知道进入 Trap 的时候,硬件会立即覆盖掉
scause/stval/sstatus/sepc
的全部或是其中一部分。scause/stval
的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。 而对于sstatus/sepc
而言,它们会在 Trap 处理的全程有意义(在 Trap 控制流最后sret
的时候还用到了它们),而且确实会出现 Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在sret
之前恢复原样。
Trap 管理¶
Trap 上下文的保存与恢复¶
首先是具体实现 Trap 上下文保存和恢复的汇编代码。
在批处理操作系统初始化时,我们需要修改 stvec
寄存器来指向正确的 Trap 处理入口点。
1// os/src/trap/mod.rs
2
3core::arch::global_asm!(include_str!("trap.S"));
4
5pub fn init() {
6 extern "C" { fn __alltraps(); }
7 unsafe {
8 stvec::write(__alltraps as usize, TrapMode::Direct);
9 }
10}
这里我们引入了一个外部符号 __alltraps
,并将 stvec
设置为 Direct 模式指向它的地址。我们在 os/src/trap/trap.S
中实现 Trap 上下文保存/恢复的汇编代码,分别用外部符号 __alltraps
和 __restore
标记为函数,并通过 global_asm!
宏将 trap.S
这段汇编代码插入进来。
Trap 处理的总体流程如下:首先通过 __alltraps
将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler
函数
完成 Trap 分发及处理。当 trap_handler
返回之后,使用 __restore
从保存在内核栈上的 Trap 上下文恢复寄存器。最后通过一条
sret
指令回到应用程序执行。
首先是保存 Trap 上下文的 __alltraps
的实现:
1# os/src/trap/trap.S
2
3.macro SAVE_GP n
4 sd x\n, \n*8(sp)
5.endm
6
7.align 2
8__alltraps:
9 csrrw sp, sscratch, sp
10 # now sp->kernel stack, sscratch->user stack
11 # allocate a TrapContext on kernel stack
12 addi sp, sp, -34*8
13 # save general-purpose registers
14 sd x1, 1*8(sp)
15 # skip sp(x2), we will save it later
16 sd x3, 3*8(sp)
17 # skip tp(x4), application does not use it
18 # save x5~x31
19 .set n, 5
20 .rept 27
21 SAVE_GP %n
22 .set n, n+1
23 .endr
24 # we can use t0/t1/t2 freely, because they were saved on kernel stack
25 csrr t0, sstatus
26 csrr t1, sepc
27 sd t0, 32*8(sp)
28 sd t1, 33*8(sp)
29 # read user stack from sscratch and save it on the kernel stack
30 csrr t2, sscratch
31 sd t2, 2*8(sp)
32 # set input argument of trap_handler(cx: &mut TrapContext)
33 mv a0, sp
34 call trap_handler
第 7 行我们使用
.align
将__alltraps
的地址 4 字节对齐,这是 RISC-V 特权级规范的要求;第 9 行的
csrrw
原型是 \(\text{csrrw rd, csr, rs}\) 可以将 CSR 当前的值读到通用寄存器 \(\text{rd}\) 中,然后将 通用寄存器 \(\text{rs}\) 的值写入该 CSR 。因此这里起到的是交换 sscratch 和 sp 的效果。在这一行之前 sp 指向用户栈, sscratch 指向内核栈(原因稍后说明),现在 sp 指向内核栈, sscratch 指向用户栈。第 12 行,我们准备在内核栈上保存 Trap 上下文,于是预先分配 \(34\times 8\) 字节的栈帧,这里改动的是 sp ,说明确实是在内核栈上。
第 13~24 行,保存 Trap 上下文的通用寄存器 x0~x31,跳过 x0 和 tp(x4),原因之前已经说明。我们在这里也不保存 sp(x2),因为它在第 9 行 后指向的是内核栈。用户栈的栈指针保存在 sscratch 中,必须通过
csrr
指令读到通用寄存器中后才能使用,因此我们先考虑保存其它通用寄存器,腾出空间。我们要基于 sp 来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存 Trap 上下文的地址区间为 \([\text{sp},\text{sp}+8\times34)\) , 按照
TrapContext
结构体的内存布局,基于内核栈的位置(sp所指地址)来从低地址到高地址分别按顺序放置 x0~x31这些通用寄存器,最后是 sstatus 和 sepc 。因此通用寄存器 xn 应该被保存在地址区间 \([\text{sp}+8n,\text{sp}+8(n+1))\) 。为了简化代码,x5~x31 这 27 个通用寄存器我们通过类似循环的
.rept
每次使用SAVE_GP
宏来保存,其实质是相同的。注意我们需要在trap.S
开头加上.altmacro
才能正常使用.rept
命令。第 25~28 行,我们将 CSR sstatus 和 sepc 的值分别读到寄存器 t0 和 t1 中然后保存到内核栈对应的位置上。指令 \(\text{csrr rd, csr}\) 的功能就是将 CSR 的值读到寄存器 \(\text{rd}\) 中。这里我们不用担心 t0 和 t1 被覆盖, 因为它们刚刚已经被保存了。
第 30~31 行专门处理 sp 的问题。首先将 sscratch 的值读到寄存器 t2 并保存到内核栈上,注意: sscratch 的值是进入 Trap 之前的 sp 的值,指向 用户栈。而现在的 sp 则指向内核栈。
第 33 行令 \(\text{a}_0\leftarrow\text{sp}\),让寄存器 a0 指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址, 这是由于我们接下来要调用
trap_handler
进行 Trap 处理,它的第一个参数cx
由调用规范要从 a0 中获取。而 Trap 处理函数trap_handler
需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的 syscall ID 和 对应参数。我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值。
注解
CSR 相关原子指令
RISC-V 中读写 CSR 的指令是一类能不会被打断地完成多个读写操作的指令。这种不会被打断地完成多个操作的指令被称为 原子指令 (Atomic Instruction)。这里的 原子 的含义是“不可分割的最小个体”,也就是说指令的多个操作要么都不完成,要么全部完成,而不会处于某种中间状态。
当 trap_handler
返回之后会从调用 trap_handler
的下一条指令开始执行,也就是从栈上的 Trap 上下文恢复的 __restore
:
1.macro LOAD_GP n
2 ld x\n, \n*8(sp)
3.endm
4
5__restore:
6 # case1: start running app by __restore
7 # case2: back to U after handling trap
8 mv sp, a0
9 # now sp->kernel stack(after allocated), sscratch->user stack
10 # restore sstatus/sepc
11 ld t0, 32*8(sp)
12 ld t1, 33*8(sp)
13 ld t2, 2*8(sp)
14 csrw sstatus, t0
15 csrw sepc, t1
16 csrw sscratch, t2
17 # restore general-purpuse registers except sp/tp
18 ld x1, 1*8(sp)
19 ld x3, 3*8(sp)
20 .set n, 5
21 .rept 27
22 LOAD_GP %n
23 .set n, n+1
24 .endr
25 # release TrapContext on kernel stack
26 addi sp, sp, 34*8
27 # now sp->kernel stack, sscratch->user stack
28 csrrw sp, sscratch, sp
29 sret
第 8 行比较奇怪,我们暂且不管,假设它从未发生,那么 sp 仍然指向内核栈的栈顶。
第 11~24 行负责从内核栈顶的 Trap 上下文恢复通用寄存器和 CSR 。注意我们要先恢复 CSR 再恢复通用寄存器,这样我们使用的三个临时寄存器 才能被正确恢复。
在第 26 行之前,sp 指向保存了 Trap 上下文之后的内核栈栈顶, sscratch 指向用户栈栈顶。我们在第 26 行在内核栈上回收 Trap 上下文所 占用的内存,回归进入 Trap 之前的内核栈栈顶。第 27 行,再次交换 sscratch 和 sp,现在 sp 重新指向用户栈栈顶,sscratch 也依然保存 进入 Trap 之前的状态并指向内核栈栈顶。
在应用程序控制流状态被还原之后,第 28 行我们使用
sret
指令回到 U 特权级继续运行应用程序控制流。
Trap 分发与处理¶
Trap 在使用 Rust 实现的 trap_handler
函数中完成分发和处理:
1// os/src/trap/mod.rs
2
3#[no_mangle]
4pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
5 let scause = scause::read();
6 let stval = stval::read();
7 match scause.cause() {
8 Trap::Exception(Exception::UserEnvCall) => {
9 cx.sepc += 4;
10 cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
11 }
12 Trap::Exception(Exception::StoreFault) |
13 Trap::Exception(Exception::StorePageFault) => {
14 println!("[kernel] PageFault in application, core dumped.");
15 run_next_app();
16 }
17 Trap::Exception(Exception::IllegalInstruction) => {
18 println!("[kernel] IllegalInstruction in application, core dumped.");
19 run_next_app();
20 }
21 _ => {
22 panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval);
23 }
24 }
25 cx
26}
第 4 行声明返回值为
&mut TrapContext
并在第 25 行实际将传入的cx
原样返回,因此在__restore
的时候a0
寄存器在调用trap_handler
前后并没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶,和此时sp
的值相同,我们 \(\text{sp}\leftarrow\text{a}_0\) 并不会有问题;第 7 行根据
scause
寄存器所保存的 Trap 的原因进行分发处理。这里我们无需手动操作这些 CSR ,而是使用 Rust 第三方库 riscv 。第 8~11 行,发现触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面 sepc,让其增加 4。这是因为我们知道这是一个由
ecall
指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条ecall
指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序控制流从ecall
的下一条指令 开始执行。因此我们只需修改 Trap 上下文里面的 sepc,让它增加ecall
指令的码长,也即 4 字节。这样在__restore
的时候 sepc 在恢复之后就会指向ecall
的下一条指令,并在sret
之后从那里开始执行。用来保存系统调用返回值的 a0 寄存器也会同样发生变化。我们从 Trap 上下文取出作为 syscall ID 的 a7 和系统调用的三个参数 a0~a2 传给
syscall
函数并获取返回值。syscall
函数是在syscall
子模块中实现的。 这段代码是处理正常系统调用的控制逻辑。第 12~20 行,分别处理应用程序出现访存错误和非法指令错误的情形。此时需要打印错误信息并调用
run_next_app
直接切换并运行下一个 应用程序。第 21 行开始,当遇到目前还不支持的 Trap 类型的时候,批处理操作系统整个 panic 报错退出。
对于系统调用而言, syscall
函数并不会实际处理系统调用,而只是根据 syscall ID 分发到具体的处理函数:
1// os/src/syscall/mod.rs
2
3pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
4 match syscall_id {
5 SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
6 SYSCALL_EXIT => sys_exit(args[0] as i32),
7 _ => panic!("Unsupported syscall_id: {}", syscall_id),
8 }
9}
这里我们会将传进来的参数 args
转化成能够被具体的系统调用处理函数接受的类型。它们的实现都非常简单:
1// os/src/syscall/fs.rs
2
3const FD_STDOUT: usize = 1;
4
5pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
6 match fd {
7 FD_STDOUT => {
8 let slice = unsafe { core::slice::from_raw_parts(buf, len) };
9 let str = core::str::from_utf8(slice).unwrap();
10 print!("{}", str);
11 len as isize
12 },
13 _ => {
14 panic!("Unsupported fd in sys_write!");
15 }
16 }
17}
18
19// os/src/syscall/process.rs
20
21pub fn sys_exit(xstate: i32) -> ! {
22 println!("[kernel] Application exited with code {}", xstate);
23 run_next_app()
24}
sys_write
我们将传入的位于应用程序内的缓冲区的开始地址和长度转化为一个字符串&str
,然后使用批处理操作系统已经实现的print!
宏打印出来。这里我们并没有检查传入参数的安全性,存在安全隐患。sys_exit
打印退出的应用程序的返回值并同样调用run_next_app
切换到下一个应用程序。
执行应用程序¶
当批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app
函数切换到下一个应用程序。此时 CPU 运行在
S 特权级,而它希望能够切换到 U 特权级。在 RISC-V 架构中,唯一一种能够使得 CPU 特权级下降的方法就是通过 Trap 返回系列指令,比如
sret
。事实上,在运行应用程序之前要完成如下这些工作:
跳转到应用程序入口点
0x80400000
;将使用的栈切换到用户栈;
在
__alltraps
时我们要求sscratch
指向内核栈,这个也需要在此时完成;从 S 特权级切换到 U 特权级。
它们可以通过复用 __restore
的代码来更容易的实现上述工作。我们只需要在内核栈上压入一个为启动应用程序而特殊构造的 Trap 上下文,再通过 __restore
函数,就能
让这些寄存器到达启动应用程序所需要的上下文状态。
1// os/src/trap/context.rs
2
3impl TrapContext {
4 pub fn set_sp(&mut self, sp: usize) { self.x[2] = sp; }
5 pub fn app_init_context(entry: usize, sp: usize) -> Self {
6 let mut sstatus = sstatus::read();
7 sstatus.set_spp(SPP::User);
8 let mut cx = Self {
9 x: [0; 32],
10 sstatus,
11 sepc: entry,
12 };
13 cx.set_sp(sp);
14 cx
15 }
16}
为 TrapContext
实现 app_init_context
方法,修改其中的 sepc 寄存器为应用程序入口点 entry
, sp 寄存器为我们设定的
一个栈指针,并将 sstatus 寄存器的 SPP
字段设置为 User 。
在 run_next_app
函数中我们能够看到:
1// os/src/batch.rs
2
3pub fn run_next_app() -> ! {
4 let mut app_manager = APP_MANAGER.exclusive_access();
5 let current_app = app_manager.get_current_app();
6 unsafe {
7 app_manager.load_app(current_app);
8 }
9 app_manager.move_to_next_app();
10 drop(app_manager);
11 // before this we have to drop local variables related to resources manually
12 // and release the resources
13 extern "C" {
14 fn __restore(cx_addr: usize);
15 }
16 unsafe {
17 __restore(KERNEL_STACK.push_context(TrapContext::app_init_context(
18 APP_BASE_ADDRESS,
19 USER_STACK.get_sp(),
20 )) as *const _ as usize);
21 }
22 panic!("Unreachable in batch::run_current_app!");
23}
__restore
所做的事情是在内核栈上压入一个 Trap 上下文,其 sepc
是应用程序入口地址 0x80400000
,其 sp
寄存器指向用户栈,其 sstatus
的 SPP
字段被设置为 User 。
push_context
的返回值是内核栈压入 Trap 上下文之后的栈顶,它会被作为 __restore
的参数(
回看 __restore 代码 ,这时我们可以理解为何 __restore
函数的起始部分会完成
\(\text{sp}\leftarrow\text{a}_0\) ),这使得在 __restore
函数中 sp
仍然可以指向内核栈的栈顶。这之后,就和执行一次普通的
__restore
函数调用一样了。
注解
有兴趣的读者可以思考: sscratch 是何时被设置为内核栈顶的?