第一章:操作系统接口
第一章:操作系统接口
操作系统的工作是在多个程序之间共享计算机资源,并提供比硬件本身更有用的服务。操作系统管理并抽象低级硬件,例如,一个文字处理器无需关心使用的是哪种类型的磁盘硬件。操作系统共享硬件资源给多个程序,使它们可以同时运行(或看起来像同时运行)。最后,操作系统提供受控的程序交互方式,以便它们可以共享数据或协同工作。
操作系统通过接口向用户程序提供服务。设计一个好的接口是困难的。一方面,我们希望接口简洁而明确,因为这样更容易实现正确。另一方面,我们可能会倾向于向应用程序提供许多复杂的功能。解决这种矛盾的关键在于设计依赖于少数机制的接口,这些机制可以组合使用,从而提供更大的通用性。
这本书使用一个操作系统作为具体示例来说明操作系统的概念。这个操作系统名为 xv6,它提供了肯·汤普逊(Ken Thompson)和丹尼斯·里奇(Dennis Ritchie)的 Unix 操作系统引入的基本接口,并且模仿 Unix 的内部设计。Unix 提供了简洁的接口,其机制相互配合良好,提供了非常好的通用性。这个接口设计非常成功,以至于现代操作系统——BSD、Linux、Mac OS X、Solaris,甚至在较小程度上的 Microsoft Windows——都具有类似 Unix 的接口。理解 xv6 是理解这些系统以及许多其他系统的良好起点。
正如图1.1所示,xv6采用了传统的内核形式,即一种特殊的程序,为正在运行的程序提供服务。每个正在运行的程序,称为进程,都有包含指令、数据和栈的内存。这些指令实现了程序的计算。数据是计算作用的变量。栈组织了程序的过程调用。一个给定的计算机通常有许多进程,但只有一个内核。

