文章

6.s081-note01

Isolation

应用程序与操作系统之间具有强隔离性。简单来说,操作系统能够很好地管理多个应用程序的正常运行。

在内存层面来看,不同应用程序之间的内存是隔离的,这样应用程序就不会覆盖另一个的内存。

其实接口就被精心设计来实现资源的强隔离。

接下来是几个例子。

例如进程,进程抽象了CPU,这样多个应用程序才能复用一个或多个CPU。

我们也可以认为exec抽象了内存,应用程序并没有直接访问物理内存的权限。

files抽象了磁盘,应用程序不会读写挂在计算机上的磁盘本身。

Defensive

操作系统需要一种方式来完成:操作系统需要能够应对恶意的应用程序。

应用程序不能够打破对它的隔离。

如果操作系统需要具备防御性,那么在应用程序和操作系统之间需要有一堵“厚墙”,并且可以在这堵墙执行任何想执行的策略。

通常来讲,需要通过硬件来实现这种强隔离性。这里的硬件支持包括了两部分,第一部分是user/kernel mode,kernel mode在RISC-V中被称为Supervisor mode但是其实是同一个东西;第二部分是page table或者虚拟内存(Virtual Memory)。

为了支持user/kernel mode,处理器会有两种操作模式,第一种是user mode,第二种是kernel mode。当运行在kernel mode时,CPU可以运行特定权限的指令(privileged instructions);当运行在user mode时,CPU只能运行普通权限的指令(unprivileged instructions)。

普通指令就是一般的一些指令,这里不会再介绍。

特殊权限指令主要是一些直接操纵硬件的指令和设置保护的指令,例如设置page table寄存器、关闭时钟中断。在处理器上有各种各样的状态,操作系统会使用这些状态,但是只能通过特殊权限指令来变更这些状态。

实际上RISC-V还有第三种模式称为machine mode。在大多数场景下,我们会忽略这种模式,所以我也不太会介绍这种模式。 所以实际上我们有三级权限(user/kernel/machine),而不是两级(user/kernel)。

基本上来说,处理器包含了page table,而page table将虚拟内存地址与物理内存地址做了对应。

每一个进程都会有自己独立的page table,这样的话,每一个进程只能访问出现在自己page table中的物理内存。操作系统会设置page table,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,因为其他进程的物理内存都不在它的page table中。一个进程甚至都不能随意编造一个内存地址,然后通过这个内存地址来访问其他进程的物理内存。这样就给了我们内存的强隔离性。

基本上来说,page table定义了对于内存的视图,而每一个用户进程都有自己对于内存的独立视图。这给了我们非常强的内存隔离性。

类似的,内核位于应用程序下方,假设是XV6,那么它也有自己的内存地址空间,并且与应用程序完全独立。

User/Kernel mode切换

我们可以认为user/kernel mode是分隔用户空间和内核空间的边界,用户空间运行的程序运行在user mode,内核空间的程序运行在kernel mode。操作系统位于内核空间。

必须要有一种方式可以使得用户的应用程序能够将控制权以一种协同工作的方式转移到内核,这样内核才能提供相应的服务。

所以,需要有一种方式能够让应用程序可以将控制权转移给内核(Entering Kernel)。

在RISC-V中,有一个专门的指令用来实现这个功能,叫做ECALL。ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的System Call。

ECALL会跳转到内核中一个特定,由内核控制的位置。我们在这节课的最后可以看到在XV6中存在一个唯一的系统调用接入点,每一次应用程序执行ECALL指令,应用程序都会通过这个接入点进入到内核中。举个例子,不论是Shell还是其他的应用程序,当它在用户空间执行fork时,它并不是直接调用操作系统中对应的函数,而是调用ECALL指令,并将fork对应的数字作为参数传给ECALL。之后再通过ECALL跳转到内核。

下图中通过一根竖线来区分用户空间和内核空间,左边是用户空间,右边是内核空间。在内核侧,有一个位于syscall.c的函数syscall,每一个从应用程序发起的系统调用都会调用到这个syscall函数,syscall函数会检查ECALL的参数,通过这个参数内核可以知道需要调用的是fork

这里需要澄清的是,用户空间和内核空间的界限是一个硬性的界限,用户不能直接调用fork,用户的应用程序执行系统调用的唯一方法就是通过这里的ECALL指令。

假设我现在要执行另一个系统调用write,相应的流程是类似的,write系统调用不能直接调用内核中的write代码,而是由封装好的系统调用函数执行ECALL指令。所以write函数实际上调用的是ECALL指令,指令的参数是代表了write系统调用的数字。之后控制权到了syscall函数,syscall会实际调用write系统调用。

宏内核 vs 微内核 (Monolithic Kernel vs Micro Kernel)

现在,我们有了一种方法,可以通过系统调用或者说ECALL指令,将控制权从应用程序转到操作系统中。之后内核负责实现具体的功能并检查参数以确保不会被一些坏的参数所欺骗。所以内核有时候也被称为可被信任的计算空间(Trusted Computing Base),在一些安全的术语中也被称为TCB。

内核首先要是正确且没有Bug的。假设内核中有Bug,攻击者可能会利用那个Bug,并将这个Bug转变成漏洞,这个漏洞使得攻击者可以打破操作系统的隔离性并接管内核。所以内核真的是需要越少的Bug越好(但是谁不是呢)。

