[rCore学习笔记 018]实现特权级的切换

写在前面

本随笔是非常菜的菜鸡写的。如有问题请及时提出。

可以联系:1160712160@qq.com

GitHhub:https://github.com/WindDevil (目前啥也没有

本节内容

因为risc-v存在硬件特权级机制,我们又要实现一个可以使得应用程序工作在用户级,使得操作系统工作在特权级.原因是要保证用户态的应用程序不能随意使用内核态的指令,要使用内核态的指令就必须通过操作系统来执行,这样有了操作系统的控制和检查,程序不会因为应用程序的问题导致整个操作系统都运行错误.

RISC-V特权级切换

为什么要实现特权级切换

回到我们之前提到的那张图:

可以看到,对应地SEESupervisor Execution Enviroment ,顾名思义是在Machine机器层构建的 特权级应用运行环境 .

我们通过Rust-SBI建立了一个SBI Supervisor Binary Interface ,顾名思义是 特权级二进制接口 , 把 机器层 的一些指令 抽象 化了,我们在实现 特权级应用 时调用这个接口来实现,这样就可以使得特权级应用可以方便地 移植到 实现不同拓展指令集的RISC-V架构的 处理器 上.

上一大章我们实现了一个OS Operating System ,也就是 操作系统 , 它实际上就是一个调用了SBI特权级应用 .

那么照葫芦画瓢,现在我们需要实现一个AEE Application Execution Environment , 顾名思义是 应用运行环境 , 并且实现ABI Application Binary Interface 应用程序二进制接口, 使得用户层的应用可以通过调用ABI 从而可以移植到任何 操作系统 上.

上一节我们实现了一个应用程序加载器,重新回味之前对于 特权级机制 的描述:
首先,操作系统需要提供相应的功能代码,能在执行 sret 前准备和恢复用户态执行应用程序的上下文。其次,在应用程序调用 ecall 指令后,能够检查应用程序的系统调用参数,确保参数不会破坏操作系统。

那么把上一大章实现的操作系统,上一节实现的批处理功能和上上节实现的应用程序组装起来的正是 应用程序的上下文保存,特权级切换,监控应用程序运行 的功能.

具体的实现方法分为以下几部分,直接从官方文档求取过来:

  • 当启动应用程序的时候,需要初始化应用程序的用户态上下文,并能切换到用户态执行应用程序;
  • 当应用程序发起系统调用(即发出 Trap)之后,需要到批处理操作系统中进行处理;
  • 当应用程序执行出错的时候,需要到批处理操作系统中杀死该应用并加载运行下一个应用;
  • 当应用程序执行结束的时候,需要到批处理操作系统中加载运行下一个应用(实际上也是通过系统调用 sys_exit 来实现的)。

这些处理都涉及到特权级切换,因此需要应用程序、操作系统和硬件一起协同,完成特权级切换机制。

特权级切换相关的控制状态寄存器

当从一般意义上讨论 RISC-V 架构的 Trap 机制时,通常需要注意两点:

  • 在触发 Trap 之前 CPU 运行在哪个特权级;
  • CPU 需要切换到哪个特权级来处理该 Trap ,并在处理完成之后返回原特权级。

但是实际上我们之前也提到过,关于(Hypervisor, H)模式的特权规范还没完全制定好,而M特权级的机制细节则是作为可选内容在 附录 C:深入机器模式:RustSBI 中讲解,因为我们已经引用了Rust-SBI,所以只需要关心U特权级和S特权级的切换.
当 CPU 在用户态特权级( RISC-V 的 U 模式)运行应用程序,执行到 Trap,切换到内核态特权级( RISC-V的S 模式),批处理操作系统的对应代码响应 Trap,并执行系统调用服务,处理完毕后,从内核态返回到用户态应用程序继续执行后续指令。

官方文档对RISC-V架构的Trap特性做了详细介绍,大概就是 低优先级 的应用不会触发 高优先级Trap:
在 RISC-V 架构中,关于 Trap 有一条重要的规则:在 Trap 前的特权级不会高于 Trap 后的特权级。因此如果触发 Trap 之后切换到 S 特权级(下称 Trap 到 S),说明 Trap 发生之前 CPU 只能运行在 S/U 特权级。但无论如何,只要是 Trap 到 S 特权级,操作系统就会使用 S 特权级中与 Trap 相关的 控制状态寄存器 (CSR, Control and Status Register) 来辅助 Trap 处理。我们在编写运行在 S 特权级的批处理操作系统中的 Trap 处理相关代码的时候,就需要使用如下所示的 S 模式的 CSR 寄存器。

