Professordeng's Blog

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

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

系统调用 readwrite 从文件描述符所指的文件中读或者写 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 会释放一个文件描述符,使得它未来可以被 openpipedup 等调用重新使用。一个新分配的文件描述符永远都是当前进程的最小的未被使用的文件描述符。

文件描述符和 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 重定位的。在 shell 的代码中,记得这时 fork 出了子进程,在子进程中 runcmd 会调用 exec 加载新的程序。现在你应该很清楚为何 forkexec 是单独的两种系统调用了吧。这种区分使得 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);

从同一个原初文件描述符通过一系列 forkdup 调用产生的文件描述符都共享同一个文件偏移,而其他情况下产生的文件描述符就不是这样了,即使他们打开的都是同一份文件。dup 允许 shell 像这样实现命令:ls existing-file non-exsiting-file > tmp1 2>&12>&1 告诉 shell 给这条命令一个复制描述符 1 的描述符 2。这样 existing-file 的名字和 non-exsiting-file 的错误输出都将出现在 tmp1 中。XV6 shell 并未实现标准错误输出的重定向,但现在你知道该怎么去实现它。

文件描述符是一个强大的抽象,因为他们将他们所连接的细节隐藏起来了:一个进程向描述符 1 写出,它有可能是写到一份文件,一个设备(如控制台),或一个管道。