Job of operating system:
操作系统使得多个程序分享一台计算机,提供一系列仅靠硬件无法支持的服务。
- 管理与抽象低级别硬件(如:文件处理程序不需要关注使用哪种硬盘)
- 使得多个程序分享硬件(programs that can run at the same time)
- 为程序提供可控的相互交流(分享数据或工作)的方式。
操作系统是通过interface为用户程序提供服务,然而设计良好的接口并不容易:
一方面,希望接口简单且应用场景单一,以实现起来更简单;
一方面,希望提供复杂的feature给应用。
So,设计依赖机制较少,但可结合起来以提供更具通用性的接口。
本书使用一个操作系统作为例子来诠释操作系统的概念。xv6 系统提供了一些由 Ken Thompson 与 Dennis Ritchie 的 Unix系统引入的基本接口,同时也模仿了Unix 系统的内部设计。Unix 系统提供的狭窄接口能很好底结合起来,以实现令人惊喜的通用性。此接口是如此的成功,以至于现代操作系统,BSD,Linux,Mac OS X, Solaris, 甚至Microsoft Windows 都具有类 Unix 的接口。理解 xv6 是理解这些操作系统的不错的开端。
正如 图1.1所示,xv6 采用了传统的kernel形式,内核是向用户程序提供服务的特定程序。每个运行的程序称之为进程(process),都具有其对应的内存空间(memory),内存中包含指令、数据与栈(instructions, data, and a stack)。
- 指令 (instructions) 实现程序的计算 (computation)
- 计算基于变量 (data)
- 栈组织程序的过程调用 (procedure calls)
一台计算机通常有很多进程,但仅一个内核。
当进程需要 invoke 内核服务时,程序 invoke 系统调用 (system call),系统调用进入内核,内核服务并返回,所以进程在用户空间 user space 与内核空间 kernel space 之间交替执行。
内核使用CPU的硬件保护机制 (hardware protection mechanisms) 以保证每个执行在用户空间的程序只能访问自己的内存。内核拥有实现此硬件保护机制所需的硬件特权 (hardware privileges) ,用户程序 (user program) 无此特权。当用户程序 invokes 一个系统调用时,硬件会提高特权级别并开始执行内核中的 pre-arranged 函数。
内核提供的系统调用集是用户程序可见的 interface。Xv6内核提供的服务与系统调用,是传统 UNIX内核提供的子集。
本章的剩余部分将提到 xv6 系统的服务:进程,内存,文件描述符,管道以及一个文件系统,通过代码段与讨论shell使用这些服务的方法,来详述xv6服务。shell(Unix的命令行用户接口)对系统调用的使用显示了这些系统调用设计的精巧。
shell 读取用户输入并执行,其作为一个用户程序,而非内核的一部分,也体现了 system call interface 的强大之处。shell 并不特殊,易被取代, 现代UNIX系统有一系列shell可供选择。The xv6 shell is a simple implementation of the essence of the Unix Bourne shell.
1.1 进程与内存
一个 xv6 进程包含用户空间的内存(指令,数据与栈),以及仅内核可见的各进程状态。xv6 为等待执行的进程间分配CPU秒。当一个进程不在执行时,xv6 保存其CPU寄存器,下次执行时恢复。内核为每个进程关联一个进程标识符(PID)。
一个进程(父进程)可以通过 fork 系统调用创建新进程。fork 创建的新进程称之为子进程,子进程拥有内存的内容与父进程相同。fork 在子进程与父进程都返回,具体而言,父进程中返回子进程的 PID,子进程中返回 0。例如,下面代码段
int pid = fork();
if(pid > 0){
printf("parent: child=%d\n", pid); //1
pid = wait((int *) 0);
printf("child %d is done\n", pid);
} else if(pid == 0){
printf("child: exiting\n");//2
exit(0);
} else {
printf("fork error\n");
}
exit 系统调用终止调用进程的执行,释放资源,如内存及打开的文件。Exit 接受一个整型数,一般 0 表示成功,1 表示失败。wait 系统调用返回当前进程的某个退出/终止的子进程的PID,拷贝子进程的退出状态,并传递给 wait 的地址参数,如果调用进程无子进程退出,wait 等待某个子进程退出;若调用进程无子进程,wait 立刻返回 -1。当然,如果调用进程不在乎子进程的退出状态,完全可以给 wait 传递一个 0 地址。
(注意1,2 处的打印顺序不一定)
尽管,开始子进程的内存内容与父进程相同,但是,二者通过不同的内存与寄存器执行,改变某个进程中的某个变量不会影响另一个进程中该变量的值。如,wait 返回值存在父进程的 pid 中,这不会改变子进程的pid 变量,其值依然为0。
exec 系统调用使用加载自文件的新 memory image取代调用进程的内存。使用的文件必须为特定格式,以指明文件的哪个部分为指令、数据,以及从哪个指令开始执行等。xv6 使用 ELF 格式,第三章将讨论更多细节。当 exec 成功执行时,不会返回到调用进程,加载自文件的指令从ELF header 中声明的入口点开始执行。exec 有两个参数:文件名,字符串数组。
char *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 使用以上的系统调用代替用户执行程序。shell 的主要结构很简单,参考 user/sh.c 内的main函数。主循环使用 getcmd 读取用户的一行输入。然后调用 fork 拷贝 shell 进程。父进程调用 wait,而子进程执行命令。例如,如果用户向 shell 输入 “echo hello”,runcmd 被调用的同时被传递了参数 "echo hello",runcmd 实际执行命令。对于 "echo hello",exec 将被调用。若 exec 成功,子进程将执行来自 echo 而非 runcmd 的指令。某个点,echo 调用 exit ,这将使得父进程从 wait 返回。
为什么 fork 与 exec 不作为一个调用整体实现呢?之后我们将发现这种分隔在shell 实现 IO重定向时大有用处。立即替换(exec)一个刚刚被创建的复制进程是一种浪费,为了避免这种浪费,通过虚拟内存技术,如写时复制,内核优化了 fork 的实现。
xv6 大多数时候分配内存是显式的:fork 分配子进程所需用来拷贝父进程内存的内存,exec 分配存储可执行文件的内存。一个进程在运行时若需要更多空间,可调用 sbrk 实现增长 n 字节的数据内存。sbrk 返回新内存的位置。
1.2 I/O 与 文件描述符
文件描述符是小的整型,指代一个进程可读写的,受内核管理的对象。一个进程可以通过打开一个文件、目录、设备或创建一个管道,或复制一个已存的描述符来获得一个文件描述符。为了简要起见,我们通常将一个文件描述符指向的对象视为一个 “文件”,文件描述符接口通过抽象,消除了文件、管道、设备之间的差异性,使之都可被视为字节流。输入输出将被视为 I/O。
xv6 内核使用文件描述符作为一个各进程都拥有的一张表的索引,每个进程有一个保存从0开始的文件描述符的私有空间。传统上,一个进程从 0 文件描述符(标准输入)读取,向 1 文件描述符(标准输出)写入,向 2 文件描述符(标准错误) 写入错误消息。正如我们即将看到的那样,shell 基于这种惯例实现 IO重定向与管道。shell 确保总是有三个文件描述符为开启状态,也正是console 的默认文件描述符。
read 及 write 系统调用分别从或向文件描述符指代的打开文件读取或写入一定的字节数。read (fd, buf, n) 从文件描述符 fd 读取至多 n 字节,并拷贝内容到 buf 内,同时返回成功读到的字节数。每个文件描述符指代的文件都对应的偏移量 offset。Read 从文件当前的偏移量开始读取数据,并且偏移量会增长实际读到的字节数:后面的 read 返回的字节数会紧随着前一个 read 返回的字节。若无更多字节可读,read 返回 0 来表示文件末尾。
write (fd, buf, n) 从 buf 取n字节写入到文件描述符 fd,并返回成功写入的字节数。除非发生错误才会写入少于 n 字节。就像 read,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 不知道它读取数据的来源是文件,console 还是管道。类似的,cat 也不知道被写入的是文件还是什么。文件描述符的使用,加上0作为标准输入,1 作为标准输出的惯例,使得 cat 的简单实现成为可能。
close 系统调用释放一个文件描述符,使之可以被之后的 open, pipe, 或 dup 系统调用重用。新分配的文件描述符总是当前进程未使用的最小描述符。
文件描述符与 fork 交互可以简单地实现 I/O 重定向。fork 拷贝父进程的文件描述符表到其子集的内存,子进程开始拥有与父进程完全相同的文件描述符。exec 系统调用替换调用进程的内存,但是会保留文件表。这样,shell 可以通过 fork,重新打开子进程中的指定文件描述符,然后调用 exec 运行新程序来实现 I/O重定向。下面展示了 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。cat 执行时 0 指向文件 input.txt。当然父进程的文件描述符不会改变。
·xv6 shell 的 I/O重定向代码就是如此运行的(user/sh.c 82)。记住,代码到这里 shell 已经创建(forked)了 子shell,而 runcmd 将调用 exec 来加载新程序。
open 系统调用的第二个参数由一系列以 比特位 表示的flag值组成,这些 flag 值决定了 open 的行为。可能的值在 fcntl 头(kernel/fcntl.h)内有定义:O_RDONLY, O_WRONLY, O_RDWR, O_CREATE 与 O_TRUNC,对应 open 会以读、写、读写,或若文件不存在则创建的方式打开文件并文件长度截取为0 的方式打开文件。
现在就很清楚了,为什么 fork 与 exec 是各自独立的系统调用,在二者之间,shell 有机会在不干扰主 shell 的 I/O 的前提下重定向子 shell 的 I/O。假设有一个结合二者的名为 forkexec 的系统调用,但是这样实现 I/O 重定向就有点尴尬了。shell 可以在调用 forkexec 前设置 I/O,之后再恢复设置;或者 forkexec 可以将 I/O 重定向的指令作为参数;或者每个程序如 cat 可以自己实现重定向。
尽管 fork 拷贝文件描述符表,底层每个文件的偏移量都被父子进程共享。考虑下面这个例子:
if(fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
代码段尾,文件描述符1指向的文件将包含 " hello world ",因为由 wait,父进程中的 write 将接着子进程write 写后的位置继续写入。这一行为,可以根据 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 作为描述符1 的拷贝。existing file 的名字,与 non existing file 的错误消息都将出现在 tmp1 文件内。xv6 shell 不支持错误文件描述符的 I/O 重定向,但是现在你知道怎样实现它。
文件描述符的抽象是很强大的,因为它隐藏了其指向对象的细节:向 1文件描述符写入的进程可能在向一个文件,设备(如 console)或管道写入。
1.3 管道
一个管道实际上是一块内核buffer,以一对文件描述符的形式提供给进程,一个作为读取,另一个作为写入。向管道的一段写入数据,则数据在管道的另一端可读。管道提供了进程间相互通信的方式。
下列代码通过将标准输入关联到管道读端来执行程序 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从标准输入读取时,实际j就从管道读取。父进程关闭管道的读端,向管道写入,然后关闭写端。
如果没有可读数据,作用于管道的read等待数据写入,或指向写端的全部文件描述符被关闭,后续的read返回0,就像到达文件的末尾。read会阻塞直到不可能有新数据到达,这有一个重大影响,即子进程在执行wc之前要关闭管道写端。如果wc的某个文件描述符指向管道的写端,则wc永远到达不了end-of-file。
通过类似上面代码的方式,Xv6 shell实现了如 grep fork sh.c | wc -l 这样的pipeline。子进程创建连接pipeline左右两端的管道,然后为pipeline的左右两端分别调用 fork 与 runcmd,等待其完成。Pipeline 右端可能是自身包含管道的命令,如a|b|c,该命令自己fork两个子进程(a | b | c)。因此,shell可能创建进程树。树的叶子是命令,内部节点是等待左右子进程结束的进程。
理论上,可以用内部节点执行pipeline的左侧,但这样可能使得实现更加复杂。考虑做下面的修改: sh.c 不在内部节点为了 p->left 调用 fork 以及执行运行 runcmd (p->left)。例如:echo hi | wc 不会产生输出,因为当 echo hi 在 runcmd中退出时,内部进程退出,就不会再调用 fork 以执行管道的右端。当然,通过取消内部进程的 runcmd取消调用 exit 可以修复此行为,但是同时会加大了代码的复杂度:runcmd 需要知道当前是否是一个 内部进程。取消为了 runcmd (p->right) 而调用 fork也会提高复杂度。例如,sleep 10 | echo hi 会立刻打印 “hi” 而不是先等待10秒,因为 echo 立刻运行并退出,不等待 sleep 完成。既然 sh.c的设计旨在尽可能简单,就不会刻意避免创建内部进程。
管道也许看上去并没有比临时文件强到哪里,比如下面的pipeline:
echo hello world | wc
完全可以不借助管道机制实现:
echo hello world > /tmp/xyz; wc < /tmp/xyz
实际上,就这种场景而言,管道至少有4点优于临时文件。首先,管道会自动清理,shell不必特意删除 /tmp/xyz;其二,管道能传递任意长度的数据流,而文件重定向需要磁盘(disk)上有足够的可用空间以存储数据;其三,管道允许并行的pipeline,而文件方法必须等待前一程序完成后再执行下一程序;最后,进程间通信时,管道的读写阻塞机制相比文件的非阻塞行为更有效率。
1.4 文件系统
xv6 文件系统由文件与目录组成,其中文件包含无解释字节数组,目录包含指向文件或其它目录的名字。目录有树结构,从 root 目录开始。以 /a/b/c路径为例,其指向一个名为c的文件或目录,c在b目录下,b在a目录下,a在根目录 / 下。不以 / 开头的路径为相对路径,相对于调用进程的当前路径,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节点,可以有多个名字,称之 links(链接)。每个link 由目录中的一条数据项组成,该数据想包含文件名与inode节点的引用。一个inode节点存储了对应文件的metadata(元数据),包含类型(文件、目录、设备),长度,文件内容在磁盘上的位置,指向该文件的链接数量。
fstat 系统调用从文件描述符指向的inode节点取到信息,为一个定义在 kernel/stat.h里的struct stat 结构体赋值:
#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。
ulink 系统调用从文件系统删除一个名字。文件的inode与存储文件内容的磁盘空间仅当文件的链接计数为0 且无文件描述符指向时才被释放。因此,添加下面一行代码,就只能通过b获取inode与文件内容。进一步,下面是一种常用的创建无名临时inode节点的手法,这样当进程关闭 fd 或退出时,该inode被清理。
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
Unix 提供shell可调用的文件工具作为用户级别的程序,例如 mkdir 与rm。这种设计使得用户可通过添加新的用户级别程序来扩展命令行接口。这种设计现在看来是理所当然的,但是,与Unix同时代其它系统一般是将这些命令嵌入 shell (built into)。
一个特例是 cd,这个是嵌入 shell的(user/sh.c)。cd必须改变shell自身的当前工作目录。如果cd 作为一个普通命令运行,然后shell会创建一个子进程,子进程运行cd,cd会改变子进程的工作目录。父进程(即shell)的工作目录不会改变。
1.5 真实世界
Unix将标准的文件描述符、管道及对应的便利的操作特性结合起来,对于编写通用的可重用程序是一个重大进步。这催生了一种称之为软件工具(software tools)的文化,这种文化对Unix的强大与普及有重大影响,shell则是首个被成为脚本语言的。在今天的BSD,Linux与Mac OS X系统中仍然可见Unix系统调用接口(interface)的影子。
Unix系统调用接口通过 POSIX 实现了标准化。Xv6 不是POSIX标准的,缺少了很多系统调用(包括基础接口如 lseek),它提供的很多系统调用也与标准的不同。Xv6 系统的目标在于实现类UNIX系统调用的同时保持简洁。几个人为了运行基本的UNIX程序,为Xv6 扩展了一些系统调用与一个简单的C库。现代内核,提供了多得多的系统调用,以及比xv6多得多的内核服务。比如,支持网络,窗口系统,用户级别的线程,设备驱动等等。现代内核持续地快速迭代,提供了POSIX外的很多特点。
UNIX用一个简单的文件名与文件描述符接口的集合,统一了多种资源的入口(文件,目录,设备)。当然这种设计也适用于其它资源,一个很好的例子是 Plan9,这个将“资源即文件”的理念拓展到网络,图像等等。然而,大多数源自UNIX的操作系统并没有遵循。
文件系统于文件描述符的抽象是很强大的。尽管如此,操作系统接口也有其它的模型。Multics,作为UNIX之前的操作系统,将文件存储抽象得像内存一样,因此接口的风格很不同。Multics系统的设计对UNIX系统有直接的影响,尽管UNIX希望实现更简单的设计。
Xv6并没有用户的概念,更不会保护用户不受其他用户的影响。用UNIX的术语来说,所有的xv6进程作为root进程运行。
这本书说明了xv6怎样实现类UNIX接口,但是这些理念适用范围远不止UNIX。任意操作系统都必须实现对底层硬件的多路复用,屏蔽进程与其它进程,提供受控的进程间通信机制。学习xv6 系统后,可以理解更复杂的操作系统,其它操作系统体现了xv6的一些特点。