这段还提到了关于CSR的内容,CSR Control and Status Register ,顾名思义 控制和状态寄存器 , 储存了当前Trap的状态信息,并且可以控制Trap代码的部分功能.这些寄存器在这里有详尽的描述.
注意 sstatus 是 S 特权级最重要的 CSR,可以从多个方面控制 S 特权级的 CPU 行为和执行状态。

特权级切换

回想之前学到的关于触发异常Trap的情况:

  1. 其一是用户态软件为获得内核态操作系统的服务功能而执行特殊指令
    1. 指令本身属于高特权级的指令,如 sret 指令(表示从 S 模式返回到 U 模式)
    2. 指令访问了 S模式特权级下才能访问的寄存器 或内存,如表示S模式系统状态的 控制状态寄存器 sstatus 等
  2. 其二是在执行某条指令期间产生了错误(如执行了用户态不允许执行的指令或者其他错误)并被 CPU 检测到

对于 寄存器 级,官方文档有了更详细的说法,当 CPU 执行完一条指令(如 ecall )并准备从用户特权级 陷入( Trap )到 S 特权级的时候,硬件会自动完成如下这些事情:

  • sstatus 的 SPP 字段会被修改为 CPU 当前的特权级(U/S)。
  • sepc 会被修改为 Trap 处理完成后默认会执行的下一条指令的地址。
  • scause/stval 分别会被修改成这次 Trap 的原因以及相关的附加信息。
  • CPU 会跳转到 stvec 所设置的 Trap 处理入口地址,并将当前特权级设置为 S ,然后从Trap 处理入口地址处开始执行。

在 RV64 中, stvec 是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:

  • MODE 位于 [1:0],长度为 2 bits;
  • BASE 位于 [63:2],长度为 62 bits。

当 MODE 字段为 0 的时候, stvec 被设置为 Direct 模式,此时进入 S 模式的 Trap 无论原因如何,处理 Trap 的入口地址都是 BASE<<2 , CPU 会跳转到这个地方进行异常处理。本书中我们只会将 stvec 设置为 Direct 模式。而 stvec 还可以被设置为 Vectored 模式,有兴趣的同学可以自行参考 RISC-V 指令集特权级规范。

回想到之前学到的异常控制流,stvec可以帮助我们切换到特权级切换后的处理Trap的地址:

  1. 上层软件执行过程中出现了一些异常或 特殊情况 , 需要用到执行环境中提供的功能
    1. 这里可以看到虽然都叫做 异常 但是实际上有一部分情况是特殊情况需要使用执行环境中的功能,不能非黑即白地把 异常 理解为 坏的
    2. 用户态应用直接触发从用户态到内核态的异常的原因总体上可以分为两种
      1. 其一是用户态软件为获得内核态操作系统的服务功能而执行特殊指令
        1. 指令本身属于高特权级的指令,如 sret 指令(表示从 S 模式返回到 U 模式)
        2. 指令访问了 S模式特权级下才能访问的寄存器 或内存,如表示S模式系统状态的 控制状态寄存器 sstatus 等
      2. 其二是在执行某条指令期间产生了错误(如执行了用户态不允许执行的指令或者其他错误)并被 CPU 检测到
  2. 暂停上层软件的功能,转而运行执行环境的代码 (伴随 特权级切换 )
  3. 回到上层软件暂停的位置继续执行

而当 CPU 完成 Trap 处理准备返回的时候,需要通过一条 S 特权级的特权指令 sret 来完成,这一条指令具体完成以下功能:

  • CPU 会将当前的特权级按照 sstatus 的 SPP 字段设置为 U 或者 S ;
  • CPU 会跳转到 sepc 寄存器指向的那条指令,然后继续执行。

同上所述, sret 可以帮我们完成 上下文储存恢复 .

用户栈和内核栈

我们刚刚提到了有关于stvecsret的作用,sstatus可以给出Trap发生之前CPU处于哪个特权级,stval可以给出Trap附加信息,可以在Trap发生时保存里边的内容.

但是我们不能把脑子只沉浸在切换优先级这件事上,就像在使用asm开发mcu的时候,我们需要把寄存器的数据压到栈里,等需要的时候再取出来一样.我们也需要保存用户的上下文.

更抽象地讲,我们把切换优先级类似地看成是C语言调用了"内核库"(虚构)的函数,导致了函数嵌套调用一样.

