第 0 章
操作系统接口
操作系统的工作是:
1).将计算机的资源在多个程序间共享,并且给程序提供一系列比硬件本身更有用
的服务;
2).管理并抽象底层硬件,举例来说,一个文字处理软件(比如 word)不用去关心
自己使用的是何种硬盘;
3).多路复用硬件,使得多个程序可以同时运行(或看上去同时运行);
4).最给程序间提供一种受控的交互方式,使得程序之间可以共享数据、共同工作。
操作系统通过接口向用户程序提供服务。设计一个好的接口非常困难,一方面我们
希望接口设计得尽可能短小精悍,这样便于去实现它;另一方面我们希望这些接口足够
强大,从而为应用程序提供便利。解决这种矛盾的办法是设计尽可能少的机制,通过这
些机制的组合提供强大、通用的接口。
本书通过 xv6 操作系统来阐述操作系统的概念,它提供 Unix 操作系统中的基本
接口(由 Ken Thompson 和 Dennis Richie 引入),同时模仿 Unix 的内部设计。
Unix 的接口很简单,但恰当搭配就够组合出惊人的通用性。这样的接口设计非常成功,
使得包括 BSD,Linux,Mac OS X,Solaris (甚至在某种程度上 Microsoft)都有类
似 Unix 的接口。理解 xv6 是理解这些操作系统的一个良好起点。
如图 0-1 所示,xv6 使用了传统的内核概念——一个特殊的向其他运行中程序提供
服务的程序。每一个运行中程序(称之为进程),有一块包含指令、数据、栈的内存空
间。指令实现了程序的运算,数据是运算的载体,栈管理了程序的过程调用。
1
进程通过系统调用使用内核服务。系统调用会进入内核,让内核执行服务然后返回。
所以进程总是在用户空间和内核空间之间交替运行。
内核使用了 CPU 的硬件保护机制保证用户进程只能访问自己的内存空间。内核拥
有实现保护机制所需的硬件特权(hardware privileges),而用户程序没有这些权限。当
一个用户程序进行一次系统调用时,硬件提升了特权级并且开始执行一些内核中预定义
的功能。
内核提供的一系列系统调用就是用户程序可见的操作系统接口,xv6 操作系统提供
了 Unix 传统系统调用的一部分,它们是:
系统调用
描述
fork()
exit()
wait()
kill(pid)
getpid()
sleep(n)
创建进程
结束当前进程
等待子进程结束
结束 pid 所指进程
获得当前进程 pid
睡眠 n 秒
exec(filename, *argv)
加载并执行一个文件
sbrk(n)
增加进程 n 字节的用户进程内存空
间
open(filename, flags)
打开文件,flags 指定读/写模式
read(fd, buf, n)
write(fd, buf, n)
close(fd)
dup(fd)
pipe( p)
从文件中读 n 个字节到 buf
从 buf 中写 n 个字节到文件
关闭打开的 fd
复制 fd
创建管道,从 p 反悔读和写的 fd
2
系统调用
描述
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 保存它的 CPU 寄
存器,当他们再次被执行时恢复这些寄存器的值。内核将每个进程和一个 pid 关联起
来。
一个进程可以调用系统调用 fork 来创建一个新的进程。fork 创建的新进程被称为
子进程,子进程的内存中的内容同创建它的进程(父进程)一样。fork 函数在父进程、
3
子进程中都返回(一次调用两次返回)。对于父进程它返回子进程的 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 文件格
式,第 2 章将详细介绍它。exec 接受两个参数:可执行文件名和一个字符串参数数组。
举例来说:
char *argv[3];
argv[0] = "echo";
argv[1] = "hello";
4
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”,runcmd(7906) 会运行真正的 echo hello(/bin/echo
hello)。如果 exec 成功执行,子进程就会执行 echo 的指令而非 runcmd 的指令。在某
个时刻 echo 会调用 exit,这会使得主进程从 wait 返回。你可能会疑惑为什
么 fork 和 exec 为什么没有被合并成一个调用,我们之后将会发现,将创建进程——加
载程序分为两个过程是一个非常机智的设计。
xv6 通常隐式地分配用户的内存空间。fork 分配子进程需要的父进程的内存拷贝,
exec 分配足够的内存用于装载一份可执行文件。一个在运行时需要额外内存的进程可
以通过调用 sbrk(n) 来增加 n 字节的数据内存。 sbrk 返回新的内存的地址。
xv6 没有用户,按照 Unix 的术语来说,所有的 xv6 进程都以 root 用户执行。
I/O 和文件描述符
文件描述符是一个整数,它代表了一个进程可以读写的对象。进程可以通过多种方
式获得一个文件描述符,如打开文件、目录、设备,或者创建一个管道(pipe),或者
复制已经存在的文件描述符。简单起见,我们常常把文件描述符指向的对象称为“文件”。
文件描述符的接口是对文件、管道、设备等的抽象,这种抽象使得它们看上去就是字节
流。
每个进程都有一张表,而 xv6 内核就以文件描述符作为这张表的索引,所以每个
进程都有一个从 0 开始的文件描述符空间。按照惯例,进程从文件描述符 0 读入(标
准输入),从文件描述符 1 输出(标准输出),从文件描述符 2 输出错误(标准错误
输出)。我们会看到 shell 正是利用了这种惯例来实现 I/O 重定向。shell 保证在任何
时候都有 3 个打开的文件描述符(8007),他们是控制台(console)的默认文件描述
符。
系统调用 read 和 write 从文件描述符所指的文件中读或者写 n 个字节。read(fd,
buf, n) 从 fd 读最多 n 个字节(fd 可能没有 n 个字节),将它们拷贝到 buf 中,然后
5
返回读出的字节数。每一个指向文件的文件描述符都和一个偏移关联。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 的交叉使用使得 I/O 重定向能够轻易实现。fork 会复制父进程
的文件描述符和内存,所以子进程和父进程的文件描述符一模一样。exec 会替换调用
它的进程的内存但是会保留它的文件描述符表。这种行为使得 shell 可以这样实现重定
向:fork 一个进程,重新打开指定文件的文件描述符,然后执行新的程序。下面是一个
简化版的 shell 执行 cat
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 正是这样实现 I/O 重定位的(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 结束的地方继续写。这种行为有利于顺序执行的 shell 命令
的顺序输出,例如 (echo hello; echo world)>output.txt。
dup 复制一个已有的文件描述符,返回一个新的指向同一个文件对象的描述符。这
两个描述符共享一个文件偏移,正如被 fork 复制的文件描述符一样。这里有另一种打
印 “hello world” 的办法:
fd = dup(1);
write(1, "hello", 6);
write(fd, "world\n", 6);
7
从同一个原初文件描述符通过一系列 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 之后,父进程和子进程都有了指向管道的文件描述符。子进程将管道的读端口
8