3.1 上一节课回顾
- Isolation。隔离性是设计操作系统组织结构的驱动力。
- Kernel和User mode。这两种模式用来隔离操作系统内核和用户应用程序。
- System calls。系统调用是你的应用程序能够转换到内核执行的基本方法,这样你的用户态应用程序才能使用内核服务。
3.2 操作系统隔离性(isolation)
需要在不同的应用程序之间有强隔离性。
需要在应用程序和操作系统之间有强隔离性。
使用操作系统的一个目的是为了同时运行多个应用程序,所以时不时的,CPU会从一个应用程序切换到另一个应用程序。我们假设硬件资源里只有一个CPU核,并且我们现在在这个CPU核上运行Shell。但是时不时的,也需要让其他的应用程序也可以运行。现在我们没有操作系统来帮我们完成切换,所以Shell就需要时不时的释放CPU资源。
为了不变成一个恶意程序,Shell在发现自己运行了一段时间之后,需要让别的程序也有机会能运行。这种机制有时候称为协同调度(Cooperative Scheduling)。但是这里的场景并没有很好的隔离性,比如说Shell中的某个函数有一个死循环,那么Shell永远也不会释放CPU,进而其他的应用程序也不能够运行,甚至都不能运行一个第三方的程序来停止或者杀死Shell程序。所以这种场景下,我们基本上得不到真正的multiplexing(CPU在多进程同分时复用)。而这个特性是非常有用的,不论应用程序在执行什么操作,multiplexing都会迫使应用程序时不时的释放CPU,这样其他的应用程序才能运行。
从内存的角度来说,如果应用程序直接运行在硬件资源之上,那么每个应用程序的文本,代码和数据都直接保存在物理内存中。物理内存中的一部分被Shell使用,另一部分被echo使用。即使在这么简单的例子中,因为两个应用程序的内存之间没有边界,如果echo程序将数据存储在属于Shell的一个内存地址中(下图中的1000),那么echo就会覆盖Shell程序内存中的内容。这是非常不想看到的场景,因为echo现在渗透到了Shell中来,并且这类的问题是非常难定位的。所以这里也没有为我们提供好的隔离性。我们希望不同应用程序之间的内存是隔离的,这样一个应用程序就不会覆盖另一个应用程序的内存。
使用操作系统的一个原因,甚至可以说是主要原因就是为了实现multiplexing和内存隔离。如果你不使用操作系统,并且应用程序直接与硬件交互,就很难实现这两点。所以,将操作系统设计成一个库,并不是一种常见的设计。你或许可以在一些实时操作系统中看到这样的设计,因为在这些实时操作系统中,应用程序之间彼此相互信任。但是在大部分的其他操作系统中,都会强制实现硬件资源的隔离。
fork创建了进程。操作系统不是直接将CPU提供给应用程序,而是向应用程序提供“进程”,进程抽象了CPU,这样操作系统才能在多个应用程序之间复用一个或者多个CPU。
我们可以认为exec抽象了内存。当我们在执行exec系统调用的时候,我们会传入一个文件名,而这个文件名对应了一个应用程序的内存镜像。内存镜像里面包括了程序对应的指令,全局的数据。应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限,例如应用程序不能直接访问物理内存的1000-2000这段地址。不能直接访问的原因是,操作系统会提供内存隔离并控制内存,操作系统会在应用程序和硬件资源之间提供一个中间层。exec是这样一种系统调用,它表明了应用程序不能直接访问物理内存。
另一个例子是files,files基本上来说抽象了磁盘。应用程序不会直接读写挂在计算机上的磁盘本身,并且在Unix中这也是不被允许的。在Unix中,与存储系统交互的唯一方式就是通过files。Files提供了非常方便的磁盘抽象,你可以对文件命名,读写文件等等。之后,操作系统会决定如何将文件与磁盘中的块对应,确保一个磁盘块只出现在一个文件中,并且确保用户A不能操作用户B的文件。通过files的抽象,可以实现不同用户之间和同一个用户的不同进程之间的文件强隔离。
更复杂的内核会不会尝试将进程调度到同一个CPU核上来减少Cache Miss?
是的。有一种东西叫做Cache affinity。现在的操作系统的确非常复杂,并且会尽量避免Cache miss和类似的事情来提升性能。我们在这门课程后面介绍高性能网络的时候会介绍更多相关的内容。
3.3 操作系统防御性(Defensive)
操作系统应该具有防御性(Defensive)。
当你在做内核开发时,这是一种你需要熟悉的重要思想。操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击。如果说应用程序无意或者恶意的向系统调用传入一些错误的参数就会导致操作系统崩溃,那就太糟糕了。在这种场景下,操作系统因为崩溃了会拒绝为其他所有的应用程序提供服务。所以操作系统需要以这样一种方式来完成:操作系统需要能够应对恶意的应用程序。
通常来说,需要通过硬件来实现这的强隔离性。我们这节课会简单介绍一些硬件隔离的内容,但是在后续的课程我们会介绍的更加详细。这里的硬件支持包括了两部分,第一部分是user/kernel mode,kernel mode在RISC-V中被称为Supervisor mode但是其实是同一个东西;第二部分是page table或者虚拟内存(Virtual Memory)。
所以,所有的处理器,如果需要运行能够支持多个应用程序的操作系统,需要同时支持user/kernel mode和虚拟内存。
3.4 硬件对于强隔离的支持
硬件对于强隔离的支持包括了:user/kernle mode和虚拟内存。
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,当操作系统拿到控制权之后,或许会杀掉进程,因为应用程序执行了不该执行的指令。
如果kernel mode允许一些指令的执行,user mode不允许一些指令的执行,那么是谁在检查当前的mode并实际运行这些指令,并且怎么知道当前是不是kernel mode?是有什么标志位吗?
是的,在处理器里面有一个flag。在处理器的一个bit,当它为1的时候是user mode,当它为0时是kernel mode。当处理器在解析指令时,如果指令是特殊权限指令,并且该bit被设置为1,处理器会拒绝执行这条指令,就像在运算时不能除以0一样。
许多同学都已经知道了,实际上RISC-V还有第三种模式称为machine mode。在大多数场景下,我们会忽略这种模式,所以我也不太会介绍这种模式。 所以实际上我们有三级权限(user/kernel/machine),而不是两级(user/kernel)。
那BIOS呢?BIOS会在操作系统之前运行还是之后?
BIOS是一段计算机自带的代码,它会先启动,之后它会启动操作系统,所以BIOS需要是一段可被信任的代码,它最好是正确的,且不是恶意的。
在RISC-V中,如果你在用户空间(user space)尝试执行一条特殊权限指令,用户程序会通过系统调用来切换到kernel mode。当用户程序执行系统调用,会通过ECALL触发一个软中断(software interrupt),软中断会查询操作系统预先设定的中断向量表,并执行中断向量表中包含的中断处理程序。中断处理程序在内核中,这样就完成了user mode到kernel mode的切换,并执行用户程序想要执行的特殊权限指令。
虚拟内存
page table将虚拟内存地址与物理内存地址做了对应。
每一个进程都会有自己独立的page table,这样的话,每一个进程只能访问出现在自己page table中的物理内存。操作系统会设置page table,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,因为其他进程的物理内存都不在它的page table中。一个进程甚至都不能随意编造一个内存地址,然后通过这个内存地址来访问其他进程的物理内存。这样就给了我们内存的强隔离性。
内核位于应用程序下方,假设是XV6,那么它也有自己的内存地址空间,并且与应用程序完全独立。
3.5 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。
假设我现在要执行另一个系统调用write,相应的流程是类似的,write系统调用不能直接调用内核中的write代码,而是由封装好的系统调用函数执行ECALL指令。所以write函数实际上调用的是ECALL指令,指令的参数是代表了write系统调用的数字。之后控制权到了syscall函数,syscall会实际调用write系统调用。
这里需要澄清的是,用户空间和内核空间的界限是一个硬性的界限,用户不能直接调用fork,用户的应用程序执行系统调用的唯一方法就是通过这里的ECALL指令。
操作系统在什么时候检查是否允许执行fork或者write?现在看起来应用程序只需要执行ECALL再加上系统调用对应的数字就能完成调用,但是内核在什么时候决定这个应用程序是否有权限执行特定的系统调用?
是个好问题。原则上来说,在内核侧实现fork的位置可以实现任何的检查,例如检查系统调用的参数,并决定应用程序是否被允许执行fork系统调用。在Unix中,任何应用程序都能调用fork,我们以write为例吧,write的实现需要检查传递给write的地址(需要写入数据的指针)属于用户应用程序,这样内核才不会被欺骗从别的不属于应用程序的位置写入数据。
当应用程序表现的恶意或者就是在一个死循环中,内核是如何夺回控制权限的?
内核会通过硬件设置一个定时器,定时器到期之后会将控制权限从用户空间转移到内核空间,之后内核就有了控制能力并可以重新调度CPU到另一个进程中。我们接下来会看一些更加详细的细节。
这其实是一个顶层设计的问题,是什么驱动了操作系统的设计人员使用编程语言C?
啊,这是个好问题。C提供了很多对于硬件的控制能力,比如说当你需要去编程一个定时器芯片时,这更容易通过C来完成,因为你可以得到更多对于硬件资源的底层控制能力。所以,如果你要做大量的底层开发,C会是一个非常方便的编程语言,尤其是需要与硬件交互的时候。当然,不是说你不能用其他的编程语言,但是这是C成功的一个历史原因。
为什么C比C++流行的多?仅仅是因为历史原因吗?有没有其他的原因导致大部分的操作系统并没有采用C++?
我认为有一些操作系统是用C++写的,这完全是可能的。但是大部分你知道的操作系统并不是用C++写的,这里的主要原因是Linus不喜欢C++,所以Linux主要是C语言实现。
3.6 宏内核 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的程序。
其中一个选项是让整个操作系统代码都运行在kernel mode。大多数的Unix操作系统实现都运行在kernel mode。比如,XV6中,所有的操作系统服务都在kernel mode中,这种形式被称为Monolithic Kernel Design(宏内核)。
这里有几件事情需要注意:
- 首先,如果考虑Bug的话,这种方式不太好。从安全的角度来说,在内核中有大量的代码是宏内核的缺点。
- 另一方面,如果你去看一个操作系统,它包含了各种各样的组成部分,比如说文件系统,虚拟内存,进程管理,这些都是操作系统内实现了特定功能的子模块。宏内核的优势在于,因为这些子模块现在都位于同一个程序中,它们可以紧密的集成在一起,这样的集成提供很好的性能。例如Linux,它就有很不错的性能。
另一种设计主要关注点是减少内核中的代码,它被称为Micro Kernel Design(微内核)。在这种模式下,希望在kernel mode中运行尽可能少的代码。所以这种设计下还是有内核,但是内核只有非常少的几个模块,例如,内核通常会有一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。
微内核的目的在于将大部分的操作系统运行在内核之外。所以,我们还是会有user mode以及user/kernel mode的边界。但是我们现在会将原来在内核中的其他部分,作为普通的用户程序来运行。比如文件系统可能就是个常规的用户空间程序。可能还会有一些其他的用户应用程序,例如虚拟内存系统的一部分也会以一个普通的应用程序的形式运行在user mode。
某种程度上来说,这是一种好的设计。因为在内核中的代码的数量较小,更少的代码意味着更少的Bug。
但是这种设计也有相应的问题。假设我们需要让Shell能与文件系统交互,比如Shell调用了exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是,Shell会通过内核中的IPC(进程间通信)系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。文件系统会完成它的工作之后会向IPC系统发送回一条消息说,这是你的exec系统调用的结果,之后IPC系统再将这条消息发送给Shell。
这里是典型的通过消息来实现传统的系统调用。
现在,对于任何文件系统的交互,都需要分别完成2次用户空间<->内核空间的跳转。与宏内核对比,在宏内核中如果一个应用程序需要与文件系统交互,只需要完成1次用户空间<->内核空间的跳转,所以微内核的的跳转是宏内核的两倍。通常微内核的挑战在于性能更差,这里有两个方面需要考虑:
- 在user/kernel mode反复跳转带来的性能损耗。
- 在一个类似宏内核的紧耦合系统,各个组成部分,例如文件系统和虚拟内存系统,可以很容易的共享page cache。而在微内核中,每个部分之间都很好的隔离开了,这种共享更难实现。进而导致更难在微内核中得到更高的性能。
我们这里介绍的有关宏内核和微内核的区别都特别的笼统。在实际中,两种内核设计都会出现,出于历史原因大部分的桌面操作系统是宏内核,如果你运行需要大量内核计算的应用程序,例如在数据中心服务器上的操作系统,通常也是使用的宏内核,主要的原因是Linux提供了很好的性能。但是很多嵌入式系统,例如Minix,Cell,这些都是微内核设计。这两种设计都很流行,如果你从头开始写一个操作系统,你可能会从一个微内核设计开始。但是一旦你有了类似于Linux这样的宏内核设计,将它重写到一个微内核设计将会是巨大的工作。并且这样重构的动机也不足,因为人们总是想把时间花在实现新功能上,而不是重构他们的内核。