Skip to content

Latest commit

 

History

History
208 lines (193 loc) · 9.66 KB

20220823-kpti.md

File metadata and controls

208 lines (193 loc) · 9.66 KB

切换不同地址空间上的任务

经过总结,切换控制流有 3 种形式,开销从小到大分别是:

  1. 函数调用(无栈协程)
  2. 线程切换(相同地址空间上下文切换)
  3. 进程切换(不同地址空间上下文切换)

内核将用不同的流程应对三种切换。

  1. 函数调用设置好参数直接 call,如果是协程,将在挂起点返回;
  2. 线程控制流支持抢断,因此需要保存通用寄存器;

    为什么必须在汇编里保存/恢复 sstatussepc?我忽然不那么确定了。

  3. 进程切换除了保存通用寄存器外还需要切换地址空间;

    本质上说,切换地址空间和切换进程控制流并不耦合;但如果地址空间都变了,就切换个协程,虽然理论上是可能的,但实在没这个必要;

这里还有个特殊情况:支持抢占的协程。如果想要达到这类任务的极限性能,需要另一个抢占协程执行器。这就是另一个话题了。

本文尝试描述一种中转代理机制,将是否切换地址空间的切换在内核上统一起来。

切换地址空间和不切换最大的区别其实不在于多几条 csr 指令操作了一下根页表,而在于切换地址空间这个操作本身必须发生在两个地址空间的公共部分。这给代码带来了极大的不便:

  1. 保存/恢复通用寄存器的代码必须被两个地址空间映射;
  2. 保存寄存器的位置/要恢复的寄存器的值必须在两个地址空间映射;

为了获得起码的可用性,这个要在两个地址空间映射的位置不可能是它的物理位置,只能是一个正常内核和用户程序都不可能碰到的位置,也就是虚地址空间高地址的高处。所以上面的代码必须是位置无关(PIC)的,并且这里的代码不可能太复杂,这意味着我们不可能把整个调度器都放上去(不然为什么不干脆把整个内核映射到用户空间去呢)。所以我得出一个必然的结论:跨地址空间的切换流程必须独立于正常流程以外

接下来的设计就是顺理成章的了:

  1. 每个硬件线程(HART)有一个内部调度器,负责选择什么任务可以占用自己的计算时间;
  2. 如果调度器选中的任务不需要切换地址空间,直接切换上去;
  3. 如果调度器发现选中的任务需要切换地址空间,将其上下文拷贝到公共地址空间上,然后跳到公共地址空间上的切换函数。切换函数负责按照上下文的描述切换地址空间再切换任务;
  4. 切换函数同时将 stvec 也改到公共地址空间上的某处,陷入时切换通用寄存器后先恢复地址空间再返回到调度器;
  5. 由于每个 HART 都需要一个同时具有 XWR 权限的页,它们没法映射同一个页;所以它们应该在各自初始化自己的公共页,并把通用寄存器切换函数拷贝上去;这个操作可以在第一个这样的任务调度到时懒加载;
  6. 无论是懒加载还是跳上去,都可以封装在进程切换函数内部,调度器没必要积极地处理这种复杂性;
  7. 为了尽量减少需要拷贝到公共地址空间的数据,可以将公共地址空间上的切换函数视作一个线程。这样大部分通用寄存器都被保存在调度器自己的地址空间上了,切换函数没必要全部保存;
  8. 既然跨地址空间的切换函数需要修改 stvec,那么不跨的也需要;虽然实际上调度器知道 stvec 是不是需要重置,但重置一下实在是太快了,以至于调度器没必要告诉切换函数。重置甚至快于一次判断,这是一个可以接受的额外开销;

分割线以下是旧的版本,很有趣所以保留了。


# 内核地址隔离

原理 先切换到中转地址空间上的单页表微型内核,再从这个微型内核引导用户态。

设计

本文描述了一种设计,目的是实现单页表内核和双页表内核的一致性。位于不同地址空间的任务被传递给一个公共地址空间(称为中转地址空间)上的任务执行器(称为异世界执行器),由这个执行器中转执行。执行器在主内核上表现为一个屏蔽中断的内核线程(即一个正常的任务)。

执行器、微型内核、内核线程,是同一个结构从三个不同角度的描述。执行器是功能上的描述,一个任务上下文被传递给它,由它代理执行;微型内核是结构上的描述,它有内聚的、静态的分段:代码段、(共享)数据段和栈;内核线程是它在主内核中的表现,它可以用一个上下文描述,并通过这个上下文进行调度。

执行器内存布局

为了方便映射到多个地址空间中,形成中转地址空间,这个内存布局应该被放进一个物理页里。如果放不下,考虑多个连续页或一个大页。

内容
共享用户上下文
execute
trap
中转内核入口
中转内核栈

