第十章 系统级 I/O

系统级 I/O

输入/输出(I/O)是在主存和外部设备(例如磁盘驱动器、终端和网络)之间复制数据的过程。输人操作是从 I/O 设备复制数据到主存,而输出操作是从主存复制数据到 I/O 设备。

所有语言的运行时系统都提供执行 I/O 的较髙级别的工具。例如,ANSI C 提供标准I/O 库,包含像 printf 和 scanf 这样执行带缓冲区的 I/O 函数。C++ 语言用它的重载操作符<<(输入)和>>(输出)提供了类似的功能。在 Linux 系统中,是通过使用由内核提供的系统级 Unix I/O 函数来实现这些较高级别的 I/O 函数的。大多数时候,高级别 I/O 函数工作良好,没有必要直接使用 Unix I/O。那么为什么还要麻烦地学习 Unix I/O 呢?

  • 了解 Unix I/O 将帮助你理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到 I/O 和其他系统概念之间的循环依赖。例如,I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解 1/0, 你必须理解进程,反之亦然。在对存储器层次结构、链接和加载、进程以及虚拟内存的讨论中,我们已经接触了I/O 的某些方面。既然你对这些概念有了比较好的理解,我们就能闭合这个循环,更加深人地研究 I/O。
  • 有时你除了使用 Unix I/O 以外别 无选择。在某些重要的情况中,使用高级 I/O 函数不太可能,或者不太合适。例如,标准 I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O 库还存在一些问题,使得用它来进行网络编程非常冒险。

这一章介绍 Unix I/O 和标准 I/O 的一般概念,并且向你展示在 C 程序中如何可靠地使用它们。除了作为一般性的介绍之外,这一章还为我们随后学习网络编程和并发性奠定坚实的基础。

10.1 Unix I/O

一个 Linux 文件就是一个 m 个字节的序列:
B0, B0, … ,Bk, … , Bm-1
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O, 这使得所有的输人和输出都能以一种统一且一致的方式来执行:

  • 打开文件
  • Linux shell 创建的每个进程开始时都有三个打开的文件
  • 改变当前的文件位置
  • 读写文件
  • 关闭文件

10.2 文件

每个 Linux 文件都有一个类型(type)来表明它在系统中的角色:

  • 普通文件
  • 目录(directory)
  • 套接字

其他文件类型包含命名通道(named pipe)。 符号链接(symbolic link), 以及字符和块设备(character and block device), 这些不在本书的讨论范畴。

Linux 内核将所有文件都组织成一个目录层次结构(directory hierarchy), 由名为/(斜杠)的根目 录确定。系统中的每个文件都是根目录的直接或间接的后代。图 10-1 显示了Linux 系统的目录层次结构的一部分。


图 10-1 Linux 目录层次的一部分。尾部有斜杠表示是目录

作为其上下文的一部分,每个进程都有一个当前工作目录(current working directory)来确定其在目录层次结构中的当前位置。你可以用 cd 命令来修改 shell 中的当前工作目录。

目录层次结构中的位置用路径名(pathname)来指定。路径名是一个字符串,包括一个可选斜杠,其后紧跟一系列的文件名,文件名之间用斜杠分隔。路径名有两种形式:

  • 绝对路径名
  • 相对路径名

10.3 打开和关闭文件

进程是通过调用 open 函数来打开一个已存在的文件或者创建一个新文件的:

1
2
3
4
5
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(char int flags, modest mode);
// 返回:若成功则为新文件描述符,若出错为一1。

最后,进程通过调用 close 函数关闭一个打开的文件。

1
2
3
#include <unistd.h>
int close(int fd);
// 返回:若成功则为 0,若出错则为 一1。

关闭一个已关闭的描述符会出错。

10.4 读和写文件

应用程序是通过分别调用 read 和 write 函数来执行输入和输出的。

1
2
3
4
5
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
// 返回:若成功则 为读的 字节数,若 EOF 则为 0, 若出错为一U
ssize.t write(int fd, const void *buf, size_t n);
// 返回:若成功则 为写的 字节数,若出错则为 一1。

read 函数从描述符为 fd 的当前文件位置复制最多 n 个字节到内存位置 buf。返回值一1表示一个错误,而返回值 0 表示 EOF 否则,返回值表示的是实际传送的字节数量。

write 函数从内存位置 buf 复制至多 n 个字节到描述符 fd 的当前文件位置。

