MIT6828 学习笔记 003 (book::c2)

MIT6828 学习笔记 003 (book::c2)

RayAlto OP

操作系统需要满足三个需求:

  • 复用 (multiplexing) :即使进程数超过了 CPU 数,每个进程都应该有机会运行,实现对计算机资源的 time-share
  • 隔离 (isolation) :即使进程出错,它也不应该影响不依赖于出错进程的其他进程
  • 交互 (interaction) :允许进程间主动通信,如 pipe

这一章介绍宏内核如何系统地实现上面三个需求。

Xv6 采用 LP64 C 编写,运行在 qemu -machine virt 模拟出的硬件上,有一个多核 RISC-V 微处理器,外围设备有: RAM 、包含启动代码的 ROM 、连接了用户的键盘/屏幕的串口、一块磁盘

1. 物理资源的抽象

为什么需要操作系统来管理硬件资源?直接按照 000 中列出的系统调用表把操作系统封装成一个库供用户程序链接,这样用户程序甚至可以设计一套对于自己最高效的库来使用,甚至一些嵌入式设备的操作系统和即时计算机系统都是这样设计的。

  • 进程直接与硬件交互的情况下如果某个进程一直占用 CPU 不放,其他进程就不能运行。
  • 比如 Unix 提供了文件系统,进程可以方便地通过文件名访问磁盘数据。
  • 比如 Unix 进程用 exec 方便地加载映像,不用手动管理内存空间,甚至在内存空间紧张时操作系统可以把进程的一部分数据保存在磁盘上。
  • 比如 Unix 提供了基于 fd 的进程间通信方式,不仅抽象了很多细节,还简化了过程,比如一方进程崩溃后操作系统会给另一方发送 EOF 进行提醒。

2. user mode, supervisor mode 和系统调用

操作系统需要提供强隔离 (strong isolation) ,进程崩溃后操作系统应该清理资源并继续正常运行其他进程,且不应该让进程接触到操作系统的数据结构和指令或其他进程的内存。 CPU 为这个强隔离提供了硬件支持,比如 RISC-V 提供了三个执行指令的模式: machine mode, supervisor mode 和 user mode 。

  1. machine mode: 模式下的指令具有全部权限,主要用于配置计算机。 CPU 以这个模式启动; Xv6 在这个模式下执行一些指令后进入 supervisor mode 。
  2. supervisor mode: 模式下 CPU 可以执行特权指令,比如启用/禁用 interrput 、读写保存 page table 地址的寄存器等。运行在 supervisor mode 的程序可以执行特权指令(也就是运行在 kernel space ),这种程序通常称作内核 (kernel) 。
  3. user mode: 如果处于 user mode 的程序试图执行特权指令, CPU 会忽略这个指令切换至 supervisor mode 以终止这个程序,因为这种程序只能执行比如数字的加减的 user mode 指令(也就是运行在 user space )。

进程在调用系统调用时必须切换到内核(执行内核里的指令)而不能直接调用内核里的方法。 CPU 提供一个特殊的指令把 user mode 的 CPU 切换到 supervisor mode 并进入内核指定的内核入口点( RISC-V 通过 ecall 指令完成这个操作)。 CPU 切换至 supervisor mode 后内核会检查系统调用的参数是否合法(比如检查传来的地址参数是否为进程自己的地址)来决定进程是否被允许执行请求的操作(比如检查进程是否被允许写入某个文件)后拒绝或执行这个请求。

进入的内核入口点必须由内核指定,如果用户进程可以随意进入内核的任意入口点,它就有可能进入某个入口点来跳过内核对请求的检查。

3. 内核架构

问题:操作系统的哪些部分应该运行在 supervisor mode ?一个答案是整个操作系统都在内核里,所以所有系统调用的实现都运行在 supervisor mode 。这就是 monolithic kernel (宏内核)架构。

3.1. monolithic kernel (宏内核)

宏内核下所有系统调用都具有完整的硬件权限,这种设计使操作系统的设计者不必考虑操作系统的哪一部分不需要完整的硬件权限,而且为操作系统不同部分的合作提供了方便(比如操作系统可能需要在文件系统和虚拟内存系统之间共享一块缓冲区)。

