想象你的计算机上跑着机器学习,但是你又想听歌,但是你的计算机只有一个CPU,如果把所有的CPU资源都拿去跑机器学习的话,你必须等程序跑完之后才能听歌。但是现实是,你可以在机器学习程序运行的时候放着自己喜欢的歌,甚至还可以打开微信聊天。这就是操作系统一个很重要的功能,那就是操作系统需要支持多个进程同时运行,例如在第一章中提到的,我们可以用fork创建子进程,然后父进程和子进程会在相同的内存空间上同时运行。但是父进程和子进程的运行顺序是不确定的,因为这取决于调度器的决策,所以操作系统还必须要做好调度工作,让每个进程都能够利用有限的CPU资源来运行自己的代码,即对资源进行复用。操作系统另一个很重要的作用是使进程与进程之间相互隔离,因为需要防止一个进程在运行出现的错误影响到其他进程的运行,又或者进程会访问到内核资源,对内核进行破坏。但是进程之间又不应该是绝对的隔离,因为有时候我们还需要让进程之间进行通信,或者共享数据。
总的来说,一个操作系统满足一下三个要求:
能够对有限资源进行合理的分配和复用;能够使进程与进程之间保持各自运行的独立性,起到隔离进程的作用;能够使进程之间进行交流和通信,必要时进行数据的共享和传输。
本章将主要介绍实现上述三个要求的办法,其实有很多不同的实现方式,因为不同的操作系统会有不同的操作系统内核架构。一些功能较为简单的操作系统,会将应用程序和操作系统放在同一个地址空间中,应用程序可以直接通过函数调用操作系统提供的服务。但是这样做法的缺点显而易见,如果操作系统的某个模块出现问题,应用程序也会崩溃;同时由于程序有了很大的自由度,恶意的程序可能对系统造成危害。所以会本章还会主要介绍几种常见的内核组织架构,其中包括宏内核(monolithic kernel)和微内核(Microkernel)架构.
本实验的xv6运行在一个多核的RISC-V微处理器上,RISC-V是一个64位的CPU,所以在编写xv6时,一般的数据类型long和pointers都是64位的,而int是32位的。
对物理资源进行抽象
设计操作系统时,为了保证硬件资源的充分利用,我们需要让每个应用程序周期地放弃资源,然后好让其他程序运行。这里体现了操作系统的公平性,以及操作系统应该具有调度的功能。这样的分时系统在每个程序运行正常的情况下能够很好地工作,但是每个程序难免出现运行错误或者不愿意空出资源的情况。所以为了让进程之间能够更独立地运行,我们可以禁止应用去接触敏感的硬件资源,而是将资源的利用抽象进一个个服务里。就像上一章所提到的,我们通过系统调用open, read, write, close来对内存进行读写操作,而不是对物理内存直接进行读写。所以文件系统就是对物理的硬件资源一个很好的抽象,当CPU在每个进程之间进行切换时,操作系统要做的只需要将每个进程运行停止时必要的寄存器信息保存下来,这样即使某个进程出现问题,也能够切换到其他进程,然后再返回。
文件系统是对物理资源进行的抽象,而对文件进行操作时是通过对文件描述符进行的。所以通过设计系统调用接口,能够更方便地使用和利用抽象后的资源,换句话说,系统调用的目的就是为了让我们更好地处理文件,而这些处理的文件就是对物理资源的抽象。
特权级别的切换
使各进程相互独立地运作需要保证在操作系统和各个程序之间有很明确的界限,因为我们不想因为某个程序运行发生错误而导致操作系统或其他程序也发生错误。所以,作为操作系统,需要具有清理运行失败的进程,然后将资源交给其他进程继续运行的功能。同时,一个进程不能够访问到操作系统的数据以及其独有的命令,也不能访问其他进程的内存资源。
一般来说,CPU能够提供硬件上的安全保障。例如RISC-V具有三个运行模式:machine mode、supervisor mode和user mode,在这三个模式下能分别运行不同的命令。在machine mode下运行的指令有绝对的特权,一个CPU最开始运行在machine mode下,一般情况下,machine mode用于最开始对计算机进行配置。对于xv6来说,最开始会在machine mode下运行部分指令,然后切换至supervisor mode下。
在supervisor mode下,具有相较于user mode下的特权,例如允许和禁止中断,能够读写存放页表地址的内存空间等。当在user mode下的程序想要运行特权指令时会被CPU禁止,而只有转换到supervisor mode下才能运行此类指令。一般来说,执行user mode下的命令将运行在user space,相应的,特权指令运行在内核空间里。
从user mode到supervisor mode的转换命令一般由CPU给出,在RISC-V里,ecall命令可以完成上述功能。当CPU从user mode转换到supervisor mode后,会对应用程序提出的系统调用进行检查,再去决定这些命令是否被执行。
注意,决定从user mode到supervisor mode转换的是内核,因为如果让应用程序来决定的话,可能会跳过检查合法性的步骤,因为如果程序是恶意的话,可能会发出不合法的命令。
内核的组织架构
在阅读时,产生了一个疑问,操作系统和操作系统内核概念的区分。所以这里补充一下操作系统内核和操作系统的差别。操作系统是管理系统资源的系统程序,具有很多细分的功能,而内核是操作系统中的重要部分(程序),是部分功能的集合。所以一般来说,内核是操作系统的子集,操作系统可以选择将那些部分功能放在内核中,哪些不用。
对于操作系统的设计,很重要的一点是让哪些命令运行在supervisor mode下。一种办法是让操作系统整个放在内核中,那么所有的命令都将运行在supervisor mode下。这样的组织方式就叫宏内核。
宏内核(monolithic kernel),其特征是操作系统内核的所有模块都运行在内核态下。但是随着操作系统功能的不断增长,整个系统的复杂度越来越大,因此在安全性和可靠性等方面出现了很多问题。在宏内核架构下,所有的内核模块都运行在特权空间,因此某个地方的错误可能导致整个系统崩溃或被破坏。
在宏内核这种架构下,操作系统对整个硬件都有绝对的特权。设计者在设计这种操作系统时,不需要对操作系统的运行模式进行特别的区分。同时,由于整个操作系统都在内核里,操作系统各个功能模块在进行交流时能够更加方便,而不需要考虑权限问题。
但是在这种情况下,由于操作系统的各个部分都非常复杂,所以一但操作系统的某个部分发生错误,就会导致内核进入瘫痪,当内核出现问题后,计算机也就不工作了,进而在之上运行的所有程序也会停止运行。
为了减少这种风险,操作系统的设计者会尽可能地把运行在supervisor mode下的操作系统相关代码减少,然后将其他代码运行在user mode之下,这种组织方式叫微内核。
微内核(Microkernel)架构,在这种架构下,内核只保留了极少的功能,而将部分功能模块从内核里抽离出来。因此,即使一个服务出现问题,一般情况下不会导致整个系统崩溃。同时,在不同场景下,可以为了不同的需求进行不同的设计。
微内核的架构设计理念,是将大部分的操作系统功能以系统服务的形式运行在user mode。但是微内核也只是一个设计原则,并没有规定哪些功能组件应该运行在内核态,所以不同的操作系统会有不同的设计。
上图为一个微内核的设计,此时文件系统作为一个运行在user space的进程,更像一个服务器,而微内核为不同user mode下的进程提供交流的机制。当shell需要对文件进行读写操作时,首先会通过内核发送请求信息,并等待回复。
在微内核下,内核部分只有部分必要的功能,例如控制程序启动,发送信息,直接对硬件资源进行控制等。
常见的Linux和Windows都是宏内核的架构,在Linux和Windows中,文件系统一般是作为操作系统内核的一部分直接运行在内核态。
xv6采用宏内核的方式实现,整个操作系统全部都在内核里面,因此整个操作系统的接口就是内核的接口。
xv6的内核实现
xv6的内核源码在kernel目录下,具体包含的文件以及描述如下:
关于进程
在xv6里,被隔离的最小单位就是进程,有了进程的抽象,每个程序就像独自运行在CPU上,看起来拥有一个属于自己运行的系统。每个进程有单独的内存空间,有单独的CPU。这个专属的虚拟内存空间,又叫地址空间,是不会被其他进程所访问的,这样也就保证了进程之间的隔离。
xv6用页表来给出每个进程的地址空间,页表其实就是虚拟空间与物理地址空间的映射。应用程序在运行时只能使用虚拟地址,而CPU负责将虚拟地址翻译成物理地址。这样对于每个进程来说,看到的虚拟地址空间都是连续的、而不需要考虑物理空间的具体位置,即使是离散的物理空间在每个进程看到都是连续的虚拟地址空间。
xv6为每个进程都提供了一个独立的页表,用于表示各自的地址空间。 下图为每个进程的虚拟地址空间的表示:
从上图可以看到,一个进程的虚拟内存空间从地址0开始,然后向上扩展。首先是存放进程运行的相关数据和代码段,然后是用户栈,还有可以扩展的用户堆。
一般来说,一个进程分配的虚拟内存空间大小由CPU的位数决定。在RISC-V里,指针的大小为64位,而低39位被用于页表里表示虚拟地址,但是xv6只用了其中38位,因此对于一个进程,他的内存空间大小最大就是2^38 – 1 = 0x3fffffffff。这个最大的地址在文件中kernel/riscv.h:348被定义为MAXVA,即能够表示的最大虚拟地址。
每个进程里会有一个更小的单元,称为线程。之所以我们需要引入线程,是因为创建进程的开销较大,需要为每个新的进程创建独立的地址空间,即使是用fork,也需要进行大量状态的拷贝。其次,由于进程之间拥有独立的虚拟内存空间,因此进程之间进行数据共享就比较麻烦。因此操作系统的设计人员提出了线程的概念,作为操作系统调度和管理程序的最小单位。
简单来说进程和线程的区别,一个进程一般拥有多个线程,一个进程里的不同线程运行在相同的内存空间里。而不同的进程相互隔离,运行在各自不同的内存空间里。线程和进程的关系,就好像进程是一个main函数,而在函数里需要调用其他的函数,这些其他函数就是一个个的线程。
线程作为进程执行时的最小单元,共享同一个进程的内存空间,因此在进程运行时,可能会需要在不同的线程之间来回切换,不同的线程并发执行。在这个切换上下文的过程中就需要将每个线程的状态存下来,这些状态都被存在堆栈中。对于每个进程,都有用户堆栈和内核堆栈两个堆栈。当进程执行用户命令时,它会在运行在用户堆栈,而需要进入内核,比如系统调用或中断时,会切换到内核堆栈进行执行。
当运行中的进程需要通过系统调用而进入内核时可以执行ecall指令。这个指令可以切换至更高的硬件权限级别,并将程序计数器改变到内核规定的入口点,从在内核态下执行命令。进入点的代码会切换到内核堆栈,执行实现系统调用的内核指令。当系统调用完成后,内核通过sret指令再切换回user space。
xv6启动的第一个进程
上面介绍了内核的组织架构以及进程和线程的概念,接下来让我们来看看xv6的内核是如何启动,并开始第一个进程的。
当RISC_V计算机通电的时候,boot loader会将xv6的内核代码加载到内存中,在machine mode下,(上面所提到的最高级别状态,具有绝对的特权),CPU会自动从_entry处开始执行xv6内核。
boot loader会将xv6的内核代码加载到内存中物理地址为0x80000000的地方。之所以不是从地址0开始是因为地址0x0:0x80000000的地方已经存放了I/O设备相关数据。
xv6定义了一个初始的栈空间,为每个CPU都分配了一个大小为4096字节的栈。
在_entry里,将stack0+4096(栈顶)的位置初始化给栈指针寄存器sp,因为在RISC-V里,栈是向低地址(向下)扩展的。初始化完成后,就会直接调用start.c里的start函数。
start函数在machine mode下进行了一些配置,然后最后切换到supervisor mode下。在RISC-V里,可以通过mret指令进入到supervisor mode下。这个指令一般用于之前由于系统调用或中断从supervisor mode到machine mode之后返回。start虽然不是从这样的调用中返回,但是它会通过设置表现得和系统调用发生过一样:它会把mstatus中的特权模式设置为supervisor mode,通过把main的地址写进寄存器mepc来设置最后的返回地址,通过在页表寄存器satp中写0来关闭supervisor mode下的虚拟地址转换,并把所有中断和异常交给supervisor mode。
在进入supervisor mode之前,start还执行了一项任务:对时钟芯片进行编程,以产生定时器中断。在完成这些相关工作后,start通过调用mret返回到supervisor mode。这使得程序计数器变为main,于是初始化后,内核将从main函数开始运行。
在main初始化了几个设备和子系统之后,它通过调用userinit创建了第一个进程。第一个进程执行一个用RISC-V汇编编写的小程序initcode.S,它通过调用exec系统调用重新进入内核。在第一章有提到系统调用exec,会将另一个进程载入到当前进程的内存空间进行执行,这里的exec用一个新的程序(在这里是init)替换了当前进程的内存和寄存器。一旦内核完成了exec,它就返回到user space的/init进程中。Init在需要时创建一个新的控制台设备文件,然后将其作为文件描述符0、1和2打开。然后,它在控制台启动一个shell,这个时候系统就算是正式启动了。