隔离性

为了支持多时复用,以及内存隔离,需要实现操作系统的隔离性。
假定没有操作系统,则进程需要直接对硬件资源进行操作,有如下情况:
操作系统正常情况下一个核运行一个进程,当有多个进程时,会进行分时复用,比如一个核分多个时间片供多个进程运行,当没有操作系统的情况下,用户进程需要自行让出 CPU,假如进程陷入死循环,CPU 资源便会一直占用。
原先的情况下,操作系统提供了对内存的抽象,进程认为自己独占整块内存。当没有操作系统时,多个进程会直接面对同一块物理内存,这会导致多个进程可能出现内存覆盖的情况,如进程a的内存覆盖进程b的内存。


防御性

操作系统应该具有防御性(Defensive)。
操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击。如果说应用程序无意或者恶意的向系统调用传入一些错误的参数就会导致操作系统崩溃,在这种场景下,操作系统因为崩溃了会拒绝为其他所有的应用程序提供服务。所以操作系统需要以这样一种方式来完成:操作系统需要能够应对恶意的应用程序。
另一个需要考虑的是,应用程序不能够打破对它的隔离。应用程序非常有可能是恶意的,它或许是由攻击者写出来的,攻击者或许想要打破对应用程序的隔离,进而控制内核。一旦有了对于内核的控制能力,你可以做任何事情,因为内核控制了所有的硬件资源。
这意味着我们需要在应用程序和操作系统之间提供强隔离性。如果操作系统需要具备防御性,那么在应用程序和操作系统之间需要有一堵厚墙,并且操作系统可以在这堵墙上执行任何它想执行的策略。
总的来说,防御性其实还是在加强隔离性。


硬件对于强隔离的支持

硬件对于强隔离的支持包括了:user/kernel mode和虚拟内存。

  • user/kernel mode

处理器会有两种操作模式,第一种是 user mode,第二种是 kernel mode。当运行在 kernel mode 时,CPU可以运行特定权限的指令(privileged instructions);当运行在 user mode 时,CPU只能运行普通权限的指令(unprivileged instructions)。
普通权限的指令都是一些你们熟悉的指令,例如将两个寄存器相加的指令 ADD、将两个寄存器相减的指令SUB、跳转指令 JRC、BRANCH 指令等等。这些都是普通权限指令,所有的应用程序都允许执行这些指令。
特殊权限指令主要是一些直接操纵硬件的指令和设置保护的指令,例如设置 page table 寄存器、关闭时钟中断。在处理器上有各种各样的状态,操作系统会使用这些状态,但是只能通过特殊权限指令来变更这些状态。
举个例子,当一个应用程序尝试执行一条特殊权限指令,因为不允许在 user mode 执行特殊权限指令,处理器会拒绝执行这条指令。通常来说,这时会将控制权限从 user mode 切换到 kernel mode ,当操作系统拿到控制权之后,或许会杀掉进程,因为应用程序执行了不该执行的指令。

  • 虚拟内存

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


user/kernel mode 切换

我们可以认为user/kernel mode是分隔用户空间和内核空间的边界,用户空间运行的程序运行在user mode,内核空间的程序运行在kernel mode。操作系统位于内核空间。
ls 与 echo 进程运行在 user mode 下
进程可能需要执行一些高权限的操作,如 read 或者 write ,这个时候只有在 kernel mode 下才可以执行,所以必须要有一种方式可以使得用户的应用程序能够将控制权以一种协同工作的方式转移到内核,这样内核才能提供相应的服务。
在 RISC-V 中,有一个专门的指令用来实现这个功能,叫做ECALL。ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的 System Call。
显然,用户进程可以随意通过 ECALL 指令执行系统调用,因此系统调用往往需要对执行的参数等进行严格的校验。


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

现在,我们有了一种方法,可以通过系统调用或者说ECALL指令,将控制权从应用程序转到操作系统中。之后内核负责实现具体的功能并检查参数以确保不会被一些坏的参数所欺骗。所以内核有时候也被称为可被信任的计算空间(Trusted Computing Base),在一些安全的术语中也被称为TCB。
基本上来说,要被称为 TCB,内核首先要是正确且没有 Bug 的。假设内核中有 Bug,攻击者可能会利用那个 Bug,并将这个 Bug 转变成漏洞,这个漏洞使得攻击者可以打破操作系统的隔离性并接管内核。所以内核需要越少的 Bug 越好。
一个有趣的问题是,什么程序应该运行在kernel mode?敏感的代码肯定是运行在kernel mode,因为这是Trusted Computing Base。
对于这个问题的一个答案是,首先我们会有 user/kernel 边界,在上面是应用程序,在下面是运行在kernel mode的程序。
user 与 kernel 边界

宏内核

其中一个选项是让整个操作系统代码都运行在 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的一些支持。

某种程度上来说,这是一种好的设计。因为在内核中的代码的数量较小,更少的代码意味着更少的Bug。但是这种设计也有相应的问题。

微内核的优缺点

假设我们需要让 Shell 能与文件系统交互,比如 Shell 调用了exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是:

  • Shell 会通过内核中的 IPC 系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。shell(user)-> IPC (kernel) -> 文件系统(user)
  • 文件系统会完成它的工作之后会向IPC系统发送回一条消息说,这是你的exec系统调用的结果,之后IPC系统再将这条消息发送给Shell。文件系统(user)-> IPC (kernel) -> shell(user)

所以,这里是典型的通过消息来实现传统的系统调用。现在,对于任何文件系统的交互,都需要分别完成 2 次用户空间<->内核空间的跳转。与宏内核对比,在宏内核中如果一个应用程序需要与文件系统交互,只需要完成1次用户空间<->内核空间的跳转,所以微内核的的跳转是宏内核的两倍。通常微内核的挑战在于性能更差,这里有两个方面需要考虑:

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

XV6 启动过程

从 kernel.ld 设置的入口点 0x80000000 开始,跳转到 entry.S 文件
1. 操作系统组织架构 - 图3
entry.S 文件再跳转到 start 函数中
image.png
w_mepc 写入 main 函数地址,执行 mret 指令时,跳转到 main 入口函数,执行初始化操作。

PS:xv6 中有 3 种模式,machine mode,supervisor mode (也就是上文所说的 kernel mode),以及 user mode,xv6 中对 machine mode 基本没有怎么使用。xv6 从 entry.S 开始启动,这个时候没有内存分页,没有隔离性,并且运行在 M-mode(machine mode)。xv6 会尽可能快的跳转到 kernel mode 或者说是supervisor mode。运行到 main 函数时,已经运行在 supervisor mode 了。