这时候我们就可以很自然地想到,上一章学到的函数调用栈,同样地,我们实现 内存分配出栈入栈 也可以实现这样一个栈.

但是考虑到我们实现的这个调用栈本身是使用了sp指针的,而我们如果要主动保存用户上下文则需要自己实现 入栈出栈 ,并且更重要的是我们需要能够 分配一片地址 .

在 Trap 触发的一瞬间, CPU 就会切换到 S 特权级并跳转到 stvec 所指示的位置。但是在正式进入 S 特权级的 Trap 处理之前,上面 提到过我们必须保存原控制流的 寄存器状态 ,这一般通过 内核栈 来保存。注意,我们需要用专门为操作系统准备的内核栈,而不是应用程序运行时用到的用户栈。

使用两个 不同的栈 主要是为了 安全性:如果两个控制流(即应用程序的控制流和内核的控制流)使用同一个栈,在返回之后应用程序就能读到 Trap 控制流的历史信息,比如内核一些函数的地址,这样会带来安全隐患。于是,我们要做的是,在批处理操作系统中添加一段汇编代码,实现从用户栈切换到内核栈,并在 内核栈 上保存应用程序控制流的 寄存器状态

也就是说,使用两个栈也是为了分开优先级,使得应用程序能够访问的U特权级的数据只能由应用程序访问,而S级别的数据只能由内核访问.因此储存两个等级的数据的栈也要实现两个.

现在运行在U级上的应用数据被储存在 用户栈 , 而运行在S级上的应用(内核API)被储存在 内核栈 .那么怎么切换运行状态呢?

答案是只需要修改sp寄存器的值就可以更换当前程序运行位置,那么我们要做的其实是 切换栈 ,在特权级切换的过程中完成栈切换.

那么怎么具体实现这个栈呢?官方文档给出了参考,简要说就是对 数组 进行封装,并且为它实现一个可以返回作为 栈顶地址 的数组地址的方法,然后实例化这样的结构体,当作 单例 使用:

// os/src/batch.rs

const USER_STACK_SIZE: usize = 4096 * 2;
const KERNEL_STACK_SIZE: usize = 4096 * 2;

#[repr(align(4096))]
struct KernelStack {
    data: [u8; KERNEL_STACK_SIZE],
}

#[repr(align(4096))]
struct UserStack {
    data: [u8; USER_STACK_SIZE],
}

static KERNEL_STACK: KernelStack = KernelStack { data: [0; KERNEL_STACK_SIZE] };
static USER_STACK: UserStack = UserStack { data: [0; USER_STACK_SIZE] };

impl UserStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + USER_STACK_SIZE
    }
}

impl KernelStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + KERNEL_STACK_SIZE
    }
}

这里注意#[repr(align(N))] 表示该类型在内存中的对齐方式应至少为 N 字节.

接下来就需要保存Trap发生后的寄存器上下文,并且保存到内核栈中了,这个可以分为三步做:

  1. 知道有什么寄存器内容需要保存
  2. 用什么样的数据结构保存
  3. 怎么压入内核栈

我们看官方文档,可以看到除了我们自己看功能就知道要储存的一众CSR寄存器,还有通用寄存器x0~x31需要储存:

  • 对于通用寄存器而言,两条控制流(应用程序控制流和内核控制流)运行在不同的特权级,所属的软件也可能由不同的编程语言编写,虽然在 Trap 控制流中只是会执行 Trap 处理相关的代码,但依然可能直接或间接调用很多模块,因此很难甚至不可能找出哪些寄存器无需保存。既然如此我们就只能全部保存了。但这里也有一些例外,如 x0 被硬编码为 0 ,它自然不会有变化;还有 tp(x4) 寄存器,除非我们手动出于一些特殊用途使用它,否则一般也不会被用到。虽然它们无需保存,但我们仍然在 TrapContext 中为它们预留空间,主要是为了后续的实现方便。
  • 对于 CSR 而言,我们知道进入 Trap 的时候,硬件会立即覆盖掉 scause/stval/sstatus/sepc 的全部或是其中一部分。scause/stval 的情况是:它总是在 Trap 处理的第一时间就被使用或者是在其他地方保存下来了,因此它没有被修改并造成不良影响的风险。而对于 sstatus/sepc 而言,它们会在 Trap 处理的全程有意义(在 Trap 控制流最后 sret 的时候还用到了它们),而且确实会出现 Trap 嵌套的情况使得它们的值被覆盖掉。所以我们需要将它们也一起保存下来,并在 sret 之前恢复原样。

