操作系统的任务是在多个进程之间共享计算机,并提供比硬件本身支持更有用的服务。操作系统抽象和管理底层的硬件,以使上层的程序不必关心硬件的细节,例如,一个文档处理程序无需关心所使用的磁盘的类型。操作系统也可以在多个进程之间共享硬件,这样多个进程就可以同时运行了。最终,操作系统为程序提供了受控的交互方式,让它们可以共享数据或同时工作。
操作系统使用接口来为用户程序提供服务。设计一个好的接口是很困难的。一方面我们希望它简单以便于正确地实现,另一方面我们又希望能提供大量复杂的特性。解决这一矛盾的技巧是,在设计接口的时候让它们依赖一些机制,这些机制可以组合起来提供更多的通用性。
xv6实现了Unix的基本接口,并模仿了它的内部设计。
如下图所示,xv6符合传统的内核(一个提供程序执行服务的特殊程序)形式。每个程序是一个进程(process),有自己的内存用以包含指令、数据和栈。指令实现程序的计算,数据是计算所需的变量(variable)。使用栈来组织程序过程的调用。一台计算机通常有多个程序(进程)但是只有一个内核。
进程通过系统调用(system call)使用系统服务。这样,进程就在内核空间和用户空间之间切换。
内核使用CPU提供的硬件保护机制保证每个在用户空间执行的进程只能访问它自己的内存。内核使用硬件特权来实现这种保护,而用户程序没有这些特权。当用户程序发起一个系统调用时,硬件会提升特权级别并在内核态执行预先安置好的程序。
内核提供的系统调用就是用户程序可以看到的接口。XV6 提供了一些和 Unix 类似的系统调用。下表列出了xv6 包含的所有的系统调用:
system call description int fork() 创建一个进程,并返回子进程的 PID int exit(int status) 终止当前进程;状态(status)report to wait(),无返回值int wait(int *status) 等待一个 child 退出;退出状态(exit status)保存在 *status中;返回 child PIDint kill(int pid) 终止指定进程 PID。返回 0或者-1(for error)。int getpid() 返回当前进程的 PID int sleep(int n) Pause for n clock ticks. int exec(char *file, char *argv[]) 载入一个 file 并传入指定参数 argv 执行;只有有错误发生时返回 char *sbrk(int n) 为进程内存增加 n bytes。返回新的内存的起始地址 int open(char *file, int flags) 打开一个文件; flags指定read/write。返回一个fd(file descriptor)int write(int fd, char *buf, int n) 从 char* buf写入 n 个字节到指定的fd;返回 nint read(int fd, char *buf, int n) 从 char* buf读取 n 个字节;returns number read; or 0 if end of file.int close(int fd) 释放指定的文件 int dup(int fd) Return a new file descriptor referring to the same file as fd.int pipe(int p[]) 创建管道,将 read/write文件描述符放入p[0]和p[1]int chdir(char *dir) 变更当前目录 int mkdir(char *dir) 创建新目录 int mknod(char *file, int, int) Create a device file. int fstat(int fd, struct stat *st) 将一个已打开文件的信息放入 *stint stat(char *file, struct stat *st) Place info about a named file into *st.int link(char *file1, char *file2) Create another name (file2) for the file file1. int unlink(char *file) Remove a file. 本章的其余部分概述了xv6提供的服务:进程、内存、文件描述符、管道、文件系统,并讨论了shell。
shell只是一个普通的程序,它从用户那里读取命令并执行它们。它的代码在user/sh.c。
1.1 进程、内存
一个 xv6 进程包含用户空间内存(指令、数据和栈)和对内核私有的进程状态。xv6 在可用的 CPU 之间切换进程,当一个进程没有运行时,xv6 保存其CPU 寄存器,进程下次运行时再重新载入。内核为每个进程分配一个 ID,即 PID(Process Identifier)。
一个进程可以使用fork来创建一个新进程。系统调用fork会创建一个被称为child process的新进程,新进程的内存和父进程时完全一样的。fork调用在父进程和子进程中都会返回(即返回 2 次)。在父进程中,返回子进程的 PID;在子进程中,返回 0。考虑下面的调用代码:
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");
}
对fork函数的一点分析
- p为当前进程,np为子进程。
- uvmcopy把父进程的内存复制给子进程。
- np->tf->a0 = 0保证了子进程返回0,因为寄存器a0保存了函数的返回值。
- return pid对于子进程来说,在汇编层面应该实际执行的返回寄存器a0的值。
exit系统调用让调用它的进程停止运行并释放资源(如内存、打开的文件等)。exit接受一个整数作为状态参数,0代表成功,1代表失败。
wait等待当前进程的某个子进程退出;如果有子进程退出(exited or killed),返回该进程的 PID,并把退出状态复制到传递给wait的地址上。如果调用者没有子进程,那么wait直接返回-1。如果不关心子进程的退出状态,可以直接给wait传递参数0作为地址。
fork, wait and exit文件位置:kernel/proc.c
上述代码中执行结果为:
parent: child=1234
child: exiting
打印顺序随机,取决于父进程和子进程谁先调用printf。
当子进程退出后(exited),父进程的wait返回,父进程打印:
parent: child 1234 is done
值得注意的是,尽管初始时父进程和子进程有相同的内存内容,但是父子进程在执行时使用不同的内存和寄存器:在一个进程中改变变量值不会影响另一个。例如,当父进程中wait的返回值赋给pid之后,并不会改变子进程中pid的值,子进程中pid值依然为 0。
exec调用会把当前进程的内存替换为文件里保存的内存镜像并执行之。文件需要时符合要求的格式,以指定文件中哪些部分是指令,哪些部分是数据,从哪条指令开始等。xv6 使用 ELF 格式。当exec成功时,并不返回到调用程序,相反,会从文件载入指令并开始执行。 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。大多数程序忽略参数列表的第一个参数,一般来说,第一个参数是程序的名字。
xv6的shell使用上述方式代表用户执行程序。shell 的主要结构很简单:可以查看代码user/sh.c:145 的 main函数。
main函数利用getcmd循环从用户输入读取一行- 然后调用
fork来创建shell进程的一份拷贝 - 父进程调用
wait来等待子进程执行程序
例如,如果用户在 shell 中输入 echo hello,那么 runcmd 就会被调用,并且调用时的参数列表为 echo hello。runcmd (user/sh.c:58)执行真正的程序。对于 echo hello 来说,将会调用 exec(suer/sh.c:78)。如果 exec 执行成功,那么子进程就会从 echo (而不是 runcmd) 来执行指令。在某一时刻,echo 会调用 exit,这将会导致父进程 wait 结束。
你可能会有疑惑:为什么 fork 和 exec 不合并为一条来调用呢?后面我们将会看到 shell 在实现I/O redirection 的时候利用了这种分离。为了避免创建一个进程的拷贝然后马上替换原来进程的情况(exec),内核使用写时复制(cpoy-on-write)来优化fork 的实现。
xv6 隐式分配用户空间内存:fork 为子进程分配父进程内存副本所需的内存;exec 为要执行的文件分配足够的内存。对于一个在运行时(run-time)需要更多内存的进程(比如 malloc),可以调用 sbrk(n) 来为 data memory 增加 n 字节。(sbrk 返回新内存的位置。)
1.2 I/O 和文件描述符(file descriptors)
一个文件描述符是一个小的整数,它代表了一个内核管理的对象(kernel-managed object),进程可以对这个对象进行读或写操作。进程可能通过如下方法获取文件描述符,打开文件、目录或设备,创建管道,复制一个已有的描述符。为简单起见,文件描述符指向的对象都被称为是“文件”;文件描述符抽象出来文件、管道和设备的共同点,使它们看起来都像是字节流。
在每个进程表里都把文件描述符作为索引,这样每个进程都有文件描述符的私有空间,文件描述符都是从0开始计数。按照惯例,0是标准输入,1是标准输出,2是标准错误。shell就是使用的这个约定实现的I/O重定向和管道。shell始终打开了三个文件描述符,作为控制台的默认文件描述符。
相关系统调用在
kernel/sysfile.c中实现。
系统调用 read(fd, buf, n) 从文件描述符 fd 指向的文件读取最多 n 个字节,并复制到 buf,并返回读取的字节数。每个文件描述符都记录了它在文件里的偏移位置,read就是从当前偏移位置开始读取数据的,然后在那个偏移量上递增读取的字节数。当无法读取更多的字节时,返回 0 表示到达文件末尾。
系统调用 write(fd, buf, n) 从buf写入n个字节到文件描述符fd所指向的文件,并返回写入的字节数。当有错误发生时,可能会写入少于 n 个字节。它对文件描述符里偏移量的操作和read是一样的。
read和write代码见kernel/file.c。
下面的代码片段(cat 的核心功能)从标准输入拷贝数据到标准输出。如果出错,则向 standard error 写入一条消息。
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还是 pipe 读取内容,也不知道它正在向哪里写出。
系统调用 close 释放一个文件描述符,这样这个文件描述符就可以被其它的系统调用(open、pipe、dup)使用了。总是优先分配当前进程里未使用的数字最小的描述符。
文件描述符和fork的交互可以比较容易地实现I/O重定向。fork把文件描述符表复制给了子进程,而exec在替换子进程的内存的时候并不会替换这个文件描述符表。这样shell在实现I/O重定向的时候,只要forking,重新打开关闭的文件描述符,执行新程序就可以了。下面是在 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中的重定向就是这么工作的(user/sh.c:82)。
open的第二个参数指定一些标志位来决定打开方式。这些标志定义在 kernel/fcntl.h:1-5,包含:
- O_RDONLY
- O_WRONLY
- O_RDWR
- O_TRUNC (To truncate the file to zero length)
现在,可以看出把fork和exec分开的好处:shell 无需改动 main shell 的 I/O 就可以重定向子进程的 I/O。我们可以假想一个 forkexec 调用,但是使用这个调用来实现 I/O 重定向是笨拙的。因为 shell 在调用 forkexec 之前需要先改变它自己的 I/O 设置,执行完后再改回去;或者 forkexec 需要接受 I/O 重定向相关的参数;或者,最不吸引人的是,类似 cat ,每个程序都需要学会处理 I/O 重定向。
尽管 fork 会复制file descriptor table,每个文件的偏置(offset)在父进程和子进程之间共享。考虑下例:
if(fork() == 0) {
write(1, "hello ", 6);
exit(0);
} else {
wait(0);
write(1, "world\n", 6);
}
上述代码执行完后,文件描述符 1 对应的文件包含 “hello world”。父进程中的 write (多亏了 wait,父进程的 write 会在子进程完成后才执行)从子进程的 offset 开始写入。这种行为有助于从shell命令序列中产生连续的输出,就像 (echo hello; echo world) >output.txt。
系统调用 dup 复制一个已有的文件描述符,返回一个新的文件描述符,这个新的文件描述符和原文件描述符指向相同的底层I/O实体。(这个底层I/O实体可能是管道、索引结点或设备)。这两个文件描述符共用同一个offset,就像用fork复制的那样。除了fork和dup,文件描述符之间是没有办法共享位移的。有了dup系统调用,我们就可以把标准输出复制给标准错误,进而在shell里实现2>&1这样的功能(把标准错误和标准输出都作为标准输出)。xv6不支持标准错误的I/O重定向,但你已经知道怎么实现它了。
文件描述符是个强有力的抽象,因为可以用同一个接口处理不同的实体:文件,设备和管道。
1.3 管道 (pipes)
管道用于进程间的通信,它其实是内核里一块小的缓冲区,这个缓冲区向进程提供了一对文件描述符,一个用于读而另一个用于写。向管道的一端写数据,会使管道的另一端可以读这些数据。管道提供了一种进程间通信的方式。
如下是程序 wc (word-count)的示例代码,它把标准输入连接到了一个管道的读取端。
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\en", 12);
close(p[1]);
}
程序调用pipe来创建管道,并把读和写的文件描述符记录在数组p里。执行fork后,父进程和子进程都有了指向管道的文件描述符。子进程关闭p里的文件描述符,把文件描述符0分配个给读取端(p[0]),然后调用exec执行wc。当wc从标准输入里读取输入时,它就是从管道里读。父进程关闭管道的读取端,向管道写入数据,然后关闭写入端。
当管道里没有数据的时候,管道上的read要么等数据写入,要么等写入端的文件描述符被关闭;在后一种情况下,read将返回0,就像到达了数据文件的尾部一样。如果新数据不可能再到来,read就会一直阻塞,这就是在执行wc之前子进程要关闭管道的写端的原因之一:如果有一个wc的文件描述符指向了管道的写端,wc就再也看不到文件结束了。
XV6 shell实现的管道和上面的代码是类似的,详见user/sh.c:100。子进程创建了一个管道。然后它为管道的左端和右端都调用fork 和runcmd,并且等待两边都完成。管道的右端可能也是一个包含了管道的命令,那么所有使用了管道的这些子进程就构成了一颗树。树的叶子是命令,而内部结点是等待左子结点和右子结点都完成的进程。
理论上讲可以让内部结点从管道的左端开始执行,但这会增加复杂性。
看起来管道做的事,用临时文件重定向也可以做。但实际上,管道比临时文件至少有如下优势:
- 管道会自动清除它自己,而使用重定向则要手动清除临时文件。
- 管道可以传输任意长度的数据流,而重定向则要求磁盘有足够的空间来保存所有的数据。
- 管道可以并行执行,而重定向只能依次执行。
- 如果进行的是进程间通信,管道阻塞读和写这样的语义比文件的非阻塞语义更有效。
1.4 文件系统
XV6的文件系统提供了文件和目录,文件就是字节组成的数组,而目录由带着名字的文件和其它目录组成。全部目录构成了一个树,它从root目录开始。路径给出了一个文件或目录的位置。不以 / 开始的路径是相对路径。路径除了可以从根目录开始,也可以从进程的当前目录开始,当前目录可以用chdir系统调用来切换目录。下面两组指令都是打开文件 c:
chdir("/a");
chdir("b");
open("c", O_RDONLY);
open("/a/b/c", O_RDONLY);
可以创建文件或目录的系统调用:mkdir创建一个新的目录,open带标志O_CREATE可以创建一个新的data file,mknod创建一个设备文件:
mkdir("/dir");
fd = open("/dir/file", O_CREATE|O_WRONLY);
close(fd);
mknod("/console", 1, 1);
mknod 创建一个指向设备的特殊文件。使用 major 和 minor (传给 mknod 的两个实参)来唯一指定一个内核设备。当一个进程要打开这个设备文件的时候,内核就不会把read和write系统调用传给文件系统了,而是传给内核设备。
另外,文件名和文件本身是有区别的。文件的底层实现是 索引结点(inode, index node),而一个索引结点可以有多个名字,叫做 链接(link),每个链接都是目录中的一个条目(entry)构成,这个条目包含文件名和一个 inode 引用。一个 inode 包含对应文件的元数据(metadata),比如文件类型(file, directory or device),长度(length),文件在磁盘的位置以及指向这个文件的链接的数目等。fstat用于取出文件描述符所指向的那个文件的元信息,它会把这些信息填充到结构体struct stat里(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系统调用可以给已有的索引结点再创建一个文件名。下面的代码片段为文件常见了两个名字 “a” 和 “b”:
open("a", O_CREATE|O_WRONLY);
link("a", "b");
对于同一个文件,不管用它的哪个文件名读写,其效果都是一样的。一个索引结点和它的 索引结点号 是一一对应的。在上面的代码片段中,通过 fstat 可以确定 a 和 b 指向相同的文件:二者都返回相同的索引节点号(inode number),此时 nlink 的为 2。unlink系统调用用于从文件系统里移除一个文件名。当文件的链接记数为0且没有文件描述符指向它,那个文件的索引结点和磁盘空间才会被释放。
unlink("a");
另外,下面的代码片段是一种创建没有名字的临时 inode 的理想方式,当进程关闭或退出时,清理此临时 inode。
fd = open("/tmp/xyz", O_CREATE|O_RDWR);
unlink("/tmp/xyz");
像mkdir, ln, rm这样的用于文件操作的shell命令,都被实现为用户态的程序。这样任何人都可以扩展用户程序了。这似乎看起来是显而易见的,但同时期的其它系统常常是把这些命令内建到shell里,然后又把shell内建到内核里。
但cd 命令是个例外,它是内建在shell里的,因为它必须改变shell自身的当前工作目录。如果cd是一个常规命令,那么它就会作为子进程来运行,它改变的是子进程的工作目录。而我们想改变的父进程的工作目录并没有改变。
1.5 Real World
Unix 系统的系统调用接口被标准化为 POSIX (Portable Operating System Interface)。xv6 并不遵守 POSIX。
