系统级IO

系统级 I/O(CSAPP Chapter 10)

输入/输出 的定义:主存和外部设备(磁盘驱动器、终端、网络)之间复制数据的过程。输入是从 I/O 设备复制到主存,输出是主存中复制数据到 I/O 设备。

高级别 I/O 函数:例如 <<, >>printf, scanf

低级别 I/O 函数:内核提供的系统级 I/O

大多数时候高级别 I/O 效果良好,但理论上学习低级别 I/O 也有其意义:

image

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:是一个与另一个进程进行跨网络通信的文件

image

3. 打开/关闭文件

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

int open(char *filename, int flags, mode_t mode);
// if success, return descriptor; else, return -1

flags 表示如何访问这个文件:

image

mode 参数指定了新文件的访问权限位。它们的名字在下面表示:

image

想访问的时候,你可以把不同的 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++)

image

有时,这两个函数传送的字节比预期少一点,这种 不足值 不一定代表有错误。原因主要包括

  • \(\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 来填满缓冲区。

image

打开一个描述符时,我们调用 rio_readinitb 函数,将描述符 fd 和地址 rp 处的一个读缓冲区联系起来。

rio_readlineb 函数从 rp 读出一个文本行,且有字节上限,最后一位留给 NULLrio_readnb 从 n 个字节到内存。对于同一描述符,这两个函数可以任意交叉进行。然而,对这些带缓冲的函数的调用却不应该和无缓冲的 rio_readn 函数交叉使用。

缓冲区大概长这样:

image

初始化函数大致如下:

image

其实,上述的这些读程序的核心都是 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)

image

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

一个目录一般包括这些内容,这也就是文件的名字和文件的位置。

image

如果出错,上述函数返回 NULL,并设置 errno,这也代表唯一的区分错误/流结束的办法是检查调用前后 errno 是否被修改过。

image

8. 共享文件

内核用三个相关的数据结构来表示打开的文件:

  • 描述符表。每个进程都有独立的 descriptor table,它的表项是由进程打开的文件描述符来索引的,指向文件表中的一个表项。
  • 文件表。打开文件的集合是用一个文件表的表示的,所有的进程共享这张表。每个文件表的表项组成包括当前的文件位置、引用计数(表示有多少描述符指向该表项)、以及一个指向 v-node 表中对应项的指针。关闭一个描述符会减少对应的计数,如果计数减少到 0,内核会关上这个文件表表项。
  • v-node 表。所有的进程共享这张表,其中包含 stat 结构中的大多数信息。

一张图描述:

image

image

如果调用 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 函数。

image

类型为 FILE 的流是对文件描述符和缓冲区的抽象。缓冲区的目的即让开销较高的 \(\mathrm{Linux\ I/O}\) 的调用次数尽可能地小,因为如果缓冲区里面有东西,下一次 getc 就能直接从缓冲区中得到服务。

image

11. 总结

image

参考材料

[1] 深入了解计算机系统 Chapter 10

posted @ 2023-11-03 19:53  Radioheading  阅读(17)  评论(0编辑  收藏  举报