这时候就需要设计一个结构体来储存这些寄存器的值:

// os/src/trap/context.rs

#[repr(C)]
pub struct TrapContext {
    pub x: [usize; 32],
    pub sstatus: Sstatus,
    pub sepc: usize,
}

可以看到这里使用一个大小为32的数组储存x0~x31,并且使用一个Sstatus的结构体保存sstatus寄存器,用uszie的一个变量保存sepc寄存器.

其中Sstatus结构体是riscv::register这个包里专门用来储存sstatus寄存器的一个包,sstatus寄存器的一些主要字段包括:

  • SD (Supervisor Debug Mode):此位表示处理器是否处于监督调试模式。
  • SIE (Supervisor Interrupt Enable):此位控制是否允许监督模式下的中断。
  • SPIE (Supervisor Previous Interrupt Enable):当从一个中断返回时,此位用于恢复之前的中断使能状态。
  • SPP (Supervisor Previous Privilege Level):此位表示进入监督模式前的上一个特权级别。
  • SPRV (Supervisor Previous RVISZ):此位表示进入监督模式前的上一个 RVISZ 值。
  • XS (User-mode Exception Specification):此位用于控制用户模式异常处理的规格。
  • FS (Floating-point Status):此位用于控制浮点单元的状态。
  • XS (User-mode Exception Specification):此位用于控制用户模式异常处理的规格。

因此专门用一个结构体来储存它的信息也是合理的,既然有设计好的轮子我们没有理由不用.

这里这个#[repr(C)]没用搞清楚,看起来像是和C语言相关的某些玩意,我们问一下同义千问:
#[repr(C)] 是 Rust 语言中的一个属性,用于控制结构体(struct)或联合体(union)的布局和表示形式。具体来说,#[repr(C)] 指示 Rust 编译器按照 C 语言的规则来布局结构体或联合体。
为什么使用 #[repr(C)]

  1. 互操作性:当你需要与其他语言(如 C 或 C++)进行互操作时,确保 Rust 的结构体具有相同的布局是非常重要的。
  2. 二进制兼容性:如果你想保证结构体的布局在不同平台上的二进制兼容性,#[repr(C)] 可以帮助你做到这一点。
  3. 外部接口:如果你的结构体将作为外部接口的一部分暴露给其他系统,那么保持一致的布局是很重要的。
    #[repr(C)] 的行为特点:
  • 字段顺序:字段的顺序将被保留,不会被 Rust 优化器重新排序。
  • 对齐:每个字段将按照 C 语言的默认对齐方式进行对齐。
  • 填充:Rust 可能会在字段之间添加额外的填充(pad)字节以满足对齐要求。
  • 大小:结构体的大小将基于字段的实际大小和对齐要求。

具体地,在os/src目录下创建trap文件夹,在其中创建mod.rscontext.rs,我们上述的结构体就在context.rs里被描述.

cd os/src
mkdir trap
cd trap
touch mod.rs
touch context.rs

这时候还需要实现一个把这个结构体的数据压入内核栈的函数,要为内核栈实现一个方法:

impl KernelStack {
    fn get_sp(&self) -> usize {
        self.data.as_ptr() as usize + KERNEL_STACK_SIZE
    }
    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() }
    }
}

可以看到push_context方法是直接获取了一个栈顶指针(实际上是数组的底),然后通过TrapContext的大小计算出栈底指针,然后把数据直接放到指针指向的位置.

随后返回栈底位置(保存的数据的指针位置)的 可变引用 .

这里使用 unwrap() 方法来处理可能的 None 情况.

Trap 管理

特权级切换的核心是对Trap的管理。这主要涉及到如下一些内容:

  • 应用程序通过 ecall 进入到内核状态时,操作系统保存被打断的应用程序的 Trap 上下文;
  • 操作系统根据Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理;
  • 操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通 sret 让应用程序继续执行。

Trap 上下文的保存与恢复

注意上述提到的第二点,操作系统需要Trap相关的CSR寄存器内容,完成系统调用服务的分发与处理.

想到stvec寄存器的作用,它控制 Trap 处理代码的入口地址.那么为了具体实现 系统调用服务的分发与处理 .需要配置stvec的内容,把它设置为Trap处理入口点.

编辑os/src/trap/mod.rs:

// os/src/trap/mod.rs

global_asm!(include_str!("trap.S"));

pub fn init() {
    extern "C" { fn __alltraps(); }
    unsafe {
        stvec::write(__alltraps as usize, TrapMode::Direct);
    }
}