通过调用 lseek 函数,应用程序能够显示地修改当前文件的位置,这部分内容不在我们的讲述范围之内。

在某些情况下,read 和 write 传送的字节比应用程序要求的要少。这些不足值(short count)不表示有错误。出现这样情况的原因有:

  • 读时遇到 EOF
  • 从终端读文本行
  • 读和写网络套接字

实际上,除了 EOF, 当你在读磁盘文件时,将不会遇到不足值,而且在写磁盘文件时,也不会遇到不足值。然而,如果你想创建健壮的(可靠的)诸如 Web 服务器这样的网络应用,就必须通过反复调用 read 和 write 处理不足值,直到所有需要的字节都传送完毕。

10.5 用 RIO 包健壮地读写

在这一小节里,我们会讲述一个 I/O 包,称为 RIO(Robust I/O, 健壮的 I/O)包,它会自动为你处理上文中所述的不足值。在像网络程序这样容易出现不足值的应用中,RIO包提供了方便、健壮和髙效的 I/O。RIO 提供了两类不同的函数:

  • 无缓冲的输入输出函数
  • 带缓冲的输入函数

10.5.1 RIO 的无缓冲的输入输出函数

通过调用 rio_readn 和 rio_writen 函数,应用程序可以在内存和文件之间直接传送数据。

1
2
3
4
#include "csapp.h"
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
// 返回:若成功则为 传送的字节数,若 EOF 则为 0(只对 rio_readn 而言), 若出错则为 -1。

rio_readn函数从描述符 fd 的当前文件位置最多传送 n 个字节到内存位置 usrbuf 。类似地,rio_wrften 函数从位置 usrbuf传送 n 个字节到描述符 fd。rio_read 函数在遇到 EOF 时只能返回一个不足值。函数决不会返回不足值。对同一个描述符,可以任意交错地调用 rio_readn 和 rio_writen。

10.5.2 RIO 的带缓冲的输入函数

假设我们要编写一个程序来计算文本文件中文本行的数量,该如何来实现呢?一种方法就是用 read 函数来一次一个字节地从文件传送到用户内存,检查每个字节来查找换行符。这个方法的缺点是效率不是很高,每读取文件中的一个字节都要求陷入内核。一种更好的方法是调用一个包装函数(rio_readlineb),它从一个内部读缓冲区复制一个文本行,当缓冲区变空时,会自动地调用 read 重新填满缓冲区。对于既包含文本行也包含二进制数据的文件, 我们也提供了一个 rio_readn 带缓冲区的版本,叫做 rio_readnb。它从和 rio_readlineb —样的读缓冲区中传送原始字节。

1
2
3
4
5
6
#include "csapp.h"
void rio_readinitb(rio_t *rp, int fd);
// 返回:无。
ssize_t rio_readlineb(rio_t *rp, void *usrbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *usrbuf, size_t n);
// 返回:若成功则为读的 字节数,若 EOF 则为 0, 若出错则为 一1。

10.6 读取文件元数据

应用程序能够通过调用 stat 和 fstat 函数,检索到关于文件的信息(有时也称为文件的元数据(metadata))。

1
2
3
4
5
#include <unistd.h>
#include <sys/stat.h>
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);
// 返回:若成功则为 0,若出错则为 一1。

stat 函数以一个文件名作为输入,并填写如图 10-9 所示的一个 stat 数据结构中的各个成员。fstat 函数是相似的,只不过是以文件描述符而不是文件名作为输人。当我们在 11.5 节中讨论 Web 服务器时,会需要 stat 数据结构中的 st_mode 和 st_size 成员,其他成员则不在我们的讨论之列。

10.7 读取目录内容

应用程序可以用 readdir 系列函数来读取目录的内容。

1
2
3
4
#include <sys/types.h>
#include <dirent . h>
DIR *opendir(const char *name) ;
// 返回:若成功,则为 处理的指针;若出错,则为 NULL。

函数 opendir 以路径名为参数,返回指向目录流(directory stream)的指针。流是对条目有序列表的抽象,在这里是指目录项的列表。

1
2
3
#include <dirent .h>
struct dirent *readdir(DIR *dirp);
// 返回:若成功,则为指向下一个目 录项的指针;若没有更多的目录项或出错,则为 NULL。

每次对 readdir 的调用返回的都是指向流 dirp 中下一个目录项的指针,或者,如果没有更多目录项则返回 NULL。

