RCore-邓氏鱼

BatchOS总体结构

背景知识

特权级设计

提供服务

为了让应用程序获得操作系统的函数服务,采用传统的函数调用方式(即通常的 call 和 ret 指令或指令组合)将会直接绕过硬件的特权级保护检查。为了解决这个问题, RISC-V 提供了新的机器指令:执行环境调用指令(Execution Environment Call,简称 ecall )和一类执行环境返回(Execution Environment Return,简称 eret )指令。

  • ecall 具有用户态到内核态的执行环境切换能力的函数调用指令;
  • sret :具有内核态到用户态的执行环境切换能力的函数返回指令。

../_images/PrivilegeStack.png

  • SEE stands for ‘Supervisor Execution Environment’
    bootloader RustSBI就是一个SEE。

异常

执行环境的另一种功能是对上层软件的执行进行监控管理。监控管理可以理解为,当上层软件执行的时候出现了一些异常或特殊情况,导致需要用到执行环境中提供的功能,因此需要暂停上层软件的执行,转而运行执行环境的代码。由于上层软件和执行环境被设计为运行在不同的特权级,这个过程也往往(而 不一定 )伴随着 CPU 的 特权级切换 。当执行环境的代码运行结束后,我们需要回到上层软件暂停的位置继续执行。在 RISC-V 架构中,这种与常规控制流(顺序、循环、分支、函数调用)不同的 异常控制流 (ECF, Exception Control Flow) 被称为 异常(Exception) ,是 RISC-V 语境下的 Trap 种类之一

M 模式软件 SEE 和 S 模式的内核之间的接口被称为 监督模式二进制接口 (Supervisor Binary Interface, SBI),而内核和 U 模式的应用程序之间的接口被称为 应用程序二进制接口 (Application Binary Interface, ABI),当然它有一个更加通俗的名字—— 系统调用 (syscall, System Call) 。

../_images/EnvironmentCallFlow.png

事实上 M/S/U 三个特权级的软件可分别由不同的编程语言实现。

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 Store/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 Store/AMO page fault

陷入

其中 断点 (Breakpoint) 和 执行环境调用 (Environment call) 两种异常(为了与其他非有意为之的异常区分,会把这种有意为之的指令称为 陷入 或 trap 类指令,此处的陷入为操作系统中传统概念)是通过在上层软件中执行一条特定的指令触发的:

  • 执行 ebreak 这条指令之后就会触发断点陷入异常;
  • 而执行 ecall 这条指令时候则会随着 CPU 当前所处特权级而触发不同的异常。从表中可以看出,当 CPU 分别处于 M/S/U 三种特权级时执行 ecall 这条指令会触发三种异常(分别参考上表 Exception Code 为 11/9/8 对应的行)。
    • ecall为一种很特殊的陷入类指令,实现不同特权级切换的接口

一般异常

其他的异常则一般是在执行某一条指令的时候发生了某种错误(如除零、无效地址访问、无效指令等),或处理器认为处于当前特权级下执行的当前指令是高特权级指令或会访问不应该访问的高特权级的资源(可能危害系统)。

碰到这些情况,就需要将控制转交给高特权级的软件(如操作系统)来处理。当错误/异常恢复后,则可重新回到低优先级软件去执行;如果不能恢复错误/异常,那高特权级软件可以杀死和清除低特权级软件,避免破坏整个执行环境。

特权指令

而每个特权级都对应一些特殊指令和 控制状态寄存器 (CSR, Control and Status Register) ,来控制该特权级的某些行为并描述其状态。当然特权指令不仅具有读写 CSR 的指令,还有其他功能的特权指令。

低特权级状态的处理器试图执行高特权级的指令,一般会直接终止该程序。

在 RISC-V 中,会有两类属于高特权级 S 模式的特权指令:

  • 指令本身属于高特权级的指令,如 sret 指令(表示从 S 模式返回到 U 模式)。
  • 指令访问了 S模式特权级下才能访问的寄存器 或内存,如表示S模式系统状态的 控制状态寄存器sstatus 等。

实现

重要细节

