Lec3 Operating System Organization
Lec2 是 C 和 gdb,跳过。
1 课前准备
Read chapter 2 and xv6 code: kernel/proc.h, kernel/defs.h, kernel/entry.S, kernel/main.c, user/initcode.S, user/init.c, and skim kernel/proc.c and kernel/exec.c.
操作系统需要满足三个需求:复用、隔离和交互。
为什么操作系统要设计成现在这样?不仅是为了安全和隔离,也是为了使用的便利。
1.1 用户模式、监督模式和系统调用
CPU 为强隔离提供硬件支持。RISC-V 有三种 CPU 执行指令的模式:机器模式、监督模式和用户模式。机器模式主要用于配置计算机。xv6 在机器模式下执行几行,后更改为监督模式。
在监督模式下,CPU 可以执行特权命令:启用或禁用中断、读写持有页表地址的寄存器,等等。如果一个用户模式下的应用试图执行特权命令,CPU 不会执行指令,而是切换为监督模式并终止应用。一个只能执行用户模式命令的应用运行在用户空间中,可以执行监督模式下特权命令的应用运行在内核空间中,这个应用称为内核。
想要调用内核函数(例如 xv6 中的 read
系统调用)的应用必须转换到内核;应用程序不能直接调用内核函数。CPU 提供了一种特殊的指令,可以将 CPU 从用户模式切换到监督模式,并在内核指定的入口点进入内核(比如 RISC-V 的 ecall
)。一旦 CPU 切换到监督模式,内核就可以验证系统调用的参数,决定是否允许应用程序执行请求的操作(例如,检查应用程序是否允许写入指定的文件),然后拒绝或执行它。内核控制转换到管理模式的入口点非常重要;例如,如果应用程序可以决定内核入口点,则恶意应用程序可以在跳过参数验证的点进入内核。
1.2 内核组织
设计的核心问题是,应该把操作系统的哪些部分放在监督模式下运行?一个想法是把整个系统全部放入内核中,这种组织方式称为宏内核(monolithic kernel)。优点是方便设计和不同部分间的合作,缺点是接口复杂且开发人员容易犯错误,而在监督模式下犯错是致命的。
为了减少犯错的风险,微内核(microkernel)尽量减少在监督模式下运行的操作系统代码,并将大部分作为服务器(server)在用户模式下运行。比如,文件服务器在用户空间里运行,为允许应用与文件服务器交互,内核通过收发信息来提供进程间的通信,如下图。
两种方法都是流行的组织方法,并没有哪个完全优于另一个的结论。具体问题,具体分析。在本课程中,我们更关注两种方法共同体现出的关键思想。它们实现系统调用、使用页表、处理中断、支持进程、使用锁进行并发控制、实现文件系统等。
与大多数 UNIX 操作系统一样,xv6 是 monolithic 的。因此,xv6 的内核接口就对应于整个操作系统的接口,内核实现了完整的操作系统。由于 xv6 没有提供很多服务,因此它的内核比某些微内核要小,但从概念上讲 xv6 仍是宏内核。
xv6 内核的代码位于 kernel
文件夹下,大致遵循模块化的理念分成各个文件,见书中的表。模块间接口在 kernel/defs.h
中定义。
1.3 进程概览
进程给了程序一个自己仿佛拥有私有机器和私有内存空间(称为地址空间)的幻象。进程同时让程序认为自己拥有了自己的 CPU 来执行命令。这样的设计也是为了保证隔离。
xv6 使用页表(硬件实现)为每个进程提供自己的地址空间。RISC-V 页表将虚拟地址映射到物理地址。xv6 为每个进程维护一个单独的页表,页表定义了进程的地址空间。如图所示,包含用户内存的地址空间从虚拟地址零开始,首先是指令、全局变量,然后是栈、堆。RISC-V 是 64 位的,但硬件在页表种查找虚拟地址时只用低 39 位的数据,而 xv6 只用这之中的 38 位。因此,最大地址为 \(2^{38}-1=\rm{0x3fffffffff}\),即 MAXVA
。在顶端 xv6 为 trampoline 和映射进程的 trapframe 分别保留了一页空间。xv6 使用这两个页面来转换到内核并返回;trampoline 页包含转换进/出内核的代码,并且映射 trapframe 对于保存/恢复用户进程的状态是必要的,第 4 章会细讲。
进程的状态储存在一个 struct proc
中,在下文中我们以 p
代之,并以 p->xxx
代表其中的属性。每个进程都有一个执行线程(或简称线程)执行进程的指令。线程可以挂起并稍后恢复。为了在进程之间透明地切换,内核挂起当前正在运行的线程并恢复另一个进程的线程。线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的栈中。每个进程都有两个栈:用户栈和内核栈 (p->kstack
)。执行用户指令时使用用户栈,进入内核执行内核代码时使用内核栈。进程的线程在这两个栈之间切换。内核栈被用户代码保护。
进程通过 ecall
执行系统调用,这个指令提升了硬件的权限并将 program counter 改为内核定义的入口点,开始执行指令。结束时,内核调用 sret
返回,它同时也降低了硬件权限。进程的线程可以在内核中“阻塞”以等待 I/O,并在 I/O 完成时从中断处恢复。
p->state
指示进程是否已分配、准备运行、正在运行、正在等待 I/O 或正在退出。p->pagetable
是页表。
总之,进程捆绑了两种设计思想:地址空间给进程提供了自己的内存的假象,线程给进程提供了自己的 CPU 的假象。在 xv6 中,一个进程由一个地址空间和一个线程组成。在实际操作系统中,一个进程可能有多个线程来利用多个 CPU。
1.4 Code:starting xv6, the first process and system call
这一节讲了启动 xv6 的具体代码流程,这里省略了,看书吧。
1.5 安全模型
操作系统如何解决恶意代码?操作系统中典型的安全假设是:操作系统必须假设进程的用户级代码可能尽最大努力破坏内核或其他进程;相反,内核代码被认为是由善意且细心的程序员编写的。内核代码应该没有错误,并且肯定不包含任何恶意内容。在硬件层面,RISC-V CPU、RAM、磁盘等应当按照文档中描述的方式运行,没有硬件错误。
当然,现实世界是残酷的。系统漏洞和恶意代码是无法完全杜绝的。
1.6 Real World
现代操作系统支持一个进程多个线程。这涉及到 xv6 所没有的大量机制,包括潜在的接口更改(例如 Linux 的 clone
,一个变体 fork
)。
2 Lecture
课上详细调试了 xv6 系统的启动过程,对应了上面我省略的部分,这里简单叙述一下。程序从 _entry
处开始,先分配一个栈供执行 C 代码,然后调用 main
函数,main
函数主要做一些东西的初始化工作(虚拟内存、页表、文件系统等等)。之后创建第一个进程 userinit
,它更像是一个胶水代码,将执行 initcode
中的内容。而 initcode
则是执行系统调用 syscall_exec
,这个 exec
则执行 init
程序。注意这个 init
与之前不同,是用户级别的初始化程序。它会为用户配置一些东西,如 console,然后 fork
出一个子进程执行 shell。这样 xv6 正式启动,用户可以输入指令了。