0.操作系统接口

操作系统的工作是将计算机的资源在多个程序之间共享,并且给程序提供一系列比硬件本身更有用的服务。管理并抽象底层硬件,举例来说,一个文字处理软件(比如word)不用关心自己使用的是何种硬盘。多路复用硬件,使得多个程序可以同时运行。最后,给程序提供一种受控的方式,使得程序之间可以共享数据,共同工作。

操作系统通过接口向用户程序提供服务。设计一个好的接口实际上是很难的。一方面我们希望接口设计得简单和精准,使其易于正确地实现;另一方面,我们可能忍不住想为应用提供一些更复杂的功能。解决这种矛盾的办法是让接口的设计依赖于少量的机制(mechanism),而通过这些机制的组合提供强大、通用的功能。

本书通过xv6操作系统来阐述操作系统的概念,它提供Unix操作系统中的基本接口(由Ken Thompson和Dennis Ritchie引入),同时模仿Unix的内部设计。Unix里机制结合良好的窄接口提供了令人吃惊的通用性。这样的接口设计非常成功,使得包括BSD、LInux、MacOSX\Solaris(甚至Microsoft Windows在某种程度上)都有类似的Unix接口。理解xv6是理解这些操作系统的一个良好起点。

如图,xv6使用了传统的内核概念,一个向其他运行中程序提供服务的特殊程序。每一个运行中程序(称之为进程)都拥有包含指令、数据、栈的内存空间。指令实现了程序的运算,数据用于运算过程的变量,栈管理了程序的过程调用。

进程通过系统调用使用内核服务。系统调用会进入内核,让内核执行服务然后返回。所以进程总是在用户空间和内核空间之间交替运行。

内核使用了CPU的硬件保护机制来保证用户进程只能访问自己的内存空间。内核拥有实现保护机制所需的硬件权限(hardware pricileges),而用户程序没有这些权限。当一个用户程序进行一次系统调用时,硬件会提升特权级并开始执行一些内核中预定义的功能。

内核提供的一系列调用就是用户程序可见的操作系统接口,xv6提供了Unix传统系统调用的一部分,它们是:

 

系统调用描述
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是一个普通的程序,它接收用户输入的命令并且执行它们,它也是传统Unix系统中最基本的用户界面。shell作为一个普通程序,而不是内核的一部分,充分说明了系统调用接口的强大:shell并不是也给特别的用户程序。这意味着shell是很容易被替代的,实际上这导致了现代Unix系统有着各种各样的shell,每一个都有自己的用户界面和脚本特性。xv6 shell本质上是一个Unix Bourne shell的简单实现。它的实现在第7850行。

进程和内存

一个xv6进程由两部分组成,一部分是用户内存空间(指令,数据,栈),另一部分是仅对内核可见的进程状态。xv6提供了分时特性:它在可用CPU之间不断切换,决定哪一个等待中的进程被执行。当一个进程不再执行时,xv6保存它的寄存器,当它们再次被执行时恢复这些寄存器的只。内核将每一个进程和一个pid(process identifier)关联起来。

一个进程可以通过系统调用fork来创建一个新的进程。fork创建的新进程被称为子进程,子进程的内容同创建它的进程(父进程)一样。fork函数在父进程,子进程中都返回(一次调用两次返回)。对于父进程返回子进程的pid,对于子进程它返回0.

int pid;
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会返回一个当前进程已退出的子进程,如果没有子进程退出,wait会等候直到有一个子进程退出。在上面的例子中,下面两行输出

parent: child=1234
child: exiting

 可能以任意顺序被打印,这种顺序由父进程或子进程谁先结束printf决定。当子进程退出时,父进程的wait也就是返回了,于是父进程打印:

parent: child 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这个程序,这个程序的参数列表为echo hello。大部分的程序都忽略第一个参数,这个参数惯例上是程序的名字(此是echo)

xv6 shell用以上调用为用户执行程序。shell的主要结构很简单那,详见main的代码(8001)。主循环通过getcmd读取命令行的输入,然后它调用fork来生成一个shell进程的副本。父shell调用wait,而子进程执行用户命令。举例来说,用户在命令行输入“echo hello”,getcmd会以echo hello为参数调用runcmd(7906),由runcmd执行实际的命令。对于echo hello,runcmd将调用exec。如果exec调用成功,子进程就会转而取执行echo程序里的指令。在某个时刻echo会调用exit,这会使得其父进程wait返回。你可能会疑惑为什么fork和exec为什么没有被合并为一个调用,我们之后会发现,将创建进程和加载程序分开两个过程是一个非常机智的设计。

xv6没有用户这个概念当然更没有不同用户间的保护隔离措施。按照Unix的术语来说,所有的xv6进程都以root用户执行。

I/O和文件描述符