Rust 编译器如何保证正确性
Rust 的内联汇编通过 约束声明 明确了哪些输入必须在执行之前准备好,asm! 宏中的约束确保:

  1. in 的寄存器准备:
  • 所有通过 in(…) 指定的寄存器值会在汇编代码执行前设置到位。
  1. 顺序性:
  • 由于 ecall 是一个对外部环境(内核)的显式依赖指令,编译器会严格按照输入依赖关系安排指令顺序。
  • 即,in 操作的赋值会始终出现在 ecall 指令之前。
    这种机制确保即便参数在语法上“写在”汇编指令之后,最终生成的机器代码仍是正确的。
    有些时候不必将变量绑定到固定的寄存器,此时 asm! 宏可以自动完成寄存器分配。某些汇编代码段还会带来一些编译器无法预知的副作用,这种情况下需要在 asm! 中通过 options 告知编译器这些可能的副作用,这样可以帮助编译器在避免出错更加高效分配寄存器。事实上, asm! 宏远比我们这里介绍的更加强大易用,详情参考 Rust 相关 RFC 文档 1 。

胖指针
注意 sys_write 使用一个 &[u8] 切片类型来描述缓冲区,这是一个 胖指针 (Fat Pointer),里面既包含缓冲区的起始地址,还 包含缓冲区的长度。我们可以分别通过 as_ptr 和 len 方法取出它们并独立地作为实际的系统调用参数。

怎么将应用程序加载

在批处理操作系统中,每当一个应用执行完毕,我们都需要将下一个要执行的应用的代码和数据加载到内存。在操作系统和应用程序需要被放置到同一个可执行文件的前提下,设计一种尽量简洁的应用放置和加载方式,使得操作系统容易找到应用被放置到的位置,从而在批处理操作系统和应用程序之间建立起联系的纽带。具体而言,应用放置采用“静态绑定”的方式,而操作系统加载应用则采用“动态加载”的方式:

  • 静态绑定:通过一定的编程技巧,把多个应用程序代码和批处理操作系统代码“绑定”在一起。
  • 动态加载:基于静态编码留下的“绑定”信息,操作系统可以找到每个应用程序文件二进制代码的起始地址和长度,并能加载到内存中运行。

注意我们之前写的应用程序的链接脚本:

1
BASE_ADDRESS = 0x80400000;

使得应用程序的二进制文件中,代码、数据等地址都是从0x80400000开始排布(在应用程序的视角里)。在操作系统的视角里,应用程序在加载之前的地址不一定是0x8040000,需要操作系统将其放在实际上的0x80400000处,这就是加载。具体实现看代码。

RefCell
RefCell和内部可变性模式
Cell和RefCell

代码

overview

较三叶虫,新加入了处理 trap 、syscall 、批处理程序的代码。重点关注上下文切换、内核栈、用户栈等之间的关系。

stack

kernel stack

内核栈,目前的大小是两页。

1
2
3
4
5
6
7
pub fn push_context(&self, cx: TrapContext) -> &'static mut TrapContext {
let cx_ptr = (self.get_sp() - core::mem::size_of::<TrapContext>()) as *mut TrapContext;
unsafe {
*cx_ptr = cx;
}
unsafe { cx_ptr.as_mut().unwrap() }
}

这个是结构 KernelStack对外提供的方法。在将 TrapContext压入栈时,栈从 sp 向下增长,然后返回推入后栈顶位置。

user stack

用户栈

所以两个栈是放在哪里?

trap

初始化时,需要先设置stvec,使其指向 S模式 下,发生 trap 应该跳转到的位置:__alltraps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
__alltraps:
# 一般 sscratch 作为临时寄存器来保存上下文信息。
# 这里 trap 都是从用户空间调用,为了陷入内核空间
# 这一步实现了从用户栈到内核栈的切换
csrrw sp, sscratch, sp
# now sp->kernel stack, sscratch->user stack


# allocate a TrapContext on kernel stack
addi sp, sp, -34*8

# 接下根据 sp 的位置,将陷入 trap 前的上下文都保存
# 全部放在内核栈中
# save general-purpose registers
sd x1, 1*8(sp)
# skip sp(x2), we will save it later
sd x3, 3*8(sp)
# skip tp(x4), application does not use it
# save x5~x31
.set n, 5
.rept 27
SAVE_GP %n
.set n, n+1
.endr

