操作系统的任务是让多个程序共享计算机(资源),并且提供一系列基于计算机硬件的但更有用的服务。操作系统管理并且把底层的硬件抽象出来,举例来说,一个文字处理软件(例如word)不需要关心计算机使用的是哪种类型的磁盘。操作系统使得硬件可以多路复用,允许许多程序共同使用计算机并且在同一时间上运行。最后,操作系统为程序间的互动提供受控的方法,因此多个程序可以共享数据、协同工作。
计算机操作系统通过接口向用户程序提供服务。设计一个好的接口是一件困难的事情。一方面,我们希望设计出来的接口足够简单且功能单一(精准),这样能够容易地保证实现的正确性。而另一方面,我们可能会忍不住想为应用添加一些更加复杂的功能。解决这种矛盾的诀窍是让接口的设计依赖于少量的机制(mechanism),并通过这些机制的组合来提供更加通用的功能。
这本书以XV6操作系统作为一个实用的例子来阐述操作系统的观念。XV6提供Unix操作系统的基本接口(由Ken Thompson 和 Dennis Ritchies引入) ,同时也模仿了Unix的内部实现。Unix提供的窄接口(narrow interface)所实现的机制能够很好地组合起来,并且具有令人吃惊的通用性。这些接口设计得如此成功——以至于现代操作系统包括BSD、Linux、Mac OS X ,Solaris,甚至是Microsoft Windows(在某种较小的程度上)都拥有类似于Unix的接口。理解XV6是理解上述操作系统的好开端。
如图 Figure 0-1所示,XV6采用了传统的内核概念:内核是向运行中的其他程序提供服务的特殊程序。每一个运行中的程序称之为进程,都拥有包括指令集、数据、栈的内存空间。指令完成了程序的运算,数据为运算过程中的变量,而栈管理程序运行中的函数调用。
当进程需要内核所提供的服务时,进程调用了称为系统调用的操作系统接口。系统调用会进入内核,内核执行相应的服务后返回用户空间,所以进程总是在用户空间与内核空间之间交替进行着。
内核使用CPU的硬件保护机制来确保每一个在用户空间中执行的进程只能访问它自己的内存空间。内核拥有实现这些保护机制所需要的硬件特权,而用户程序没有这些特权。当用户进程调用系统调用时,硬件会提供权限登记,并且执行内核中预先设置的功能。
内核提供的一系列的系统调用集合,这些系统调用是用户程序可见的接口。XV6操作系统的内核提供了Unix系统调用的子集。下面这张列表中是所有XV6所提供的系统调用:
系统调用 | 描述 |
---|---|
fork() | 创建一个进程 |
exit() | 结束当前进程 |
wait() | 等待子进程结束 |
kill(pid) | 结束 pid 所指进程 |
getpid() | 返回当前进程 pid |
sleep(n) | 睡眠 n 秒 |
exec(filename, *argv) | 加载一个文件并执行它 |
sbrk(n) | 为进程内存空间增加 n 字节 |
open(filename, flags) | 打开一个文件,flags 指定读/写模式 |
read(fd, buf, n) | 从文件中读 n 个字节到 buf |
write(fd, buf, n) | 从 buf 中写 n 个字节到文件 |
close(fd) | 关闭fd指向的文件 |
dup(fd) | 复制 fd |
pipe( p) | 创建管道, 并把读和写的 fd 返回到p |
chdir(dirname) | 改变当前目录 |
mkdir(dirname) | 创建新的目录 |
mknod(name, major, minor) | 创建设备文件 |
fstat(fd) | 返回打开的文件信息 |
link(f1, f2) | 给 f1 创建一个新名字(f2) |
unlink(filename) | 删除文件 |
本章的剩余内容将概述XV6所提供的服务——进程、内存、文件描述符、管道以及文件系统,通过一段段的代码来介绍它们并且讨论shell是如何使用它们的。这些系统调用在shell上的使用,体现了它们的设计是多么独具匠心。
shell是一个普通的程序,它读取用户的命令并且执行它们,shell也是传统的类Unix(Unix-like)系统中主要的用户界面。实际上,shell也是一个用户程序,它并不是内核的一部分,这也说明了系统调用接口的强大:shell并没有什么特殊之处,它很容易被替代。所以,现代的类Unix操作系统有许多种shell可以选择,每种shell都有其自身的用户界面与脚本特性。XV6的shell 是Unix Bourne shell的一个简单实现。在第8350行能够找得到它的实现。
一个Xv6进程由用户内存空间(指令、数据、栈)和仅对内核可见的进程状态这两部分组成。Xv6能够分时运行进程:等待执行的多个进程能够在CPU可用时占用CPU,并不断切换。当一个进程不再执行而让出CPU时,Xv6保存了该进程的CPU上某些相关寄存器中的内容,方便该进程在下次占用CPU时恢复到上次运行的状态并接着运行。内核将每一个进程与一个唯一的进程标识符,即pid(process identifier)关联在一起。
一个进程可以使用系统调用fork
来创建一个新的进程。调用fork
的进程称为父进程,fork
创建了一个新的进程,称为子进程。子进程拥有与父进程完全相同的内存内容。在父进程的程序中,fork
函数返回的是子进程的pid,而在子进程的程序中,fork函数返回0。例如,思考下面代码片段:
int 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");
eixt();
}else{
printf("fork error\n");
}
系统调用exit
会导致进程停止执行并释放资源,例如内存或者打开的文件。系统调用wait
会返回一个当前进程已退出的子进程的pid,如果没有子进程退出,wait
会等待直到有一个进程退出。在例子中,输出结果为:
parent: child = 1234
child:eixting
可能会有不同顺序的结果,这取决于父进程与子进程谁先执行完printf函数。在子进程退出之后,父进程的wait
也就返回了,于是父进程打印:
parent:chlid 1234 is done
注意到父进程与子进程拥有不同的内存空间与寄存器,因此在父进程中改变某个变量的值,并不影响子进程中该变量的值,反过来也成立。
系统调用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");
这段代码用/bin/echo
这个程序代替了调用程序,/bin/echo
程序的参数列表为echo hello
。大部分程序忽略第一个参数,这通常是程序的名称。
xv6的shell使用上述的系统调用来运行用户程序。shell的主要结构很简单:详见main
的代码(在8501行),主循环使用getcmd
读取命令行的输入,然后它调用fork
,来创建shell进程的一份拷贝。父进程shell调用wait
,子进程执行命令。例如,如果用户输入了echo hello
,runcmd
(在8406行)将被调用并以echo hello
作为参数,runcmd
真正执行了命令。对于echo hello
,runcmd
将调用exec
(在8426行),如果exec
调用成功,那么子进程将代替runcmd
执行echo
指令。在某个时刻,echo
将调用exit
,这会使得父进程shell从wait
返回到main
。你或许会疑惑为什么fork
与exec
不合并为一个系统调用,我们稍后将看到,把创建进程与加载进程分割成两个系统调用是一个灵巧的设计。
Xv6通常隐式地分配用户空间的内存:当子进程复制父进程的内存时,fork
为子进程分配内存,而exec
分配了足够的内存来保存可执行文件。在运行时需要更多内存的进程可以调用sbrk(n)
来增加n字节的数据内存。sbrk
返回新内存的地址。
Xv6没有提供用户的概念,或者提供用户之间的保护隔离机制。用Unix的术语来说,所有的xv6的进程都以root的身份来运行。
文件描述符是一个整数,表示一个可被进程读或写内核管理对象。进程可以通过打开一个文件来获得该文件的文件描述符,文件可以是目录、设备,或者创建一个管道(pipe),或者通过复制已经存在的文件描述符。简单起见,我们把文件描述符指向的对象称为“文件”。文件描述符接口是对文件、管道、设备的抽象,使它们看上去都只是字节流。
每个进程都有一张进程表,Xv6内核使用文件描述符作为进程表的索引,使每一个进程都有一个从0开始的私有的文件描述符空间。按照Unix惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标准输出),将错误信息写入到文件描述符2(标准错误)。正如我们将看到的,shell运用这三个文件描述符来实现I/O重定向以及管道。shell进程确保它始终打开了这三个文件描述符(在8507行),这些是控制台的默认文件描述符。
系统调用read
和write
从文件描述符所指的文件读或写数个字节的数据。read(fd,buf,n)
从文件描述符fd所指的文件读取最多n个字节,并将它们拷贝到缓冲区,同时返回成功读取到的字节数。每个文件描述符都与一个偏移值相关,read
读取数据时从当前文件的偏移值开始读取,然后偏移值增加成功读取的字节数,随后的read
会从新的文件偏移读取数据。当没有更多的数据可以读取时,read
返回0,表示文件结束了。
系统调用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();
}
if(write(1,buf,n) != n){
fprintf(2,"write error\n");
eixt();
}
}
这段代码需要重视的地方在于,cat
并不知道它是从文件、控制台还是管道中读取数据的。同样的,cat
也不知道它是否写到了一个控制台、一个文件或其他的什么地方。文件描述符的使用与一些惯例——0是标准输入,1是标准输出,2是标准错误,使我们很轻松地实现了cat
。
系统调用close
释放了一个文件描述符,使得该文件描述符未来可以被open
pipe
dum
等系统调用重用。一个新分配的文件描述符当前进程中最小的、未使用的文件描述符。
文件描述符与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
能够保证会使用0作为文件input.txt
的文件描述符,这是因为0是此时进程中最小的、未使用的文件描述符。然后,cat
就会在标准输入指向input.txt
的情况下运行。
xv6 的shell正是以这样的方式实现I/O重定向的(在8430行)。回想一下,在shell进程中会fork
出一个shell子进程,子进程运行runcum
系统调用,runcum
调用exec
加载新的程序。现在你应该很清楚为什么把fork
与exec
分开调用是个好主意了:这种分离使得shell可以在子进程执行指定程序之前对子进程进行修改。
虽然fork
复制文件描述符表,但父进程与子进程共享每一个文件的当前偏移。思考下面这个例子:
if((fork() == 0)
{
write(1,"hello ",6);
exit();
}else{
wait();
write(1,"world\n",6);
}
在这段代码的执行末尾,文件描述符1所指的文件将包含数据"hello world"。父进程的系统调用write
从子进程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\n",6);
如果两个文件描述符是通过系统调用fork
或系统调用dup
从同一个原始的文件描述符派生而来,那么这两个文件描述符共享同一个文件偏移,否则文件描述符不共享文件偏移,即使这两个文件描述符是使用系统调用open
来打开同一个文件而得到的。系统调用dup
允许shell这样来实现命令:ls existing-file non-existing-file > tmp1 2>&1
。2>&1
通知shell把文件描述符2给命令,这个文件描述符2是文件描述符1的拷贝。已存在的文件名称与因文件不存在而引发的错误信息将显示在文件temp1
中。xv6的shell不支持标准错误输出的重定向,但现在你知道如何去实现它。
文件描述符是一个强大的抽象,因为它隐藏了它所指向的文件的细节:一个向文件描述符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{
write(p[1],"hello world\n",12);
close(p[0]);
close(p[1]);
程序调用了系统调用pipe
,pipe
创建了一个新的管道并将读与写这两个文件描述符保存在数组p中。执行了fork
之后,父进程与子进程都拥有与管道相关的文件描述符。子进程复制了管道读的一端到文件描述符0,接着关闭了文件描述符p[0]及p[1],然后执行了系统调用wc
。当wc
从标准输入读取时,它实际上是从管道读取数据的。父进程从管道的写端口写入数据,然后关闭了管道的文件描述符。
如果管道中没有可用的数据,从管道读取数据的系统调用read
将一直等待,直到有数据写入管道或者所有与管道写端口关联的文件描述符都被关闭。在后面这种情况中,read
返回0,就好像数据的读取已经到了文件结束部分(end-of-file)。读操作会一直阻塞直到不可能有新数据到来,这就是为什么我们在执行wc
之前要关闭子进程的写端口。如果wc
指向一个管道的写端口,那么wc
就永远看不到eof了。
xv6 shell使用了与上面代码类似的方法,实现了如grep fork sh.c | wc -l
这样的管道(在8450行)。子进程创建一个管道连接管道的左右两端,然后为管道的左右两端都调用runcmd
,然后通过调用两次wait
等待左右两端结束。管道的右端可能也是一个带有管道的命令(例如 a|b|c
),它fork
两个新的子进程(一个b
,一个c
)。因此,shell可能会创建出一棵进程树,树的叶子节点为命令,中间节点为进程,它们等待左右子树执行结束。原则上来说,你可以让中间节点都运行在管道的左端,但做得如此精确会使得实现变得复杂。
管道看起来似乎比临时文件没什么两样:管道echo hello world | wc
可用用无管道的方式实现为echo hello world >/temp/xyz; wc< /tmp/xyz
。
管道与临时文件的区别至少有三点。第一,管道会进行自我清扫,如果使用文件重定向的话,shell需要在任务完成后删除temp/xyz
。第二,管道可以传递任意长度的数据流,而文件重定向需要在磁盘上有足够的空闲空间来存储数据。第三,管道允许同步:两个进程可以使用一对管道来进行彼此间的通信,调用进程的read
操作会被阻塞,直到另一个进程调用write
完成数据的发送。
xv6 文件系统提供数据文件与目录,文件就是一个简单的字节数组,目录包含了指向文件或其他目录的引用。Xv6把目录作为特殊的文件来处理。目录构成了一棵树,树根为一个称为root
的特殊目录。路径a/b/c
指向了一个名为c
的文件或目录,c
在文件目录b
下,而目录b
又处于目录a
下,a
又是处于root
目录之下。不以/
开头的目录表示相对当前进程目录的目录,进程的当前目录可以通过系统调用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
转发到内核设备的实现上,而不是传递给文件系统。
fstat
用来获取文件描述符所指向的对象的信息。这些信息使用stuct stat
结构来描述,该结构定义在头文件stat.h
中:
#define T_DIR 1 //目录
#define T_FILE 2 //文件
#define T_DEV 3 //设备
struct stat{
short type; //文件的类型
int dev; //文件系统的磁盘设备
uint ino; //inode号码
short nlink; //链接到文件的链接数目
unit size; //文件大小,以字节为单位
};
文件名称与文件本身是有很大区别的。同一个文件称为inode
,它可以由多个不同的文件名,称为links
(链接)。系统调用link
为文件创建了另一个名称,它们指向同一个已存在文件的inode
。这段代码创建了创建了一个新文件,a
与b
都是该文件的名称。
open("a",O_CREATE|O_WRONLY);
link("a","b");
对文件a
进行读写就是对文件b
进行读写。每一个inode
使用一个唯一的inode号
来确定。在上面这些代码示例后,我们可以通过fstat
来验证a
与b
都指向了同样的内容:它们都返回了相同的inode号
,而且nlink
会被设置为2。
系统调用unlink
从文件系统中删除一个名字。文件的inode
以及存储该文件内容的磁盘空间只有在文件的链接数目(nlink
)为0时被清空,此时没有文件描述符指向该文件。因此在上面代码的末尾加入:
unlink("a");
此时只有通过b
来访问文件的inode
与文件内容。更多的,
fd = open("/tmp/xyz",O_CREATE|O_RDWR);
unlink("/tmp/xyz");
这是一种创建临时inode
的惯用方法,当进程关闭文件描述符fd
或进程退出时,这个inode
会被自动清空。
Xv6对文件系统进行操作的命令被实现为用户程序,例如mkdir
,ln
,rm
等等。这种设计允许任何人为shell拓展新的命令。现在看来这种设计似乎是理所应当的,但其他在Unix时代设计的系统都将这些命令内置在shell之中(而且把shell也内置在内核之中)。
cd
是这种设计的一个例外,它是在shell中实现的(在8516行)。cd
必须改变shell自身的当前工作目录。如果cd
作为一个普通命令来执行,那么shell会 fork
一个子进程,由子进程执行cd
,cd
会改变子进程的工作路径,然而父进程的工作目录不会被改变。
UNIX将"标准的"文件描述符,管道以及操纵它们的便捷的shell语法组合在一起,这是编写通用且可重用程序的重大进步。这种想法引发了“软件工具”的文化以及Unix的强大,而shell也成为首个所谓的“脚本语言”。Unix的系统调用接口在今天仍然存在于许多操作系统上,如BSD、Linux以及Mac OS X。
现代内核提供了比xv6要多得多的系统调用以及各种类型的内核服务。最重要的一点,从Unix衍生出来的现代操作系统没有沿用早期Unix把设备暴露为特殊文件的设计,比如上面所提到的控制台文件。Unix系统的作者打算建立Plan 9,它将“资源是文件”的观念应用到现代设备上,把网络、图形以及其他的资源作为文件或者文件树。
把文件系统进行抽象是个强大的想法,它被以万维网的形式应用在网络的资源上。尽管如此,还有其他操作系统接口的设计模型。Multics,一位Unix的前辈,将文件抽象成类似与内存的概念,产生了风格非常不一样的接口。Multies设计的复杂性对Unix设计者由直接的影响,他们尝试把文件系统的设计做的更简单。
这本书详述xv6是如何实现类Unix的接口,但设计的想法与观念可以应用到Unix之外的更多地方。任何操作系统必须让多个进程复用硬件,进程与进程之间需要隔离开来,并提供进程间通信的机制。在学习了xv6之后,你应该能够看到其他更复杂的操作系统背后蕴藏着xv6的种种概念。