XV6(2) Operating system organization
2023-08-09 14:53:19 # MIT # 6.S081

Chapter 2 Operating system organization

操作系统应当能够保证同时支持多个活动,不同进程可以共享计算机的资源

操作系统还应保证进程间的隔离性(isolation),也就是说,如果一个进程出现错误和故障,不应影响不依赖于这个错误进程的进程。

但是,不应做到完全隔离,因为不同进程间也会进行交互,例如管道。

因此,操作系统必须满足三个要求

  • 多进程支持(multiplexing)
  • 进程间隔离(isolation)
  • 受控制的进程间交互(interaction)

2.1 Abstracting physical resources

方案一

可以将系统调用实现为一个库,应用程序与之链接。在这个方案中,每个应用程序都有专属于自己的库来满足其需求,可以直接与硬件资源交互,并且可以以最优方式使用(例如,实现高性能或达到可预测的性能)。一些嵌入式设备或实时系统的操作系统就是以这种方式组织的。

如果有多个应用程序在同时运行,则每个应用程序必须表现良好。例如,每个应用程序必须定期放弃CPU,以便其他应用程序可以运行。如果所有应用程序彼此信任并且没有出现错误,则这种协作分时方案是可行的。但对于应用程序来说,更典型的情况是彼此不信任,并且可能存在bug。因此通常需要比该方案更强的隔离性。

方案二

为了实现强隔离,可以禁止应用程序直接访问敏感硬件资源,将资源抽象为服务。

例如,Unix应用程序仅通过文件系统的openreadwriteclose系统调用与存储交互,而不是直接读取和写入磁盘。

这为应用程序提供了路径名的便利,并允许操作系统(作为接口的实现者)来管理磁盘。

该方案中,即使对隔离的要求不高,有意交互的程序(或只是希望避免相互干扰)也会发现使用文件系统抽象也比直接使用磁盘更加方便

例1. Unix可以在进程间透明地切换CPU,根据需要保存和恢复寄存器状态,因此应用程序不必知道’分时’如何实现,这种透明性允许操作系统共享CPU,即使某些应用程序处于死循环

例2. Unix进程使用exec构建其内存映像,而不是直接和物理内存交互。这允许操作系统决定进程在内存中如何存放,如果内存紧张,操作系统甚至会将进程的部分数据存放在磁盘。exec还为用户提供了存储可执行程序图像的文件系统的便利

例3. Unix进程之间的许多交互形式都是通过文件描述符进行的。文件描述符不仅抽象了许多细节(例如管道或文件中的数据存储位置),而且还以简化交互的方式定义。例如,如果管道中的一个应用程序出错了,内核将为管道中的下一个进程生成EOF信号。

2.2 User mode, supervisor mode, and system calls

为了实现强隔离,操作系统必须保证应用程序不能修改(甚至读取)操作系统的数据结构和指令,并且应用程序不能访问其他进程的内存

CPU为强隔离提供硬件支持。例如,RISC-V有三种CPU可以执行指令的模式

  • machine mode: 权限最高,主要目的是为了配置电脑,CPU以machine mode启动,之后立即切换到supervisor mode。
  • supervisor mode: 允许CPU执行privileged instructions,比如中断管理、对存储页表地址的寄存器进行读写操作、执行system call(从user mode切换到supervisor mode)。运行在supervisor mode也称为在kernel space中运行。
  • user mode: 应用程序只能执行user mode指令,比如改变变量、执行util function。运行在user mode也称为在user space中运行。要想让CPU从user mode切换到supervisor mode,RISC-V提供了一个特殊的ecall指令。一旦CPU切换到supervisor mode,内核会验证系统调用的参数(例如检查地址是否是该程序内存的一部分),决定是否执行。

内核而不是应用程序控制切换到supervisor mode的入口点(地址)非常重要,可以避免恶意程序跳过参数验证

2.3 Kernel organization

monolithic kernel(宏内核): 整个操作系统都在kernel中,所有system call都在supervisor mode下运行,拥有全部硬件权限,不同部分便于合作。但是一旦出现错误常常会导致内核崩溃,导致所有程序出错,不得不重启。xv6就是monolithic kernel

micro kernel(微内核): 将需要运行在supervisor mode下的操作系统代码压到最小,保证kernel内系统的安全性,将大部分的操作系统代码执行在user mode下。会有在user/kernel mode反复跳转带来的性能损耗。

