以距型表示:
硬件资源包括:CPU、内存、磁盘、网卡,在底层
架构上层,需要运行各种的应用程序,例如 文本编译器(vi)、c编译器(cc),cli 存在的 shell ,这些就是正在运行的所有程序,这里程序都运行在统一空间中,被称为用户空间
区别于用户空间的程序,又一个特殊的程序总是在运行,被称为kernel,kernel 总是第一个被启动,kernel 程序只有一个,维护数据来管理每一个用户空间进程,Kernel同时还维护了大量的数据结构来帮助它管理各种各样的硬件资源,以供用户空间的程序使用。Kernel同时还有大量内置的服务,例如,Kernel通常会有文件系统实现类似文件名,文件内容,目录的东西,并理解如何将文件存储在磁盘中。所以用户空间的程序会与Kernel中的文件系统交互,文件系统再与磁盘交互。(所以说就是需要有用户态和内核态的切换)
Kernel
在这门课程中,我们主要关注点在Kernel、连接Kernal和用户空间程序的接口、Kernel内软件的架构。所以,我们会关心Kernel中的服务,**其中一个服务是文件系统,另一个就是进程管理系统。每一个用户空间程序都被称为一个进程,它们有自己的内存和共享的CPU时间。同时,Kernel会管理内存的分配。**不同的进程需要不同数量的内存,Kernel会复用内存、划分内存,并为所有的进程分配内存。
包含文件系统、安全控制、进程
用户空间
在这个架构的最上层,我们会运行各种各样的应用程序,或许有一个文本编辑器(VI),或许有一个C编译器(CC),你还可以运行大量我们今天会讨论的其他事物,例如作为CLI存在的Shell,所以这些就是正在运行的所有程序。这里程序都运行在同一个空间中,这个空间通常会被称为用户空间(Userspace)。
在一个真实的完备的操作系统中,会有很多很多其他的服务,比如在不同进程之间通信的进程间通信服务,比如一大票与网络关联的软件(TCP/IP协议栈),比如支持声卡的软件,比如支持数百种不同磁盘,不同网卡的驱动。所以在一个完备的系统中,Kernel会包含大量的内容,数百万行代码。
整体结构如下:
我们同时也对应用程序是如何与Kernel交互,它们之间的接口长什么样感兴趣。这里通常成为Kernel的API,它决定了应用程序如何访问Kernel。通常来说,这里是通过所谓的系统调用(System Call)来完成。系统调用与程序中的函数调用看起来是一样的,但区别是系统调用会实际运行到系统内核中,并执行内核中对于系统调用的实现。在这门课程的后面,我会详细介绍系统调用。现在,我只会介绍一些系统调用在应用程序中是长什么样的。
第一个例子是,如果应用程序需要打开一个文件,它会调用名为open的系统调用,并且把文件名作为参数传给open。假设现在要打开一个名为“out”的文件,那么会将文件名“out”作为参数传入。同时我们还希望写入数据,那么还会有一个额外的参数,在这里这个参数的值是1,表明我想要写文件。
fd = open("out", 1)
这里看起来像是个函数调用,但是open是一个系统调用,它会跳到Kernel,Kernel可以获取到open的参数,执行一些实现了open的Kernel代码,或许会与磁盘有一些交互,最后返回一个文件描述符对象。上图中的fd全称就是file descriptor。之后,应用程序可以使用这个文件描述符作为handle,来表示相应打开的文件。
如果你想要向文件写入数据,相应的系统调用是write。你需要向write传递一个由open返回的文件描述符作为参数。你还需要向write传递一个指向要写入数据的指针(数据通常是char型序列),在C语言中,可以简单传递一个双引号表示的字符串(下图中的\n表示是换行)。第三个参数是你想要写入字符的数量。
fd = open("out", 1);
write(fd, "hello\n", 6);
第二个参数的指针,实际上是内存中的地址。所以这里实际上告诉内核,将内存中这个地址起始的6个字节数据写入到fd对应的文件中。
除此之外,还有 fork(),在系统调用的时候,会进行创建一摸一样的新的进程,并返回新的进程的pid
pid = fork();
对于 fork() 来说:父进程会返回子进程的 pid, 对于子进程来说返回0
系统调用的不同是,它最终会跳到内核当中
学习操作系统比较难的一个原因是,内核的编程环境比较困难。当你在编写、修改,扩展内核,或者写一个新的操作系统内核时,你实际上在提供一个基础设施让别人来运行他们的程序。当程序员在写普通的应用程序时,应用程序下面都是操作系统。而当我们在构建操作系统时,在操作系统下面就是硬件了,这些硬件通常会更难处理。在这门课程中,我们会使用一个叫做QEMU的硬件模拟器,来模拟CPU和计算机。这会简单一些,但即使这样,编程环境还是比较恶劣。
学习操作系统比较难的另一个原因是,当你在设计一个操作系统时,你需要满足一些列矛盾的需求。
学生提问:系统调用跳到内核与标准的函数调用跳到另一个函数相比,区别是什么?
Robert教授:Kernel的代码总是有特殊的权限。当机器启动Kernel时,Kernel会有特殊的权限能直接访问各种各样的硬件,例如磁盘。而普通的用户程序是没有办法直接访问这些硬件的。所以,当你执行一个普通的函数调用时,你所调用的函数并没有对于硬件的特殊权限。然而,如果你触发系统调用到内核中,内核中的具体实现会具有这些特殊的权限,这样就能修改敏感的和被保护的硬件资源,比如访问硬件磁盘。我们之后会介绍更多有关的细节。
另一件使得操作系统的设计难且有趣的点是:操作系统提供了大量的特性和大量的服务,但是它们趋向于相互交互。有时,这种交互以奇怪的方式进行,并且需要你大量的思考。即使在我之前给出的一个简单例子中,对于open和fork,它们之间也可能有交互。如果一个应用程序通过open系统调用得到了一个文件描述符fd。之后这个应用程序调用了fork系统调用。fork的语义是创建一个当前进程的拷贝进程。而对于一个真正的拷贝进程,父进程中的文件描述符也必须存在且可用。所以在这里,一个通过open获得的文件描述符,与fork以这种有趣的方式进行交互。当然,你需要想明白,子进程是否能够访问到在fork之前创建的文件描述符fd。在我们要研究的操作系统中答案是,Yes,需要能够访问。
以 copy 举例
这个程序里面执行了3个系统调用,分别是read,write和exit。
如果你看第13行的read,它接收3个参数:
read的返回值可能是读到的字节数,在上面的截图中也就是6(xyzzy加上结束符)。read可能从一个文件读数据,如果到达了文件的结尾没有更多的内容了,read会返回0。如果出现了一些错误,比如文件描述符不存在,read或许会返回-1。在后面的很多例子中,比如第16行,我都没有通过检查系统调用的返回来判断系统调用是否出错,但是你应该比我更加小心,你应该清楚系统调用通常是通过返回-1来表示错误,你应该检查所有系统调用的返回值以确保没有错误。
open程序会创建一个 output.txt的文件,并写入一些数据
并看到open程序写入的“ooo”。所以,代码中的第11行,执行了open系统调用,将文件名output.txt作为参数传入,第二个参数是一些标志位,用来告诉open系统调用在内核中的实现:我们将要创建并写入一个文件。open系统调用会返回一个新分配的文件描述符,这里的文件描述符是一个小的数字,可能是2,3,4或者其他的数字。
之后,这个文件描述符作为第一个参数被传到了write,write的第二个参数是数据的指针,第三个参数是要写入的字节数。数据被写入到了文件描述符对应的文件中。
文件描述符本质上对应了内核中的一个表单数据。内核维护了每个运行进程的状态,内核会为每一个运行进程保存一个表单,表单的key是文件描述符。这个表单让内核知道,每个文件描述符对应的实际内容是什么。这里比较关键的点是,每个进程都有自己独立的文件描述符空间,所以如果运行了两个不同的程序,对应两个不同的进程,如果它们都打开一个文件,它们或许可以得到相同数字的文件描述符,但是因为内核为每个进程都维护了一个独立的文件描述符空间,这里相同数字的文件描述符可能会对应到不同的文件。
学生提问:有一个系统调用和编译器的问题。编译器如何处理系统调用?生成的汇编语言是不是会调用一些由操作系统定义的代码段?
Robert教授:有一个特殊的RISC-V指令,程序可以调用这个指令,并将控制权交给内核。所以,实际上当你运行C语言并执行例如open或者write的系统调用时,从技术上来说,open是一个C函数,但是这个函数内的指令实际上是机器指令,也就是说我们调用的open函数并不是一个C语言函数,它是由汇编语言实现,组成这个系统调用的汇编语言实际上在RISC-V中被称为ecall。这个特殊的指令将控制权转给内核。之后内核检查进程的内存和寄存器,并确定相应的参数。
学生提问:fork产生的子进程是不是总是与父进程是一样的?它们有可能不一样吗?
Robert教授:在XV6中,除了fork的返回值,两个进程是一样的。两个进程的指令是一样的,数据是一样的,栈是一样的,同时,两个进程又有各自独立的地址空间,它们都认为自己的内存从0开始增长,但这里是不同的内存。 在一个更加复杂的操作系统,有一些细节我们现在并不关心,这些细节偶尔会导致父子进程不一致,但是在XV6中,父子进程除了fork的返回值,其他都是一样的。除了内存是一样的以外,文件描述符的表单也从父进程拷贝到子进程。所以如果父进程打开了一个文件,**子进程可以看到同一个文件描述符,尽管子进程看到的是一个文件描述符的表单的拷贝。**除了拷贝内存以外,fork还会拷贝文件描述符表单这一点还挺重要的,我们接下来会看到。
fork创建了一个新的进程。当我们在Shell中运行东西的时候,Shell实际上会创建一个新的进程来运行你输入的每一个指令。所以,当我输入ls时,我们需要Shell通过fork创建一个进程来运行ls,这里需要某种方式来让这个新的进程来运行ls程序中的指令,加载名为ls的文件中的指令(也就是后面的exec系统调用)。
代码会执行exec系统调用,**这个系统调用会从指定的文件中读取并加载指令,并替代当前调用进程的指令。**从某种程度上来说,这样相当于丢弃了调用进程的内存,并开始执行新加载的指令。所以第12行的系统调用exec会有这样的效果:**操作系统从名为echo的文件中加载指令到当前的进程中,并替换了当前进程的内存,之后开始执行这些新加载的指令。**同时,你可以传入命令行参数,exec允许你传入一个命令行参数的数组,这里就是一个C语言中的指针数组,在上面代码的第10行设置好了一个字符指针的数组,这里的字符指针本质就是一个字符串(string)。
所以,exec系统调用从文件中读取指令,执行这些指令,然后就没有然后了。exec系统调用只会当出错时才会返回,因为某些错误会阻止操作系统为你运行文件中的指令,例如程序文件根本不存在,因为exec系统调用不能找到文件,exec会返回-1来表示:出错了,我找不到文件。所以通常来说exec系统调用不会返回,它只会在kernel不能运行相应的文件时返回。
当echo退出了,一切就结束了。所以我们不想要echo替代Shell。实际上,Shell会执行fork,之后fork出的子进程再调用exec系统调用,这是一个非常常见的Unix程序调用风格。对于那些想要运行程序,但是还希望能拿回控制权的场景,可以先执行fork系统调用,然后在子进程中调用exec。
Unix提供了一个wait系统调用,如第20行所示。**wait会等待之前创建的子进程退出。当我在命令行执行一个指令时,我们一般会希望Shell等待指令执行完成。**所以wait系统调用,使得父进程可以等待任何一个子进程返回。这里wait的参数status,是一种让退出的子进程以一个整数(32bit的数据)的格式与等待的父进程通信方式。所以在第17行,exit的参数是1,操作系统会将1从退出的子进程传递到第20行,也就是等待的父进程处。&status,是将status对应的地址传递给内核,内核会向这个地址写入子进程向exit传入的参数。
学生提问:子进程可以等待父进程吗?
Robert教授:Unix并没有一个直接的方法让子进程等待父进程。wait系统调用只能等待当前进程的子进程。所以wait的工作原理是,如果当前进程有任何子进程,并且其中一个已经退出了,那么wait会返回。但是如果当前进程没有任何子进程,比如在这个简单的例子中,如果子进程调用了wait,因为子进程自己没有子进程了,所以wait会立即返回-1,表明出现错误了,当前的进程并没有任何子进程。
简单来说,不可能让子进程等待父进程退出。
学生提问:当我们说子进程从父进程拷贝了所有的内存,这里具体指的是什么呢?是不是说子进程需要重新定义变量之类的?
Robert教授:在编译之后,你的C程序就是一些在内存中的指令,这些指令存在于内存中。所以这些指令可以被拷贝,因为它们就是内存中的字节,它们可以被拷贝到别处。通过一些有关虚拟内存的技巧,可以使得子进程的内存与父进程的内存一样,这里实际就是将父进程的内存镜像拷贝给子进程,并在子进程中执行。
实际上,当我们在看C程序时,你应该认为它们就是一些机器指令,这些机器指令就是内存中的数据,所以可以被拷贝。
学生提问:如果父进程有多个子进程,wait是不是会在第一个子进程完成时就退出?这样的话,还有一些与父进程交错运行的子进程,是不是需要有多个wait来确保所有的子进程都完成?
Robert教授:**是的,如果一个进程调用fork两次,如果它想要等两个子进程都退出,它需要调用wait两次。**每个wait会在一个子进程退出时立即返回。当wait返回时,你实际上没有必要知道哪个子进程退出了,但是wait返回了子进程的进程号,
所以在wait返回之后,你就可以知道是哪个子进程退出了。
$ echo hello > out
$ cat < out
hello
我们可以看到保存在out文件中的内容就是echo指令的输出。
Shell之所以有这样的能力,是因为Shell首先会像第13行一样fork,然后在子进程中,Shell改变了文件描述符。文件描述符1通常是进程用来作为输出的(也就是console的输出文件符),Shell会将文件描述符1改为output文件,之后再运行你的指令。 同时,父进程的文件描述符1并没有改变。**所以这里先fork,再更改子进程的文件描述符,是Unix中的常见的用来重定向指令的输入输出的方法,这种方法同时又不会影响父进程的输入输出。**因为我们不会想要重定向Shell的输出,我们只想重定向子进程的输出。
这个例子同时也演示了分离fork和exec的好处。fork和exec是分开的系统调用,意味着在子进程中有一段时间,fork返回了,但是exec还没有执行,子进程仍然在运行父进程的指令。所以这段时间,尽管指令是运行在子进程中,但是这些指令仍然是父进程的指令,所以父进程仍然可以改变东西,直到代码执行到了第19行。这里fork和exec之间的间隔,提供了Shell修改文件描述符的可能。
复用和物理内存隔离
之前通过fork创建了进程。进程本身不是CPU,但是它们对应了CPU,它们使得你可以在CPU上运行计算任务
Shell在发现自己运行了一段时间之后,需要让别的程序也有机会能运行。这种机制有时候称为协同调度(Cooperative Scheduling)。但是这里的场景并没有很好的隔离性,比如说Shell中的某个函数有一个死循环,那么Shell永远也不会释放CPU,进而其他的应用程序也不能够运行,甚至都不能运行一个第三方的程序来停止或者杀死Shell程序。所以这种场景下,我们基本上得不到真正的multiplexing(CPU在多进程同分时复用)。而这个特性是非常有用的,不论应用程序在执行什么操作,multiplexing都会迫使应用程序时不时的释放CPU,这样其他的应用程序才能运行。
从内存的角度来说,如果应用程序直接运行在硬件资源之上,那么每个应用程序的文本,代码和数据都直接保存在物理内存中。物理内存中的一部分被Shell使用,另一部分被echo使用。
使用操作系统的一个原因,甚至可以说是主要原因就是为了实现multiplexing和内存隔离。如果你不使用操作系统,并且应用程序直接与硬件交互,就很难实现这两点。所以,将操作系统设计成一个库,并不是一种常见的设计。你或许可以在一些实时操作系统中看到这样的设计,因为在这些实时操作系统中,应用程序之间彼此相互信任。但是在大部分的其他操作系统中,都会强制实现硬件资源的隔离。
如果我们从隔离的角度来稍微看看Unix接口,那么我们可以发现,接口被精心设计以实现资源的强隔离,也就是multiplexing和物理内存的隔离。接口通过抽象硬件资源,从而使得提供强隔离性成为可能。
之前通过fork创建了进程。进程本身不是CPU,但是它们对应了CPU,它们使得你可以在CPU上运行计算任务。所以你懂的,**应用程序不能直接与CPU交互,只能与进程交互。操作系统内核会完成不同进程在CPU上的切换。**所以,操作系统不是直接将CPU提供给应用程序,而是向应用程序提供“进程”,进程抽象了CPU,这样操作系统才能在多个应用程序之间复用一个或者多个CPU。
学生提问:这里说进程抽象了CPU,是不是说一个进程使用了部分的CPU,另一个进程使用了CPU的另一部分?这里CPU和进程的关系是什么?
Frans教授:我这里真实的意思是,我们在实验中使用的RISC-V处理器实际上是有4个核。所以你可以同时运行4个进程,一个进程占用一个核。但是假设你有8个应用程序,操作系统会分时复用这些CPU核,比如说对于一个进程运行100毫秒,之后内核会停止运行并将那个进程从CPU中卸载,再加载另一个应用程序并再运行100毫秒。通过这种方式使得每一个应用程序都不会连续运行超过100毫秒。这里只是一些基本概念,我们在接下来的几节课中会具体的看这里是如何实现的。
学生提问:好的,但是多个进程不能在同一时间使用同一个CPU核,对吧?
Frans教授:是的,这里是分时复用。CPU运行一个进程一段时间,再运行另一个进程。
例如 exec :
我们可以认为exec抽象了内存。当我们在执行exec系统调用的时候,我们会传入一个文件名,而这个文件名对应了一个应用程序的内存镜像。内存镜像里面包括了程序对应的指令,全局的数据。应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限,例如应用程序不能直接访问物理内存的1000-2000这段地址。不能直接访问的原因是,操作系统会提供内存隔离并控制内存,操作系统会在应用程序和硬件资源之间提供一个中间层。exec是这样一种系统调用,它表明了应用程序不能直接访问物理内存。
内存镜像里面包括了程序对应的指令,全局的数据。应用程序可以逐渐扩展自己的内存,但是应用程序并没有直接访问物理内存的权限,例如应用程序不能直接访问物理内存的1000-2000这段地址。不能直接访问的原因是,操作系统会提供内存隔离并控制内存,操作系统会在应用程序和硬件资源之间提供一个中间层。exec是这样一种系统调用,它表明了应用程序不能直接访问物理内存。
files:抽象了磁盘
应用程序不会直接读写挂在计算机上的磁盘本身,并且在Unix中这也是不被允许的。在Unix中,与存储系统交互的唯一方式就是通过files。Files提供了非常方便的磁盘抽象,你可以对文件命名,读写文件等等。之后,操作系统会决定如何将文件与磁盘中的块对应,确保一个磁盘块只出现在一个文件中,并且确保用户A不能操作用户B的文件。通过files的抽象,可以实现不同用户之间和同一个用户的不同进程之间的文件强隔离。
操作系统需要确保所有的组件都能工作,所以它需要做好准备抵御来自应用程序的攻击。如果说应用程序无意或者恶意的向系统调用传入一些错误的参数就会导致操作系统崩溃,那就太糟糕了。在这种场景下,操作系统因为崩溃了会拒绝为其他所有的应用程序提供服务。所以操作系统需要以这样一种方式来完成:操作系统需要能够应对恶意的应用程序
**应用程序不能够打破对它的隔离。**应用程序非常有可能是恶意的,它或许是由攻击者写出来的,攻击者或许想要打破对应用程序的隔离,进而控制内核。一旦有了对于内核的控制能力,你可以做任何事情,因为内核控制了所有的硬件资源。
如何保证强隔离性?通过用户空间和内核空间保证 以及 内核和虚拟内存
通常来说,需要通过硬件来实现这的强隔离性。我们这节课会简单介绍一些硬件隔离的内容,但是在后续的课程我们会介绍的更加详细。这里的硬件支持包括了两部分,第一部分是user/kernel mode,kernel mode在RISC-V中被称为Supervisor mode但是其实是同一个东西;第二部分是page table或者虚拟内存(Virtual Memory)。
用户态和内核态
处理器会有两种操作模式,第一种是user mode,第二种是kernel mode。当运行在kernel mode时,CPU可以运行特定权限的指令(privileged instructions);当运行在user mode时,CPU只能运行普通权限的指令(unprivileged instructions)。
普通权限的指令都是一些你们熟悉的指令,例如将两个寄存器相加的指令ADD、将两个寄存器相减的指令SUB、跳转指令JRC、BRANCH指令等等。这些都是普通权限指令,所有的应用程序都允许执行这些指令。
特殊权限指令主要是一些直接操纵硬件的指令和设置保护的指令,例如**设置page table寄存器、关闭时钟中断。**在处理器上有各种各样的状态,操作系统会使用这些状态,但是只能通过特殊权限指令来变更这些状态。
举个例子,当一个应用程序尝试执行一条特殊权限指令,因为不允许在user mode执行特殊权限指令,处理器会拒绝执行这条指令。通常来说,这时会将控制权限从user mode切换到kernel mode,当操作系统拿到控制权之后,或许会杀掉进程,因为应用程序执行了不该执行的指令。
学生提问:如果kernel mode允许一些指令的执行,user mode不允许一些指令的执行,那么是谁在检查当前的mode并实际运行这些指令,并且怎么知道当前是不是kernel mode?是有什么标志位吗?
Frans教授:是的,在处理器里面有一个flag。在处理器的一个bit,当它为1的时候是user mode,当它为0时是kernel mode。当处理器在解析指令时,如果指令是特殊权限指令,并且该bit被设置为1,处理器会拒绝执行这条指令,就像在运算时不能除以0一样。
同一个学生继续问:所以,唯一的控制方式就是通过某种方式更新了那个bit?
Frans教授:你认为是什么指令更新了那个bit位?是特殊权限指令还是普通权限指令?(等了一会,那个学生没有回答)。很明显,设置那个bit位的指令必须是特殊权限指令,因为应用程序不应该能够设置那个bit到kernel mode,否则的话应用程序就可以运行各种特殊权限指令了。所以那个bit是被保护的,这样回答了你的问题吗?
同一个学生提问:那BIOS呢?BIOS会在操作系统之前运行还是之后?
Frans教授:BIOS是一段计算机自带的代码,它会先启动,之后它会启动操作系统,所以BIOS需要是一段可被信任的代码,它最好是正确的,且不是恶意的。
用户程序会通过系统调用来切换到kernel mode。当用户程序执行系统调用,会通过ECALL触发一个软中断(software interrupt),软中断会查询操作系统预先设定的中断向量表,并执行中断向量表中包含的中断处理程序。中断处理程序在内核中,这样就完成了user mode到kernel mode的切换,并执行用户程序想要执行的特殊权限指令。
硬件对于支持基本上所有的CPU都支持虚拟内存。我下节课会更加深入的讨论虚拟内存,这里先简单看一下。基本上来说,处理器包含了page table,而page table将虚拟内存地址与物理内存地址做了对应。
页表实现分离
每一个进程都会有自己独立的page table,这样的话,每一个进程只能访问出现在自己page table中的物理内存。操作系统会设置page table,使得每一个进程都有不重合的物理内存,这样一个进程就不能访问其他进程的物理内存,因为其他进程的物理内存都不在它的page table中。一个进程甚至都不能随意编造一个内存地址,然后通过这个内存地址来访问其他进程的物理内存。这样就给了我们内存的强隔离性。
页表定义了对于内存的视图,而每个用户进程都有自己对于内存的独立视图
ls程序位于这个矩形中;再画一个矩形,echo程序位于这个矩形中。每个矩形都有一个虚拟内存地址,从0开始到2的n次方。
这样,ls程序有了一个内存地址0,echo程序也有了一个内存地址0。但是操作系统会将两个程序的内存地址0映射到不同的物理内存地址,所以ls程序不能访问echo程序的内存,同样echo程序也不能访问ls程序的内存。
user/kernel mode是分隔用户空间和内核空间的边界,用户空间运行的程序运行在user mode,内核空间的程序运行在kernel mode。操作系统位于内核空间。
例如 当ls程序运行的时候,会调用read/write系统调用;Shell程序会调用fork或者exec系统调用,所以必须要有一种方式可以使得用户的应用程序能够将控制权以一种协同工作的方式转移到内核,这样内核才能提供相应的服务。
所以,需要有一种方式能够让应用程序可以将控制权转移给内核(Entering Kernel)。
在RISC-V中,有一个专门的指令用来实现这个功能,叫做ECALL。ECALL接收一个数字参数,当一个用户程序想要将程序执行的控制权转移到内核,它只需要执行ECALL指令,并传入一个数字。这里的数字参数代表了应用程序想要调用的System Call。
不论是Shell还是其他的应用程序,当它在用户空间执行fork时,它并不是直接调用操作系统中对应的函数,而是调用ECALL指令,并将fork对应的数字作为参数传给ECALL。之后再通过ECALL跳转到内核。
下图中通过一根竖线来区分用户空间和内核空间,左边是用户空间,右边是内核空间。在内核侧,有一个位于syscall.c的函数syscall,每一个从应用程序发起的系统调用都会调用到这个syscall函数,syscall函数会检查ECALL的参数,通过这个参数内核可以知道需要调用的是fork(3.9会有相应的代码跟踪介绍)。
学生提问:操作系统在什么时候检查是否允许执行fork或者write?现在看起来应用程序只需要执行ECALL再加上系统调用对应的数字就能完成调用,但是内核在什么时候决定这个应用程序是否有权限执行特定的系统调用?
Frans教授:是个好问题。原则上来说,在内核侧实现fork的位置可以实现任何的检查,例如检查系统调用的参数,并决定应用程序是否被允许执行fork系统调用。在Unix中,任何应用程序都能调用fork,我们以write为例吧,write的实现需要检查传递给write的地址(需要写入数据的指针)属于用户应用程序,这样内核才不会被欺骗从别的不属于应用程序的位置写入数据。
可以通过系统调用或者说ECALL指令,将控制权从应用程序转到操作系统中。之后内核负责实现具体的功能并检查参数以确保不会被一些坏的参数所欺骗。所以内核有时候也被称为可被信任的计算空间(Trusted Computing Base),在一些安全的术语中也被称为TCB。
其中一个选项是让整个操作系统代码都运行在kernel mode。大多数的Unix操作系统实现都运行在kernel mode。比如,XV6中,所有的操作系统服务都在kernel mode中,这种形式被称为Monolithic Kernel Design(宏内核)。
宏内核
这里有几件事情需要注意:
微内核
另一种设计主要关注点是减少内核中的代码,它被称为Micro Kernel Design(微内核)。在这种模式下,希望在kernel mode中运行尽可能少的代码。所以这种设计下还是有内核,但是内核只有非常少的几个模块,例如,内核通常会有一些IPC的实现或者是Message passing;非常少的虚拟内存的支持,可能只支持了page table;以及分时复用CPU的一些支持。
现在,文件系统运行的就像一个普通的用户程序,就像echo,Shell一样,这些程序都运行在用户空间。可能还会有一些其他的用户应用程序,例如虚拟内存系统的一部分也会以一个普通的应用程序的形式运行在user mode。
假设我们需要让Shell能与文件系统交互,比如Shell调用了exec,必须有种方式可以接入到文件系统中。通常来说,这里工作的方式是,Shell会通过内核中的IPC系统发送一条消息,内核会查看这条消息并发现这是给文件系统的消息,之后内核会把消息发送给文件系统。文件系统会完成它的工作之后会向IPC系统发送回一条消息说,这是你的exec系统调用的结果,之后IPC系统再将这条消息发送给Shell。
现在,对于任何文件系统的交互,都需要分别完成2次用户空间<->内核空间的跳转。与宏内核对比,在宏内核中如果一个应用程序需要与文件系统交互,只需要完成1次用户空间<->内核空间的跳转,所以微内核的的跳转是宏内核的两倍
导致的问题:
2.在一个类似宏内核的紧耦合系统,各个组成部分,例如文件系统和虚拟内存系统,可以很容易的共享page cache。而在微内核中,每个部分之间都很好的隔离开了,这种共享更难实现。进而导致更难在微内核中得到更高的性能。
第一个是kernel。我们可以ls kernel的内容,里面包含了基本上所有的内核文件。因为XV6是一个宏内核结构,这里所有的文件会被编译成一个叫做kernel的二进制文件,然后这个二进制文件会被运行在kernle mode中。
第二个部分是user。这基本上是运行在user mode的程序。这也是为什么一个目录称为kernel,另一个目录称为user的原因。
第三部分叫做mkfs。它会创建一个空的文件镜像,我们会将这个镜像存在磁盘上,这样我们就可以直接使用一个空的文件系统。
首先,Makefile(XV6目录下的文件)会读取一个C文件,例如proc.c;之后调用gcc编译器,生成一个文件叫做proc.s,这是RISC-V 汇编语言文件;之后再走到汇编解释器,生成proc.o,这是汇编语言的二进制格式。
Makefile会为所有内核文件做相同的操作,比如说pipe.c,会按照同样的套路,先经过gcc编译成pipe.s,再通过汇编解释器生成pipe.o。
之后,系统加载器(Loader)会收集所有的.o文件,将它们链接在一起,并生成内核文件。
让我们不带gdb运行XV6(make会读取Makefile文件中的指令)。这里会编译文件,然后调用QEMU(qemu-system-riscv64指令)。这里本质上是通过C语言来模拟仿真RISC-V处理器。
我们来看传给QEMU的几个参数:
这样,XV6系统就在QEMU中启动了。
当你想到QEMU时,你不应该认为它是一个C程序,你应该把它想成是下图,一个真正的主板
当你通过QEMU来运行你的内核时,你应该认为你的内核是运行在这样一个主板之上。主板有一个开关,一个RISC-V处理器,有支持外设的空间,比如说一个接口是连接网线的,一个是PCI-E插槽,主板上还有一些内存芯片,这是一个你可以在上面编程的物理硬件,而XV6操作系统管理这样一块主板,你在你的脑海中应该有这么一张图。
这个图里面有:
在QEMU的主循环中,只在做一件事情:
为了完成这里的工作,QEMU的主循环需要维护寄存器的状态。所以QEMU会有以C语言声明的类似于X0,X1寄存器等等。
当QEMU在执行一条指令,比如(ADD a0, 7, 1),这里会将常量7和1相加,并将结果存储在a0寄存器中,所以在这个例子中,寄存器X0会是7。
学生提问:我想知道,QEMU有没有什么欺骗硬件的实现,比如说overlapping instruction?
Frans教授:并没有,真正的CPU运行在QEMU的下层。当你运行QEMU时,很有可能你是运行在一个x86处理器上,这个x86处理器本身会做各种处理,比如顺序解析指令。所以QEMU对你来说就是个C语言程序。
学生提问:那多线程呢?程序能真正跑在4个核上吗?还是只能跑在一个核上?如果能跑在多个核上,那么QEMU是不是有多线程?
Frans教授:我们在Athena上使用的QEMU还有你们下载的QEMU,它们会使用多线程。QEMU在内部通过多线程实现并行处理。所以,当QEMU在仿真4个CPU核的时候,它是并行的模拟这4个核。我们在后面有个实验会演示这里是如何工作的。所以,(当QEMU仿真多个CPU核时)这里真的是在不同的CPU核上并行运算。
我会启动QEMU,并打开gdb。本质上来说QEMU内部有一个gdb server,当我们启动之后,QEMU会等待gdb客户端连接。
我会在我的计算机上再启动一个gdb客户端,这里是一个RISC-V 64位Linux的gdb,有些同学的电脑可能是multi-arch或者其他版本的的gdb,但是基本上来说,这里的gdb是为RISC-V 64位处理器编译的。
在连接上之后,我会在程序的入口处设置一个端点,因为我们知道这是QEMU会跳转到的第一个指令
我们可以看到,在地址0x8000000a读取了控制系统寄存器(Control System Register)mhartid,并将结果加载到了a1寄存器。所以QEMU会模拟执行这条指令,之后执行下一条指令。
地址0x80000000是一个被QEMU认可的地址。也就是说如果你想使用QEMU,那么第一个指令地址必须是它。所以,我们会让内核加载器从那个位置开始加载内核。如果我们查看kernel.ld,
具体流程:
首先读取控制系统寄存器(Control System Register)mhartid,并将结果加载到了a1寄存器
XV6从entry.s开始启动,这个时候没有内存分页,没有隔离性,并且运行在M-mode(machine mode)。XV6会尽可能快的跳转到kernel mode或者说是supervisor mode。我们在main函数设置一个断点,main函数已经运行在supervisor mode了,会到 gcb
通过在gdb中输入n,可以挑到下一条指令。这里调用了一个名为consoleinit的函数,它的工作与你想象的完全一样,也就是设置好console
kinit:设置好页表分配器(page allocator)
kvminit:设置好虚拟内存,这是下节课的内容
kvminithart:打开页表,也是下节课的内容
processinit:设置好初始进程或者说设置好进程表单
trapinit/trapinithart:设置好user/kernel mode转换代码
plicinit/plicinithart:设置好中断控制器PLIC(Platform Level Interrupt Controller),我们后面在介绍中断的时候会详细的介绍这部分,这是我们用来与磁盘和console交互方式
binit:分配buffer cache
iinit:初始化inode缓存
fileinit:初始化文件系统
virtio_disk_init:初始化磁盘
userinit:最后当所有的设置都完成了,操作系统也运行起来了,会通过userinit运行第一个进程,这里有点意思,接下来我们看一下userinit
userinit有点像是胶水代码/Glue code(胶水代码不实现具体的功能,只是为了适配不同的部分而存在),它利用了XV6的特性,并启动了第一个进程,通过 initcode来初始化第一个用户进程
它首先将init中的地址加载到a0(la a0, init),argv中的地址加载到a1(la a1, argv),exec系统调用对应的数字加载到a7(li a7, SYS_exec),最后调用ECALL。所以这里执行了3条指令,之后在第4条指令将控制权交给了操作系统。
userinit会创建初始进程,返回到用户空间,执行刚刚介绍的3条指令,再回到内核空间。这里是任何XV6用户会使用到的第一个系统调用。让我们来看一下会发生什么。通过在gdb中执行c,让程序运行起来,我们现在进入到了syscall函数。
num = p->trapframe->a7 会读取使用的系统调用对应的整数。当代码执行完这一行之后,我们可以在gdb中打印num,可以看到是7。
syscall.h,可以看到7对应的是exec系统调用。
p->trapframe->a0 = syscallnum 这一行是实际执行系统调用。这里可以看出,num用来索引一个数组,这个数组是一个函数指针数组,可以预期的是syscall[7]对应了exec的入口函数。我们跳到这个函数中去,可以看到,我们现在在sys_exec函数中。
sys_exec中的第一件事情是从用户空间读取参数,它会读取path,也就是要执行程序的文件名。这里首先会为参数分配空间,然后从用户空间将参数拷贝到内核空间。之后我们打印path,打印了 $2 = "/init\
可以看到传入的就是init程序。所以,综合来看,initcode完成了通过exec调用init程序。让我们来看看init程序,
init会为用户空间设置好一些东西,比如配置好console,调用fork,并在fork出的子进程中执行shell。
最终的效果就是Shell运行起来了。如果我再次运行代码,我还会陷入到syscall中的断点,并且同样也是调用exec系统调用,只是这次是通过exec运行Shell。当Shell运行起来之后,我们可以从QEMU看到Shell。
虚拟内存或者页表
无非就是个表单,将虚拟地址和物理地址映射起来,实际可能稍微复杂一点,但是应该不会太难。可是当我开始通过代码管理虚拟内存,我才知道虚拟内存比较棘手,比较有趣,功能也很强大。
对于虚拟内存的理解
学生1:这就是用来存放虚拟内存到物理内存映射关系的。
学生2:这是用来保护硬件设备的。在6.004中介绍的,虚拟地址是12bit,最终会映射到某些16bit的物理地址。
学生3:通过虚拟内存,每个进程都可以有独立的地址空间。通过地址管理单元(Memory Management Unit)或者其他的技术,可以将每个进程的虚拟地址空间映射到物理内存地址。虚拟地址的低bit基本一样,所以映射是以块为单位进行,同时性能也很好。
学生4:虚拟地址可以让我们对进程隐藏物理地址。通过一些聪明的操控,我们可以读写虚拟地址,最后实际读写物理地址。
学生5:虚拟内存对于隔离性来说是非常基础的。每个进程都可以认为自己有独立的内存可以使用。
总结点:隔离
每个进程都有独立的地址空间
正确的设置了page table,并且通过代码对它进行正确的管理,那么原则上你可以实现强隔离
有一些用户应用程序比如说Shell,cat以及你们自己在lab1创造的各种工具。在这些应用程序下面,我们有操作系统位于内核空间。
内存是由一些DRAM芯片组成。在这些DRAM芯片中保存了程序的数据和代码。例如内存中的某一个部分是内核,包括了文本,数据,栈等等;如果运行了Shell,内存中的某个部分就是Shell;如果运行了cat程序,内存中的某个部分是cat程序。这里说的都是物理内存,它的地址从0开始到某个大的地址结束。结束地址取决于我们的机器现在究竟有多少物理内存。所有程序都必须存在于物理内存中,否则处理器甚至都不能处理程序的指令。
地址空间的出现就是能够将不同程序之间的内存隔离开来,保证 比如 cat程序不要弄坏了shell程序的内存镜像
我们给包括内核在内的所有程序专属的地址空间。所以,当我们运行cat时,它的地址空间从0到某个地址结束。当我们运行Shell时,它的地址也从0开始到某个地址结束。内核的地址空间也从0开始到某个地址结束。
如果cat程序想要向地址1000写入数据,那么cat只会向它自己的地址1000,而不是Shell的地址1000写入数据。所以,基本上来说,每个程序都运行在自己的地址空间,并且这些地址空间彼此之间相互独立。在这种不同地址空间的概念中,cat程序甚至都不具备引用属于Shell的内存地址的能力。这是我们想要达成的终极目标,因为这种方式为我们提供了强隔离性,cat现在不能引用任何不属于自己的内存
那么如何实现物理内存和虚拟内存的映射呢,实际上就是依靠页表
学生提问:我比较好奇物理内存的配置,因为物理内存的数量是有限的,而虚拟地址空间存在最大虚拟内存地址,但是会有很多个虚拟地址空间,所以我们在设计的时候需要将最大虚拟内存地址设置的足够小吗?
Frans教授:并不必要,虚拟内存可以比物理内存更大,物理内存也可以比虚拟内存更大。我们马上就会看到这里是如何实现的,其实就是通过page table来实现,这里非常灵活。
同一个学生继续问:如果有太多的进程使用了虚拟内存,有没有可能物理内存耗尽了?
Frans教授:这必然是有可能的。我们接下来会看到如果你有一些大的应用程序,每个程序都有大的page table,并且分配了大量的内存,在某个时间你的内存就耗尽了。
Frans教授提问:大家们,在XV6中从哪可以看到内存耗尽了?如果你们完成了syscall实验,你们会知道在syscall实验中有一部分是打印剩余内存的数量。
学生回答:kalloc?
Frans教授:是的,kalloc。kalloc保存了空余page的列表,如果这个列表为空或者耗尽了,那么kalloc会返回一个空指针,内核会妥善处理并将结果返回给用户应用程序。并告诉用户应用程序,要么是对这个应用程序没有额外的内存了,要么是整个机器都没有内存了。
内核的一部分工作就是优雅的处理这些情况,这里的优雅是指向用户应用程序返回一个错误消息,而不是直接崩溃。
页表是在硬件中通过处理器和内存管理单元(Memory Management Unit)实现
所以,在你们的脑海中,应该有这么一张图:CPU正在执行指令,例如sd $7, (a0)。
对于任何一条带有地址的指令,其中的地址应该认为是虚拟内存地址而不是物理地址。假设寄存器a0中是地址0x1000,那么这是一个虚拟内存地址。虚拟内存地址会被转到内存管理单元(MMU,Memory Management Unit)
内存管理单元会将虚拟地址翻译成物理地址。之后这个物理地址会被用来索引物理内存,并从物理内存加载,或者向物理内存存储数据。
从CPU的角度来说,一旦MMU打开了,它执行的每条指令中的地址都是虚拟内存地址。
为了能够完成虚拟内存地址到物理内存地址的翻译,MMU会有一个表单,表单中,一边是虚拟内存地址,另一边是物理内存地址。举个例子,虚拟内存地址0x1000对应了一个我随口说的物理内存地址0xFFF0。这样的表单可以非常灵活。通常来说,内存地址对应关系的表单也保存在内存中。所以CPU中需要有一些寄存器用来存放表单在物理内存中的地址。
这样,CPU就可以告诉MMU,可以从哪找到将虚拟内存地址翻译成物理内存地址的表单。
学生提问:所以MMU并不会保存page table,它只会从内存中读取page table,然后完成翻译,是吗?
Frans教授:是的,这就是你们应该记住的。page table保存在内存中,MMU只是会去查看page table,我们接下来会看到,page table比我们这里画的要稍微复杂一些。
这里的基本想法是每个应用程序都有自己独立的表单,并且这个表单定义了应用程序的地址空间。所以当操作系统**将CPU从一个应用程序切换到另一个应用程序时,同时也需要切换SATP寄存器中的内容,从而指向新的进程保存在物理内存中的地址对应表单。**这样的话,cat程序和Shell程序中相同的虚拟内存地址,就可以翻译到不同的物理内存地址,因为每个应用程序都有属于自己的不同的地址对应表单
学生提问:刚刚说到SATP寄存器会根据进程而修改,我猜每个进程对应的SATP值是由内核保存的?
Frans教授:是的。内核会写SATP寄存器,写SATP寄存器是一条特殊权限指令。所以,用户应用程序不能通过更新这个寄存器来更换一个地址对应表单,否则的话就会破坏隔离性。所以,只有运行在kernel mode的代码可以更新这个寄存器。
首先对于虚拟内存地址,我们将它划分为两个部分,index和offset,index用来查找page,offset对应的是一个page中的哪个字节
当MMU在做地址翻译的时候,通过读取虚拟内存地址中的index可以知道物理内存中的page号,这个page号对应了物理内存中的4096个字节。之后虚拟内存地址中的offset指向了page中的4096个字节中的某一个,假设offset是12,那么page中的第12个字节被使用了。将offset加上page的起始地址,就可以得到物理内存地址。
有关RISC-V的一件有意思的事情是,虚拟内存地址都是64bit,这也说的通,因为RISC-V的寄存器是64bit的。但是实际上,在我们使用的RSIC-V处理器上,并不是所有的64bit都被使用了,也就是说高25bit并没有被使用。这样的结果是限制了虚拟内存地址的数量,虚拟内存地址的数量现在只有2^39个,大概是512GB。当然,如果必要的话,最新的处理器或许可以支持更大的地址空间,只需要将未使用的25bit拿出来做为虚拟内存地址的一部分即可。
在剩下的39bit中,有27bit被用来当做index,12bit被用来当做offset。offset必须是12bit,因为对应了一个page的4096个字节。
学生提问:我想知道4096字节作为一个page,这在物理内存中是连续的吗?
Frans教授:是的,在物理内存中,这是连续的4096个字节。所以物理内存是以4096为粒度使用的。
同一个学生:所以offset才是12bit,这样就足够覆盖4096个字节?
Frans教授:是的,page中的每个字节都可以被offset索引到。
同一个学生:图中的56bit又是根据什么确定的?
Frans教授:这是由硬件设计人员决定的。所以RISC-V的设计人员认为56bit的物理内存地址是个不错的选择。可以假定,他们是通过技术发展的趋势得到这里的数字。比如说,设计是为了满足5年的需求,可以预测物理内存在5年内不可能超过256这么大。或许,他们预测是的一个小得多的数字,但是为了防止预测错误,他们选择了像256这么大的数字。这里说的通吗?很多同学都问了这个问题。
学生提问:如果虚拟内存最多是227(最多应该是239),而物理内存最多是2^56,这样我们可以有多个进程都用光了他们的虚拟内存,但是物理内存还有剩余,对吗?
Frans教授:是的,完全正确。
学生提问:因为这是一个64bit的机器,为什么硬件设计人员本可以用64bit但是却用了56bit?
Frans教授:选择56bit而不是64bit是因为在主板上只需要56根线。
学生提问:我们从CPU到MMU之后到了内存,但是不同的进程之间的怎么区别?比如说Shell进程在地址0x1000存了一些数据,ls进程也在地址0x1000也存了一些数据,我们需要怎么将它们翻译成不同的物理内存地址。
Frans教授:SATP寄存器包含了需要使用的地址转换表的内存地址。所以ls有自己的地址转换表,cat也有自己的地址转换表。每个进程都有完全属于自己的地址转换表。
实际中,page table是一个多级的结构。下图是一个真正的RISC-V page table结构和硬件实现。
这样就变成了3步索引
所以实际上,SATP寄存器会指向最高一级的page directory的物理内存地址,之后我们用虚拟内存中index的高9bit用来索引最高一级的page directory,这样我们就能得到一个PPN,也就是物理page号。这个PPN指向了中间级的page directory。
当我们在使用中间级的page directory时,我们通过虚拟内存地址中的L1部分完成索引。接下来会走到最低级的page directory,我们通过虚拟内存地址中的L0部分完成索引。在最低级的page directory中,我们可以得到对应于虚拟内存地址的物理内存地址。
学生提问: 这里有层次化的3个page table,每个page table都由虚拟地址的9个bit来索引,所以是由虚拟地址中的3个9bit来分别索引3个page table,对吗?
Frans教授:是的,最高的9个bit用来索引最高一级的page directory,第二个9bit用来索引中间级的page directory,第三个9bit用来索引最低级的page directory。
学生提问:当一个进程请求一个虚拟内存地址时,CPU会查看SATP寄存器得到对应的最高一级page table,这级page table会使用虚拟内存地址中27bit index的最高9bit来完成索引,如果索引的结果为空,MMU会自动创建一个page table吗?
Frans教授:不会的,MMU会告诉操作系统或者处理器,抱歉我不能翻译这个地址,最终这会变成一个page fault。如果一个地址不能被翻译,那就不翻译。就像你在运算时除以0一样,处理器会拒绝那样做。
学生提问:我想知道我们是怎么计算page table的物理地址,是不是这样,我们从最高级的page table得到44bit的PPN,然后再加上虚拟地址中的12bit offset,就得到了完整的56bit page table物理地址?
Frans教授:我们不会加上虚拟地址中的offset,这里只是使用了12bit的0。所以我们用44bit的PPN,再加上12bit的0,这样就得到了下一级page directory的56bit物理地址。这里要求每个page directory都与物理page对齐(也就是page directory的起始地址就是某个page的起始地址,所以低12bit都为0)。
第一次在最高级的page directory,第二次在中间级的page directory,最后一次在最低级的page directory。所以对于一个虚拟内存地址的寻址,需要读三次内存,这里代价有点高
TLB会保存虚拟地址到物理地址的映射关系。
学生提问:3级的page table是由操作系统实现的还是由硬件自己实现的?
Frans教授:这是由硬件实现的,所以3级 page table的查找都发生在硬件中。MMU是硬件的一部分而不是操作系统的一部分。在XV6中,有一个函数也实现了page table的查找,因为时不时的XV6也需要完成硬件的工作,所以XV6有这个叫做walk的函数,它在软件中实现了MMU硬件相同的功能。
学生提问:在这个机制中,TLB发生在哪一步,是在地址翻译之前还是之后?
Frans教授:整个CPU和MMU都在处理器芯片中,所以在一个RISC-V芯片中,有多个CPU核,MMU和TLB存在于每一个CPU核里面。RISC-V处理器有L1 cache,L2 Cache,有些cache是根据物理地址索引的,有些cache是根据虚拟地址索引的,由虚拟地址索引的cache位于MMU之前,由物理地址索引的cache位于MMU之后。
学生提问:之前提到,硬件会完成3级 page table的查找,那为什么我们要在XV6中有一个walk函数来完成同样的工作?
Frans教授:非常好的问题。这里有几个原因,首先XV6中的walk函数设置了最初的page table,它需要对3级page table进行编程所以它首先需要能模拟3级page table。另一个原因或许你们已经在syscall实验中遇到了,在XV6中,内核有它自己的page table,**用户进程也有自己的page table,用户进程指向sys_info结构体的指针存在于用户空间的page table,但是内核需要将这个指针翻译成一个自己可以读写的物理地址。**如果你查看copy_in,copy_out,你可以发现内核会通过用户进程的page table,将用户的虚拟地址翻译得到物理地址,这样内核可以读写相应的物理内存地址。这就是为什么在XV6中需要有walk函数的一些原因。
学生提问:为什么硬件不开发类似于walk函数的接口?这样我们就不用在XV6中用软件实现自己的接口,自己实现还容易有bug。为什么没有一个特殊权限指令,接收虚拟内存地址,并返回物理内存地址?
Frans教授:其实这就跟你向一个虚拟内存地址写数据,硬件会自动帮你完成工作一样(工作是指翻译成物理地址,并完成数据写入)。你们在page table实验中会完成相同的工作。我们接下来在看XV6的实现的时候会看到更多的内容。
page table提供了一层抽象(level of indirection)。我这里说的抽象就是指从虚拟地址到物理地址的映射。这里的映射关系完全由操作系统控制。比如,当一个PTE是无效的,硬件会返回一个page fault,对于这个page fault,操作系统可以更新 page table并再次尝试指令。所以,通过操纵page table,在运行时有各种各样可以做的事情。
我们看一下在XV6中,page table是如何工作的?首先我们来看一下kernel page的分布。下图就是内核中地址的对应关系,左边是内核的虚拟地址空间,右边上半部分是物理内存或者说是DRAM,右边下半部分是I/O设备。接下来我会首先介绍右半部分,然后再介绍左半部分。
图中的右半部分的结构完全由硬件设计者决定。如你们上节课看到的一样,当操作系统启动时,会从地址0x80000000开始运行,这个地址其实也是由硬件设计者决定的
中间是RISC-V处理器,我们现在知道了处理器中有4个核,每个核都有自己的MMU和TLB。处理器旁边就是DRAM芯片。
主板的设计人员决定了,在完成了虚拟到物理地址的翻译之后,如果得到的物理地址大于0x80000000会走向DRAM芯片,如果得到的物理地址低于0x80000000会走向不同的I/O设备。这是由这个主板的设计人员决定的物理结构。
首先,地址0是保留的,地址0x10090000对应以太网,地址0x80000000对应DDR内存,处理器外的易失存储(Off-Chip Volatile Memory),也就是主板上的DRAM芯片。所以,在你们的脑海里应该要记住这张主板的图片,即使我们接下来会基于你们都知道的C语言程序—QEMU来做介绍,但是最终所有的事情都是由主板硬件决定的.即CPU所在主板
学生提问:当你说这里是由硬件决定的,硬件是特指CPU还是说CPU所在的主板?
Frans教授:CPU所在的主板。CPU只是主板的一小部分,DRAM芯片位于处理器之外。是主板设计者将处理器,DRAM和许多I/O设备汇总在一起。对于一个操作系统来说,CPU只是一个部分,I/O设备同样也很重要。所以当你在写一个操作系统时,你需要同时处理CPU和I/O设备,比如你需要向互联网发送一个报文,操作系统需要调用网卡驱动和网卡来实际完成这个工作。
回到最初那张图的右侧:物理地址的分布。可以看到最下面是未被使用的地址,这与主板文档内容是一致的(地址为0)。地址0x1000是boot ROM的物理地址,当你对主板上电,主板做的第一件事情就是运行存储在boot ROM中的代码,当boot完成之后,会跳转到地址0x80000000,操作系统需要确保那个地址有一些数据能够接着启动操作系统。
这里还有一些其他的I/O设备:
地址0x02000000对应CLINT,当你向这个地址执行读写指令,你是向实现了CLINT的芯片执行读写。这里你可以认为你直接在与设备交互,而不是读写物理内存。
学生提问:确认一下,低于0x80000000的物理地址,不存在于DRAM中,当我们在使用这些地址的时候,指令会直接走向其他的硬件,对吗?
Frans教授:是的。高于0x80000000的物理地址对应DRAM芯片,但是对于例如以太网接口,也有一个特定的低于0x80000000的物理地址,我们可以对这个叫做内存映射I/O(Memory-mapped I/O)的地址执行读写指令,来完成设备的操作。
学生提问:为什么物理地址最上面一大块标为未被使用?
Frans教授:物理地址总共有2^56那么多,但是你不用在主板上接入那么多的内存。所以不论主板上有多少DRAM芯片,总是会有一部分物理地址没有被用到。实际上在XV6中,我们限制了内存的大小是128MB。
学生提问:当读指令从CPU发出后,它是怎么路由到正确的I/O设备的?比如说,当CPU要发出指令时,它可以发现现在地址是低于0x80000000,但是它怎么将指令送到正确的I/O设备?
Frans教授:你可以认为在RISC-V中有一个多路输出选择器(demultiplexer)。
用的虚拟地址空间,也就是这张图左边的地址分布。
因为我们想让XV6尽可能的简单易懂,所以这里的虚拟地址到物理地址的映射,大部分是相等的关系。比如说内核会按照这种方式设置page table,虚拟地址0x02000000对应物理地址0x02000000。这意味着左侧低于PHYSTOP的虚拟地址,与右侧使用的物理地址是一样的。
除此之外,这里还有两件重要的事情:
第一件事情是,有一些page在虚拟内存中的地址很靠后,比如kernel stack在虚拟内存中的地址就很靠后。**这是因为在它之下有一个未被映射的Guard page,这个Guard page对应的PTE的Valid 标志位没有设置,这样,如果kernel stack耗尽了,它会溢出到Guard page,但是因为Guard page的PTE中Valid标志位未设置,会导致立即触发page fault,这样的结果好过内存越界之后造成的数据混乱。**立即触发一个panic(也就是page fault),你就知道kernel stack出错了。同时我们也又不想浪费物理内存给Guard page,所以Guard page不会映射到任何物理内存,它只是占据了虚拟地址空间的一段靠后的地址。
同时,kernel stack被映射了两次,在靠后的虚拟地址映射了一次,在PHYSTOP下的Kernel data中又映射了一次,但是实际使用的时候用的是上面的部分,因为有Guard page会更加安全。
你可以向同一个物理地址映射两个虚拟地址,你可以不将一个虚拟地址映射到物理地址。可以是一对一的映射,一对多映射,多对一映射。
第二件事情是权限。例如Kernel text page被标位R-X,意味着你可以读它,也可以在这个地址段执行指令,但是你不能向Kernel text写数据。通过设置权限我们可以尽早的发现Bug从而避免Bug。对于Kernel data需要能被写入,所以它的标志位是RW-,但是你不能在这个地址段运行指令,所以它的X标志位未被设置。(注,所以,kernel text用来存代码,代码可以读,可以运行,但是不能篡改,kernel data用来存数据,数据可以读写,但是不能通过数据伪装代码在kernel中运行)
学生提问:对于不同的进程会有不同的kernel stack吗?
Frans:答案是的。每一个用户进程都有一个对应的kernel stack
学生提问:用户程序的虚拟内存会映射到未使用的物理地址空间吗?
Frans教授:在kernel page table中,有一段Free Memory,它对应了物理内存中的一段地址。
学生提问:每个进程都会有自己的3级树状page table,通过这个page table将虚拟地址翻译成物理地址。所以看起来当我们将内核虚拟地址翻译成物理地址时,我们并不需要kernel的page table,因为进程会使用自己的树状page table并完成地址翻译(注,不太理解这个问题点在哪)。
Frans教授:当kernel创建了一个进程,针对这个进程的page table也会从Free memory中分配出来。内核会为用户进程的page table分配几个page,并填入PTE。在某个时间点,当内核运行了这个进程,内核会将进程的根page table的地址加载到SATP中。从那个时间点开始,处理器会使用内核为那个进程构建的虚拟地址空间。
同一个学生提问:所以内核为进程放弃了一些自己的内存,但是进程的虚拟地址空间理论上与内核的虚拟地址空间一样大,虽然实际中肯定不会这么大。
Frans教授:是的,下图是用户进程的虚拟地址空间分布,与内核地址空间一样,它也是从0到MAXVA。
它有由内核设置好的,专属于进程的page table来完成地址翻译。
学生提问:但是我们不能将所有的MAXVA地址都使用吧?
Frans教授:是的我们不能,这样我们会耗尽内存。大多数的进程使用的内存都远远小于虚拟地址空间。
在 boot 的启动流程当中,有main -> kvminit,这个函数会设置好kernel的地址空间。
在gdb中执行layout split,可以看到(从上面的代码也可以看出)函数的第一步是为最高一级page directory分配物理page(注,调用kalloc就是分配物理page)。下一行将这段内存初始化为0。
之后,通过kvmmap函数,将每一个I/O设备映射到内核。例如,下图中高亮的行将UART0映射到内核的地址空间。所以,通过kvmmap可以将物理地址映射到相同的虚拟地址(注,因为kvmmap的前两个参数一致)。
我们来看一下这里的输出。第一行是最高一级page directory的地址,这就是存在SATP或者将会存在SATP中的地址。第二行可以看到最高一级page directory只有一条PTE序号为0,它包含了中间级page directory的物理地址。第三行可以看到中间级的page directory只有一条PTE序号为128,它指向了最低级page directory的物理地址。第四行可以看到最低级的page directory包含了PTE指向物理地址。你们可以看到最低一级 page directory中PTE的物理地址就是0x10000000,对应了UART0。
前面是物理地址,我们可以从虚拟地址的角度来验证这里符合预期。我们将地址0x10000000向右移位12bit,这样可以得到虚拟地址的高27bit(index部分)。之后我们再对这部分右移位9bit,并打印成10进制数,可以得到128,这就是中间级page directory中PTE的序号。这与之前(4.4)介绍的内容是符合的。
并且Valid标志位也设置了(4.3底部有标志位的介绍)。
内核会持续的按照这种方式,调用kvmmap来设置地址空间。之后会对VIRTIO0、CLINT、PLIC、kernel text、kernel data、最后是TRAMPOLINE进行地址映射。最后我们还会调用vmprint打印完整的kernel page directory,可以看出已经设置了很多PTE。
之后,kvminit函数返回了,在main函数中,我们运行到了kvminithart函数。
这个函数首先设置了SATP寄存器,kernel_pagetable变量来自于kvminit第一行。所以这里实际上是**内核告诉MMU来使用刚刚设置好的page table。**当这里这条指令执行之后,下一个指令的地址会发生什么?
在这条指令之前,还不存在可用的page table,所以也就不存在地址翻译。执行完这条指令之后,程序计数器(Program Counter)增加了4。而之后的下一条指令被执行时,**程序计数器会被内存中的page table翻译。**在这条指令之前,我们使用的都是物理内存地址,这条指令之后page table开始生效,所有的内存地址都变成了另一个含义,也就是虚拟内存地址。
这里能正常工作的原因是值得注意的。因为前一条指令还是在物理内存中,而后一条指令已经在虚拟内存中了。比如,下一条指令地址是0x80001110就是一个虚拟内存地址。
为什么这里能正常工作呢?因为kernel page的映射关系中,虚拟地址到物理地址是完全相等的。所以,在我们打开虚拟地址翻译硬件之后,地址翻译硬件会将一个虚拟地址翻译到相同的物理地址。所以实际上,我们最终还是能通过内存地址执行到正确的指令,因为经过地址翻译0x80001110还是对应0x80001110。
管理虚拟内存的一个难点是,一旦执行了类似于SATP这样的指令,你相当于将一个page table加载到了SATP寄存器,你的世界完全改变了。现在每一个地址都会被你设置好的page table所翻译。那么假设你的page table设置错误了,会发生什么呢?有人想回答这个问题吗?
学生A回答:你可能会覆盖kernel data。
学生B回答:会产生page fault。
是的,因为page table没有设置好,虚拟地址可能根本就翻译不了,那么内核会停止运行并panic。所以,如果page table中有bug,你将会看到奇怪的错误和崩溃,这导致了page table实验将会比较难。如果你不够小心,或者你没有完全理解一些细节,你可能会导致kernel崩溃,这将会花费一些时间和精力来追踪背后的原因。但这就是管理虚拟内存的一部分,因为对于一个这么强大的工具,如果出错了,相应的你也会得到严重的后果。我并不是要给你们泼凉水,哈哈。另一方面,这也很有乐趣,经过了page table实验,你们会真正理解虚拟内存是什么,虚拟内存能做什么。
walk函数模拟了MMU,返回的是va对应的最低级page table的PTE
这个函数会返回page table的PTE,而内核可以读写PTE。首先我们有一个page directory,这个page directory 有512个PTE。最下面是0,最上面是511。这个函数的作用是返回某一个PTE的指针
这是个虚拟地址,它指向了这个PTE。之后内核可以通过向这个地址写数据来操纵这条PTE执行的物理page。当page table被加载到SATP寄存器,这里的更改就会生效。
从代码看,这个函数从level2走到level1然后到level0,如果参数alloc不为0,且某一个level的page table不存在,这个函数会创建一个临时的page table,将内容初始化为0,并继续运行。所以最后总是返回的是最低一级的page directory的PTE。
如果参数alloc没有设置,那么在第一个PTE对应的下一级page table不存在时就会返回。
学生提问:对于walk函数,我有一个比较困惑的地方,在写完SATP寄存器之后,内核还能直接访问物理地址吗?在代码里面看起来像是通过page table将虚拟地址翻译成了物理地址,但是这个时候SATP已经被设置了,得到的物理地址不会被认为是虚拟地址吗?
Frans教授:让我们来看kvminithart函数,**这里的kernel_page_table是一个物理地址,并写入到SATP寄存器中。**从那以后,我们的代码运行在一个我们构建出来的地址空间中。在之前的kvminit函数中,kvmmap会对每个地址或者每个page调用walk函数。所以你的问题是什么?
学生:我想知道,在SATP寄存器设置完之后,walk是不是还是按照相同的方式工作?
Frans:是的。它还能工作的原因是,内核设置了虚拟地址等于物理地址的映射关系,这里很重要,因为很多地方能工作的原因都是因为内核设置的地址映射关系是相同的。
学生:每一个进程的SATP寄存器存在哪?
Frans:每个CPU核只有一个SATP寄存器,但是在每个proc结构体,如果你查看proc.h,里面有一个指向page table的指针,这对应了进程的根page table物理内存地址。
当进行处理 c 语言的时候,会有 main 函数,处理器不能理解 C语言,处理器直接处理的是二进制编码之后的汇编代码
任何一个处理器都有一个关联的ISA(Instruction Sets Architecture),ISA就是处理器能够理解的指令集。每一条指令都有一个对应的二进制编码或者一个Opcode。当处理器在运行时,如果看见了这些编码,那么处理器就该知道该做什么操作。
C语言能够运行在你的处理器之上,之后进行编译为汇编语言,再会被翻译成二进制文件也就是.obj或者.o文件。
因为汇编语言有很多种(注,因为不同的处理器指令集不一样,而汇编语言中都是一条条指令,所以不同处理器对应的汇编语言必然不一样)。RISC-V,你不太能将Linux运行在上面。相应的,大多数现代计算机都运行在x86和x86-64处理器上。x86拥有一套不同的指令集,看起来与RISC-V非常相似。通常你们的个人电脑上运行的处理器是x86,Intel和AMD的CPU都实现了x86。
RISC-V和x86并没有它们第一眼看起来那么相似。RISC-V中的RISC是精简指令集(Reduced Instruction Set Computer)的意思,而x86通常被称为CISC,复杂指令集(Complex Instruction Set Computer)。这两者之间有一些关键的区别:
ARM:典型苹果
当将C代码编译成汇编代码时,现代的编译器会执行各种各样的优化,所以你们自己编译得到的汇编代码可能看起来是不一样的
当你在gdb中做debug的时候,有时候你会看到gdb提示你说某些变量被优化掉了,这意味着编译器决定了自己不再需要那个变量,变量以及相关的信息会在某个时间点删掉。
每个进程的page table中有一个区域是text,汇编代码中的text表明这部分是代码,并且位于page table的text区域中。text中保存的就是代码。
寄存器是CPU或者处理器上,预先定义的可以用来存储数据的位置。寄存器之所以重要是因为汇编代码并不是在内存上执行,而是在寄存器上执行,也就是说,当我们在做add,sub时,我们是对寄存器进行操作。
我们通过load将数据存放在寄存器中,这里的数据源可以是来自内存,也可以来自另一个寄存器。之后我们在寄存器上执行一些操作。如果我们对操作的结果关心的话,我们会将操作的结果store在某个地方。这里的目的地可能是内存中的某个地址,也可能是另一个寄存器。这就是通常使用寄存器的方法。
如果你们还记得的话,所有的寄存器都是64bit,各种各样的数据类型都会被改造的可以放进这64bit中。比如说我们有一个32bit的整数,取决于整数是不是有符号的,会通过在前面补32个0或者1来使得这个整数变成64bit并存在这些寄存器中。
栈:每个区域都是一个 Stack Frame,每执行一次函数调用就会产生一个Stack Frame。
有关Stack Frame有两件事情是确定的:
Return address总是会出现在Stack Frame的第一位
指向前一个Stack Frame的指针也会出现在栈中的固定位置
有关Stack Frame中有两个重要的寄存器,**第一个是SP(Stack Pointer),它指向Stack的底部并代表了当前Stack Frame的位置。第二个是FP(Frame Pointer),它指向当前Stack Frame的顶部。**因为Return address和指向前一个Stack Frame的的指针都在当前Stack Frame的固定位置,所以可以通过当前的FP寄存器寻址到这两个数据。
我们保存前一个Stack Frame的指针的原因是为了让我们能跳转回去。所以当前函数返回时,我们可以将前一个Frame Pointer存储到FP寄存器中。所以我们使用Frame Pointer来操纵我们的Stack Frames,并确保我们总是指向正确的函数。
Stack Frame必须要被汇编代码创建,所以是编译器生成了汇编代码,进而创建了Stack Frame。
struct在内存中是一段连续的地址,如果我们有一个struct,并且有f1,f2,f3三个字段。可以认为struct像是一个数组,但是里面的不同字段的类型可以不一样。
今天我想讨论一下,程序运行是完成用户空间和内核空间的切换。每当
用户空间和内核空间的切换通常被称为trap
很多应用程序,要么因为系统调用,要么因为page fault,都会频繁的切换到内核中。所以,trap机制要尽可能的简单,这一点非常重要。
从只拥有user权限并且位于用户空间的Shell,切换到拥有supervisor权限的内核。在这个过程中,硬件的状态将会非常重要,因为我们很多的工作都是将硬件从适合运行用户应用程序的状态,改变到适合运行内核代码的状态。
首先,我们需要保存32个用户寄存器。因为很显然我们需要恢复用户应用程序的执行,尤其是当用户程序随机的被设备中断所打断时。我们希望内核能够响应中断,之后在用户程序完全无感知的情况下再恢复用户代码的执行。所以这意味着32个用户寄存器不能被内核弄乱。但是这些寄存器又要被内核代码所使用,所以在trap之前,你必须先在某处保存这32个用户寄存器。
程序计数器也需要在某个地方保存,它几乎跟一个用户寄存器的地位是一样的,我们需要能够在用户程序运行中断的位置继续执行用户程序。
我们需要将mode改成supervisor mode,因为我们想要使用内核中的各种各样的特权指令。
SATP寄存器现在正指向user page table,而user page table只包含了用户程序所需要的内存映射和一两个其他的映射,它并没有包含整个内核数据的内存映射。所以在运行内核代码之前,我们需要将SATP指向kernel page table。
我们需要将堆栈寄存器指向位于内核的一个地址,因为我们需要一个堆栈来调用内核的C函数。
一旦我们设置好了,并且所有的硬件状态都适合在内核中使用, 我们需要跳入内核的C代码。
需要特别指出的是,supervisor mode中的代码并不能读写任意物理地址。在supervisor mode中,就像普通的用户代码一样,也需要通过page table来访问内存。如果一个虚拟地址并不在当前由SATP指向的page table中,又或者SATP指向的page table中PTE_U=1,那么supervisor mode不能使用那个地址。所以,即使我们在supervisor mode,我们还是受限于当前page table设置的虚拟地址。
虚拟内存的优点:
内存地址映射相对来说比较静态。不管是user page table还是kernel page table,都是在最开始的时候设置好,之后就不会再做任何变动
通过page fault,内核可以更新page table
STVAL寄存器:用来存储其他异常有关的东西
当一个用户应用程序触发了page fault,page fault会使用与Robert教授上节课介绍的相同的trap机制,将程序运行切换到内核,同时也会将出错的地址存放在STVAL寄存器中
sbrk是XV6提供的系统调用,它使得用户程序能扩大自己的heap
当调用sbrk时,它的参数是整数,代表了你想要申请的page数量(实际中sbrk的参数是字节数)sbrk会扩展heap的上边界(也就是会扩大heap)
当sbrk实际发生或者被调用的时候,内核会分配一些物理内存,并将这些内存映射到用户应用程序的地址空间,然后将内存内容初始化为0,再返回sbrk系统调用。这样,应用程序可以通过多次sbrk系统调用来增加它所需要的内存
实际上使用lazy思想:等到要使用的时候,才进行分配内存
当你查看一个用户程序的地址空间时,存在text区域,data区域,同时还有一个BSS区域(注,BSS区域包含了未被初始化或者初始化为0的全局或者静态变量)。当编译器在生成二进制文件时,编译器会填入这三个区域。text区域是程序的指令,data区域存放的是初始化了的全局变量,BSS包含了未被初始化或者初始化为0的全局变量。
比如说需要更改一两个变量的值,我们会得到page fault。那么,对于这个特定场景中的page fault我们该做什么呢?
学生回答:我认为我们应该创建一个新的page,将其内容设置为0,并重新执行指令。
当Shell处理指令时,它会通过fork创建一个子进程
当我们创建子进程时,与其创建,分配并拷贝内容到新的物理内存,其实我们可以直接共享父进程的物理内存page。所以这里,我们可以设置子进程的PTE(页表描述符)指向父进程对应的物理内存page。
因为一旦子进程想要修改这些内存的内容,相应的更新应该对父进程不可见,因为我们希望在父进程和子进程之间有强隔离性,所以这里我们需要更加小心一些。为了确保进程间的隔离性,我们可以将这里的父进程和子进程的PTE的标志位都设置成只读的。
在某个时间点,当我们需要更改内存的内容时,我们会得到page fault。因为父进程和子进程都会继续运行,而父进程或者子进程都可能会执行store指令来更新一些全局变量,这时就会触发page fault,因为现在在向一个只读的PTE写数据。
在得到page fault之后,我们需要拷贝相应的物理page。假设现在是子进程在执行store指令,那么我们会**分配一个新的物理内存page,然后将page fault相关的物理内存page拷贝到新分配的物理内存page中,并将新分配的物理内存page映射到子进程。**这时,新分配的物理内存page只对子进程的地址空间可见,所以我们可以将相应的PTE设置成可读写,并且我们可以重新执行store指令。实际上,对于触发刚刚page fault的物理page,因为现在只对父进程可见,相应的PTE对于父进程也变成可读写的了。
拷贝了一个page,将新的page映射到相应的用户地址空间,并重新执行用户指令
Frans教授:对于每个物理内存page,我们都需要做引用计数,也就是说对于每4096个字节,我们都需要维护一个引用计数
lazy loading的一种技术
在使用请求分页的系统中,只有在尝试访问磁盘页面并且该页面尚未在内存中时,操作系统才将磁盘页面复制到物理内存中。因此,进程开始执行时其页面都不在物理内存中,并且在进程的大部分工作页面位于物理内存中之前,将发生许多页面错误。这是延迟加载技术的一个示例
将完整或者部分文件加载到内存中,这样就可以通过内存地址相关的load或者store指令来操纵文件。为了支持这个功能,一个现代的操作系统会提供一个叫做**mmap的系统调用。**这个系统调用会接收一个虚拟内存地址(VA),长度(len),protection,一些标志位,一个打开文件的文件描述符,和偏移量(offset)。
大部分操作系统运行时几乎没有任何空闲的内存。这意味着,如果应用程序或者内核需要使用新的内存,那么我们需要丢弃一些已有的内容。现在的空闲内存(free)或许足够几个page用,但是在某个时间点如果需要大量内存的话,要么是从应用程序,要么是从buffer/cache中,需要撤回已经使用的一部分内存。所以,当内核在分配内存的时候,通常都不是一个低成本的操作,因为并不总是有足够的可用内存,为了分配内存需要先撤回一些内存。
中断是从哪里产生的
外设中断来自于主板上的设备,下图是一个SiFive主板,如果你查看这个主板,你可以发现有大量的设备连接在或者可以连接到这个主板上。
UART0会映射到内核内存地址的某处,而所有的物理内存都映射在地址空间的0x80000000之上。(注,详见4.5)。类似于读写内存,通过向相应的设备地址执行load/store指令,我们就可以对例如UART的设备进行编程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JGS3bAT4-1676999753338)(null)]
从左上角可以看出,我们有53个不同的来自于设备的中断。这些中断到达PLIC之后,PLIC会路由这些中断。图的右下角是CPU的核,PLIC会将中断路由到某一个CPU的核。如果所有的CPU核都正在处理中断,PLIC会保留中断直到有一个CPU核可以用来处理中断。所以PLIC需要保存一些内部数据来跟踪中断的状态。
如果你看过了文档,这里的具体流程是:
bottom/top。
bottom部分通常是Interrupt handler。当一个中断送到了CPU,并且CPU设置接收这个中断,CPU会调用相应的Interrupt handler。Interrupt handler并不运行在任何特定进程的context中,它只是处理中断。
top部分,是用户进程,或者内核的其他部分调用的接口。对于UART来说,这里有read/write接口,这些接口可以被更高层级的代码调用。
驱动中会有一些队列(或者说buffer),top部分的代码会从队列中读写数据,而Interrupt handler(bottom部分)同时也会向队列中读写数据。这里的队列可以将并行运行的设备和CPU解耦开来。
通常对于Interrupt handler来说存在一些限制,因为它并没有运行在任何进程的context中,**所以进程的page table并不知道该从哪个地址读写数据,也就无法直接从Interrupt handler读写数据。**驱动的top部分通常与用户的进程交互,并进行数据的读写。我们后面会看更多的细节,这里是一个驱动的典型架构。
如何对设备进行编程。通常来说,编程是通过memory mapped I/O(存储器映射输入输出)完成的
操作系统需要知道这些设备位于物理地址空间的具体位置,然后再通过普通的load/store指令对这些地址进行编程。load/store指令实际上的工作就是读写设备的控制寄存器。
学生提问:如果你写入数据到Transmit Holding Register,然后再次写入,那么前一个数据不会被覆盖掉吗?
Frans教授:这是我们需要注意的一件事情。我们通过load将数据写入到这个寄存器中,之后UART芯片会通过串口线将这个Byte送出。当完成了发送,UART会生成一个中断给内核,这个时候才能再次写入下一个数据。所以内核和设备之间需要遵守一些协议才能确保一切工作正常。上图中的UART芯片会有一个容量是16的FIFO,但是你还是要小心,因为如果阻塞了16个Byte之后再次写入还是会造成数据覆盖。
对于“ls”,这是用户输入的字符。键盘连接到了UART的输入线路,当你在键盘上按下一个按键,UART芯片会将按键字符通过串口线发送到另一端的UART芯片。另一端的UART芯片先将数据bit合并成一个Byte,之后再产生一个中断,并告诉处理器说这里有一个来自于键盘的字符。之后Interrupt handler会处理来自于UART的字符
在实际运行进程之前,会执行intr_on函数来使得CPU能接收中断
设置SSTATUS寄存器,打开中断标志位
在这个时间点,中断被完全打开了。如果PLIC正好有pending的中断,那么这个CPU核会收到中断。
以上就是中断的基本设置。
如何从Shell程序输出提示符“$ ”到Console
通过mknod操作创建了console设备。因为这是第一个打开的文件,所以这里的文件描述符0。之后通过dup创建stdout和stderr。这里实际上通过复制文件描述符0,得到了另外两个文件描述符1,2。最终文件描述符0,1,2都用来代表Console。
Shell程序首先打开文件描述符0,1,2。之后Shell向文件描述符2打印提示符“$ ”
先通过either_copyin将字符拷入,之后调用uartputc函数。uartputc函数将字符写入给UART设备,所以你可以认为consolewrite是一个UART驱动的top部分。
RUNNING,线程当前正在某个CPU上运行
RUNABLE,线程还没有在某个CPU上运行,但是一旦有空闲的CPU就可以运行
SLEEPING,这节课我们不会介绍,下节课会重点介绍,这个状态意味着线程在等待一些I/O事件,它只会在I/O事件发生了之后运行
除了系统调用,用户进程也有可能是因为CPU需要响应类似于定时器中断走到了内核空间
运行多个用户空间进程,例如C compiler(CC),LS,Shell,它们或许会,也或许不会想要同时运行。在用户空间,每个进程有自己的内存,对于我们这节课来说,我们更关心的是每个进程都包含了一个用户程序栈(user stack),并且当进程运行的时候,它在RISC-V处理器中会有程序计数器和寄存器。
实际上是用户进程中的一个用户线程在运行。如果程序执行了一个系统调用或者因为响应中断走到了内核中,那么相应的用户空间状态会被保存在程序的trapframe中(注,详见lec06),同时属于这个用户程序的内核线程被激活
所以首先,用户的程序计数器,寄存器等等被保存到了trapframe中,之后CPU被切换到内核栈上运行,实际上会走到trampoline和usertrap代码中(注,详见lec06)。之后内核会运行一段时间处理系统调用或者执行中断处理程序。在处理完成之后,如果需要返回到用户空间,trapframe中保存的用户进程状态会被恢复。
当XV6从CC程序的内核线程切换到LS程序的内核线程时:
这里核心点在于,在XV6中,任何时候都需要经历:
我们从一个正在运行的用户空间进程切换到另一个RUNABLE但是还没有运行的用户空间进程的更完整的故事是:
实际上swtch函数并不是直接从一个内核线程切换到另一个内核线程。 XV6中,一个CPU上运行的内核线程可以直接切换到的是这个CPU对应的调度器线程。所以如果我们运行在CPU0,swtch函数会恢复之前为CPU0的调度器线程保存的寄存器和stack pointer,之后就在调度器线程的context下执行schedulder函数中
在schedulder函数中会做一些清理工作,例如将进程P1设置成RUNABLE状态。之后再通过进程表单找到下一个RUNABLE进程。假设找到的下一个进程是P2(虽然也有可能找到的还是P1),schedulder函数会再次调用swtch函数,完成下面步骤:
每一个CPU都有一个完全不同的调度器线程。调度器线程也是一种内核线程,它也有自己的context对象。任何运行在CPU1上的进程,当它决定出让CPU,它都会切换到CPU1对应的调度器线程,并由调度器线程切换到下一个进程。
学生提问:出让CPU是由用户发起的还是由内核发起的?
Robert教授:对于XV6来说,并不会直接让用户线程出让CPU或者完成线程切换,而是由内核在合适的时间点做决定。有的时候你可以猜到特定的系统调用会导致出让CPU,例如一个用户进程读取pipe,而它知道pipe中并不能读到任何数据,这时你可以预测读取会被阻塞,而内核在等待数据的过程中会运行其他的进程。
内核会在两个场景下出让CPU。当定时器中断触发了,内核总是会让当前进程出让CPU,因为我们需要在定时器中断间隔的时间点上交织执行所有想要运行的进程。另一种场景就是任何时候一个进程调用了系统调用并等待I/O,例如等待你敲入下一个按键,在你还没有按下按键时,等待I/O的机制会触发出让CPU。
学生提问:用户进程调用sleep函数是不是会调用某个系统调用,然后将用户进程的信息保存在trapframe,然后触发进程切换,这时就不是定时器中断决定,而是用户进程自己决定了吧?
Robert教授:如果进程执行了read系统调用,然后进入到了内核中。而read系统调用要求进程等待磁盘,这时系统调用代码会调用sleep,而sleep最后会调用swtch函数。swtch函数会保存内核线程的寄存器到进程的context中,然后切换到对应CPU的调度器线程,再让其他的线程运行。这样在当前线程等待磁盘读取结束时,其他线程还能运行。所以,这里的流程除了没有定时器中断,其他都一样,只是这里是因为一个系统调用需要等待I/O(注,感觉答非所问)
**每个CPU核在一个时间只会运行一个线程,它要么是运行用户进程的线程,要么是运行内核线程,要么是运行这个CPU核对应的调度器线程。**所以在任何一个时间点,CPU核并没有做多件事情,而是只做一件事情。线程的切换创造了多个线程同时运行在一个CPU上的假象。类似的每一个线程要么是只运行在一个CPU核上,要么它的状态被保存在context中。线程永远不会运行在多个CPU核上,线程要么运行在一个CPU核上,要么就没有运行。
学生提问:我们这里一直在说线程,但是从我看来XV6的实现中,一个进程就只有一个线程,有没有可能一个进程有多个线程?
Robert教授:我们这里的用词的确有点让人混淆。在XV6中,一个进程要么在用户空间执行指令,要么是在内核空间执行指令,要么它的状态被保存在context和trapframe中,并且没有执行任何指令。这里该怎么称呼它呢?你可以根据自己的喜好来称呼它,对于我来说,每个进程有两个线程,一个用户空间线程,一个内核空间线程,并且存在限制使得一个进程要么运行在用户空间线程,要么为了执行系统调用或者响应中断而运行在内核空间线程 ,但是永远也不会两者同时运行。
proc.h中的proc结构体
学生提问:怎么区分不同进程的内核线程?
Robert教授:每一个进程都有一个独立的内核线程。实际上有两件事情可以区分不同进程的内核线程,其中一件是,每个进程都有不同的内核栈,它由proc结构体中的kstack字段所指向;另一件就是,任何内核代码都可以通过调用myproc函数来获取当前CPU正在运行的进程。内核线程可以通过调用这个函数知道自己属于哪个用户进程。myproc函数会使用tp寄存器来获取当前的CPU核的ID,并使用这个ID在一个保存了所有CPU上运行的进程的结构体数组中,找到对应的proc结构体。这就是不同的内核线程区分自己的方法。
yield函数只做了几件事情,它首先获取了进程的锁。实际上,在锁释放之前,进程的状态会变得不一致,例如,yield将要将进程的状态改为RUNABLE,表明进程并没有在运行,但是实际上这个进程还在运行,代码正在当前进程的内核线程中运行。所以这里加锁的目的之一就是:即使我们将进程的状态改为了RUNABLE,其他的CPU核的调度器线程也不可能看到进程的状态为RUNABLE并尝试运行它。否则的话,进程就会在两个CPU核上运行了,而一个进程只有一个栈,这意味着两个CPU核在同一个栈上运行代码(注,因为XV6中一个用户进程只有一个用户线程)。
**接下来yield函数中将进程的状态改为RUNABLE。**这里的意思是,当前进程要出让CPU,并切换到调度器线程。当前进程的状态是RUNABLE意味着它还会再次运行,因为毕竟现在是一个定时器中断打断了当前正在运行的进程。
swtch函数会将当前的内核线程的寄存器保存到p->context中。 swtch函数的另一个参数c->context,c表示当前CPU的结构体。CPU结构体中的context保存了当前CPU核的调度器线程的寄存器。所以swtch函数在保存完当前内核线程的内核寄存器之后,就会恢复当前CPU核的调度器线程的寄存器,并继续执行当前CPU核的调度器线程。
从哪调用进到swtch函数的,因为当我们通过switch恢复执行当前线程并且从swtch函数返回时,我们希望能够从调用点继续执行。ra寄存器保存了swtch函数的调用点,所以这里保存的是ra寄存器。我们可以打印ra寄存器,如你们所预期的一样,它指向了sched函数。
调度器线程调用了swtch函数
另一件需要注意的事情是,swtch函数是线程切换的核心,但是swtch函数中只有保存寄存器,再加载寄存器的操作。线程除了寄存器以外的还有很多其他状态,它有变量,堆中的数据等等,但是所有的这些数据都在内存中,并且会保持不变。我们没有改变线程的任何栈或者堆数据。所以线程切换的过程中,处理器中的寄存器是唯一的不稳定状态,且需要保存并恢复。而所有其他在内存中的数据会保存在内存中不被改变,所以不用特意保存并恢复。我们只是保存并恢复了处理器中的寄存器,因为我们想在新的线程中也使用相同的一组寄存器。
学生提问:当调用swtch函数的时候,实际上是从一个线程对于switch的调用切换到了另一个线程对于switch的调用。所以线程第一次调用swtch函数时,需要伪造一个“另一个线程”对于switch的调用,是吧?因为也不能通过swtch函数随机跳到其他代码去。
Robert教授:是的。我们来看一下第一次调用switch时,“另一个”调用swtch函数的线程的context对象。proc.c文件中的allocproc函数会被启动时的第一个进程和fork调用,allocproc会设置好新进程的context
在XV6中,任何时候调用switch函数都会从一个线程切换到另一个线程,通常是在用户进程的内核线程和调度器线程之间切换。在调用switch函数之前,总是会先获取线程对应的用户进程的锁。所以过程是这样,一个进程先获取自己的锁,然后调用switch函数切换到调度器线程,调度器线程再释放进程锁。
实际上的代码顺序更像这样:
在第1步中获取进程的锁的原因是,**这样可以阻止其他CPU核的调度器线程在当前进程完成切换前,发现进程是RUNNABLE的状态并尝试运行它。**为什么要阻止呢?因为其他每一个CPU核都有一个调度器线程在遍历进程表单,如果没有在进程切换的最开始就获取进程的锁的话,其他CPU核就有可能在当前进程还在运行时,认为该进程是RUNNABLE并运行它。而两个CPU核使用同一个栈运行同一个线程会使得系统立即崩溃。
所以,在进程切换的最开始,进程先获取自己的锁,并且直到调用switch函数时也不释放锁。而另一个线程,也就是调度器线程会在进程的线程完全停止使用自己的栈之后,再释放进程的锁。释放锁之后,就可以由其他的CPU核再来运行进程的线程,因为这些线程现在已经不在运行了。
如果你知道你要等待的事件极有可能在0.1微秒内发生,通过循环等待或许是最好的实现方式。通常来说在操作设备硬件的代码中会采用这样的等待方式,如果你要求一个硬件完成一个任务,并且你知道硬件总是能非常快的完成任务,这时通过一个类似的循环等待或许是最正确的方式。
另一方面,事件可能需要数个毫秒甚至你都不知道事件要多久才能发生,或许要10分钟其他的进程才能向Pipe写入数据,那么我们就不想在这一直循环并且浪费本可以用来完成其他任务的CPU时间。这时我们想要通过类似switch函数调用的方式出让CPU,并在我们关心的事件发生时重新获取CPU。Coordination就是有关出让CPU,直到等待的事件发生再恢复执行。人们发明了很多不同的Coordination的实现方式,但是与许多Unix风格操作系统一样,XV6使用的是Sleep&Wakeup这种方式。
中断处理程序会在最开始读取UART对应的memory mapped register,并检查其中表明传输完成的相应的标志位,也就是LSR_TX_IDLE标志位。**如果这个标志位为1,代码会将tx_done设置为1,并调用wakeup函数。这个函数会使得uartwrite中的sleep函数恢复执行,并尝试发送一个新的字符。**所以这里的机制是,**如果一个线程需要等待某些事件,比如说等待UART硬件愿意接收一个新的字符,线程调用sleep函数并等待一个特定的条件。**当特定的条件满足时,代码会调用wakeup函数。这里的sleep函数和wakeup函数是成对出现的。我们之后会看sleep函数的具体实现,它会做很多事情最后再调用switch函数来出让CPU。
学生提问:进程会在写入每个字符时候都被唤醒一次吗?
Robert教授:在这个我出于演示目的而特别改过的UART驱动中,传输每个字符都会有一个中断,所以你是对的,对于buffer中的每个字符,我们都会等待UART可以接收下一个字符,之后写入一个字符,将tx_done设置为0,回到循环的最开始并再次调用sleep函数进行睡眠状态,直到tx_done为1。当UART传输完了这个字符,uartintr函数会将tx_done设置为1,并唤醒uartwrite所在的线程。所以对于每个字符都有调用一次sleep和wakeup,并占用一次循环。
UART实际上支持一次传输4或者16个字符,所以一个更有效的驱动会在每一次循环都传输16个字符给UART,并且中断也是每16个字符触发一次。更高速的设备,例如以太网卡通常会更多个字节触发一次中断。
对于sleep函数,有一个有趣的参数,我们需要将一个锁作为第二个参数传入, 不太可能设计一个sleep函数并完全忽略需要等待的事件。所以很难写一个通用的sleep函数,只是睡眠并等待一些特定的事件,并且这也很危险,因为可能会导致lost wakeup,而几乎所有的Coordination机制都需要处理lost wakeup的问题。
我们需要记录特定的sleep channel值,这样之后的wakeup函数才能发现是当前进程正在等待wakeup对应的事件。最后再调用switch函数出让CPU。
如果sleep函数只做了这些操作,那么很明显sleep函数会出问题,我们至少还应该在这里获取进程的锁。
之后是wakeup函数。我们**希望唤醒所有正在等待特定sleep channel的线程。**所以wakeup函数中会查询进程表单中的所有进程,如果进程的状态是SLEEPING并且进程对应的channel是当前wakeup的参数,那么将进程的状态设置为RUNNABLE。
为什么需要通过一个循环while(tx_done == 0)来调用sleep函数?这个问题的答案适用于一个更通用的场景:实际中不太可能将sleep和wakeup精确匹配。并不是说sleep函数返回了,你等待的事件就一定会发生。举个例子,假设我们有两个进程同时想写UART,它们都在uartwrite函数中。可能发生这种场景,当一个进程写完一个字符之后,会进入SLEEPING状态并释放锁,而另一个进程可以在这时进入到循环并等待UART空闲下来。之后两个进程都进入到SLEEPING状态,当发生中断时UART可以再次接收一个字符,两个进程都会被唤醒,但是只有一个进程应该写入字符,所以我们才需要在sleep外面包一层while循环。实际上,你可以在XV6中的每一个sleep函数调用都被一个while循环包着。因为事实是,你或许被唤醒了,但是其他人将你等待的事件拿走了,所以你还得继续sleep。这种现象还挺普遍的。
通过消除下面的窗口时间
sleep函数需要释放作为第二个参数传入的锁,这样中断处理程序才能获取锁。函数中第一件事情就是释放这个锁。当然在释放锁之后,我们会担心在这个时间点相应的wakeup会被调用并尝试唤醒当前进程,而当前进程还没有进入到SLEEPING状态。所以我们不能让wakeup在release锁之后执行。为了让它不在release锁之后执行,在release锁之前,sleep会获取即将进入SLEEPING状态的进程的锁。
如果你还记得的话,wakeup在唤醒一个进程前,需要先获取进程的锁。所以在整个时间uartwrite检查条件之前到sleep函数中调用sched函数之间,这个线程一直持有了保护sleep条件的锁或者p->lock。让我回到UART的代码并强调一下这一点。
如果你还记得的话,当我们从当前线程切换走时,调度器线程中会释放前一个进程的锁(注,详见11.8)。所以在调度器线程释放进程锁之后,wakeup才能终于获取进程的锁,发现它正在SLEEPING状态,并唤醒它。
这里的效果是由之前定义的一些规则确保的,这些规则包括了:
这样的话,我们就不会再丢失任何一个wakeup
接下来,我想讨论一下XV6面临的一个与Sleep&Wakeup相关的挑战,也就是如何关闭一个进程。每个进程最终都需要退出,我们需要清除进程的状态,释放栈。在XV6中,**一个进程如果退出的话,我们需要释放用户内存,释放page table,释放trapframe对象,将进程在进程表单中标为REUSABLE,这些都是典型的清理步骤。**当进程退出或者被杀掉时,有许多东西都需要被释放。
这里会产生的两大问题:
exit的代码
从exit接口的整体来看,在最后它会释放进程的内存和page table,关闭已经打开的文件,同时我们也知道父进程会从wait系统调用中唤醒,所以exit最终会导致父进程被唤醒。这些都是我们预期可以从exit代码中看到的内容。
**如果一个进程要退出,但是它又有自己的子进程,接下来需要设置这些子进程的父进程为init进程。**我们接下来会看到,每一个正在exit的进程,都有一个父进程中的对应的wait系统调用。父进程中的wait系统调用会完成进程退出最后的几个步骤。所以如果父进程退出了,那么子进程就不再有父进程,当它们要退出时就没有对应的父进程的wait。所以在exit函数中,会为即将exit进程的子进程重新指定父进程为init进程,也就是PID为1的进程。
之后,我们需要通过调用wakeup函数唤醒当前进程的父进程,当前进程的父进程或许正在等待当前进程退出。
接下来,进程的状态被设置为ZOMBIE。现在进程还没有完全释放它的资源,所以它还不能被重用。所谓的进程重用是指,我们期望在最后,进程的所有状态都可以被一些其他无关的fork系统调用复用,但是目前我们还没有到那一步。
现在我们还没有结束,因为我们还没有释放进程资源。我们在还没有完全释放所有资源的时候,通过调用sched函数进入到调度器线程。
到目前位置,进程的状态是ZOMBIE,并且进程不会再运行,因为调度器只会运行RUNNABLE进程。同时进程资源也并没有完全释放,如果释放了进程的状态应该是UNUSED。但是可以肯定的是进程不会再运行了,因为它的状态是ZOMBIE。所以调度器线程会决定运行其他的进程。
通过Unix的exit和wait系统调用的说明,我们可以知道如果一个进程exit了,并且它的父进程调用了wait系统调用,父进程的wait会返回。wait函数的返回表明当前进程的一个子进程退出了。所以接下来我们看一下wait系统调用的实现。
这是关闭一个进程的最后一些步骤。如果由正在退出的进程自己在exit函数中执行这些步骤,将会非常奇怪。这里释放了trapframe,释放了page table。如果我们需要释放进程内核栈,那么也应该在这里释放。但是因为内核栈的guard page,我们没有必要再释放一次内核栈。不管怎样,当进程还在exit函数中运行时,任何这些资源在exit函数中释放都会很难受,所以这些资源都是由父进程释放的。
wait不仅是为了父进程方便的知道子进程退出,wait实际上也是进程退出的一个重要组成部分。在Unix中,对于每一个退出的进程,都需要有一个对应的wait系统调用,这就是为什么当一个进程退出时,它的子进程需要变成init进程的子进程。init进程的工作就是在一个循环中不停调用wait,因为每个进程都需要对应一个wait,这样它的父进程才能调用freeproc函数,并清理进程的资源。
当父进程完成了清理进程的所有资源,子进程的状态会被设置成UNUSED。之后,fork系统调用才能重用进程在进程表单的位置。
学生提问:在exit系统调用中,为什么需要在重新设置父进程之前,先获取当前进程的父进程?
Robert教授:这里其实就是在防止一个进程和它的父进程同时退出。通常情况下,一个进程exit,它的父进程正在wait,一切都正常。但是也可能一个进程和它的父进程同时exit。所以当子进程尝试唤醒父进程,并告诉它自己退出了时,父进程也在退出。这些代码我一年前还记得是干嘛的,现在已经记不太清了。它应该是处理这种父进程和子进程同时退出的情况。如果不是这种情况的话,一切都会非常直观,子进程会在后面通过wakeup函数唤醒父进程。
学生提问:为什么我们在唤醒父进程之后才将进程的状态设置为ZOMBIE?难道我们不应该在之前就设置吗?
Robert教授:正在退出的进程会先获取自己进程的锁,同时,因为父进程的wait系统调用中也需要获取子进程的锁,所以父进程并不能查看正在执行exit函数的进程的状态。这意味着,正在退出的进程获取自己的锁到它调用sched进入到调度器线程之间(注,因为调度器线程会释放进程的锁),父进程并不能看到这之间代码引起的中间状态。所以这之间的代码顺序并不重要。大部分时候,如果没有持有锁,exit中任何代码顺序都不能工作。因为有了锁,代码的顺序就不再重要,因为父进程也看不到进程状态。
最后我想看的是kill系统调用。Unix中的一个进程可以将另一个进程的ID传递给kill系统调用,并让另一个进程停止运行。如果我们不够小心的话,kill一个还在内核执行代码的进程,会有一些我几分钟前介绍过的风险,比如我们想要杀掉的进程的内核线程还在更新一些数据,比如说更新文件系统,创建一个文件。如果这样的话,我们不能就这样杀掉进程,因为这样会使得一些需要多步完成的操作只执行了一部分。所以kill系统调用不能就直接停止目标进程的运行。实际上,在XV6和其他的Unix系统中,kill系统调用基本上不做任何事情。
它先扫描进程表单,找到目标进程。然后只是将进程的proc结构体中killed标志位设置为1。如果进程正在SLEEPING状态,将其设置为RUNNABLE。这里只是将killed标志位设置为1,并没有停止进程的运行。所以kill系统调用本身还是很温和的。
而目标进程运行到内核代码中能安全停止运行的位置时,会检查自己的killed标志位,如果设置为1,目标进程会自愿的执行exit系统调用。
所以kill系统调用并不是真正的立即停止进程的运行,它更像是这样:如果进程在用户空间,那么下一次它执行系统调用它就会退出,又或者目标进程正在执行用户代码,当时下一次定时器中断或者其他中断触发了,进程才会退出。所以从一个进程调用kill,到另一个进程真正退出,中间可能有很明显的延时。
fd = open("x/y", -);
上面的系统调用会创建文件,并返回文件描述符给调用者。调用者也就是用户应用程序可以对文件描述符调用write
write(fd, "abc", 3)
这里我们向文件写入“abc”三个字符
从这两个调用已经可以看出一些信息了:
所以文件系统内部需要以某种方式跟踪指向同一个文件的多个文件名。
我们还可能会在文件打开时,删除或者更新文件的命名空间。例如,用户可以通过unlink系统调用来删除特定的文件名。如果此时相应的文件描述符还是打开的状态,那我们还可以向文件写数据,并且这也能正常工作。
所以,在文件系统内部,文件描述符必然与某个对象关联,而这个对象不依赖文件名。这样,即使文件名变化了,文件描述符仍然能够指向或者引用相同的文件对象。所以,实际上操作系统内部需要对于文件有内部的表现形式,并且这种表现形式与文件名无关。
首先,最重要的可能就是inode,**这是代表一个文件的对象,并且它不依赖于文件名。实际上,inode是通过自身的编号来进行区分的,这里的编号就是个整数。**所以文件系统内部通过一个数字,而不是通过文件路径名引用inode。同时,基于之前的讨论,**inode必须有一个link count来跟踪指向这个inode的文件名的数量。**一个文件(inode)只能在link count为0的时候被删除。实际的过程可能会更加复杂,实际中还有一个openfd count,也就是当前打开了文件的文件描述符计数。一个文件只能在这两个计数器都为0的时候才能被删除。
同时基于之前的讨论,我们也知道write和read都没有针对文件的offset参数,所以文件描述符必然自己悄悄维护了对于文件的offset。
文件系统中核心的数据结构就是inode和file descriptor。后者主要与用户进程进行交互。
尽管文件系统的API很相近并且内部实现可能非常不一样。但是很多文件系统都有类似的结构。因为文件系统还挺复杂的,所以最好按照分层的方式进行理解。可以这样看:
实际中有非常非常多不同类型的存储设备,这些设备的区别在于性能,容量,数据保存的期限等。其中两种最常见,并且你们应该也挺熟悉的是SSD和HDD
这里有些术语有点让人困惑,它们是sectors和blocks。
有的时候,人们也将磁盘上的sector称为block。所以这里的术语也不是很精确。
**这些存储设备连接到了电脑总线之上,总线也连接了CPU和内存。一个文件系统运行在CPU上,将内部的数据存储在内存,同时也会以读写block的形式存储在SSD或者HDD。**这里的接口还是挺简单的,包括了read/write,然后以block编号作为参数。
而文件系统的工作就是将所有的数据结构以一种能够在重启之后重新构建文件系统的方式,存放在磁盘上。虽然有不同的方式,但是XV6使用了一种非常简单,但是还挺常见的布局结构。
通常来说:
通常来说,bitmap block,inode blocks和log blocks被统称为metadata block。它们虽然不存储实际的数据,但是它们存储了能帮助文件系统完成工作的元数据。
通常来说它有一个type字段,表明inode是文件还是目录。
nlink字段,也就是link计数器,用来跟踪究竟有多少文件名指向了当前的inode。
size字段,表明了文件数据有多少个字节。
不同文件系统中的表达方式可能不一样,不过在XV6中接下来是一些block的编号,例如编号0,编号1,等等。XV6的inode中总共有12个block编号。这些被称为direct block number。这12个block编号指向了构成文件的前12个block。举个例子,如果文件只有2个字节,那么只会有一个block编号0,它包含的数字是磁盘上文件前2个字节的block的位置。
之后还有一个indirect block number,它对应了磁盘上一个block,这个block包含了256个block number,这256个block number包含了文件的数据。所以inode中block number 0到block number 11都是direct block number,而block number 12保存的indirect block number指向了另一个block
学生回答:可以扩展inode中indirect部分吗?
是的,可以用类似page table的方式,构建一个双重indirect block number指向一个block,这个block中再包含了256个indirect block number,每一个又指向了包含256个block number的block。这样的话,最大的文件长度会大得多(注,是2562561K)
总结一下,inode中的信息完全足够用来实现read/write系统调用,至少可以找到哪个disk block需要用来执行read/write系统调用。
接下来我们讨论一下目录(directory)。文件系统的酷炫特性就是层次化的命名空间(hierarchical namespace),你可以在文件系统中保存对用户友好的文件名。大部分Unix文件系统有趣的点在于,一个目录本质上是一个文件加上一些文件系统能够理解的结构。在XV6中,这里的结构极其简单。每一个目录包含了directory entries,每一条entry都有固定的格式:
对于实现路径名查找,这里的信息就足够了。假设我们要查找路径名“/y/x”,我们该怎么做呢?
从路径名我们知道,应该从root inode开始查找。通常root inode会有固定的inode编号,在XV6中,这个编号是1。我们该如何根据编号找到root inode呢?从前一节我们可以知道,inode从block 32开始,如果是inode1,那么必然在block 32中的64到128字节的位置。所以文件系统可以直接读到root inode的内容。
对于路径名查找程序,接下来就是扫描root inode包含的所有block,以找到“y”。该怎么找到root inode所有对应的block呢?根据前一节的内容就是读取所有的direct block number和indirect block number。
如果找到了,那么目录y也会有一个inode编号,假设是251,我们可以继续从inode 251查找,先读取inode 251的内容,之后再扫描inode所有对应的block,找到“x”并得到文件x对应的inode编号,最后将其作为路径名查找的结果返回。
这里会有几个阶段
你们觉得的write 33代表了什么?我们正在创建文件,所以我们期望文件系统干什么呢?
学生回答:这是在写inode。
是的,看起来给我们分配的inode位于block 33。之所以有两个write 33,第一个是为了标记inode将要被使用。在XV6中,我记得是使用inode中的type字段来标识inode是否空闲,这个字段同时也会用来表示inode是一个文件还是一个目录。所以这里将inode的type从空闲改成了文件,并写入磁盘表示这个inode已经被使用了。第二个write 33就是实际的写入inode的内容。inode的内容会包含linkcount为1以及其他内容。
write 46是向第一个data block写数据,那么这个data block属于谁呢?
学生回答:属于根目录。
是的,block 46是根目录的第一个block。为什么它需要被写入数据呢?
学生回答:因为我们正在向根目录创建一个新文件。
是的,这里我们向根目录增加了一个新的entry,其中包含了文件名x,以及我们刚刚分配的inode编号。
接下来的write 32又是什么意思呢?block 32保存的仍然是inode,那么inode中的什么发生了变化使得需要将更新后的inode写入磁盘?是的,根目录的大小变了,因为我们刚刚添加了16个字节的entry来代表文件x的信息。
最后又有一次write 33,我在稍后会介绍这次写入的内容,这里我们再次更新了文件x的inode, 尽管我们又还没有写入任何数据。
以上就是第一阶段创建文件的过程。第二阶段是向文件写入“hi”。
首先是write 45,这是更新bitmap。文件系统首先会扫描bitmap来找到一个还没有使用的data block,未被使用的data block对应bit 0。找到之后,文件系统需要将该bit设置为1,表示对应的data block已经被使用了。所以更新block 45是为了更新bitmap。
接下来的两次write 595表明,文件系统挑选了data block 595。所以在文件x的inode中,第一个direct block number是595。因为写入了两个字符,所以write 595被调用了两次。
第二阶段最后的write 33是更新文件x对应的inode中的size字段,因为现在文件x中有了两个字符。
学生提问:block 595看起来在磁盘中很靠后了,是因为前面的block已经被系统内核占用了吗?
Frans教授:我们可以看前面makefs指令,makefs存了很多文件在磁盘镜像中,这些都发生在创建文件x之前,所以磁盘中很大一部分已经被这些文件填满了。
学生提问:第二阶段最后的write 33是否会将block 595与文件x的inode关联起来?
Frans教授:会的。这里的write 33会发生几件事情:首先inode的size字段会更新;第一个direct block number会更新。这两个信息都会通过write 33一次更新到磁盘上的inode中。
以上就是磁盘中文件系统的组织结构的核心,希望你们都能理解背后的原理。
今天的课程是有关文件系统中的Crash safety。这里的Crash safety并不是一个通用的解决方案,而是只关注一个特定的问题的解决方案,也就是crash或者电力故障可能会导致在磁盘上的文件系统处于不一致或者不正确状态的问题。当我说不正确的状态时,是指例如一个data block属于两个文件,或者一个inode被分配给了两个不同的文件。
这个问题可能出现的场景可能是这样,当你在运行make指令时,make与文件系统会有频繁的交互,并读写文件,但是在make执行的过程中断电了,可能是你的笔记本电脑没电了,也可能就是停电了,之后电力恢复之后,你重启电脑并运行ls指令,你会期望你的文件系统仍然在一个好的可用的状态。
我们上节课看过了如何创建一个文件,这里多个步骤的顺序是(注,实际步骤会更多,详见14.5):
在super block之后就是log block,我们今天主要介绍的就是log block。log block之后是inode block,每个block可能包含了多个inode。**之后是bitmap block,它记录了哪个data block是空闲的。**最后是data block,这里包含了文件系统的实际数据。
在创建文件时,操作系统与磁盘block的交互过程
假设我们在下面这个位置出现了电力故障或者内核崩溃
在出现电力故障之后,因为内存数据保存在RAM中,所有的内存数据都丢失了。所有的进程数据,所有的文件描述符,内存中所有的缓存都没有了,因为内存数据不是持久化的。我们唯一剩下的就是磁盘上的数据,因为磁盘的介质是持久化的,所以只有磁盘上的数据能够在电力故障之后存活。基于这些事实,如果我们在上面的位置出现故障,并且没有额外的机制,没有logging,会有多糟糕呢?我们这里会有什么风险?
在这个位置,我们先写了block 33表明inode已被使用,之后出现了电力故障,然后计算机又重启了。这时,我们丢失了刚刚分配给文件x的inode。这个inode虽然被标记为已被分配,但是它并没有放到任何目录中,所以也就没有出现在任何目录中,因此我们也就没办法删除这个inode。所以在这个位置发生电力故障会导致我们丢失inode。
再看向文件x写入“hi”
在这里发生 crash
这里我们从bitmap block中分配了一个data block,但是又还没有更新到文件x的inode中。当我们重启之后,磁盘处于一个特殊的状态,这里的风险是什么?是的,我们这里丢失了data block,因为这个data block被分配了,但是却没有出现在任何文件中,因为它还没有被记录在任何inode中。
所以,为了避免丢失data block,我们将写block的顺序改成这样。现在我们考虑一下,如果故障发生在这两个操作中间会怎样?
这时inode会认为data block 595属于文件x,但是在磁盘上它还被标记为未被分配的。之后如果另一个文件被创建了,block 595可能会被另一个文件所使用。所以现在两个文件都会在自己的inode中记录block 595。如果两个文件属于两个用户,那么两个用户就可以读写彼此的数据了。很明显,我们不想这样,文件系统应该确保每一个data block要么属于且只属于一个文件,要么是空闲的。所以这里的修改会导致磁盘block在多个文件之间共享的安全问题,这明显是错误的。
所以这里的问题并不在于操作的顺序,而在于我们这里有多个写磁盘的操作,这些操作必须作为一个原子操作出现在磁盘上。
我们这节课要讨论的针对文件系统crash之后的问题的解决方案,其实就是logging。这是来自于数据库的一种解决方案。它有一些好的属性:
logging的基本思想还是很直观的。首先,你将磁盘分割成两个部分,其中一个部分是log,另一个部分是文件系统,文件系统可能会比log大得多。
当需要更新文件系统时,我们并不是更新文件系统本身。假设我们在内存中缓存了bitmap block,也就是block 45。当需要更新bitmap时,我们并不是直接写block 45,而是将数据写入到log中,并记录这个更新应该写入到block 45。对于所有的写 block都会有相同的操作,例如更新inode,也会记录一条写block 33的log。
所以基本上,任何一次写操作都是先写入到log,我们并不是直接写入到block所在的位置,而总是先将写操作写入到log中。
(commit op)之后在某个时间,当文件系统的操作结束了,比如说我们前一节看到的4-5个写block操作都结束,并且都存在于log中,我们会commit文件系统的操作。这意味着我们需要在log的某个位置记录属于同一个文件系统的操作的个数,例如5。
(install log)当我们在log中存储了所有写block的内容时,如果我们要真正执行这些操作,只需要将block从log分区移到文件系统分区。我们知道第一个操作该写入到block 45,我们会直接将数据从log写到block45,第二个操作该写入到block 33,我们会将它写入到block 33,依次类推。
(clean log)一旦完成了,就可以清除log。清除log实际上就是将属于同一个文件系统的操作的个数设置为0。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YdPYoOsU-1676999753489)(null)]
以上就是log的基本工作方式。为什么这样的工作方式是好的呢?假设我们crash并重启了。在重启的时候,文件系统**会查看log的commit记录值,如果是0的话,那么什么也不做。如果大于0的话,我们就知道log中存储的block需要被写入到文件系统中,很明显我们在crash的时候并不一定完成了install log,我们可能是在commit之后,clean log之前crash的。**所以这个时候我们需要做的就是reinstall(注,也就是将log中的block再次写入到文件系统),再clean log。(想到定时任务的处理以及消息中间件)
学生提问:因为这里的接口只有read/write,但是如果我们做append操作,就不再安全了,对吧?
Frans教授:某种程度来说,append是文件系统层面的操作,在这个层面,我们可以使用上面介绍的logging机制确保其原子性(注,append也可以拆解成底层的read/write)。
学生提问:当正在commit log的时候crash了会发生什么?比如说你想执行多个写操作,但是只commit了一半。
Frans教授:在上面的第2步,执行commit操作时,你只会在记录了所有的write操作之后,才会执行commit操作。所以在执行commit时,所有的write操作必然都在log中。而commit操作本身也有个有趣的问题,它究竟会发生什么?如我在前面指出的,commit操作本身只会写一个block。文件系统通常可以这么假设,单个block或者单个sector的write是原子操作(注,有关block和sector的区别详见14.3)。这里的意思是,如果你执行写操作,要么整个sector都会被写入,要么sector完全不会被修改。所以sector本身永远也不会被部分写入,并且commit的目标sector总是包含了有效的数据。而commit操作本身只是写log的header,如果它成功了只是在commit header中写入log的长度,例如5,这样我们就知道log的长度为5。这时crash并重启,我们就知道需要重新install 5个block的log。如果commit header没能成功写入磁盘,那这里的数值会是0。我们会认为这一次事务并没有发生过。这里本质上是write ahead rule,它表示logging系统在所有的写操作都记录在log中之前,不能install log。
XV6的log结构:我们在最开始有一个header block,也就是我们的commit record,里面包含了:
当文件系统在运行时,在内存中也有header block的一份拷贝,拷贝中也包含了n和block编号的数组。这里的block编号数组就是log数据对应的实际block编号,并且相应的block也会缓存在block cache中,这个在Lec14有介绍过。与前一节课对应,log中第一个block编号是45,那么在block cache的某个位置,也会有block 45的cache。
先获取log header的锁,之后再更新log header。首先代码会查看block 45是否已经被log记录了。如果是的话,其实不用做任何事情,因为block 45已经会被写入了。这种忽略的行为称为log absorbtion。如果block 45不在需要写入到磁盘中的block列表中,接下来会对n加1,并将block 45记录在列表的最后。之后,这里会通过调用bpin函数将block 45固定在block cache中
参考事务
很明显这里的记录要比只在log_write中记录要长的多。之前的log_write只有11条记录(注,详见14.5)但是可以看到实际上背后有很多个磁盘写操作,让我们来分别看一下这里的写磁盘操作:
如果我写一个大文件,我需要在磁盘中将这个大文件写两次。所以这必然不是一个高性能的实现,为了实现Crash safety我们将原本的性能降低了一倍。当你们去读ext3论文时,你们应该时刻思考如何避免这里的性能降低一倍的问题。
XV6中的任何一个例如create/write的系统调用,需要在整个transaction完成之后才能返回。所以在创建文件的系统调用返回到用户空间之前,它需要完成所有end_op包含的内容,这包括了:
ext3就是在几乎不改变之前的ext2文件系统的前提下,在其上增加一层logging系统。所以某种程度来说,logging是一个容易升级的模块。
在内存中,存在block cache,这是一种write-back cache(注,区别于write-through cache,指的是cache稍后才会同步到真正的后端)。block cache中缓存了一些block,其中的一些是干净的数据,因为它们与磁盘上的数据是一致的;其他一些是脏数据,因为从磁盘读出来之后被修改过;有一些被固定在cache中,基于前面介绍的write-ahead rule和freeing rule,不被允许写回到磁盘中。
除此之外,ext3还维护了一些transaction信息。它可以维护多个在不同阶段的transaction的信息。每个transaction的信息包含有:
在磁盘上,与XV6一样:
在log的最开始,是super block。这是log的super block,而不是文件系统的super block。log的super block包含了log中第一个有效的transaction的起始位置和序列号。起始位置就是磁盘上log分区的block编号,序列号就是前面提到的每个transaction都有的序列号。log是磁盘上一段固定大小的连续的block
每个transaction在log中包含了:
ext3通过3种方式提升了性能:
fsync:这个系统调用接收一个文件描述符作为参数,它会告诉文件系统去完成所有的与该文件相关的写磁盘操作,在所有的数据都确认写入到磁盘之后,fsync才会返回
每个系统调用在调用了start函数之后,会得到一个handle,它某种程度上唯一识别了当前系统调用。当前系统调用的所有写操作都是通过这个handle来识别跟踪的
之后系统调用需要读写block,它可以通过get获取block在buffer中的缓存,同时告诉handle这个block需要被读或者被写。如果你需要更改多个block,类似的操作可能会执行多次。之后是修改位于缓存中的block。
当这个系统调用结束时,它会调用stop函数,并将handle作为参数传入。
transaction只能在所有已经开始了的系统调用都执行了stop之后才能commit。所以transaction需要记住所有已经开始了的handle,这样才能在系统调用结束的时候做好记录。
虚拟内存是指:User Mode或者应用程序想要使用与内核相同的机制,来产生Page Fault并响应Page Fault(注,详见Lec08,内核中几乎所有的虚拟内存技巧都基于Page Fault)。也就是说User Mode需要能够修改PTE的Protection位(注,Protection位是PTE中表明对当前Page的保护,对应了4.3中的Writeable和Readable位)或者Privileged level
用户应用程序使用虚拟内存的必要性。这些应用程序包括了:
mmap的系统调用。它接收某个对象,并将其映射到调用者的地址空间中。
当你将某个对象映射到了虚拟内存地址空间,你可以修改对应虚拟内存的权限,这样你就可以以特定的权限保护对象的一部分,或者整个对象。
这里的挑战是,表单可能会很大,或许会大过物理内存,这里可以使用论文提到的虚拟内存特性来解决这个挑战。
首先,你需要分配一个大的虚拟地址段,但是并不分配任何物理内存到这个虚拟地址段。这里只是从地址空间获取了很大一段地址,并将要使用地址空间的这部分来保存表单。
在发生Page Fault时,先针对对应的虚拟内存地址分配物理内存Page,之后计算f(i),并将结果存储于tb[i],也就是表单的第i个槽位,最后再恢复程序的运行。
什么是copying GC?假设你有一段内存作为heap,应用程序从其中申请内存。你将这段内存分为两个空间,其中一个是from空间,另一个是to空间。当程序刚刚启动的时候,所有的内存都是空闲的,应用程序会从from空间申请内存。假设我们申请了一个类似树的数据结构。树的根节点中包含了一个指针指向另一个对象,这个对象和根节点又都包含了一个指针指向第三个对象,这里构成了一个循环。(引用计数法进行gc的转为可达性分析 young gc的简单版,因为没有后面一层所以不需要)
或许应用程序在内存中还有其他对象,但是没有别的指针指向这些对象,所以所有仍然在使用的对象都可以从根节点访问到。在某个时间,或许因为之前申请了大量的内存,已经没有内存空间给新对象了,也就是说整个from空间都被使用了。
Copying GC的基本思想是将仍然在使用的对象拷贝到to空间去,具体的流程是从根节点开始拷贝。每一个应用程序都会在一系列的寄存器或者位于stack上的变量中保存所有对象的根节点指针,通常来说会存在多个根节点,但是为了说明的简单,我们假设只有一个根节点。拷贝的流程会从根节点开始向下跟踪,所以最开始将根节点拷贝到了to空间,但是现在根节点中的指针还是指向着之前的对象。
当开始GC之后,应用程序第一次使用根节点,它会得到Page Fault,因为这部分内存的权限为None。
在GC最开始的时候,我们将根节点拷贝过来了;**之后在Page Fault Handler中通过扫描,将根节点指向的对象也都拷贝过来了。**在我们的例子中根节点指向的只有两个对象,这两个对象会被拷贝到unscanned区域中,而根节点会被标记成scanned。在我们扫描完一个内存Page中的对象时,我们可以通过Unprot(注,详见17.1)恢复对应内存Page的权限。
之后,应用程序就可以访问特定的对象,因为我们将对象中的指针转换成了可以安全暴露给应用程序的指针(注,因为这些指针现在指向了位于to空间的对象),所以应用程序可以访问这些指针。当然这些指针对应的对象中还没有被扫描。如果dereference这些指针,我们会再次得到Page Fault,之后我们会继续扫描。
学生提问:刚刚说到在Handler里面会扫描一个Page中的所有对象,但是对象怎么跟内存Page对应起来呢?
Frans教授:在最开始的时候,to空间是没有任何对象的。当需要forward的时候,我刚刚描述的是拷贝一个对象,**但是实际上拷贝的是一个内存Page中的N个对象,这样它们可以填满整个Page。**所以现在我们在to空间中,有N个对象位于一个Page中,并且它们都没有被扫描。**之后某个时间,Page Fault Handler会被调用,GC会遍历这个内存Page上的N个对象,并检查它们的指针。**对于这些指针,GC会将对应的对象拷贝到to空间的unscanned区域中。之后,当应用程序使用了这些未被扫描的对象,它会再次得到Page Fault,进而再扫描这些对象,以此类推。
学生提问:在完成了GC之后,会切换from和to空间吗?
Frans教授:最开始我们使用的是from空间,当用完了的时候,你会将对象拷贝到to空间,一旦完成了扫描,from空间也被完全清空了,你可以切换两个空间的名字。现在会使用to空间来完成内存分配。直到它也满了,你会再次切换。
使用虚拟内存的好处:
它仍然是递增的GC,因为每次只需要做一小部分GC的工作。除此之外,它还有额外的优势:现在不需要对指针做额外的检查了(注,也就是不需要查看指针是不是指向from空间,如果是的话,将其forward到to空间)。或者说指针检查还在,只是现在通过虚拟内存相关的硬件来完成了。
它简化了GC的并发。**GC现在可以遍历未被扫描的内存Page,并且一次扫描一个Page,同时可以确保应用程序不能访问这个内存Page,**因为对于应用程序来说,**未被扫描的内存Page权限为None。虚拟内存硬件引入了这种显式的同步机制,或者说对于抢占的保护。现在只有GC可以访问未被扫描的内存Page,而应用程序不能访问。**所以这里提供了自动的并发,应用程序可以运行并完成它的工作,GC也可以完成自己的工作,它们不会互相得罪,因为一旦应用程序访问了一个未被扫描的Page,它就会得到一个Page Fault。而GC也永远不会访问扫描过的Page,所以也永远不会干扰到应用程序。所以这里以近乎零成本获取到了并发性。
但是实际上有个麻烦的问题。回到我们之前那张图,我们在heap中有from空间,to空间。在to空间中又分为了unscanned和scanned区域,对于应用程序来说,unscanned区域中的Page权限为None。
这就引出了另一个问题,GC怎么能访问unscanned 区域的内存Page?因为对于应用程序来说,这些Page是inaccessible。
这里的技巧是使用map2(注,详见17.1)。这里我们会将同一个物理内存映射两次,第一次是我们之前介绍的方式,也就是为应用程序进行映射,第二次专门为GC映射。在GC的视角中,我们仍然有from和to空间。在to空间的unscanned区域中,Page具有读写权限。
学生提问:GC和应用程序是不是有不同的Page Table?
Frans教授:不,它们拥有相同的Page Table。它们只是将物理内存映射到了地址空间的两个位置,也就是Page Table的两个位置。在一个位置,PTE被标记成invalid,在另一个位置,PTE被标记成可读写的。
Monolithic kernel 和 Micro kernel 像单体服务到微服务的演进
微内核的核心就是实现了IPC(Inter-Process Communication)以及线程和任务的tiny kernel。所以微内核只提供了进程抽象和通过IPC进程间通信的方式,除此之外别无他物。
整个计算机还是分为两层,下面是kernel,上面是用户空间。在用户空间或许还是会有各种各样常见的程序,例如VI,CC,桌面系统。除此之外,在用户空间还会有文件系统以及知道如何与磁盘交互的磁盘驱动,或许我们还会有一个知道如何进行TCP通信的网络协议栈,或许还有一个可以实现酷炫虚拟内存技巧的虚拟内存系统。
当文本编辑器VI需要读取一个文件时,它需要与文件系统进行交互,所以它通过IPC会发送一条消息到文件系统进程。文件系统进程中包含了所有的文件系统代码,它知道文件,目录的信息。文件系统进程需要与磁盘交互,所以它会发送另一个IPC到磁盘驱动程序。磁盘驱动程序再与磁盘硬件进行交互,之后磁盘驱动会返回一个磁盘块给文件系统。之后文件系统再将VI请求的数据通过IPC返回给VI。
微内核中的用户进程通过IPC通信,这在很多操作系统都存在。例如我现在运行的macOS,它就是一个普通的monolithic kernel,它也很好的支持用户进程通过IPC进行通信。所以用户进程通过内核内的IPC相互通信,这是一个成功的思想并且被广泛采用。
论文发表时的Linux,甚至直到最近,当你运行在x86上,且运行在用户空间时,使用的Page Table同时会有用户空间的内存Page,以及所有的内核内存Page。所以当你执行系统调用,并跳转到内核中,内核已经映射到了Page Table中,因此不需要切换Page Table。所以当你执行一个系统调用时,代价要小得多,因为这里没有切换Page Table。如果你回想我们之前介绍的内容,trampoline代码会切换Page Table(注,也就是更新SATP寄存器,详见6.5)。这是个代价很高的操 作,因为这会涉及到清除TLB。所以出于性能的考虑,Linux将内核和用户进程映射到同一个Page Table,进而导致更快的系统调用
Socket layer是内核中的一层软件,它会维护一个表单来记录文件描述符和UDP/TCP端口号之间的关系。同时它也会为每个socket维护一个队列用来存储接收到的packet
当一个packet从网络送达时,**网卡会从网络中将packet接收住并传递给网卡驱动。网卡驱动会将packet向网络协议栈上层推送。**在IP层,软件会检查并校验IP header,将其剥离,再把剩下的数据向上推送给UDP。UDP也会检查并校验UDP header,将其剥离,再把剩下的数据加入到socket layer中相应文件描述符对应的队列中。所以一个packet在被收到之后,会自底向上逐层解析并剥离header。当应用程序发送一个packet,会自顶向下逐层添加header,直到最底层packet再被传递给硬件网卡用来在网络中传输。所以内核中的网络软件通常都是被嵌套的协议所驱动。
这里实际上我忘了一件重要的事情,在整个处理流程中都会有packet buffer。所以当收到了一个packet之后,它会被拷贝到一个packet buffer中,这个packet buffer会在网络协议栈中传递。通常在不同的协议层之间会有队列,比如在socket layer就有一个等待被应用程序处理的packet队列,这里的队列是一个linked-list。通常整个网络协议栈都会使用buffer分配器,buffer结构。在我们提供的networking lab代码中,buffer接口名叫MBUF。
现在我们有了一张网卡,有了一个系统内核。当网卡收到了一个packet,它会生成一个中断。系统内核中处理中断的程序会被触发,并从网卡中获取packet。因为我们不想现在就处理这个packet,中断处理程序通常会将packet挂在一个队列中并返回,packet稍后再由别的程序处理。所以中断处理程序这里只做了非常少的工作,也就是将packet从网卡中读出来,然后放置到队列中。
E1000网卡会监听网线上的电信号,但是当收到packet的时候,网卡内部并没有太多的缓存,所以网卡会直接将packet拷贝到主机的内存中,而内存中的packet会等待驱动来读取自己。所以,网卡需要事先知道它应该将packet拷贝到主机内存中的哪个位置。E1000是这样工作的,主机上的软件会格式化好一个DMA ring,ring里面存储的是packet指针。所以,DMA ring就是一个数组,里面的每一个元素都是指向packet的指针。
当位于主机的驱动初始化网卡的时候,它会分配一定数量,例如16个1500字节长度的packet buffer,然后再创建一个16个指针的数组。为什么叫ring呢?因为在这个数组中,如果用到了最后一个buffer,下一次又会使用第一个buffer。主机上的驱动软件会告诉网卡DMA ring在内存中的地址,这样网卡就可以将packet拷贝到内存中的对应位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-w3HAnafJ-1676999753371)(null)]
当网卡收到packet时,网卡还会记住当前应该在DMA ring的哪个位置并通过DMA将packet传输过去。
传输完成之后,网卡会将内部的记录的指针指向DMA ring的下一个位置,这样就可以拷贝下一个packet。
参考:https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/