# 读出 sstatus sepc 的值并放在内核栈中
# we can use t0/t1/t2 freely, because they were saved on kernel stack
csrr t0, sstatus
csrr t1, sepc
sd t0, 32*8(sp)
sd t1, 33*8(sp)

# 将用户栈的位置保存在内核栈上
# read user stack from sscratch and save it on the kernel stack
csrr t2, sscratch
sd t2, 2*8(sp)

# 调用 trap_handler
# set input argument of trap_handler(cx: &mut TrapContext)
mv a0, sp
call trap_handler

可以看到__alltraps实现了在进入trap_handler前的上下文切换。(现在TrapContext是放在内核空间的)。 trap_handler则利用TrapContext,实现 trap 的分发:

  • 应用内的系统调用:cx中包含原来用户程序系统调用传入的参数、系统调用号。系统调用的返回值也是写在cx中。
    • 在用户空间中的通过 ecall 调用
  • 当前应用异常:需要杀掉并执行下一个程序

与之对应的是__restore,实现了从内核空间切换回用户空间的功能。在trap_handler返回后,执行下一条指令就是__restore,即实现回到用户态。(注意在run_next_app中会直接跳到__restore

Batch

前面的可以看作 batch system 的基础设施。现在可以研究下 batch 中,app 是怎么切换的,切换时发生了什么。

AppManager会记录每个应用的起始位置,总应用数量和当前应用。可以用于加载程序等。加载程序时,会通过 slice 来将应用放到 APP_BASE_ADDRESS,目前是0x80400000。一个比较 tricky 的地方:我们怎么知道每个应用的起始位置?

1
2
3
4
5
6
7
8
_num_app:
.quad 5
.quad app_0_start
.quad app_1_start
.quad app_2_start
.quad app_3_start
.quad app_4_start
.quad app_4_end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
lazy_static! {
static ref APP_MANAGER: UPSafeCell<AppManager> = unsafe {
UPSafeCell::new({
extern "C" {
fn _num_app();
}
let num_app_ptr = _num_app as usize as *const usize;
let num_app = num_app_ptr.read_volatile();
let mut app_start: [usize; MAX_APP_NUM + 1] = [0; MAX_APP_NUM + 1];
let app_start_raw: &[usize] =
core::slice::from_raw_parts(num_app_ptr.add(1), num_app + 1);
app_start[..=num_app].copy_from_slice(app_start_raw);
AppManager {
num_app,
current_app: 0,
app_start,
}
})
};
}

汇编代码应该是由build.rs生成的。在实例化 APPManager时,我们通过从汇编代码中提取信息,获取每个应用的起始位置。

batch 中就是主要通过AppManager来实现应用的切换。在切换时,调用run_next_app

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pub fn run_next_app() -> ! {
let mut app_manager = APP_MANAGER.exclusive_access();
let current_app = app_manager.get_current_app();
unsafe {
app_manager.load_app(current_app);
}
app_manager.move_to_next_app();
drop(app_manager);
// before this we have to drop local variables related to resources manually
// and release the resources
extern "C" {
fn __restore(cx_addr: usize);
}
unsafe {
__restore(KERNEL_STACK.push_context(TrapContext::app_init_context(
APP_BASE_ADDRESS,
USER_STACK.get_sp(),
)) as *const _ as usize);
}
panic!("Unreachable in batch::run_current_app!");
}

我们在内核态,加载了下一个 app 后(拷贝代码、压入为启动程序而构造的上下文app_init_context),调用了__restore来回到用户态。

何时切换:app 在结束时(也可能是被内核kill时)会调用 sys_exit来执行下一个 app。

以上为批处理系统的主要功能。更细节的内容可以看书。

在看 rCore-Tutorial 之前还是自己看一遍代码、简单梳理一下、研究下一些 tricky 的代码比较好。带着问题看书效率更高!


RCore-邓氏鱼
https://pactheman123.github.io/2025/01/18/RCore-邓氏鱼/
作者
Xiaopac
发布于
2025年1月18日
许可协议