文章目录
第一章 操作系统接口
操作系统的工作是在多个程序之间共享计算机,并提供比硬件单独支持的更有用的服务集。操作系统管理和抽象低级别的硬件,所以,比如,一个文本处理器不需要关心它用到哪种磁盘硬件。操作系统让多个程序共享硬件,使多个程序可以同时运行(或者准备执行)。最后操作系统提供多个程序交互的控制方法,这样多个程序可以共享数据和协同工作。
操作系统为用户程序通过接口提供服务,设计一个好的接口变得非常困难。一方面,我们希望接口简单而且范围比较小,因为这样可以更加简单并正确的实现,另一方面,我们想给用户应用提供更多复杂的功能。解决这种设计接口的矛盾的方法是设置几种机制的接口,这些机制可以组合起来以提供更多的通用性。
本书以单个操作系统作为具体例子来说明操作系统概念。该操作系统xv6,提供了一些Ken Thompson 和 Dennis Ritchie 的 Unix 操作系统引入的基本接口[14],并模仿了unix的内部设计。Unix机制应用的很好并提供了简洁的系统接口,实现了令人惊讶的通用性。这些接口非常成功的应用到了现代操作系统———BSD,Linux,Mac OS X,Solaris甚至更多。在一定程度上Windows有一些类Unix的接口。理解xv6对于理解这些操作系统或者其他的操作系统是个很好的开始。
如图1所示,xv6的内核使用了与传统的结构,内核是一个提供运行程序服务的特殊的程序。每次执行一个程序,称之为一个进程,都有包含指令、数据和堆栈的内存。这些指令实现程序的计算,数据是计算所用到的变量,堆栈组织程序运行过程中的调用。一个计算机通常有许多进程但只有一个内核。
当一个进程需要调用内核服务,它将调用一个被称之为操作系统接口的系统调用。系统调用陷入内核;内核实现该功能并返回值。所以一个进程执行时会在内核空间和用户空间之间切换。
内核使用CPU提供的硬件保护机制来确保在用户空间中执行的每个进程只能访问自己的内存。内核通过运行在更高的硬件特权实现这些保护;用户进程没有这些特权,硬件提升特权等级并执行内核中已有的程序。
内核提供的系统调用集合时用户程序可以访问的接口。xv6系统内核提供一系列Unix内核通常提供的服务和系统调用。下表是该系统提供的所有的系统调用及说明
xv6 系统调用及含义
系统调用 | 描述 |
---|---|
int fork() | 创建进程,返回子进程的pid |
int exit(int status) | 终止当前进程;报告给wait 退出状态,没有返回值 |
int wait(int *status) | 等待一个子进程的退出,子进程的状态保存在*status;返回值是子进程的pid |
int kill(int pid) | 终止进程号为pid的进程,返回值0表示成功,-1标识失败 |
int getpid() | 返回值为当前进程的pid |
int sleep(n) | 暂停n个时钟周期 |
int exec(char *file, char argv[]) | 加载一个文件,并带参数执行,只有运行失败才有返回值 |
char *sbrk(int n) | 将进程内存增加n个字节,返回新内存的其实位置 |
int open(char *file, int flags) | 打开一个文件,flags包含读写属性;返回文件句柄号(fd, file descriptor) |
int write(int fd, char *buf, int n) | 向文件fd中写入buf中的n个字节;返回值为n |
int read(int fd, char *buf, int n) | 从文件fd中读入buf中n个字节;返回值为n |
int close(int fd) | 释放已打开的文件句柄 |
int dup(int fd) | 返回一个新的文件描述符,指向与 fd 相同的文件。 |
int pipe(int p[]) | 创建一个管道,将读/写文件描述符放入 p[0] 和 p[1] 中 |
int chdir(char *dir) | 更改当前目录 |
int mkdir(char *dir) | 创建一个新目录 |
int mknod(char *file, int, int) | 创建一个设备节点 |
int fstat(int fd, struct stat *st) | 将句柄为fd的文件的信息保存到*st中 |
int stat(char *file, struct stat *st) | 将文件file的文件信息保存到*st中 |
int link(char *file1, char *file2) | 为文件(file1)创建另一个名字(链接file2) |
int unlink(char *file) | 删除一个文件 |
本章剩余的内容介绍了xv6系统的进程,内存,文件描述符,管道,和文件系统——并通过代码片段和讨论类Unix命令行(shell)用户接口的讨论和应用来说明它们。shell 对系统调用的使用说明了它们的设计是多么仔细。
shell 是一个普通的程序,它读取用户的命令并执行他们。shell并不是内核的一部分而是一个用户程序的事实说明了系统调用的能力:shell并没有什么不同。也意味着shell可以轻易的被替换;因此,现代 Unix 系统有多种 shell 可供选择,每种 shell 都有自己的用户界面和脚本功能。 xv6 shell 是 Unix Bourne shell 本质的简单实现。 它的实现可以在代码“user/sh.c”中找到。
1.1 进程与内存
一个xv6 进程包含用户空间内存(指令,数据和堆栈)和内核私有的每个进程状态组成。Xv6分时进程:它在等待的进程组之间透明的切换进程。当一个进程没有被执行时,Xv6会保存它的CPU寄存器信息,在下次该进程被唤醒时还原。内核为每个进程分配一个进程标识符或者说是叫PID.
一个进程可以通过一个fork系统调用来创建一个新的进程。Fork创建一个新进程,称之为子进程,其内存内容与创建它的进程(称之为父进程)完全相同。Fork在父进程和子进程中都有返回值,在父进程中返回子进程的进程号(PID),在子进程中,返回0。例如,思考一下如下的C语言代码片段。
int pid = fork();
if(pid > 0 ) {
printf("parent: child pid:%d\n", pid);
pid = wait((int *) 0);
printf("child %d is done\n", pid);
} else if(pid == 0){
printf("child: exiting...\n");
exit(0);
} else {
printf("fork error\n");
}
exit 系统调用会造成调用的进程停止运行并且释放该进程占用的资源,比如内存及打开的文件。Exit系统调用采用整数的参数,通常0表示成功,1表示失败。wait的系统调用返回退出(或被杀掉)的当前进程的子进程的PID,并且将子进程的退出状态复制到传递给wait(通过传参)的地址;如果当前进程的子进程都没有退出,wait就等待其中一个退出。如果调用wait系统调用的进程没有子进程,则会立即返回-1。如果调用wait系统调用的进程不关心子进程的退出状态,则可以通过传入0的地址来等待。
在linux系统上,上诉程序输出如下:
parent: child pid:2887032
child: exiting...
child 2887032 is done
前两行可能会以另外的顺序打印,取决与父进程和子进程谁先调用printf函数,子进程退出后,父进程中的wait系统调用返回,使父进程打印子进程的退出信息,如第三行所示。
虽然刚创建子进程时子进程和父进程有着相同的内存内容。但父进程和子进程运行在不同的内存区间和寄存器状态:修改其中一个进程中的内容不会影响到另一个进程。比如说,上诉程序在父进程中,当系统调用wait的返回值保存在pid变量中时,它不会影响到子进程中的pid变量的值。子进程中pid的值还是0.
Exec系统调用把文件系统中存储的文件加载到内存中替换调用者进程的内存。这个文件必须有特别的格式,这个格式指出该文件的那一部分包含有指令,哪一部分包含数据,从哪条指令开始执行,等。xv6使用ELF格式,第三章会有详细介绍。当exec
调用成功后,不会返回调用该系统调用的进程,相反,进程会从ELF文件表头中声明的入口点的指令开始执行。exec
指令传入两个参数:可执行文件的名字和执行所需的字符数组参数。比如
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec error\n");
该片段将调用程序替换为使用参数列表 echo hello 运行的程序 /bin/echo 的实例。 大多数程序都会忽略参数数组的第一个元素,这通常是程序的名称。
xv6 shell通过以上系统调用来运行用户的程序。shell 主要的指令是简单的(user/sh.c:145)。主循环通过getcmd
读取用户输入的命令行。然后它会调用fork
,创建一个shell进程的拷贝(子进程)。当子进程在运行命令的时候父进程会执行wait
。比如说,用户在shell终端输入“echo hello”,runcmd
函数会以“echo hello”作为参数执行,runcmd
(user/sh.c:58) 进行实际的操作。对于“echo hello”来说,会调用exec
(user/sh.c:78)。如果 exec
成功,那么子进程将执行来自 echo
而不是 runcmd
的指令。当echo
调用exit
函数的时候,会从父进程中main函数的wait函数返回(user/sh.c:78)。
你可能会考虑为什么fork和exec没有放在一个系统调用里面去实现;稍后我们会看到shell在实现I/O重定向中利用了分离。为了避免创建一个被复制的进程然后立即被替换(通过exec
)的浪费,操作系统内核通过使用虚拟内存技术(例如写时复制)优化fork
函数的实现。(4.6节会介绍)。
Xv6隐式地分配大部分用户空间内存,fork
通过复制父进程的内存老来分配子进程所需内存,exec
函数分配足够的内存来放可执行文件。一个进程在执行的时候如果需要更多的内存(比如malloc)可以通过sbrk(int n)
系统调用来使进程内存增加n个字节。sbrk
返回新内存的位置。
1.2 I/O和文件描述符
文件描述符是一个小整数,表示进程可以读取或写入的内核管理对象。一个进程可以通过打开一个文件,目录,设备,管道,或者通过复制一个已有的文件描述符来获取一个文件描述符。简单起见,我们通常将文件描述符指向的对象称之为文件。文件描述符的接口抽象了文件管道和设备的差异,使他们看起来都是字节流。我们称呼这种输入输出为I/O.
在内部,xv6使用文件描述符作为每个进程表的索引,这样每个进程都有一个从0开始的文件描述符的私有空间。按照约定,一个进程会从文件描述符0(标准输入)读取,写入文件描述符1(标准输出),将错误信息写入文件描述符2(标准错误)。正如我们即将看到的,shell利用这一约定实现I/O 重定向和管道。shell确保打开三个文件描述符(user/sh.c:151),默认是控制台的文件描述符。
read
和write
系统调用通过文件描述符读入或者写入打开的文件。系统调用read(fd, buf, n)
在文件描述符fd的文件中读取最多n个字符并复制到buf,最终返回读取到的字节数。引用文件的每个文件描述符都有对应的与其关联的偏移量。read
系统调用从当前文件对应的偏移量读取数据,然后偏移量会增长对应read
系统调用读取的字节数:后续read
系统调用会接着上一次读取后的偏移量继续读取数据。当文件没有数据可读时,read
系统调用会返回0来表示已文件的结尾。
write(fd, buf, n)
系统调用从buf中读取n个字节并写入文件描述符fd对应的文件中,并且返回写入的字节数。除非写入出错,否则不会写入低于n个字节。像read
系统调用一样,write
写入当前文件的文件描述符中偏移量位置数据,并在写入n个数据后偏移量自动增加写入的字节数:每次的写入都会接着上一次写入后的偏移量来写入。
下面的一段程序(来自cat
命令)从标准输入复制到标准输出。如果有错误,会些错误信息到标准错误。
char buf[512];
int n;
for(;;) {
n = read(0, buf, sizeof(buf));
if(n == 0)
break;
if(n < 0 ) {
fprintf(2,"read error\n");
exit(1);
}
if(write(1, buf, n) != n){
fprintf(2, "write error\n");
exit(1);
}
}
需要注意并记录下来的是cat
代码片段不知道读入的是文件,控制台输入还是管道。类似的cat
也不知道输出的是控制台,文件,或是其它。通常文件描述符0作为输入,文件描述符1作为输出允许cat
作为简单的实现。
close
的系统调用释放一个文件描述符,空下来的文件描述符可以在接下来的open
,pipe
或是dup
系统调用使用(如下介绍)。系统总是会分配一个当前进程没有用到的最低数字的文件描述符。
文件描述符和fork
函数的相互作用是I/O重定向更加容易实现。fork
从父进程中拷贝文件描述符的表格到子进程的内存,这样子进程实际上打开着父进程同样的文件。系统调用exec
复制自己的内存来替换调用exec
的程序的内存,但是保留了文件描述符表。这种现象允许shell通过fork来实现I/O重定向,重新打开子进程里选中的文件描述符,并执行exec
执行新的程序。下面是简单版本的实现cat < input.txt
命令的shell程序:
char *argv[2];
argv[0] = "cat";
argv[1] = 0;
if(fork() == 0) {
close(0);
open("input.txt", O_RDONLY);
exec("cat", argv);
}
当子进程关闭文件描述符0,就保证了open
函数给新打开的文件“input.txt”分配文件描述符0 是最小可用的文件描述符。cat之后会将文件描述符0指向“input.txt”然后执行。父进程的文件描述符在此示例中没有没有变化,只修改了子进程的文件描述符。
xv6中关于I/O重定向的实现的代码实现在user/sh.c:82行实现。回想一下,此时在代码中,shell 已经分叉了子进程,并且 runcmd 将调用 exec 来加载新程序。
open
函数的第二个参数是由一系列的flags组成,可以解析为bits,来控制open函数的做法。所有的值被定义在“文件控制(fcntl)”头文件里(kernel/fcntl.h:1-5): O_RDONLY, O_WRONLY, O_RDWR, O_CREATE, and O_TRUNC, 对应打开文件是用来,读,写,读或写,若无则创建文件,截断文件使其长度为0.
现在应该清晰为什么fork
和exec
系统调用分开调用是有帮助的:两者之间,shell
有机会重定向I/O而且不会影响shell主函数的I/O配置。人们可以想象一个假设的组合 forkexec 系统调用,但使用此类调用进行 I/O 重定向的功能似乎很尴尬。shell 可以在调用forkexec
之前修改自身的I/O 配置(在调用之后恢复此修改);或者 forkexec 可以将 I/O 重定向指令作为参数; 或者(最不吸引人的)每个程序(例如 cat)都可以被要求进行自己的 I/O 重定向。
虽然fork
系统调用会复制文件描述符表,但每个底层文件的偏移量在父进程和子进程之间是共享的。如下示例:
if(fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
在代码片段的最后,文件描述符1指向的文件内容应该是“hello world”。父进程里面的write函数(使用wait
机制,使父进程在子进程执行完成后执行)使用了子进程写入后的偏移量。这种现象有助于从 shell 命令序列中生成顺序输出,比如(echo hello; echo world) >output.txt
。
dup
系统调用复制一个已有的文件描述符,返回一个新的指向同一个底层I/O对象。两个文件描述符共享偏移量,就像fork复制文件描述符一样。这有另外一种写入“hello world”函数到一个文件中的方法:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world", 6);
如果两个文件描述符是通过一系列 fork 和 dup 调用从同一原始文件描述符派生的,则它们共享一个偏移量。其它情况,文件描述符不会共享偏移量,比如利用open
系统调用来打开同一文件。dup
系统调用允许shell实现这种命令ls <存在的文件> <不存在的文件> > tmp1 2>&1
。2>&1可以通知命令实现将文件描述符1复制为文件描述符2. <存在的文件>的文件名和<不存在的文件>的错误信息会同时保存到tmp1. xv6系统不支持错误信息的重定向功能,但可以了解它的原理。
文件描述符是一个强大的抽象概念,因为它隐藏了它们所连接的细节:写入文件描述符 1 的进程可能正在写入文件、控制台等设备或管道。
1.3 管道
管道是一个小的内核缓冲区,作为一对文件描述符暴露给进程,一个用于读取,一个用于写入。 将数据写入管道的一端使得该数据可用于从管道的另一端读取。 管道为进程提供了一种通信方式。
以下示例代码运行程序 wc,并将标准输入连接到管道的读取端。
int p[2];
char *argv[2];
argv[0] = "wc"
argv[1] = 0;
pipe(p);
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
close(p[0]);
write(p[1], "hello world\n", 12);
close(p[1]);
}
程序调用pipe
会创建一个管道,并且创建一个整形数组p来记录读和写的文件描述符。fork
之后父进程和子进程共同拥有管道的输入和输出文件描述符。child子进程调用close
和dup
使输入文件描述符0指向管道输入的末端,关闭管道的文件描述符p,最后调用exec
去执行wc
命令。wc
命令从管道读取作为标准输入。父进程关闭管道的读的一侧,向管道写的一侧写入数据,然后关掉管道写的一侧。
如果没有可用数据,管道的“read”函数会等待写入端数据的写入或者等待所有指向管道写入端文件描述符都被关闭,后者read函数会返回0就像读普通文件到文件末尾一样。事实上,读取管道直到无法再继续读取,这是上诉子进程中"wc"命令执行之前关闭写入端的重要原因:如果不关闭,“wc”命令永远不会等到pipe写入端的文件描述符指向的文件结束。
xv6 shell 实现了 grep fork sh.c |wc -l
类似于上述代码的能力(user/sh.c:100)。子进程创建一个管道,用于将命令行的左边和有边连接起来。然后,它为命令行的左端调用 fork 和 runcmd,也为右端调用 fork 和 runcmd,并等待两者完成。右边的命令本身也可以包含一个需要fork出两个子进程的管道(比如:a | b | c, 两个子进程对应b和c)。这样shell就可以创建一个进程树。树的叶子是命令,内部节点是等待左右子节点完成的进程。
原则上,可以让内部节点先运行管道的左边。但正常这样做会使实现复杂化。大致需要这样实现:修改sh.c 使其不去为p->left
fork新的进程,并且在进程内部执行runcmd(p->left
)。然后例如“echo hi | wc” 将不产生输出,因为当“echo hi” 从runcmd
退出后,fork出的子进程也随之退出并将永远也执行不到管道的右端(p->right
)。我们可以通过在管道左边的子进程runcmd执行完后不去执行exit
来解决这个现象。但此修复使代码变得复杂:现在 runcmd 需要知道它是否是一个管道的内部进程,当不fork去运行管道的右边 runcmd(p->right) 时,也会出现复杂情况。例如,在我们的设计中,sleep 10 | echo hi
是立即打印“hi” 而不是等待10秒以后,因为echo
命令会立即执行并退出而不是等待sleep
执行完成。因为sh.c 的目标是做到精简,因此我们不去尝试阻止创建更多的进程。
管道的功能可能不比临时文件强大,比如说 echo hello world | wc
命令可以以临时文件的方式实现echo hello world > /tmp/xyz; wc < /tmp/xyz
但在这种情况下,管道至少有4个优点。第一,管道会自动清理自己,做好资源回收;如果用重定向,需要在命令执行完成后删除/tmp/xyz。第二,管道可以传输任意长度的数据,而文件的重定向需要磁盘有足够的空间来保存数据。第三,管道可以并行的执行两边的指令,而采用文件重定向和临时文件的方法只能在第一个程序执行完成后执行第二个程序。第四,如果要实现进程间通信,管道的阻塞读写比文件的非阻塞语义更有效。
1.4 文件系统
xv6 文件系统提供数据文件(包含未解释的字节数组)和目录(包含对数据文件和其他目录的命名引用)。目录组成一棵树,从称之为根的特殊目录开始。以路径“/a/b/c”为例,根目录 “/” 包含了目录"a",然后“a”包含了目录“b”,最后b包含了一个目录或者文件“c”。通常不以"/"开始的路径一般被认定为相对进程执行的当前目录的路径,可以通过系统调用chdir
来实现目录切换。就像以下两段代码片段实现了同样的功能:
chdir("/a");
chdir("b");
open("c", O_RDONLY);
和
open("/a/b/c", O_RDONLY);
第一种方法首先切换进程当前工作的目录到"/a/b", 第二种方法没有修改进程当前工作的目录。
系统有专门的系统调用创建文件己目录:mkdir
来创建目录,带有“O_CREATE”标志的open
系统调用来创建一个数据文件,使用mknode
来创建一个设备文件,下面的一段代码说明以上三种情况
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknode("/console", 1, 1);
mknode
指令创建一个特殊的指向一个设备的文件。-与设备文件关联的是主设备号和次设备号(mknod 的两个参数),它们唯一地标识内核设备。当进程稍后打开设备文件时,内核会将读写系统调用转移到内核设备驱动的实现,而不是将它们传递到文件系统。
文件名和文件本身并不是一一对应的;同一个底层文件(称之为索引节点)可能有多个名称(称之为链接)。每个链接都包含目录中的一个条目; 该条目包含文件名和对 inode 的引用。每个inode索引节点都有一个文件的元数据,包含它的类型(文件,目录或者设备),长度,文件内容在磁盘的位置,和一个文件的链接数。
fstat
系统调用从文件描述符引用的 inode 检索信息。包含在结构体struct stat
,定义在“stat.h”(kernel/stat.h)中:
#define T_DIR 1 //目录
#define T_FILR 2 //文件
#define T_DEVICE 3 //设备
struct stat {
int dev; //文件系统的磁盘设备
uiint ino; //索引节点号
short type; //文件的类型(目录,文件或者设备)
short nlinks; //文件的索引数
uint64 size; //文件的字节数
};
系统调用link
创建另一个文件系统内的文件名来指向已经存在的一个文件名指向的同一个索引节点。下面这段代码创建了一个新的文件,同时命名为“a”或者"b"。
open("a", _CREATE|O_WRONLY);
link("a", "b");
从a文件读写的效果跟从b上面读写的效果是相同的。每一个索引节点都通过独一无二的索引节点号来区分。通过以上的代码流程,"a"和"b"通过fstat系统调用都将指向相同的底层内容:两者都会返回同样的索引节点号(ino),"nlink"的值将会被设置为2.
unlink
系统调用会在文件系统里面删除掉一个名字。磁盘里的文件将会一直存在直到文件的链接数变为0,并且没有文件描述符引用才会释放。所以 添加unlink("a");
到最后一个代码序列使 inode 和文件内容可作为 b 访问。 此外
fd = open("/tmp/xyz", O_CREATE|O_RDER);
unlink("/tmp/xyz");
是通常用来创建临时没有名字的节点,当进程close fd 或者进程退出的时候清理。
Unix在用户等级程序上的shell程序提供了可供调用的二年件工具,例如mkdir
, ln
, rm
。这种设计允许任何人通过添加用户等级的程序来拓展命令行接口。事后看来,这个计划似乎是显而易见的,但在 Unix 时代设计的其他系统经常将此类命令内置到 shell 中(并将 shell 内置到内核中)。
一个例外是cd,被创建到了shell里(user/sh.c:160). cd
必须改变shell自身的工作目录。如果cd 以常规的命令运行,shell会去fork一个子进程子进程回去运行cd
, cd
就会改变子进程的工作目录。父进程(在这个例子中是shell)的工作目录不会变化。
1.5 现实世界
Unix 将“标准”文件描述符、管道和对其进行操作的便捷 shell 语法相结合,这是编写通用可重用程序的一大进步。这个想法引发了一种“软件工具”文化,Unix 的强大和流行在很大程度上归功于这种文化,而 shell 是第一个所谓的“脚本语言”。如今,Unix 系统调用接口仍然存在于 BSD、Linux 和 Mac OS X 等系统中。
Unix 系统调用接口已通过可移植操作系统接口 (POSIX) 标准进行了标准化。xv6不符合POSIX标准:缺少许多系统调用(包括一些基础的系统调用,比如lseek),还有一些系统调用与标准的系统调用有所差别。Xv6系统的目标是简单清晰的提供一个类UNIX的系统调用接口。一些人给xv6系统扩展了更多的系统调用接口还有一些用来跑简单unix程序的C库。现代的内核,提供比xv6更多的系统调用和更多的内核服务。比如,它们支持网络,窗口系统,用户级线程,许多设备的驱动等等。现代内核不断快速发展,并提供了许多超越 POSIX 的功能。
Unix 通过一组文件名和文件描述符接口统一访问多种类型的资源(文件、目录和设备)。这个想法可以扩展到更多种类的资源; 一个很好的例子是计划 9 [13],它将“资源就是文件”的概念应用于网络、图形等。 然而,大多数 Unix 派生的操作系统并没有遵循这条路线。
文件系统和文件描述符被很大程度的抽象化。尽管如此,操作系统接口还有其他模型。Multics 是 Unix 的前身,它以一种看起来像内存的方式抽象了文件存储,从而产生了一种截然不同的界面风格。 Multics 设计的复杂性对 Unix 的设计者产生了直接影响,他们试图构建更简单的东西。
xv6 不提供用户概念或保护一个用户免受另一用户侵害的概念; 在 Unix 术语中,所有 xv6 进程都以 root 身份运行。
本书探讨了 xv6 如何实现其类 Unix 接口,但其中的想法和概念不仅仅适用于 Unix。 任何操作系统都必须将进程复用到底层硬件上,将进程彼此隔离,并提供受控进程间通信的机制。 研究完 xv6 后,您应该能够了解其他更复杂的操作系统,并了解这些系统中 xv6 的底层概念。
1.6 练习
- 编写一个程序,使用 UNIX 系统调用通过一对管道(每个方向一个)在两个进程之间“乒乓”一个字节。 衡量程序的性能,以每秒的交换次数为单位。
第二章 操作系统组织结构
操作系统的一个关键问题是可以同时支持运行多个任务。比如用第一章介绍的系统调用接口一个进程可以通过fork
来开始一个新的进程。操作系统必须在这些进程中分时共享计算机资源。例如,即使进程数量多于硬件 CPU 数量,操作系统也必须确保所有进程都有机会执行。操作系统也会安排进程间相互隔离。也就是说,如果一个进程有错误并且发生故障,它不应该影响不依赖于有错误的进程的进程。然而,完全隔离太强了,因为进程应该可以有意地交互; 管道就是一个例子。 因此,操作系统必须满足三个要求:多路复用、隔离和交互。
本章概述了如何组织操作系统来实现这三个要求。 事实证明,有很多方法可以做到这一点,但本文重点关注以单片内核为中心的主流设计,许多 Unix 操作系统都使用该内核。 本章还概述了 xv6 进程(xv6 中的隔离单元)以及 xv6 启动时第一个进程的创建。
Xv6 在多核(本文中的“多核”是指多个共享内存但并行执行的 CPU,每个 CPU 都有自己的一组寄存器。 本文有时使用术语多处理器作为多核的同义词,尽管多处理器也可以更具体地指具有多个不同处理器芯片的计算机) RISC-V 微处理器上运行,其许多低级功能(例如,其流程实现)特定于 RISC-V。 RISC-V是64位CPU,xv6是用“LP64”C编写的,这意味着C编程语言中的长整型(L)和指针(P)都是64位,但 int 是 32 位。 本书假设读者已经在某些架构上完成了一些机器级编程,并将在出现时介绍 RISC-V 特定的想法。 RISC-V 的有用参考是“he RISC-V Reader: An Open Architecture Atlas” ”[12]。 用户级ISA [2]和特权架构[1]是官方规范。
完整的计算机中 CPU 周围环绕着支持的硬件,其中大部分以 I/O 接口的形式存在。Xv6 是为支持 qemu 的“-machine virt”选项模拟的硬件而编写的。这里面包含RAM,包含启动代码的ROM,与用户键盘/屏幕的串行连接以及用于存储的磁盘。
2.1 抽象物理资源
当遇到操作系统时,人们可能会问的第一个问题是为什么要拥有它? 也就是说,可以将图 1.2 中的系统调用实现为一个库,应用程序可以与该库链接。在此计划中,每个应用程序甚至可以拥有自己的适合其需求的库。应用程序可以直接与硬件资源交互,并以最适合应用程序的方式使用这些资源(比如实现高性能或可预测的性能)。一些嵌入式设备上的操作系统或者实时操作系统以这种方式组织的。
这种库方法的缺点是,如果有多个应用程序正在运行,则它们必须表现良好。例如,每个应用程序必须定期放弃CPU,以便其他应用程序可以运行。如果所有应用程序相互信任并且没有错误,那么这种协作分时方案可能是可行的。更常见的情况是应用程序彼此不信任并且存在错误,因此人们通常需要比协作方案提供的更强的隔离性。
为了实现强隔离,禁止应用程序直接访问敏感硬件资源,并将资源抽象为服务,会很有帮助。比如Unix应用只能通过read
,write
,open
,和close
系统调用来与存储交互,而不是直接访问。这为应用程序提供了路径名的便利,并且允许操作系统(作为接口的实现者)管理磁盘。 即使隔离不是问题,有意交互(或只是希望不妨碍彼此)的程序也可能会发现文件系统是比直接使用磁盘更方便的抽象。
类似地,Unix在进程之间透明地切换硬件CPU,必要的保存和还原寄存器状态,使进程不用关心分时复用的硬件。即使某些应用程序处于无限循环中,这种透明度也允许操作系统共享 CPU。
另一个例子,unix进程用exec
来创建自己的内存映像。而不是直接与内存交互。这使操作系统可以决定一个进程放在内存的哪个位置;如果内存相对紧张,操作系统甚至可以将进程的数据保存在磁盘里。exec
同时提供给用户一个便利的方式在文件系统里存放可执行程序的映像。
Unix 进程之间的许多形式的交互都是通过文件描述符发生的。文件描述符不只抽象了一些细节(比如,数据是在管道里还是在存储介质里),它同时以简化交互的方式定义。比如,如果管道中的一个应用程序失败,内核会为管道中的下一个进程生成文件结束信号。
图1.2里面的接口同时提供了编程的便利性和很强的隔离性。Unix的接口不是抽象化硬件资源的唯一方法,但已经证实是非常好的方法。
2.2 用户模式,管理模式,系统调用
强隔离性要求用户应用和操作系统有着清晰的边界。当用户应用运行失败时,我们不希望它影响操作系统内核或其他应用程序。相反的,操作系统应该可以去清理失败的应用并且继续去执行其他程序。为了实现较强的隔离,操作系统必须使应用不能去修改(甚至去读)操作系统的数据结构和命令,并且一个进程不能访问另外一个进程的内存。
CPU 为强隔离提供硬件支持。比如,RISC-V 提供了CPU的3种执行模式:机器模式,管理员模式及用户模式。运行在机器模式的指令拥有一切特权;CPU在机器模式中开始。机器模式一般用来配置计算机。Xv6系统有一部分代码运行在机器模式,并随后进入到管理员模式。
在管理员模式下,CPU允许执行一些特权指令:比如使能和关闭中断。读写保存页表的寄存器等。如果一个用户模式的用户试图去执行一些特权指令,CPU将不会去执行,而是切换到管理员模式,以便管理员模式的代码可以终端应用,因为它做了不该做的事情。第一章里的图1.1说明了这种组织结构。应用程序只能运行用户模式的指令(比如数字加法,等)并运行在用户空间,而管理员模式的软件同样在内核空间执行特权指令。运行在内核空间的软件(也可以说运行特权模式的软件)被称之为内核。
想要调用内核函数(例如 xv6 中的 read 系统调用)的应用程序必须转换到内核。CPU提供了一种特殊的指令,可以将CPU从用户模式切换到管理模式,并在内核指定的入口点进入内核(RISC-V 提供ecall
指令来实现这一功能)。一旦CPU切换到管理模式,内核就可以验证系统调用的参数,决定是否允许应用程序执行请求的操作,然后拒绝它或执行它。内核控制转换到管理模式的入口点非常重要; 例如,如果应用程序可以决定内核入口点,则恶意应用程序可以在跳过参数验证的点进入内核。
2.3 内核组织结构
一个关键的设计问题是操作系统的哪一部分应该在管理模式下运行。一种可能性是整个操作系统驻留在内核中,因此所有系统调用的实现都在管理程序模式下运行。 这种组织称为宏内核。
在这个组织中,整个操作系统以完全硬件权限运行。这种结构是非常方便的,因为操作系统的设计者不需要考虑操作系统的哪一部分不需要完全硬件权限。另外,更方便操作系统不同部分的协作。例如,操作系统可能具有可由文件系统和虚拟内存系统共享的缓冲区高速缓存。
宏内核组织的缺点是操作系统不同部分之间的接口通常很复杂(正如我们将在本文的其余部分中看到的),因此操作系统开发人员很容易犯错误。 在宏内核里,一个错误是致命的,因为supervisor模式下的一个错误往往会导致内核失败。当内核运行失败时,计算机会停止工作,从而应用程序也会运行失败。计算机必须重启来重新开始。
为了减少内核出错的风险,操作系统设计者往往最小化管理员权限运行的代码数量,尽量在用户模式执行操作系统的大部分程序。这种内核的组织结构被称之为微内核。
图2.1展示了微内核的设计。图中,文件系统运行在用户等级的进程中。作为进程运行的操作系统服务称为服务器。为了允许应用程序与文件服务器交互,内核提供了一种进程间通信机制,将消息从一个用户模式进程发送到另一个用户模式进程。比如一个应用程序比如shell想去读写一个文件,它会给文件服务器发送消息,然后等待回复。
在微内核中,内核接口由一些低级函数组成,用于启动应用程序、发送消息、访问设备硬件等。这种组织使内核相对简单,因为大多数操作系统驻留在用户级服务器中 。
与大多数 Unix 操作系统一样,Xv6 是作为宏内核实现的。 由此可见,xv6内核接口对应于操作系统接口,内核实现了完整的操作系统。 由于 xv6 不提供很多服务,因此它的内核比一些微内核要小,但从概念上讲 xv6 是宏内核的。
2.4 代码:xv6组织结构
xv6的内核代码在/kernel
子目录下。源代码被分成文件,遵循模块化的粗略概念; 图 2.2 列出了这些文件。 模块间接口在 defs.h (kernel/defs.h) 中定义。
文件名 | 描述 |
---|---|
bio.c | 文件系统的磁盘块缓存 |
console.c | 连接用户键盘和屏幕 |
entry.S | 最开始的启动指令 |
exec.c | exec()系统调用 |
file.c | 文件描述符的支持 |
fs.c | 文件系统 |
kalloc.c | 物理页分配器 |
kernelvec.S | 处理来自内核的陷阱和计时器中断。 |
log.c | 文件系统日志记录和崩溃恢复。 |
main.c | 启动阶段控制其它模块的初始化 |
pipe.c | 管道 |
plic.c | RISC-V 中断控制器 |
printf.c | 格式化终端输出 |
proc.c | 进程和调度 |
sleeplock.c | 释放 CPU 的锁。 |
spinlock.c | 不让出 CPU 的锁。 |
start.c | 较早机器模式下执行的代码 |
string.c | C语言string和字符数组的库 |
swtch.S | 线程切换 |
systemcall.c | 将系统调用分派给处理函数 |
sysfile.c | 文件相关的系统调用 |
sysproc.c | 进程相关的系统调用 |
trampoline.S | 用于内核和用户切换的汇编代码 |
trap.c | 用于处理陷阱和中断并从中返回的 C 代码。 |
uart.c | 串口控制台设备驱动 |
virtio_disk.c | 磁盘设备驱动 |
vm.c | 管理页表和用户空间 |
图2.2: xv6内核源码文件 |
2.5 进程概览
xv6里面隔离的单元(也是类Unix系统)是进程。进程抽象可防止一个进程破坏或监视另一进程的内存、CPU、文件描述符等。同时也防止进程破坏内核本身,这样进程就无法破坏内核的隔离机制。内核必须小心地实现进程抽象,因为有错误或恶意的应用程序可能会欺骗内核或硬件做一些坏事(例如,规避隔离)。内核实现进程的机制包括用户/管理员模式标志,地址空间,及线程的时间切片。
为了帮助实施隔离,进程抽象为程序提供了它拥有自己的私有机器的错觉。进程为程序提供看似私有的内存系统或地址空间,其他进程无法读取或写入。 进程还为程序提供看似其自己的 CPU 来执行程序的指令。
Xv6 使用页表(由硬件实现)为每个进程提供自己的地址空间。RISC-V 页表将虚拟地址(RISC-V 指令操作的地址)转换(或“映射”)为物理地址(CPU 芯片发送到主存储器的地址)。
Xv6 为每个进程维护一个单独的页表,用于定义该进程的地址空间。如图2.3所示,包含进程的用户内存的内存空间在虚拟地址0开始。首先是指令,然后是全局变量,然后是堆栈,最后是进程可以根据需要扩展的“堆”区域(用于 malloc)。有许多因素制约着进程的最大地址空间:RISC-V的指针是64位宽度的;硬件仅用低39位来在页表中查询虚拟地址;xv6只使用39位中的38位。所以,最大的地址是2^38 - 1 = 0x3ff fff ffff, 以MAXVA
定义在kernel/riscv.h:348中。在地址空间的顶部,xv6 保留了一个用于 Trampoline 的页面和一个映射进程的 trapframe 以切换到内核的页面,我们将在第 4 章中解释。
xv6 内核为每个进程维护许多内容,并将其收集到 struct proc (kernel/proc.h:86) 中。一个进程最重要的状态部分是它的页表,内核栈,还有它的运行状态。我们将使用符号 p->xxx 来引用 proc 结构的元素; 例如,p->pagetable是指向进程页表的指针。
每个进程都有一个执行线程(或简称线程),用于执行进程的指令。线程可以被挂起和唤醒。为了在进程之间透明地切换,内核会挂起当前正在运行的线程并恢复另一个进程的线程。线程的很多状态(本地变量,函数调用返回的地址等 )保存在线程栈里面。每个进程有两个栈:用户栈和内核栈(p->kstack)。当进程执行用户指令,只用到用户栈,内核栈是空的。当进程进入内核(系统调用或者中断),内核的代码运行在进程的内核栈;当一个进程在内核态,它的用户栈仍然保存着已经保存过的数据,但没有实际用到。进程的线程在主动使用其用户堆栈和内核堆栈之间交替。内核堆栈是独立的(并保护其不受用户代码的影响),因此即使进程破坏了其用户堆栈,内核也可以执行。
进程可以通过执行 RISC-V ecall 指令来进行系统调用。该指令提高硬件特权级别并将程序计数器更改为内核定义的入口点。入口点的代码切换到内核堆栈并执行实现系统调用的内核指令。当系统调用完成后,内核通过调用sret指令切换回用户堆栈并返回用户空间,从而降低硬件特权级别并在系统调用指令之后恢复执行用户指令。 进程的线程可以在内核中“阻塞”以等待 I/O,并在 I/O 完成后从中断处恢复。
p->state 指示进程是否已分配、准备运行、正在运行、正在等待 I/O 或正在退出。
p->pagetable 以 RISC-V 硬件期望的格式保存进程的页表。xv6 实现分页硬件在用户空间中执行该进程时使用该进程的 p->pagetable。进程的页表还充当分配用于存储进程内存的物理页地址的记录。
2.6 代码:开启xv6和第一个进程
为了使 xv6 更加具体,我们将概述内核如何启动并运行第一个进程。后续章节将更详细地描述本概述中显示的机制。
当RISC-V机器上电后,它将会初始化自己,并且运行存储在只读存储器的引导加载程序。引导加载程序将xv6的内核加载到内存,然后,在机器模式,CPU执行xv6的起始代码_entry
(kernel/entry.S:6). RISC-V 在禁用分页硬件的情况下启动:虚拟地址直接映射到物理地址.
引导加载程序将xv6的内核加载到物理地址的0x80000000. 为什么是0x8000 0000而不是0x0地址的原因是地址0x0 ~ 0x8000 0000 包含了I/O 设备。
_entry里面的指令设置一个栈,这样xv6就可以运行C代码。Xv6 在文件 start.c (kernel/start.c:11) 中声明初始堆栈 stack0 的空间。_entry 处的代码将堆栈指针寄存器 sp 加载到地址 stack0+4096(堆栈顶部),因为 RISC-V 上的堆栈是向下增长的。 现在内核有了堆栈,_entry 在启动时调用 C 代码 (kernel/start.c:21)。
函数start执行一些仅在机器模式下允许的配置,然后切换到管理模式。 RISC-V 为进入管理模式,提供了 mret 指令。该指令最常用于从先前的调用返回,因此start函数中应该会从管理模式返回到机器模式。但start 不会从这样的调用中返回,而是像以前那样进行设置:它在寄存器 mstatus
中将先前的特权模式设置为 Supervisor,它通过将 main 的地址写入寄存器 mepc 来将返回地址设置为 main ,通过将 0 写入页表寄存器 satp 来禁用管理模式下的虚拟地址转换,并将所有中断和异常委托给管理模式。
在进入管理员模式之前,start
完成一项更多的功能,它对时钟芯片编程使它产生定时器中断。完成此内务处理后,通过调用 mret 开始“返回”到主管模式。这将会使程序计时器指向main(kernel/main.c:11)。
在main函数(kernel/main.c:11)初始化完成几个设备和子系统后,会通过调用userinit
(kernel/proc.c:212)来创建第一个进程。这个进程执行一小段RISC-V的汇编代码,initcode.S
(user/initcode.S:1),该程序通过调用exec
系统调用返回内核。正如我们在第一章中看到的,exec 用一个新程序(在本例中为 /init)替换当前进程的内存和寄存器。一旦内核完成 exec,它就会返回到 /init 进程中的用户空间。 Init
(user/init.c:15) 根据需要创建一个新的控制台设备文件,然后将其作为文件描述符 0、1 和 2 打开。然后它在控制台上启动一个 shell。 系统由此启动。
2.7 现实世界
在现实世界里,我们可以同时找到宏内核和微内核,许多的类Unix内核都是宏内核。比如Linux有一个宏内核,虽然它的许多功能(例如窗口界面系统)是运行在用户等级的服务。比如L4, Minx, 和QNX的内核是微内核的服务,在许多嵌入式设备上得到广泛的部署。
大多数操作系统都采用了进程概念,并且大多数进程看起来与 xv6 类似。然而,现代操作系统支持进程内的多个线程,以允许单个进程利用多个 CPU。支持进程中的多个线程涉及 xv6 所没有的大量机制,包括潜在的接口更改(例如 Linux 的克隆,fork 的变体),以控制进程线程共享的哪些方面
2.8 练习
您可以使用 gdb 来观察第一个内核到用户的转换。运行make qemu-gdb
,在另一个窗口的同一目录,运行gdb
。输入gdb命令break *0x3ffffff10e
, 来设置一个内核里sret
指令跳转到用户空间的断点。输入countinue
gdb指令。gdb 应该停在断点处,即将执行 sret。 输入stepi。 gdb 现在应该表明它正在地址 0x0 处执行,该地址位于 initcode.S 开头的用户空间中。
第三章 页表
页表是操作系统为每个进程提供自己的私有地址空间和内存的机制。页表确定了内存地址的含义,决定了可以访问哪一块物理地址。xv6使用页表实现独立不同进程的内存地址空间,并把他们复用在一块物理内存上。页表还提供了一定程度的间接性,允许 xv6 执行一些技巧:将相同的内存(蹦床页)映射到多个地址空间,并使用未映射的页面保护内核和用户堆栈。本章剩余内容讲解RISC-V硬件提供的页表以及xv6如何利用它的。
3.1 分页硬件
就像提到的一样,RISC-V 指令(包括用户模式和内核模式)操作虚拟地址。机器的RAM,或者物理内存是以物理地址来索引的。RISC-V页表硬件连接这两种地址,映射每一个虚拟地址到物理地址。
xv6在Sv39 RISC-V硬件平台上,意味着64位中只有低39位虚拟地址被使用;高25位是没有被用到的。在这个Sv39的配置中,RISC-V逻辑上是2的27次方(134,217,728)页表条目(PTEs)数组。每个页表条目PTE包含44位物理页号和一些标志位。分页硬件使用39位中的高27位去索引页表来找到页表条目(PTE)。然后取页表中44位物理页号(PPN)和原始虚拟地址中包含的低12位地址组成一个56位的物理地址。图 3.1 使用页表的逻辑视图显示了此过程,将其作为简单的 PTE 数组(有关更完整的故事,请参见图 3.2)。 页表使操作系统能够以 4096 (2的12次方) 字节对齐块的粒度控制虚拟到物理地址的转换。 这样的块称为页面。
如图3.2所示,实际的虚拟地址转换成物理地址分三步进行,页表作为三层树存储在物理内存中。树的根部是一个4096字节的页面包含512页表条目(PTE),每一个页表条目内的内容包含了树的下一级页表页在内存中的物理地址。然后这些页面上的页表又包含512个页表项,每个页表项包含512个页表条目(PTE)指向树的最后一级。在根页表页中,分页硬件用了27位中的高9位来区分页表条目(PTE),中间9位来选择树的下一等级的页表目录中的页表条目(PTE)。低9位来选择树的最后一级页表目录中的页表条目(PTE)。
如果转换地址所需的三个页表目录PTEs中的任何一个不存在,分页硬件引发页错误异常,将其留给内核来处理异常(参见第 4 章)。这种三级结构允许页表在大范围虚拟地址没有映射的常见情况下省略整个页表目录。
每个页表条目PTE都包含标志位,告诉分页硬件如何允许使用关联的虚拟地址。“PTE_V” 表示该页表条目是否存在:如果没被置位,对于该页的引用会造成一个异常(即不允许)。“PTE_R” 表示是否允许指令去读该页。“PTE_W” 表示是否允许指令去写该页。“PTE_X”控制CPU是否可以将页面内容解释为指令并执行它们。“PTE_U”控制是否允许用户态指令访问该页面; 如果未设置PTE_U,则PTE只能在supervisor模式下使用。图 3.2 显示了它是如何工作的。 标志和所有其他页面硬件相关结构在 (kernel/riscv.h) 中定义。
为了告诉硬件使用页表,内核必须将根页表页的物理地址写入 satp
寄存器。每一个CPU都有自己的satp
寄存器。CPU将使用其自己的satp指向的页表来转换后续指令生成的所有地址。每个CPU都有自己的satp
,这样不同的CPU可以运行不同的进程,每个进程都有一个由自己的页表描述的私有地址空间。
关于术语的一些注释。 物理内存是指DRAM中的存储单元。物理内存的一个字节有一个地址,称为物理地址。指令仅仅用虚拟地址,分页硬件会将其翻译成物理地址然后发送给DRAM硬件去读写内存。区别于物理内存和虚拟地址,虚拟内存不是物理对象,而是指内核提供的用于管理物理内存和虚拟地址的抽象和机制的集合。
3.2 内核地址空间
Xv6系统为每个进程维护一个页表,描述每个进程的用户地址空间,另外还维护一个页表,描述内核的地址空间。内核配置其地址空间的布局,使其能够在可预测的虚拟地址上访问物理内存和各种硬件资源。图 3.3 显示了该布局如何将内核虚拟地址映射到物理地址。文件 (kernel/memlayout.h) 声明 xv6 内核内存布局的常量。
QEMU 模拟一台包含 RAM(物理内存)的计算机,该 RAM 从物理地址 0x80000000 开始,并持续到至少 0x86400000,xv6 称之为 PHYSTOP。QEMU同样也模拟了像磁盘接口的I/O设备。QEMU 将设备接口作为内存映射控制寄存器公开给软件,这些寄存器位于物理地址空间中的 0x80000000 以下。内核可以通过读写这些特殊的物理地址来跟设备交互;此类读取和写入与设备硬件而不是 RAM 进行通信。第 4 章解释了 xv6 如何与设备交互。
内核使用“直接映射”获取 RAM 和内存映射设备寄存器;也就是说,将资源映射到等于物理地址的虚拟地址。比如:内核本身在虚拟地址空间和物理内存中都位于 KERNBASE=0x80000000 处。”直接映射“的方式简化了内核代码读写物理内存。比如,当fork
系统调用为子进程分配用户内存时,分配器会返回内存的物理地址,fork 在将父级的用户内存复制到子进程时直接使用该地址作为虚拟地址。
有几个内核虚拟地址不是直接映射的:
- 蹦床页面。 它映射在虚拟地址空间的顶部; 用户页表具有相同的映射。第四章会讨论蹦床页面的作用,第 4 章讨论了蹦床页的作用,但我们在这里看到了页表的一个有趣的用例; 物理页(保存蹦床代码)在内核的虚拟地址空间中映射两次:一次在虚拟地址空间的顶部,一次是直接映射。
- 内核的堆栈页。每个进程都有自己的内核堆栈,该堆栈被映射到高位,以便在其下方 xv6 可以留下未映射的保护页。保护页的 PTE 无效(即 PTE_V 未设置),因此如果内核溢出内核堆栈,很可能会导致异常并且内核将发生恐慌。如果没有保护页,溢出的堆栈将覆盖其他内核内存,从而导致不正确的操作。 恐慌崩溃是更好的选择。
虽然内核通过高内存映射使用其堆栈,但内核也可以通过直接映射地址访问它们。替代设计可能只有直接映射,并在直接映射地址处使用堆栈。然而,在这种安排中,提供保护页将涉及取消虚拟地址的映射,否则虚拟地址将引用物理内存,从而难以使用。
内核使用权限 PTE_R 和 PTE_X 映射蹦床和内核text的页面。 内核从这些页面读取并执行指令。 内核将其他页面映射为权限PTE_R和PTE_W,以便可以读写这些页面中的内存。 保护页的映射无效。
3.3 代码:创建地址空间
大多数用于操作地址空间和页表的 xv6 代码都驻留在 vm.c (kernel/vm.c)中。核心的数据结构时pagetable_t
, 它实际上是指向 RISC-V 根页表页的指针;一个pagetable_t
可能时内核页表,或者是一个进程的页表。核心的函数时walk
(根据虚拟地址来查询页表条目)和mappages
(为新的映射建立页表)。以 ”kvm” 开头的函数操作内核页表,以”uvm“开头的函数操作用户页表;其它函数兼顾两者。copyout
和copyin
函数将数据复制到作为系统调用参数提供的用户虚拟地址或从用户虚拟地址复制数据;他们在“vm.c”里因为他们需要显式地转换这些地址才能找到相应的物理内存。
在启动的早期阶段,main
调用kvminit
(kernel/vm.c:22)来创建内核页表。RISC-V在使能分页前调用此函数,所以地址会直接映射到物理地址。kvminit
首先分配一页物理内存来保存根页表页。然后它调用 kvmmap 来安装内核所需的转换。这些转换包括内核的指令和数据,直至 PHYSTOP 的物理内存以及实际上是设备的内存范围。
kvmmap
(kernel/vm.c:118) 调用kvmpages
(kernel/vm.c:149),用来建立一定虚拟地址范围的页表和对应范围的物理地址的映射。它以页面间隔为范围内的每个虚拟地址单独执行此操作。对于要映射的每一个虚拟地址,mappages
调用walk
来查询对应这个地址的页表条目(PTE)地址。然后初始化页表条目(PTE)保存与之相关的物理页号,所需的权限(PTE_W、PTE_X 和/或 PTE_R)和 PTE_V 将 PTE 标记为有效 (kernel/vm.c:161)。
walk
(kernel/vm.c:72) 模仿 RISC-V 分页硬件,在 PTE 中查找虚拟地址(见图 3.2)。walk 此时将 3 级页表下降 9 位。它使用每级的 9 位虚拟地址来查找下一级页表或最终页的页表条目PTE(kernel/vm.c:78)。如果这个页表条目PTE无效,则所需的页面尚未分配;如果设置了alloc
参数,walk
会分配一个新的页表页,并将分配的物理内存地址保存到页表条目PTE里面。返回页表树最低一层页表条目PTE的地址(kernel/vm.c:78)。
main
调用kvminithart
(kernel/vm.c:53)来建立内核页表。它将根页表页的物理地址写入寄存器 satp
中。此后,CPU 将使用内核页表转换地址。 由于内核使用恒等映射,因此下一条指令的当前虚拟地址将映射到正确的物理内存地址。
procinit
(kernel/proc.c:26), 由main
函数调用,为每一个进程分配一个内核栈。它将每个堆栈映射到 KSTACK 生成的虚拟地址,这为无效的堆栈保护页面留出了空间。kvmmap
将已映射的页表目录PTEs添加到内核页表,并且对 kvminithart
的调用将内核页表重新加载到 satp
中,以便硬件了解新的页表目录 PTE。
每个 RISC-V CPU 将页表条目PTEs缓存在转换后备缓冲区 (TLB) 中,当 xv6 更改页表PTEs时,它必须告诉 CPU 使相应的缓存 TLB 条目失效。如果不告诉CPU的话,那么在稍后的某个时刻,TLB 可能会使用旧的缓存映射,指向同时已分配给另一个进程的物理页,因此,进程可能能够在某些内容上随意操作其他进程的内存。RISC-V 有一条指令 sfence.vma
可以刷新当前 CPU 的 TLB。xv6在keminithart
重新加载satp
寄存器后执行sfence.vma
指令,以及在返回用户空间之前切换到用户页表的蹦床代码(kernel/trampoline.S:79)中也会执行sfence.vma
指令。
3.4 物理地址分配
内核在运行时必须给页表,用户内存,内核堆栈,管道缓存分配和释放物理内存。
xv6 使用内核末尾和 PHYSTOP 之间的物理内存进行运行时分配。它一次分配和释放一整页4096字节的页面。它通过页面自身的一个链表来维护空闲页面。分配意味着从链表中移除一个页面,释放意味着将页面加入链表。
3.5 代码:物理内存分配器
分配器包含在代码kalloc.c
(kernel/kalloc.c:1)中。分配器的数据结构是一个由可分配的物理内存页组成的“空闲链表”。每一个空闲页的链表元素是struct run
(kernel/kalloc.c:17)。分配器从哪里获取内存来保存该数据结构? 它将每个空闲页面的运行结构存储在空闲页面本身中,因为那里没有存储任何其他内容。空闲列表受自旋锁保护 (kernel/kalloc.c:21-24)。列表和锁包装在一个结构中,以明确锁保护结构中的字段。目前可以先忽略锁,和acquire
获取和release
释放的调用;第6章会详细介绍。
main
函数调用kinit
来初始化分配器(kernel/kalloc.c:27)。kinit
初始化空闲列表来维护从内核结束到PHYSTOP
中间的每一页。xv6 应该通过解析硬件提供的配置信息来确定有多少物理内存可用。相反,xv6 假设机器有 128 MB RAM。 kinit 调用 freerange
通过每页调用 kfree 将内存添加到空闲列表中。 PTE 只能引用在 4096 字节边界(是 4096 的倍数)上对齐的物理地址,因此 freerange
使用 PGROUNDUP 来确保它仅释放对齐的物理地址。 分配器启动时没有内存; 这些对 kfree 的调用给了它一些管理空间。
分配器有时将地址视为整数,以便对它们执行算术运算(例如,遍历free range中的所有页面),有时使用地址作为读写内存的指针(例如,操纵存储在每个页面中的运行结构); 地址的双重使用是分配器代码充满 C 类型转换的主要原因。 另一个原因是释放和分配本质上改变了内存的类型。
函数 kfree
(kernel/kalloc.c:47) 首先将要释放的内存中的每个字节设置为值 1。这将导致在释放内存后使用内存的代码(使用“悬空引用”)读取垃圾而不是旧的有效内容; 希望这会导致这样的代码更快地崩溃。然后 kfree 将页面添加到空闲列表中:它将 pa 转换为指向 struct run 的指针,在 r->next 中记录空闲列表的旧开头,并将空闲列表设置为等于 r。 kalloc 删除并返回空闲列表中的第一个元素。
3.6 进程地址空间
每一个进程都有一个独立的地址空间,当xv6进程切换的时候,也会切换页表。如图2.3所示,一个进程的内存从零开始,最大到MAXVA
(kernel/riscv.h:348), 原则上允许进程寻址 256 GB 内存。
当进程向xv6请求更多的用户内存时,xv6首先使用kalloc
来分配物理页面。然后添加页表入口PTEs到进程的页表里来指向新的物理页面。Xv6会配置PTEs里面的PTE_W, PTE_X, PTE_R, PTE_U和PTE_V标志位。大部分进程不会用到整个的用户地址空间;xv6用PTE_V标志位来清理不用的PTEs。
我们在这里看到一些使用页表的很好的例子。第一,不同进程的页表转换用户地址到不同的物理内存,所以每个进程有私有的用户内存。第二,每个进程都认为自己的内存是从零开始的连续虚拟地址,虽然进程的物理地址可以是不连续的。第三,内核将带有蹦床代码的页面映射到用户地址空间的顶部,因此单个物理内存页面出现在所有地址空间中。
图 3.4 更详细地显示了 xv6 中执行进程的用户内存布局。堆栈是一个独立的页面,并显示由 exec 创建的初始内容。包含命令行参数的字符串以及指向它们的指针数组位于堆栈的最顶部。就在其下方是允许程序从 main 启动的值,就好像函数 main(argc, argv) 刚刚被调用一样。
为了检测用户堆栈溢出分配的堆栈页,xv6 在堆栈正下方放置了一个无效的保护页。如果用户堆栈溢出,进程尝试去用堆栈下方的地址,硬件会产生一个页错误异常,因为映射是无效的。真实的操作系统一般是在产生溢出的时候去自动分配更多的内存。
3.7 代码:sbrk
sbrk
是一个用于进程减小或者增大内存的系统调用。这个系统调用通过函数growproc
(kernel/proc:239)实现。grpwproc
根据参数n是正数还是负数来分别调用uvmalloc
和uvmdealloc
。uvmalloc
通过kalloc
分配物理内存,并通过mappages
将PTEs添加到用户页表里。uvmdealloc
调用uvmunmap
(kernel/vm.c:174)(用walk
来查找PTEs,并使用kfree
来释放引用的物理内存)。
xv6用进程的页表不仅仅用来告诉硬件怎么去映射用户的虚拟内存,也是本进程已经分配了哪些物理内存页的记录。这就是释放用户内存(在 uvmunmap
中)需要检查用户页表的原因。
3.8 代码:exec
exec
是创建用户地址空间的系统调用。它使用存储在文件系统里的文件来初始化地址空间内的用户部分。exec
(kernel/exec.c:13)打开通过namei
(kernel/exec.c:26)打开二进制文件的路径(第八章会详细介绍)。之后,他会读取ELF头。Xv6的应用采用了广泛使用的ELF格式(kernel/elf.h中定义)。一个ELF二进制文件包含ELF头,struct elfhdr
(kernel/elf.h:25)。后面跟着一系列程序节头,struct proghdr
(kernel/elf.h:25)。每个 proghdr 描述了必须加载到内存中的应用程序的一部分; xv6 程序只有一个程序节标头,但其他系统可能有单独的指令和数据节。
第一步是快速检查该文件是否可能包含 ELF 二进制文件。ELF 二进制文件以四字节“幻数”0x7F、‘E’、‘L’、‘F’或 ELF_MAGIC (kernel/elf.h:3) 开头。如果 ELF 标头具有正确的幻数,则 exec 会假定二进制文件格式正确。
exec
使用proc_pagetable
(kernel/exec.c:38)分配一个没有用户映射的新页表,使用uvmalloc
(kernel/exec.c:52)为每个ELF段分配内存,并使用loadseg
(kernel/exec.c:52)将每个段加载到内存中 (kernel/exec.c:10)。 loadseg
使用 walkaddr
查找已分配内存的物理地址,在该地址写入 ELF 段的每个页面,并使用 readi
从文件中读取。
第一个通过exec
创建的程序"/init"的程序段头如下:
\# objdump -p _init
user/_init: file format elf64-littleriscv
Program Header:
LOAD off 0x00000000000000b0 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**3
filesz 0x0000000000000840 memsz 0x0000000000000858 flags rwx
STACK off 0x0000000000000000 vaddr 0x0000000000000000
paddr 0x0000000000000000 align 2**4
filesz 0x0000000000000000 memsz 0x0000000000000000 flags rw-
程序节头的 “filesz” 可能小于 “memsz”,这表明它们之间的间隙应该用零填充(对于 C 全局变量)而不是从文件中读取。对于/init
程序来说,"filesz"的大小是0x840字节,“memsz”的大小是0x858字节,所以,uvmalloc
分配足够多的内存来存放0x858字节。但只在“/init”文件中读取2112字节。
现在exec
分配并初始化用户堆栈。它只分配了一个堆栈页。exec
将参数字符串一次复制到堆栈顶部,并将指向它们的指针记录在 ustack 中。它将一个空指针放置在传递给 main 的 argv 列表的末尾。 ustack 中的前三个条目是假返回程序计数器、argc 和 argv 指针。
exec
在堆栈页的正下方放置了一个不可访问的页,因此尝试使用多个页的程序将会出错。这个不可访问的页面还允许 exec 处理太大的参数; 在这种情况下,exec 用于将参数复制到堆栈的 copyout (kernel/vm.c:355) 函数将注意到目标页面不可访问,并将返回 -1。
在准备新内存映像的过程中,如果 exec 检测到诸如无效程序段之类的错误,它将跳转到标签 bad,释放新映像,并返回 -1。exec
必须等待释放旧的映像直到系统调用成功:如果旧的映像消失,系统调用无法返回-1。exec
仅在影响创建的时候可能会产生error。一旦映像执行完成,exec
可以提交新的页表(kernel/exec.c:113)并且释放旧的(kernel/exec.c:117)。
exec
从ELF文件中加载字符数据到ELF文件指定的位置,用户或进程可以将他们想要的任何地址放入 ELF 文件中。这样exec
会是有风险的,因为 ELF 文件中的地址可能无意或有意地引用内核。粗心的内核所造成的后果可能包括崩溃和恶意破坏内核隔离机制(即安全漏洞)。xv6 执行多项检查以避免这些风险。比如,if(ph.vaddr + ph.memsz < ph.vaddr)
检查总和是否溢出 64 位整数。危险在于,用户可以使用指向用户选择的地址的 ph.vaddr 和足够大的 ph.memsz 来构造 ELF 二进制文件,以致总和溢出到 0x1000,这看起来像是一个有效值。 在xv6的早期版本,用户地址包含内核(但是在用户模式下不可读写),用户可以选择与内核内存相对应的地址,从而将数据从 ELF 二进制文件复制到内核中。在RISC-V的xv6版本中,这种情况不会发生,因为内核自己有自己独立的页表;loadseg
加载到进程的页表中,而不是内核的页表。
内核开发人员很容易忽略关键的检查,并且现实世界的内核长期以来一直缺少检查,用户程序可以利用这些检查的缺失来获取内核权限。xv6 很可能没有完成验证提供给内核的用户级数据的完整工作,恶意用户程序可能能够利用这些数据来规避 xv6 的隔离。
3.9 现实世界
像大多数操作系统一样,xv6利用分页硬件实现内存保护和映射。大多数操作系统通过结合分页和页错误异常来比 xv6 更复杂地使用分页,我们将在第 4 章中讨论。
Xv6 的简化是通过内核使用虚拟地址和物理地址之间的直接映射,并假设在地址 0x8000000 处有物理 RAM,内核期望在该位置加载。这适用于 QEMU,但在实际硬件上,这并不是一个好主意; 真实的硬件将 RAM 和设备放置在不可预测的物理地址处,因此(例如)0x8000000 处可能没有 RAM,而 xv6 期望能够在该位置存储内核。更严格的内核设计利用页表将任意硬件物理内存布局转换为可预测的内核虚拟地址布局。
RISC-V 支持物理地址级别的保护,但 xv6 不使用该功能。在具有大量内存的机器上,使用 RISC-V 对“超级页面”的支持可能很有意义。当物理内存较小时,小页面有意义,以允许以细粒度分配和页出到磁盘。比如一个程序只需要8KB的内存,给他4MB超级页的物理内存是比较浪费的。大的页面对于RAM的机器比较有优势,可以减少页表操作的开销。
xv6 内核缺乏可以为小对象提供内存的类似 malloc 的分配器,从而阻止内核使用需要动态分配的复杂数据结构。
内存分配是一个长期的热门话题,基本问题是有效利用有限内存并为未知的未来请求做好准备[7]。今天大家相对于空间效率更多的关注速率。另外,一个精细的内核相较于xv6只能分配4096字节的块,可以分配多种不同大小的小块;真实的内核分配器需要处理小型的分配也要处理大型分配。
3.10 练习
- 解析 RISC-V 的设备树以查找计算机拥有的物理内存量。
- 写一个用户程序实现调用
sbrk(1)
来增长1字节地址空间。运行程序并且调查在sbrk
运行之前和之后的程序页表。内核分配了多少空间?新内存的PTE的内容。 - 修改 xv6 以对内核使用超级页面。
- 修改xv6,以便当用户程序取消引用空指针时,它将收到异常。 即修改xv6,使虚拟地址0不映射给用户程序。
- exec 的 Unix 实现传统上包括对 shell 脚本的特殊处理。 如果要执行的文件以文本 #! 开头,则第一行将被视为要运行以解释该文件的程序。 例如,如果调用 exec 来运行 myprog arg1 并且 myprog 的第一行是 #!/interp,则 exec 使用命令行 /interp myprog arg1 运行 /interp。 在 xv6 中实现对此约定的支持。
- 为内核实现地址空间随机化
第四章 陷阱和系统调用
共有三种事件导致 CPU 搁置普通指令执行并强制将控制权转移到处理该事件的特殊代码。一种是系统调用,当用户程序执行ecall
指令来请求内核为其做一些事情。另外的一种情况是异常:指令(用户或者内核)做了一些非法的操作,比如除以0,或者访问无效的虚拟地址。第三种情况是设备中断,当设备发出需要注意的信号时,例如当磁盘硬件完成读取或写入请求时。
本书使用陷阱作为这些情况的通用术语。通常陷阱发生时,正在运行的代码不需要关心发生了什么情况,并会在稍后唤醒。也就是说,我们常常希望陷阱是透明的; 这对于中断来说尤其重要,被中断的代码通常不希望出现这种情况。通常的流程是陷阱会强制转换控制权给到内核;内核保存寄存器和其它状态,这样方便之后现场恢复;内核执行相应的处理程序(例如,系统调用实现或者设备驱动程序);内核还原保存的状态并从陷阱中返回;原始的代码从暂停处唤醒。
xv6 内核处理所有陷阱。这对于系统调用来说是很自然的。 这对于中断来说是有意义的,因为隔离要求用户进程不直接使用设备,并且只有内核具有设备处理所需的状态。它对于异常也有意义,因为 xv6 通过杀死有问题的程序来响应用户空间的所有异常。
Xv6 陷阱处理分四个阶段进行:RISC-V CPU 采取的硬件操作、为内核 C 代码做好准备的汇编“vector”、决定如何处理陷阱的 C 陷阱处理程序以及系统调用或设备驱动程序服务例程。虽然这三种陷阱类型之间的共性表明内核可以使用单个代码路径处理所有陷阱,但事实证明,对于三种不同的情况使用单独的汇编向量和 C 陷阱处理程序会很方便:来自用户空间的陷阱、来自内核空间的陷阱 ,和定时器中断。
4.1 RISC-V 陷阱机制
每一个RISC-V的CPU都有一套内核写入的控制寄存器来告诉CPU怎么处理陷阱,内核可以读取这些寄存器并找到刚出现的陷阱。RISC-V文档会有详细介绍。riscv.h
(kernel/riscv.h:1) 包含xv6用到的定义。以下是最重要寄存器的概述:
- stvec:内核在这写入它的陷阱处理程序;RISC-V跳到这来处理陷阱。
- sepc:当陷阱出现时,RISC-V在这保存程序计数器(pc寄存器被stvec复写)。
sret
指令(陷阱中返回)指令将sepc
寄存器复制回pc
。内核可以通过写spec
寄存器来控制sret
返回到什么地方。 - scause:RISC-V在此寄存器放入一个数值来描述陷阱的原因。
- sscratch: 内核在这里放置一个值,该值在陷阱处理程序一开始就派上用场。
- sstatus:
sstatus
里面的SIE位控制设备中断是否使能。如果内核清除SIE位,RISC-V将不响应设备中断,直到SIE位被配置。SPP位来指示陷阱来自用户模式还是管理员模式,控制sret
指令返回的模式。
上述寄存器关系到管理员模式的陷阱处理,不能在用户模式下读写。对于机器模式下处理的陷阱,有一组等效的控制寄存器;xv6仅用此来处理一些特殊的情况,比如时间片中断。
在多核处理器里,每个CPU都有自己的一套寄存器,在特定的时间里可能同时有多个CPU在处理不同的陷阱。
当必须进入一个陷阱时,RISC-V硬件对于所有的陷阱类型(定时器中断除外),处理流程如下:
- 如果陷阱是设备中断,同时
sstatus
里的SIE位是0,将不继续向下执行。 - 通过清除SIE位来关闭设备中断。
- 拷贝
pc
里面的值到sepc
。 - 保存现在的模式(用户或者是管理员模式)到
sstatus
里面的SPP位。 - 设置
scause
寄存器来表示陷阱原因。 - 设置模式到管理员模式。
- 复制
stvec
到pc
寄存器。 - 在新的
pc
指向的位置处执行程序。
注意,CPU不会切换内核的页表,不会切换内核的一个堆栈,不会保存除了pc
外其它寄存器。内核软件必须执行这个任务。CPU在陷阱期间执行最小工作量的原因是给软件提供灵活性;比如一些操作系统不要求在某些情况下切换页表,这样可以增加性能。
您可能想知道CPU硬件的陷阱处理流程是否可以进一步简化。比如支持CPU不切换程序计数器pc
。然后陷阱以管理员模式仍然来执行用户态的指令。那样用户指令回破坏用户/内核的隔离,比如修改指向页表的satp
寄存器来使其被允许访问物理地址。因此,CPU 切换到内核指定的指令地址(即 stvec)非常重要。
4.2 用户空间的陷阱
当用户程序在用户空间做一次系统调用(使用ecall
指令),或者做一些非法的操作,或者一个设备中断的时候会产生陷阱。来自用户空间的 trap 的高级路径是 uservec (kernel/trampoline.S:16),然后是 usertrap (kernel/trap.c:37); 返回时,先是 usertrapret (kernel/trap.c:90),然后是 userret (kernel/trampoline.S:16)。
来自用户代码的陷阱比来自内核的陷阱更具挑战性,因为 satp 指向不映射内核的用户页表,并且堆栈指针可能包含无效甚至恶意的值。
因为RISC-V页表在陷阱发生时不切换页表,所以,用户的页表必须包含对uservec
(stvec 指向的陷阱向量指令。)的映射。uservec必须切换satp指向内核页表; 为了在切换后继续执行指令,uservec
必须映射到内核页表中与用户页表中相同的地址。
Xv6系统通过包含uservec
的trampoline
页来满足这一限制条件。Xv6在每一个用户页表上将相同虚拟地址的内核页表映射在trampoline
页上。这个虚拟地址是TRAMPOLINE
(如图2.3和图3.3所示)。trampoline内容在trampoline.S
中设置,并且(执行用户代码时)stvec 设置为 uservec (kernel/trampoline.S:16)。
当 uservec 启动时,所有 32 个寄存器都包含被中断代码拥有的值。 但是 uservec 需要能够修改一些寄存器,以便设置 satp 并生成保存寄存器的地址。RISC-V 以 scratch 寄存器的形式提供了帮助。uservec 开头的 csrrw
指令交换 a0 和 sscratch 的内容。 此时用户代码里的a0已保存; uservec 有一个寄存器(a0)可以使用; a0 包含内核先前放置在 sscratch 中的值。
uservec的下一个任务是保存用户寄存器。进入用户空间之前,内核预先设置sscratch
寄存器设置位指向每一个进程的trapframe,该 trapframe(除其他外)有空间保存所有用户寄存器 (kernel/proc.h:44)。因为satp
寄存器还是指向的用户页表,uservec需要trapframe映射其它的用户地址空间,每创建一个进程,xv6都会分配一页来保存进程的trapframe,并安排它始终映射到用户虚拟地址 TRAPFRAME,该地址位于 TRAMPOLINE 下方。进程的 p->trapframe 也指向 trapframe,尽管指向它的物理地址,以便内核可以通过内核页表使用它。
因此,在交换 a0 和 sscratch 之后,a0 保存了指向当前进程的 trapframe 的指针。 uservec 现在保存所有用户寄存器,包括从 sscratch 读取的用户的 a0。
trapframe包含指向当前进程的内核堆栈、当前CPU的hartid、usertrap的地址和内核页表的地址的指针。 uservec 检索这些值,将 satp 切换到内核页表,并调用 usertrap。
usertrap 的工作是确定陷阱的原因、处理它并返回 (kernel/trap.c:37)。如上所述,它首先更改 stvec,以便 kernelvec 处理内核中的陷阱。它保存了 sepc(保存的用户程序计数器),同样是因为 usertrap 中可能存在进程切换,可能导致 sepc 被覆盖。如果陷阱是系统调用,则 syscall 处理它; 如果设备中断,devintr
处理它; 否则,这是一个异常,内核会杀死出错的进程。系统调用路径向保存的用户 PC 增加 4,因为 RISC-V 在系统调用的情况下使程序指针指向 ecall 指令。在退出时,usertrap 检查进程是否已被终止或应让出 CPU(如果此陷阱是计时器中断)。
在返回用户空间的第一步是调用usertrapret
(kernel/trap.c:90)。该函数设置 RISC-V 控制寄存器,为未来来自用户空间的陷阱做好准备。这包括更改 stvec 以引用 uservec、准备 uservec 依赖的 trapframe 字段以及将 sepc 设置为之前保存的用户程序计数器。最后,usertrapret 在用户页表和内核页表都映射的trampoline页上调用userret; 原因是userret中的汇编代码会切换页表
usertrapret 对 userret 的调用将指针传递给 a0 中的进程用户页表和 a1 中的 TRAPFRAME (kernel/trampoline.S:88)。 userret 将 satp 切换到进程的用户页表。 回想一下,用户页表映射了蹦床页和 TRAPFRAME,但没有映射来自内核的其他内容。 同样,蹦床页面映射到用户和内核页表中的同一虚拟地址的事实使得 uservec 在更改 satp 后可以继续执行。 userret 将 trapframe 保存的用户 a0 复制到 sscratch ,为以后与 TRAPFRAME 的交换做准备。 从此时起,userret 唯一可以使用的数据是寄存器内容和 trapframe 的内容。 接下来 userret 从 trapframe 恢复保存的用户寄存器,最后交换 a0 和 sscratch 以恢复用户 a0 并为下一个 trap 保存 TRAPFRAME,并使用 sret 返回用户空间。
4.3 代码:调用系统调用
第 2 章以 initcode.S 调用 exec 系统调用 (user/initcode.S:11) 结束。 让我们看看用户调用如何进入内核中 exec 系统调用的实现。
用户代码将exec
系统调用的参数放在a0和a1寄存器,将系统调用的编码放在a7. 系统调用编码与system calls 数组(函数指针表:kernel/syscall.c:108)的入口相匹配。ecall
指令陷入内核并执行uservec
,usertrap
然后syscall
,如我们上面所说。
syscall
(kernel/syscall.c:133) 从 trapframe 中保存的 a7 中检索系统调用号,并使用它来索引系统调用。对于第一个系统调用,a7包含SYS_exec(kernel/syscall.h:8),导致调用系统调用实现函数sys_exec。
当系统调用实现函数返回时,syscall将其返回值记录在p->trapframe->a0中。这将导致对 exec() 的原始用户空间调用返回该值,因为 RISC-V 上的 C 调用约定将返回值放在 a0 中。系统调用通常返回负数来指示错误,返回零或正数来指示成功。 如果系统调用号无效,则 syscall 将打印错误并返回 -1。
4.4 代码:系统调用参数
系统调用在内核的实现需要用到用户代码传进来的参数,由于用户代码调用系统调用包装函数,因此参数最初位于 RISC-V C 调用约定放置它们的位置:寄存器中。 内核陷阱代码将用户寄存器保存到当前进程的陷阱帧中,内核代码可以在其中找到它们。 函数 argint、argaddr 和 argfd 从陷阱帧中检索第 n 个系统调用参数作为整数、指针或文件描述符。 它们都调用 argraw 来检索适当的已保存的用户寄存器 (kernel/syscall.c:35)。
某些系统调用将指针作为参数传递,内核必须使用这些指针来读取或写入用户内存。比如exec
系统调用通过指针从用户空间传入内核字符串参数。这些指针的参数将会带来两个挑战。第一,用户程序可能是错误或者是恶意的,可能传递给内核一个无效的指针或者故意欺骗内核去访问内核的内存而不是用户的内存。第二,xv6内核页表与用户页表映射的内容是不一样的,所以内核不能通过常规的指令从用户提供的地址去加载或存储。
内核实现了安全地与用户提供的地址之间传输数据的功能。fetchstr
就是一个例子 (kernel/syscall.c:25)。文件系统的调用,比如exec
就是通过fetchstr
从用户空间获取文件名参数的字符串。fetchstr
通过调用copyinstr
去做复杂的工作。
copyinstr
(kernel/vm.c:406)从用户页表pagetable
的"srcva"的虚拟地址复制最大max
的字节数到dst
。它使用walkaddr(调用walk)在软件中遍历页表以确定srcva的物理地址pa0。由于内核将所有物理RAM地址映射到同一个内核虚拟地址,因此copyinstr可以直接将字符串字节从pa0复制到dst。walkaddr
(kernel/vm.c:95)检查用户提供的虚拟地址是进程用户空间的一部分,所以程序不能欺骗内核读取其它的内存。类似的函数copyout
,从内核向用户提供的地址复制数据。如果陷阱导致切换到不同的线程,这一点尤其重要——在这种情况下,陷阱实际上将返回到新线程的堆栈上,将被中断线程保存的寄存器安全地保留在其堆栈上。
4.5 来自内核空间的陷阱
根据执行的是用户代码还是内核代码,Xv6 对 CPU 陷阱寄存器的配置略有不同。当内核在 CPU 上执行时,内核将 stvec
指向 kernelvec
处的汇编代码 (kernel/kernelvec.S:10)。由于 xv6 已经在内核中,kernelvec 可以依赖设置为内核页表的 satp 以及引用有效内核堆栈的堆栈指针。kernelvec 保存所有寄存器,以便中断的代码最终可以不受干扰地恢复。
kernelvec
将被中断的内核现成的寄存器保存下来是有意义的,因为寄存器的内容是属于这个线程的。如果陷阱导致切换到不同的线程,这一点尤其重要——在这种情况下,陷阱实际上将返回到新线程的堆栈上,将被中断线程保存的寄存器安全地保留在其堆栈上。
kernelvec
保存完寄存器后会跳转到kerneltrap
(kernel/trap.c:134)。kerneltrap
准备了两种类型的陷阱:设备中断和异常。调用devintr
(kernel/trap.c:177)来处理设备中断。如果陷阱不是设备中断,必然是一个异常,通常对于xv6内核来讲是一个致命的错误;内核会panic
,并停止执行。
如果kernelvec
因为时间片中断而被调用,并且进程的内核线程正在运行(而不是调度程序线程),那么 kerneltrap 会调用yield 为其他线程提供运行的机会。在某些时候,其中一个线程被yield
,后面会被kerneltrap
重新唤醒。第7章会介绍yield
具体如何操作的。
当kerneltrap
的工作完成后,它需要返回到被陷阱中断的任何代码。因为yield可能会扰乱sstatus中保存的sepc和先前的模式,所以kerneltrap在启动时会保存它们。现在它恢复这些控制寄存器并返回到 kernelvec
(kernel/kernelvec.S:48)。kernelvec
从栈里面弹出保存的寄存器,然后执行sret
指令,以此实现从sepc
寄存器复制到pc
寄存器并且唤醒被中断的内核代码。
值得思考的是,如果 kerneltrap 由于计时器中断而调用了 Yield,那么 trap 返回是如何发生的。
Xv6设置CPU的stvec
到kernelvec
当CPU从用户空间进入内核时;可以从usertrap
(kernel/trap:29)中可以看出。内核执行时有一个时间窗口,但 stvec 设置为 uservec,在该窗口期间禁用设备中断至关重要。幸运的是,RISC-V 在开始捕获陷阱时总是禁用中断,并且 xv6 在设置 stvec 之前不会再次启用中断。
4.6 页错误异常
Xv6 对异常的响应相当无聊:如果异常发生在用户空间,内核会杀掉出错的进程。如果异常发生在内核空间,内核会panic。真实的操作系统会有更多有趣的方式响应。
例如,许多内核使用页面错误来实现写时复制 (COW) 分叉。为了实现写时复制 fork,请考虑第 3 章中描述的 xv6 的 fork。fork 通过调用 uvmcopy (kernel/vm.c:309) 为子进程分配物理内存,从而使子进程具有与父进程相同的内存内容。 子进程并将父进程的内存复制到其中。如果子进程和父进程可以共享父进程物理内存中的内容会更加有效率。然而,直接实现这种方法是行不通的,因为它会导致父进程和子进程通过写入共享栈和堆来阻断彼此的执行。
父进程和子进程可以通过写时复制实现共享物理内存,需要页错误的支持。当CPU不能将虚拟地址转换成物理地址,CPU就会产生一个页错误。RISC-V有三种不同类型的页错误:加载页错误(当加载指令遇到虚拟地址不能转换成物理地址的时候),存储页错误(当一个存储指令遇到虚拟地址不能转换成物理地址的时候)和指令页错误(当一个指令的地址不能转换)。scause
寄存器的值表示页错误发生的类型,stval
寄存器保存不能被翻译的地址。
写时拷贝最基础的计划是对于父进程和子进程初始共享所有物理内存,但是是以只读的形式去映射。这样当子进程或者父进程执行写操作时,RISC-V会产生一个页错误异常。为响应这一异常,内核会复制报错地址的内存页。复制后,子进程和父进程关于这一虚拟地址对应的内存都可以获得可以读写的两块独立的物理内存。更新完页表后,内核就会唤醒刚报这个页错误的进程。因为更新后的页表项"PTE"指向的地址已经可写了,所以之前写入报错的指令现在再次执行将不会报错。
这种写时拷贝COW的方式在fork的时候比较好用,因为子进程常常在fork后立马调用exec
,用一个新的地址空间来替换旧的地址空间。在这种情况下,子进程只会遇到一些页面错误,并且内核可以避免完整的复制。另外写时拷贝是透明的:无需对应用进行修改就可从中获益。
页表和页错误的组合使除了写时复制COW的fork以外的其它有趣的事物变得可能。另外一个广泛应用的功能称为延时分配(lazy allocation),有两个部分。第一,如果一个应用调用sbrk
,内核会增加地址空间,但会在页表中标记无效。第二,新地址的页错误时,内核分配物理内存并映射到页表。当应用多次超出其自身需求的申请内存,延时分配是一个比较好的方法:内核只分配应用实际用到的内存。就像写时拷贝COW一样,内核可以透明的向应用实现这一功能。
另一个广泛使用的利用页面错误的功能是从磁盘进行分页(paging from disk)。如果应用需要的内存大于硬件实际有的物理内存RAM,内核可以驱逐一些页:将它们写入存储设备比如磁盘上,并且标记这些页表项PTE无效。如果应用去读写驱逐页,CPU会报一个页错误,内核之后会检查这个错误的地址。如果地址是属于磁盘上的页,内核会分配一页物理内存,并从磁盘上的页读取到内存里,更新页表项PTE,使其有效并且映射到内存上,最后唤醒应用。为了在内存上保持足够的空间,内核可能要驱逐其它的内存页到磁盘。此功能不需要对应用程序进行任何更改,并且如果应用程序具有引用局部性(即,它们在任何给定时间仅使用其内存的子集),则该功能可以很好地工作。
结合分页和页错误异常的其他功能包括自动扩展堆栈和内存映射文件。
4.7 真实世界
如果将内核内存映射到每个进程的用户页表(具有适当的 PTE 权限标志),则可以消除对特殊蹦床页面的需求。 当从用户空间捕获到内核时,这也将消除对页表切换的需要。 这反过来又允许内核中的系统调用实现利用当前进程的用户内存映射,从而允许内核代码直接取消引用用户指针。 许多操作系统都使用了这些想法来提高效率。 Xv6 避免了它们,是为了减少由于无意使用用户指针而导致内核出现安全错误的可能性,并减少确保用户和内核虚拟地址不重叠所需的复杂性。
4.8 练习
- 软件中copyin和copyinstr函数遍历用户页表。 设置内核页表,以便内核映射用户程序,并且copyin和copyinstr可以使用memcpy将系统调用参数复制到内核空间,依靠硬件进行页表遍历。
- 实现惰性内存分配
- 实现COW分叉
第五章 中断和设备驱动
驱动是操作系统中管理特殊设备的代码:它配置硬件,告诉设备执行操作,处理结果中断,并与可能正在等待设备 I/O 的进程进行交互。驱动程序代码可能很棘手,因为驱动程序与其管理的设备同时执行。另外驱动必须了解设备硬件接口,可能比较复杂且缺乏对应的文档。
设备通常被配置成通过中断(陷阱的一种)出现在操作系统中。内核陷阱处理代码识别设备何时引发中断并调用驱动程序的中断处理程序; 在 xv6 中,此调度发生在 devintr (kernel/trap.c:177) 中。
很多设备驱动执行代码分为两个上下文:上面的一部分运行在进程的内核线程,下边部分运行在中断时间执行。上半部分是通过系统调用(例如 read 和 write)来调用的,这些系统调用希望设备执行 I/O。该代码可能会要求硬件开始一项操作(例如,要求磁盘读取一个块); 然后代码等待操作完成。最终设备完成操作并引发中断。驱动程序的中断处理程序充当下半部分,确定哪些操作已完成,在适当的情况下唤醒等待进程,并告诉硬件开始处理任何等待的下一个操作。
5.1 代码:控制台输入
控制台驱动(console.c)是驱动架构的简单实现。控制台驱动通过连接到RISC-V的串口接收人们输入的字符。控制台驱动程序一次累积一行输入,处理特殊输入字符,例如退格键和 control-u。用户进程(例如 shell)使用 read 系统调用从控制台获取输入行。当您在 QEMU 中向 xv6 键入输入时,您的键盘输入将通过 QEMU 的模拟 UART 硬件传送到 xv6。
驱动程序与之通信的 UART 硬件是由 QEMU 模拟的 16550 芯片 [11]。 在真实的计算机上,16550 将管理连接到终端或其他计算机的 RS232 串行链路。 运行 QEMU 时,它会连接到您的键盘和显示器。
串口硬件以一组内存映射的控制寄存器的形式暴露给软件。也就是说,RISC-V 硬件有一些物理地址连接到 UART 设备,以便加载和存储与设备硬件而不是 RAM 进行交互。UART串口设备映射的地址起始于0x1000 0000或者UART0
(kernel/memlayout.h:21). 有几个 UART 控制寄存器,每个寄存器都有一个字节的宽度。 它们与 UART0 的偏移量在 (kernel/uart.c:22) 中定义。例如,LSR 寄存器包含指示输入字符是否正在等待软件读取的位。这些字符(如果有)可从 RHR 寄存器中读取。每次读取一个字符时,UART 硬件都会将其从等待字符的内部 FIFO 中删除,并在 FIFO 为空时清除 LSR 中的“就绪”位。UART 发送硬件很大程度上独立于接收硬件; 如果软件向 THR 写入一个字节,则 UART 会传输该字节。
Xv6的main函数调用consoleinit
(kernel/console.c:184)来初始化UART串口硬件。这段代码配置UART串口接收每一个字节时都会产生一个接收中断,并且产生一个传输完成的中断在每一次输出一个字节之后(kernel/uart.c:53)。
xv6 shell 通过 init.c (user/init.c:19) 打开的文件描述符从控制台读取。对 read
系统调用的调用通过内核调用consoleread
(kernel/console.c:82)。consoleread
等待输入(通过中断的方式)到来,并保存在cons.buf
,然后复制输入到用户空间,之后(在一整行输入完成之后)返回用户空间。如果用户还没输入完一行,任何读取的进程将通过调用sleep
(kernel/console.c:98)(第7章解释sleep
调用的细节)来进行等待。
当用户输入一个字符时,UART串口会在RISC-V上产生一个中断并使能了xv6的陷阱处理程序。陷阱处理程序调用devintr
(kernel/trap.c:177),该函数通过查看RISC-V的scause
寄存器来定位中断来自外部设备。然后它让硬件单元调用PLIC[1]来告诉内核哪个设备造成的 (kernel/trap.c:186)。如果是UART串口,devintr
调用uartintr
。
uartintr
(kernel/uart.c:180)从硬件读取输入的任意字符并传送给consoleintr
(kernel/console.c:138); 到这之后将不会再接收新的字符,除非产生一次新的中断。consoleintr
的作用是积累一整行输入到cons.buf
里。consoleintr
处理退格还有几个其它字符相对比较特殊。如果一行输入完成,consoleintr
唤醒等待的consoleread
进程(如果有)。
一旦正常工作,consoleread
将会从cons.buf
中获取一整行输入,复制到用户空间,并返回用户空间(通过系统调用机制)。
5.2 代码:控制台输出
关于关联到控制台的文件描述符上的write
的系统调用实际上调用到了uartputc
(kernel/uart.c:87)。设备驱动包含了输出缓冲(uart_tx_buf),所以写进程不一定等待UART串口完成发送;而是,uartputc
函数将字符添加到缓冲,调用 uartstart 启动设备传输(如果尚未传输),然后返回。 uartputc 等待的唯一情况是缓冲区已满。
每次UART串口完成传输一个字节时都会产生一个中断。uartintr
调用uartstart
检查设备是否真正完成发送,并将下一个缓冲区的字符交给设备。因此,如果一个进程向控制台写入多个字符的时候,典型的方式是第一个字节是uartputc调用uartstart来完成发送的,剩下的其它缓冲区字符是中断产生后uartintr
函数调用uartstart
来仅从传输,直到传输完成。
需要注意的一般模式是通过缓冲和中断将设备活动与进程活动解耦。即使没有进程正在等待读取输入,控制台驱动程序也可以处理输入; 随后的读取将看到输入。相似的是,进程可以在不等待设备的时候发送输出。这种解耦可以通过允许进程与设备 I/O 并发执行来提高性能,并且当设备速度较慢(如 UART)或需要立即关注(如回显键入的字符)时尤其重要。 这种想法有时称为 I/O 并发。
5.3 驱动的并发性
你可能已经注意到在consoleread
和consoleintr
函数中调用了acquire
。这些调用将会获得一个锁来保护控制台驱动程序的数据结构免受并发访问。这里有三种并发访问的危险之处:两个不同CPU上的不同进程可能同时调用consoleread
;当 CPU 已经在 consoleread 内执行时,硬件可能会让CPU产生一个控制台(物理串口)中断;当 consoleread 执行时,硬件可能会在不同的 CPU 上传递控制台中断。 第 6 章探讨了锁在这些场景中如何发挥作用。
另外一个驱动需要关注并发的原因是一个进程可能等待一个设备的输入,但当另一个进程(或根本没有进程)正在运行时中断信号到达。这样中断处理程序无法判断它打断的哪个进程或代码。比如,中断处理程序不能在当前进程的页表中安全的调用copyout
。中处理程序通常只完成与其相关的一小部分工作(比如,只是复制输入数据到缓冲区)。然后唤醒上半部代码完成剩下的工作。
5.4 定时器中断
Xv6利用时间片来管理时钟并使其能够在计算密集型进程之间切换;usertrap
和kerneltrap
中的yield
调用造成切换。时间片中断来自绑定在每一个RISC-V中CPU核上时钟硬件。xv6系统编程时钟硬件实现定期中断CPU。
RISC-V要求时钟中断在机器模式而不是管理员模式。RISC-V的机器模式在没有分页且在独立的控制寄存器的情况下执行,所以原始的xv6内核运行在机器模式下并不现实。因此,xv6 完全独立于上面列出的陷阱机制来处理定时器中断。
在 main 之前的 start.c
中以机器模式执行的代码设置为接收定时器中断 (kernel/start.c:57)。部分工作就是编程CLINT硬件(核心本地中断器)在一定的延时后产生中断。另外一部分时设置一段暂存区,类似于trapframe,帮助定时器中断处理程序保存寄存器和CLINT寄存器的地址,最后start
设置mtvec
成“timervec”并使能定时器中断。
定时器中断可以出现在用户或内核进程执行的任何时间点;内核无法在关键操作期间禁用定时器中断。因此,定时器中断的处理程序必须保证自己的工作不会影响到被中断的内核代码。处理程序的基本策略是要求 RISC-V 引发“软件中断”并立即返回。RISC-V通过普通的陷阱机制将软件中断传递给内核,并允许内核禁用它们。处理定时器中断生成的软件中断的代码可以在 devintr (kernel/trap.c:204) 中看到。
机器模式下的定时器中断观测器是timevec
(kernel/kernelvec.S:93)。保存了一些寄存器在跳床页(由start
准备)。告诉 CLINT 何时生成下一个定时器中断,要求 RISC-V 引发软件中断,恢复寄存器并返回。 定时器中断处理程序中没有 C 代码。
5.5 真实世界
Xv6 允许在内核中执行以及执行用户程序时发生设备和定时器中断。 定时器中断强制从定时器中断处理程序进行线程切换(调用yield),即使在内核中执行时也是如此。 如果内核线程有时花费大量时间进行计算而不返回用户空间,那么在内核线程之间公平地对 CPU 进行时间切片的能力非常有用。 然而,内核代码需要注意它可能会被挂起(由于计时器中断)并稍后在不同的 CPU 上恢复,这是 xv6 中一些复杂性的根源。 如果设备和定时器中断仅在执行用户代码时发生,则内核可以变得更简单。
支持典型计算机上的所有设备是一项艰巨的工作,因为有很多设备,这些设备有很多功能,并且设备和驱动程序之间的协议可能很复杂并且文档记录很差。 在许多操作系统中,驱动程序所占的代码比核心内核还要多。
UART 驱动程序通过读取 UART 控制寄存器一次检索一个字节的数据; 这种模式称为编程 I/O,因为软件驱动数据移动。 编程 I/O 很简单,但速度太慢,无法在高数据速率下使用。 需要高速移动大量数据的设备通常使用直接内存访问 (DMA)。 DMA 设备硬件直接将传入数据写入 RAM,并从 RAM 读取传出数据。 现代磁盘和网络设备使用 DMA。 DMA 设备的驱动程序将在 RAM 中准备数据,然后使用对控制寄存器的单次写入来告诉设备处理准备好的数据。
当设备在不可预测的时间(但不是太频繁)需要关注时,中断就有意义。 但中断的CPU开销很高。 因此,高速设备(例如网络和磁盘控制器)使用减少中断需求的技巧。 一个技巧是为整批传入或传出请求引发一个中断。 另一个技巧是驱动程序完全禁用中断,并定期检查设备以查看是否需要关注。 这种技术称为轮询。 如果设备执行操作速度非常快,则轮询是有意义的,但如果设备大部分时间处于空闲状态,则轮询会浪费 CPU 时间。 某些驱动程序根据当前设备负载在轮询和中断之间动态切换。
UART 驱动程序首先将传入数据复制到内核中的缓冲区,然后复制到用户空间。 这在低数据速率下是有意义的,但这样的双副本会显着降低快速生成或消耗数据的设备的性能。 一些操作系统能够直接在用户空间缓冲区和设备硬件之间移动数据,通常使用 DMA。
5.6 练习
- 修改 uart.c 以完全不使用中断。 您可能还需要修改console.c。
- 为以太网卡添加驱动。
第六章 锁
大部分的内核包含xv6,都是多个任务之间交错运行。造成这种现象的一个原因是多处理器的硬件:多CPU的处理器独立执行,比如xv6系统的RISC-V架构。这些多CPU处理器共享物理内存,xv6 利用共享来维护所有 CPU 读写的数据结构。这种共享可能造成一个CPU在读取一个别的CPU正在更新的数据结构,或者多个CPU同时更新相同的数据结构;如果没有巧妙的设,这种并行访问通常造成不正确的结果或者损坏的数据结构。就算是运行在单内核的系统上,内核可能会让CPU切换执行多个线程,造成多个线程交错执行。最终,如果中断出现在错误的时间,则修改与某些可中断代码相同的数据的设备中断处理程序可能会损坏数据。并发一词是指由于多处理器并行、线程切换或中断而导致多个指令流交错的情况。
内核充满了并发访问的数据。比如,两个CPU可以同时调用kalloc
,从而同时从空闲列表的头部弹出。内核的设计者倾向于允许很多的并发,因为它可以通过并行性提高性能并提高响应能力。然而,结果是,尽管存在这样的并发性,内核设计者仍花费大量精力来说服自己的正确性。实现并发的代码的方法有很多,其中一些方法比其他方法更容易推理。旨在并发下正确性的策略以及支持它们的抽象称为并发控制技术。
Xv6根据具体情况使用几种并发控制技术;可以使用更多。本章聚焦于一个广泛使用的方法:锁。锁提供互斥,保证同一时间内只有一个CPU可以拥有锁。如果程序员将一块共享数据数据关联一个锁,并且代码在访问这段数据时都关联一下这个锁,那么这一段共享数据在同一时间只允许一个CPU去执行。这种情况下,我们称之为锁来保护共享数据。虽然锁是一种易于理解的并发控制机制,但锁的缺点是它们会降低性能,因为它们会串行化并发操作。
本章的其余部分解释了为什么 xv6 需要锁、xv6 如何实现它们以及如何使用它们。
6.1 竞争情况
作为为什么需要锁的一个例子,想象一下两个在不同CPU的进程调用wait
。wait
释放子进程的内存,这样在每一个CPU上,内核调用kfree
去释放子进程的页表。内核分配器维护一个链接列表:kalloc()
(kernel/kalloc.c:69)从空闲链表里面弹出一页内存,kfree()
添加一页空闲内存到空闲链表。理想状态下我们希望两个父进程的kfree
并行进行,而不是一个等待另一个执行完成后执行,但考虑到 xv6 的 kfree 实现,这是不正确的。
图6.1直观的展示了更多的细节:链接列表的空闲内存在两个CPU中是共享的,使用加载和存储指令来操作链接列表。(实际上,处理器有缓存,但从概念上讲,多处理器系统的行为就好像有一个共享内存一样。)如果没有并发请求,您可以按如下方式实现列表推送操作。
struct element {
int data;
struct element *next;
};
struct element *list = 0;
void
push(int data)
{
struct element *l;
l = malloc(sizeof *l);
l->data = data;
l->next = list;
list = l;
}
如果单独执行,这种方法是有效的。然而,代码在超过一个进程并发执行的时候就会出错,如果两个CPU同时执行push
,同时执行代码的第15行,如图6.1中所示。在执行16行之前,这会导致错误的结果,如图 6.2 所示。此时将会出现两个链表的next指向list链表。当赋值代码(第16行)执行时,第二个执行的将会覆盖第一个执行的;第一个执行向链表中添加的元素将会丢失。
图6.2中丢失的更新是一个竞争情况的例子。竞争状况是一块内存区域并发访问的情况,并且至少其中有一个是写操作。竞争通常会是bug的标志,无论丢失了一部分更新(如果并发操作都是写),还是读取不完整的数据结构体(并发操作有读有写的时候)。竞争的结果取决于所涉及的两个 CPU 的确切时序以及内存系统如何排序它们的内存操作,这可能会使竞争引起的错误难以重现和调试。例如,在调试推送时添加打印语句可能会改变执行时间,足以使竞争消失。
通常避免竞争的方式就是使用锁。锁确保互斥,来保证同一时间内只有一个CPU来执行敏感的语句"push"。使以上的场景不可能发生。添加锁的代码版本相对于上面只添加了几行,如下所示:
struct element {
int data;
struct element *next;
};
struct element *list = 0;
struct lock listlock;
void
push(int data)
{
struct element *l;
l = malloc(sizeof *l);
l->data = data;
acquire(&listlock);
l->next = list;
list = l;
release(&listlock);
}
在acquire
和release
之间的指令序列我们称之为临界区。锁通常来说是保护列表。
当我们说锁保护数据,我们真正的意思是锁保护一些适用于数据的不变量集合。不变量是跨操作维护的数据结构的属性。通常,操作的正确行为取决于操作开始时不变量是否为真。 该操作可能会暂时违反不变量,但必须在完成之前重新建立它们。例如,在链表情况下,不变式是列表指向列表中的第一个元素,每个元素的 next 字段指向下一个元素。Push 的实现暂时违反了这个不变量:在第 17 行,l 指向下一个列表元素,但 list 尚未指向 l(在第 18 行重新建立)。 我们上面检查的竞争条件之所以发生,是因为第二个 CPU 执行了依赖于列表不变量的代码,而它们(暂时)被违反了。 正确使用锁可以确保一次只有一个 CPU 可以操作临界区中的数据结构,这样当数据结构的不变量不成立时,没有 CPU 会执行该数据结构操作。
您可以将锁视为串行化并发临界区,以便它们一次运行一个,从而保留不变量(假设关键部分在隔离中是正确的)。你也可以认为锁保护的临界区对于其它的操作是一个原子操作,后续对于临界区的执行要等上一个执行临界区代码的操作完整运行之后,而不能并发的去更新临界区的内容。
虽然正确对锁的使用可以使代码避免一些错误,但是锁限制性能,比如,两个处理器并行执行kfree
。锁会串行化执行两个调用,我们没用因为是两个不同的CPU执行而获得更快得体。在多个处理器同时获取一个锁时,我们称之为处理器冲突,或者说锁遇到了争用。内核设计的一个关键挑战就是避免锁的争用。Xv6 几乎没有做到这一点,但复杂的内核专门组织数据结构和算法以避免锁争用。在列表的实例中内核为每个CPU维护一个单独的空闲列表,并且只有在该 CPU 的列表为空时才触及另一个 CPU 的空闲列表,并且它必须从另一个 CPU 窃取内存。 其他用例可能需要更复杂的设计。
锁的放置的位置对于性能的影响也是很重要的。比如,在示例代码中中将 acquire 移到更早的位置也是可以的:可以将对 acquire 的调用移动到第 13 行之前。这可能会降低性能,因为这样对 malloc 的调用也会被序列化。 下面的“使用锁”部分提供了有关在何处插入获取和释放调用的一些指导原则。
6.2 代码:锁
Xv6有两种锁:自旋锁和睡眠锁。我们以自旋锁开始。Xv6 将自旋锁表示为 struct spinlock (kernel/spinlock.h:2)。该结构中重要的字段是locked
,该字段是0时表示锁空闲,非0时表示锁被持有。逻辑上,xv6通过下面的代码来获取锁。
void acquire(struct spinlock *lk)//does not work
{
for(;;) {
if(lk->locked == 0) {
lk->locked = 1;
break;
}
}
}
不幸的是,这种实现并不能保证多处理器上的互斥,可能会发生两个CPU同时执行到第5行,lk->locked 是0,两个获取锁的进程都将在第6行得到锁。此时,两个不同的CPU持有锁,违反了互斥性。我们需要一个操作来使第5行和第6行为原子操作(即不可分割的执行)。
因为锁的广泛使用,多核处理器通常会提供一个指令来保证第5和第6行的原子性。在RISC-V中,我们通常用amoswap r, a
指令。amoswap
读取内存地址a处的值,将寄存器r的内容写入该地址,并将读取到的值放入r中。也就是说,交换了寄存器r和地址a中的内容。它以原子方式执行此序列,使用特殊硬件来防止任何其他 CPU 使用读取和写入之间的内存地址。
Xv6 的 acquire (kernel/spinlock.c:22) 使用可移植 C 库调用 __sync_lock_test_and_set
,它可以归结为 amoswap
指令; 返回值是 lk->locked
的旧(交换)内容。acquire
在一个循环中检查swap,直到获取到这个锁。每次迭代将 1 交换为 lk->locked 并检查前一个值;如果前值是0,我们将获取到这个锁,交换将会配置lk->locked
为1。如果前值是1,说明某个CPU正在占用这个锁,事实上我们自动的交换到lk->locked
为1,并没有改变它的值。
获取锁后,获取获取锁的CPU记录,以供调试。 lk->cpu
字段受锁保护,并且只能在持有锁的情况下更改。
函数release (kernel/spinlock.c:47) 与acquire 相反:它清除lk->cpu 字段,然后释放锁。 从概念上讲,该版本只需要为 lk->locked 分配零即可。 C 标准允许编译器使用多个存储指令实现赋值,因此 C 赋值对于并发代码来说可能是非原子的。 相反,release 使用执行原子分配的 C 库函数 __sync_lock_release。 该函数也可以归结为 RISC-V amoswap 指令。
6.3 使用锁
Xv6在许多地方使用锁来避免中断的发生。如上描述:kalloc
和kfree
是一个很好的例子。尝试练习1和2来看一下在没有锁的时候会发生什么。您可能会发现很难触发不正确的行为,这表明很难可靠地测试出代码在不存在锁的情况下的错误和竞争情况。 xv6 里面会有一些竞争也不是不可能。
使用锁的一个困难部分是决定使用多少个锁以及每个锁应保护哪些数据和不变量。有一些简单的原则。首先,任何时候一个 CPU 可以写入一个变量,同时另一个 CPU 可以读取或写入该变量,则应使用锁来防止两个操作重叠。 其次,请记住锁保护不变量:如果不变量涉及多个内存位置,通常所有这些位置都需要由单个锁保护以确保保持不变量。
上面的规则说明了何时需要锁,但没有提及何时不需要锁,并且不要锁太多对于效率很重要,因为锁会降低并行性。 如果并行性不重要,那么可以安排只有一个线程而不用担心锁。简单的内核可以在多处理器上通过拥有一个锁来实现这一点,该锁必须在进入内核时获取并在退出内核时释放(尽管诸如管道读取或等待之类的系统调用会引起问题)。许多单处理器操作系统已使用这种方法(有时称为“大内核锁”)转换为在多处理器上运行,但该方法牺牲了并行性:一次只能有一个 CPU 在内核中执行。 如果内核执行任何繁重的计算,那么使用更大的一组更细粒度的锁会更有效,以便内核可以同时在多个 CPU 上执行。
作为一个粗粒度锁的示例,xv6的kalloc.c
分配器有一个单独的锁保护的空闲列表。如果多处理器的不同CPU同时试图去分配内存页,每一个都需要通过acquire
来等待获取锁。锁降低性能,所以这不是一个实用的做法。如果锁的争用浪费了很大一部分 CPU 时间,也许可以通过更改分配器设计来提高性能,使其具有多个空闲列表,每个列表都有自己的锁,以允许真正的并行分配。
作为细粒度锁定的一个示例,xv6 对每个文件都有一个单独的锁,因此操作不同文件的进程通常可以继续进行,而无需等待彼此的锁。如果希望允许进程同时写入同一文件的不同区域,则可以使文件锁定方案变得更加细粒度。 最终,锁粒度决策需要由性能测量和复杂性考虑来驱动。
后续章节在解释 xv6 的各个部分时,会提到 xv6 使用锁来处理并发的示例。 作为预览,图 6.3 列出了 xv6 中的所有锁。
锁 | 描述 |
---|---|
bcache.lock | 保护块缓冲区高速缓存条目的分配 |
cons.lock | 串行化对控制台硬件的访问,避免混合输出 |
ftable.lock | 串行化文件表中结构文件的分配 |
icache.lock | 保护 inode 缓存条目的分配 |
vdisk_lock | 串行化对磁盘硬件和 DMA 描述符队列的访问 |
kmem.lock | 串行化内存分配 |
log.lock | 串行化事务日志上的操作 |
pipe’s pi->lock | 串行化对于每个管道的操作 |
pid_lock | 串行化next_pid的增量 |
proc’s p->lock | 串行化修改进程的状态 |
tickslock | 串行化对定时器的操作 |
inode’s ip->lock | 串行化对每一个inode节点和其内容的操作 |
buf’s b->lock | 串行化对于每个缓存区块的操作 |
图6.3: xv6系统里面的锁
6.4 死锁和锁顺序
如果通过内核的代码路径必须同时持有多个锁,则所有代码路径以相同的顺序获取这些锁非常重要。如果不这样做,可能会有死锁的风险。假设 xv6 中的两条代码路径需要锁 A 和 B,但代码路径 1 按 A、B 的顺序获取锁,另一条路径按 B、A 的顺序获取锁。假设线程T1执行代码路径1并获取锁A,线程T2执行代码路径2并获取锁B。接下来T1将尝试获取锁B,T2将尝试获取锁A。这两个获取都会无限期地阻塞,因为在 在这两种情况下,另一个线程都持有所需的锁,并且在获取返回之前不会释放它。为了避免这种死锁的出现,所有的代码必须以相同的顺序获取锁。对全局锁获取顺序的需求意味着锁实际上是每个函数规范的一部分:调用者必须以商定的顺序获取锁的方式调用函数。
由于 sleep 的工作方式(参见第 7 章),Xv6 有许多长度为 2 的锁顺序链,涉及每个进程锁(每个 struct proc 中的锁)。 例如,consoleintr (kernel/console.c:138) 是处理键入字符的中断例程。当新的一行输入进来时,所有等待输入的进程应该被唤醒。为了实现这个功能consoleintr
在调用wakeup
的时候获取cons.lock
锁,用来获得唤醒等待这个锁的进程。因此,全局避免死锁的锁定顺序包括必须在任何进程锁定之前获取 cons.lock
的规则。例如,创建文件需要同时持有目录锁、新文件 inode 锁、磁盘块缓冲区锁、磁盘驱动程序的 vdisk_lock
以及调用进程的 p->lock
。为了避免死锁,文件系统代码始终按照上一句中提到的顺序获取锁。
遵守全局避免死锁的策略实现起来异常的困难。有时候锁的顺序与逻辑程序结构有所冲突,比如有时候代码中模块M1调用模块M2,但是锁的顺序要求先获取 M2 中的锁,然后再获取 M1 中的锁。有时,锁的作用不能提前知道,可能是因为必须持有一个锁才能了解到下一个要获取锁的作用。这种情况出现在文件系统中,因为它在路径名中查找连续的组件,并且在等待和退出的代码中,因为它们搜索进程表以查找子进程。最后,死锁的危险通常是对锁定方案的细粒度程度的限制,因为更多的锁通常意味着更多的死锁机会。 避免死锁的需要通常是内核实现的一个主要因素。
6.5 锁和中断处理程序
一些xv6的自旋锁通过线程和中断处理程序来保护数据。例如clockintr
定时器中断处理程序可能会在增加tick
(kernel/trap.c:163)时,内核线程同时会通过sys_sleep
(kernel/sysproc.c:64).来读取ticks
。锁tickslock
来串行化两个访问。
自旋锁和中断的相互作用会带来潜在的危险。假设sys_sleep
持有tickslock
,正好它的CPU被定时器中断打断。定时器中断处理程序clockintr
会尝试去获取tickslock
锁,但它被持有,需要等到被释放。在这种情况下,tickslock
锁除了sys_sleep
主动释放,否则将永远不会被释放,但是sys_sleep
在中断处理程序clockintr
执行完成之前不会被执行。所以CPU将会陷入死锁,任何需要任意锁的代码也会被冻结。
为了防止这种情况的发生,如果一个自旋锁会被中断处理程序用到,CPU的其它程序将不会在启用中断时持有该锁。xv6比较保守:当一个CPU申请任何的锁,xv6将会关掉该CPU上的所有中断。中断在其它CPU上依然可以发生,这样中断的acquire
函数在等待另一个线程的锁时,不会发生在同一个CPU上(即可能等待不会被本中断阻塞住的其它CPU上线程持有的锁)。
xv6在不持有任何锁时重新使能中断。它必须做一些笔记来处理嵌套的关键部分。acquire
调用push_off
(kernel/spinlock.c:89),release
调用pop_off
(kernel/spinlock.c:100) 来跟踪当前CPU上锁的深度。当数量到0时,pop_off
恢复最外层临界区开始时存在的中断使能状态。intr_on
和intr_off
函数分别执行RISC-V架构中使能和关闭中断的指令。
在lk-locked
(kernel/spinlock.c:28)之前,acquire
严格的调用push_offf
是很重要的。如果两者颠倒,中断被使能,当锁被持有后,会有一个短暂的窗口,在这个窗口里如果不幸定时器中断发生时会产生死锁。相似的,release
(kernel/spinlock.c:66)函数在释放锁之后再去调用pop_off
也很重要。
6.6 指令和内存排序
很自然的想到,程序执行的顺序取决于源代码语句出现的顺序。然而,很多编译器和CPU,为了更好的性能不按照顺序执行代码。如果一个指令需要多个周期完成,CPU会提前执行该指令,使其可以与其它指令同时执行,并避免CPU停顿。比如,一个CPU可能以为串行序列A和B指令并不相互依赖,CPU可能就会先执行B指令,也有可能因为B的输入准备好,而A的输入还没有,或者仅仅是重叠了A和B的执行。编译器可以通过在源代码中该语句之前的语句的指令之前发出一条语句的指令来执行类似的重新排序。
编译器和CPU在重新排序指令顺序的时候遵循不去改变源代码本身串行顺序执行结果的原则。然而,这个原则下,重新排序会改变并行程序的执行结果,并且会比较容易的在多进程之间造成错误的表现[2, 3]。CPU的排序规则被称之为 memory model
.
例如,针对push
的这段代码,如果编译器或 CPU 将第 4 行对应的存储移动到第 6 行释放之后的某个点,那将是一场灾难:
l = malloc(sizeof(*l));
l->data = data;
acquire(&listlock);
l->next = list;
list = l;
release(&listlock)
如果上诉重排序发生,将会有一个时间窗口,在这个窗口里其它CPU可能会获取到锁并更新这个列表,这样在另一个进程里将会获取到一个没有初始化的列表list->next
。
为了让硬件和编译器不去表现这种重新排序,xv6在acquire
(kernel/spinlock.c:22)和release
(kernel/spinlock.c:47)中都使用__sync_synchronize()
。__sync_synchronize()
是一个内存屏障(memery barrier):它根据这个屏障告诉CPU和编译器不要去重新排序存储和还原。xv6里的acquire
和release
的屏障强制按照它原本的顺序执行,因为 xv6 在访问共享数据时使用了锁。 第 9 章讨论了一些例外情况。
6.7 睡眠锁
有时xv6需要长时间的持有一个锁。比如,文件系统(第八章)在读或者写磁盘上文件的内容时持有的文件锁,这些操作可能会消耗数十毫秒。长时间的持有锁,会造成另外一个想获取该锁的进程循环等待造成浪费。自旋锁的另一个缺点是进程在保留自旋锁的同时无法让出 CPU。我们做这个的原因是进程在另一个进程等待磁盘锁的时候可以使用这颗CPU。持有一个自旋锁时阻塞是非法的,因为可能在另外一个进程尝试获取该锁时造成死锁;因为acquire
不释放CPU,因此第二个线程的自旋可能会阻止第一个线程运行并释放锁。持有锁时释放CPU也违反了自旋锁持有时中断必须关掉的需求。因此,我们需要一种在等待获取时让出 CPU,并在持有锁时允许让出(和中断)的锁。
xv6以sleep-lock
的形式提供了这种锁。acquiresleep
(kernel/sleeplock.c:22)在等待时释放CPU,使用的手段将会在第七章介绍。在更高一级上,睡眠锁的一块区域是被自旋锁上锁保护的字段。acquiresleep
调用会以原子操作来调用sleep
让出CPU,并释放自旋锁。结果就是其他的线程在acquiresleep
等待的时候可以执行。
因为睡眠锁是允许中断的,因此不能用在中断处理程序。因为acquiresleep
可能会让出CPU,所以睡眠锁不能用在自旋锁内部关键部分使用(尽管自旋锁可以在睡眠锁关键部分内使用)。
自旋锁非常适用于精简且关键的部分,但对其的等待会浪费CPU的时间;睡眠锁在时间有些长的等待中更有优势。
6.8 真实世界
尽管对并发原语和并行性进行了多年的研究,但使用锁进行编程仍然具有挑战性。通常最好在同步队列等更高级别的构造中隐藏锁,尽管 xv6 不这样做。如果您使用锁进行编程,明智的做法是使用尝试识别竞争条件的工具,因为很容易错过需要锁的不变量。大多数的操作系统支持POSIX线程(Pthreads),它支持在不同CPU并发运行多个线程。Pthreads支持用户级别的锁,屏障等。支持Pthread需要操作系统的支持。例如,如果一个 pthread 在系统调用中阻塞,则同一进程的另一个 pthread 应该能够在该 CPU 上运行。另外一个例子,如果一个pthread改变了进程的地址空间(比如映射或者取消映射内存),内核必须让其它运行该进程的线程的CPU更新它们的物理页表来重新映射地址空间的修改
无需原子指令[8]也可以实现锁,但成本昂贵,并且大多数操作系统都使用原子指令。
如果许多 CPU 尝试同时获取相同的锁,那么锁的成本可能会很高。 如果一个CPU在其本地缓存中缓存了一个锁,而另一个CPU必须获取该锁,则更新持有该锁的缓存行的原子指令必须将该行从一个CPU的缓存移动到另一个CPU的缓存,并且也许 使缓存行的任何其他副本无效。 从另一个 CPU 的缓存中获取缓存行的成本可能比从本地缓存中获取缓存行的成本高出几个数量级。
为了阻止锁的成本过高,许多操作系统使用无锁的数据结构和算法[5,10]。例如,可以实现像本章开头那样的链表,在列表搜索期间不需要锁,并且可以通过一条原子指令在列表中插入一项。然而无锁编程比带有锁的编程更复杂;比如,必须考虑指令和内存的排序。带有锁的编程已经很难实现,所以xv6不采用无锁编程避免了额外的工作量。
6.9 练习
- 注释掉 kalloc 中对 acquire 和release 的调用 (kernel/kalloc.c:69)。 这似乎会给调用 kalloc 的内核代码带来问题; 您预计会看到什么症状? 当您运行 xv6 时,您是否看到这些症状? 运行用户测试时怎么样? 如果您没有发现问题,为什么不呢? 看看是否可以通过将虚拟循环插入 kalloc 的关键部分来引发问题
- 假设您注释掉了 kfree 中的锁定(在 kalloc 中恢复锁定之后)。 现在可能会出现什么问题? kfree 中缺少锁的危害是否比 kalloc 中的危害小?
- 如果两个CPU同时调用kalloc,一个CPU将不得不等待另一个CPU,这对性能不利。 修改 kalloc.c 以具有更多并行性,以便不同 CPU 对 kalloc 的同时调用可以继续进行,而无需互相等待。
- 使用大多数操作系统都支持的 POSIX 线程编写并行程序。 例如,实现一个并行哈希表并测量放入/获取的数量是否随着核心数量的增加而变化。
- 在 xv6 中实现 Pthread 的子集。 即实现一个用户级线程库,使得一个用户进程可以有1个以上的线程,并安排这些线程可以在不同的CPU上并行运行。 提出一种设计,可以正确处理线程进行阻塞系统调用并更改其共享地址空间。理想情况下,共享对用户进程是透明的。
第七章 调度
任何的操作系统都可以运行比计算机CPU数量更多的进程,所以需要一个在多个进程中共享CPU时间的规划。一种常见的方法是通过将进程复用到硬件 CPU 上,为每个进程提供一种假象,即它拥有自己的虚拟 CPU。 本章解释 xv6 如何实现这种多路复用。
7.1 复用
Xv6的CPU从一个进程切换到另一个进程的复用有两种情况。首先,xv6的sleep
和wakeup
机制在一个进程在等待一个设备或者管道I/O操作完成的时候,或者等待子进程退出或者在sleep
系统调用中等待中切换。第二,xv6 定期强制切换以处理长时间计算而不休眠的进程。这种复用会造成每个进程似乎都有它们自己CPU的错觉,就像xv6通过内存的分配器和硬件的页表造成一种每个进程都有自己的内存的假象。
实施多路复用带来了一些挑战。首先,怎么从一个进程切换到另一个进程?虽然上下文切换的想法很简单,该实现是 xv6 中一些最不透明的代码。第二,如何对用户进程透明的进行切换?xv6用标准的方法利用定时器中断实现上下文的切换。第三,多个CPU可能在进程间切换,所以锁的使用是避免竞争的有效手段。第四,进程的内存和其它的资源在进程退出后要被释放,但是进程不能自己做所有的事情,因为(比如)当还需要用到它自己的内核栈时,内核栈不能被释放。第五,多核机器上的每一个核心必须记住它在执行哪一个进程,以便于系统的调用可以影响到当前进程的内核状态。最后,sleep
和wakeup
允许一个进程放弃CPU并且睡眠等待一个事件,并且允许其它的进程唤醒第一个进程。需要注意的是避免竞争引发的唤醒动作丢失。xv6试图以最简单的方式解决这种问题,但尽管如此,生成的代码还是很复杂。
7.2 代码:上下文切换
图7.1展示了一个用户进程切换到另外一个用户进程的步骤:一个用户态到内核态的转换(系统调用或中断)到旧的进程的内核线程,一个上下文切换到当前CPU的调度线程,再一个上下文切换到新的进程的内核线程,通过陷阱机制回到用户进程。xv6的调度器在每个CPU上有一个专属的线程(保存寄存器和栈),因为对于调度器来说在旧的进程的内核栈上执行是不安全的:因为其它的核心可能会唤醒并执行它,在不同的CPU上用同样的栈将会是一个灾难。在这部分我们将考核从一个内核线程到调度器线程的切换机制。
从一个线程切换到另一个线程涉及到保存旧的线程的CPU的寄存器,并且还原新线程预先存好的寄存器;实际上栈的指针和程序计数器的保存和还原意味着CPU将会切换栈和切换正在执行的代码。
函数switch
实现保存和还原内核线程。switch
不会直接知道线程的内容;它只是保存和还原寄存器的配置,称之为上下文。每当一个进程到时间让出CPU,进程的内核线程调用switch
来保存它自己的上下文,并返回到调度器的上下文。上下文的内容包含在一个结构体struct context
(kernel/proc.h:2),自身包含进程的结构体struct proc
或者CPU的结构体struct cpu
。switch
函数传入两个参数struct context *old
和struct context *new
。在old
里面包含当前的寄存器,在new
里面加载新的寄存器。之后返回。
让我们通过 swtch
跟踪一个进程进入调度程序。我们在第 4 章中看到,中断结束时的一种可能性是 usertrap
调用yield
。yield
再去调用sched
(调用switch
来保存当前的上下文到,p->context
然后切换到预先保存在cpu->scheduler
调度器线程的上下文)r(kernel/proc.c:509)。
switch
(kernel/switch.S)只保存被调用者保存的寄存器;调用者保存的寄存器被调用的C代码保存在栈里(如需要)。switch
了解每一个寄存器在结构体struct context
里的偏移量。不保存程序计数器。而是在新的上下文里保存ra
寄存器,用于记录之前switch
里保存的寄存器的值。当switch
返回时,会返回到恢复的ra寄存器指向的指令,即新线程之前调用swtch的指令。 此外,它返回新线程的堆栈。
在我们的示例中,sched
调用 swtch
来切换到 cpu->scheduler
,即每 CPU 调度程序上下文。 该上下文已通过调度程序调用 swtch
(kernel/proc.c:475) 保存。 当我们一直跟踪的swtch
返回时,它不是返回到sched
而是返回到scheduler
,并且它的堆栈指针指向当前CPU的scheduler
堆栈。
7.3 调度
上一节介绍了swtch
的底层细节;现在,以switch
作为输入和检查从一个进程的内核线程通过调度器切换到另外一个进程。调度器作为一个特殊线程的形式存在在每一个CPU上,每一个都执行scheduler
函数。函数用来选择下一个要运行的进程。一个想放弃CPU的进程首先要获取该进程自己的锁p->lock
,释放它持有的其它锁,更新自己的状态p->state
。然后调用sched
。yield
(kernel/proc.c:515)遵循这个惯例(kernel/proc.c:499-504)然后是这些惯例的实现:持有锁之后,中断必须被禁用。最后,sched
调用switch
来保存当前的上下文到p->context
之后切换到调度器cpu->scheduler
的上下文。switch
返回到调度器上的栈,就像scheduler
的switch
已返回的一样。调度器继续执行for
来循环,找到一个可执行的进程,切换到该进程执行,然后重复该进程。
我们刚看到xv6通过调用switch
来持有锁:switch
的调用者必须已经持有锁,并且锁的控制权传递到切换到的代码。这种对锁的使用方法是非常规的;通常获取锁的线程有义务释放锁,这样更加方便的实现用锁的正确性。对于上下文切换必须打破这种常规的方法,因为p->lock
保护在执行switch
过程中,进程中不为真的状态和上下文段中的不变量。如果在switch
时p->lock
不被持有,会出现的一个问题比如:另外一个CPU在该进程通过yield
将进程状态设置为“RUNABLE”后想调用该进程,但在 swtch
导致它停止使用自己的内核堆栈之前。结果将时两个CPU用相同的堆栈,这是不正确的。
内核线程总是在sched
中放弃CPU并且总是调用到调度器的同一位置,它(几乎)总是切换到先前调用 sched 的某个内核线程。因此,如果要打印出 xv6 切换线程的行号,则会观察到以下简单参数:(kernel/proc.c:475)、(kernel/proc.c:509)、(kernel/proc.c :475),(kernel/proc.c:509),等等。两个线程之间发生这种程式化切换的过程有时被称为“coroutines”; 在此示例中,sched 和调度程序是彼此的协同例程。
有一种情况是调度程序对 switch 的调用没有在 sched
中找到对应新的进程。当一个新的进程第一次被调用的时候,它开始于forkret
(kernel/proc.c:527)。forkret
用来释放锁p->lock
;这样新的进程会在usertrapret
中开始执行。
scheduler
(kernel/proc.c:457)以一个简单的循环来执行,找一个进程去执行,在进程让出CPU时执行,以此重复。调度器在进程表中寻找一个可执行的进程,进程的状态p->state == RUNNABLE
。一旦找到一个进程,它会配置CPU的的当前进程参数c->proc
,配置该进程的状态为“RUNNING”,然后再调用switch
开始执行它(kernel/proc.c:470-475)。
考虑调度代码结构的一种方法是,它对每个进程强制执行一组常量,并在这些常量不成立时保持 p->lock。一个常量是进程是否是在RUNNING
状态,定时器中断的yeild
必须安全的从进程中转换出去;意味着CPU的寄存器必须保存着进程的寄存器(switch
并没有将他们转入进上下文context
),c-proc
必须指向该进程。另外一个常量是进程是否处于RUNNABLE
状态,空闲 CPU 的调度程序运行它必须是安全的;这意味着p->contexe
必须保存着进程的寄存器(它们并不是真正存在物理寄存器中),没有CPU在处理器的内核栈上执行,并且没有CPU的c->proc
指向该进程。请注意,当 p->lock 被持有时,这些属性通常不成立。
保持上述常量是xv6经常在一个线程中获取p->lock
并在其他线程中释放它的原因,比如在yield
中获取锁,并在scheduler
中释放。一旦yield
函数开始将一个正在运行的进程变化为RUNNABLE
状态的,锁必须一直被持有一直到常量被还原:最早正确释放的点是在scheduler
(在它自己的堆栈上)清除c->proc
。类似的,一旦scheduler
开始转换一个RUNNABLE
状态的进程到RUNNING
状态时,锁在内核线程运行(在switch
之后,比如yeild)之前不能被释放。
p->lock
同时也保护了其它的内容:exit
和wait
的相互作用,避免丢失唤醒的机制(参见第 7.5 节),避免在进程退出时其它进程读取或者写入该进程的状态时的竞争(比如:exit
系统调用查询p->pid
并且配置p->killed
d(kernel/proc.c:611))。为了清晰起见,或许也为了性能,是否可以将 p->lock 的不同功能分开,可能值得考虑。
7.4 代码:mycpu 和 myproc
Xv6 通常需要一个指向当前进程的 proc
结构的指针。在单处理器上,可以有一个指向当前proc
的全局变量。但在多核机器上不能工作,因为每个核心都在执行不同的进程。解决这个问题的方法是利用每个内核都有自己的一组寄存器的事实; 我们可以使用这些寄存器之一来帮助查找每个核心的信息。
Xv6为每一个CPU维护了一个结构体struct cpu
(kernel/proc.h:22),记录当前CPU上正在运行的进程(如果有),为CPU的调度线程保存寄存器,以及管理中断禁用所需的嵌套自旋锁的数量。函数mycpu
(kernel/proc.c:60)返回当前cpu结构体struct cpu
的指针。RISC-V给它的CPU做了编号,给每一个CPU一个"hartid"。Xv6确保在内核状态下将每一个CPU的hartid存放在tp
寄存器。这允许mycpu
通过tp
寄存器来查询cpu
结构体并能确定该CPU。
确保CPU的tp
寄存器始终保存着CPU的hartid有点复杂。mstart
在启动流程较早的阶段设置了tp
寄存器,大概在机器模式阶段(kernel/start.c:46)。usertrapret
因为用户进程可能会修改tp
寄存器,所以在跳床页保存了tp
寄存器。最后,uservec
在从用户空间进入到内核(kernel/trampoline.S:70)时还原已保存的tp
寄存器。编译器确保绝不使用tp
寄存器。如果RISC-V支持xv6直接读取当前的hartid将会使事情变得非常简单,但是直接获取是在机器模式下而不是在管理员模式下。
cpuid
和mycpu
的返回值是比较脆弱的:如果定时器中断打断导致线程让出CPU,并且转移到其它CPU去执行该线程,那之前返回的值将不会是正确的。未来防止这种问题出现,xv6要求在这种情况下调用者禁用中断,仅当完成对返回的结构体struct cpu
的使用时再重新打开中断。
函数myproc
(kernel/proc.c:68)返回当前CPU正在运行的进程结构体struct proc
的指针。myproc
关闭中断,调用mycpu
,从 struct cpu 中获取当前进程指针 (c->proc),然后启用中断。myproc
的返回值即使在中断被启用时也是可以安全使用的:如果定时器中断将该进程转移到其它CPU上执行,它的struct proc
结构体指针依然指向同一个。
7.5 睡眠和唤醒
调度和锁帮助在一个进程中隐藏一个已存在的进程,但到目前为止,我们还没有帮助进程虚拟化使进程本身实现无感。为解决这一问题,实现了很多的机制。xv6用到了一种名字叫睡眠和唤醒的机制,这种机制运行进程睡眠等待一种事件,并且其它进程在事件发生后唤醒这个进程。睡眠和唤醒机制通常被称之为序列协调或条件同步机制。
为了说明这一点,让我们考虑一种称为信号量 [4] 的同步机制,它协调生产者和消费者。信号量提供一种机制并提供两种操作。“V”操作(生产者)增加计数。“P”操作(消费者)等待到数值非0,然后减少它并返回。如果仅有1个生产者和1个消费者的线程,并且两个线程执行在不同的CPU上,并且编译器没有优化的太激进,这种实现方法应该时对的:
struct semaphore {
struct spinlock lock;
int count;
};
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count +=1;
releease(&s->lock);
}
void
P(struct semaphore *s)
{
while(s->count == 0)
;
acquire(&s->lock);
s->count -=1;
releease(&s->lock);
}
通过以上代码实现代价是很大的。如果生产者生产的比较慢,消费者会消耗大量的时间通过while循环等待非零的数值。消费者的CPU本可以做其它更有价值的事情而不是循环的去读取s->count
的数值。防止这种忙等待的方法是消费者睡眠让出CPU,只有在生产者增大数值后被唤醒。
这是朝着这个方向迈出的一步,尽管我们将看到这还不够。我们想象这样一对调用,sleep
和wakwup
,工作原理如下。sleep
(chan)
在变量“chan”时睡眠,称之为等待通道“wait channel”。sleep
使调用它的进程休眠,使CPU去做其它的工作。wakeup
(chan)唤醒所有在"chan“(如果有)上的进程,造成sleep
调用返回。如果”chan“上没有等待的进程,wakeup
将不做任何事情。我们可以通过sleep
和wakeup
(加粗显示)实现信号量如下:
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count +=1;
**wakeup(s);**
releease(&s->lock);
}
void
P(struct semaphore *s)
{
while(s->count == 0)
**sleep(s);**
acquire(&s->lock);
s->count -=1;
releease(&s->lock);
}
P 现在采用更好的方法–放弃了CPU的执行权而不是自旋等待。事实证明,使用此接口设计睡眠和唤醒而不遭受所谓的”唤醒丢失“问题并不容易。支持P在while循环里面检查count==0,V在另一个CPU上执行:它用来将变量count+1并且唤醒,如果没有睡眠的进程则什么都不做。现在P在代码里执行sleep并且会睡眠。这会产生一个问题,当”while(s->count == 0)“执行后,V正好执行了“s->count +=1;”及wakeup语句,P中又接着执行了sleep语句,除非比较幸运,V又再次调用wakeup函数,否则即使count数值不为0,消费者也会一直睡眠。
这个问题的根源在于P仅在“s->count= =0“时才休眠的常量被V在错误的时间运行而造成冲突。保护常量的错误方法是将锁获取(下面以加粗突出显示)移至 P 中,以便其对计数的检查和对 sleep 的调用是原子的:
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count +=1;
wakeup(s);
releease(&s->lock);
}
void
P(struct semaphore *s)
{
**acquire(&s->lock);**
while(s->count == 0)
sleep(s);
s->count -=1;
releease(&s->lock);
}
人们可能希望这个版本的 P 能够避免丢失唤醒,因为锁阻止 V 在while之间执行。它确实做到了这一点,但它也产生了死锁:P 在休眠时持有锁,因此 V 将永远阻塞等待锁。
我们将通过更改 sleep 的接口来修复前面的方案:调用者必须将条件锁传递给睡眠,以便在调用进程被标记为睡眠并在睡眠通道上等待后释放该锁。 该锁将强制并发 V 等待,直到 P 完成将自己置于休眠状态,以便唤醒会找到休眠的消费者并将其唤醒。 一旦消费者再次醒来,睡眠就会在返回之前重新获取锁。 我们新的正确睡眠/唤醒方案可按如下方式使用(更改以加粗突出显示):
void
V(struct semaphore *s)
{
acquire(&s->lock);
s->count +=1;
wakeup(s);
releease(&s->lock);
}
void
P(struct semaphore *s)
{
acquire(&s->lock);
while(s->count == 0)
**sleep(s, &s->lock);**
s->count -=1;
releease(&s->lock);
}
P 持有 s->lock 的事实可以防止 V 在 P 检查 c->count 和调用 sleep 之间尝试唤醒它。 但请注意,我们需要 sleep 来自动释放 s->lock 并使消费进程进入睡眠状态。
7.6 代码:睡眠和唤醒
让我们来看一下sleep
(kernel/proc.c:548)和wakeup
(kernel/proc.c:582)的实现。基本思想是让sleep将当前进程标记为SLEEPING,然后调用sched释放CPU; wakeup 查找在给定等待通道上休眠的进程并将其标记为 RUNNABLE。 睡眠和唤醒的呼叫者可以使用任何彼此方便的号码作为通道。 Xv6经常使用参与等待的内核数据结构的地址。
sleep
获取"p->lock"(kernel/proc.c:559)。之后进程会陷入睡眠并且持有"p->lock"和”lk“。持有”lk“对于调用者非常重要(比如:P):它确保没有其他进程(在示例中,一个正在运行的 V)可以启动对wakeup
(chan)的调用。现在 sleep
持有 p->lock
,释放 lk
是安全的:其他进程可能会开始调用wakeup
(chan),但wakeup
将等待获取p->lock
,因此将等待直到sleep
完成将 睡眠过程,防止唤醒错过睡眠。
有一个小问题:如果 lk
与 p->lock
是同一个锁,那么如果 sleep
试图获取 p->lock
,它就会与自身发生死锁。 但是,如果调用 sleep
的进程已经持有 p->lock
,则无需执行任何其他操作即可避免错过并发唤醒。 当 wait
(kernel/proc.c:582) 使用 p->lock
调用 sleep
时,就会出现这种情况。
现在 sleep
持有 p->lock
而没有其他锁,它可以通过记录睡眠通道、将进程状态更改为 SLEEPING
并调用 sched
让进程进入睡眠状态 (kernel/proc.c:564-567)。 稍后我们就会明白为什么在进程被标记为“睡眠”之前不释放 p->lock
(由调度程序)是至关重要的。
在某个时刻,进程将获取条件锁,设置休眠程序正在等待的条件,并调用wakeup
(chan)。持有条件锁时调用唤醒很重要(严格来说,wakeup只要紧跟在acquire之后就足够了,也就是说,可以在release之后调用wakeup)。wakeup
在进程表(kernel/proc.c:582)中循环。它获取它检查的每个进程的 p->lock
,既因为它可以操纵该进程的状态,又因为 p->lock
确保睡眠和唤醒不会错过彼此。当wakeup
发现正在SLEEPING状态且通道”chan“匹配的时候,会将进程的状态切换为RUNNABLE。下次调度器运行的时候,会看到该进程已经准备好运行了。
为什么对于wakeup
和sleep
,锁的规则确保了睡眠进程不会错过唤醒?睡眠进程从检查条件之前到标记为 SLEEPING 之后的某个点都持有条件锁或它自己的 p->lock 或两者。进程调用wakeup
锁持有以上两锁在wakeup
的循环中。因此,唤醒器要么在消费线程检查条件之前使条件为真;要么使条件为真。 或者唤醒器的唤醒在休眠线程被标记为 SLEEPING 后严格检查该线程。 然后wakeup会看到睡眠进程并唤醒它(除非有其他东西先唤醒它)。
有时会出现多个进程在同一个通道上休眠的情况; 例如,多个进程从管道读取数据。 一次唤醒调用即可将它们全部唤醒。 其中一个将首先运行并获取调用 sleep 所用的锁,并且(在管道的情况下)读取管道中等待的任何数据。 其他进程会发现,尽管被唤醒,却没有数据可读取。 从他们的角度来看,这次唤醒是“虚假的”,他们必须再次入睡。 因此,总是在检查条件的循环内调用 sleep。
如果两次使用睡眠/唤醒意外选择相同的通道,则不会造成任何损害:它们会看到虚假唤醒,但如上所述的循环将容忍此问题。 睡眠/唤醒的大部分魅力在于它既是轻量级的(不需要创建特殊的数据结构来充当睡眠通道),又提供了一个间接层(调用者不需要知道他们正在与哪个特定进程交互)。
7.7 代码:管道
一个使用睡眠和唤醒来同步生产者和消费者的更复杂的例子是 xv6 的管道实现。我们在第一章中看到了管道的接口:写入管道一端的字节被复制到内核缓冲区中,然后可以从管道的另一端读取。 未来的章节将研究管道周围的文件描述符支持,但现在让我们看看 pipelinewrite 和 pipelineread 的实现。
每个管道都用struct pipe
结构体表示,包含一个锁和数据缓冲区。变量nread
和nwrite
记录读取和写入缓冲区的字节数。缓冲区以循环的方式擦除和写入:写入时,buf[PIPESIZE-1]
的下一位是buf[0]
。但是计数不会循环。这种实现的方式区分了缓冲区满(nwrite == nrea+PIPESIZE)或者是空(nwrite= = nread),这意味着缓冲区的编号是buf[nread%PIPESIZE]
而不是buf[nread]
(nwrite类似)。
介绍一下在不同CPU之间同步执行pipewrite
和piperead
的情况。pipewrite
(kernel/pipe.c:77)以申请管道的锁作为开始,锁用来保护计数器,数据及相关的常量。piperead
(kernel/pipe.c:103)之后也会尝试获取锁,但获取不了。会在acquire
(kernel/spinlock.c:22)自旋等待锁。在piperead
等待的过程中,pipewite
遍历所有待写入的字节(addr[0…n-1]),按照顺序将数据放入管道(kernel/pipe.c:95)。在写入的过程中,可能会出现缓冲区被填满的情况。在这种情况下,pipewrite
会唤醒在等待读取该数据的进程,然后回在&pi->nwrite
上休眠,然后等待读取进程从缓冲区读取出一些数据。sleep
会释放pi->lock
锁作为pipewrite
休眠过程的一部分。
现在pi->lock
是可用的,piperead
会去获取到该锁,然后进入其紧急的部分:试着对比pi->nread != pi->nwrite
(kernel/pipe.c:110)(pipewrite
进程已经进入休眠,因为pi->nwrite == pi->nread+PIPESIZE
(kernel/pipe.c:85)),以此判断管道可读取的数据是否为空,如果不为空,就开始遍历,从管道中读出数据(kernel/pipe.c:117),并增加nread
的已经复制出的字节数的值。这样管道中就会空出缓冲区来写入,所以piperead
进程在返回之前会调用wakeup
(kernel/pipe.c:124)来唤醒所有在等待的有效的写入进程。wakeup
函数在&pi->nwrite
查找休眠的进程,也就是在执行pipewrite
函数时因为缓冲区满而阻塞的进程。它标记这个进程为RUNNABLE,并等待调度器调度。
管道代码为读取器和写入器准备单独的休眠通道(pi->nread
和p->nwrite
);如果有很多读取器和写入器在等待同一个管道,这可能会使系统更加高效(虽然这种情况不太可能发生)。管道代码在循环内休眠,检查休眠条件;如果有多个读取器或写入器,则除了第一个唤醒的进程之外的所有进程都会看到条件仍然为假并再次睡眠。
7.8 代码:等待退出和杀进程
sleep
和wakeup
可以应用在很多种类的等待。一个有趣的例子,在第一章介绍,是子进程的退出exit
对父进程等待wait
的影响。在子进程退出的时候,父进程可能已经在wait
中休眠,或者在做其他事情;当正在做其他事情时,对 wait 的调用必须观察子进程的死亡,可能是在调用 exit 很久之后。xv6 记录子进程死亡直到 wait
观察到它的方式是 exit
将调用者置于 ZOMBIE 状态,它会一直保持在该状态,直到父进程的 wait
注意到它,将子进程的状态更改为 UNUSED,复制子进程的退出状态,并返回 子进程 ID 给父进程。如果父进程在子进程之前退出,则父进程将子进程交给 init
进程,该进程将永远调用 wait
; 因此,每个孩子都有一个父母来清理。 主要的实现挑战是父子等待和退出以及退出和退出之间可能出现竞争和死锁。
wait
使用对进程的p->lock
作为条件锁来避免对唤醒事件的遗漏,它在一开始就申请获取锁(kernel/proc.c:398)。之后扫描进程表。如果发现ZOMBIE状态的子进程。它会释放子进程的资源和其proc
结构体,复制子进程的退出状态保存到wait
附带的变量地址(如果不为0)。之后返回子进程的进程id。如果wait
发现子进程但没有一个是退出状态,它会调用sleep
等待其中的一个进程退出(kernel/proc.c:445),然后重新扫描。这里,在睡眠中释放的条件锁是等待进程的p->lock
,即上面提到的特殊情况。 请注意,wait
通常持有两个锁; 它在尝试获取任何子锁之前先获取自己的锁; 因此,所有 xv6 必须遵守相同的锁定顺序(父级,然后子级)以避免死锁。
wait
检索每个进程的np->parent
成员变量来查找它的子进程。它在没持有np->lock
的情况下使用了np->parent
,这违反了共享变量必须受锁保护的通常规则。np 可能是当前进程的祖先,在这种情况下获取 np->lock
可能会导致死锁,因为这会违反上述顺序。在这种情况下,在没有锁的情况下检查 np->parent
似乎是安全的; 进程的父字段只能由其父进程更改,因此如果np->parent==p
为 true,则该值无法更改,除非当前进程更改它。
exit
(kernel/proc.c:333)记录退出状态,释放一些资源,将任何子进程交给 init
进程,唤醒处于等待状态的父进程,将调用者标记为僵尸进程,并永久让出 CPU。最终的流程是比较棘手的。退出进程在将其状态设置为 ZOMBIE 并唤醒父进程时必须保持其父进程的锁,因为父进程的锁是防止等待中丢失唤醒的条件锁。子进程还必须持有自己的 p->lock
,否则父进程可能会看到它处于 ZOMBIE 状态,并在它仍在运行时释放它。 锁获取顺序对于避免死锁很重要:因为 wait
在子锁之前获取父锁,所以 exit
必须使用相同的顺序。
exit
调用特殊的唤醒函数,wakeup1
这个函数仅唤醒父进程,并且仅仅在因wait
(kernel/proc.c:598)而休眠的情况。子进程在将其状态设置为 ZOMBIE 之前唤醒父进程可能看起来不正确,但这是安全的:虽然wakeup1
可能导致父进程运行,但 wait
中的循环无法检查子进程,直到子进程的p->lock
被释放 由调度程序执行,因此 wait
无法查看退出进程,直到 exit
将其状态设置为 ZOMBIE (kernel/proc.c:386) 后很久。
exit
允许进程自行终止,而kill
(kernel/proc.c:611) 则允许一个进程请求另一个进程终止。 如果直接销毁其它进程,kill 会太复杂,因为被销毁的进程可能正在另一个 CPU 上执行,也许正在对内核数据结构进行敏感的更新操作。 因此,kill
做的事情很少:它只是设置被销毁进程的 p->killed
,如果它正在休眠,则将其唤醒。 最终被销毁的进程将进入或离开内核,此时如果设置了 p->killed
,usertrap
中的代码将调用 exit
。 如果受害者运行在用户空间,它很快就会通过系统调用或定时器(或其他设备)中断进入内核。
如果被销毁进程正在休眠状态,kill
对wakeup
的调用会使被销毁进程从休眠中返回。这具有潜在的危险,因为正在等待的条件可能不成立。然而,xv6中在总是在sleep
返回时循环检测等待的条件是否被满足。一些对sleep
的调用也会在循环里检测p->killed
如果被配置则禁止其它操作。只有当这种放弃是正确的时候才会这样做。 例如,如果设置了killed标志,则管道读写代码返回; 最终代码将返回陷阱,陷阱将再次检查标志并退出。
某些 xv6 睡眠循环不检查 p->killed
,因为代码位于应该是原子的多步系统调用的中间。 virtio 驱动程序 (kernel/virtio_disk.c:242) 就是一个例子:它不检查 p->killed
,因为磁盘操作可能是为了将文件系统保留在其中而需要的一组写入操作之一。 正确的状态。 在等待磁盘 I/O 时被终止的进程在完成当前系统调用并且 usertrap
看到终止标志之前不会退出。
7.9 真实世界
xv6调度器实现了一个简单的调度策略,它依次运行每个进程。 此策略称为循环法。 真实的操作系统实施更复杂的策略,例如允许进程具有优先级。 这个想法是,调度程序将优先选择可运行的高优先级进程,而不是可运行的低优先级进程。 这些策略可能很快就会变得复杂,因为经常存在相互竞争的目标:例如,运营商可能还希望保证公平性和高吞吐量。 此外,复杂的策略可能会导致意外的交互,例如优先级反转和车队。 当低优先级进程和高优先级进程共享锁时,可能会发生优先级反转,当低优先级进程获取锁时,会阻止高优先级进程取得进展。 当许多高优先级进程正在等待获取共享锁的低优先级进程时,就会形成一长串等待进程; 车队一旦形成,就可以持续很长时间。 为了避免此类问题,复杂的调度程序中需要额外的机制。
sleep
和wakeup
机制时一个简单有效的同步方法,但也有许多其它方法。它们面临的第一个挑战就是本章刚开始介绍的“唤醒遗漏”的问题。原始的类unix系统的休眠仅仅时关闭了中断,这对于一开始的单CPU来说已经够用了。因为xv6运行在多CPU核心的机器上,所以添加了显式的睡眠锁。FreeBSD操作系统的msleep
采用了相同的方法。Plan 9 的休眠使用回调函数,该函数在睡眠前持有调度锁的情况下运行; 该功能用作睡眠状况的最后一刻检查,以避免丢失唤醒。 Linux内核的睡眠使用显式进程队列,称为等待队列,而不是等待通道; 队列有自己的内部锁。
在唤醒时扫描整个进程列表以查找具有匹配 chan 的进程效率很低。 更好的解决方案是用一个数据结构替换睡眠和唤醒中的 chan,该数据结构保存在该结构上睡眠的进程列表,例如 Linux 的等待队列。 Plan 9 的睡眠和唤醒呼叫构建了集合点或集合点。 许多线程库引用相同的结构作为条件变量; 在这种情况下,睡眠和唤醒操作称为等待和信号。 所有这些机制都有相同的特点:睡眠条件受到睡眠期间原子删除的某种锁的保护。
唤醒的实现会唤醒在特定通道上等待的所有进程,并且可能存在许多进程正在等待该特定通道的情况。 操作系统将调度所有这些进程,并且它们将竞相检查睡眠状况。 以这种方式运行的进程有时被称为惊群,最好避免这种情况。 大多数条件变量都有两种唤醒原语:信号(唤醒一个进程)和广播(唤醒所有等待进程)。
信号量通常用于同步。 该计数通常对应于管道缓冲区中可用的字节数或进程拥有的僵尸子进程的数量。 使用显式计数作为抽象的一部分可以避免“唤醒丢失”问题:对已发生的唤醒次数进行显式计数。 该计数还避免了虚假唤醒和惊群问题。
终止进程并清理它们在 xv6 中引入了很多复杂性。 在大多数操作系统中,它甚至更加复杂,因为,例如,被销毁进程可能在内核深处休眠,并且展开其堆栈需要非常仔细的编程。 许多操作系统使用显式的异常处理机制来展开堆栈,例如 longjmp
。 此外,还有其他事件可能导致睡眠进程被唤醒,即使它正在等待的事件尚未发生。 例如,当一个 Unix 进程正在睡眠时,另一个进程可能会向它发送信号。 在这种情况下,进程将从中断的系统调用中返回,返回值是 -1,错误代码设置为 EINTR。 应用程序可以检查这些值并决定做什么。 Xv6 不支持信号,因此不会出现这种复杂性。 Xv6 对kill 的支持并不完全令人满意:有一些睡眠循环可能应该检查p->killed
。 一个相关的问题是,即使对于检查 p->killed
的睡眠循环,睡眠和终止之间也存在竞争; 后者可能会设置 p->killed
并尝试在被销毁进程循环检查 p->killed
之后但调用 sleep
之前唤醒b被销毁进程。 如果发生此问题,受害者将不会注意到 p->killed
,直到其等待的条件发生。 这可能会晚一些(例如,当 virtio 驱动程序返回受害者正在等待的磁盘块时)或永远不会(例如,如果受害者正在等待来自控制台的输入,但用户没有键入任何输入 )。
真实的操作系统会在常数时间内找到具有显式空闲列表的空闲 proc 结构,而不是在 allocproc
中进行线性时间搜索; 为了简单起见,xv6 使用线性扫描。
7.10 练习
- 休眠必须检测
lk != &p->lock
来避免死锁(kernel/proc.c:558-561)。假设通过以下替换消除了特殊情况:
if(lk != &p->lock){
acquire(&p->lock);
release(lk);
}
替换为
release(lk);
acquire(&p->lock);
这样会不会破坏sleep
。怎么破坏的?
2. 大多数进程清理可以通过exit
或wait
来完成。 事实证明,exit
必须是关闭打开文件的那个。 为什么? 答案涉及管道
3. 在xv6中实现信号量,不使用睡眠和唤醒(但使用自旋锁是可以的)。 将 xv6 中睡眠和唤醒的使用替换为信号量。 判断结果。
4. 修复上面提到的kill
和sleep
之间的竞争,这样在被销毁进程的sleep
循环检查p->killed
之后但在调用sleep
之前发生的kill
会导致受害者放弃当前的系统调用。
5. 设计一个计划,以便每个睡眠循环都检查 p->killed
,以便 virtio 驱动程序中的进程在被另一个进程杀死时可以快速从 while 循环返回。
6. 修改 xv6,使其在从一个进程的内核线程切换到另一个进程的内核线程时仅使用一次上下文切换,而不是通过调度程序线程进行切换。 让步线程需要选择下一个线程本身并调用 switch
。 挑战在于防止多个内核意外执行同一线程; 正确锁定; 并避免死锁。
7. 修改 xv6 的调度程序,以在没有进程可运行时使用 RISC-V WFI(等待中断)指令。 尝试确保只要有可运行进程等待运行,WFI 中就不会暂停任何核心。
8. 锁 p->lock
保护许多常量,当查看受 p->lock
保护的特定 xv6 代码片段时,可能很难弄清楚正在强制执行哪个常量。 通过将 p->lock
拆分为多个锁来设计更干净的计划。
第八章 文件系统
文件系统的目的是组织管理和存储数据。文件系统典型的支持再用户和应用之间共享数据,同时也具有持久性,这样即使重启也不会丢失数据。
xv6的文件系统提供类Unix的文件目录和路径名(见第一章),将其数据存储再virtio磁盘上已保证其持久性(见第四章)。文件系统的实现面临以下挑战:
- 文件系统需要磁盘上数据结构来表示目录树和文件,来记录每一块内存保存的每一个文件的内容,来表示磁盘上哪些空间是空的。
- 文件系统必须支持“错误修复”。即,如果一个错误(例如,上电失败)出现,文件系统必须在重新启动后正常工作。风险是未知的错误可能打断正在更新的磁盘上数据结构,使其不再完整(比如一个块同时被文件使用又被标记为空闲)。
- 不同的进程可能同时操作文件系统,因此文件系统代码必须协调以维持常量。
- 访问磁盘相对于访问内存是非常慢的,所以文件系统必须在内存里保存一块缓存来保存常用的块。
本章的其余部分将解释 xv6 如何应对这些挑战。“directory”目录层以特殊类型的索引节点(内容是)实现
8.1 概述
xv6文件系统的实现被分为7层,如下图8.1所示。"disk"磁盘层在virtio硬件驱动上读写块。“buffer cache”层缓存链接与磁盘块和同步,保证同一时间只有一个内核进程修改保存在常用存储块上的数据。"logging"日志层允许更高层将更新包装到事务中的多个块,并确保在崩溃时自动更新块。(即全部更新或没有更新)。“Inode”索引节点层提供独立的文件,每个文件都表示为具有唯一 i 编号的 inode 和一些保存文件数据的块。“directory”目录层将每个目录实现为一种特殊的索引节点,其内容是一系列目录条目,每个目录条目包含一个文件名和 i 号。"pathname"路径名层提供分层的路径名字,比如/usr/rtm/xv6/fs.c
,并通过递归查找来实现。“File descriptor”文件描述符抽象了类Unix系统的很多资源(比如,管道,设备,文件等)使用文件系统接口,简化程序员的开发工作。
文件系统必须规划在磁盘上存储 inode 和内容块的位置。为实现这一点,xv6将磁盘划分为几个部分如图8.2所示。文件系统不会用到第0块(其中保存了启动部分)。第一块被称之为超级块,保存了文件系统的元数据(文件系统大小(以块为单位)、数据块数量、索引节点数量以及日志中的块数量)。起始于第2块是日志。日志后面是索引节点,每个块有多个节点。之后是位图块,跟踪哪些数据块正在使用中。剩下的块是数据块; 每个都在位图块中标记为空闲,或者保存文件或目录的内容。 超级块由一个名为 mkfs 的单独程序填充,该程序构建一个初始文件系统。
本章的其余部分将讨论每一层,从缓冲区高速缓存开始。 留意较低层精心选择的抽象可以简化较高层设计的情况。
8.2 高速缓存层
缓冲区高速缓存有两个工作:(1)同步对磁盘块的访问,以确保内存中只有一个块的副本,并且一次只有一个内核线程使用该副本; (2) 缓存常用的块,这样就不需要从慢速磁盘重新读取它们。 代码在bio.c中。
高速缓存层主要暴露的接口是bread
和bwrite
;前者获得一个包含可以在内存中读取或修改的块副本的buf,后者将修改后的缓冲区写入磁盘上的相应块。内核线程必须通过调用brelse
在读写完成该块后释放。高速缓存使用每个缓冲休眠锁来保证同一时间只有一个线程使用这块缓冲(也就是说每个磁盘块);bread
返回一个上锁的缓冲区,brelse
释放。
让我们回到缓冲区高速缓存。 缓冲区高速缓存具有固定数量的缓冲区来保存磁盘块,这意味着如果文件系统请求高速缓存中尚未存在的块,则缓冲区高速缓存必须回收当前保存其他块的缓冲区。 缓冲区高速缓存为新块回收最近最少使用的缓冲区。 假设最近最少使用的缓冲区是最不可能很快再次使用的缓冲区。
8.3 代码 高速缓存
缓冲区高速缓存是缓冲区的双向链表。函数binit
被main
(kernel/main.c:27)函数调用,使用静态数组 buf (kernel/bio.c:43-52) 中的 NBUF 缓冲区初始化列表。所有其它对高速缓存的访问第一是通过链表bcache.head
,而不是数组。
缓冲区有两个与其关联的状态字段。字段valid
表示缓存包含了块的拷贝。字段disk
表示缓冲区内容已经交给磁盘,这可能会改变缓冲区(例如,将数据从磁盘写入data
)。
bread
(kernel/bio.c:93)调用bget
从给定的扇区(kernel/bio.c:97)获取一段缓存。如果该缓存需要从磁盘获取,bread
调用virtio_disk_rw
在返回混村之前实现从磁盘获取。
bget
(kernel/bio.c:59)扫描缓存列表来查找给定的设备和扇区号(kernel/bio.c:65-73)的一块缓存。如果这有这么一块缓存,bget
就会申请休眠锁来获取到该缓存。之后bget
返回上锁的缓存。
如果没有给定扇区的高速缓存块,bget
必须创建一个,可能需要释放一个保存其它扇区的缓存。然后第二次烧苗缓存列表,查找一个没有使用的缓存(b->refcnt = 0
);这种缓存时可以被使用的。bget
编辑缓存块的元数据记录新的设备及扇区号,并申请它的休眠锁。需要注意的是需要使b->valid = 0
来确保bread
会从磁盘读取块数据而不是错误的使用缓存里保存的原始的内容。
重要的是,每个磁盘扇区最多有一个缓存缓冲区,以确保读取器看到写入,并且因为文件系统使用缓冲区上的锁来进行同步。bget
通过从第一个循环检测块是否缓存到第二个循环声明的块现在已被缓存(通过设定dev
, blockno
和refcnt
)持续持有bache.lock
锁来确保不会被修改。这会导致检查块是否存在以及(如果不存在)指定用于保存该块的缓冲区是原子的。
bget
在 bcache.lock
临界区之外获取缓冲区的休眠锁是安全的,因为非零 b->refcnt
可以防止缓冲区被重新用于不同的磁盘块。休眠锁保护块的缓冲区内容的读写,而bcache.lock
保护有关缓存哪些块的信息。
如果所有的缓冲区都在忙的状态,太多的进程同时的执行文件系统的调用;bget
恐慌。比较优雅的做法是使该进程在有空的缓存区之前休眠,虽然这可能会造成死锁。
一旦bread
需要读取磁盘(如需要)并将缓冲区返回给其调用者,调用者就可以独占使用该缓冲区并可以读取或写入数据字节。如果调用者修改了缓冲区,则在释放缓冲区之前需要调用bwrite
将修改的数据保存到磁盘。bwrite
(kernel/bio.c:107)调用virtio_disk_rw
与硬件交互。
当调用者使用完缓冲区,必须调用brelse
释放它(brelse
的名字是"b-release"的缩写,比较神秘但值得了解的是: Unix就是这样表示的,包括BSD, Linux, Solaris等)brelse
(kernel/bio.c:117)释放休眠锁并将该缓冲区移动到链表(kernel/bio.c:128-133)的前部。移动缓冲区的位置使链表的顺序变成按照最近使用的顺序排序(释放的意义):链表的第一个缓冲区是最近使用的,最后一个是最早使用过的。bget
的两次循环就是利用了这点:在最坏的情况下,对现有缓冲区的扫描必须处理整个列表,但是当存在良好的引用局部性时,首先检查最近使用的缓冲区(从 bcache.head
开始并跟随下一个指针)将减少扫描时间。选择要重用的缓冲区的扫描通过向后扫描(遵循上一个指针)来选择最近最少使用的缓冲区。
8.4 日志层
文件系统设计中比较有趣的一部分是错误修复。这种问题出现的原因是因为许多文件系统的操作涉及到向磁盘的多次写入,并且一系列写入后出现的崩溃会造成文件系统处于不一致的状态。比如,假设在文件截断期间发生崩溃(设置文件的长度为0,释放其内容块)。根据磁盘写入的顺序,崩溃可能会留下对标记为空闲的内容块的引用的索引节点,也可能会留下已分配但未引用的内容块。
后者相对良性,但引用已释放块的 inode 可能会在重新启动后导致严重问题。重启之后,内核可能将块分配给另外一个文件,这样我们有两个不同的文件无意中指向同一个内存块。如果xv6支持多用户,这种情况将会是一个安全问题,因为旧的文件所有者可以读写新用户创建的新文件所处的内存块。
xv6在文件系统操作时使用日志来解决这一问题。一个xv6系统调用不会直接写入磁盘上文件系统的数据结构。而是将希望写入磁盘的内容写入到磁盘上的日志中。一旦系统调用记录了所有要写入的操作,它将特殊的提交记录写入磁盘,指示日志包含完整的操作。在那个时间点,系统调用复制写入的内容到磁盘上的文件系统数据结构。在这些写入完成后,文件系统擦除磁盘上日志。
如果系统崩溃或者重启,文件系统的代码。文件系统会在运行任何程序前按照以下方法从崩溃中恢复。如果日志被标记为包含完整的操作,然后恢复代码会将所有待写入的内容写入到磁盘上文件系统属于它的位置。如果日志被标记为不包含完整的操作,恢复代码会忽略这个日志。恢复的代码已擦除日志作为结束。
为什么xv6的日志系统解决了文件系统操作时崩溃导致的文件系统问题?如果崩溃发生在操作的动作提交之前,磁盘上的日志将不会被标记成传输完成,恢复的代码就会忽略它,磁盘的状态就像是操作甚至没开始一样。如果崩溃发生在操作动作提交之后,之后恢复将重放所有操作的写入,如果操作已开始将它们写入磁盘数据结构,则可能会重复写入并覆盖它们。在任何一种情况下,日志都会使操作相对于崩溃而言是原子的:恢复后,操作的所有写入要么都出现在磁盘上,要么都不出现。
8.5 日志的设计
日志驻留在超级块中指定的已知固定位置。它由一个标头块组成,后跟一系列更新的块副本(“记录块”)。标头块包含了一个扇区号的列表,每个扇区号对应一个已记录的块,以及日志块的计数。磁盘上标头块中的计数要么为零,表明日志中没有事务,或非零,表示日志包含具有指定数量的已记录块的完整已提交传输事务。xv6在一件传输事物提交时而不是开始前写入标头块,并在将日志块复制到文件系统后清零。这样在传输事物发生过程中发生崩溃会造成计数为0;在提交事物后发生崩溃会使计数不为0.
每个系统调用的代码都指示写入序列的开始和结束,这些写入序列对于崩溃而言必须是原子的。为了允许不同进程并发执行文件系统操作,日志系统可以将多个系统调用的写入累积到一个事务中。因此,单个提交可能涉及多个完整系统调用的写入。 为了避免跨事务分割系统调用,日志系统仅在没有文件系统系统调用正在进行时提交。
一起提交多个事务的想法称为组提交。组提交减少了磁盘的操作,因为它将多个磁盘操作摊销一次提交的成本。组提交也提高了同一时间处理多个写入操作,可能使磁盘可以在单次磁盘周期一次性写入所有数据。xv6的virtio驱动不支持这种批处理,但是xv6的文件系统被设计支持这种操作。
Xv6 在磁盘上指定固定数量的空间来保存日志。在一次传输操作中的系统调用写入块的总数必须适合该空间。这有两个结果。不允许单个系统调用写入超过日志块数目的不同的块。这对于大部分的系统调用是没影响的,但是其中两个系统调用可能会写入多个块:write
和unlink
。一个大文件的写入可能会写入许多的块,许多的位图,同时许多的节点块;释放一个大文件可能会写入许多的位图块和节点块。xv6的写入系统调用可以将一次大的写入操作拆分成几个小的写入来适应日志,xv6中unlink
操作则不会有这个问题,作为练习的xv6系统的文件系统仅用了一个位图块。有限的日志块引起的另外的问题是日志系统无法允许系统调用启动,除非确定系统调用的写入适合日志中的剩余空间。
8.6 代码:日志
系统调用中日志的典型用法如下:
begin_op();
...
bp = bread(...);
bp->data[...] = ...;
...
end_op();
begin_op
(kernel/log.c:126)需要等待日志系统没有正在被占用着提交,并且等到有足够的空间来写入这次系统调用所用到的空间。log.outstanding
记录了系统调用预定的日志空间的数量。总预留的空间是log.outstanding
倍的MAXOPBLOCKS。增加 log.outstanding 既可以保留空间,又可以防止在此系统调用期间发生提交。 该代码保守地假设每个系统调用最多可以写入 MAXOPBLOCKS 个不同的块。
log_write
(kernel/log.c:214)充当bwrite
的代理。它在内存中记录块的扇区号,在磁盘上的日志中为其保留一个槽,并将缓冲区固定在块缓存中以防止块缓存将其覆盖。块在提交之前必须保持在缓存中:在那之前,缓存中的备份是唯一记录修改的地方;在提交之前不会写入到磁盘中文件所处的位置;并且同一事物中其它的修改必须看到该修改。log_write
会注意到在单个事务期间某个块被多次写入的情况,并为该块分配日志中的同一槽。这种优化被称为“absorption”。例如,常见的是,包含多个文件的 inode 的磁盘块在一个事务中被写入多次。通过将多个磁盘写入吸收为一个,文件系统可以节省日志空间并可以获得更好的性能,因为只需将磁盘块的一份副本写入磁盘。
end_op
(kernel/log.c:146)首先减少未完成的系统调用的计数。如果现在计数为0.它将会通过调用commit()
来提交当前的事务。这个过程中会有4哥部分。write_log()
(kernel/log.c:178)从高速缓存复制事务中的每一个块的修改到磁盘上日志的槽中。````write_head()(kernel/log.c:102)向磁盘写入头部块:这是提交的点,写入后的崩溃将导致恢复重放日志中事务的写入。
install_trans(kernel/log.c:69)从日志中读取每个块并将其写入文件系统中的正确位置。 最后
end_op写入计数为零的日志头; 这必须在下一个事务开始写入记录块之前发生,这样崩溃就不会导致使用一个事务的标头和后续事务的记录块进行恢复。
recover_from_log(kernel/log.c:116)被
initlog(kernel/log.c:55)调用,此函数是在启动阶段第一个进程执行之前(kernel/proc.c:539)中
fsinit调用的。它会读取log的标头,如果标头指示日志包含已提交的事务,则模仿
end_op的操作。 日志的使用示例出现在
filewrite```(kernel/file.c:135) 中。 操作过程如下:
begin_op();
ilock(f->ip);
r = writei(f->ip, ...);
iunlock(f->ip);
end_op();
该代码包含在一个循环中,该循环将大型写入一次分解为几个扇区的单独事务,以避免日志溢出。 对 writei
的调用会写入许多块作为此事务的一部分:文件的索引节点、一个或多个位图块以及一些数据块。
8.7 块分配器
文件和目录内容存储在磁盘块中,必须从空闲池中分配磁盘块。xv6 的块分配器在磁盘上维护一个空闲位图,每个块一位。0代表对应的块是空闲的;1表示该块被使用。程序 mkfs
设置与引导扇区、超级块、日志块、索引节点块和位图块相对应的位。
块分配器提供两个函数:balloc
分配新的磁盘块,bfree
释放一个块。balloc
(kernel/fs.c:71) 中 balloc
中的循环考虑每个块,从块 0 开始一直到sb.size
(文件系统中的块数)。它会查找在位图中表示为0的块,表示这个块是空闲的。如果balloc
找到这么一个块,会更新位图并返回这个块。为了提高效率,循环分为两部分。外面的循环会读取位图中每个块的位。内部循环检查单个位图块中的所有 BPB 位。由于缓冲区高速缓存一次只允许一个进程使用任意一个位图块,因此可以避免两个进程同时尝试分配一个块时可能发生的竞争。
bfree
(kernel/fs.c:90) 找到正确的位图块并清除正确的位。 同样,bread
和 brelse
隐含的独占使用避免了显式锁定的需要。
与本章其余部分描述的大部分代码一样,必须在事务内部调用 balloc
和 bfree
。
8.8 索引节点层
术语"inode" 可以具有两个相关含义之一。它可能指的是包含文件大小和数据块编号列表的磁盘数据结构。 或者“inode”可能指的是内存中的inode,其中包含磁盘上的inode 的副本以及内核中所需的额外信息。
磁盘上的索引节点被打包到称为索引节点块的磁盘连续区域中。每一个索引节点的大小一致,所以很简单,赋值一个编号n,就可以查找磁盘上第n个索引节点。事实上,这个数字n,称为inode
编号或i-number
,是在实现中识别inode
的方式。
磁盘 inode
由 struct dinode
(kernel/fs.h:32) 定义。type
字段区分文件、目录和特殊文件(设备)。类型0表示磁盘上索引节点是空闲的。nlink
字段计算引用该 “inode” 的目录条目的数量,以便识别何时应释放磁盘 “inode” 及其数据块。 size
字段记录了文件内容的字节数。 addrs
数组记录保存文件内容的磁盘块的块号。
内核将活动索引节点集保存在内存中;struct inode
(kernel/file.h:17)是磁盘上 struct dinode
的内存中副本。内核只有在C指针指向那个索引节点的时候在内存中保存一个索引节点。ref
字段计算引用内存中 inode
的 C 指针的数量,如果引用计数降至零,则内核会从内存中丢弃该 inode
。iget
和iput
获取和释放指向索引节点的指针,修改引用的计数。指向索引的节点指针可以来自文件描述符,当前工作的文件夹或者瞬时内核代码,例如exec
。
在xv6的索引节点代码里,有4个锁或者类锁的机制。icache.lock
保护 inode
最多在缓存中出现一次的不变量,以及缓存 “inode” 的 ref
字段计算内存中指向缓存 “inode” 的指针数量的不变量。每个内存中索引节点都有一个lock
字段,包含一个休眠锁,这确保了对 inode 字段(例如文件长度)以及 inode 的文件或目录内容块的独占访问。一个索引节点的ref
,如果比0大,表示系统在缓存中维护该 inode,并且不会将缓存条目重新用于不同的 inode。最后,每个 inode 都包含一个 nlink
字段(在磁盘上,如果已缓存,则复制到内存中),用于计算引用文件的目录条目的数量; 如果链接计数大于零,xv6 将不会释放索引节点。
iget()
返回的struct inode
指针保证在对应的 iput() 调用之前有效;inode 不会被删除,并且指针引用的内存也不会被重新用于不同的 inode。iget()
提供对 inode 的非独占访问,因此可以有多个指向同一 inode 的指针。文件系统代码的许多部分都依赖于 iget()
的这种行为,既可以保存对 inode 的长期引用(如打开的文件和当前目录),也可以防止竞争,同时避免操作多个 inode 的代码中的死锁(例如 路径名查找)。
iget
返回的 struct inode
可能没有任何有用的内容。为了确定它持有的是磁盘上索引节点的副本,代码必须调用ilock
。这会给该索引节点上锁(以便于其它进程可以对它ilock
)并且从磁盘读取inode(如果尚未读取)。iunlock
释放inode上的锁。分开获取inode的锁可以在一些情况下避免死锁,比如,在路径查找时。多进程通过iget
可以持有一个指向一个inode的C指针,但只有一个进程可以在一个时间给该inode上锁。
inode 缓存仅缓存内核代码或数据结构保存 C 指针的 inode。它的主要工作实际上是同步多个进程的访问; 缓存是次要的。如果一个索引节点是常被访问的,如果索引节点缓存没有保存它,缓冲区缓存可能会将其保留在内存中。inode 缓存是直写式的,这意味着修改缓存 inode 的代码必须立即使用 iupdate
将其写入磁盘。
8.9 代码:索引节点
分配一个新的索引节点(比如创建一个文件),xv6调用ialloc
(kernel/fs.c:196)。ialloc
与balloc
类似;它遍历磁盘上的索引节点结构体,一次一个块,找到一个标记为空闲的。当它找到一个,它通过将新类型写入磁盘来声明它,然后通过尾部调用 iget
(kernel/fs.c:210) 从 索引节点缓存返回一个条目。ialloc
的正确操作取决于这样一个事实:一次只有一个进程可以持有对 bp
的引用:ialloc
可以确保某些其他进程不会同时看到该索引节点可用并尝试声明它。
iget
(kernel/fs.c:243)通过 inode 缓存查找具有所需设备和 inode 编号的活动条目 (ip->ref > 0),如果找到,则返回对此索引节点的一个新的引用(kernel/fs.c:252-256)。如iget
扫描,它记录了第一个空槽(kernel/fs.c:257-258)的位置,这将在有需要的时候分配一个缓存入口。
代码必须通过ilock
在读写它的元数据或者内容时锁定索引节点。ilock
(kernel/fs.c:289)用一个睡眠锁实现。一旦ilock
获取到锁并独享对索引节点的链接,如果需要将会从磁盘读取索引节点(更有可能是在高速缓存里)。函数iunlock
(kernel/fs.c:317)释放睡眠锁,这可能会唤醒一些等待该锁的进程。
iput
(kernel/fs.c:333)通过减少引用计数(kernel/fs.c:356)来释放指向节点的C指针。如果是最后的一次引用,节点缓存里的节点槽现在就会是空闲的,可以被另外其它的节点使用。
如果iput
(kernel/fs.c:356)发现没有指向该节点的C指针,并且该索引节点没有指向它的链接(没出现在目录中),则必须释放它的数据块及该索引节点。iput
调用itrunc
去截断文件到长度0,释放数据块;设置索引节点的类型为0(未分配);并将索引节点写入磁盘(kernel/fs.c:338)。
iput
中释放 索引节点的情况下的锁的协议值得仔细研究。一种危险的情况是同时进行的线程可能正在等待ilock
以使用该索引节点(例如读取一个文件或者列出目录下的文件),没有准备好就发现访问的索引节点已经不在被分配空间了。这不会发生因为系统调用获取指向一个缓存中索引节点的指针时如果没有对它的链接或者ip->ref
是1的时候是不能获取的。这一个引用时调用iput
线程的引用。确实,iput
检查引用计数是否在其 icache.lock
临界区之外,但此时已知链接计数为零,因此没有线程会尝试获取新引用。另外一个比较危险的点是,同时进行的调用ialloc
的进程可能会选择iput
释放的相同的线程。这只能发生在iupdate
函数写入磁盘之后索引节点类型变成0之后。这种资源的竞争是良性的;分配线程在读取或者写入索引节点之前都会礼貌的等待获取索引节点的睡眠锁,等运行的时候iput
函数已经执行完成。
iput
函数可以写入到磁盘。这意味着任何用到文件系统的系统调用都可能会写磁盘,因为该系统调用可能是最后一个引用该文件的系统调用。甚至以只读形式调用的read()
函数,也可能以iput()
函数的调用结束。反过来,这意味着即使是只读系统调用,如果使用文件系统,也必须包装在事务中。
iput()
和崩溃之间存在具有挑战性的交互。iput()
不会立即裁剪文件当链接到文件的数量到0时,因为一些进程可能在内存中持有对其索引节点的引用:因为已经被成功打开,一个进程可能仍在读或写文件。但如果在最后一个进程关闭文件的文件描述符之前发生崩溃,文件将会被标记为在磁盘上分配了空间但没有路径条目指向它。
文件系统以两种方式之一处理这种情况。最简单的解决办法是恢复,重启之后,文件系统程序扫描整个指向文件的文件系统中的被标记为已分配的文件,并且没有目录条目来指向它。如果存在类似的文件,就释放它。
第二个解决办法不需要扫描文件系统。在这个方法里,文件系统记录在磁盘(比如,超级块)上文件的索引节点号(该文件的链接数量将会被设置成0,但被引用的数量不为0),当文件系统删除了这个文件,即引用次数降为0,就会通过从列表中删除索引节点来更新磁盘上的列表。通过第一条恢复的方法,文件系统需释放列表里的文件。
Xv6没有实现上诉任何的方法,这意味着索引节点可能在磁盘上被标记为已分配,虽然可能甚至都没有被使用。这意味着可能一段时间后磁盘就会被占满空间。
8.10 代码:索引节点内容
磁盘上的索引节点结构:struct dinode
,包括一个大小和块号的数组(如图8.3)。索引节点的数据内容包含在dinode
的addrs
数组里。第一个NDIRECT
数据块列出了数组中第一个NDIRECT
条目;这些块称之为直接块(direct blocks)。接着的NINDIRECT
数据块列出了不在索引节点但是在一个数据块中,称之为“间接块”(indirect block)。数据地址数组中的最后一个条目给出了间接块条目的地址。这样,文件的前12kB(NDIRECT x BSIZE)字节数据可以从索引节点结构体中列出的块中加载,而接下来的 256 kB (NINDIRECT x BSIZE) 字节只能在查阅间接块后加载。这是一种很好的磁盘表示形式,但对于客户端来说却很复杂。函数 bmap
管理映射,以便执行更高级别的例程,例如 readi
和 writei
,我们很快就会看到。 bmap
返回索引节点ip 的第 bn 个数据块的磁盘块号。 如果 ip 还没有这样的块,bmap 会分配一个。
函数bmap
(kernel/fs.c:378)首先选择简单的情况:在索引节点自身列出NDIRECT
块(kernel/fs.c:383:387)。接着的NINDIRECT
块被放在NDIRECT
块的ip->addrs[NDIRECT]
。bmap
读取直接块(kernel/fs.c:394)然后从块的正确位置读到块号 (kernel/fs.c:395)。如果块号超过了NDIRECT+NINDIRECT
,bmap
就会恐慌报错;writei
包含了这一项检查并阻止这种情况的发生。(kernel/fs.c:490)。
bmap
根据需求分配块。ip->addrs[]
或间接条目为零表示没有分配块。当bmap
遇到0时,它将它们替换为按需分配的新块数 (kernel/fs.c:384-385) (kernel/fs.c:392-393)。
itrunc
释放文件的块,设置索引节点的大小为0. itrunc
(kernel/fs.c:410)开始于释放直接块(kernel/fs.c:416:421),然后是在间接块列表里的块(kernel/fs.c:426-429),然后释放间接块本身(kernel/fs.c:431-432)。
bmap
使函数readi
和writei
轻易的获取到索引节点的数据。readi
(kernel/fs.c:456)开始时会确定偏移量和读取块的数量不会超出文件的结尾。偏移量如果超出文件结尾时会返回一个错误(kernel/fs.c:461-462),而偏移量没有超出文件结尾但是读取的数量超出文件结尾时,会返回比请求的要少的字节数(kernel/fs.c:463-464)。主循环处理文件的每个块,将数据从缓冲区复制到 dst
(kernel/fs.c:466-474)。writei
和readi
相同,3点需要注意:写入的时候偏移量或者写入的块数超过文件的末尾时,会使文件变大增加最大文件大小(kernel/fs.c:490-491);循环是复制到缓存而不是复制出;最后一个是如果写扩展了文件writei
必须修改索引节点里的大小(kernel/fs.c:504-511)。
readi
和writei
都同样开始于检查ip->type == T_DEV
。这种情况处理数据不在文件系统中的特殊设备; 我们将回到文件描述符层的这种情况。
函数stati
(kernel/fs.c:442)复制索引节点的元数据到stat
结构体,该数据结构通过stat
系统调用暴露给用户程序。
8.11 代码:目录层
目录的内部实现与文件十分相似。它的索引节点的类型是T_DIR
,它的数据是目录条目的序列。每个条目是结构体struct dirent
(kernel/fs.h:56),包含名字和索引节点号。名字最多DIRSIZ
(14)个字符,至于最短,可以以NUL
(0) 结束。节点号为0的目录条目是空闲的。
函数dirlookup
(kernel/fs.c:527)根据给定的名字查找目录的条目。如果查找到,就会返回相应的目录的索引节点的指针,解锁,设置*poff
到目录内条目的偏移字节数,以防止调用者试图去修改它。如果dirlookup
用正确的名字查找到了,则会更新*poff
并通过iget
返回获得的未上锁的索引节点。dirlookup
是iget
返回未上锁的索引节点的原因。调用者已经对dp
上锁,所以查找的如果是".“(当前目录的别名)目录,试图在返回之前锁上索引节点会造成对dp
的重复上锁,而造成死锁。(还有更复杂的死锁场景,涉及多个进程和 “…”(父目录的别名;”." 不是唯一的问题。)) 调用者可以先释放dp
之后在给ip
上锁,确保一个时刻只持有一个锁。
函数dirlink
(kernel/fs.c:554)将一个给定名称和索引节点号的新的目录条目放进目录dp
。如果名称已经存在,dirlink
返回一个错误(kernel/fs.c:560-564)。主循环读取目录条目来查找一个未分配的条目。当找到一个后会提前终止循环(kernel/fs.c:538-539),并且off
记录可用条目的偏移量。否则,循环将off
设置为dp->size
。无论哪种方式,dirlink
通过写入偏移量off
在目录中添加新的条目(kernel/fs.c:574-577)。
8.12 代码:路径名
路径名查找涉及对dirlookup
的一系列调用,每个路径组件调用一次。namei
(kernel/fs.c:661)解析路径并返回对应的索引节点。nameiparent
是一个变体:它在最后一个元素之前停止,返回父目录的索引节点并将最终元素复制到name
。两者都调用通用函数 namex
来完成实际工作。
namex
(kernel/fs.c:626) 首先决定路径解析从哪里开始。 如果路径以斜线开头,则从根目录开始求值; 否则,当前目录(kernel/fs.c:630-633)。然后它使用skipelem
来按顺序考虑路径里的每一个元素(kernel/fs.c:635)。循环的每一次都必须在当前索引节点ip
中查找name
。迭代从锁定 ip
并检查它是否是一个目录开始。 如果不是,则查找失败 (kernel/fs.c:636-640)。 (锁定 ip
是必要的,不是因为 ip->type
可以改变(事实上它不能变化)而是因为在 ilock
运行之前,不能保证 ip->type
已从磁盘加载。)如果调用者是nameparent
并且是路径中最后一个元素,循环会提前结束,根据 nameiparent
的定义; 最终的路径元素已经被复制到name
中,因此namex
只需要返回解锁的ip
(kernel/fs.c:641-645)。最后,循环使用 dirlookup
查找路径元素,并通过设置 ip = next
(kernel/fs.c:646-651) 为下一次迭代做准备。 当循环用完路径元素时,它返回 ip
。
过程 namex
可能需要很长时间才能完成:它可能涉及多个磁盘操作来读取路径名中遍历的目录的 索引节点和目录块(如果它们不在缓冲区高速缓存中)。Xv6 经过精心设计,如果一个内核线程对 namex
的调用在磁盘 I/O 上被阻塞,则查找不同路径名的另一个内核线程可以同时进行。namex
单独锁定路径中的每个目录,以便不同目录中的查找可以并行进行。
这种并发带来了一些挑战。 例如,当一个内核线程正在查找路径名时,另一个内核线程可能会通过取消目录链接来更改目录树。 潜在的风险是查找可能正在搜索已被另一个内核线程删除的目录,并且其块已被重新用于另一个目录或文件。
Xv6 避免了这样的竞争。 例如,在 namex
中执行 dirlookup
时,查找线程持有目录上的锁,并且 dirlookup
返回使用 iget
获取的 索引节点。 iget
增加索引节点的引用计数。 只有在从dirlookup
收到索引节点后,namex
才会释放目录上的锁。 现在另一个线程可能会取消该索引节点与目录的链接,但 xv6 还不会删除该 索引节点,因为该 索引节点的引用计数仍然大于零。
另外一个风险是死锁。例如查找“.”时next
指向与ip
相同的索引节点。 在释放ip
的锁定之前锁定next
导致死锁。 为了避免这种死锁,namex
获取next
锁之前先解锁该目录。 在这里我们再次看到为什么 iget
和 ilock
之间的分离很重要。
8.13 文件描述符层
Unix接口一个非常酷的方面是把所有资源都作为文件的形式表示,包括像控制台管道等设备,当然也包括真实的文件。文件描述符层是实现这样一种一致性的一层。
如第一章中所讲,xv6给每一个进程一个单独的表来保存打开的文件,或者文件描述符。每一个打开的文件由数据结构struct file
(kernel/file.h:1)表示,里面包含了一个索引节点或者管道,外加I/O的偏移量。每一个open
函数的调用创建一个新的打开的文件(新的struct file
):如果多个进程打开同一个文件,不同的进程将会有不同的I/O偏移量。另一方面,单独打开一个文件可以在一个进程的文件列表里面出现多次,也可以出现在多进程的多个文件列表。如果一个进程使用open
打开文件,然后使用dup
创建别名或使用 fork
与子进程共享该文件,就会发生这种情况。引用计数跟踪对特定打开文件的引用数量。 文件可以打开以进行读取或写入或两者兼而有之。 可读和可写字段对此进行跟踪。
系统中所有被打开的文件都维护在一个全局文件列表里,叫做ftable
。文件表有分配文件的函数(filealloc
),创建一个复制的引用(filedup
),释放一个引用(fileclose
),和读写数据的函数(fileread
和fileread
)。
前三个遵循现在熟悉的形式。filealloc
(kernel/file.c:30)扫描文件表格查找未被引用的文件(f->ref == 0
),然后返回一个新的引用;filedup
(kernel/file.c:48)增加一个引用值;fileclose
(kernel/file.c:60)减少引用值。当文件的引用值的数量减少为0时,fileclose
根据类型释放占用的管道和索引节点。
函数filestat
, fileread
, 和filewrite
实现了文件操作的stat
, read
, write
。filestat
(kernel/file.c:88)仅被允许用在索引节点上,并调用stati
。fileread
和filewrite
通过打开的模式检查操作的合理性,然后将操作透传到管道或者索引节点来具体实现。如果操作的文件是索引节点,fileread
或者filewrite
通过I/O偏移进行操作,然后再增加偏移量的数值(kernel/file.c:122-123)(kernel/file.c:153-154)。管道没有偏移量的概念。回想一下,索引节点函数要求调用者处理锁(kernel/file.c:94-96) (kernel/file.c:121-124) (kernel/file.c:163-166)。索引节点的锁有比较好用的一面,即读取和写入偏移量会自动更新,因此同时对同一文件进行多次写入不会覆盖彼此的数据,尽管它们的写入可能最终会交错。
8.14 代码:系统调用
使用较低层提供的函数,大多数系统调用的实现都是微不足道的(参见(kernel/sysfile.c))。 有一些调用值得仔细研究。
函数sys_link
和函数sys_unlink
编辑目录,创建或者去除对索引节点的引用。他们是使用交易的力量的一个很好的例子。sys_link
(kernel/sysfile.c:120)首先分析参数,两个字符串: old
和new
(kernel/sysfile.c:125)。假设old
存在且不是一个目录(kernel/sysfile.c:129-132),sys_link
增加它的ip->nlink
的数值。然后sys_link
调用nameiparent
来查找父目录和new
(kernel/sysfile.c:145)中最后的路径元素,之后会在old
的索引节点(kernel/sysfile.c:148)中创建一个新的目录条目点。新的父路径必须存在并且与已存在的索引节点在同一设备上:索引节点号仅在单一设备上具有唯一含义。如果像这样的错误发生,sys_link
必须返回减少ip->nlink
的数值。
事务简化了实现,因为它需要更新多个磁盘块,但我们不必担心执行它们的顺序。他们要么全部成功,要么一无所获。 例如,如果没有事务,在创建链接之前更新 ip->nlink
会使文件系统暂时处于不安全状态,而其间的崩溃可能会导致严重破坏。 对于交易,我们不必担心这个问题。
sys_link
为已存在的索引节点创建一个新的名字。函数create
(kernel/sysfile.c:242)为新的索引节点创建一个新的名字。它是三个文件创建系统调用的概括:open
加一个O_CREATE
标志的函数创建一个新的文件,mkdir
创建一个新的目录,mkdev
创建一个新的设备文件。像sys_link
,create
开始时调用nameiparent
获取父目录的索引节点。然后调用dirlookup
来检查文件名是否已经存在(kernel/sysfile.c:252)。如果文件名存在,create
的反应取决于系统调用的用处:open
相对于mkdir
和mkdev
有不同的语义。如果create
被用来一半是open
(type == T_FILE) 并存在的文件名本身是一个常规的文件,之后open
将会被作为打开成功,索引create
也一样(kernel/sysfile.c:256)。否则,将会被认为是一个错误(kernel/sysfile.c:257-258)。如果文件不存在,create
将在“…”目录上创建新的索引节点条目。最后,数据已正确初始化,create
可以将其链接到父目录 (kernel/sysfile.c:274)。create
与 sys_link
一样,同时持有两个索引节点锁:ip
和 dp
。
使用create
,很简单的实现sys_open
, sys_mkdir
, 和sys_mknod
。sys_open
(kernel/sysfile.c:287) 是最不容易实现的,因为创建一个新文件仅仅是它功能的一小部分。如果open
传入了O_CREATE
标志位,它会调用create
(kernel/sysfile.c:301)。否则,它会调用namei
(kernel/sysfile.c:307)。create
返回一个上锁的索引节点,而namei
不会,所以sys_open
必须锁定索引节点本身。这会提供便利的方式来检查打开目录是为了读,而并不是写。假设索引节点是通过一种或另一种方式获得的,sys_open
会分配一个文件和一个文件描述符 (kernel/sysfile.c:325),然后填充该文件 (kernel/sysfile.c:337-342)。 请注意,其他进程无法访问部分初始化的文件,因为它仅位于当前进程的表中。
第 7 章在我们拥有文件系统之前研究了管道的实现。函数sys_pipe
通过提供创建管道对的方法将该实现连接到文件系统。它的参数是一个指向两个整数空间的指针,它将在其中记录两个新的文件描述符。 然后它分配管道并安装文件描述符。
8.15 真实世界
实际操作系统中的缓冲区高速缓存比 xv6 的缓冲区高速缓存复杂得多,但它具有相同的两个目的:高速缓存和同步对磁盘的访问。Xv6 的缓冲区高速缓存与 V6 一样,使用简单的最近最少使用 (LRU) 驱逐策略;有许多更复杂的政策可以实施,每种政策都适合某些工作负载,但不适合其他工作负载。更高效的 LRU 缓存将消除链表,而是使用哈希表进行查找并使用堆进行 LRU 逐出。 现代缓冲区高速缓存通常与虚拟内存系统集成以支持内存映射文件。
Xv6 的日志系统效率低下。提交不能与文件系统系统调用同时发生。 系统会记录整个块,即使块中仅更改了几个字节。 它执行同步日志写入,一次一个块,每个写入可能需要整个磁盘旋转时间。 真正的日志系统可以解决所有这些问题。
日志记录并不是提供崩溃恢复的唯一方法。 早期的文件系统在重新引导期间使用清除程序(例如,UNIX fsck
程序)来检查每个文件和目录以及块和索引节点空闲列表,查找并解决不一致的问题。 对于大型文件系统,清理可能需要数小时,并且在某些情况下无法以导致原始系统调用成为原子的方式解决不一致问题。 从日志中恢复要快得多,并且导致系统调用在崩溃时成为原子调用。
Xv6 使用与早期 UNIX 相同的基本磁盘索引节点和目录布局; 多年来,这一计划一直非常持久。BSD的UFS/FFS和Linux的ext2/ext3的使用
本质上是相同的数据结构。文件系统布局中效率最低的部分是目录,它需要在每次查找期间对所有磁盘块进行线性扫描。当目录只有几个磁盘块时,这是合理的,但对于保存许多文件的目录来说,这是昂贵的。 Microsoft Windows 的 NTFS、Mac OS X 的 HFS 和 Solaris 的 ZFS(仅举几例)将目录实现为磁盘上平衡的块树。 这很复杂,但保证了对数时间的目录查找。
Xv6 对于磁盘故障很天真:如果磁盘操作失败,xv6 就会出现恐慌。 这是否合理取决于硬件:如果操作系统位于使用冗余来掩盖磁盘故障的特殊硬件之上,那么操作系统可能很少会看到故障,因此出现恐慌是可以接受的。 另一方面,使用普通磁盘的操作系统应该预料到故障并更妥善地处理它们,以便一个文件中块的丢失不会影响文件系统其余部分的使用。
Xv6 要求文件系统适合一个磁盘设备并且大小不变。 随着大型数据库和多媒体文件对存储的要求越来越高,操作系统正在开发消除“每个文件系统一个磁盘”瓶颈的方法。 基本方法是将许多磁盘组合成单个逻辑磁盘。 RAID 等硬件解决方案仍然是最流行的,但当前的趋势是尽可能在软件中实现这种逻辑。 这些软件实现通常允许丰富的功能,例如通过动态添加或删除磁盘来增大或缩小逻辑设备。 当然,可以动态增长或收缩的存储层需要一个可以执行相同操作的文件系统:xv6 使用的固定大小的索引节点块数组在这种环境中无法正常工作。 将磁盘管理与文件系统分离可能是最简洁的设计,但两者之间的复杂接口导致一些系统(例如 Sun 的 ZFS)将它们结合起来。
Xv6 的文件系统缺乏现代文件系统的许多其他功能; 例如,它缺乏对快照和增量备份的支持。
现代 Unix 系统允许使用与磁盘存储相同的系统调用来访问多种资源:命名管道、网络连接、远程访问的网络文件系统以及监视和控制接口(例如/proc
)。 这些系统通常为每个打开的文件提供一个函数指针表,每个操作一个,而不是 xv6 在 fileread
和filewrite
中的 if 语句,并调用函数指针来调用该 索引节点的调用实现。 网络文件系统和用户级文件系统提供将这些调用转换为网络 RPC 并在返回之前等待响应的功能
8.16 练习
- 为什么
balloc
会恐慌,xv6能恢复吗? - 为什么
ialloc
会恐慌,xv6能恢复吗? - 为什么在运行出界文件时,
filealloc
不会恐慌?为什么这种情况更常见并且值得处理? - 假设在
sys_link
调用iunlock(ip)
和dirlink
之间,另一个进程取消了与ip
对应的文件的链接。 链接会正确创建吗? 为什么或者为什么不? create
会进行四次函数调用(一次调用ialloc
,三次调用dirlink
),只有调用这些函数才能成功。 如果没有,就会造成调用恐慌。 为什么这是可以接受的? 为什么这四个调用都不会失败?sys_chdir
在输入(cp->cwd
)之前调用iunlock(ip)
,这可能会尝试锁定cp->cwd
,但将iunlock(ip)
推迟到输入之后不会导致死锁。 为什么不?- 实现
lseek
系统调用。 如果lseek
超出f->ip->size
,支持lseek
还需要您修改filewrite
以用零填充文件中的漏洞。 - 给函数
open
添加O_TRUNC
和O_APPEND
标志位参数的支持,以便于在shell中实现">“和”>>"。 - 修改文件系统使其支持软链接。
- 修改文件系统使其支持命名管道。
- 修改文件和VM系统以支持内存映射文件。
第九章 重温并发
同时获得良好的并行性能、并发时的正确性以及可理解的代码是内核设计中的一大挑战。 直接使用锁是实现正确性的最佳途径,但并不总是可行。 本章重点介绍 xv6 被迫以复杂方式使用锁的示例,以及 xv6 使用类锁技术但不使用锁的示例。
9.1 锁定模式
缓存的项目通常很难锁定。例如,文件系统的块缓存 (kernel/bio.c:26) 存储最多 NBUF 磁盘块的副本。给定的磁盘块在缓存中最多有一个副本至关重要; 否则,不同的进程可能会对本应是同一块的不同副本进行冲突的更改。每一个缓存块保存在结构体struct buf
(kernel/buf.h:1)中。一个struct buf
有一个锁来保证一个时刻只有一个进程使用给定的磁盘块。然而,那个锁时不够的:如果缓存中根本不存在某个块,并且两个进程想要同时使用它怎么办?现在还没有结构体struct buf
(因为块还没有被缓存),所以没有东西可以上锁。Xv6 通过将附加锁 (bcache.lock
) 与缓存块的标识集相关联来处理这种情况。代码需要检查一个块是否被缓存(比如,bget
(kernel/bio.c:59)),或者更改缓存块的合集,必须持有bcache.lock
;在那之后才能找到它需要的块和struct buf
,它可以释放 bcache.lock
并仅锁定特定块。这是一种常见模式:一组项目一把锁,每个项目一把锁。
通常,获取锁的同一函数也会释放它。但更精确的看待事物的方法是,在必须呈现原子性的序列开始时获取锁,并在该序列结束时释放锁。如果序列在不同的函数、不同的线程或不同的 CPU 上开始和结束,则锁获取和释放必须执行相同的操作。锁的作用是强制其他用途等待,而不是将一条数据固定到特定的代理。一个例子是yield
kernel/proc.c:515)中的acquire,它是在调度程序线程中而不是在获取过程中释放的。 另一个例子是 ilock
中的 acquiresleep
(kernel/fs.c:289); 这段代码经常在读磁盘时休眠; 它可能在不同的CPU上唤醒,这意味着锁可能在不同的CPU上获取和释放。
释放受嵌入在对象中的锁保护的对象是一件微妙的事情,因为拥有锁并不足以保证释放是正确的。 当其他线程正在等待获取使用该对象时,就会出现问题; 释放对象会隐式释放嵌入的锁,这将导致等待线程发生故障。 一种解决方案是跟踪存在多少个对该对象的引用,以便仅在最后一个引用消失时才释放该对象。 有关示例,请参见 pipelineclose
(kernel/pipe.c:59); pi->readopen
和 pi->writeopen
跟踪管道是否有文件描述符引用它。
9.2 类锁模式
在很多地方xv6用到一个引用计数或者一个标志位作为一种软锁定的的方法来表明项目已经被分配并且不能被释放或者复用。进程的 p->state
以这种方式起作用,文件、索引节点和 buf
结构中的引用计数也是如此。虽然在每种情况下锁都会保护标志或引用计数,但后者可以防止对象过早释放。
文件系统使用 struct inode
引用计数作为一种可由多个进程持有的共享锁,以避免代码使用普通锁时出现的死锁。例如,namex
(kernel/fs.c:626) 中的循环依次锁定每个路径名组件命名的目录。 然而,namex 必须在循环结束时释放每个锁,因为如果它持有多个锁,如果路径名包含点(例如,a/./b),它可能会与自身发生死锁。 它还可能因涉及目录的并发查找而死锁"…".正如第 8 章所解释的,解决方案是循环将目录索引节点传递到下一次迭代,其引用计数递增,但不锁定。
某些数据项在不同时间由不同机制保护,并且有时可能通过 xv6 代码的结构而不是显式锁来隐式地防止并发访问。比如,当一个物理页是空闲的时候,被kmem.lock
(kernel/kalloc.c:24)保护起来。如果这个页被分配作为管道(kernel/pipe.c:23),会被不同的页保护(嵌入的pi->lock
)。如果这个页被重新分配作为用户内存,将不会被一个锁保护。相反,分配器不会将该页面提供给任何其他进程(直到它被释放),这一事实可以保护它免受并发访问。新进程内存的所有权很复杂:首先父进程在 fork
中分配和操作它,然后子进程使用它,并且(子进程退出后)父进程再次拥有内存并将其传递给 kfre
e。 这里有两个教训:数据对象可以在其生命周期的不同点以不同的方式受到并发保护,并且保护可以采取隐式结构的形式而不是显式锁的形式。 最后一个类似锁的示例是需要禁用对 mycpu()
(kernel/proc.c:68) 调用的中断。 禁用中断会导致调用代码对于计时器中断而言是原子的,这可能会强制进行上下文切换,从而将进程移动到不同的 CPU。
9.3 根本没有锁
有一些地方 xv6 完全不加锁地共享可变数据。其中之一是自旋锁的实现,尽管人们可以将 RISC-V 原子指令视为依赖于硬件中实现的锁。另外一个是main.c
中的started
变量(kernel/main.c:7),用于防止其他CPU运行,直到CPU 0完成初始化xv6;易失性确保编译器实际生成加载和存储指令。 第三个是 proc.c
(kernel/proc.c:398) (kernel/proc.c:306) 中 p->parent
的一些使用,其中正确的锁定可能会死锁,但似乎很清楚没有其他进程可以同时修改 p->parent
。 第四个例子是 p->killed
,它是在持有 p->lock
时设置的 (kernel/proc.c:611),但在没有持有锁的情况下进行检查 (kernel/trap.c:56)。
Xv6 包含一个 CPU 或线程写入一些数据,而另一个 CPU 或线程读取数据的情况,但没有专门用于保护该数据的特定锁。例如,在fork
里,父进程写子进程的用户内存页,子进程(不同的线程,可能运行在不同的CPU上)读取这些页;没有锁明确的保护这个界面。严格来说,这并不是一个锁定问题,因为子进程直到父进程完成写入后才开始执行。这是一个潜在的内存排序问题(详见第六章),因为没有一个内存屏障所以没有理由期望一个CPU看到另一个CPU的写入。但是,由于父进程释放锁,而子进程在启动时获取锁,因此获取和释放中的内存屏障可确保子进程的 CPU 看到父进程的写入。
9.4 并行性
锁的作用本就是为了正确性而抑制并行性。因为性能也同样很重要,所以内核的设计者要考虑在同时兼顾了正确性和较高的并行性的前提下使用锁。虽然 xv6 不是为高性能而系统设计的,但仍然值得考虑哪些 xv6 操作可以并行执行,以及哪些操作可能会发生锁冲突。
xv6 中的管道是相当好的并行性的一个例子。 每个管道都有自己的锁,这样不同的进程可以在不同的CPU上并行读写不同的管道。然而,对于给定的管道,写入者和读取者必须等待对方释放锁; 他们不能同时读/写同一个管道。 还有一种情况是,从空管道读取(或向满管道写入)必须阻塞,但这不是由于锁定方案造成的。
上下文切换是比较复杂的例子。两个内核线程,执行在各自的CPU上,可以同时执行yeild
, sched
或者switch
,并且这些调用会并行执行。线程各自持有一个锁,但它们是不同的锁,因此它们不必互相等待。然而,一旦进入调度程序,在进程表中搜索可运行的进程时,两个 CPU 可能会发生锁冲突。 也就是说,xv6 可能会在上下文切换期间从多个 CPU 中获得性能优势,但可能不会那么多。
另一个例子是不同 CPU 上的不同进程并发调用 fork
。 这些调用可能必须等待彼此的 pid_lock
和 kmem.lock
,以及在进程表中搜索未使用进程所需的每进程锁。 另一方面,两个分叉进程可以完全并行地复制用户内存页面和格式化页表页面。
上述每个示例中的锁定方案在某些情况下都会牺牲并行性能。在每种情况下,都可以使用更精细的设计来获得更多并行性。是否值得取决于细节:相关操作被调用的频率、代码在持有竞争锁的情况下花费了多长时间、有多少个 CPU 可能同时运行冲突的操作、代码的其他部分是否是更具限制性的瓶颈。 很难猜测给定的锁定方案是否会导致性能问题,或者新的设计是否明显更好,因此通常需要对实际工作负载进行测量。
9.5 练习
- 修改 xv6 的管道实现,以允许对同一管道的读取和写入在不同内核上并行进行。
- 修改xv6的
scheduler()
以减少不同核心同时寻找可运行进程时的锁争用。 - 消除xv6中
fork()
函数的一些序列化。
第十章 总结
这篇文章通过逐行对操作系统xv6的学习介绍了实现操作系统的主要思想。一些代码行体现了主要思想的本质(比如,上下文切换,用户空间和内核空间的界限,锁等。)并且每一行都很重要。其他代码行提供了如何实现特定操作系统想法的说明,并且可以通过不同的方式轻松完成(比如,一种实现更好调度的算法,更好的磁盘上数据结构来表示文件,更好的日志系统来允许并发访问等。)。所有的想法都是在一个特定的、非常成功的系统调用接口(Unix 接口)的背景下阐述的,但这些想法也延续到了其他操作系统的设计中。
参考书目
[1] The RISC-V instruction set manual: privileged architecture. https://riscv.org/specifications/privileged-isa/, 2019.
[2] The RISC-V instruction set manual: user-level ISA. https://riscv.org/specifications/isa-spec-pdf/, 2019.
[3] Hans-J Boehm. Threads cannot be implemented as a library. ACM PLDI Conference, 2005.
[4] Edsger Dijkstra. Cooperating sequential processes. https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/EWD123.html, 1965.
[5] Maurice Herlihy and Nir Shavit. The Art of Multiprocessor Programming, Revised Reprint. 2012.
[6] Brian W. Kernighan. The C Programming Language. Prentice Hall Professional Technical Reference, 2nd edition, 1988.
[7] Donald Knuth. Fundamental Algorithms. The Art of Computer Programming. (Second ed.), volume 1. 1997.
[8] L Lamport. A new solution of dijkstra’s concurrent programming problem. Communications of the ACM, 1974.
[9] John Lions. Commentary on UNIX 6th Edition. Peer to Peer Communications, 2000.
[10] Paul E. Mckenney, Silas Boyd-wickizer, and Jonathan Walpole. RCU usage in the linux kernel: One decade later, 2013.
[11] Martin Michael and Daniel Durich. The NS16550A: UART design and application considerations. http://bitsavers.trailing- dge.com/components/national/_appNotes/AN-0491.pdf, 1987.
[12] David Patterson and Andrew Waterman. The RISC-V Reader: an open architecture Atlas. Strawberry Canyon, 2017.
[13] Dave Presotto, Rob Pike, Ken Thompson, and Howard Trickey. Plan 9, a distributed system. In In Proceedings of the Spring 1991 EurOpen Conference, pages 43–50, 1991.
[14] Dennis M. Ritchie and Ken Thompson. The UNIX time-sharing system. Commun. ACM, 17(7):365–375, July 1974.
[14] Dennis M. Ritchie and Thompson. The UNIX time-sharing system. Commun. ACM,
17(7):365–375, July 197