宏内核 Monolithic Kernel
宏内核 vs Unikernel
从Unikernel到宏内核
宏内核模式下运行用户程序
和之前Unikernel类似的,经过一系列引导,内核构建起来了,开始执行main函数。不同的是,Unikernel中,main函数直接就是运行在内核态的一个应用程序,没有用户态的说法;而现在在宏内核中,这个main函数是一个内核态的“应用准备程序”,它会为对应的应用准备资源,随后基于spawn流程创建一个应用进程,并进入用户态执行该应用。
1 | fn main() { |
资源准备
具体而言:
- 为应用创建单独的地址空间和页表(页表涵盖的范围大于等于可见范围,areas中的地址范围也不一定在可见范围中)。并将内核地址空间的映射复制到该空间
1
2
3
4
5pub struct AddrSpace {
va_range: VirtAddrRange, // 可见范围 [0, 0x40_0000_0000]
areas: MemorySet<Backend>, // 多个已alloc的虚拟地址段
pt: PageTable,
} - 分配APP_ENTRY为起始虚拟地址的一页,加载app的bin文件。
- 分配就是基于global_allocator
- 注意此时仍是基于内核页表,所以要访问对应的物理页,注意先转换为基于内核页表的虚拟地址
分配用户地址空间可见部分的最高的一段地址,作为用户栈
传入地址空间和用户上下文(创建一个进程的必要资源)给spawn_user_task
进程设置
对于各类内核模式,调度子系统机制是基本一致的,
调度仅关心Task中与调度相关的属性,而不关心资源属性。我们在从Unikernel模式扩展到其他模式的时候,只需要在原来调度系统的基础上,增加对资源的管控即可。
Unikernel模式下,资源都是全局的,Task几乎不包含资源属性;宏内核模式下,以进程为单位管理和隔离资源,Task表示真正意义上的进程,页表、栈等都独属于该Task,且执行调度时都是内核态,要想运行用户态程序,还要在运行前做特权模式转换。
具体而言:
- 在创建task时设置satp和task_ext成员
- 单独设计一个闭包作为进程的入口函数,执行特权模式转换
- 设置调度时上下文切换的前一步为页表切换 (搜索
#[cfg(feature = "uspace")]
可发现该增量改动)
1 | pub fn spawn_user_task(aspace: Arc<Mutex<AddrSpace>>, uctx: UspaceContext) -> AxTaskRef { |
特权模式转换
- 关中断
- 设置sscratch(此处设置为内核栈的栈顶,这个是和之后trap引导程序配合进行的)*
- 设置spec
- 内核栈顶增长一个TrapFrame的区域,存放部分寄存器(实际仅存放S态的tp和gp寄存器)*
- 设置sp为当前进程的进程控制块中专门存放用户态TrapFrame的地方
- 将提前设置好的用户态TrapFrame里的值放到相应寄存器中
- 将用户态TrapFrame里的sp值(用户栈地址)设置为新的sp
- sret
sret原始用法起始是用于中断/异常陷入s态后,返回中断/异常前状态用的,硬件会根据sepc设置pc,根据sstatus决定要不要恢复中断、返回S态还是U态等。现在其实就是设置好这些寄存器的值,通过伪造一个中断结束来进入U态。
注:
- TrapFrame是一个保存33个64位寄存器的结构,用于为上下文保存和恢复提供统一的规范(结构中的第几个成员对应哪个寄存器)
- “*” 表示这个过程是与后续trap引导程序配合使用的,做法多样,只要能配合上即可
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// function of UspaceContext
pub unsafe fn enter_uspace(&self, kstack_top: VirtAddr) -> ! {
use riscv::register::{sepc, sscratch};
super::disable_irqs();
sscratch::write(kstack_top.as_usize());
// 写入用户态的sepc,即之前设置的entry
sepc::write(self.0.sepc);
// 把当前的寄存器值写入内核栈,加载UspaceContext
// Address of the top of the kernel stack after saving the trap frame.
let kernel_trap_addr = kstack_top.as_usize() - core::mem::size_of::<TrapFrame>();
// 此时的sp其实就是kstack_top,即内核栈的顶部,前面已经设置了sscratch,所以可以直接覆盖
asm!("
mv sp, {tf}
STR gp, {kernel_trap_addr}, 2
LDR gp, sp, 2
STR tp, {kernel_trap_addr}, 3
LDR tp, sp, 3
LDR t0, sp, 32
csrw sstatus, t0
POP_GENERAL_REGS
LDR sp, sp, 1
sret",
tf = in(reg) &(self.0),
kernel_trap_addr = in(reg) kernel_trap_addr,
options(noreturn),
)
}
宏内核模式下支持用户程序trap
ecall和trap简要介绍
在用户程序程序中,一种主动触发trap的方式就是ecall环境调用(被动触发有缺页异常等):
1 | unsafe extern "C" fn _start() -> ! { |
当执行一个trap时,除了 timer interrupt,所有的过程都是相同的,硬件会自动完成下述过程:
- 如果该 trap 是一个设备中断并且 sstatus 的 SIE bit 为 0,那么不再执行下述过程
- 通过置零 SIE 禁用中断
- 将 pc 拷贝到 sepc
- 保存当前的特权级到 sstatus 的 SPP 字段
- 将 scause 设置成 trap 的原因
- 设置当前特权级为 supervisor
- 拷贝 stvec(中断服务程序的首地址)到 pc
- 开始执行中断服务程序(trap引导程序)
(不会自动切换到内核栈,也不会自动切换页表)
详见:https://www.cnblogs.com/harrypotterjackson/p/17548837.html#_label1
这些s开头的寄存器,我们称之为CSR(控制与状态寄存器),主要就用于trap处理。
支持trap
汇编书写trap引导程序
在trap.rs中,trap.S中汇集了内核主要需要用到的直接汇编程序,global_asm!
宏使trap.S的所有内容在编译时被直接插入到最终的二进制文件中,一同被编译的rust代码可以通过extern "C"
的方式找到该汇编里的一些符号对应的虚拟地址。而且global_asm!
支持将 Rust 常量值注入到汇编代码中,支持Rust到汇编的数据传输。
1 | // oscamp/arceos/modules/axhal/src/arch/riscv/trap.rs |
trap.S中,先关注trap_vector_base。
- 判断trap前属于什么特权级:因为在前面代码的支持下,只有进入用户态的程序会设置sscratch为跳至用户态时的内核栈顶地址,所以通过判断sscratch是否为0,可判断当前是否为用户态,从而决定是否需要换栈。如果当前为用户态,则将sp变为内核栈顶,sscratch变为用户栈顶。
- 保存上下文:之后的流程都是要进入SAVE_REGS保存当前寄存器至当前栈顶(使用TrapFrame结构),如果是用户态,就额外处理一下gp和tp寄存器(与前面内核态进入用户态流程配合)。
- 调用trap处理函数:设置保存的上下文的起始地址为传入参数,call处理函数。
- 恢复上下文。
- sret:会根据sstatus寄存器的设置回到trap前的特权级,根据sepc寄存器的值回到trap前执行的指令。
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70// oscamp/arceos/modules/axhal/src/arch/riscv/trap.S
.macro SAVE_REGS, from_user
addi sp, sp, -{trapframe_size}
PUSH_GENERAL_REGS
csrr t0, sepc
csrr t1, sstatus
csrrw t2, sscratch, zero // save sscratch (sp) and zero it
STR t0, sp, 31 // tf.sepc
STR t1, sp, 32 // tf.sstatus
STR t2, sp, 1 // tf.regs.sp
.if \from_user == 1
LDR t0, sp, 2 // load supervisor gp
LDR t1, sp, 3 // load supervisor tp
STR gp, sp, 2 // save user gp and tp
STR tp, sp, 3
mv gp, t0
mv tp, t1
.endif
.endm
.macro RESTORE_REGS, from_user
.if \from_user == 1
LDR t1, sp, 2 // load user gp and tp
LDR t0, sp, 3
STR gp, sp, 2 // save supervisor gp
STR tp, sp, 3 // save supervisor gp and tp
mv gp, t1
mv tp, t0
addi t0, sp, {trapframe_size} // put supervisor sp to scratch
csrw sscratch, t0
.endif
LDR t0, sp, 31
LDR t1, sp, 32
csrw sepc, t0
csrw sstatus, t1
POP_GENERAL_REGS
LDR sp, sp, 1 // load sp from tf.regs.sp
.endm
.section .text
.balign 4
.global trap_vector_base
trap_vector_base:
// sscratch == 0: trap from S mode
// sscratch != 0: trap from U mode
csrrw sp, sscratch, sp // swap sscratch and sp
bnez sp, .Ltrap_entry_u
csrr sp, sscratch // put supervisor sp back
j .Ltrap_entry_s
.Ltrap_entry_s:
SAVE_REGS 0
mv a0, sp
li a1, 0
call riscv_trap_handler
RESTORE_REGS 0
sret
.Ltrap_entry_u:
SAVE_REGS 1
mv a0, sp
li a1, 1
call riscv_trap_handler
RESTORE_REGS 1
sret
设置stvec
将trap_vector_base的虚拟地址设置到stvec寄存器中,硬件就会支持在trap时跳转到该引导程序。
书写trap处理函数
通过查看代码中对stvec的设置和对应的汇编代码,可以发现就对应了riscv_trap_hanlder函数。其中E::UserEnvCall对应的便是ecall产生的调用,此处注意执行完后要把sepc寄存器后移一个命令执行,防止死循环。如果就是要重新执行触发trap的命令,那就不用加。
1 | fn riscv_trap_handler(tf: &mut TrapFrame, from_user: bool) { |
多页表、多任务、特权级转换
在宏内核模式下,引入了U和S间特权级转换,也引入了多页表。但需要注意的是,特权级转换、多页表、多任务三者之间没有必然的依赖关系,在不考虑实际意义的情况下,都可以作为单独的功能而存在。
- 多任务可以单独存在:axtask中,所有任务都运行在一个地址空间中,首先是有一个操作系统启动时产生的“主进程”,使用主进程的栈(内核栈)以及全局分配器,可以spawn出其他进程,并为每个进程提供进程控制块(包括他们各自的内核栈),进而可以独立运行,这样的问题在于搭载的应用通常就需要与内核一起编译,因为独立编译的应用通常会自行假定运行在一个单独的地址空间(如elf文件),从而多应用会导致地址冲突。
- 特权级转换可以单独存在:前面所述的特权级转换核心过程,其实基本没有涉及多页表和多任务。
- 多页表可以单独存在:考虑一个更有意义的场景,即多页表加多任务,可以实现Unikernel形态下加载运行多应用
页表及内核栈切换
- 页表产生:在多任务场景下,要spawn一个新的任务,一开始是在一个S态的“主进程”,由它生成一个新的页表,此时会将内核地址空间复制到该页表的高地址部分(也就是说,内核地址空间是供所有任务的内核栈一起使用的)。
- 进程控制块创建:将页表地址放到新创建进程的控制块中,然后将新进程放入就绪队列等待调度。这个过程中,如果需要用到栈上的一些空间,都由该主进程的栈提供。新的进程的内核栈分配也是在此处进行,所以,除了这个主进程的内核栈用的是一开始启动分配的内核栈空间,其他内核栈用的其实是全局分配器的空间。虽然此时仍是在主进程的地址空间/页表,但由于内核地址空间共享,所有分配的虚拟地址到了新的页表中仍能使用。
- 页表&内核栈转换:当新进程被调度,则在切换上下文之前,切换页表(写satp寄存器)。context_switch将当前上下文保存,切换为新进程中保存的上下文。上下文格式为TaskContext,与特权级切换时的TrapFrame不同,TaskContext仅包含:
(1)s0至s11寄存器(因为根据riscv调用规范,context_switch函数调用会自动保存a和t开头的通用寄存器)
(2)ra寄存器(实现ret时跳转到新进程)
(3)sp寄存器(实现切换至新进程内核栈)
1 | // oscamp/arceos/modules/axhal/src/arch/riscv/context.rs |