CSAPP-系统级IO

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

了解Unix I/O将帮助你理解其他的系统概念。 I/O是操作系统不可或缺的一部分,因此,我们经常遇到I/O和其他系统概念之间的循环依赖。

有时候除了使用Unix I/O以外别无选择。

1. Unix I/O

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

  • 打开文件。应用程序要求内核打开相应的文件,宣告他要访问一个IO设备。内核会返回一个非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关打开的文件的所有信息。应用程序只需要记住这个描述符。
  • Linux Shell创建每个进程开始时都有三个打开的文件:标准输入(描述符为0)标准输出(描述符为1)标准错误(描述符为2)。头文件<unistd.h>定义了常量STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO, 可以用来代替显式的描述符值。
  • 改变当前文的文件位置。 对于打开的文件,内核保持着文件位置k,初始为0,这个文件位置就是从文件头开始的字节偏移量。
  • 读写文件。读操作就是从文件中复制字节到内存;类似的写操作就是从内存复制字节到文件。
  • 关闭文件。当应用程序结束对文件的访问,就应该通知内核关闭这个文件。内核这时释放文件打开时创建的数据结构,并将描述符恢复到可用描述符池中。进程终止,内核会关闭所有打开的文件。

2. 文件

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

  • 普通文件 (regular file),可以包含任意数据,如文本文件(text file)和二进制文件(binary file);
  • 目录 (directory)是包含一组链接(link) 的文件,其中每个链接都将一个文件名映射到一个文件,这个文件可以是另一个目录。
  • 套接字(socket)是用来与另一个进程进行跨网络通信的文件。

每个进程都有一个当前工作目录(current working directory)来确定其在目录层次结构中的位置。

目录层次结构中的位置用路径名(pathname)来指定。路径名有两种形式:1. 绝对路径名;2. 相对路径名;

3. 打开和关闭文件

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

4. 读和写文件

应用程序通过分别调用readwrite函数来执行输入和输出操作.

1
2
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf,size_t n);

ssize_t在x86-64系统中被定义为long; 而size_t被定义为unsigned long。因为ssize_t作为read函数返回值的类型,出错的时候需要能返回-1.

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

  1. 读时遇到EOF。如读一个文件只有20个字节,但是应用程序通过read函数读取50个字节。这样一来,read将通过返回不足值0来发出EOF信号。
  2. 从终端读取文本行。这种情况下read函数将一次传送一个文本行。
  3. 读和写网络套接字(socket)。内部缓冲约束会引起read和write返回不足值。

5. 使用RIO包健壮地读写

这里将会讲述一个I/O包,称为RIO(Robust I/O,健壮的I/O)包。它会自动帮助处理上一节讲到的不足值。RIO包提供了方便,健壮和高效的I/O。RIO还提供了两类不同的函数:

  • 无缓冲的输入输出函数。这些函数直接在内存和文件之间传送数据,没有应用级缓冲。
  • 带缓冲的输入函数。类似于printf这样的标准I/O函数提供的缓冲区。带缓冲的RIO输入函数是线程安全的,它在同一个描述符号上可以被交错地调用。

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

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

1
2
3
ssize_t rio_readn(int fd, void *usrbuf, size_t n);
ssize_t rio_writen(int fd, void *usrbuf, size_t n);
//成功的话返回传送的字节数,若EOF则返回0,若出错则返回-1

5.2 RIO的带缓冲的输入函数

RIO提供一种包装函数(rio_readlineb),它从一个内部读缓冲区复制一个文本行,当缓冲区变空的时候,会自动的调用read重新填满缓冲区。对于既包含文本行也包含二进制数据的文件,也有rio_readn带缓冲区的版本叫做rio_readnb

1
2
3
4
5
void rio_readinitb(rio_t *rp, int fd);


ssize_t rio_readlineb(rio_t *rp, void *userbuf, size_t maxlen);
ssize_t rio_readnb(rio_t *rp, void *userbuf, size_t n);

6. 读取文件元数据

应用程序通过调用statfstat函数,检索关于文件的信息或称为文件的元数据(metadata)

1
2
int stat(const char *filename, struct stat *buf);
int fstat(int fd, struct stat *buf);

7. 读取目录和内存

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

1
2
DIR *opendir(const char *name);
struct dirent *readdir(DIR *dirp);

8. 共享文件

可以使用许多不同的方式来共享Linux文件。内核使用三个相关的数据结构来表示打开的文件:

  • 描述符表(descriptor table)。每个进程都有它独立的描述符号表,它的表项是由进程打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。
  • 文件表(file table)。打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。包含当前文件的位置,引用计数(reference count)(即当前指向该表项的描述符表项数),以及一个指向v-node表的指针。进程关闭描述符只会减少相应的文件表表项中的引用计数。内核并不会删除这个文件表表项,直到引用计数为零。
  • v-node表(v-node table)。同文件表一样,所有的进程共享这张 v-node 表。保存文件访问,文件大小,文件类型等信息。

父子进程共享相同的打开文件表集合,因此共享相同的文件位置。

9. I/O重定向

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

1
linux> ls > foo.txt

同时Linux提供函数dup2来实现I/O重定向

1
int dup2(int oldfd, int newfd);

10. 标准I/O

C语言定义了一组高级输入输出函数,称为标准I/O库,为程序员提供Unix I/O的较高级别的替代。

标准I/O库将一个打开的文件模型转化为一个流。对于程序员来说,一个流就是一个指向FILE类型结构的指针。类型FILE的流是对文件描述符和流缓冲区的抽象。流缓冲区的目的和RIO读缓冲区的一样:就是使开销较高的Linux I/O系统调用的数量尽可能的小。如getc函数第一次被调用时,系统会调用read函数来填充缓冲区,接下来getc直接从缓冲区得到服务。

11. 综合

Unix I/O模型是在操作系统内核中实现的。应用程序可以通过open,close,lseek等函数来访问Unix I/O。标准I/O函数提供了 Unix I/O函数的一个更完整的带缓冲的替代品,包括格式化的I/O例程,如printf和scanf。下面是一些基本指导:

  1. 只要有可能就使用标准I/O。
  2. 不要使用scanfrio_readline来读二进制文件,因为它设计是用来处理文本文件的。
  3. 注意网络套接字。

标准I/O流,从某种意义上而言是全双工的,因为程序能够在同一个流上面执行输入和输出。然而,对流的限制和对套接字的限制,有时候会互相冲突。

  1. 跟在输出函数之后的输入函数。需要在中间插入对 fflushfseekfsetpos或者rewind的调用。 一个输入函数不能跟随在一个输出函数之后。fflush函数清空与流相关的缓冲区。后三个函数使用 Unix I/O lseek 函数来重置当前的文件位置。
  2. 跟在输入函数之后的输出函数。需要在中间插入对fseekfsetpos或者rewind的调用,一个输出函数不能跟随在一个输入函数之后,除非该输入函数遇到了一个文件结束。