image-20220915012051093

如2.1所示,文件系统是一个user-level的进程,为其他进程提供服务,因此也叫做server

作为进程运行的操作系统服务称为server。

为了允许应用程序与文件服务交互,内核提供了一种进程间通信机制,将消息从一个用户模式进程发送到另一个进程。(例如,如果像shell这样的应用程序想要读取或写入文件,它会通过内核中的IPC系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统并等待响应)

xv6 kernel source file如下所示

image-20220915010746816

2.4 Process overview

隔离的单元叫做进程,进程的抽象使得一个进程不能破坏或者监听另外一个进程的内存、CPU、文件描述符等,也不能破坏kernel本身,即进程无法破坏内核的隔离机制

内核用于实现进程的机制包括user/supervisor mode标志,地址空间,线程的时间切片

为了实现进程隔离,xv6提供了一种机制让每个程序都认为自己拥有一个独立的机器

一个进程为一个程序提供了一个私有的内存系统,或address space,其他的进程不能够读/写这个内存

xv6使用page table(页表)来给每个进程分配自己的address space,页表再将这些address space,也就是虚拟地址(virtual address)(RISC-V指令操作的地址) 映射为物理地址(physical address)(CPU芯片发送到主存的地址)。

image-20220922154824955

虚拟地址从0开始,往上依次是指令、全局变量、栈、堆。

有许多因素限制了进程地址空间的最大大小:RISC-V上的指针是64位的,硬件在页表寻找虚拟地址时只使用低39位,而xv6只使用39位中的低38位,因此最大的地址是$2^{38}-1$=0x3fffffffff=MAXVA

在地址空间的顶部,xv6为trampoline保留了一页,并为映射进程trapframe预留了一页。xv6使用这两页切换到内核并返回:trampoline页包含了该功能的代码,而映射trapframe是保存/恢复用户进程状态所必需的

xv6内核保存了每个进程的许多状态,并集中在struct pr中。进程最重要的内核状态有:

  1. 页表 p->pagetable,分页硬件会在用户空间执行进程时使用,并且页表还会记录分配给用于储存进程的内存的物理页地址
  2. 内核堆栈p->kstack
  3. 运行状态p->state,显示进程是否已经被分配、准备运行/正在运行/等待I/O或退出

每个进程中都有线程(thread),是执行进程命令的最小单元,可以被暂停和继续。为了透明地切换进程,内核会挂起正在运行的线程,并恢复其他进程的线程。线程的大部分状态(局部变量、函数返回地址等)都被存储在线程的堆栈中。

每个进程有两个堆栈:用户堆栈(user stack)和内核堆栈(kernel stack)

  • 当进程在user space中执行用户指令时只使用用户堆栈,内核堆栈为空
  • 当进程进入了内核(比如进行了system call或中断)使用内核堆栈,此时用户堆栈仍然包含保存的数据

一个进程的线程交替使用其用户堆栈和内核堆栈。内核堆栈是独立的(并受用户代码保护的),因此即使进程破坏了其用户堆栈,内核也可以正常执行

进程可以通过执行RISC-V ecall指令进行系统调用。该指令会提高硬件的特权级别,并将程序计数器更改为内核定义的入口点。入口点的代码会切换到内核堆栈并执行实现系统调用的内核指令。当系统调用完成,内核切换回用户堆栈,通过调用sret指令返回用户空间,同时降低硬件特权级别并恢复执行用户指令。

ecall会接收一个数字参数,代表应用程序想要调用的System Call,ecall会跳转到内核中一个特定,由内核控制的位置。之后控制权到了syscall函数

在内核空间,有一个位于syscall.c的函数syscall,每一个从应用程序发起的系统调用都会调用到这个syscall函数,syscall函数会检查ecall的参数,通过这个参数内核可以知道需要调用的是哪个system call

一个进程的线程可以在内核中”阻塞”以等待I/O,并在I/O完成后恢复到中断的位置

进程捆绑了两种设计思想:地址空间(进程认为拥有属于自己的内存)、线程(进程认为拥有属于自己的CPU)。xv6中,一个进程包括一个address space和一个thread。而真实操作系统一个进程往往有多个线程来充分利用多核CPU

2.5 Code: starting xv6, the first process and system call

