xv6 start
操作系统必须满足三个要求:多路,隔离,交流。
用户模式和管理模式
强隔离要求应用程序和操作系统之间有一个硬边界。如果应用程序出错,我们不希望操作系统失败或其他应用程序出错,相反操作系统应该能够清理失败的应用程序并继续运行其他应用程序。为了实现强隔离,操作系统必须安排应用程序不能修改(甚至读取)操作系统的数据结构和指令,并且应用程序不能访问其他进程的内存。
RISC-V有三种CPU执行指令的模式:机器模式、管理模式(内核模式)和用户模式。
在机器模式下执行的指令具有完全权限,主要用于配置计算机。CPU以机器模式启动,执行一些代码,然后切换到管理模式。
在管理模式下,CPU被允许执行特权指令:比如启用和禁用中断,读取和写入保存页表地址的寄存器等等。如果用户模式下的应用程序试图执行特权指令,则CPU不会执行该指令,而是切换到管理模式,以便内核代码可以终止应用程序。应用程序只能执行用户模式指令,被称为在用户空间中运行,而处于管理模式的软件也可以执行特权指令,被称为在内核空间中运行。在内核空间(或在管理器模式下)运行的软件称为内核。
一个想要调用内核函数的应用程序(例如系统调用)必须转换到内核,应用程序不能直接调用内核函数。CPU提供一个特殊的指令,将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V为此目的提供了调用指令ecall)。一旦CPU切换到管理模式,内核就可以验证系统调用的参数(例如,检查传递给系统调用的地址是否属于应用程序内存的一部分),决定是否允许应用程序执行请求的操作(例如,检查是否允许应用程序写入指定的文件),然后拒绝它或执行它。内核控制过渡到管理模式的入口点,如果应用程序可以决定内核入口点,那么恶意应用程序就可以跳过参数验证的点进入内核。
内核设计
单片内核:整个操作系统驻留在内核中,因此所有系统调用的实现都以管理模式运行。操作系统以完全硬件特权运行,使设计者不必决定操作系统的哪一部分不需要完全的硬件特权,并且不同部分更容易协作。缺点是操作系统的不同部分之间的接口通常是复杂的,很容易犯错误。管理模式下的错误通常会导致内核失败,计算机将停止工作,计算机必须重新启动。
为了减少在内核中出错的风险,操作系统设计者可以尽量减少在管理模式下运行的操作系统代码的数量,并在用户模式下执行大部分操作系统。这种内核组织称为微内核。
文件系统作为用户级进程运行,作为进程运行的操作系统服务称为服务器。为了允许应用程序与文件服务器交互,内核提供了一个进程间通信机制,将消息从一个用户模式进程发送到另一个用户模式进程。如果像shell这样的应用程序想要读取或写入文件,它会向文件服务器发送消息并等待响应。在微内核中,内核接口由几个低级函数组成,用于启动应用程序、发送消息、访问设备硬件等,这种组织允许内核相对简单,因为大多数操作系统驻留在用户级服务器中。
在现实世界中,单内核和微内核都很流行。许多Unix内核都是单体的,例如Linux有一个单片内核,尽管一些操作系统功能作为用户级服务器运行(用户界面窗口系统)。Linux为操作系统密集型应用程序提供了高性能,部分原因是内核的子系统可以紧密集成。Minix、L4和QNX等操作系统被组织为带有服务器的微内核,并且在嵌入式设置中得到了广泛部署。
评估:更快的性能、更小的代码大小、内核的可靠性、完整操作系统(包括用户级服务)的可靠性等。
与大多数Unix操作系统一样,Xv6是单内核实现的。因此xv6内核接口对应于操作系统接口,内核实现完整的操作系统。由于xv6不提供很多服务,它的内核比一些微内核要小,但从概念上讲,xv6是整体的。
函数start执行一些只允许在机器模式下进行的配置,然后切换到管理模式。为了进入管理模式,RISC-V提供指令mret。该指令最常用于先前的调用(从机器模式返回到管理模式)中返回。start并没有从这样的调用中返回,而是把事情设置成好像有这样的调用:它在寄存器mstatus中将先前的特权模式设置为supervisor,通过将main的地址写入寄存器mepc而将返回地址设置为main,通过将0写入页表寄存器satp而在管理模式中禁用虚拟地址转换,并将所有中断和异常委托给管理模式。
mret、sret、uret分别对应于三种模式,高等级的特权模式可以执行低等级的ret指令。
进程
在xv6中,进程是隔离单元。进程抽象可以防止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等。它还可以防止进程破坏内核本身,这样进程就不能破坏内核的隔离机制。
为了帮助实现隔离,进程抽象为程序提供了一种错觉,即它拥有自己的私有机器。进程为程序提供了一个私有的内存系统或地址空间,其他进程无法读取或写入。进程还为程序提供了自己的CPU来执行程序的指令。xv6使用页表为每个进程提供自己的地址空间,将虚拟地址(RISC-V指令操作的地址)转换为物理地址(CPU芯片发送到主存的地址)。
地址空间包括从虚拟地址0开始的进程用户内存。首先是指令,然后是全局变量,然后是栈,最后是进程可以根据需要扩展的堆区域。
xv6保留了trampoline和trapframe页面,xv6使用这两个页面转换到内核和返回内核。trampoline页面包含在内核内外转换的代码,trapframe保存/恢复用户进程的状态。
每个进程都有一个执行线程来执行进程的指令。线程可以挂起,稍后再恢复。为了在进程之间透明地切换,内核挂起当前正在运行的线程并恢复另一个进程的线程。线程的大部分状态(局部变量、函数调用返回地址)都存储在线程的堆栈中。每个进程有两个堆栈:用户堆栈和内核堆栈(p->kstack)。当进程执行用户指令时,只有它的用户堆栈在使用,而它的内核堆栈是空的。当进程进入内核时,内核代码在进程的内核堆栈上执行,但它的用户堆栈仍然包含保存的数据,虽然未使用。进程的线程在主动使用其用户堆栈和内核堆栈之间交替。
内核堆栈是独立的并且不受用户代码的影响,因此即使进程破坏了它的用户堆栈,内核也可以执行。
总而言之,一个进程捆绑了两个设计思想:一个地址空间,给进程一个自己内存的错觉;一个线程(在xv6中只有硬件线程,也就是CPU,并没有用户多线程),给进程一个独占CPU的错觉。在xv6中,一个进程由一个地址空间和一个线程组成。在实际操作系统中,一个进程可能有多个线程来利用多个cpu。
xv6,启动!
当RISC-V计算机启动时,它会初始化自身并运行存储在只读内存中的引导加载程序。引导加载程序将xv6内核加载到物理地址0x80000000的内存中。然后,在机器模式下,CPU从_entry处开始执行xv6。RISC-V开始时禁用了分页硬件:虚拟地址直接映射到物理地址。
_entry处的指令设置了一个堆栈,以便xv6可以运行C代码。xv6在文件start.c中为初始堆栈stack0声明了空间,_entry处的代码加载堆栈指针寄存器sp,地址为stack0+4096*hartid。现在内核有了堆栈,_entry调用start运行代码。
start返回后从main开始执行,初始化几个设备和子系统之后,调用userinit创建第一个进程,并加载汇编程序带内存initcode.S,然后main进入调度程序。第一个进程被调度后,执行汇编程序,执行系统调用exec(将SYS_EXEC的号码加载到寄存器a7中,然后调用ecall重新进入内核)。
内核使用sycall中寄存器a7中的数字来调用所需的系统调用。系统调用表将SYS_EXEC映射到内核调用的sys_exec。exec用一个新程序(在本例中是/init)替换当前进程的内存和寄存器。一旦内核执行完exec,它就返回到/init进程中的用户空间。如果需要,init创建一个新的控制台设备文件,然后将其作为文件描述符0、1和2打开。然后在控制台上启动一个shell。
系统就这样启动了。
回顾
内核代码应该是无bug的,不包含任何恶意代码。这个假设影响了我们分析内核代码的方式,如果内核代码不正确地使用了许多内部内核函数(例如自旋锁),就会导致严重的问题。在检查任何特定的内核代码片段时,我们都想让自己确信它的行为是正确的。我们假设内核代码通常是正确编写的,并且遵循有关使用内核自己的函数和数据结构的所有规则。在硬件层面,RISC-V CPU、RAM、磁盘被假定为按照文档中展示的的那样运行,没有硬件错误。
现代操作系统支持一个进程内的多个线程,以允许单个进程利用多个cpu。在一个进程中支持多个线程涉及到相当多的机制,这是xv6所没有的,包括潜在的接口更改(例如,Linux的克隆,fork的变体),以控制线程共享进程的哪些方面。