这里可以看到这段代码利用global_asm!宏引入trap.S,然后通过extern的方式引入__alltraps这个label,然后使用stvec::writestvec写入__alltraps作为入口,模式为Direct,都是和上边讲的知识是对应的.
在 RV64 中, stvec 是一个 64 位的 CSR,在中断使能的情况下,保存了中断处理的入口地址。它有两个字段:
- MODE 位于 [1:0],长度为 2 bits;
- BASE 位于 [63:2],长度为 62 bits。

接下来解析trap.S的内容:

# os/src/trap/trap.S

.macro SAVE_GP n
    sd x\n, \n*8(sp)
.endm

.align 2
__alltraps:
    csrrw sp, sscratch, sp
    # now sp->kernel stack, sscratch->user stack
    # allocate a TrapContext on kernel stack
    addi sp, sp, -34*8
    # 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
    # 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)
    # set input argument of trap_handler(cx: &mut TrapContext)
    mv a0, sp
    call trap_handler

这里有个tip,就是这些csr开头的意味着是对CSR寄存器的 原子操作命令 ,这样后边就容易看懂了,比如r就是读取rw就是读写,嘻嘻.

首先是定义了一个宏SAVE_GP:

.macro SAVE_GP n
    sd x\n, \n*8(sp)
.endm

这个n是传进去的宏参数,展开SAVE_GP 5sd x5, 5*8(sp),这样的话对于每个寄存器的编号都有一个栈上的位置专门储存.

然后是.align 2是将__alltraps的地址4字节对齐.这是RISC-V特权级规范的要求.

第 9 行的 csrrw 原型是 csrrw rd, csr, rs 可以将CSR当前的值读到通用寄存器rd中,然后将通用寄存器rs的值写入该CSR .因此这里起到的是交换sscratchsp的效果.在这一行之前sp指向用户栈,sscratch指向内核栈(原因稍后说明),现在sp指向内核栈,sscratch指向用户栈.

这里注意sscratch 寄存器位于 RISC-V 架构的特权模式中,是监督模式的一部分,它通常用于保存和恢复关键寄存器的值,特别是在异常处理和中断处理的过程中.因此我们可以猜测是有另外的操作在触发Trap的时候把内核栈的位置暂存在里边.那么考虑到是把sscratchsp
进行了交换,我们可以理解为进入S模式操作内核栈的时候,把用户栈指针暂存在sscratch,回到U模式操作用户栈的时候再把内核栈指针暂存在ssratch之中.

第 12 行,我们准备在内核栈上保存 Trap 上下文,于是预先分配 34×8 字节的栈帧,这里改动的是 sp,说明确实是在内核栈上.这里考虑TrapContext的定义,那么它的大小为34:

pub struct TrapContext {
    pub x: [usize; 32],
    pub sstatus: Sstatus,
    pub sepc: usize,
}

第 13~24 行,保存 Trap 上下文的通用寄存器x0~x31,跳过x0tp(x4),原因之前已经说明.我们在这里也不保存sp(x2),因为我们要基于它来找到每个寄存器应该被保存到的正确的位置.实际上,在栈帧分配之后,我们可用于保存Trap上下文的地址区间为$[sp+8n,sp+8(n+1))$,按照TrapContext结构体的内存布局,基于内核栈的位置(sp所指地址)来从低地址到高地址分别按顺序放置x0~x31这些通用寄存器,最后是sstatussepc.因此通用寄存器xn应该被保存在地址区间$[sp+8n,sp+8(n+1))$.为了简化代码,x5~x31这 27 个通用寄存器我们通过类似循环的.rept每次使用SAVE_GP宏来保存,其实质是相同的.注意我们需要在trap.S开头加上.altmacro才能正常使用.rept命令.

第 25~28 行,我们将CSRsstatussepc的值分别读到寄存器t0t1中然后保存到内核栈对应的位置上.指令 的功能就是将 CSR 的值读到寄存器 中.这里我们不用担心t0t1被覆盖,因为它们刚刚已经被保存了.

第 30~31 行专门处理sp的问题.首先将sscratch的值读到寄存器t2并保存到内核栈上,注意:sscratch的值是进入Trap之前的sp的值,指向用户栈.而现在的sp则指向内核栈.

