最简宏内核模式内核构建

宏内核 Monolithic Kernel

宏内核 vs Unikernel

alt text

从Unikernel到宏内核

alt text

宏内核模式下运行用户程序

和之前Unikernel类似的,经过一系列引导,内核构建起来了,开始执行main函数。不同的是,Unikernel中,main函数直接就是运行在内核态的一个应用程序,没有用户态的说法;而现在在宏内核中,这个main函数是一个内核态的“应用准备程序”,它会为对应的应用准备资源,随后基于spawn流程创建一个应用进程,并进入用户态执行该应用。

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
fn main() {
// A new address space for user app.
// 把高端处的内核空间映射出来
let mut uspace = axmm::new_user_aspace().unwrap();

// Load user app binary file into address space.
// 把对应的img/bin文件(就是直接一堆指令和数据)放到以APP_ENTRY为起始虚拟地址的地方
if let Err(e) = load_user_app("/sbin/origin", &mut uspace) {
panic!("Cannot load app! {:?}", e);
}

// Init user stack.
let ustack_top = init_user_stack(&mut uspace, true).unwrap();
ax_println!("New user address space: {:#x?}", uspace);

// Let's kick off the user process.
let user_task = task::spawn_user_task(
Arc::new(Mutex::new(uspace)),
// 用户空间上下文 目前主要保存指令指针和栈指针和sstatus
// TrapFrame
UspaceContext::new(APP_ENTRY.into(), ustack_top),
);

// Wait for user process to exit ...
let exit_code = user_task.join();
ax_println!("monolithic kernel exit [{:?}] normally!", exit_code);
}

资源准备

具体而言:

  1. 为应用创建单独的地址空间和页表(页表涵盖的范围大于等于可见范围,areas中的地址范围也不一定在可见范围中)。并将内核地址空间的映射复制到该空间
    1
    2
    3
    4
    5
    pub struct AddrSpace {
    va_range: VirtAddrRange, // 可见范围 [0, 0x40_0000_0000]
    areas: MemorySet<Backend>, // 多个已alloc的虚拟地址段
    pt: PageTable,
    }
    alt text
  2. 分配APP_ENTRY为起始虚拟地址的一页,加载app的bin文件。
  • 分配就是基于global_allocator
  • 注意此时仍是基于内核页表,所以要访问对应的物理页,注意先转换为基于内核页表的虚拟地址
  1. 分配用户地址空间可见部分的最高的一段地址,作为用户栈

  2. 传入地址空间和用户上下文(创建一个进程的必要资源)给spawn_user_task

进程设置

对于各类内核模式,调度子系统机制是基本一致的,
调度仅关心Task中与调度相关的属性,而不关心资源属性。我们在从Unikernel模式扩展到其他模式的时候,只需要在原来调度系统的基础上,增加对资源的管控即可。
Unikernel模式下,资源都是全局的,Task几乎不包含资源属性;宏内核模式下,以进程为单位管理和隔离资源,Task表示真正意义上的进程,页表、栈等都独属于该Task,且执行调度时都是内核态,要想运行用户态程序,还要在运行前做特权模式转换。

