系统级IO
系统级 I/O(CSAPP Chapter 10)
输入/输出 的定义:主存和外部设备(磁盘驱动器、终端、网络)之间复制数据的过程。输入是从 I/O 设备复制到主存,输出是主存中复制数据到 I/O 设备。
高级别 I/O 函数:例如 <<
, >>
,printf
, scanf
低级别 I/O 函数:内核提供的系统级 I/O
大多数时候高级别 I/O 效果良好,但理论上学习低级别 I/O 也有其意义:
1. Unix I/O
在 \(\mathrm{Linux}\) 中,文件是一个 \(m\) 个字节的序列 \(\{B_i\}\)。通过这种方法,我们把每个设备映射到了一个文件上,这样内核就可以引出一个简单、低级的应用接口,称为 Unix I/O,这样所有的输入和输出本质上方式都是一致的。
- 启动文件:一个应用程序通过要求内核打开相应的文件,来表示其将要访问某个 I/O 设备。内核返回一个小的
u_int
,称为 描述符,在接下来标识这个文件。内核记录关于这个打开文件的信息,而应用程序只需要记住这个描述符。 - \(\mathrm{Linux\ shell}\) 中,每个进程一开始都有三个打开的文件:标准输入(0)、标准输出(1)和标准错误(2)
- 改变当前的文件位置:对于每个打开的文件,内核维护一个 文件位置 \(k\),初始为 0。这个文件位置是从当前开始的字节偏移量。你可以通过
seek
操作来改变之(回顾 C++) - 读写文件:一个读操作就是从文件复制 \(k\sim k+n\) 的字节到内存,如果 \(k > m\),那么就报错 \(\mathrm{EOF}\),代表文件结尾,应用程序可以检测到这个条件。
- 关闭文件:当应用弄完了,那就让内核把这个文件关了,并释放这个描述符。无论一个进程因为何种原因终止,内核都会关闭打开的所有文件并释放其全部内存。
2. 文件
每个 \(\mathrm{Linux}\) 文件都有一个类型来表示其在系统中的角色:
-
普通文件:包含任意数据,分为文本文件(只有 \(\mathrm{ASCII/Unicode}\) 字符)和二进制文件。对内核而言,两者没有区别
-
目录:包含一组链接的文件。即有一个文件名到文件的映射。每个目录至少有两个条目
./..
, -
socket:是一个与另一个进程进行跨网络通信的文件
3. 打开/关闭文件
进程是通过调用 open 函数来打开一个已经存在的文件/创建一个新文件的:
int open(char *filename, int flags, mode_t mode);
// if success, return descriptor; else, return -1
flags 表示如何访问这个文件:
mode 参数指定了新文件的访问权限位。它们的名字在下面表示:
想访问的时候,你可以把不同的 mode 或 mask 全部按位或连接;并指定访问权限位为 mode & ~umask
。
访问完了,就直接关掉就行了。但是,关掉已经关掉的文件会出错。
#include <unistd.h>
int close(int fd);
// if success, return 0; else, return -1;
4. 读和写文件
应用程序是通过 read/write 函数来执行输入和输出的:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t n);
// if ok, return the number of bytes you've read; EOF: 0; error: -1
ssize_t write(int fd, const void *buf, size_t n);
// if ok, return the number of bytes you've written; error: -1
buf 代表内存中的位置, fd 代表描述符。
通过调用 lseek
函数,应用程序可以显式地修改当前文件的位置。(回顾 C++)
有时,这两个函数传送的字节比预期少一点,这种 不足值 不一定代表有错误。原因主要包括
- \(\mathrm{EOF}\)(磁盘中的唯一问题)
- 从终端读文本行,那么每次
read
将一次传送一个文本行,返回的不足值代表文本的大小。 - 读写 socket:内部缓冲约束和网络延迟会导致
read/write
返回不足值,这对于 \(\mathrm{Linux\ pipe}\)而言也是一样的。
5. 用 RIO 包进行读写
R 指的是 robust(健壮)的意思。其中有对于不足值的自动处理,而在像网络程序这样容易出现不足值的应用中,RIO 包提供了方便、健壮和高效的 I/O。
- 无缓冲的输入输出函数。这些函数直接在内存和文件之间传送数据,没有应用级缓冲
- 带缓冲的输入函数。类似于为
printf
这样的函数提供缓冲区。它是 线程安全 的,意味着可以在同一个描述符上交错调用。
5.1 RIO 的无缓冲的输入输出
通过调用以下的两个函数,可以直接在内存和文件中传送数据。两者也可以任意交错。其中 rio_read
在遇到 \(\mathrm{EOF}\) 时只能返回不足值,而另一个则永远不会。
ssize_t rio_readn(int fd, void *usrbuf, size_t n) {
size_t nleft = n;
ssize_t nread;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nread = read(fd, bufp, nleft)) < 0) {
if(errno == EINTR) /* Interrupted by big handler return */
nread = 0; /* and call read() again */
else
return -1; /* errno set by read() */
}
else if (nread == 0)
break; /* EOF */
nleft -= nread;
bufp += nread;
}
return (n - nleft); /* return >= 0 */
}
ssize_t rio_writen(int fd, void *usrbuf, size_t n) {
size_t nleft = n;
ssize_t nwritten;
char *bufp = usrbuf;
while (nleft > 0) {
if ((nwritten = write(fd, bufp, nleft)) <= 0) {
if(errno == EINTR) /* Interrupted by big handler return */
nwritten = 0; /* and call write() again */
else
return -1; /* errno set by write() */
}
nleft -= nwritten;
bufp += nwritten;
}
return n;
}
5.2 RIO 的带缓冲的输入函数
如果我们要写一个程序来计算文本文件中文本行的数量,如何实现?如果一个个字节读入检查换行符,效率无疑是很低的。我们可以调用一个包装函数 rio_readlineb
,它从一个内部读缓冲区复制一个文本行,当缓冲区变空,会继续调用 read
来填满缓冲区。
打开一个描述符时,我们调用 rio_readinitb
函数,将描述符 fd 和地址 rp 处的一个读缓冲区联系起来。
rio_readlineb
函数从 rp 读出一个文本行,且有字节上限,最后一位留给 NULL
,rio_readnb
从 n 个字节到内存。对于同一描述符,这两个函数可以任意交叉进行。然而,对这些带缓冲的函数的调用却不应该和无缓冲的 rio_readn
函数交叉使用。
缓冲区大概长这样:
初始化函数大致如下:
其实,上述的这些读程序的核心都是 rio_read
函数。它是 \(\mathrm{Linux\ read}\) 函数的带缓冲的版本。
如果缓冲区为空,会调用 read
来填满它。这个 read
调用收到一个不足值并不是错误,只不过代表只填充了一部分。一旦缓冲区非空,rio_read
就从读缓冲区复制 std::min{n, rp->rio_cnt}
个字节到用户缓冲区,并返回复制的字节数。
对于一个应用程序,rio_read
函数与 \(\mathrm{Linux\ read}\) 有相同的意思。
6. 读取文件元数据
应用程序能通过调用 stat/fstat
函数来检索有关文件的信息。(成功返回 0,失败返回 -1)
st_size
成员包含了文件的字节数大小。st_mode
成员编码了文件访问许可位,其中也有一部分规范,置于 sys/stat.h
中。
7. 读取目录内容
可以使用 readdir
#include <sys/types.h>
#include <dirent.h>
DIR *opendir(const char *name);
struct dirent *readdir(DIR *dirp);
// if success, return the pointer to the next index in the directory; otherwise, return NULL
一个目录一般包括这些内容,这也就是文件的名字和文件的位置。
如果出错,上述函数返回 NULL
,并设置 errno
,这也代表唯一的区分错误/流结束的办法是检查调用前后 errno
是否被修改过。
8. 共享文件
内核用三个相关的数据结构来表示打开的文件:
- 描述符表。每个进程都有独立的 descriptor table,它的表项是由进程打开的文件描述符来索引的,指向文件表中的一个表项。
- 文件表。打开文件的集合是用一个文件表的表示的,所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数(表示有多少描述符指向该表项)、以及一个指向 v-node 表中对应项的指针。关闭一个描述符会减少对应的计数,如果计数减少到 0,内核会关上这个文件表表项。
- v-node 表。所有的进程共享这张表,其中包含
stat
结构中的大多数信息。
一张图描述:
如果调用 open
两次打开同一文件名就会是上图这样的。
而在 fork()
中,子进程有一个父进程描述符表的副本。两者共享相同的打开文件表集合,共享相同的文件位置。
一个重要的结论:如果内核要删除相应文件表表项,必须等父子进程都关闭了它们的描述符。
9. I/O 重定向
大家应该都对 I/O 重定向不陌生:
g++ -o main main.cpp
./main > foo.xt
类似的重定向还出现在 Web 服务器代表客户端运行 CGI 程序时。那么 I/O 重定向是如何工作的呢?一种方法是使用 dup2
函数。
#include <unistd.h>
int dup2(int oldfd, int newfd);
这个函数的原理也非常简单。本质上就是减少 oldfd
对应的打开文件表 refcnt
,如果到 0 就关闭之;另外,把描述符表的索引定向到文件 newfd
,并增加其引用计数。
10. 标准 I/O
提供了打开关闭文件的函数,读写字节的函数,读写字符串的函数,以及复杂格式化的 I/O 函数。
类型为 FILE 的流是对文件描述符和缓冲区的抽象。缓冲区的目的即让开销较高的 \(\mathrm{Linux\ I/O}\) 的调用次数尽可能地小,因为如果缓冲区里面有东西,下一次 getc
就能直接从缓冲区中得到服务。
11. 总结
参考材料
[1] 深入了解计算机系统 Chapter 10