这种设计也有一个缺点:操作系统的不同部分之间的接口通常很复杂(后面会解释),导致操作系统的开发者容易犯错,在 monolithic kernel 下错误是致命的,因为 supervisor mode 下的一个错误就会导致内核崩溃,内核崩溃后计算机会停止工作,所有进程也都会崩溃,这时只能重启计算机。

读到这我才明白这个“宏”指的并不是 macro ,而是有一点“宏大”,“整体”的意思

3.2. microkernel (微内核)

为了弥补宏内核的不足,操作系统的设计者可以减少运行在 supervisor mode 的代码数量,使一部分操作系统运行在 user mode ,这就是 microkernel 。在这种架构下,比如文件系统,是一个 user mode 的进程,被称作 server ,当进程想要读写文件时,它会通过内核提供的一套 user mode 进程间通信机制向文件系统 server 发送请求后等待响应。这样的设计下,内核接口只需要一些底层方法,比如:启动进程、发送信息、访问硬件设备等,使得内核相对简洁(因为操作系统的大部分都在 user mode 的 server 里)。

3.3. 总览

monolithic kernel 和 microkernel 都很流行。 Linux 有一个 monolithic kernel ,但一些操作系统功能如窗口系统 (X,是你吗?) 也是一个 user mode 的 server 。 Linux 为操作系统密集型应用程序提供了高性能,部分原因就是内核的子系统可以紧密集成。 Minix , L4 , QNX 等都是微内核 + servers 的架构,在嵌入式设备中得到了广泛的应用。

什么叫操作系统密集型应用程序?是 kvm 之类的吗?

有些 microkernel 操作系统为了提高性能也会让一些用户层面的服务运行在内核空间;有些 monolithic kernel 操作系统在设计之初就是 monolithic kernel ,比起重构为 microkernel ,开发新功能可能更为重要。所以有时存在比“采用什么架构更好”这个问题更值得考虑的因素。 microkernel 和 monolithic kernel 有很多共同点,比如:都实现了系统调用、都用 page tables (分页表)、都处理 interrput 、都支持进程、都用锁控制并发、都实现了文件系统等等。 Xv6 是 monolithic kernel ,所以它的内核接口对应操作系统接口,且内核实现了整个操作系统,因为 Xv6 没有提供很多服务,所以体积上比一些 microkernel 还小,但概念上是宏内核。

4. 代码: Xv6 架构

Xv6 内核源码在 kernel/ 目录下,粗略地按照模块分成了几个文件,模块间接口的定义在 kernel/defs.h

文件描述
bio.c文件系统的磁盘块缓存
console.c连接用户的键盘和屏幕
entry.S最初启动指令
exec.cexec 系统调用
file.cfd 支持
fs.c文件系统
kalloc.c物理 page 分配器
kernelvec.S处理内核的 trap 和 timer interrupt
log.c文件系统的日志和崩溃恢复
main.c控制启动过程中其他模块的初始化
pipe.cpipe
plic.cRISC-V 的 interrupt 控制器
printf.cprintf
proc.c进程 & 调度
sleeplock.c让出 CPU 的锁
spinlock.c不让出 CPU 的锁
start.c初期 machine mode 启动代码
string.ccstr
switch.S线程切换
syscall.c把系统调用分派给实际的处理函数
sysfile.c文件相关的系统调用
sysproc.c进程相关的系统调用
trampoline.Skernel↔user 切换的汇编代码
trap.c处理 trap 和 interrupt 并返回的 C 代码
uart.c串口 console 设备驱动
virtio_disk.c磁盘设备驱动
vm.c管理 page table 和 address spaces

5. 进程概述

Xv6 下隔离 (isolation) 的单元(和其他 Unix 操作系统一样)是进程,这个进程抽象防止进程破坏或监视其他进程的内存、 CPU 、 fd 等,也防止进程破坏内核。所以内核必须仔细小心地实现进程隔离,使用的机制包括 user/supervisor mode flag 、 address spaces 和线程的 time-slicing 。为了辅助执行隔离,进程抽象使程序认为自己运行在独立的机器上,比如其他进程不可读写的私有内存系统 (address space) 和来运行程序的指令的 CPU 。