第 33 行令a0←sp,让寄存器a0指向内核栈的栈指针也就是我们刚刚保存的 Trap 上下文的地址,这是由于我们接下来要调用 trap_handler 进行 Trap 处理,它的第一个参数 cx 由调用规范要从a0中获取.而Trap 处理函数 trap_handler 需要 Trap 上下文的原因在于:它需要知道其中某些寄存器的值,比如在系统调用的时候应用程序传过来的syscall ID和对应参数.我们不能直接使用这些寄存器现在的值,因为它们可能已经被修改了,因此要去内核栈上找已经被保存下来的值.

这里这个 a0的描述 是非常重要的,这可以让我么之打破a0是作为调用函数默认的参数.

这里我们可以绘制出这一段的示意图:

我们可以看到这里它调用了trap_handler函数来做Trap 的处理,但是这里 先不去管它的实现 ,继续看trap.S 的内容.

那么再接下来的内容就是trap_hander函数执行完毕之后的操作,结合我们之前对于sscratch的描述和:
操作系统完成系统调用服务后,需要恢复被打断的应用程序的Trap 上下文,并通 sret 让应用程序继续执行。

我们可以知道接下来的步骤应该是恢复sp指针的位置和恢复x0~x31寄存器的值,并且通过sepc的值来明确Trap发生之前执行的最后一条指令的地址.

那么我们接着看trap.S的下一步:

# os/src/trap/trap.S

.macro LOAD_GP n
    ld x\n, \n*8(sp)
.endm

__restore:
    # case1: start running app by __restore
    # case2: back to U after handling trap
    mv sp, a0
    # now sp->kernel stack(after allocated), sscratch->user stack
    # restore sstatus/sepc
    ld t0, 32*8(sp)
    ld t1, 33*8(sp)
    ld t2, 2*8(sp)
    csrw sstatus, t0
    csrw sepc, t1
    csrw sscratch, t2
    # restore general-purpuse registers except sp/tp
    ld x1, 1*8(sp)
    ld x3, 3*8(sp)
    .set n, 5
    .rept 27
        LOAD_GP %n
        .set n, n+1
    .endr
    # release TrapContext on kernel stack
    addi sp, sp, 34*8
    # now sp->kernel stack, sscratch->user stack
    csrrw sp, sscratch, sp
    sret

首先我们还是看到它定义了一个宏LOAD_GP:

.macro LOAD_GP n
    ld x\n, \n*8(sp)
.endm

sd指令相反ld指令是把sp指向的栈里的数据重新加载回xn寄存器.

第10行,可以看到是把a0的值重新保回sp,对应__alltraps中把sp的指针拿回来,不要理会官方文档那个"第 10 行比较奇怪我们暂且不管,假设它从未发生,那么 sp 仍然指向内核栈的栈顶。"作者应该是昏了头,后边我会去提issue的.

第 13~26 行负责从内核栈顶的 Trap 上下文恢复通用寄存器和 CSR 。注意我们要先恢复 CSR 再恢复通用寄存器,这样我们使用的三个临时寄存器才能被正确恢复。

在第 28 行之前,sp 指向保存了 Trap 上下文之后的内核栈栈顶, sscratch 指向用户栈栈顶。我们在第 28 行在内核栈上回收 Trap 上下文所占用的内存,回归进入 Trap 之前的内核栈栈顶。第 30 行,再次交换 sscratch 和 sp,现在 sp 重新指向用户栈栈顶,sscratch 也依然保存进入 Trap 之前的状态并指向内核栈栈顶。

在应用程序控制流状态被还原之后,第 31 行我们使用 sret 指令回到 U 特权级继续运行应用程序控制流。

流程基本是把__alltraps走了一遍,这里注意 sp指针再次被偏移回栈顶,然后和sscratch交换 .

他这个官方文档一直到这个位置才讲清楚sscratch的用处,唉,太看天赋了他这个文档.
sscratch CSR 的用途
在特权级切换的时候,我们需要将 Trap 上下文保存在内核栈上,因此需要一个寄存器暂存内核栈地址,并以它作为基地址指针来依次保存 Trap 上下文的内容。但是所有的通用寄存器都不能够用作基地址指针,因为它们都需要被保存,如果覆盖掉它们,就会影响后续应用控制流的执行。
事实上我们缺少了一个重要的中转寄存器,而 sscratch CSR 正是为此而生。从上面的汇编代码中可以看出,在保存 Trap 上下文的时候,它起到了两个作用:首先是保存了内核栈的地址,其次它可作为一个中转站让 sp (目前指向的用户栈的地址)的值可以暂时保存在 sscratch 。这样仅需一条 csrrw  sp, sscratch, sp 指令(交换对 sp 和 sscratch 两个寄存器内容)就完成了从用户栈到内核栈的切换,这是一种极其精巧的实现。

