保障系统安全和多应用支持是操作系统的两个核心目标,本章从这两个目标出发,思考如何设计应用程序,并进一步展现操作系统的一系列新功能:
- 构造包含操作系统内核和多个应用程序的单一执行程序
- 通过批处理支持多个程序的自动加载和运行
- 操作系统利用硬件特权级机制,实现对操作系统自身的保护
- 实现特权级的跨越
- 支持跨特权级的系统调用功能
批处理系统(Batch System)
,它可以用来管理无需或仅需少量用户交互即可运行的程序,在资源允许的情况下它可以自动安排程序的执行,这被称为批处理作业,此名词源自二十世纪60年代的大型机时代。批处理系统的核心思想是:将多个程序打包到一起输入计算机,当一个程序运行结束后,计算机自动加载下一个程序到内存并执行。
本片代码:
特权级机制
为了保护我们的批处理系统不受到出错应用程序的影响并全程稳定工作,单凭软件实现是很难做到的,而是需要CPU提供一种特权级隔离机制,使CPU在执行应用程序和操作系统内核的指令时处于不同的特权级。
特权级的软硬件协同设计
实现特权级机制的根本原因是应用程序运行的安全性不可充分信任。由于操作系统和应用程序两者通过编译器形成一个单一执行程序来执行,导致即使是应用本身的问题,也会牵连操作系统,导致整个计算机系统出现问题。
所以,计算机科学家和工程师想出了一个办法:让相对安全可靠的操作系统运行在一个硬件保护的安全执行环境中,不受应用程序的破坏;而让应用程序运行在另外一个无法破坏操作系统的受限执行环境中。
为了确保操作系统的安全,对应用程序而言,需要限制的主要有两个方面:
- 应用程序不能访问任意的地址空间
- 应用程序不能执行某些可能破坏计算机系统的指令
除此之外,还需要确保应用程序能够得到操作系统的服务,即应用程序和操作系统还需要有交互的手段。使得低特权级机制只能做高特权级软件允许它做的,且超出低特权级能力的功能必须寻求高特权级软件的帮助。
为了实现这样的特权级机制,需要进行软硬件协同设计。一个比较简洁的方法就是,处理器设置两个不同安全等级的执行环境:
- 用户态特权级的执行环境
- 内核态特权级的执行环境
且明确指出可能破坏计算机系统的内核态特权级指令子集,内核态特权级指令子集只能在内核态特权级的执行环境中执行。处理器在执行指令前会进行特权级安全检查,如果在用户态执行环境中执行内核态特权级指令,会产生异常。
为了让应用程序获得操作系统的函数服务,采用传统的函数调用方(即通常的call
和ret
指令或指令组合)将会绕过硬件的特权级保护检查。所以可以设计新的机器指令:执行环境调用(Execution Environment Call 简称 ecall
)和执行环境返回(Excution Environment Return 简称 eret
)
ecall
:具有用户态到内核态到执行环境切换能力的函数调用指令eret
:具有内核态到用户态的执行环境切换能力的函数返回指令
硬件具有了这样的机制后,还需要操作系统的配合才能最终完成对操作系统自身的保护。
- 首先,操作系统需要提供相应的功能代码,能在执行
eret
前准备和恢复用户态执行应用程序的上下文。 - 其次,在应用程序调用
ecall
指令后,能够检查应用程序的系统调用参数,确保参数不会破坏操作系统。
RISC-V特权级架构
RISC-V架构一共定义了4种特权级:
级别 | 编码 | 名称 |
---|---|---|
0 | 00 | 用户/应用模式(U, User/Application) |
1 | 01 | 监督模式(S, Supervistor) |
2 | 10 | 虚拟监督模式(H, Hypervistor) |
3 | 11 | 机器模式(M, Machine) |
级别数值越大,特权级越高,掌控硬件的能力越强。
在CPU硬件层面,除了M模式必须存在外,其他模式可以不存在。RISC-V架构中,只有M模式是必须实现的,剩下的特权级则可以根据跑在CPU上应用的实际需求进行调整:
- 简单的嵌入式应用只需要实现M模式
- 带有一定保护能力的嵌入式系统需要实现M/U模式
- 复杂的多任务系统则需要实现M/S/U模式
- 到目前为止,H模式特权规范还没有完全制定好
从特权级架构的角度看待执行环境栈:
其中,白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口。其中操作系统内核代码运行在S模式上;应用程序运行在U模式上。
运行在M模式上的软件被称为监督模式执行环境(SEE, Supervistor Execution Environment),比如在操作系统运行前负责加载操作系统的Bootloader-RustSBI。站在运行在S模式上的软件视角来看,它的下面也需要一层执行环境支撑,因此被命名为SEE,它需要在相比S模式更高的特权级下运行,一般情况下SEE在M模式上执行。
执行环境的功能之一是在它支持的上层软件执行之前进行一些初始化工作。之前提到的引导加载程序会在加电之后对整个系统进行初始化,它实际上就是SEE功能的一部分。也就是说在RISC-V架构上的引导加载程序一般运行在M模式上。
在上一节中,实现了简单的操作系统,它和应用程序全程运行在S模式下,应用程序很容易破坏没有任何保护的执行环境-操作系统。在之后,我们会涉及RISC-V的M/S/U三种特权级:
- 应用程序和用户态支持库运行在U模式的最低特权级
- 操作系统内核运行在S模式特权级,形成支撑应用程序和用户态支持的执行环境
- 在之前提到的bootloader-RurstSBI实际上是运行在更底层的M模式特权级下的软件,是操作系统内核的执行环境。
执行环境的另一种功能是对上层软件的执行进行监控管理。可以理解为,当上层软件执行出现了一些异常或者特殊情况时,导致需要用到执行环境中提供的功能,因此需要暂停上层软件的执行,转而运行执行环境的代码。
由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往**(不一定)伴随着CPU的特权级切换**。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在RISC-V架构中,这种与常规控制流不同的异常控制流(ECF, Exception Control Flow)被称为异常(Exception),在RISC-V语境下的Trap种类之一。
用户态应用直接触发从用户态到内核态的异常的原因总体上可以分为两种:
- 用户态软件为获得内核态操作系统的服务功能而执行特殊指令
- 在执行某条指令期间发生了错误并被CPU检测到,例如执行了用户态不允许执行的指令或者其他错误
下表是RISC-V特权级规范定义的可能会导致从低特权级到高特权级到各种异常:
Interrupt | Exception Code | Description |
---|---|---|
0 | 0 | Instruction address misaligned |
0 | 1 | Instruction access fault |
0 | 2 | Illegal instruction |
0 | 3 | Breakpoint |
0 | 4 | Load address misaligned |
0 | 5 | Load access fault |
0 | 6 | Stroe/AMO address misaligned |
0 | 7 | Store/AMO access fault |
0 | 8 | Environment call from U-mode |
0 | 9 | Environment call from S-mode |
0 | 11 | Environment call from M-mode |
0 | 12 | Instruction page fault |
0 | 13 | Load page fault |
0 | 15 | Stroe/AMO page fault |
其中,断点(Breakpoint)
和执行环境调用(Enviroment call)
两种异常(这种有意而为之的指令称为陷入或trap类指令)是通常在上层软件中执行一条特定的指令触发的:执行ebreak
这条指令之后就会触发断点陷入异常;而执行ecall
这条指令之后则会随着CPU当前所处特权级而触发不同的异常。
执行环境调用ecall
,这是一种很特殊的陷入类的指令,在之前从特权级架构看待执行环境栈这张图中,相邻两特权级软件之间的接口正是基于这种陷入机制实现的。M模式软件SEE和S模式的内核之间的接口被称为监督模式二进制接口(Supervistor Binary Interface, SBI),而内核和U模式的应用程序之间的接口被称为应用程序二进制接口(Application Binary Interface, ABI),它还有一个更加通俗的名字:系统调用(syscall ,System Cal)。而之所以叫做二进制接口,是因为它与高级编程语言的内部调用接口不同,是机器/汇编指令级的一种接口。
事实上M/S/U三个特权级的软件可分别由不同的编程语言实现。即使是用同一种汇编语言实现,其调用也不是普通的函数调用,而是陷入异常控制流,在该过程中切换CPU特权级。因此只有将接口下降到机器/汇编指令级才能满足其跨高级语言的通用性和灵活性。
可以看到,在这样的架构之下,每层特权级的软件都只能做高特权级软件允许它做的,并且不会产生什么撼动高特权级软件的情况,一旦低特权级软件的要求超出了其能力范围,就必须寻求高特权级软件的帮助,否则就是一种异常行为了。因此,在软件执行过程中,我们经常可以看到特权级切换:
其他的异常则一般是在执行某一条指令的时候发生了某种错误,例如除零、无效地址访问、无效指令等;或处理器认为处于当前特权级下执行等当前指令是高特权级指令或会访问不应该访问的高特权级的资源(可能危害系统)。碰到这种情况,就需要将控制权转交给高特权级的软件来处理:
- 当错误/异常恢复后,则重新回到低优先级软件去执行
- 如不能恢复错误/异常,那高特权级软件可以杀死和清除低特权级软件,避免破坏整个执行环境
RISC-V的特权指令
与特权级无关的一般的指令和通用寄存器x0
~x31
在任何特权级都可以执行。而每个特权级都对应一些特殊指令和控制状态寄存器(Control and Status Register, CSR)
,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写CSR的指令,还有其他功能的特权指令。
如果处于低特权级状态的处理器执行了高特权级的指令,会产生非法指令错误的异常。这样,位于高特权级的执行环境能够得知低特权级的软件出现了错误,这个错误一般是不可恢复的,此时执行环境将低特权级的软件终止,这在某种程度上体现了特权级保护机制的作用。
在RISC-V中,会有两类属于高特权级S模式的特权指令:
- 指令本身属于高特权级的指令,例如
sret
,表示从S模式返回到U模式 - 指令访问了S模式特权级下才能访问的寄存器或内存,例如表示S模式系统状态的控制状态寄存器
sstatus
等
RISC-V S模式特权指令:
指令 | 含义 |
---|---|
sret | 从S模式返回U模式:在U模式下执行会产生非法指令异常 |
wfi | 处理器在空闲时进入低功耗状态等待中断:在U模式下执行会产生非法指令异常 |
sfence.vma | 刷新TLB缓存:在U模式下执行会产生非法指令异常 |
访问S模式CSR的指令 | 通过访问sepc/stvec/scause/sscartch/stval/sstatus/satp等CSR来改变系统状态:在U模式下执行会产生非法指令异常 |
实现应用程序
接下来将设计实现被批处理系统逐个加载并运行的应用程序。应用程序的设计实现要点是:
- 应用程序的内存布局
- 应用程序发出的系统调用
从某种程度上讲,这里设计的应用程序与第一章中的最小用户态执行环境有很多相同的地方。即设计一个应用程序和基本的支持的功能库,这样应用程序在用户态通过操作系统提供的服务完成自身的任务。
应用程序设计
应用程序、用户库(由入口函数、初始化函数、I/O函数和系统调用接口等多个rust文件组成)放在根目录的user
目录下,它和上一篇的裸机应用不同之处主要在项目的目录文件结构和内存布局上:
user/src/bin/*.rs
:应用程序user/src/*.rs
:用户库user/src/linker.ld
:应用程序的内存布局说明
项目结构
user/src/bin
目录下有多个文件,每个文件是一个应用程序,分别是:
00hello_wordl
:在屏幕上打印一行Hello, world!
01store_fault
:访问一个非法的物理地址,测试批处理系统是否会被该错误影响02power
:不断在计算操作和打印字符串操作之间进行特权级切换03priv_inst
:尝试在用户态执行内核态的特权级指令sret
04priv_csr
:尝试在用户态修改内核态CSRsstatus
|
|
|
|
|
|
|
|
|
|
批处理系统会按照文件名开头的数字编号从小到大的顺序加载并运行它们。
每个应用程序的实现都在对应的单个文件中。打开其中一个文件,会看到只有一个main
函数和若干相关函数所形成的整个应用程序逻辑。
在user/src/lib.rs
中定义了用户库的入口点_start
:
|
|
第3行使用Rust宏,将_start
这段代码编译后的汇编代码放在一个名为.text.entry
的代码段中,方便我们在后续链接的时候调整它的位置使得它能够作为用户库的入口。
从第4行开始,进入用户库入口之后,与上一篇一样,手动清空需要零初始化的.bss
段;然后调用main
函数得到一个类型为i32
的返回值,最后调用用户库提供的exit
接口退出应用程序,并将main
函数的返回值告知批处理系统。
在lib.rs
中可以看到另一个main
:
|
|
第2行,我们使用Rust的宏将其函数main
标志为弱链接。这样最后链接的时候,虽然在lib.rs
和应用程序的文件中都会有main
符号,但由于lib.rs
中的main
符号是弱链接,链接器会使用应用程序的main
。这里主要是进行某种程度上的保护,如果应用程序的文件中找不到任何main
,那么编译也能够通过,但在运行时会报错。
为了支持上述的链接操作,需要引入:
|
|
内存布局
在user/.cargo/config
中,我们和第一章一样设置链接时使用链接脚本user/src/linker.ld
。
在linker.ld
中,我们做的重要的事情是:
- 将程序起始物理地址调整为
0x80400000
,上述五个应用程序都会被加载到这个物理地址上运行 - 将
_start
所在的.text.entry
放在整个程序的开头,也就是说批处理系统只要在加载之后跳转到0x80400000
就已经进入了用户库的入口点,并会在初始化之后跳转到应用程序主逻辑 - 提供了最终生成可执行文件的
.bss
段的起始和终止地址,方便clear_bss
函数调用
|
|
系统调用
在子模块syscall
中,应用通过ecall
调用批处理系统提供的接口,由于应用程序运行在用户态,ecall
指令会触发执行环境调用异常,并Trap进入S模式执行批处理系统针对这个异常特别提供的服务代码。由于这个接口处于S模式的批处理系统和U模式的应用程序之间,这个接口可以被称为ABI或者系统调用。
在本篇中,应用程序和批处理系统之间按照API的结构,约定如下两个系统调用:
|
|
系统调用实际上是汇编指令级的二进制接口,在实际调用的时候,我们需要按照RISC-V调用规范(即ABI格式)在合适的寄存器中放置系统调用的参数,然后执行ecall
指令出发Trap。在Trap回到U模式的应用程序代码之后,会从ecall
的下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值。
RISC-V寄存器编号从0~31
,表示为x0~x31
。其中
x10~x17
:对应a0~a7
x1
:对应ra
在RISC-V调用规范中,和函数调用的ABI情形类似,约定寄存器a0~a6
保存系统调用的参数,a0
保存系统调用的返回值。有些许不同的是寄存器a7
用来传递syscall ID
,这是因为所有的syscall都是通过ecall
指令触发的,除了各输入参数之外我们还额外需要一个寄存器来保存要请求那个系统调用。由于这超出了Rust语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和ecall
指令的插入:
|
|
我们将所有的系统调用都封装成了syscall
函数,可以看到它支持传入syscall ID和3个参数。在 syscall
中,从第6行开始的asm!
宏嵌入ecall
指令来触发系统调用。
从RISC-V调用规范来看,就像函数有着输入参数和返回值一样,ecall
指令同样有着输入和输出寄存器:a0~a2
和a7
作为输入寄存器分别表示系统调用参数和系统调用ID,而当系统调用返回后,a0
作为输出寄存器保存系统调用的返回值。在函数上下文中,输入参数数组args
和变量id
保存系统输调用参数和系统调用ID,而变量ret
保存系统调用返回值,它也是函数syscall
的输出/返回值。
那么如何将变量绑定到寄存器则成了一个难题:比如,在ecall
指令被执行之前,我们需要将寄存器a7
的值设置为变量id
的值,那么我们首先需要知道目前变量id
的值保存在哪里,它可能在栈上也有可能在某个寄存器中。作为程序员我们并不知道这些只有编译器才知道的信息,因此我们只能在编译器的帮助下完成变量到寄存器的绑定。
现在来看asm!
宏的格式:首先在第8行是我们要插入的汇编代码段本身,这里我们只插入一行ecall
指令,不过它可以支持同时插入多条指令。从第9行开始我们在编译器的帮助下将输入/输出变量绑定到寄存器。例如第10行的in("x11") args[1]
表示将输入参数args[1]
绑定到ecall
的输入寄存器x11
即a1
中,编译器自动插入相关指令并保证在ecall
指令被执行之前寄存器a1
的值与args[1]
的值相同。输入参数arg[2]
与id
到输入寄存器的绑定也是同样的方式,但是这里比较特殊的是a0
寄存器,它同时作为输入和输出,因此我们将in
改成inlateout
,并在行末到变量部分使用{in_var} => {out_var}
的格式,其中{in_var}
和{out_var}
分别表示上下文中的输入变量和输出变量。
有些时候不必将变量绑定到固定的寄存器,此时asm!
宏可以自动完成寄存器分配。某些汇编代码段还会带来一些编译器无法预知的副作用,这种情况下需要asm!
中通过options
告知寄存器这些可能的副作用,这样可以帮助编译器在避免出错的情况下更高效的分配寄存器。
对于sys_write
和sys_exit
只需将syscall
进行封装:
|
|
注意sys_write
使用一个&[u8]
切片类型来描述缓冲区,这是一个胖指针(Fat Pointer),里面既包含缓冲区的起始地址,还包含缓冲区的长度。
我们将上述两个系统调用在用户库user_lib
中进一步封装,从而更加接近在Linux等平台下的实际系统调用接口:
|
|
我们将console
子模块中的Stdout::write_str
改成基于write
的实现,且传入的fd
参数设置为1,它代表标准输出,也就是输出到屏幕。目前不需要考虑其他的fd
选取情况。这样,应用程序的println!
宏借助系统调用变得可以用了。
|
|
exit
接口则在用户库中的_start
内使用,当应用程序主逻辑main
返回之后,使用它退出应用并将返回值告知底层的批处理系统。
编译生成应用程序二进制码
简单介绍一下user/Makefile
:
- 对于
src/bin
下的每个应用程序,在target/riscv64gc-unknown-none-elf/release
目录下生成一个同名的ELF可执行文件 - 使用objcopy二进制工具,将上一步生成的ELF文件删除所有ELF header和符号得到
.bin
后缀的纯二进制镜像文件。它们将被链接进内核并由内核在合适的时机加载到内存
实现批处理操作系统
在批处理操作系统中,每当一个应用执行完毕,我们需要将下一个要执行的应用的代码和数据加载到内存。
将应用程序链接到内核
我们需要将应用程序的二进制镜像文件作为内核的数据段链接到内核里面,因此内核需要知道包含的应用程序的数量和他们的位置,这样才能够在运行时对他们进行管理并能够加载到物理内存:
|
|
这里我们引入了一段汇编代码link_app.S
,它一开始并不存在,而是在构建操作系统时自动生成的。这里我们需要增加一个构建脚本,在项目根目录添加一个build.rs
文件:
|
|
Cargo会先编译和执行该构建脚本,然后再去构建整个项目。使用make build
构建内核时,上述的汇编代码link_app.S
就生成了:
|
|
找到并加载应用程序二进制码
我们在os
的batch
子模块中实现一个应用管理器,它的主要功能是:
- 保存应用数量和各自的位置信息,以及当前执行到第几个应用
- 根据应用程序位置信息,初始化好应用所需内存空间,并加载应用执行
应用管理器AppManager
结构体定义如下:
|
|
在这里,应用管理器需要保存和维护的信息都在AppManager
里面。这样设计的原因在于:我们希望将AppManager
实例化为一个全局变量,使得任何函数都可以访问。然后AppManager
中的current_app
字段表示当前执行的第几个应用,它是一个可修改的变量,会在系统运行期间发生变。因此在声明全局变量时,采用static mut
是一种比较自然的方法,但是在Rust中,任何对于static mut
变量的访问控制都是unsafe的,而我们要在编程中尽量避免使用unsafe,这样才能让编译器负责更多的安全性检查。
因此我们需要考虑如何在尽量避免触及unsafe的情况下仍能声明并使用可变的全局变量。如果单独使用static
而去掉mut
的话,我们可以声明一个初始化之后就不可变的全局变量,但是我们需要AppManager
里面的内容在运行时发生变化。这就涉及到了Rust中
的内部可变性(Interior Mutability),即在变量自身不可变或仅在不可变借用的情况下仍能修改绑定到变量上的值。
我们可以使用RefCell
包裹AppManager
,这样RefCell
无需被声明为mut
,同时被包裹的AppManager
也可变。但是RefCell
并未被标记为Sync
,因此Rust编译器认为它不能被安全的在线程间共享,也就不能作为全局变量使用。所以我们需要在RefCell
的基础上,再封装一个UPSafeCell
,它名字的含义是:允许我们在单核上安全使用可变全局变量。
|
|
|
|
UPSafeCel
对于RefCell
简单进行封装,它和RefCell
一样提供内部可变性和运行时借用检查,只是更加严格:调用exclusive_access
可以得到它包裹的数据的独占访问权。因此当我们要访问数据时,需要首先调用exclusive_access
获得数据的可变借用标记,通过它可以完成数据的读写,在操作完成之后我们需要销毁这个标记,此后才能开始对该数据的下一次访问。相比RefCell
它不再允许多个读操作同时存在。
up.rs
的这段代码出现了两个unsafe
:
- 首先
new
被声明为一个unsafe
函数,是因为我们希望使用者在创建一个UPSafeCell
时保证在访问UPSafeell
内包裹的数据时始终不违背上述模式:即访问之前调用exclusive_access
,访问之后销毁借用标记再进行下一次访问。 - 另外,将
UPSafeCell
标记为Sync
使得它可以作为一个全局变量。这是unsafe行为,因为编译器无法确定我们的UPSafeCell
能否安全的再多线程共享。
接下来,初始化AppManager
的全局实例APP_MANAGER
:
|
|
初始化的逻辑很简单,就是找到link_app.S
中提供的符号_num_app
,并从这里解析出应用数量以及各个应用的起始地址。
这里使用了外部库lazy_static
提供的lazy_static!
宏。引入这个外部库,需要加入依赖:
|
|
lazy_static!
宏提供了全局变量的运行时初始化功能。一般情况下,全局变量必须在编译时设置一个初始值,但是有些全局变量依赖与运行期间才能得到的数据作为初始值。这导致这些全局变量需要在运行时发生变化,即需要重新设置初始值之后才能使用。如果我们手动实现,需要把这种全局变量声明为static mut
并衍生出很多unsafe代码。这里借助lazy_static!
声明一个AppManager
结构的名为APP_NAMAGER
的全局实例,且只有在它第一次被使用到的时候,才会进行实际的初始化工作。
为了满足我们的需求,我们要实现一些AppManager
的方法:
|
|
load_app
方法,负责将参数app_id
对应的应用程序的二进制镜像加载到物理内存以0x80400000
起始的位置,这个位置是批处理操作系统和应用程序之间约定的常数地址,在之前我们也调整应用程序的内存布局以同一个地址开头。第36行开始,我们首先将一块内存清空,然后找到待加载应用二进制镜像的位置,并将它复制到正确的位置。它的本质就是将数据从一块内存复制到另一块内存,而从批处理操作系统的角度来看,是将操作系统数据段的一部分数据复制到了一个可以执行代码的内存区域。体现了冯诺伊曼计算机的代码即数据的特征。
第34行插入了一条汇编指令fence.i
,它是用来清除i-cache
的。我们知道缓存是存储级结构中提高访存速度很重要的一环。而CPU对物理内存所做的缓存有分为数据缓存(d-cache)和指令缓存(i-cache)两部分,分别在CPU访存和取指时使用。在取指时,对于一个指令地址,CPU会先去i-cache里面查看它是否在某个已缓存的缓存行内,如果在的话它就会直接从高速缓存中拿到指令而不是通过总线访问内存。通常情况下,CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存。但在这里,OS将修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容。因此OS在这里必须使用fence.i
指令手动清空i-cache,让里面所有的内容全部失效,才能够保证CPU访问内存数据和代码的正确性。
实现特权级的切换
由于处理器具有硬件级的特权级机制,应用程序在用户态特权级运行时,是无法直接通过函数调用访问处于内核态特权级的批处理操作系统内核中的函数。但应用程序又需要得到操作系统提供的服务,所以应用程序和操作系统需要通过某种合作机制完成特权级之间的切换,使得用户态应用程序可以得到内核态操作系统函数的服务。接下来将在RISC-V64处理器提供的U/S特权级下,解决批处理操作系统和应用程序的相互配合,完成特权级切换。
RISC-V特权级切换
特权级切换的起因
批处理操作系统被设计为运行在内核态特权级,这是作为SEE的RustSBI保证的。而应用程序被设计为运行在用户态特权级,被操作系统为核心的执行环境监督起来。在本篇中,应用程序的执行环境则是批处理系统提供的AEE(Application Execution Environment)。批处理操作系统为了建立好应用程序的执行环境,需要在执行应用之前进行一些初始化工作,并监控应用程序的执行,具体体现在:
- 当应用程序被启动时,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序
- 当应用程序发起系统调用之后,需要到批处理操作系统中进行处理
- 当应用程序执行出错时,需要到批处理系统中杀死该应用并加载运行下一个应用
- 当应用程序执行结束时,需要到批处理操作系统中加载运行下一个应用
这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。
特权级切换相关的控制状态寄存器
当从一般意义上讨论RISC-V架构的Trap机制时,通常需要注意两点:
- 在触发Trap之前CPU运行在哪个特权级
- CPU需要切换到哪个特权级来处理该Trap,并在处理完成之后返回原特权级
在本篇中,我们仅考虑如下流程:当CPU在用户态特权级运行应用程序,执行到Trap,切换到内核态特权级,批处理操作系统的对应代码相应Trap,并执行系统调用服务,处理完毕后,从内核态返回到用户态应用程序继续执行后续指令。
在RISC-V架构中,关于Trap有一条重要规则:在Trap前的特权级不会高于Trap后的特权级。因此如果触发Trap之后切换到S特权级,说明Trap发生之前CPU只能运行在S/U特权级。但无论如何,只要Trap到S特权级,操作系统就会使用S特权级中与Trap相关的控制状态寄存器(CSR)来辅助Trap处理。进入S特权级Trap的相关CSR:
CSR名 | 该CSR与Trap相关的功能 |
---|---|
sstatus | spp 等字段给出Trap发生之前CPU处在哪个特权级等信息 |
sepc | 当Trap是一个异常时,记录Trap发生之前执行的最后一条指令的地址 |
scause | 描述Trap的原因 |
stval | 给出Trap附加信息 |
stvec | 控制Trap处理代码的入口地址 |
特权级切换
当执行一条Trap类指令,如ecall
时,CPU发现触发了一个异常并需要进行特殊处理,这涉及到执行环境切换。应用程序被切换回来之后需要从发出系统调用请求的执行位置恢复应用程序上下文并继续执行,这需要在切换前后维持应用程序的上下文保持不变。应用程序的上下文包括通用寄存器和栈两个主要部分。由于CPU在不同特权级下共享一套通用寄存器,所以在运行操作系统的Trap操作过程中,操作系统也会用到这些寄存器,这会改变应用程序的上下文。因此,与函数调用需要保存函数调用上下文/活动记录一样,在执行操作系统的Trap处理过程之前,我们需要在某个地方保存这些寄存器并在Trap处理结束后恢复这些寄存器。
除了通用寄存器之外还有一些可能在处理Trap过程中会被修改的CSR,比如CPU所在的特权级。我们要保证它们的变化在我们的预期之内。比如,对特权级转换而言,应该是Trap之前在U特权级,处理Trap的时候在S特权级,返回之后又需要回到U特权级。而对于栈问题则相对简单,只要两个应用程序执行过程中用来记录执行历史的栈所对应的内存区域不想交,就不会产生令我们头痛的覆盖问题和数据破坏问题,也就无需进行保存/恢复。
特权级切换的具体过程一部分由硬件直接完成,另一部分则需要由操作系统来实现。
特权级切换的硬件控制机制
当CPU执行完一条指令(例如:ecall
)并准备从用户特权级Trap到S特权级时,硬件会自动完成以下事情:
sstatus
的SPP
字段会被修改为CPU当前特权级spec
会被修改为Trap处理完成后默认会执行的下一条指令的地址scause/stval
分别会被修改成这次Trap的原因以及相关附加信息- CPU会跳转到
stvec
所设置的Trap处理入口地址,并将当前特权级设置为S,然后从Trap处理入口地址开始执行
而当CPU完成Trap处理准备返回的时候,需要通过一条S特权级的特权指令sret
来完成,这一条指令具体完成以下功能:
- CPU会将当前的特权级按照
sstatus
的SPP
字段设置为U或者S - CPU会跳转到
spec
寄存器指向那条指令,然后继续执行
用户栈与内存栈
在Trap触发的一瞬间,CPU就会切换到S特权级并跳转到stvec
所指示的位置。但是在正式进入S特权级的Trap之前,上面提到过我们必须保存原控制流的寄存器转台,这一般通过内核栈来保存。注意,我们需要用专门为操作系统准备的内核栈,而非应用程序运行时用到的用户栈。
使用两个不同的栈主要是为了安全性:如果两个控制流使用同一个栈,在返回之后应用程序就能读到Trap控制流的历史信息,比如内核一些函数的地址,这样会带来安全隐患。于是,我们要做的事,在批处理操作系统中添加一段汇编代码,实现从用户栈切换到内核栈,并在内核栈上保存应用程序控制流的寄存器状态。
|
|
KERNEL_STACK_SIZE
和USER_STACK_SIZE
指出内核栈和用户栈道大小分别为$8KiB$。两个类型是以全局变量的形式实例化在批处理操作系统的.bss
段中的。
我们为两个类型实现了get_sp
方法来获取栈顶地址。由于RISC-V中栈是向下增长的,我们只需返回包裹的数组的结尾地址:
|
|
于是换栈是非常简单的,只需将sp
寄存器的值修改为get_sp
的返回值即可。
接下来是Trap上下文,类似前面提到的函数调用上下文,即在Trap发生时需要保存的物力资源内容,并将其放在一个名为TrapContext
的类型中,定义如下:
|
|
可以看到里面包含所有的通用寄存器x0~x31
,还有sstatuc
和spec
。为什么保存它们呢?
- 对于通用寄存器而言,两条控制流运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在Trap控制流中只是会执行Trap处理相关的代码,但依然可以直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存,既然如此只能全部保存。但也有一些例外,
x0
被硬编码成0,它自然不会有变化,还有tp(x4)
寄存器,除非我们手动处于一些特殊用途使用它,否则一般也不会被用到。它们虽然无需被保存,但我们仍然为其预留空间,主要是为了后续的实现方便。 - 对于CSR而言,我们知道进入Trap的时候,硬件会立即覆盖掉
scause/stval/sstatus/sepc
的全部或是其中一部分。scause/stval
的情况是:它总是被Trap处理的第一时间就被使用或者在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。而对于sstatus/sepc
而言,它们会在Trap处理的全程有意义(在Trap控制流最后sret
的时候还用到了它们),而且确实会出现Trap嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们保存下来,并在sret
之前恢复原样。
Trap管理
特权级切换的核心是对Trap的管理。主要涉及如下一些内容:
- 应用程序通过
ecall
进入到内核状态时,操作系统保存被打断的应用程序的Trap上下文 - 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理
- 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap上下文,并通过
sret
让应用程序继续执行
Trap上下文的保存与恢复
首先是具体实现Trap上下文保存和恢复的汇编代码,在批处理操作系统初始化的时候,我们需要修改stvec
寄存器来指向正确的Trap处理入口点:
|
|
这里我们引入了一个外部符号__alltraps
,并将stvec
设置为Direct模式指向它的地址。我们在os/src/trap/trap.S
中实现Trap上下文保存/恢复的汇编代码,分别用外部符号__alltraps
和__restore
标记为函数,并通过global_asm!
宏将这段汇编代码插入进来。
Trap处理的总体流程如下:首先通过__alltraps
将Trap上下文保存在内核栈上,然后跳转到使用Rust编写的trap_handler
函数完成Trap分发及处理。当trap_handler
返回之后,使用__restore
从保存在内核栈上的Trap上下文恢复寄存器。最后通过sret
指令回到应用程序执行。
首先是保存Trap上下文的__alltraps
的实现:
|
|
- 第7行我们使用
.align
将__alltraps
的地址4字节对齐,这是RISC-V特权级规范的要求 - 第9行的
csrrw
原型是csrrw rd, csr, rs
,可以将CSR当前的值读到通用寄存器rd
中,然后将通用寄存器rs
的值写入该CSR。因此这里起到的是交换sscratch
和sp
的效果。在这一行之前sp
指向用户栈,sscratch
指向内核栈,之后sp
指向内核栈,sscratch
指向用户栈 - 第12行,我们准备在内核栈上保存Trap上下文,于是预先分配$34 \times 8$字节的栈帧,这里改动的是
sp
,说明确实是在内核栈上 - 第13~24行,保存Trap上下文的通用寄存器
x0~x31
,跳过x0
和tp(x4)
,原因之前已经说明。在这里也无需保存sp(x2)
,因为我们要基于它来找到每个寄存器应该被保存到的正确的位置。实际上,在栈帧分配之后,我们可用于保存Trap上下文的地址区间为$[sp, sp+8 \times 34)$,按照TrapContext
结构体的内存布局,基于内核栈道位置(sp所指向的地址)来从低地址到高地址分别按顺序放置x0~x31
这些通用寄存器,最后是sstatus
和sepc
。因此通用寄存器xn
应该被保存在地址区间$[sp+8n,sp+8(n+1))$。为了简化代码,x5~x31
这27个通用寄存器我们通过类似循环的.rept
每次使用SAVE_GP
宏来保存,其实质是相同的。注意我们需要在trap.S
开头加上.altmacro
才能正确使用.rept
命令 - 第25~28行,将CSR
sstatus
和sepc
的值分别读到寄存器t0
和t1
中然后保存到内核栈对应的位置上。指令csrr rd, csr
功能就是将CSR的值读到寄存器rd
中 - 第30~31行专门处理sp的问题。首先将
sscratch
的值读取到寄存器t2
并保存到内核栈上。注意:此时sscratch
指向用户栈,sp
指向内核栈 - 第33行令
a0<-sp
,让寄存器a0
指向内核栈的栈指针也就是我们刚刚保存的Trap上下文的地址,这是由于我们接下来调用trap_handler
进行Trap处理,它的第一个参数cx
由调用规范要从a0
中获取。而Trap处理函数trap_handler
需要Trap上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的syscall ID和对应参数。
当trap_handler
返回之后会从调用trap_handler
的下一条指令开始执行,也就是从栈上的Trap上下文恢复的__restore
:
|
|
- 第13~26行负责从内核栈顶的Trap上下文恢复通用寄存器和CSR。我们要先恢复CSR再恢复通用寄存器,这样我们使用的三个临时寄存器才能被正确恢复
- 在第28行之前,
sp
指向保存了Trap上下文之后的内核栈栈顶,sscratch
指向用户栈栈顶。在第28行内核栈上回收Trap上下文所占用的内存,回归进入Trap之前的内核栈栈顶。第30行,再次交换sscratch
和sp
,现在sp
重新只想用户栈栈顶,sscratch
也依然保存进入Trap之前的状态并指向内核栈栈顶 - 在应用程序控制流状态会还原之后,第31行使用
sret
指令回到U特权级继续运行应用程序控制流
Trap分发与处理
Trap在使用Rust实现的trap_handler
函数中完成分发和处理:
|
|
第4行声明返回值为
&mut TrapContext
并在第28行将传入的Trap上下文cx
原样返回,因此在__restore
的时候a0
寄存器在调用trap_handler
前后并没有发生变化,仍然指向分配Trap上下文之后的内核栈栈顶,和此时sp
的值相同,这里的sp<-a0
并不会有问题第7行根据
scause
寄存器所保存的Trap的原因进行分发处理。这里我们无须手动操作这些CSR,而是使用Rust的riscv库来更加方便的操作。引入riscv库,需要在os/Cargo.toml
中添加:1 2 3 4
# os/Cargo.toml [dependencies] riscv = { git = "https://github.com/rcore-os/riscv", features = ["inline-asm"] }
第8~11行,发现触发Trap的原因是来自于U特权级的Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的Trap上下文里面
sepc
,让其增加4。这是因为我们知道这是一个由ecall
指令触发的系统调用,在进入Trap的时候,硬件会将sepc
设置为这条ecall
指令所在的地址。而在Trap返回之后,我们希望应用程序控制流从ecall
的下一条指令开始执行。因此我们只需修改Trap上下文里面的sepc
,让他增加ecall
指令的码长,即4字节。这样在__restore
的时候sepc
在恢复之后就会指向ecall
的下一条指令,并在sret
之后从这里开始执行。用来保存系统调用返回值的
a0
寄存器也会同样发生变化。我们从Trap上下文取出作为syscall ID的a7
和系统调用的三个参数`第12~19行,分别处理应用程序出现访存错误和非法指令错误的情况。此时需要打印错误信息并调用
run_next_app
直接切换并运行下一个应用程序。第20行开始,当遇到目前还不支持的Trap类型的时候,批处理操作系统整个panic报错退出。
实现系统调用功能
对于系统调用而言,syscall
函数并不会实际处理系统调用,而只是根据syscall ID分发到具体的处理函数:
|
|
这里我们会将传进来的参数args
转化成能够被具体的系统调用处理函数接受的类型:
|
|
|
|
sys_write
我们将传入的位于应用程序内的缓冲区的开始地址和长度转化成一个字符串&str
,然后使用批处理操作系统已经实现的print!
宏打印出来。sys_exit
打印退出的应用程序的返回值并同样调用run_next_app
切换到下一个应用程序。
执行应用程序
当批处理操作系统初始化完成,或者是某个应用运行结束或出错的时候,我们要调用run_next_app
函数切换到下一个应用程序。此时CPU运行在S特权级,而它希望能切换到U特权级。在RISC-V架构中,唯一一种使得CPU特权级下降的方法就是执行Trap返回到特权指令,如sret
、mret
等。事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:
- 构造应用程序开始执行所需要的Trap上下文
- 通过
__restore
函数,从刚构造的Trap上下文中,恢复应用程序执行的部分寄存器 - 设置
sepc
CSR的内容为应用程序入口点0x80400000
- 切换
scratch
和sp
寄存器,设置sp
指向应用程序用户栈 - 执行
sret
从S特权级切换到U特权级
它们可以通过复用__restore
的代码来更容易的实现上述工作。我们只需要在内核栈上压入一个为启动应用程序而特殊构造的Trap上下文,在通过__restore
函数,就能让这些寄存器到达启动应用程序所需要的上下文状态。
|
|
为TrapContext
实现app_init_context
方法,修改其中的sepc
寄存器为应用程序入口点entry
,sp
寄存器为我们设定的一个栈指针,并将sstatus
寄存器的SPP
字段设置为User。
在run_next_app
函数中我们能够看到:
|
|
在17~22行所做的事情就是在内核栈上压入一个Trap上下文,其sepc
是应用程序入口0x80400000
,其sp
寄存器指向用户栈,其sstatus
的SPP
字段被设置为User。push_context
的返回值是内核栈压入Trap上下文之后的栈顶,它会被作为__restore
的参数,这使得在__restore
函数中sp
仍然可以指向内核栈道栈顶。这之后,就和执行一次普通的__restore
函数调用一样了。