Xv6 通过硬件实现的 page tables 来定义、并给每个进程提供自己的 address space ( RISC-V page table 把由 RISC-V 指令控制的虚拟地址翻译/映射成实际的物理地址):

进程的address space

有几个因素限制了进程 address space 的最大值: RISC-V 下指针是 64 bits 的,硬件在查找虚拟地址时只用其中的 low 39 bits , Xv6 只用其中的 38 bits ,所以地址最大值为0x3fffffffff (MAXVA, kernel/riscv.h:363)

Xv6 利用顶部的两个 page 来进出内核, trampoline page 包含进出内核的切换代码,映射 trapframe 对保存/恢复用户进程状态是必须的( c4 会介绍)。

每个进程都有一个执行线程(简称线程)来执行进程的指令,这个线程可以被挂起一段时间后继续,内核通过挂起当前进程并继续其他进程的线程实现进程间透明地切换。进程的大多数状态信息(局部变量、函数调用返回地址等)被保存在线程的两个栈上:用户栈和内核栈,进程执行用户指令时只用它的用户栈(此时内核栈为空),进程进入内核(因为系统调用或 interrput 等)后,内核代码在进程的内核栈上执行(此时不会使用用户栈,且用户栈数据不变),也就是说进程的线程在使用它的用户栈和内核栈之间不断切换。内核栈是分离的(且被保护不能被用户代码访问),所以即使进程打乱了它的用户栈,内核依然可以继续执行。

进程可以通过 RISC-V 的 ecall 指令调用系统调用,这个指令提升硬件权限等级并把 PC 切换到一个内核定义的入口点,这个入口点的代码切换内核栈并执行实现了这个系统调用的内核指令,当系统调用完成后,内核切换回进程的用户栈并通过调用 sret 指令返回用户空间(降低了硬件权限等级并继续执行这次系统调用后面的用户指令)。进程的线程可以在内核里 “block” 等待 I/O ,然后在 I/O 完成后从原地继续。

Xv6 内核为每个进程维护许多状态片段,一并放进一个 struct proc (kernel/proc.h:85) 里,其中比较重要的有它的 page table ,它的内核栈和它的运行状态。

  • p->stat (enum) 表示进程状态: allocated, ready to run, running, waiting for I/O 或 exiting
  • p->pagetable (pagetable_t, uint64*) :指向以 RISC-V 硬件要求的格式保存的进程 page table ,在用户空间执行进程时 Xv6 使 paging 硬件使用进程的 p->pagetable 。进程的 page table 也用于记录物理 pages (用于存储进程内存)的地址

总结起来,进程结合了两个设计理念: address space 使进程认为自己有独立的内存;线程使进程认为自己有独立的 CPU 。

Xv6 下进程由一个 address space 和一个线程组成,而现代的操作系统为了充分利用多个 CPU 可能给一个进程分配多个线程。

6. 代码:启动 Xv6 ,第一个进程和系统调用

为了具体描述 Xv6 ,这节是 Xv6 启动过程的概述,后面的章节会详细展开

RISC-V 计算机通电启动时,它会自初始化,运行 ROM 里的 boot loader (引导加载程序),这个 boot loader 会把 Xv6 内核加载进内存,然后 CPU 在 machine mode 下从 _entry (kernel/entry.S:7) 开始运行 Xv6 。 RISC-V 启动时 paging 硬件默认是禁用状态(虚拟地址直接映射到物理地址)。

boot loader 会把 Xv6 内核加载到物理内存 0x80000000 而不是 0x0 的原因是 0x0:0x80000000 包含 I/O 设备