Trap分发与处理

那么还记得我们没用实现trap_handler吗?那么这一步就是要在这里完成.

os/src/trap/mod.rs实现 trap_handler 函数,在该函数中完成分发和处理P:

// os/src/trap/mod.rs

#[no_mangle]
pub fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
        }
        Trap::Exception(Exception::StoreFault) |
        Trap::Exception(Exception::StorePageFault) => {
            println!("[kernel] PageFault in application, kernel killed it.");
            run_next_app();
        }
        Trap::Exception(Exception::IllegalInstruction) => {
            println!("[kernel] IllegalInstruction in application, kernel killed it.");
            run_next_app();
        }
        _ => {
            panic!("Unsupported trap {:?}, stval = {:#x}!", scause.cause(), stval);
        }
    }
    cx
}

这里主要需要关心的就是这一段代码:

Trap::Exception(Exception::UserEnvCall) => {
	cx.sepc += 4;
	cx.x[10] = syscall(cx.x[17], [cx.x[10], cx.x[11], cx.x[12]]) as usize;
}

第 8~11 行,发现触发 Trap 的原因是来自 U 特权级的 Environment Call,也就是系统调用。这里我们首先修改保存在内核栈上的 Trap 上下文里面 sepc,让其增加 4。这是因为我们知道这是一个由 ecall 指令触发的系统调用,在进入 Trap 的时候,硬件会将 sepc 设置为这条 ecall 指令所在的地址(因为它是进入 Trap 之前最后一条执行的指令)。而在 Trap 返回之后,我们希望应用程序控制流从 ecall 的下一条指令开始执行。因此我们只需修改 Trap 上下文里面的 sepc,让它增加 ecall 指令的码长,也即 4 字节。这样在 __restore 的时候 sepc 在恢复之后就会指向 ecall 的下一条指令,并在 sret 之后从那里开始执行。

回想我们之前实现的syscall:

// user/src/syscall.rs
use core::arch::asm;
fn syscall(id: usize, args: [usize; 3]) -> isize {
    let mut ret: isize;
    unsafe {
        asm!(
            "ecall",
            inlateout("x10") args[0] => ret,
            in("x11") args[1],
            in("x12") args[2],
            in("x17") id
        );
    }
    ret
}

这里要提到的重点就是x10实际上是a0,同时作为函数的第一个参数和输出,同样的x11对应a1,作为函数的第二个参数,x12对应a3函数的第三个寄存器.可见三个寄存器的别名是a0-2,是非常合理的,因为a对应arg,参数嘛.

另外,要理解这里调用了还没有具体实现的run_next_app以及容易忽略掉的一点,就是如果遇到了这些case以外的问题,那么 操作系统 也应该停止运行,而不是 运行下一个app ,所以是调用了panic!宏来处理.

实现系统调用功能

对于系统调用而言, syscall 函数并不会实际处理系统调用,而只是根据 syscall ID 分发到具体的处理函数:

// os/src/syscall/mod.rs

pub fn syscall(syscall_id: usize, args: [usize; 3]) -> isize {
    match syscall_id {
        SYSCALL_WRITE => sys_write(args[0], args[1] as *const u8, args[2]),
        SYSCALL_EXIT => sys_exit(args[0] as i32),
        _ => panic!("Unsupported syscall_id: {}", syscall_id),
    }
}

这里我们的思维必须 暂停一下 ,想一想是怎么回事才会触发这个Trap,触发Trap之后都发生了什么,这样才能理解为什么用户态有一个syscall,内核态还有一个跟着的syscall.

想一想:

  1. 是我们调用用户态的syscall
  2. 然后使用ecall,把参数存进了x10,x11,x12,x17
  3. 接下来程序陷入Trap
  4. 我们调用了__alltraps
  5. 随后使用trap_handler来处理Trap
  6. trap_handler之中调用内核态的syscall,处理保存在内核栈中的x10,x11,x12,x17
  7. 随后内核态的syscall根据x17的情况调用内核的函数

所以其实没用什么魔法可言,从用户态到内核态之后,相当于是使用x10,x11,x12,x17作为桥梁,根据x17约定好的函数编号来进行执行,只不过当前的特权级变了,可以执行一些指令了而已.

所以在硬件层面,在mstatussstatus中分别为M特权级和S特权级提供了确定当前特权级的位,而对于U特权级则只能尝试执行一些不允许的指令看看会不会发生异常.

