MIT 6.828 Operating System Engineering
这本书要和xv6
源码一起阅读
操作系统接口
操作系统的任务是在多个程序之间共享计算机,并提供比硬件单独支持更有用的服务集合。操作系统管理和抽象低级硬件,因此像文字处理机就不需要考虑使用哪种类型的磁盘。它还支持多路复用硬件,允许程序间共享计算机(或近似于)并发运行。最后,操作系统提供了程序交互的受控方式,以便程序之间可以共享数据或者协同工作。
OS通过接口向用户程序提供服务,而设计好的接口是很困难的。一方面,我们希望接口简单和紧凑(窄接口比较容易确保对某一特定点的关注。这点很像铺设自来水管道。管路最初阶段可能从需求和效率考虑需要大口径的,但要入户的时候就要转成小口径,译者注),因为这样更容易正确实现。 另一方面,我们倾向于向应用程序提供更多巧妙的功能。解决这种紧张关系的技巧是设计依赖提供更大的通用性的机制的接口。
这本书使用一个简单OS作为具体示例来阐述OS相关概念。这个OS,即xv6,提供的简单接口,来自于Ken Thompson and Dennis Ritchie的Unix操作系统,并且模拟了Unix的内部设计。Unix的接口提供的窄接口,其机制很好的融合在了一起,提供了令人惊讶的通用性。这些接口很成功,以至于像BSD、Linux,Mac OS X、Solaris,甚至一部分Windows,都有类Unix接口。理解xv6是理解其他任何系统的良好开始。
如图0-1所示,xv6采用了传统形式的内核,内核是为运行程序提供服务的特殊程序。每个运行程序(称为进程)的内存中都包含了指令、数据和栈。指令实现了程序的运算,数据则是运算所依赖的变量,栈则组织了程序的调用过程。
当进程需要调用内核服务时,将调用操作系统接口中的过程调用。这种过程称为系统调用。系统调用会陷入内核态,内核执行完服务并返回用户态。因此进程在用户空间和内核空间之间交替变换。
内核使用CPU的硬件保护机制来确保在用户空间的每个进程只能访问自己的内存。内核在硬件特权上运行,因此需要实现这些保护;用户程序在执行时则没有这些特权。当用户程序调用系统调用时,硬件会提升权限级别,并开始执行在内核中预先写好的函数。
内核提供的系统调用集合就是用户程序可见的接口。xv6内核提供了Unix内核传统提供的服务和系统调用的子集。图 0-2列出了xv6的所有系统调用。
本章其余部分概述了xv6的服务:进程、内存、文件描述、管道和文件系统,并通过代码段来说明shell如何使用它们。shell对系统调用的使用将说明系统调用设计的是多么精巧。
shell是一个从用户读取命令并执行他们的普通程序,而且是传统类Unix的主要用户界面。shell只是一个用户程序,而不是内核的一部分,shell没有什么特别之处反倒说明了系统调用接口的力量。这也意味着shell很容易更换,因此现代Unix系统都有各种各样shell去选择,每种shell都有自己的界面和脚本功能。xv6的shell本质上就是Unix Bourne shell的简单实现。其实现源码从第8350行开始。
进程和内存
xv6进程由用户空间内存(指令、数据和栈)和内核的每个进程私有状态组成。xv6可以分时(time-share
)处理进程,它透明地在等待执行的进程组之间切换,来分配可用的CPU。当进程未执行时,xv6会保存其CPU寄存器,并在下次运行时还原。内核将进程标识符(pid
)和每个进程关联。
进程可以使用fork
系统调用来创建新的进程。fork
创建的子进程(chile process
)和调用进程(父进程,parent process
)有着一样的内存信息。fork
返回父进程和子进程中都返回,在父进程中返回子进程的pid,在子进程中则返回0。例如考虑下面这个程序片段1
2
3
4
5
6
7
8
9
10
11
12
13int pid = fork();
if(pid > 0){
printf("parent: child=%d\n", pid);
pid = wait();
printf("child %d is done\n", pid);
} else if(pid == 0)
{
printf("child: exiting\n");
exit();
} else
{
printf("fork error\n");
}
exit
系统调用会导致调用进程结束执行并释放如内存和文件这样的资源。wait
系统调用返回当前进程的退出子进程的pid。如果调用者没有子进程退出,则等待。在实例中,命令行输出:1
2child=1234
chileL:exiting
上面两句话会按任意顺序出现,这取决于父进程还是子进程先接收到printf
调用。当子进程退出后,父进程的wait
返回,因此打印出1
parent:child 1234 is done
注意父进程和子进程在不同的内存和寄存器中运行,改变变量值不会影响另外一个。
exec
系统调用用从存储在文件系统的文件加载的新内存镜像来替换调用进程的内存。该文件必须具有特定格式,其指明文件的那部分是指令,那部分是数据,从哪里开始执行等。xv6使用ELF格式,第二章将详细讨论该格式。
当exex
成功调用后,不会返回调用程序,而是从ELF头部声明的入口开始执行。exec
需要两个参数:可执行文件名和字符串参数数组,例如1
2
3
4
5
6char *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的主体结构很简单,见main(8501行)。主循环使用getcmd
读取命令行上的输入。然后调用fork
,创建shell进程的副本。父进程调用wait
,子进程运行命令。例如,如果用户在提示符下输入echo hello
,runcmd
将被调用,并以echo hello
为参数。runcmd
(8406行)执行实际命令。对于echo hello
,将调用echo
(8426行)。如果exec
成功执行,子进程会执行echo
的指令,而不是runcmd
。在某些点,echo
会调用exit
,这会导致父进程从wait
返回。也许你想知道fork
和exec
为什么没有合成一个调用,稍后我们将看到,单独调用创建进程和加载程序是一个巧妙的设计。
xv6隐式分配大多数用户空间的内存,fork
分配子进程需要的内存,exec
分配足够的内存去保存可执行文件。在运行时进程可能需要更多的内存,例如malloc
这时候可以调用sbrk(n)
将内存扩展n字节。sbrk
返回新内存的地址。
xv6不提供用户的概念,也不提供保护一个用户免受一个用户影响的概念,在unix中,所有xv6都以root身份运行。
I/O和文件描述符
文件描述符是只是一个小整数值,代表一个进程读取或写入的内核管理对象。进程可以通过打开文件、目录、设备、创建管道、复制存在的描述符来获取文件描述符。简单起见我们通常把文件描述符就当做文件,但其实文件描述符接口是文件、管道和设备的抽象,使它们看起来都是字节流。
xv6内部将文件描述符作为每个预处理表(per—process table)的索引,因此每个进程都有一个从零开始的文件描述符的专有空间。按照惯例,进程从文件描述符0(标准输入)读取,将输出写入到文件描述符1(标准输出),并将错误信息写入文件描述符2(标准错误)。正如我们将看到的,shell利用这个约定(exploits the convention)来实现I/O重定向和管道。shell保证始终打开三个文件描述符(8507),这是控制台的默认文件描述符。
read
和write
系统调用从描述符命令的打开文件中读取和写入字节。read(fd,buf,n)
最多从文件描述符fd
读取n字节,将其复制到buf
,并返回读取的字节数。每个文件描述符都有一个偏移量,其和引用的文件相关。read
从当前文件偏移处读取数据,然后将偏移量向前移动所要读取的n字节数,后续的read
将从新的位置继续读取字节。当没有更多的字节能读时,返回0,标志着读到了文件末尾。
write(fd,buf,n)
从buf
中写入n字节到文件描述符fd
,并返回写入的字节数。只有在发生错误时,才会写入少于n字节。像read
、write
在当前文件偏移处写入数据,并将偏移量向前移动写入的n字节,每次write
都会从前一个写入位置写入。
下面的程序片段(构成了cat
的本质)从标准输入复制数据到标准输出。如果发生错误,将信息写入标准错误。
1 | char buf[512]; int n; |
在代码段中需要注意的是cat
并不知道它是从文件、控制台还是管道读取。同样,cat
也不知道它是打印到控制台、文件还是其他地方。文件描述符的使用,文件描述符0是输入,1是输出,这整个约定使得cat
能简单实现。
close
系统调用释放文件描述符,使其可被cat
、open
、pipe
和dup
重用。新分配的文件描述符始终是当前进程的最小未使用编号。
文件描述符和fork
的相互交互使得I/O重定向变得易于实现。fork
复制父进程的文件描述符表和内存,使得子进程从和父进程有着相同的打开文件。exec
替换调用进程的内存,但是保留其文件表。此行为允许shell通过fork
,重新打开选定的文件并执行新的程序来实现I/O重定向。下面是shell命令为cat < input.txt
简化代码1
2
3
4
5
6
7
8char *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
使用引用input.txt的文件描述符0(标准输入)执行。
xv6 shell中的I/O重定向就是以上述方式工作(8430)。回想一下,shell已经fork
了child shell,并且runcmd
将调用exec
加载新程序。现在应该清楚为什么fork
和exec
单独调用是一个好主意。这种分离允许shell在子程序运行所需要的程序之前修复子进程。
尽管fork
复制文件描述符表,但每个基础文件偏移量都在父进程和子进程之间分享。看下面这个实例:1
2
3
4
5
6
7if(fork() == 0) {
write(1, "hello ", 6);
exit();
} else {
wait();
write(1, "world\n", 6);
}
在这个片段的末尾,附加到文件描述符1的文件将包含数据hello world
。父进程的write
(由于wait
,只能在子进程结束之后才会运行)会在子进程write
中断的地方继续执行。这种行为将有助于从shell命令序列中产生有序输出,如(echo hello;echo world) > output.txt
dup
复制现有的文件描述符,返回一个指向相同I/O对象的新文件描述符。这两个文件描述符共享偏移量,就像fork
复制的文件描述符一样。这是将hello world
写入文件的另一种方法:1
2
3fd = 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的进程可能正写入文件、控制台等设备或者管道。
管道
pipe
是一个小的内核缓冲区,以一对文件描述符的形式公开给进程,一个用于读取,另一个用于写入。将数据写入管道的一段可使数据从管道的另一端被读取。管道为进程提供了一种通信方式。
下面的示例代码使用连接到管道读取端的标准输入运行程序WC
:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17int 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
{
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
程序调用pipe
,它创建一个新管道,并在数组p中记录读取和写入的文件描述符。在fork
之后,父进程和子进程都有引用管道的文件描述符。子进程将读取结果传入文件描述符0,关闭p中的文件描述符,以及执行wc。当wc从标准输入读取时,其实是从管道读取。父进程写入管道的写入端,然后关闭两个文件描述符。
如果没有可用数据,管道上的read
会等待数据的写入或者关闭写入端的所有文件描述符。在后一种情况下,read
返回0,就像已达到数据文件的末尾。阻塞read
直到不可能到达新数据的一个原因是在执行wc之前,让子进程关闭管道的写入端非常重要。因为如果wc的一个文件描述符指向了管道的写入端,wc将永远不会看到文件末尾。
xv6 shell实现了诸如grep fork sh.c | wc -l
等类似代码(8450)的管道。子进程创建一个管道,将管道的左端和右端相连,然后调用runcmd
为管道的左端和右端。然后调用两次wait
去等待左端和右端的完成。管道的右端可能是一个命令,该命令本身也是一个管道(如a | b | c
),它本省分叉成两个子进程(一个用于b,一个用于c)。因此shell可能会创建一个进程树。树的叶节点是命令,内部节点是等待左右子进程完成的进程。原则上,你可以让内部节点运行管道的左端,但正确的完成会使得实现变得复杂。管道看起来并没有比临时文件强大,例如echo hello world | wc
也可以在没有管道的情况下实现echo hello world > /tmp/xyz;wc < /tmp/xzy
管道和临时文件至少有三个关键区别。第一个,管道会自动清理自己,而在文件重定向时,shell必须在完成时小心的溢出/tmp/xzy
。其次,管道可以通过任意长度的数据流,而文件重定向需要磁盘上足够的可用空间来存储所有数据。最后,管道允许同步,两个进程可以使用一对管道相互发送消息,每个read
都会阻塞调用进程,直到另一个进程通过write
写入数据。
文件系统
xv6文件系统提供了数据文件,这些数据文件都是没解释的字节数组和目录,其中包含对数据文件和其他目录的命令引用。xv6将目录实现为一种特殊的文件。目录形成一个从称为root
的特殊目录开始的树。像/a/b/c
这样的路径指向了根目录中b目录中的命名为c的文件或目录。路径如果没有以/
开始,就会改用chdir
系统调用,来计算相对于调用进程的当前目录的位置。这两种方式都能打开相同的文件(假设涉及的目录都存在):1
2
3
4chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
第一个片段将进程的当前目录更改为/a/b
,第二个既不引用也不修改进程的当前目录。
这里有多个系统调用来创建新的文件和目录:mkdir
创建一个新的目录,使用O_CREAT
标志的open
创建一个新的数据文件,mknod
创建新的设备文件。下面的示例说明了这三个方面:1
2
3
4mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod
在文件系统中创建一个文件,但是该文件没有内容。相反,该文件的元数据将其标记为设备文件,并记录主要和次要的设备编号(传给mknod
的两个参数),它们惟一的标志内核设备。当进程之后打开该文件时,内核将read
和write
调用转移到内核实现,而不是将它们传到文件系统。
fstat
检索有关文件描述符所引用对象的信息。它填充struct stat
,该结构定义在stat.h
1
2
3
4
5
6
7
8
9
10
11
struct stat
{
short type; // Type of file
int dev; // File system’s disk device
uint ino; // Inode number
short nlink; // Number of links to file
uint size; // Size of file in bytes
};
文件名和文件本身并不同,同一个基础文件,叫做inode
,可以有多个名称,叫做links
。link
系统调用创建另一个文件系统名称,引用和现有文件相同的inode。下面片段创建了一个文件,其名称既可以为a
,也可以为b
1
2open("a", O_CREATE|O_WRONLY);
link("a", "b");
从a或者从b读取、写入是一模一样的。每个inode都是有惟一的inode number标识的。执行了上面的代码之后,可以通过fstat
的结果来确定a和b指向相同的基础内容,两者都返回相同的inode number(ino),并且nlink
将设置为2。
unlink
将从文件系统中删除一个名称。只有当文件的链接数为0并没有文件描述符引用该文件时,才会释放文件的inode和磁盘空间,因此unlink("a");
将使得inode和文件内容只能被b访问。而且下面的方法是常用的方式来创建临时inode,其在进程关闭fd
或者退出时,会被清理。1
2fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
用于文件系统操作的xv6 命令被作为用户级程序(如mkdir
、ln
、rm
等)实现。这种设计允许任何人使用新的用户命令扩展shell。事后看来,这个计划似乎很明显,但和unix同时期设计的其他系统经常在shell中构建这样的命令(并将shell构建到内核中)。
一个例外是cd
,它内置到shell (8516) 中。cd
必须更改shell本身的当前工作目录。如果cd
作为常规命令运行,并且shell 将分叉子进程, 子进程将运行cd
,cd
将更改子进程的工作目录。父目录(即shell的)工作目录不会更改。
真实世界
unix将“标准”文件描述符、管道和方便的shell语法结合起来,用于对其进行操作,这是编写通用可重用程序的一大进步。这个想法引发了整个“软件工具”的文化,它在很大程度上是unix的力量和人气的原因,,shell是第一个所谓的“脚本语言”。在BSD、Linux和Mac OS X等系统中, unix系统调用接口如今仍然存在。
与xv6相比,现代内核提供了更多的系统调用和更多种类的内核服务。在大多数情况下,现代unix派生的操作系统没有遵循早期的unix模型,即将设备公开为特殊文件,如上面讨论的控制台设备文件。unix的作者接着构建了计划9,该计划将“资源是文件”概念应用于现代设施,将网络、图形和其他资源表示为文件或文件树。
文件系统抽象是一个强大的想法,最近以万维网的形式应用于网络资源。即使如此,操作系统接口还有其他模型。multics是unix的前身,它的抽象文件存储使其看起来像内存,产生了截然不同的接口风格。multics设计的复杂性直接影响了unix的设计者,他们试图构建更简单的设计器。
这本书探讨了xv6如何实现类似unix的界面, 但这些想法和概念不仅仅适用于unix。任何操作系统都必须将进程多路复用到底层硬件上,将进程彼此隔离,并提供受控制的进程间通信机制。在研究了xv6之后,您应该能够查看其他更复杂的操作系统,并在这些系统中看到xv6的基本概念。