6.1 Trap机制

今天我想讨论一下,程序运行是完成用户空间和内核空间的切换。每当

  • 程序执行系统调用

  • 程序出现了类似page fault、运算时除以0的错误

  • 一个设备触发了中断使得当前程序运行需要响应内核设备驱动

都会发生这样的切换 。

这里用户空间和内核空间的切换通常被称为trap,而trap涉及了许多小心的设计和重要的细节,这些细节对于实现安全隔离和性能来说非常重要。因为很多应用程序,要么因为系统调用,要么因为page fault,都会频繁的切换到内核中。所以,trap机制要尽可能的简单,这一点非常重要。

初始的场景你们已经非常熟悉了。我们有一些用户应用程序,例如Shell,它运行在用户空间,同时我们还有内核空间。Shell可能会执行系统调用,将程序运行切换到内核。比如XV6启动之后Shell输出的一些提示信息,就是通过执行write系统调用来输出的。这是Shell尝试执行wrtie系统调用的一个例子。

我们需要清楚如何让程序的运行,从只拥有user权限并且位于用户空间的Shell,切换到拥有supervisor权限的内核。在这个过程中,硬件的状态将会非常重要,因为我们很多的工作都是将硬件从适合运行用户应用程序的状态,改变到适合运行内核代码的状态。

我们最关心的状态可能是32个用户寄存器,这在上节课中有介绍。RISC-V总共有32个比如a0,a1这样的寄存器,用户应用程序可以使用全部的寄存器,并且使用寄存器的指令性能是最好的。

这里的很多寄存器都有特殊的作用,我们之后都会看到。其中一个特别有意思的寄存器是stack pointer(也叫做堆栈寄存器 stack register)。所以,我们有了包含堆栈寄存器在内的这32个寄存器。

此外,

  • 在硬件中还有一个寄存器叫做程序计数器(Program Counter Register)。

  • 表明当前mode的标志位,这个标志位表明了当前是supervisor mode还是user mode。当我们在运行Shell的时候,自然是在user mode。

  • 还有一堆控制CPU工作方式的寄存器,比如SATP(Supervisor Address Translation and Protection)寄存器,它包含了指向page table的物理内存地址(详见4.3)。

  • 还有一些对于今天讨论非常重要的寄存器,比如STVEC(Supervisor Trap Vector Base Address Register)寄存器,它指向了内核中处理trap的指令的起始地址。

  • SEPC(Supervisor Exception Program Counter)寄存器,在trap的过程中保存程序计数器的值。

  • SSRATCH(Supervisor Scratch Register)寄存器,这也是个非常重要的寄存器(详见6.5)。

这些寄存器表明了执行系统调用时计算机的状态。

可以肯定的是,在trap的最开始,CPU的所有状态都设置成运行用户代码而不是内核代码。在trap处理的过程中,我们实际上需要更改一些这里的状态,或者对状态做一些操作。这样我们才可以运行系统内核中普通的C程序。接下来我们先来预览一下需要做的操作:

  • 首先,我们需要保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机的被设备中断所打断时。我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap之前,你必须先在某处保存这32个用户寄存器。

  • 程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。

  • 我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。

  • SATP寄存器现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将SATP指向kernel page table。

  • 我们需要将堆栈寄存器指向位于内核的一个地址,因为我们需要一个堆栈来调用内核的C函数。

  • 一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的C代码。

一旦我们运行在内核的C代码中,那就跟平常的C代码是一样的。之后我们会讨论内核通过C代码做了什么工作,但是今天的讨论是如何从将程序执行从用户空间切换到内核的一个位置,这样我们才能运行内核的C代码。

操作系统的一些high-level的目标能帮我们过滤一些实现选项。其中一个目标是安全和隔离,我们不想让用户代码介入到这里的user/kernel切换,否则有可能会破坏安全性。所以这意味着,trap中涉及到的硬件和内核机制不能依赖任何来自用户空间东西。比如说我们不能依赖32个用户寄存器,它们可能保存的是恶意的数据,所以,XV6的trap机制不会查看这些寄存器,而只是将它们保存起来。

在操作系统的trap机制中,我们仍然想保留隔离性并防御来自用户代码的可能的恶意攻击。同样也很重要的是,另一方面,我们想要让trap机制对用户代码是透明的,也就是说我们想要执行trap,然后在内核中执行代码,同时用户代码并不用察觉到任何有意思的事情。这样也更容易写用户代码。

需要注意的是,虽然我们这里关心隔离和安全,但是今天我们只会讨论从用户空间切换到内核空间相关的安全问题。当然,系统调用的具体实现,比如说write在内核的具体实现,以及内核中任何的代码,也必须小心并安全的写好。所以,即使从用户空间到内核空间的切换十分安全,整个内核的其他部分也必须非常安全,并时刻小心用户代码可能会尝试欺骗它。

在前面介绍的寄存器中,有一个特殊的寄存器我想讨论一下,也就是mode标志位。这里的mode表明当前是user mode还是supervisor mode。当然,当我们在用户空间时,这个标志位对应的是user mode,当我们在内核空间时,这个标志位对应supervisor mode。但是有一点很重要:当这个标志位从user mode变更到supervisor mode时,我们能得到什么样的权限。实际上,这里获得的额外权限实在是有限。也就是说,你可以在supervisor mode完成,但是不能在user mode完成的工作,或许并没有你想象的那么有特权。所以,我们接下来看看supervisor mode可以控制什么?

其中的一件事情是,你现在可以读写控制寄存器了。比如说,当你在supervisor mode时,你可以:读写SATP寄存器,也就是page table的指针;STVEC,也就是处理trap的内核指令地址;SEPC,保存当发生trap时的程序计数器;SSCRATCH等等。在supervisor mode你可以读写这些寄存器,而用户代码不能做这样的操作。

另一件事情supervisor mode可以做的是,它可以使用PTE_U标志位为0的PTE。当PTE_U标志位为1的时候,表明用户代码可以使用这个页表;如果这个标志位为0,则只有supervisor mode可以使用这个页表。我们接下来会看一下为什么这很重要。

这两点就是supervisor mode可以做的事情,除此之外就不能再干别的事情了。

需要特别指出的是,supervisor mode中的代码并不能读写任意物理地址。在supervisor mode中,就像普通的用户代码一样,也需要通过page table来访问内存。如果一个虚拟地址并不在当前由SATP指向的page table中,又或者SATP指向的page table中PTE_U=1,那么supervisor mode不能使用那个地址。所以,即使我们在supervisor mode,我们还是受限于当前page table设置的虚拟地址。

所以,这就是全部了。在supervisor我们只能做这些事情,我们接下来会看一下,当我们进入到内核空间时,trap代码的执行流程。

Last updated