说回到这个函数,它根据我们传入的id判断当前是要执行哪个函数.这里的两个函数都在os/src/syscall/fs.rs里有实现:

// os/src/syscall/fs.rs

const FD_STDOUT: usize = 1;

pub fn sys_write(fd: usize, buf: *const u8, len: usize) -> isize {
    match fd {
        FD_STDOUT => {
            let slice = unsafe { core::slice::from_raw_parts(buf, len) };
            let str = core::str::from_utf8(slice).unwrap();
            print!("{}", str);
            len as isize
        },
        _ => {
            panic!("Unsupported fd in sys_write!");
        }
    }
}

// os/src/syscall/process.rs

pub fn sys_exit(xstate: i32) -> ! {
    println!("[kernel] Application exited with code {}", xstate);
    run_next_app()
}

执行应用程序

当批处理操作系统初始化完成,或者是某个应用程序运行结束或出错的时候,我们要调用 run_next_app 函数切换到下一个应用程序.

刚刚提到的trap_handler里也针对两种Expectation调用了run_next_app.

这里需要考虑的问题就是:此时 CPU 运行在 S 特权级,而它希望能够切换到 U 特权级.

在 RISC-V 架构中,唯一一种能够使得 CPU 特权级下降的方法就是执行 Trap 返回的特权指令,如 sret ,mret 等.事实上,在从操作系统内核返回到运行应用程序之前,要完成如下这些工作:

  • 构造应用程序开始执行所需的 Trap 上下文;
  • 通过 __restore 函数,从刚构造的 Trap 上下文中,恢复应用程序执行的部分寄存器;
  • 设置 sepc CSR的内容为应用程序入口点 0x80400000
  • 切换 scratch 和 sp 寄存器,设置 sp 指向应用程序用户栈;
  • 执行 sret 从 S 特权级切换到 U 特权级。

这里官方的架构设计就非常巧妙:
它们可以通过复用 __restore 的代码来更容易的实现上述工作。我们只需要在内核栈上压入一个为启动应用程序而特殊构造的 Trap 上下文,再通过 __restore 函数,就能让这些寄存器到达启动应用程序所需要的上下文状态。

// os/src/trap/context.rs

impl TrapContext {
    pub fn set_sp(&mut self, sp: usize) { self.x[2] = sp; }
    pub fn app_init_context(entry: usize, sp: usize) -> Self {
        let mut sstatus = sstatus::read();
        sstatus.set_spp(SPP::User);
        let mut cx = Self {
            x: [0; 32],
            sstatus,
            sepc: entry,
        };
        cx.set_sp(sp);
        cx
    }
}

为 TrapContext 实现 app_init_context 方法,修改其中的 sepc 寄存器为应用程序入口点 entry, sp 寄存器为我们设定的一个栈指针,并将 sstatus 寄存器的 SPP 字段设置为 User 。

那么可以这样实现run_next_app:

// os/src/batch.rs

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!");
}

在高亮行所做的事情是在内核栈上压入一个 Trap 上下文,其 sepc 是应用程序入口地址 0x80400000 ,其 sp 寄存器指向用户栈,其 sstatus 的 SPP 字段被设置为 User 。push_context 的返回值是内核栈压入 Trap 上下文之后的栈顶,它会被作为 __restore 的参数(回看 __restore 代码 ,这时我们可以理解为何 __restore 函数的起始部分会完成 sp←a0 ),这使得在 __restore 函数中 sp 仍然可以指向内核栈的栈顶。这之后,就和执行一次普通的 __restore 函数调用一样了。

sscratch 是何时被设置为内核栈顶的?

在程序启动时,和实验一一样,sp指向了boot_stack,那么那里就是入口函数load_all所用的栈。然后load_all调用run_next_app,后者调用app_init_context,这里对TrapContext的操作修改的都是内核栈的栈底,把USER_TOP传给了TrapContext里的sp寄存器,并返回了TrapContext的指针。

然后进入restore,第一句mv sp, a0,将TrapContext的指针传给了sp,这一步就相当于重置了内核栈,把内核栈重置为栈底只有一个TrapContext的状态,同时此处也将程序当前的栈从boot_stack转到内核栈了。然后各条指令都是在处理内核栈里的那一个TrapContext。之后csrw sscratch, t2将刚才传进x[2]的用户栈栈底传给sscratch寄存器,最后一句csrrw sp, sscratch, sp,sscratch被设置为了内核栈底,sp也指向了用户栈底。

posted @ 2024-07-27 22:31  winddevil  阅读(12)  评论(0编辑  收藏  举报