当一个进程需要调用内核服务时,它会调用一个系统调用,即操作系统接口中的一个调用。系统调用进入内核;内核执行服务然后返回。因此,一个进程在用户空间和内核空间之间交替执行。
内核利用 CPU 提供的硬件保护机制,确保在用户空间执行的每个进程只能访问自己的内存。内核以实现这些保护所需的硬件特权级别执行;用户程序则在没有这些特权的情况下执行。当用户程序调用系统调用时,硬件会提升特权级别,并开始执行内核中预先安排的函数。
内核提供的系统调用集合是用户程序所见到的接口。xv6 内核提供了传统 Unix 内核通常提供的服务和系统调用的一个子集。表1.2列出了所有 xv6 的系统调用。
表1.2: Xv6 系统调用。 除非另有说明,这些调用在无错误时返回0,如果有错误则返回-1。
操作系统接口 | 作用 |
---|---|
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(int n) | 暂停n个时钟滴答。 |
int exec(char *file, char *argv[]) | 加载一个文件并使用参数执行它;仅在出错时返回。 |
char *sbrk(int n) | 扩展进程的内存n字节。返回新内存的起始地址。 |
int open(char *file, int flags) | 打开一个文件;标志指示读/写;返回一个fd(文件描述符)。 |
int write(int fd, char *buf, int n) | 从缓冲区buf中写入n字节到文件描述符fd;返回写入的字节数。 |
int read(int fd, char *buf, int n) | 从文件描述符fd读取n字节到缓冲区buf;返回读取的字节数;或如果到达文件结尾则返回0。 |
int close(int fd) | 释放打开的文件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) | 将打开文件的信息放入*st中。 |
int stat(char *file, struct stat *st) | 将命名文件的信息放入*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.1进程和内存
一个 xv6 进程由用户空间内存(指令、数据和栈)以及内核私有的每个进程状态组成。Xv6 对进程进行时间共享:它会在等待执行的进程集合之间透明地切换可用的 CPU。当一个进程没有执行时,xv6 会保存它的 CPU 寄存器,并在下次运行该进程时恢复它们。内核为每个进程分配一个进程标识符,或 PID。
一个进程可以使用 fork 系统调用创建一个新的进程。Fork 创建出的新进程,称为子进程,子进程的内存内容与调用进程完全相同,调用进程也称为父进程。Fork 在父进程和子进程中都返回。在父进程中,fork 返回子进程的 PID;在子进程中,fork 返回零。例如,考虑以下用 C 编程语言编写的程序片段:
int pid = fork();
if(pid > 0){
printf("parent: child=%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。如果父进程不关心子进程的退出状态,则可以将一个0地址传递给 wait。
在上面这个例子中,输出结果如下:
parent: child=1234
child: exiting
可能以任何顺序出现,这取决于父进程或子进程哪一个先到达它的 printf 调用。在子进程退出后,父进程的 wait 返回,导致父进程打印。
parent: child 1234 is done
虽然子进程最初具有与父进程相同的内存内容,但父进程和子进程在执行时具有不同的内存和不同的寄存器:在其中一个进程中更改变量不会影响另一个进程。例如,在父进程中将 wait 的返回值存储到 pid 中时,它不会改变子进程中的 pid 变量。子进程中的 pid 值仍然为零。
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 的主要结构很简单;请参阅 main (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:145)。
你可能会想为什么 fork 和 exec 没有合并成一个单独的调用;我们稍后会看到,shell 在实现 I/O 重定向时利用了这种分离。为了避免创建一个重复的进程,然后立即替换它(使用 exec)的浪费,操作系统内核通过使用诸如写时复制(见第4.6节)等虚拟内存技术来优化 fork 的实现,以满足这种用例。
xv6 隐式分配了大多数用户空间内存:fork 分配了子进程所需的内存,以存储父进程的内存副本;exec 分配了足够的内存来容纳可执行文件。在运行时需要更多内存的进程(例如,对于 malloc)可以调用 sbrk(n) 来将其数据内存增长 n 字节;sbrk 返回新内存的位置。
1.2 I/O 和文件描述符
文件描述符是一个小整数,代表一个进程可以从中读取或向其中写入的内核管理对象。进程可以通过打开文件、目录或设备,或者创建管道,或者复制现有描述符来获得文件描述符。为简单起见,我们经常将文件描述符引用的对象称为“文件”;文件描述符接口将文件、管道和设备之间的差异抽象掉,使它们都像字节流一样。我们将输入和输出称为 I/O。
在内部,xv6 内核使用文件描述符作为索引,指向每个进程的一个表,因此每个进程都有一个从零开始的私有文件描述符空间。按照惯例,一个进程从文件描述符 0(标准输入)读取数据,将输出写入文件描述符 1(标准输出),将错误消息写入文件描述符 2(标准错误)。正如我们将看到的那样,shell 利用这种惯例来实现 I/O 重定向和管道。shell 确保它始终有三个打开的文件描述符(user/sh.c:151),默认情况下是控制台的文件描述符。
read 和 write 系统调用从由文件描述符命名的打开文件中读取字节和写入字节。调用 read(fd, buf, n) 从文件描述符 fd 中最多读取 n 个字节,将它们复制到 buf 中,并返回读取的字节数。每个引用文件的文件描述符都有一个与之关联的偏移量。read 从当前文件偏移量读取数据,然后将该偏移量按读取的字节数增加:随后的读取将返回第一个读取的字节后面的字节。当没有更多字节可读时,read 返回零以表示文件结束。
调用 write(fd, buf, n) 将来自 buf 的 n 个字节写入文件描述符 fd,并返回写入的字节数。只有当发生错误时才会写入少于 n 个字节。与 read 类似,write 在当前文件偏移量处写入数据,然后将该偏移量按写入的字节数增加:每次写入都从前一次写入的地方继续。
以下程序片段(程序 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 替换了调用进程的内存,但保留了其文件表。这种行为允许 shell 通过 fork 来实现 I/O 重定向,重新打开子进程中选择的文件描述符,然后调用 exec 来运行新程序。以下是 shell 运行命令 cat < input.txt
的简化代码版本:
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 shell 中,I/O 重定向的代码确实以这种方式工作。回顾一下代码中的这一点,此时 shell 已经 fork 出了子 shell,并且 runcmd 将调用 exec 来加载新的程序。
open 的第二个参数是一组标志,表示为位,控制 open 的行为。可能的值在文件控制(fcntl)头文件(kernel/fcntl.h:1-5)中定义:O_RDONLY、O_WRONLY、O_RDWR、O_CREATE 和 O_TRUNC,它们指示 open 打开文件进行读取、写入,或者读取和写入,如果文件不存在则创建文件,并将文件截断为零长度。
现在应该清楚为什么分开 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"。父进程中的写操作(由于wait的存在,仅在子进程完成后运行)将继续子进程写操作结束的地方。这种行为有助于从一系列shell命令中产生顺序输出,比如(echo hello; echo world) > output.txt。
dup 系统调用复制一个已存在的文件描述符,并返回一个指向相同底层 I/O 对象的新文件描述符。两个文件描述符共享一个偏移量,就像 fork 复制的文件描述符一样。这是另一种将 hello world 写入文件的方法:
fd = dup(1);
write(1, "hello ", 6);
write(fd, "world\n", 6);
如果两个文件描述符是通过一系列 fork 和 dup 调用从同一个原始文件描述符派生而来的,则它们共享一个偏移量。否则,即使它们是由相同文件的 open 调用产生的,文件描述符也不共享偏移量。Dup 允许 shell 实现像这样的命令:ls existing-file non-existing-file > tmp1 2>&1
。2>&1
告诉 shell 将文件描述符 2 重定向为描述符 1 的副本。现有文件的名称和不存在文件的错误消息都将显示在文件 tmp1 中。xv6 shell 不支持错误文件描述符的 I/O 重定向,但现在你知道如何实现它了。文件描述符是一个强大的抽象,因为它们隐藏了它们连接的细节:写入文件描述符 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 之后,父进程和子进程都有文件描述符引用管道。子进程调用 close 和 dup,使文件描述符零指向管道的读取端,关闭数组 p 中的文件描述符,并调用 exec 来运行 wc。当 wc 从其标准输入读取时,它会从管道中读取。父进程关闭管道的读取端,向管道写入数据,然后关闭写入端。
如果没有数据可用,管道上的读取操作将等待数据被写入,或者等待所有引用写入端的文件描述符被关闭;在后一种情况下,读取将返回 0,就像已经到达数据文件的末尾一样。读取操作阻塞直到不可能再有新数据到达是一个原因,这也是在上面执行 wc 前关闭管道的写入端很重要的原因之一:如果 wc 的文件描述符之一指向管道的写入端,wc 将永远不会看到文件结束标志。
xv6 shell实现了类似上述代码的管道功能,比如 grep fork sh.c | wc -l
。子进程创建一个管道,将管道的左端与右端连接起来。然后它调用fork
和runcmd
来处理管道的左端,并调用fork
和runcmd
来处理管道的右端,然后等待两端都完成。管道的右端可能是一个包含管道的命令(例如,a | b | c),它本身会fork出两个新的子进程(一个为b,一个为c)。因此,shell可能会创建一个进程树。这个树的叶子节点是命令,内部进程是等待左右子节点完成的进程。
原则上,可以让内部进程直接运行管道的左端,而不是fork
后在runcmd
,但这样做正确性会变得更加复杂。考虑只做以下修改:将sh.c
修改为不为p->left
进行fork,并在内部进程中运行runcmd(p->left)
。然后,例如,echo hi | wc
不会产生输出,因为当echo hi在runcmd中退出时,内部进程也退出,从而不会调用fork
来运行管道的右端。这种不正确的行为可以通过在内部进程中不调用exit
来修复runcmd,但是这个修复会让代码变得更加复杂:现在runcmd需要知道它是内部进程还是其他进程。同样,在不为runcmd(p->right)
进行fork时也会出现问题。例如,进行了该修改,sleep 10 | echo hi
将立即打印“hi”,而不是在10秒后,因为echo立即运行并退出,而不是等待sleep完成。由于sh.c的目标是尽可能简单,因此它不尝试避免创建内部进程。
(我测试了bash
和csh
执行sleep 10 | echo hi
的结果。bash中,首先输出hi,然后等待10s退出。csh中,输出hi之后就立即退出。)
管道看起来可能并不比临时文件更强大:管道命令echo hello world | wc
可以不使用管道,而是使用临时文件实现:
echo hello world >/tmp/xyz; wc </tmp/xyz
这个命令会将"hello world"写入临时文件/tmp/xyz,然后将该文件作为wc命令的输入。
在这种情况下,管道相对于临时文件至少有四个优点。
- 首先,管道会自动清理自己;而使用文件重定向时,shell 必须小心在完成时删除 /tmp/xyz。
- 其次,管道可以传递任意长的数据流,而文件重定向需要足够的磁盘空间来存储所有数据。
- 第三,管道允许管道阶段的并行执行,而文件方法要求第一个程序完成后才能启动第二个程序。
- 第四,如果您正在实现进程间通信,管道的阻塞读写比非阻塞更有效。
1.4 文件系统
xv6文件系统提供了数据文件和目录。数据文件包含未经解释的字节数组,而目录则包含对数据文件和其他目录的命名引用。这些目录形成了一个树结构,以一个称为根目录的特殊目录开始。像 /a/b/c
这样的路径指的是根目录 /
中的目录 a
中的目录 b
中的文件或目录 c
。不以 /
开头的路径相对于调用进程的当前目录进行解析,这可以通过 chdir
系统调用来更改。这两个代码片段都打开相同的文件(假设所有涉及的目录都存在)。
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
第一个片段将进程的当前目录更改为 /a/b
;第二个片段既不引用也不更改进程的当前目录。 有系统调用可以创建新的文件和目录:mkdir
创建一个新目录,带有 O_CREATE
标志的 open
创建一个新的数据文件,而 mknod
创建一个新的设备文件。以下示例说明了这三种情况:
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
创建一个特殊文件,该文件引用一个设备。与设备文件相关联的是主设备号和次设备号(mknod
的两个参数),它们唯一地标识一个内核设备。当一个进程稍后打开一个设备文件时,内核会将读写系统调用重定向到内核设备实现,而不是将它们传递给文件系统。
一个文件的名称与文件本身是不同的;同一个底层文件,称为索引节点(inode),可以有多个名称,称为链接。每个链接都包含在目录中的一个条目;该条目包含文件名和对一个索引节点的引用。索引节点包含有关文件的元数据,包括其类型(文件、目录或设备)、其长度、文件内容在磁盘上的位置,以及指向该文件的链接数。
fstat系统调用从文件描述符所引用的索引节点中检索信息。它填充一个struct stat,该结构在stat.h(kernel/stat.h)中定义为:
#define T_DIR 1 // Directory
#define T_FILE 2 // File
#define T_DEVICE 3 // Device
struct stat {
int dev; // File system’s disk device
uint ino; // Inode number
short type; // Type of file
short nlink; // Number of links to file
uint64 size; // Size of file in bytes
};
link系统调用创建另一个文件系统名称,指向与现有文件相同的索引节点。以下代码片段创建了一个名为a和b的新文件。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
从a读取或写入与从b读取或写入相同。每个索引节点由唯一的索引节点号标识。在上面的代码序列之后,可以通过检查fstat
的结果来确定a和b指向相同的底层内容:两者都将返回相同的索引节点号(ino),并且nlink
计数将设置为2。
unlink
系统调用从文件系统中删除一个名称。只有当文件的链接计数为零并且没有文件描述符引用它时,文件的索引节点和保存其内容的磁盘空间才会被释放。
unlink("a");
因此,添加对于最后的代码序列,删除了名称,但是inode和文件内容仍然可以通过b访问到。此外,
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
是一种惯用的方式来创建一个没有名称的临时inode
,当进程关闭fd或退出时会被清理。
Unix提供了可从shell中调用的文件实用程序,作为用户级程序,例如mkdir
、ln
和rm
。这种设计允许任何人通过添加新的用户级程序来扩展命令行界面。从事后来看,这个计划似乎是显而易见的,但在Unix诞生时期设计的其他系统通常将这些命令内建到了shell中(并将shell内建到了内核中)。
一个例外是cd
命令,它内建到了shell
中(user/sh.c:160)。cd必须更改shell本身的当前工作目录。如果cd被作为常规命令运行,那么shell
会派生出一个子进程,子进程会运行cd
命令,cd
命令会更改子进程的工作目录。父进程(即shell)的工作目录不会改变。
1.5 实际应用
Unix引入了标准文件描述符、管道以及方便的Shell语法来操作它们的组合,这是编写通用可重用程序的重大进步。这个想法引发了一个“软件工具”的文化,这个文化为Unix的强大和流行贡献了很多,而Shell是第一个被称为“脚本语言”的。Unix的系统调用接口今天仍然存在于诸如BSD、Linux和Mac OS X等系统中。
Unix系统调用接口已通过可移植操作系统接口(POSIX)标准进行了标准化。Xv6不符合POSIX标准:它缺少许多系统调用(包括基本的lseek等),并且它提供的许多系统调用与标准不同。我们对xv6的主要目标是提供简洁清晰的UNIX样式系统调用接口,同时保持简单和清晰。一些人已经扩展了xv6,添加了一些额外的系统调用和一个简单的C库,以便运行基本的Unix程序。然而,现代内核提供了更多的系统调用和更多种类的内核服务,而不仅仅是xv6。例如,它们支持网络、窗口系统、用户级线程、许多设备的驱动程序等等。现代内核不断快速演进,并提供许多超出POSIX标准的功能。
Unix通过一组文件名和文件描述符接口,实现了对多种类型资源(文件、目录和设备)的统一访问。这个思想可以扩展到更多种类的资源;一个很好的例子是Plan 9,它将资源即文件的概念应用到了网络、图形等领域。然而,大多数Unix衍生操作系统并没有沿着这条路线发展。
文件系统和文件描述符是强大的抽象概念。即便如此,操作系统接口还有其他模型。Multics,Unix的前身,以一种使文件存储看起来像内存的方式进行了抽象,产生了一种非常不同的接口风格。Multics设计的复杂性直接影响了Unix的设计者,他们试图构建一些更简单的东西。
Xv6并不提供用户的概念,也没有保护一个用户不受另一个用户的影响的机制;用Unix术语来说,所有的xv6进程都以root用户的身份运行。
这本书研究了xv6如何实现其类Unix接口,但其中的思想和概念不仅适用于Unix。任何操作系统都必须将进程复用到底层硬件上,将进程相互隔离,并提供受控的进程间通信机制。通过学习xv6,你应该能够查看其他更复杂的操作系统,并在这些系统中看到与xv6相同的概念。
1.6 练习
编写一个程序,使用UNIX系统调用在两个进程之间通过一对管道(每个方向一个)进行“ping-pong”,即交替发送一个字节。测量程序的性能,以交换次数每秒(exchanges per second)为单位。