批处理系统
本节讲解内核如何装载用户程序,实现批处理.
应用程序构建
Section titled “应用程序构建”对user/rust/src/bin中的任何一个文件,我们可以看到 main 函数和外部库引用:
这个外部库其实就是 user/rust/src 目录下的 lib.rs 以及它引用的若干子模块. 在 user/rust/Cargo.toml 中我们对于库的名字进行了设置: name = "user_lib". 它作为应用程序所依赖的用户库,等价于其他编程语言提供的标准库.
在 lib.rs 中,定义用户库的入口点 _start.
#[link_section = ".text.entry"] 使用 Rust 的宏将 _start 这段代码编译后的汇编代码中放在一个名为 .text.entry 的代码段中,方便我们在后续链接的时候 调整它的位置使得它能够作为用户库的入口.
用 clear_bss() 函数手动清空 .bss 段. clear_bss() 定义在 lib.rs 中.
在lib.rs中定义一个main函数,其具有弱链接特性. 在编译过程中,弱符号遇到强符号时,会选择强符号而丢掉弱符号.
所以,程序在链接时,虽然 lib.rs 和 bin 目录下的某个应用程序中都有 main 符号,但由于 lib.rs 中的 main 符号是弱链接,链接器会使用 bin 目录下的函数作为 main. 如果在 bin 目录下找不到任何 main,那么编译也能通过,但会在运行时报错.
在系统调用时,我们需要将参数按 ABI 约定放入寄存器,因此需要先了解 RISC-V 的调用约定与寄存器分工.
在 syscall 中,应用程序来通过 ecall 调用批处理系统提供的接口,由于应用程序运行在 U 模式, ecall 指令会触发名为 Environment call from U-mode 的异常,并 Trap 进入 S 模式.
约定如下系统调用.
系统调用实际上是汇编指令级的二进制接口. 在实际调用的时候,需要按照 RISC-V 调用规范在合适的寄存器中放置系统调用的参数,然后执行 ecall 指令触发 Trap. 在 Trap 回到 U 模式的应用程序代码之后,会从 ecall 的 下一条指令继续执行,同时我们能够按照调用规范在合适的寄存器中读取返回值.
约定寄存器 a0~a6 可用于保存系统调用参数,a0~a1 保存系统调用返回值. 寄存器 a7 用来传递 syscall ID. 因为所有 syscall 都通过 ecall 触发,所以除了参数之外还需要一个寄存器标识“请求哪一个系统调用”.
由于这超出了 Rust 语言的表达能力,我们需要在代码中使用内嵌汇编来完成参数/返回值绑定和 ecall 指令的插入.
这里使用了Rust的内联汇编宏 asm!,可以参考Inline assembly - The Rust Reference. 由于Rust 编译器无法判定汇编代码的安全性,所以我们需要将其包裹在 unsafe 块中.
简而言之,这条汇编代码的执行结果是以寄存器 a0~a2 来保存系统调用的参数,以及寄存器 a7 保存 syscall ID, 返回值通过寄存器 a0 传递给局部变量 ret.
对于 inlateout("x10") args[0] => ret,
- 传值:
args[0]是输入值,它会在调用ecall之前被加载到x10寄存器. - 返回结果:执行完
ecall后,x10的值变为结果值,并把这个结果存入变量ret.
于是我们基于syscall就可以实现一些基本的系统功能:
sys_write 使用一个 &[u8] 切片类型来描述缓冲区,这是一个胖指针,里面既包含缓冲区的起始地址,还包含缓冲区的长度. 我们可以分别通过 as_ptr 和 len 方法取出它们,并独立的作为实际的系统调用参数.
我们也可以在 usr_lib 中封装 syscall.rs 的方法,更加接近在 Linux 等平台的实际体验,有
由此可实现 console 子模块中 Stdout::write_str. write_str 会通过 sys_write 到 syscall 这层封装,最终触发 ecall.
为确保操作系统的安全,需要限制应用程序的两个方面:
- 应用程序不能访问任意的地址空间(ch4)
- 应用程序不能执行某些可能破坏计算机系统的指令(本章, ch2)
处理器设置不同安全等级的执行环境 (用户态与内核态). 对于可能破坏系统状态的指令,硬件规定其只能在高特权级执行;若在低特权级执行,会触发异常并交给内核处理.
为了让应用程序安全地获得内核服务,不能直接使用普通 call/ret 跨特权级调用,否则会绕过硬件保护. 因此需要使用陷入和返回机制:
ecall:在 RISC-V 的 U 模式下执行时,会触发Environment call from U-mode异常,并陷入到 S 模式交给内核处理;sret:在 RISC-V 的 S 模式下执行,用于从内核返回到先前特权级 (通常是 U 模式).
操作系统需要提供相应的控制流:在内核准备执行 sret 返回前,能够恢复用户态应用程序的上下文;在应用程序执行 ecall 陷入后,能够保存用户态应用程序的上下文.
RISC-V存在四种特权级:
| 级别 | 编码 | 名称 |
|---|---|---|
| 0 | 00 | U, User/Application |
| 1 | 01 | S, Supervisor |
| 2 | 10 | H, Hypervisor |
| 3 | 11 | M, Machine |
这张图片给出了能够支持运行 Unix 这类复杂系统的软件栈:
白色块表示一层执行环境,黑色块表示相邻两层执行环境之间的接口. 内核代码运行在 S 模式上;应用程序运行在 U 模式上. 运行在 M 模式上的软件被称为 监督模式执行环境 (SEE, Supervisor Execution Environment) ,从运行在 S 模式上的软件的视角来看,它的下面也需要一层执行环境支撑,因此被命名为 SEE,它需要在相比 S 模式更高的特权级下运行,一般情况下在 M 模式上运行.
执行环境的其中一种功能是,在执行它支持的上层软件之前,进行一些初始化工作. 我们之前提到的,引导加载程序会在加电后对整个系统进行初始化,它实际上是 SEE 功能的一部分,也就是说在 RISC-V 架构上引导加载程序一般运行在 M 模式上.
后续的章节中,应用程序和用户态支持库运行在 U 模式的最低特权级;操作系统内核运行在 S 模式特权级(ch2, 批处理系统),形成支撑应用程序和用户态支持库的执行环境;而 OpenSBI 是运行在 M 模式特权级下的软件,是操作系统内核的执行环境. 整个软件系统就由这三层运行在不同特权级下的不同软件组成.
执行环境的另一种功能,是对上层软件的执行进行监控管理.
当上层软件执行的时,需要用到执行环境中提供的功能,需要暂停,转而运行 执行环境 的代码. 由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往伴随着 CPU 的 特权级切换,当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行.
用户态应用触发从 用户态到内核态 的 异常控制流 的原因总体上可以分为两种:
- 执行 Trap 类异常指令
- 执行 Fault 类异常的指令
Trap 类异常指令即用户态软件为获得内核态操作系统的服务功能,而发出的特殊指令. Fault类的指令为用户态软件执行了在内核态操作系统看来是非法操作的指令.
在 RISC-V 架构中,这种与常规控制流 (顺序、循环、分支、函数调用)不同的 异常控制流 (ECF, Exception Control Flow) 被称为 异常(Exception).
执行环境中相邻两特权级软件之间的接口正是基于 ecall 的陷入机制实现的. M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口 (Supervisor Binary Interface, SBI),而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI). 特权级切换如图.
与特权级无关的一般的指令和通用寄存器 x0~x31 在任何特权级都可以任意执行. 而每个特权级都对应一些特殊指令和控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态. 当然特权指令不只是具有有读写 CSR 的指令,还有其他功能的特权指令.
批处理系统实现
Section titled “批处理系统实现”这一部分描述批处理系统如何加载并执行前面构建的用户程序.
应用程序链接至内核
Section titled “应用程序链接至内核”我们需要将应用程序链接到内核,即将应用程序的二进制镜像文件作为内核的数据段链接到内核内,因此内核需要知道内含的应用程序的数量和它们的位置,实现运行时的管理并能够加载到物理内存.
汇编代码 link_app.S 一开始并不存在,而是在 build.rs 构建的时候 File::create("src/link_app.S") 自动生成的.
在.section指定接下来的数据属于.data(数据段)后,.global声明符号为全局符号,使其在其他文件中可见.
.align用于指定数据的对齐,.align 3 将接下来的数据对齐到 2 ^ 3 = 8 字节的边界,因为_num_app 和随后的 app_0_start、app_1_start、app_2_start 等符号是以 quad 进行定义的,.quad分配并初始化一个 64-bit 数据. 在 64 位系统中实现内存对齐到 8 字节边界.
.incbin包含一个外部二进制文件的内容,并将其嵌入到当前字节流中. 这在需要将预编译的二进制文件直接打包到程序中的情况下非常有用.
其中_num_app段相当于一个64位整数数组,第一个元素表示应用程序数量,之后是各个应用的起始地址,最后是最后一个程序的结束位置,这样应用程序的位置都能从该数组中相邻两个元素中得知.
Rust应用加载与AppManager
Section titled “Rust应用加载与AppManager”由此我们可以在Rust中实现应用加载.
初始化 AppManager 的全局实例.
在初始化AppManager的代码中,UPSafeCell获取内部对象的可变引用,拥有内部可变性.
对于有些全局变量,其初始化依赖于运行期间才能得到的数据. 此处声明了一个 AppManager 结构的名为 APP_MANAGER 的全局实例, 只有在它第一次被使用到的时候才会进行实际的初始化工作.
初始化的逻辑很简单,就是找到 link_app.S 中提供的符号 _num_app ,并从这里开始解析出应用数量以及各个应用的开头地址. 对 app_start[..=num_app]一句,..=num_app 是一个范围表达式,表示索引从 0 到 num_app(包含 num_app).
用容器 UPSafeCell 包裹 AppManager 是为了防止全局对象 APP_MANAGER 被重复获取. UPSafeCell 实现在 sync 模块中,调用 exclusive_access 方法能获取其内部对象的可变引用,如果程序运行中同时存在多个这样的引用,会触发 already borrowed: BorrowMutError,UPSafeCell 既提供了内部可变性,又在单核情境下防止了内部对象被重复借用.
在AppManager方法中,
这个方法负责将参数 app_id 对应的应用程序的二进制镜像加载到物理内存以 0x80400000 (BASE_ADDRESS) 开头的位置,即批处理操作系统和应用程序之间,约定的常数地址.
此处,.fill(0)将一块内存清空,然后app_src找到待加载应用二进制镜像的位置,app_dst将它复制到正确的位置. 它本质上是把数据从一块内存复制到另一块内存.
core::slice::from_raw_parts方法究竟是什么?
pub const unsafe fn from_raw_parts<'a, T>(data: *const T, len: usize) -> &'a [T].Explanation: Forms a slice from a pointer and a length. The
lenargument is the number of elements, not the number of bytes.
注意我们插入了一条汇编指令 fence.i ,它是用来清理 i-cache 的.
缓存是存储层级结构中,提高访存速度的很重要一环. 而 CPU 对物理内存所做的缓存又分成 数据缓存 (d-cache) 和 指令缓存 (i-cache) 两部分,分别在 CPU 访存和取指的时候使用. 取指时,对于一个指令地址, CPU 会先去 i-cache 里面查看它是否在某个已缓存的缓存行内,如果在,它就会直接从高速缓存中拿到指令,而不是通过总线和内存通信.
通常情况下, CPU 会认为程序的代码段不会发生变化,因此 i-cache 是一种只读缓存. 但在这里,我们会修改会被 CPU 取指的内存区域,这会使得 i-cache 中含有与内存中不一致的内容. 因此我们这里必须使用 fence.i 指令手动清空 i-cache ,让里面所有的内容全部失效, 才能够保证正确性.
为什么这里需要 fence.i 呢?我们刚把新的应用指令拷贝到 0x80400000,但 CPU 的 i-cache 里可能仍缓存旧内容. fence.i 会让后续取指看到最新内存内容,避免“代码已写入但仍执行旧指令”的问题.
特权级切换实现
Section titled “特权级切换实现”此处我们仅考虑当 CPU 在 U 特权级运行用户程序的时候触发 Trap,并切换到 S 特权级的批处理操作系统.
在 RISC-V 架构中,关于 Trap 有一条重要的规则:在 Trap 前的特权级不会高于Trap后的特权级. 因此如果触发 Trap 之后切换到 S 特权级(Trap 到 S), 说明 Trap 发生之前 CPU 只能运行在 S/U 特权级. 操作系统会使用 S 特权级中与 Trap 相关的 控制状态寄存器 (CSR, Control and Status Register) 来辅助 Trap 处理.
| CSR 名 | 该 CSR 与 Trap 相关的功能 |
|---|---|
| sstatus | SPP 等字段给出 Trap 发生之前 CPU 处在哪个特权级(S/U)等信息 |
| sepc | 当 Trap 是一个异常的时候,记录 Trap 发生之前执行的最后一条指令的地址 |
| scause | 描述 Trap 的原因 |
| stval | 给出 Trap 附加信息 |
| stvec | 控制 Trap 处理代码的入口地址 |
应用程序的上下文可以分为通用寄存器和栈两部分. 通用寄存器部分先前提及过;而对于栈,需要两个执行流,并且其记录的执行历史的栈所对应的内存区域不相交,就不会产生覆盖问题,无需进行保存/恢复.
硬件控制: 寄存器
Section titled “硬件控制: 寄存器”当 CPU 执行完一条指令并准备从用户特权级 Trap 到 S 特权级的时候,硬件会自动帮我们做这些事情:
sstatus的SPP字段会被修改为 CPU 当前的特权级(U/S).sepc会被修改为 Trap 回来之后默认会执行的下一条指令的地址. 当 Trap 是一个异常的时候,它实际会被修改成 Trap 之前执行的最后一条 指令的地址.scause/stval分别会被修改成这次 Trap 的原因以及相关的附加信息.- CPU 会跳转到
stvec所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后开始向下执行.
而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:
- CPU 会将当前的特权级按照
sstatus的SPP字段设置为 U 或者 S ; - CPU 会跳转到
sepc寄存器指向的那条指令,然后开始向下执行.
用户栈与内核栈
Section titled “用户栈与内核栈”在 Trap 触发的一瞬间, CPU 就会切换到 S 特权级并跳转到 stvec 所指示的位置. 在正式进入 S 特权级的 Trap 处理之前,我们必须保存原执行流的寄存器状态,这一般通过栈来完成.
声明两个类型 KernelStack 和 UserStack 分别表示用户栈和内核栈,它们都只是字节数组的简单包装.
常数 USER_STACK_SIZE 和 KERNEL_STACK_SIZE 指出内核栈和用户栈的大小分别为 8KiB,以全局变量的形式实例化在批处理操作系统的 .bss 段中.
为两个类型实现了 get_sp 方法来获取栈顶地址. 由于在 RISC-V 中栈是向下增长的,我们只需返回包裹的数组的终止地址,以用户栈类型 UserStack 为例:
换栈是非常简单的,只需将 sp 寄存器的值修改为 get_sp 的返回值即可.
接着,是Trap上下文 TrapContext ,即在 Trap 发生时需要保存的物理资源内容,并将其一起放在一个名为 TrapContext 的类型中,定义如下:
包含所有的通用寄存器 x0~x31 ,还有 sstatus 和 sepc .
Trap管理
Section titled “Trap管理”在批处理操作系统初始化的时候,我们需要修改 stvec 寄存器来指向正确的 Trap 处理入口点.
__alltraps
Section titled “__alltraps”我们在 kernel/src/trap/trap.S 中实现 Trap 上下文保存/恢复的汇编代码,分别用外部符号 __alltraps 和 __restore 标记,并将这段汇编代码中插入进来.
以下对该汇编代码进行解释:
csrrw rd, csr, rs1,作用是将来自寄存器 rs1 的值写入控制和状态寄存器(CSR),并将CSR的旧值读入寄存器 rd. 因此这里起到的是交换 sscratch 和 sp 的效果. 在这一行之前 sp 指向用户栈, sscratch 指向内核栈,现在 sp 指向内核栈, sscratch 指向用户栈.
addi sp, sp, -34*8用于预先分配栈帧(内核栈),将sp的值与-34*8相加后存入sp. 准备在内核栈上保存 Trap 上下文.
sd rs2, offset(rs1),保存 Trap 上下文的通用寄存器 x0~x31. Store Doubleword. 将 rs2 中的数据存储到地址 rs1 + offset 指向的内存位置. 此处按照 TrapContext 结构体的内存布局,基于内核栈的位置(sp所指地址)来从低地址到高地址分别按顺序放置 x0~x31 这些通用寄存器.
最后是 sstatus 和 sepc . 通用寄存器 xn 应该被保存在地址区间 [sp+8n,sp+8(n+1)) .
mv a0, sp 让寄存器 a0 指向内核栈的栈指针,即保存的 Trap 上下文的地址, 因为随后要调用 trap_handler 进行 Trap 处理. 第一个参数 cx 由调用规范,从 a0 中获取. Trap 处理函数 trap_handler 需要 Trap 上下文,因为寄存器的值可能被修改.
__restore
Section titled “__restore”当 trap_handler 返回之后会从调用 trap_handler 的下一条指令开始执行,也就是从栈上的 Trap 上下文恢复的 __restore :
__alltraps和__restore作为对应操作,其思路完全相反. 在应用程序执行流状态被还原之后,使用 sret 指令回到 U 特权级,继续运行应用程序执行流.
Trap 处理的总体流程如下:首先通过 __alltraps 将 Trap 上下文保存在内核栈上,然后跳转到使用 Rust 编写的 trap_handler 函数 完成 Trap 分发及处理. 当 trap_handler 返回之后,使用 __restore 从保存在内核栈上的 Trap 上下文恢复寄存器. 最后通过一条 sret 指令回到应用程序执行.
trap_handler
Section titled “trap_handler”返回值为 &mut TrapContext 将传入的 cx 原样返回,因此在 __restore 的时候, a0 在调用 trap_handler 前后并没有发生变化,仍然指向分配 Trap 上下文之后的内核栈栈顶.
我们可根据 scause 寄存器所保存的 Trap 的原因进行分发处理. 需要引入 RISC-V 库.
sepc 寄存器存储的是 Trap 回来之后默认会执行的下一条指令的地址,我们此处让它增加 ecall 指令的码长,也即 4 字节. 这样在 __restore 的时候 sepc 在恢复之后就会指向 ecall 的下一条指令,并在 sret 之后从那里开始执行.
用来保存系统调用返回值的 a0 寄存器也会同样发生变化. 我们从 Trap 上下文取出作为 syscall ID 的 a7 和系统调用的三个参数 a0~a2 传给 syscall 函数并获取返回值.
我们还处理应用程序出现访存错误和非法指令错误的情形. 此时需要打印错误信息,并调用 run_next_app 直接运行下一个应用程序.
系统调用 syscall
Section titled “系统调用 syscall”syscall 函数并不会实际处理系统调用,只是会根据 syscall ID 分发到具体的处理函数.
执行应用程序
Section titled “执行应用程序”在运行应用程序之前要完成如下这些工作:
- 跳转到应用程序入口点
0x80400000. - 将使用的栈切换到用户栈.
- 在
__alltraps时我们要求sscratch指向内核栈,这个也需要在此时完成. - 从 S 特权级切换到 U 特权级.
它们可以通过复用 __restore 的代码更容易的实现. 在内核栈上压入一个相应构造的 Trap 上下文,再 __restore ,就能让这些寄存器到达我们希望的状态.
由此实现.