1.2 I/O和文件描述符

文件描述符是一个小整数(small integer),表示进程可以读取或写入的由内核管理的对象。进程可以通过打开一个文件、目录、设备,或创建一个管道,或复制一个已存在的描述符来获得一个文件描述符。为了简单起见,我们通常将文件描述符所指的对象称为“文件”;文件描述符接口将文件、管道和设备之间的差异抽象出来,使它们看起来都像字节流。我们将输入和输出称为 I/O。

在内部,xv6内核使用文件描述符作为每个进程表的索引,这样每个进程都有一个从零开始的文件描述符的私有空间。按照惯例,进程从文件描述符0读取(标准输入),将输出写入文件描述符1(标准输出),并将错误消息写入文件描述符2(标准错误)。正如我们将看到的,shell利用这个约定来实现I/O重定向和管道。shell确保它始终有三个打开的文件描述符(user/sh.c:151),这是控制台的默认文件描述符。

readwrite系统调用以字节为单位读取或写入已打开的以文件描述符命名的文件。read(fd,buf,n)从文件描述符fd读取最多n字节,将它们复制到buf,并返回读取的字节数,引用文件的每个文件描述符都有一个与之关联的偏移量。read从当前文件偏移量开始读取数据,然后将该偏移量前进所读取的字节数:(也就是说)后续读取将返回第一次读取返回的字节之后的字节。当没有更多的字节可读时,read返回0来表示文件的结束。

系统调用write(fd,buf,n)将buf中的n字节写入文件描述符,并返回写入的字节数。只有发生错误时才会写入小于n字节的数据。与读一样,write在当前文件偏移量处写入数据,然后将该偏移量向前推进写入的字节数:每个write从上一个偏移量停止的地方开始写入。

以下程序片段(构成程序cat的本质)将数据从其标准输入复制到其标准输出。如果发生错误,它将消息写入标准错误:

  1. char buf[512];
  2. int n;
  3. for (;;) {
  4. n = read(0, buf, sizeof buf);
  5. if (n == 0)
  6. break;
  7. if (n < 0) {
  8. fprintf(2, "read error\n");
  9. exit(1);
  10. }
  11. if (write(1, buf, n) != n) {
  12. fprintf(2, "write error\n");
  13. exit(1);
  14. }
  15. }

代码片段中需要注意的重要一点是,cat不知道它是从文件、控制台还是管道读取。同样也不知道它是打印到控制台、文件还是其他什么地方。文件描述符的使用以及文件描述符0是输入而文件描述符1是输出的约定允许了cat的简单实现。

close系统调用释放一个文件描述符,使其可以被未来使用的openpipedup系统调用重用(见下文)。新分配的文件描述符总是当前进程中编号最小的未使用描述符。

文件描述符和fork相互作用,使I/O重定向更容易实现。fork复制父进程的文件描述符表及其内存,以便子级以与父级在开始时拥有完全相同的打开文件。系统调用exec替换了调用进程的内存,但保留其文件表。此行为允许shell通过fork实现I/O重定向,在子进程中重新打开选定的文件描述符,然后调用exec来运行新程序。下面是shell运行命令cat < input.txt的代码的简化版本。

  1. char* argv[2];
  2. argv[0] = "cat";
  3. argv[1] = 0;
  4. if (fork() == 0) {
  5. close(0);
  6. open("input.txt", O_RDONLY);
  7. exec("cat", argv);
  8. }

在子进程关闭文件描述符0之后,open保证使用新打开的input.txt:0的文件描述符作为最小的可用文件描述符。cat然后执行文件描述符0(标准输入),但引用的是input.txt。父进程的文件描述符不会被这个序列改变,因为它只修改子进程的描述符。

Xv6shell中的I/O重定向代码就是这样工作的(user/sh.c:82)。回想一下,在代码执行到这里时,shell已经fork出了子shell,runcmd将调用exec来加载新程序。

open的第二个参数由一组标志组成,这些标志以位表示,用于控制打开的操作。可能的值定义在文件控制(fcntl)头文件(kernel/fcntl.h:1-5)中

宏定义 功能说明
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 可读可写
O_CREATE 如果文件不存在则创建文件
O_TRUNC 将文件截断为零长度

现在应该很清楚为什么forkexec分离的用处了:在这两个调用之间,shell有机会对子进程进行I/O重定向,而不会干扰主shell的I/O设置。我们可以想象一个假设的forkexec系统调用组合,但是用这样的调用进行I/O重定向是很笨拙的。Shell可以在调用forkexec之前修改自己的I/O设置(然后撤销这些修改);或者forkexec可以将I/O重定向的指令作为参数;或者(最不吸引人的是)可以让每个程序(如cat)执行自己的I/O重定向。

尽管fork复制了文件描述符表,但是每个基础文件偏移量在父文件和子文件之间是共享的,比如下面的程序:

  1. if (fork() == 0) {
  2. write(1, "hello ", 6);
  3. exit(0);
  4. } else {
  5. wait(0);
  6. write(1, "world\n", 6);
  7. }

在这个片段的末尾,附加到文件描述符1的文件将包含数据hello world。父进程中的写操作(由于等待,只有在子进程完成后才运行)在子进程停止写入的位置进行。这种行为有助于从shell命令序列产生顺序输出,比如(echo hello;echo world) >output.txt

dup系统调用复制一个现有的文件描述符,返回一个引用自同一个底层I/O对象的新文件描述符。两个文件描述符共享一个偏移量,就像fork复制的文件描述符一样。这是另一种将“hello world”写入文件的方法:

  1. fd = dup(1);
  2. write(1, "hello ", 6);
  3. write(fd, "world\n", 6);

如果两个文件描述符是通过一系列forkdup调用从同一个原始文件描述符派生出来的,那么它们共享一个偏移量。否则,文件描述符不会共享偏移量,即使它们来自于对同一文件的打开调用。dup允许shell执行这样的命令:ls existing-file non-existing-file > tmp1 2>&12>&1告诉shell给命令的文件描述符2是描述符1的副本。现有文件的名称和不存在文件的错误信息都会显示在tmp1文件中。Xv6 shell不支持错误文件描述符的I/O重定向,但是现在你知道如何实现它了。

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