文件描述符是一个整数,它代表了一个进程可以读写的被内核管理的对象。进程可以通过多种方式获得一个文件描述符,如打开文件、目录、设备,或者创建一个管道(pipe),或者复制已经存在的文件描述符。简单起见,我们常常把文件描述符指向的对象称为“文件”,文件描述符的接口是对文件、管道、设备的抽象,这种抽象使得它们看上去就是字节流。

每个进程都有一张表,而xv6内核就以文件描述符作为这张表的索引,所以每个进程都有一个从0开始的文件描述符空间。按照惯例,进程从文件描述符0读入(标准输入),从文件描述符1输出(标准输出),从文件描述符2输出错误(标准错误输出)。我们会看到shell正是利用了这种惯例来实现IO重定向。shell保证在任何时候都有三个打开的文件描述符(8007),他们是控制台(console)的默认文件描述符。

系统调用read和write从文件描述符所指的文件中读或写n个字节。read(fd,buf,n)从fd读最多n个字节(fd中可能没有n个字节),将它们拷贝到buf中,然后返回读出的字节数。每一个指向文件的文件描述符都和一个偏移关联。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");
        exit();
    }
}

这段代码中值得一提的是cat并不知道它是从文件、控制台或者管道中读取数据的。同样地cat也不知道它是写到文件、控制台或者别的上面地方。文件描述符的使用和一些惯例(如0是标准输入,1是标准输出)使得我们可以轻松实现cat。

 系统调用close会释放一个文件描述符,使得它未来可以被open,pipe,dup等调用重用。一个新分配的文件描述符永远都是当前进程的最小的未被使用的文件描述符。

文件描述符和fork的交叉使用使用IO重定向能够轻易实现。fork会复制父进程的文件描述符和内存,所以子进程和父进程的文件描述符一模一样。exec会替换调用它的进程的内存但是会保留它的文件描述符表。这种行为使得shell可以这样实现重定向:fork一个进程,重新打开指定的文件描述符,然后执行新的代码。下面是一个简化版的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是open执行时的最小可用文件描述符)。之后cat就会在标准输入指向input.txt的情况下运行。

xv6的shell正是这样实现IO重定位的(7930)。在shell的代码中,记得这时fork除了子进程,在子进程中runcmd会调用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,父进程只在子进程结束之后才运行write)。这种行为有利于执行的shell命令的顺序输出,例如(echo hello;echo world)>output.txt。

dup复制一个已有的文件描述符,返回一个指向同一个输入/输出对象的新描述符。这两个描述符共享同一个文件偏移,正如被fork复制的文件描述符一样。这里有另外一种打印“hello world”的办法:

fd = dup(1);
write(1, "hello", 6);
write(fd, "world\n", 6);

 从同一个原初文件描述符通过一系列fork和dup调用产生的文件描述符都共享同一个文件偏移,而其他情况下产生的文件描述符就不是这样的了,即使它们打开的都是同一个文件。dup允许shell像这样实现命令:ls existing-file non-exsiting-file > tmp1 2 >&1。2>&1告诉shell给这条命令一个复制描述符1的描述符2.。这样existing-file的名字和non-exsiting-file的错误输出都将出现在tmp1中。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,创建一个新的管道并且将读写描述符记录在数组p中。在fork之后,父进程和子进程都有了指向管道的文件描述符。子进程将管道的都端口拷贝在描述符0上,关闭p中的描述符,然后执行wc。当wc从标准输入读取时,它实际上是从管道读取的。父进程向管道的写端口写入然后关闭它的两个文件描述符。

如果数据还没有准备好,那么对管道执行的read会一直等待,直到有数据了或者其他绑定在这个管道写端口的描述符都关闭了。在后一种情况中,read会返回0,就像是一份文件读到了最后。读操作会一直阻塞直到不可能再有新的数据到来了,这就是为什么我们执行wc之前要关闭子进程的写端口。如果wc指向了一个管道的写端口,那么wc就永远看不到eof了。

xv6 shell对管道的实现(比如fork sh.c | wc -1)和上面的描述是类似的(7950)。子进程创建一个管道连接管道的左右两端。然后它为管道左右两端都调用runcmd,然后通过两次wait等待左右两端结束。管道右端可能也是一个带有管道的指令,如a|b|c,它fork两个新进程(一个b,一个,c),因此,shell可能要创建一个进程树。树的叶子节点是命令,中间节点是进程,它们会等待左子和右子执行结束。理论上,你可以让中间节点都运行在管道的左端,但做的如此精确会使得实现变得复杂。

pipe可能看上去和临时文件没有什么两样:命令

echo hello world | wc

 可以用无管道的方式实现:

echo hello world > /tmp/xyz; wc < /tmp/xyz

 但管道和临时文件起码有三个关键的不同点。首先,管道会进行自我清扫,如果是shell重定向的话,我们必须要在任务完成后删除/tmp/xyz。第二,管道可以传输任意长度的数据。第三,管道允许同步:两个进程可以使用一对管道来进行二者之间的信息传递,每一个读操作都阻塞调用进程,直到另一个进程用write完成数据的发送。

文件系统

xv6文件系统提供文件和目录,文件就是一个简单的字节数组,而目录包含指向文件和其他目录的引用。xv6把目录实现为一种特殊的文件。目录是一棵树,它的根节点是一个特殊的目录root。/a/b/c指向一个在b目录下的文件c,而b本身又是在目录a中的,a又是处于root目录下的。不从/开始的目录表示的是相对调用进程当前目录的目录,调用进程的当前目录可以通过chdir这个系统调用进行改变。下面这些的代码都打开同一个文件(假设所有涉及到的目录都是存在的)。

chdir("/a");
chdir("b");
open("c", O_RDONLY);

open("/a/b/c", O_RDONLY);

 第一个代码段将当前目录切换到 /a/b; 第二个代码片段则对当前目录不做任何改变。

有很多的系统调用可以创建一个新的文件或者目录:mkdir创建一个新的目录,open加上O_CREATE标志打开一个新的文件,mknod创建一个新的设备文件。mknod创建一个新的设备文件。下面这个例子说明了这三种调用:

mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONGLY);
close(fd);
mknod("/console", 1, 1);

 mknod在文件系统中创建一个文件,但是这个文件没有任何内容。相反,这个文件的元信息标志他是一个设备文件,并且记录主设备号和辅设备号(mknod的两个参数),这两个设备号唯一确定一个内核设备。当一个进程之后打开这个文件时候,内核将读写的系统调用转达到内核设备的实现上,而不是传递给文件系统。

fstat可以获取一个文件描述符指向的文件的信息。它填充一个名为stat的结构体,在stat.h中定义为:
 

#define T_DIR  1
#define T_FILE 2
#define T_DEV  3
// Directory
// File
// Device
     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的新文件。

open("a", O_CREATE|O_WRONGLY);
link("a", "b");

 读写a就相当于读写b。每一个inode都是由一个唯一的inode号直接确定。在上面这段代码中,我们可以通过fstat直到a和b都指向同样的内容:a和b都会返回同样的inode(ino),并且nlink数会设置为2。

系统调用unlink从文件系统移除一个文件名。一个文件的inode和磁盘空间只有当它的连接数变为0的时候才会被清空,也就是没有一个文件再指向它。因此再上面的代码最后加上

unlink("a")

 我们同样可以通过b访问到它。另外,

fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");

是创建一个临时inode的最佳方式,这个inode会在进程关闭fd或者退出的时候被清空。

xv6关于文件系统的操作都被实现为用户程序,例如mkdir,ln,rm等等。这种设计允许任何人都可以通过命令行扩展shell。现在看起来这种设计是显然的。但是Unix时代的其他系统的设计都将这样的命令内置在了shell中,而shell又是内知道内核中的。

有一个例外,那就是cd,他是shell中实现的(8016)。cd必须改变shell自身的当前工作目录。如果cd作为一个普通命令执行,那么shell就会fork一个子进程,而子进程会运行cd,cd只会改变子进程的当前工作目录。父进程的目录保持原样。

现实情况

UNIX将标准的文件描述符,管道和便于操作他们的shell命令整合在一起,这是编写通用,可重用程序的重大进步。这个想法激发了UNIX强大和流行的软件工具文化,而且shell也是首个所谓的脚本语言。UNIX的系统调用接口在今天仍然存在于许多操作系统中,诸如BSD,Linux,以及MacOSX。

现代内核提供了比xv6要多得多的系统调用和内核服务。最重要的一点,现代基于Unix的操作系统并不遵循早期Unix将设备暴漏为特殊文件的设计,比如刚才说的控制台文件。Unix的作者继续打造Plan9项目,它将“资源是文件”的概念应用到现代设备上,将网络、图形和其他资源都视作文件或者文件树。

 文件系统抽象是一个强大的想法,它被以万维网的形式广泛的应用在互联网资源上。即使如此,也存在着其他的操作系统接口的模型。Multics,一个Unix的前辈,将文件抽象为一种类似内存的概念,产生了十分不同的系统接口。Multics的设计的复杂性对Unix的设计者门产生了直接的影响,他们因此想把文件系统的设计做的更简单。

这本书考察xv6是如何实现类似Unix接口的,但涉及的想法和概念可以运用到Unix之外很多地方上。任何一个操作系统都需要让多个进程复用硬件,实现进程之间的相互隔离,并提供进程间通讯的机制。在学习xv6之后,你应该了解一些其他的更加复杂的操作系统,看看它们之中蕴含的xv6的概念。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值