RISC-V计算机启动时,先运行一个存储于 ROM 中的 bootloader 程序 kernel.ld 来加载xv6 kernel到内存中,然后在machine模式下从_entry开始运行xv6。此时分页硬件没有工作,虚拟地址直接映射到物理地址。bootloader将xv6 kernel加载到0x80000000的物理地址中,因为前面的地址中包含I/O设备

指令在_entry中设置了一个初始stack来执行C代码,xv6执行kernel/start.c并在其中声明一个初始化堆栈stack0_entry处的代码加载堆栈的指针寄存器sp到栈顶stack0+4096,因为RISC-V的堆栈是向下生长的

start函数先在machine模式下做一些配置,还对时钟芯片进行编程,以生成定时器中断,然后通过RISC-V提供的mret指令切换到supervisor mode。使program counter(pc)也切换到kernel/main.c

mret指令常用于上一条指令为从supervisor mode切换到machine mode,但显然start并不是这样的,而是假装是这样的:它将寄存器mstatus中以前的特权模式设置为supervisor

它通过把main的地址写入到寄存器mepc中使得返回地址被设置为main

通过将 0 写入页表寄存器satp中禁用superviser模式下的虚拟地址转换

将所有中断和异常委派给supervisor mode

main先对一些设备和子系统进行初始化,然后调用kernel/proc.c中定义的userinit来创建第一个用户进程。这个进程执行了一个initcode.S的汇编程序,这个汇编程序调用了exec这个system call来执行/init,重新进入kernel。exec将当前进程的内存和寄存器替换为一个新的程序(/init),当kernel执行完毕exec指定的程序后,回到/init进程。/init(user/init.c)创建了一个新的console device以文件描述符0,1,2打开,然后在console device中开启了一个shell进程,至此整个系统启动了

2.6 Security Model

操作系统必须假设进程的用户代码会尽力破坏内核或其他进程。包括引用超出其允许地址空间的指针、执行RISC-V特权指令、读取和写入任意RISC-V控制寄存器等。而内核的目标就是要限制每个用户进程,只能读/写/执行自己的用户内存、使用通用寄存器、使用允许的系统调用。

而内核代码不同,内核代码应当没有bug,不含任何恶意代码。这个假设会影响我们分析内核代码的方式,我们往往相信内核代码都是正确编写的,并且遵循内核自身函数和数据结构的使用规则

在硬件级别,我们也假设CPU、RAM、磁盘等都按照文档正确运行,没有硬件错误

而现实中,我们不可能编写无缺陷代码或者设计无缺陷硬件,因此我们有必要为内核设计保护措施:断言、类型检查、堆栈保护页等

有时用户代码和内核代码之间的界限会变得模糊:一些特权用户级进程会提供基本服务,并有效地成为操作系统的一部分,在某些操作系统,特权用户代码甚至可以将新代码插入内核(与Linux的可加载内核模块一样)

2.7 Real World

大多数操作系统都采用了进程概念,并且大多数进程也与xv6类似。然而,现代操作系统支持一个进程中的多个线程,从而允许单个进程利用多核CPU。而在一个进程中支持多个线程需要很多xv6没有的机制,包括潜在的接口更改(e.g. Linux的clone,就是fork的变体),以控制线程共享哪些方面。

2.8 Something Else

当应用程序恶意破坏或者就是在一个死循环中,内核是如何夺回控制权限的?

  • 内核会通过硬件设置一个定时器,定时器到期之后会将控制权限从用户空间转移到内核空间,之后内核就有了控制能力并可以重新调度CPU到另一个进程中。

内核编译过程

  • 首先,Makefile(XV6目录下的文件)会读取一个C文件,例如proc.c;之后调用gcc编译器,生成一个文件叫做proc.s,这是RISC-V汇编语言文件;之后再走到汇编解释器,生成proc.o,这是汇编语言的二进制格式
  • Makefile会为所有内核文件做相同的操作,比如说pipe.c,会按照同样的套路,先经过gcc编译成pipe.s,再通过汇编解释器生成pipe.o。
  • 之后,系统加载器(Loader)会收集所有的.o文件,将它们链接在一起,并生成内核文件
  • 这里生成的内核文件就是将会在QEMU中运行的文件。同时,Makefile还会创建kernel.asm,这里包含了内核的完整汇编语言,可以通过查看它来定位究竟是哪个指令导致了Bug。