具体而言:

  1. 在创建task时设置satp和task_ext成员
  2. 单独设计一个闭包作为进程的入口函数,执行特权模式转换
  3. 设置调度时上下文切换的前一步为页表切换 (搜索#[cfg(feature = "uspace")]可发现该增量改动)
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
pub fn spawn_user_task(aspace: Arc<Mutex<AddrSpace>>, uctx: UspaceContext) -> AxTaskRef {
let mut task = TaskInner::new(
// 传入该进程的入口函数
|| {
let curr = axtask::current();
let kstack_top = curr.kernel_stack_top().unwrap();
ax_println!(
"Enter user space: entry={:#x}, ustack={:#x}, kstack={:#x}",
curr.task_ext().uctx.get_ip(),
curr.task_ext().uctx.get_sp(),
kstack_top,
);
unsafe { curr.task_ext().uctx.enter_uspace(kstack_top) };
},
"userboot".into(),
crate::KERNEL_STACK_SIZE,
);
// 设置宏内核特有的资源
// 设置该进程的用户页表(但还没启用)
task.ctx_mut()
.set_page_table_root(aspace.lock().page_table_root());
// 设置进程的拓展属性(因宏内核模式而产生)
task.init_task_ext(TaskExt::new(uctx, aspace));

// 正常调用原来的spawn函数
axtask::spawn_task(task)
}

特权模式转换

  1. 关中断
  2. 设置sscratch(此处设置为内核栈的栈顶,这个是和之后trap引导程序配合进行的)*
  3. 设置spec
  4. 内核栈顶增长一个TrapFrame的区域,存放部分寄存器(实际仅存放S态的tp和gp寄存器)*
  5. 设置sp为当前进程的进程控制块中专门存放用户态TrapFrame的地方
  6. 将提前设置好的用户态TrapFrame里的值放到相应寄存器中
  7. 将用户态TrapFrame里的sp值(用户栈地址)设置为新的sp
  8. sret
    sret原始用法起始是用于中断/异常陷入s态后,返回中断/异常前状态用的,硬件会根据sepc设置pc,根据sstatus决定要不要恢复中断、返回S态还是U态等。现在其实就是设置好这些寄存器的值,通过伪造一个中断结束来进入U态。

注:

  1. TrapFrame是一个保存33个64位寄存器的结构,用于为上下文保存和恢复提供统一的规范(结构中的第几个成员对应哪个寄存器)
  2. “*” 表示这个过程是与后续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
2
3
4
5
6
7
8
9
unsafe extern "C" fn _start() -> ! {
core::arch::asm!(
"addi sp, sp, -4",
"sw a0, (sp)",
"li a7, 93",
"ecall",
options(noreturn)
)
}

当执行一个trap时,除了 timer interrupt,所有的过程都是相同的,硬件会自动完成下述过程:

  1. 如果该 trap 是一个设备中断并且 sstatus 的 SIE bit 为 0,那么不再执行下述过程
  2. 通过置零 SIE 禁用中断
  3. 将 pc 拷贝到 sepc
  4. 保存当前的特权级到 sstatus 的 SPP 字段
  5. 将 scause 设置成 trap 的原因
  6. 设置当前特权级为 supervisor
  7. 拷贝 stvec(中断服务程序的首地址)到 pc
  8. 开始执行中断服务程序(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
2
3
4
5
// oscamp/arceos/modules/axhal/src/arch/riscv/trap.rs
core::arch::global_asm!(
include_str!("trap.S"),
trapframe_size = const core::mem::size_of::<TrapFrame>(),
);

trap.S中,先关注trap_vector_base。

  1. 判断trap前属于什么特权级:因为在前面代码的支持下,只有进入用户态的程序会设置sscratch为跳至用户态时的内核栈顶地址,所以通过判断sscratch是否为0,可判断当前是否为用户态,从而决定是否需要换栈。如果当前为用户态,则将sp变为内核栈顶,sscratch变为用户栈顶。
  2. 保存上下文:之后的流程都是要进入SAVE_REGS保存当前寄存器至当前栈顶(使用TrapFrame结构),如果是用户态,就额外处理一下gp和tp寄存器(与前面内核态进入用户态流程配合)。
  3. 调用trap处理函数:设置保存的上下文的起始地址为传入参数,call处理函数。
  4. 恢复上下文。
  5. 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
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
fn riscv_trap_handler(tf: &mut TrapFrame, from_user: bool) {
let scause = scause::read();
match scause.cause() {
#[cfg(feature = "uspace")]
Trap::Exception(E::UserEnvCall) => {
tf.regs.a0 = crate::trap::handle_syscall(tf, tf.regs.a7) as usize;
tf.sepc += 4;
}
Trap::Exception(E::LoadPageFault) => handle_page_fault(tf, MappingFlags::READ, from_user),
Trap::Exception(E::StorePageFault) => handle_page_fault(tf, MappingFlags::WRITE, from_user),
Trap::Exception(E::InstructionPageFault) => {
handle_page_fault(tf, MappingFlags::EXECUTE, from_user)
}
Trap::Exception(E::Breakpoint) => handle_breakpoint(&mut tf.sepc),
Trap::Interrupt(_) => {
handle_trap!(IRQ, scause.bits());
}
_ => {
panic!(
"Unhandled trap {:?} @ {:#x}:\n{:#x?}",
scause.cause(),
tf.sepc,
tf
);
}
}
}

多页表、多任务、特权级转换

在宏内核模式下,引入了U和S间特权级转换,也引入了多页表。但需要注意的是,特权级转换、多页表、多任务三者之间没有必然的依赖关系,在不考虑实际意义的情况下,都可以作为单独的功能而存在。

  1. 多任务可以单独存在:axtask中,所有任务都运行在一个地址空间中,首先是有一个操作系统启动时产生的“主进程”,使用主进程的栈(内核栈)以及全局分配器,可以spawn出其他进程,并为每个进程提供进程控制块(包括他们各自的内核栈),进而可以独立运行,这样的问题在于搭载的应用通常就需要与内核一起编译,因为独立编译的应用通常会自行假定运行在一个单独的地址空间(如elf文件),从而多应用会导致地址冲突。
  2. 特权级转换可以单独存在:前面所述的特权级转换核心过程,其实基本没有涉及多页表和多任务。
  3. 多页表可以单独存在:考虑一个更有意义的场景,即多页表加多任务,可以实现Unikernel形态下加载运行多应用

页表及内核栈切换

  1. 页表产生:在多任务场景下,要spawn一个新的任务,一开始是在一个S态的“主进程”,由它生成一个新的页表,此时会将内核地址空间复制到该页表的高地址部分(也就是说,内核地址空间是供所有任务的内核栈一起使用的)。
  2. 进程控制块创建:将页表地址放到新创建进程的控制块中,然后将新进程放入就绪队列等待调度。这个过程中,如果需要用到栈上的一些空间,都由该主进程的栈提供。新的进程的内核栈分配也是在此处进行,所以,除了这个主进程的内核栈用的是一开始启动分配的内核栈空间,其他内核栈用的其实是全局分配器的空间。虽然此时仍是在主进程的地址空间/页表,但由于内核地址空间共享,所有分配的虚拟地址到了新的页表中仍能使用。
  3. 页表&内核栈转换:当新进程被调度,则在切换上下文之前,切换页表(写satp寄存器)。context_switch将当前上下文保存,切换为新进程中保存的上下文。上下文格式为TaskContext,与特权级切换时的TrapFrame不同,TaskContext仅包含:
    (1)s0至s11寄存器(因为根据riscv调用规范,context_switch函数调用会自动保存a和t开头的通用寄存器)
    (2)ra寄存器(实现ret时跳转到新进程)
    (3)sp寄存器(实现切换至新进程内核栈)
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
// oscamp/arceos/modules/axhal/src/arch/riscv/context.rs
impl TaskContext {
pub fn switch_to(&mut self, next_ctx: &Self) {
#[cfg(feature = "tls")]
{
self.tp = super::read_thread_pointer();
unsafe { super::write_thread_pointer(next_ctx.tp) };
}
#[cfg(feature = "uspace")]
unsafe {
if self.satp != next_ctx.satp {
super::write_page_table_root(next_ctx.satp);
}
}
unsafe {
// TODO: switch FP states
context_switch(self, next_ctx)
}
}
}

#[naked]
unsafe extern "C" fn context_switch(_current_task: &mut TaskContext, _next_task: &TaskContext) {
asm!(
"
// save old context (callee-saved registers)
STR ra, a0, 0
STR sp, a0, 1
STR s0, a0, 2
STR s1, a0, 3
STR s2, a0, 4
STR s3, a0, 5
STR s4, a0, 6
STR s5, a0, 7
STR s6, a0, 8
STR s7, a0, 9
STR s8, a0, 10
STR s9, a0, 11
STR s10, a0, 12
STR s11, a0, 13

// restore new context
LDR s11, a1, 13
LDR s10, a1, 12
LDR s9, a1, 11
LDR s8, a1, 10
LDR s7, a1, 9
LDR s6, a1, 8
LDR s5, a1, 7
LDR s4, a1, 6
LDR s3, a1, 5
LDR s2, a1, 4
LDR s1, a1, 3
LDR s0, a1, 2
LDR sp, a1, 1
LDR ra, a1, 0

ret",
options(noreturn),
)
}