执行器编译

中转内核在编译时是主内核的一部分。主内核应该在自己的链接脚本上留一个页,然后将执行器代码段直接链接到页中间的一个位置,保证页开头到执行器控制流入口之间足够容纳上表描述的内容。

执行器初始化

RustForeignExecutor 为例,执行器初始化时,主内核拷贝 executetrap 到执行器页代码段,并初始化一个执行器上下文,内容包括:

  • sp = 执行器栈顶(如果中转页在最高虚页上,是 0)
  • a0 = 执行器页基地址/共享用户上下文基地址
  • a1 = execute 地址
  • a2 = trap 地址
  • sepc = 控制流入口
  • sstatus = 特权态屏蔽中断

以上所有地址为中转地址空间的虚地址。

执行用户程序

  1. 主内核填写共享用户上下文,内容包括基本任务上下文和用户根页表;
  2. 主内核执行中转内核上下文,切换到中转内核线程
  3. 中转内核切换上下文 CSR:satpstvecsscratch
    • 初次进入时还要将 a0a1a2 保存在栈上
    • 完成时到达用户空间
  4. 执行共享用户上下文

处理用户陷入

  1. 用户陷入到中转内核的 trap
    • 用户上下文已被保存在共享上下文区域
  2. 中转内核切换上下文 CSR:satpstvecsscratch
    • 完成时回到内核空间
  3. 模拟中断操作设置 sepc
  4. 直接跳转内核的 stvec 回到主内核

实现

定位上下文切换例程

上下文切换例程是手写内联汇编实现的,可以人工保证它们可重定位(不用 la 就行了),以便拷贝到任意位置使用。 可以通过找结尾指令在运行时定位这些函数,并拷贝到其他位置,以 execute 为例:

/// 通过寻找结尾的指令在运行时定位一个函数。
unsafe fn locate_function<const N: usize>(entry: usize, key: [u16; N]) -> &'static [u8] {
    use core::{mem::size_of, slice::from_raw_parts};
    let entry = entry as *const u16;
    for len in 1.. {
        let ptr = entry.add(len);
        if key == from_raw_parts(ptr, key.len()) {
            return from_raw_parts(entry.cast(), size_of::<u16>() * (len + key.len()));
        }
    }
    unreachable!()
}

/// 运行时定位 `locate` 函数。
#[inline]
fn locate_execute() -> &'static [u8] {
    // sret + unimp
    unsafe { locate_function(execute as _, [0x0073, 0x1020, 0x0000]) }
}

中转内核布局

直接用一个 #[repr(C)] 结构体定义中转内核布局:

/// 中转内核布局。
#[repr(C)]
pub struct TransitKernel {
    /// 共享任务上下文。
    pub shared_context: ForeignContext,
    /// `execute` 的拷贝。
    ///
    /// 512 Bytes,4 字节对齐。
    pub execute_copy: [u32; 128],
    /// `trap` 的拷贝。
    ///
    /// 512 Bytes,4 字节对齐。
    pub trap_copy: [u32; 128],
    // 中转内核控制流,直接链接进来。
    // pub main: [u32; 512],
    // 页上其余部分用作栈,运行时设置。
    // pub stack: [u8],
}

/// 位于不同地址空间的任务上下文。
#[repr(C)]
pub struct ForeignContext {
    /// `satp` 寄存器值指定地址空间。
    pub satp: usize,
    /// 正常的任务上下文。
    pub context: Context,
}

/// 中转内核控制流。
#[inline(never)]
#[link_section = ".transit.entry"]
pub extern "C" fn transit_main(
    _ctx: &'static mut ForeignContext,
    _execute_copy: unsafe extern "C" fn(),
    _trap_copy: unsafe extern "C" fn(),
) {
    todo!()
}

executetrap 会在运行时定位并拷贝到结构体。

执行器的运行时初始化

定位 executetrap,并拷贝到执行器页。

pub unsafe fn init(&mut self) {
    use core::mem::size_of_val;

    // sret + unimp
    let execute = locate_function(crate::execute as _, [0x0073, 0x1020, 0x0000]);
    assert!(
        size_of_val(&self.execute_copy) >= execute.len(),
        "`execute_copy` is too small in transit kernel"
    );
    self.execute_copy
        .as_mut_ptr()
        .cast::<u8>()
        .copy_from_nonoverlapping(execute.as_ptr(), execute.len());

    // ret + unimp
    let trap = locate_function(crate::trap as _, [0x8082, 0x0000]);
    assert!(
        size_of_val(&self.trap_copy) >= trap.len(),
        "`trap_copy` is too small in transit kernel"
    );
    self.trap_copy
        .as_mut_ptr()
        .cast::<u8>()
        .copy_from_nonoverlapping(trap.as_ptr(), trap.len());
}