原文链接:https://pdos.csail.mit.edu/6.828/2021/xv6/book-riscv-rev2.pdf
原文标题:XV6:一种简单、类Unix的教学操作系统
文件描述符是一个小整数,它代表内核管理的对象,内核可以从中读取或写入。进程可以通过打开文件、目录或者设备,或者创建管道,或者复制已存在的文件描述符,来获取文件描述符。简单起见,我们后面提到的对象的文件描述符指的是”文件“;文件描述符接口把文件,管道,和设备的区别抽象的隐藏,让它们看起来都像字节流。我们将用I/O指代输入和输出。
在内部,xv6内核使用文件描述符作为预处理表的索引,所以每一个进程都拥有以0为开始的文件描述符的私有空间。习惯上,进程从文件描述符0处(标准输入)读取,向文件描述符1处(标准输出)写入,在文件描述符2处(标准错误)写入错误信息。就像我们将要看到的那样,shell利用惯例进行I/O重定向和管道。shell确保有3个文件描述符一直处于打开状态(user/sh.c:151),这3个是控制台的缺省文件描述符。
read和write系统调用从文件描述符命名的已打开文件中读取和写入字节流。read(fd, buf, n)调用从文件描述符fd处读取最多n个字节,复制到buf中,返回读取的比特数。每一个指向文件的文件描述符都有一个与它相关联的偏移量。Read从当前文件的偏移量中读取数据,然后通过读取的字节数向前移动偏移量。当读取完毕后,read返回 0来标记文件的 结束。
write(fd, buf, n)调用从buf中向文件描述符fd写入n个字节,并返回写入的字节数。只有当错误发生时,写入的字节数才会不足 n 个。像 read 一样,write 在当前的文件偏移量写入数据,然后根据字节数向前移动偏移量。每一个 write 调用都从上一个结束的地方开始。
下面的程序段( 本质上是cat 调用)从标准输入复制数据到标准输出。如果有错误发生,就会在标准错误中写入信息。
char buf[512]
......
在这个代码段中,需要注意的是 cat 不知道它究竟是从文件,控制台还是从管道中读取数据。而且 cat 也不知道它是输出到控制台,文件还是到其它地方。文件描述符的使用以及0为输入1为输出的习惯,让 cat 的执行变得简单。
close 系统调用给出一个文件描述符,将来 open, pipe 或者 dup 系统调用 (见下)都可以再利用。一个新分配的文件描述符总是使用当前进程中尚未使用的最小值。
文件描述符与 fork 交互,让 I/O重定向变得容易执行。 Fork 在复制父进程的内存的同时,也复制父进程的文件描述符表,所以子进程会从与父进程完全相同的打开文件处开始。系统调用 exec 替换调用进程的内存,但是会保留它的文件描述符表。这样的行为允许 shell 通过 fork、再次 open 子进程中所选的文件描述符来执行 I/O 重定向,然后调用 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 为新打开的 input.txt 使用这个文件描述符 :0将是最小的可用描述符。在这个过程中,父进程的文件描述符没有变化,只更改了子进程的描述符。
xv6 shell的 I/O重定向代码完全按照这样的方式工作(user/sh.c:82)。回溯一下,在代码的这位置,shell 已经 fork 子进程的shell,runcmd 将调用 exec 来加载新程序。
open的第二个参数包含一套标识,通过比特流来表达,控制 open作什么。在文件控制头(kernel/fcntl.h: 1-5)里面定义了可能的值:O_RDONLY,O_WRONL, O_REWR, O_CREATE 和 O_TRUNC,它们指示 open打开文件用于读取,写入,既读取又写入,如果不存在就创建文件,截短文件至0长度。
为什么 fork 和 exec 是各自独立的调用呢,现在应该很清楚了:在这两个调用之间,shell 就有机会在不打扰主shell I/O设置的情况下重定向子进程的I/O。也许人们可以假设有一个合并的 forkexec 系统调用,但是用这样的调用来执行 I/O 重定向的选项看起来很可怕。在调用 forkexec前,shell可以修改它自己的 I/O设置(然后撤回这些修改); 或者 forkexec 可以把I/O重定向的指令作为参数; 或者(不那么有吸引力地)让每一个像 cat 一样的程序作自己的 I/O重定向。
尽管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 > tep1 2>&1。2>&1 让 shell 给出命令,文件描述符2是文件描述符1的副本。现有文件的名称和不存在文件的错误信息都会在文件 tmp1 中显示。 xv6 shell 不支持错误文件描述符的 I/O 重定向,不过你已经知道该如何解决了。
文件描述符是一个强有力的抽象,因为它们隐藏了与之相联系的对象的细节:一个写入文件描述符1的进程可以是写入到一个文件,写入到一个像控制台那样的设备,或写入到一个管道。