1. 前言
操作系统必须满足:
1.多路复用(multiplexing):OS要同时支持多个活动。如第一章的系统调用接口,一个进程可以用fork启动新进程。OS为进程分时的使用计算机资源,例如,即使进程比硬件处理器多,操作系统也必须确保所有进程都有机会执行。
2.隔离(isolation):如果一个进程有错误和故障,它不应该影响不依赖于有错误的进程的进程。
3.交互(interaction):完全隔离又太过头了,进程之间应当可以进行刻意为之的交互;管道就是一个例子。
1.1 抽象系统资源
我们为什么需要操作系统?
我们可以将系统调用实现为一个库,应用程序可以与之链接。在此方案中,每个应用程序甚至可以根据自己的需求定制自己的库。应用程序可以直接与硬件资源交互,并以应用程序的最佳方式使用这些资源(例如,实现高性能或可预测的性能)。一些嵌入式设备或实时系统的操作系统就是这样组织的。
这种方法的缺点是,如果有多个应用程序在运行,这些应用程序必须运行完好。例如,每个应用程序必须定期放弃中央处理器,以便其他应用程序能够运行。如果所有应用程序都相互信任并且没有错误,这种协同操作的分时方案可能是可以的。然而通常情况下应用程序互不信任且存在bug,所以人们通常希望提供比上述方案更强的隔离。
为了实现强隔离, 最好禁止应用程序直接访问敏感的硬件资源,而是将资源抽象为服务。 例如,Unix应用程序只通过文件系统的open
、read
、write
和close
系统调用与存储交互,而不是直接读写磁盘。这为应用程序提供了方便实用的路径名,并允许操作系统(作为接口的实现者)管理磁盘。即使隔离不是一个问题,有意交互(或者只是希望互不干扰)的程序可能会发现文件系统比直接使用磁盘更方便。
同样,Unix在进程之间透明地切换硬件处理器,根据需要保存和恢复寄存器状态,这样应用程序就不必意识到分时共享的存在。这种透明性允许操作系统共享处理器,即使有些应用程序处于无限循环中。
另一个例子是,Unix进程使用exec
来构建它们的内存映像,而不是直接与物理内存交互。这允许操作系统决定将一个进程放在内存中的哪里;如果内存很紧张,操作系统甚至可以将一个进程的一些数据存储在磁盘上。exec
还为用户提供了存储可执行程序映像的文件系统的便利。
Unix进程之间的许多交互形式都是通过文件描述符实现的。文件描述符不仅抽象了许多细节(例如,管道或文件中的数据存储在哪里),而且还以简化交互的方式进行了定义。例如,如果流水线中的一个应用程序失败了,内核会为流水线中的下一个进程生成文件结束信号(EOF)。
1.2 用户态,核心态,以及系统调用
强隔离需要应用程序和操作系统之间的硬边界,如果应用程序出错,我们不希望操作系统失败或其他应用程序失败,相反,操作系统应该能够清理失败的应用程序,并继续运行其他应用程序,要实现强隔离,操作系统必须保证应用程序不能修改(甚至读取)操作系统的数据结构和指令,以及应用程序不能访问其他进程的内存。
CPU为强隔离提供硬件支持。例如,RISC-V有三种CPU可以执行指令的模式:机器模式(Machine Mode)、用户模式(User Mode)和管理模式(Supervisor Mode)。在机器模式下执行的指令具有完全特权;CPU在机器模式下启动。机器模式主要用于配置计算机。Xv6在机器模式下执行很少的几行代码,然后更改为管理模式。
在管理模式下,CPU被允许执行特权指令(例如,启用和禁用中断、读取和写入保存页表地址的寄存器等。)。如果用户模式下的应用程序试图执行特权指令,那么CPU不会执行该指令,而是切换到管理模式。
应用程序只能执行用户模式的指令(例如,数字相加等),并被称为在用户空间中运行,而此时处于管理模式下的软件可以执行特权指令,并被称为在内核空间中运行。在内核空间(或管理模式)中运行的软件被称为内核。
想要调用内核函数的应用程序(例如xv6中的read
系统调用)必须过渡到内核。CPU提供一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V为此提供ecall
指令)。一旦CPU切换到管理模式,内核就可以验证系统调用的参数,决定是否允许应用程序执行请求的操作,然后拒绝它或执行它。由内核控制转换到管理模式的入口点是很重要的;如果应用程序可以决定内核入口点, 那么恶意应用程序可以在跳过参数验证的地方进入内核。
1.3 内核组织
一个关键的设计问题是操作系统的哪些部分应该以管理模式运行。
宏内核(monolithic kernel):一种可能是整个操作系统都驻留在内核中,这样所有系统调用的实现都以管理模式运行。在这种组织中,整个操作系统以完全的硬件特权运行。这个组织很方便,因为操作系统设计者不必考虑操作系统的哪一部分不需要完全的硬件特权。此外,操作系统的不同部分更容易合作。例如,一个操作系统可能有一个可以由文件系统和虚拟内存系统共享的数据缓存区。其缺点是操作系统不同部分之间的接口通常很复杂,因此操作系统开发人员很容易犯错误。因此操作系统开发人员很容易犯错误。
微内核(microkernel):操作系统设计者可以最大限度地减少在管理模式下运行的操作系统代码量,并在用户模式下执行大部分操作系统。微内核降低了内核出错的风险。
图2.1说明了这种微内核设计。在图中,文件系统作为用户级进程运行。作为进程运行的操作系统服务被称为服务器。为了允许应用程序与文件服务器交互,内核提供了允许从一个用户态进程向另一个用户态进程发送消息的进程间通信机制。例如,如果像shell这样的应用程序想要读取或写入文件,它会向文件服务器发送消息并等待响应。
在微内核中,内核接口由一些用于启动应用程序、发送消息、访问设备硬件等的低级功能组成。这种组织允许内核相对简单,因为大多数操作系统驻留在用户级服务器中。
Xv6像大多数Unix操作系统一样,作为一个宏内核实现的。因此,xv6内核接口对应于操作系统接口,内核实现了完整的操作系统。由于xv6不提供太多服务,它的内核可以比一些微内核还小,但从概念上说xv6属于宏内核
1.4代码(XV6架构篇)
XV6的源代码位于kernel/子目录中,源代码按照模块化的概念划分为多个文件,表1.4列出了这些文件,模块间的接口都被定义在了def.h(kernel/defs.h)。
文件 | 描述 |
---|---|
bio.c | 文件系统的磁盘块缓存 |
console.c | 连接到用户的键盘和屏幕 |
entry.S | 首次启动指令 |
exec.c | exec() 系统调用 |
file.c | 文件描述符支持 |
fs.c | 文件系统 |
kalloc.c | 物理页面分配器 |
kernelvec.S | 处理来自内核的陷入指令以及计时器中断 |
log.c | 文件系统日志记录以及崩溃修复 |
main.c | 在启动过程中控制其他模块初始化 |
pipe.c | 管道 |
plic.c | RISC-V中断控制器 |
printf.c | 格式化输出到控制台 |
proc.c | 进程和调度 |
sleeplock.c | Locks that yield the CPU |
spinlock.c | Locks that don’t yield the CPU |
start.c | 早期机器模式启动代码 |
string.c | 字符串和字节数组库 |
swtch.c | 线程切换 |
syscall.c | Dispatch system calls to handling function |
sysfile.c | 文件相关的系统调用 |
sysproc.c | 进程相关的系统调用 |
trampoline.S | 用于在用户和内核之间切换的汇编代码 |
trap.c | 对陷入指令和中断进行处理并返回的C代码 |
uart.c | 串口控制台设备驱动程序 |
virtio_disk.c | 磁盘设备驱动程序 |
vm.c | 管理页表和地址空间 |