_entry 的指令创建一个栈供 Xv6 运行 C 代码,Xv6 声明一个初始栈 stack0 的空间 (kernel/start.c:11) , _entry 的代码把栈指针的寄存器 sp 加载为 stack0+4096 (⚠️ 这里应该是 stack0+(hartid*4096), kernel/entry.S:11 ⚠️ ),也就是栈顶,因为 RISC-V 的栈向下延伸。现在内核有了一个栈, _entry 调用 start 处的 C 代码 (kernel/start.c:21) ,执行一些只能运行在 machine mode 的配置,然后切换到 supervisor mode 。

hart 在 RISC-V 中指硬件线程,与软件控制的线程相反,一般可以看作 CPU 线程

切换操作使用 RISC-V 的 mret 指令,这个指令通常用于从上一个由 supervisor mode 转入 machine mode 的调用返回(到 supervisor mode ),但 start 的过程是这样的:执行以下还没完成的配置:把寄存器 mstatus (上一次的权限模式)设置为 supervisor ;把寄存器 mepc (返回地址)写入 main 的地址;把寄存器 satp (page table) 写入 0 来禁用虚拟地址转换 (virtual address translation) ;将所有 interrput 和异常委托给 supervisor mode ,然后编程 clock chip 来产生 timer interrupts ,最后调用 mret 来“返回”( mret 表示返回,但 start 在从一开始就是 machine mode 所以不算返回) supervisor mode ,导致 PC 变成 main (kernel/main.c:11) ,在 main 初始化一些设备和子系统后,它会调用 userinit (kernel/proc.c:233) 创建第一个进程执行一个 RISC-V 汇编的小程序(由 user/initcode.S 汇编产生),这个程序进行 Xv6 的第一次系统调用: user/initcode.S:11 加载 exec 系统调用的号码(定义在 kernel/syscall.h:8 的 SYS_exec )进寄存器 a7 ,然后调用 ecall 重新进入内核,内核使用寄存器 a7SYS_exec 号码通过系统调用表 (kernel/syscall.c:107) 获取 sys_exec 函数,调用这个函数把自己替换成 /init 程序。

内核一旦完成 exec 就回到 /init 进程的用户空间, /init (user/init.c:15) 按需创建一个新的 console 文件 (mknod) 并以 fd 0, 1, 2 打开,然后它在这个 console 上启动一个 shell ,这个系统就完成了启动。

Xv6启动过程

7. 安全模型

比起不小心写出的 bug ,故意写 bug 的程序员往往能整出更多花活,所以在设计操作系统时应该有如下假设:进程的用户级别代码会尽力破坏内核和其他进程、用户代码可能会访问自己的 address space 以外的数据、可能会尝试使用甚至不适用于用户代码的 RISC-V 指令、可能尝试读写 RESC-V 控制寄存器、可能尝试直接访问设备硬件、可能会带着整活的参数调用系统调用来尝试给内核整花活。内核要做的就是限制用户进程不能整活,只能访问自己的内存、使用 RISC-V 的 32 个通用寄存器、以系统调用所允许的方式影响内核和其他进程,这也是设计内核的一个绝对需求。

内核这边对于安全的期望就有所不同了,内核代码一般被假设是由善意且细心的程序员写的,没有 bug 或恶意代码且遵循了内核自己的函数和数据结构的所有使用规则;硬件方面假设 RISC-V CPU, RAM, 磁盘等都按照它们的文档介绍的方式正常运行没有错误。而现实生活中总会有人想整活、开发人员也不可能写出完全没有错误的代码或设计出完全没有缺陷的硬件。设计内核时需要设计安全保护来对抗内核里有 bug 的可能性: assertions 、类型检查、栈的 guard pages 等等。

最后,用户代码和内核代码之间的区别有时是模糊的:一些特权用户级别进程可以提供必要的服务,并有效地成为操作系统的一部分;一些操作系统中,特权用户代码可以想内核插入新的代码(如 Linux 的可加载内核模块, loadable kernel modules)。

8. 联系现实

大多属操作系统都有进程的概念, Xv6 的进程和它们类似,然而现代操作系统都支持在一个进程里运行多个线程来充分使用多个 CPU ,然而 Xv6 缺少很多机制没办法实现多线程。

练习题

题目:给 Xv6 增加一个获取可用内存的系统调用