操作系统的工作,一个是在不同程序之间共享计算机,一个是提供比硬件所提供的的服务更好用的服务集合。操作系统管理并抽象底层硬件,因此,举例来说,文字处理器不需要考虑自己用的是什么类型的硬盘硬件。操作系统将硬件在多个程序之间分享,让它们能同时运行(或看起来是同时运行)。最后,操作系统为程序间的交互提供受控的方法,这样它们就能共享数据或协同工作。
操作系统通过接口向用户进程提供服务。设计一套好的接口很难。一方面,我们希望接口简单而狭窄,因为这样的接口容易正确地使用。另一方面,我们可能尝试为应用程序提供很多复杂的特性。解决这个矛盾的方法,是设计依赖于一些机制的接口,它们可以组合起来,提供更通用的功能。
本书使用一个简单的操作系统作为具体的例子,来阐述操作系统的概念。xv6操作系统提供了Ken Thompson与Dennis Ritchie的Unix操作系统引入的基础接口[14],以及模仿Unix的内部实现。Unix提供了一组狭窄的接口,其机制可以很好地结合,从而提供了令人惊讶的通用性。这组接口相当成功,现代操作系统——BSD,Linux,MAC OS X,Solarias,甚至较低程度地,Microsoft Windows——都有类Unix接口。理解xv6对于理解这些操作系统中的任何一种以及很多其他的系统来说,是一个很好地开始。
如图1.1所示,xv6延续了内核的传统,内核是一个特殊的程序,它为程序运行提供了服务。运行的程序叫做进程,有着包含指令、数据和一个栈的内存。指令实现了程序的计算。数据是计算中所使用的变量。栈组织了程序的调用过程。典型地,一台给定的计算机有很多进程,但只有一个内核。
当进程需要调用内核服务时,它调用一个系统调用,操作系统接口中的一种。系统调用进入到内核;内核执行对应的服务并返回。因此,进程在用户空间和内核空间之间切换。
内核使用CPU提供的硬件保护机制来保证每个运行在用户空间的进程只能访问它自己的内存。内核以实现这些保护所需要的硬件特权执行;用户程序没有这些特权。当用户程序调用系统调用时,硬件提升其特权级别,开始执行内核中提前安排好的函数。
内核提供的系统调用的集合是用户程序所看到的接口。xv6提供了Unix内核传统上提供的系统调用的一个子集。图1.2列出了所有的xv6系统调用。
本章其余部分简要描述了xv6提供的服务——进程、内存、文件描述符、管道以及一个文件系统——并使用代码片段举例说明了它们,还讨论了shell(Unix的命令行用户界面)如何使用它们。shell对系统调用的使用说明了系统调用的设计是多么仔细。
shell是一个普通程序,它从用户读取命令并执行。shell是用户程序而非内核一部分的事实说明了系统调用接口的强大:shell没什么特别的。它也说明,shell很容易被取代;这就造成现代Unix系统有大量可选的shell,每种shell都有自己的用户界面和脚本特性。xv6 shell是Unix Bourne shell精髓部分的简单实现。它的实现可以在(user/sh.c:1)中找到。
snippet
xv6进程由用户空间内存(指令、数据、栈)和内核私有的每进程状态组成。xv6时间共享进程:它在等待执行的进程集合中透明地切换可用的CPU。当进程没运行时,xv6将它的CPU寄存器保存,下次运行这个进程时恢复。内核用进程标识符,或者叫PID标识每个进程。
进程可能使用fork系统调用创建新进程。fork使用调用它的进程,即父进程的相同内存内容创建新进程,叫作子进程。fork在父进程和子进程中都返回。在父进程中,fork返回子进程的PID;在子进程中则返回0。举例个例子,考虑下面的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。如果父进程不在乎子进程的退出状态,它可以给wait传入0地址。
在这个例子中,输出行:
parent: child=1234
child: exiting
的先后顺序可能是任意一种,取决于父进程与子进程谁先到达printf调用。在子进程退出后,父进程的wait返回,导致父进程打印:
parent: child 1234 is done
尽管最开始子进程的内存内容与父进程相同,但父进程与子进程其实运行在不同的内存中,使用不同的寄存器:改变其中一个的变量不会影响到另一个。举例来说,当wait的返回值存放在父进程的pid中时,它并不会改变子进程中pid的值。子进程中pid的值仍然为0。
exec系统调用用一个新的内存镜像替换掉调用进程的内存,新内存来自于文件系统中保存的一个文件。文件必须有一个特殊的格式,它指定了文件的哪部分存放指令,哪部分存放数据,从哪条指令开始,等等。xv6使用ELF格式,第3章会详细讨论。当exec执行成功,它不会返回到调用程序中;从文件中加载的指令会在ELF头中声明的入口点处开始执行。exec有两个参数:可执行文件的文件名和一个字符串变量数组。例如:
char *argv[3];
argv[0] = "echo";
argv[1] = "helloc";
argv[2] = 0;
exec("/bin/echo", argv);
printf("exec erorr\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)所带来的浪费,系统内核优化了这种情况下fork的实现,在fork中使用了虚拟内存技术例如写时拷贝(见4.6)。
xv6隐式地分配大部分用户空间内存:fork分配子进程所需的用于拷贝父进程内存的内存,然后exec分配足够的空间用于存放可执行文件。运行时需要更多内存(可能用于malloc)的进程可以调用sbrk(n)来增长n字节的数据内存;sbrk返回新内存的地址。
文件描述符是代表一个进程可能读写的内核管理的对象的小整数。进程可能通过打开文件、目录或设备,或者创建管道,亦或复制已存在的描述符来获得。简单起见,我们将把文件描述符指向的对象称为“文件”;文件描述符接口的抽象隔离了文件、管道和设备之间的不同,让它们看起来都好像是字节流。我们将把输入输出称为I/O。
在内部,xv6内核将文件描述符作为每进程表的索引来使用,这样每个进程都有了一个从0开始的私有文件描述符空间。传统上,进程从文件描述符0读取(标准输入),向文件描述符1写入(标准输出),将错误消息写入文件描述符2(标准错误)。如我们所见,shell利用这个传统来实现I/O重定向与管道。shell保证有三个文件描述符一直打开(user/sh.c:151),它们就是默认的控制台文件描述符。
read和write系统调用从文件描述符所指的打开的文件读取和写入数据。read(fs, buf, n)调用从文件描述符中读取最多n个字节,将它们拷贝到buf中,然后返回读到的字节数。每个指向一个文件的文件描述符都有一个与它对应的偏移量。read从当前的文件偏移读取数据,然后将偏移量增加读取的字节数:后续的read将返回第一个read返回的数据后面的字节。当没有更多字节可以读取时,read返回0来说明文件已到结尾。
write(fs, buf, n)调用将buf中的n个字节写入到文件描述符fs中,然后返回写入的字节数。出错时写入的字节数将小于n。跟read一样,write在当前文件偏移处写入数据,然后将偏移量后移写入的字节数:每个write都在上一个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能够这样实现I/O重定向:先fork,然后在子进程中重新打开选定的文件描述符,接着调用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重定向确实是以这种方式工作(user/sh.c:82)。回忆一下,这时,代码中shell已经fork了子进程的shell,然后runcmd将调用exec来加载新程序。
open的第二个参数由一组标志位组成,以bit表示,它们控制了open的行为。可用的标志位在文件控制(fcntl)头文件中(kernel/fcntl.h:1-5):O_RDONLY,O_WRONLY,O_RDWR,O_CREATE,O_TRUNC,分别通知open打开文件用于读,写,读写,若不存在则创建文件以及将文件长度截断为0。
现在应该清楚为什么fork与exec分开非常有用了:在二者之间,shell有机会在不打扰主shell I/O设置的同时重定向子进程的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,它在子进程之后运行)从子进程的write离开的地方继续。这种行为让顺序执行的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 existing-file non-existing-file > tmp1 2>&1。2>&1告诉shell给命令一个从描述符1复制而来的文件描述符2。已存在的文件的名称和不存在文件的错误消息都将在文件tmp1中出现。xv6 shell不支持到错误文件描述符的I/O重定向,但现在你知道了怎么实现它。
文件描述符是一种强大的抽象,因为它们隐藏了它们所连接对象的细节:写入文件描述符1的进程可能写入了一个文件,一个类似控制台的设备或者一个管道。
管道是一小段内核缓冲区,它暴露给进程一对文件描述符,一个用来读,另一个用来写。向管道一端写入的数据可以在另一端读取。管道提供了一种进程间通信的方式。
下面的示例代码执行了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来将文件描述符0连接到管道的读端,关闭p中的文件描述符,然后调用exec运行wc。当wc从它的标准输入读取时,它是从管道读取。父进程关闭了管道的读端,向管道写入,然后关闭写端。
如果没有可用的数据,在管道上的read调用会等待数据写入,或者等待所有指向管道写端的文件描述符关闭;在后一种情况中,read会返回0,就文件操作中读取到文件结尾一样。read在新数据写入之前一直被阻塞的事实,使得子进程必须在执行wc之前关闭管道的写端:如果wc的的一个文件描述符还指向管道的写端,wc将永远看不到文件结束符(EOF,end-of-file)。
xv6 shell中的管道实现,例如grep fork sh.c | wc -l,与上面的代码类似(user/sh.c:100)。子进程创建了一个管道,将管道左侧与右侧连接起来。然后为管道符号左侧调用fork和runcmd,接着为右侧调用fork和runcmd,最后等待两边结束。管道右侧可能是一个包含管道的命令(例如,a|b|c),它本身又会fork出两个新的子进程(一个是b,一个是c)。因此,shell可能创建一颗进程树。树的叶子节点是命令,而内部节点是等待左右子进程结束的进程。
原则上讲,我们也可以让内部节点执行管道左侧的命令,但追求这种正确会让实现复杂化。考虑使用下面的修改:将sh.c的内部进程中的为p->left fork改为运行runcmd(p->left)。然后,举个例子,echo hi | wc将不产生输出,因为当echo hi在runcmd中退出时,内部进程退出了,因为永远都不会调用fork来运行管道右侧的命令。这种村务行为可以通过在内部进程的runcmd中不调用exit来改正,但这种修改让代码复杂化:现在runcmd需要知道它是否是内部进程。runcmd(p->left)不使用fork也会产生复杂性。举例来说,按照前面的修改,sleep 10 | echo hi会直接输出“hi”而不是10秒之后输出,因为echo直接运行并推车,不会等待sleep结束。因为sh.c的目标是越简单越好,因此不会尝试去避免创建内部进程。
管道看起来似乎并不比临时文件强多少:管道行
echo hello world | wc
也可以不用管道实现:
echo hello world >/tmp/xyz; wc </tmp/xyz
在这种情境中,管道相对临时文件至少有四个优势。第一,管道会自动完成清理;在文件重定向中,shell在完成命令后还要小心地清理/tmp/xyz文件。第二,管道能传递任意长度的流数据,而文件重定向需要足够的硬盘空闲空间来保存所有的数据。第三,管道允许管道各部分并行执行,而文件方法需要第一个程序执行完,第二个程序才会开始。第四,如果你在实现进程间通讯,管道的阻塞式读写比文件的非阻塞语义更加高效。
semantics
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的两个参数),这两个编号单独制定了一个内核设备。当进程以后打开一个设备文件时,内核将read与write系统调用转到内核设备实现中,而不是传给文件系统。
文件名与文件本身相互独立;底层的文件,叫作inode,可以有很多不同的名字,叫作连接。每个连接由一个目录项构成;目录项包含一个文件名和一个指向inode的引用。inode包含了文件的metadate,包括它的类型(文件或目录或者设备),它的长度,文件内容在硬盘上的位置,以及指向文件的连接数。
fstat系统调用从文件描述符指向的inode中获取文件的信息。它填充了一个stat结构体,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系统调用创建了一个新的指向一个已存在的文件inode的文件系统名。这段代码创建了一个新文件,有a、b两个文件名。
open("a", O_CREATE|O_WRONLY);
link("a", "b");
读写a文件与读写b文件完全相同。每个inode都有一个惟一的inode编号。在上面的代码之后,可以通过查看fstat的结果来确定a跟b指向同一个底层文件:二者都会返回相同的inode编号(ino),而且nlink将被设为2。
unlink系统调用从文件系统中删除一个名称。文件的inode与存放文件内容的硬盘空间只有在文件的连接数为0而且没有文件描述符指向它时才会被释放。因此,将unlink("a");
添加到前面的代码段末尾会使得inode与文件内容只能通过b访问。此外,
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
是一种创建临时inode的惯用方法,这个inode没有名称,当进程关闭fd或退出时,将被清理掉。
Unix提供了shell作为用户层程序可调用的文件操作工具,例如mkdir,ln和rm。这一设计允许任何人通过增加新的用户层程序来扩展命令行接口。现在看来,这种方式好像显而易见,但与Unix同时代设计的操作系统通常将这类命令内置到shell中(并将shell集成到内核中)。
cd是一个例外,它被集成在shell中(user/sh.c:160)。cd必须改变shell本身的当前工作目录。如果cd作为一个常规命令运行,shell可能会fork一个子进程,子进程运行cd,cd会改变子进程的工作路径。而父进程(即shell)的工作路径不会改变。
idiomatic