如果出错,则 readdir 返回 NULL。并设置 errno。可惜的是,唯一能区分错误和流结束情况的方法是检査自调用 readdir 以来errno是否被修改过。

1
2
3
#include <dirent.h>
int closedir(DIR *dirp);
// 返回:成功为 0; 错误为 -1。

函数 closedir 关闭流并释放其所有的资源。

10.8 共享文件

可以用许多不同的方式来共享 Linux 文件。除非你很清楚内核是如何表示打开的文件,否则文件共享的概念相当难懂。内核用三个相关的数据结构来表示打开的文件:

  • 描述符表
  • 文件表
  • v-node 表

10.9 I/O 重定向

Linux shell 提供了 I/O 重定向操作符,允许用户将磁盘文件和标准输人输出联系起来。例如,键人

linux> Is > foo.txt

使得 shell 加载和执行 Is 程序,将标准输出重定向到磁盘文件 foo.txt。就如我们将在11.5 节中看到的那样,当一个 Web 服务器代表客户端运行 CGI 程序时,它就执行一种相似类型的重定向。那么 I/O 重定向是如何工作的呢?一种方式是使用 dup2 函数。

1
2
3
#include <unistd.h>
int dup2(int oldfd, int newfd);
// 返回:若成功则为 非负的描述符,若出错则为 一1。

dup2 函数复制描述符表表项 oldfd 到描述符表表项 newfd 覆盖描述符表表项newfd以前的内容。如果 newfd已经打开了,dup2 会在复制 oldfd 之前关闭 newfd。

10.10 标准 I/O

C 语言定义了一组高级输人输出函数,称为标准 I/O 库,为程序员提供了 Unix I/O的较髙级别的替代。这个库(libc)提供了打开和关闭文件的函数(fopen 和 fclose)。读和写字节的函数(fread 和 fwrite)。读和写字符串的函数(fgets 和 fputs)。以及复杂的格式化的 I/O 函数(scanf 和 printf)。 .

标准 I/O 库将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向 FILE 类型的结构的指针。每个 ANSIC 程序开始时都有三个打开的流 stdin,stdout和 stderr。分别对应于标准输人、标准输出和标准错误:

#include <stdio.h>
extern FILE stdin; / Standard input (descriptor 0) */
extern FILE stdout; / Standard output (descriptor 1) */
extern FILE stderr; / Standard error (descriptor 2) */

类型为 FILE 的流是对文件描述符和流缓冲区的抽象。流缓冲区的目的和 RIO 读缓冲区的一样:就是使开销较高的 Linux I/O 系统调用的数量尽可能得小。例如,假设我们有一个程序,它反复调用标准 I/O 的 getc 函数,每次调用返回文件的下一个字符。当第一次调用 getc 时,库通过调用一次 read 函数来填充流缓冲区,然后将缓冲区中的第一个字节返回给应用程序。只要缓冲区中还有未读的字节,接下来对 getc 的调用就能直接从流缓冲区得到服务。

10.11 综合:我该使用哪些 I/O 函数?


图 10-16 Unix I/O、标准 VO 和 RIO 之间的关系

10.12 小结

Linux 提供了少量的基于 Unixl/O 模型的系统级函数,它们允许应用程序打开、关闭、读和写文件,提取文件的元数据,以及执行 I/O 重定向。Linux 的读和写操作会出现不足值,应用程序必须能正确地预计和处理这种情况。应用程序不应直接调用 Unix I/O 函数,而应该使用 RIO 包,RIO 包通过反复执行读写操作,直到传送完所有的请求数据,自动处理不足值。

Linux 内核使用三个相关的数据结构来表示打开的文件。描述符表中的表项指向打开文件表中的表项,而打开文件表中的表项又指向 v-node 表中的表项,每个进程都有它自己单独的描述符表,而所有的进程共享同一个打开文件表和 v-node 表。理解这些结构的一般组成就能使我们清楚地理解文件共享和I/O重定向。

标准 I/O 库是基于 Unix I/O 实现的,并提供了一组强大的高级 I/O 例程。对于大多数应用程序而言,标准 I/O 更简单,是优于 Unix I/O 的选择。然而,因为对标准 I/O 和网络文件的一些相互不兼容的限制,Unix I/O 比之标准 I/O 更该适用于网络应用程序。