一个有趣的问题是,什么程序应该运行在kernel mode?敏感的代码肯定是运行在kernel mode,因为这是Trusted Computing Base。

其中一个选项是让整个操作系统代码都运行在kernel mode。大多数的Unix操作系统实现都运行在kernel mode。比如,XV6中,所有的操作系统服务都在kernel mode中,这种形式被称为Monolithic Kernel Design(宏内核)。

这里有几件事情需要注意:

  • 首先,如果考虑Bug的话,这种方式不太好。在一个宏内核中,任何一个操作系统的Bug都有可能成为漏洞。因为我们现在在内核中运行了一个巨大的操作系统,出现Bug的可能性更大了。你们可以去查一些统计信息,平均每3000行代码都会有几个Bug,所以如果有许多行代码运行在内核中,那么出现严重Bug的可能性也变得更大。所以从安全的角度来说,在内核中有大量的代码是宏内核的缺点。
  • 另一方面,如果你去看一个操作系统,它包含了各种各样的组成部分,比如说文件系统,虚拟内存,进程管理,这些都是操作系统内实现了特定功能的子模块。宏内核的优势在于,因为这些子模块现在都位于同一个程序中,它们可以紧密的集成在一起,这样的集成提供很好的性能。例如Linux,它就有很不错的性能。

另一种设计主要关注点是减少内核中的代码,它被称为Micro Kernel Design(微内核)。在这种模式下,希望在kernel mode中运行尽可能少的代码。所以这种设计下还是有内核,但是内核只有非常少的几个模块,例如,内核通常会有一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。

微内核的目的在于将大部分的操作系统运行在内核之外。所以,我们还是会有user mode以及user/kernel mode的边界。但是我们现在会将原来在内核中的其他部分,作为普通的用户程序来运行。比如文件系统可能就是个常规的用户空间程序。

通常微内核的挑战在于性能更差,这里有两个方面需要考虑:

  1. 在user/kernel mode反复跳转带来的性能损耗。
  2. 在一个类似宏内核的紧耦合系统,各个组成部分,例如文件系统和虚拟内存系统,可以很容易的共享page cache。而在微内核中,每个部分之间都很好的隔离开了,这种共享更难实现。进而导致更难在微内核中得到更高的性能。

在实际中,两种内核设计都会出现,出于历史原因大部分的桌面操作系统是宏内核,如果你运行需要大量内核计算的应用程序,例如在数据中心服务器上的操作系统,通常也是使用的宏内核,主要的原因是Linux提供了很好的性能。但是很多嵌入式系统,例如Minix,Cell,这些都是微内核设计。这两种设计都很流行,如果你从头开始写一个操作系统,你可能会从一个微内核设计开始。但是一旦你有了类似于Linux这样的宏内核设计,将它重写到一个微内核设计将会是巨大的工作。并且这样重构的动机也不足,因为人们总是想把时间花在实现新功能上,而不是重构他们的内核。

编译运行kernel

Makefile会为所有内核文件做相同的操作,比如说pipe.c,会按照同样的套路,先经过gcc编译成pipe.s,再通过汇编解释器生成pipe.o。

之后,系统加载器(Loader)会收集所有的.o文件,将它们链接在一起,并生成内核文件。

这里生成的内核文件就是我们将会在QEMU中运行的文件。同时,为了你们的方便,Makefile还会创建kernel.asm,这里包含了内核的完整汇编语言,你们可以通过查看它来定位究竟是哪个指令导致了Bug。

我们来看传给QEMU的几个参数:

  • -kernel:这里传递的是内核文件(kernel目录下的kernel文件),这是将在QEMU中运行的程序文件。
  • -m:这里传递的是RISC-V虚拟机将会使用的内存数量
  • -smp:这里传递的是虚拟机可以使用的CPU核数
  • -drive:传递的是虚拟机使用的磁盘驱动,这里传入的是fs.img文件

QEMU

QEMU表现的就像一个真正的计算机一样。当你想到QEMU时,你不应该认为它是一个C程序,而是一个真正的主板。

当你通过QEMU来运行你的内核时,你应该认为你的内核是运行在这样一个主板之上。主板有一个开关,一个RISC-V处理器,有支持外设的空间,比如说一个接口是连接网线的,一个是PCI-E插槽,主板上还有一些内存芯片,这是一个你可以在上面编程的物理硬件,而XV6操作系统管理这样一块主板.

当你们在运行QEMU时,你们需要知道,你们基本上跟在运行硬件是一样的,只是说同样的东西,QEMU在软件中实现了而已。

当我们说QEMU仿真了RISC-V处理器时,背后的含义是什么?

直观来看,QEMU是一个大型的开源C程序,你可以下载或者git clone它。但是在内部,在QEMU的主循环中,只在做一件事情:

  • 读取4字节或者8字节的RISC-V指令。
  • 解析RISC-V指令,并找出对应的操作码(op code)。我们之前在看kernel.asm的时候,看过一些操作码的二进制版本。通过解析,或许可以知道这是一个ADD指令,或者是一个SUB指令。
  • 之后,在软件中执行相应的指令。

这基本上就是QEMU的全部工作了,对于每个CPU核,QEMU都会运行这么一个循环。

本文由作者按照 CC BY 4.0 进行授权