CSAPP 第十章 系统级IO

I/O 不只简单的输入/输出

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

所有语言的运行时系统都提供执行 I/O 的较高级别的工具。例如C语言的 scanf 和 printf,C++ 的 cin 和 cout 。

在 Linux 系统中,是通过使用由内核提供的系统级 Unix I/O 函数来实现这些较高级别的 I/O 函数的。

Unix I/O

我们知道在 Linux 当中,一切皆文件。所有的 IO 设备也都被模型化为文件。输入输出都被当作简单的读写文件,这使得我们所有的输入和输出都可以简单一致的方式去访问:

  • 打开文件:通过 open 函数打开一个文件,内核会记录有关这个打开文件的所有信息,并向用户层会返回一个文件描述符,用户层要操作文件只需要对文件描述符操作即可。
  • Linux shell 在创建进程的时候有默认的三个打开的文件:标准输入(stdin),标准输出(stdout),标准错误(stderr),他们的描述符分别为0,1,2。
  • 改变当前文件的位置:对于每个打开的文件,内核会记录文件所在的位置 k,初始为 0。应用程序可以通过 seek 操作,显示的改变这个值。
  • 读写文件:读文件就是把文件中从 k 开始到 k+size 的文件内容复制到内存,写文件就是把文件中从 k 开始到 k + size 的文件内容用内存中的某些值替换。
  • 关闭文件:完成了访问之后,我们应当使用 close 函数去通知内核关闭这个文件,释放系统资源。

文件

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

  • 普通文件(regular file):包含任意数据。应用程序常常要区分文本文件和二进制文件,而对内核而言,文本文件和二进制文件没有区别。
  • 目录(directory):是包含一组链接的文件,其中每个链接都将一个文件名映射到一个文件,这个文件可能是另一个目录。每个目录至少包含两个条目:. 是到该目录自身的链接;以及 .. 是到目录层次结构中父目录的链接。
  • 套接字(socket):是用来与另一个进程进行跨网络通信的文件

Linux 内核讲所有文件都组织成一个目录层次结构,由名为 / 的根目录确定。系统中每一个文件都是根目录的后代。

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

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

  • 绝对路径名(absolute pathname)以一个斜杠开始,表示从根节点开始的路径。例如上图中 hello.c 的绝对路径名为 /home/droh/hello.c
  • 相对路径名(relative pathname)以文件名开始,表示从当前工作目录开始的路径。例如,如果 /home/droh 是当前工作目录,那么 hello.c 的相对路径名就是 ./hello.c。反之,如果 /home/bryant 是当前工作目录,那么相对路径名就是 ../home/droh/hello.c

打开和关闭文件

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

1
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(char *filename, int flags, mode_t mode);

// 返回:若成功则为新文件描述符,若出错为 -1。

open 函数将 filename 转换为一个文件描述符,并且返回描述符数字。

返回的描述符总是在进程中当前没有打开的最小描述符。flags 参数指明了进程打算如何访问这个文件:

  • O_RDONLY:只读。
  • O_WRONLY:只写。
  • O_RDWR:可读可写。

flags 参数也可以按位或更多位掩码,为写提供给一些额外的指示:

  • O_CREAT:如果文件不存在,就创建它的一个截断的(truncated)(空)文件。
  • O_TRUNC:如果文件已经存在,就清空里面的内容。
  • O_APPEND:在每次写操作前,设置文件位置到文件的结尾处。

第三个参数 mode 是我们创建文件时的权限, Linux 的文件权限有 9 位二进制数字组成,因此它也有定义九个宏分别表示这些权限。

在操作完成后,使用 CLOSE 函数关闭文件

1
2
3
4
5
#incllude <unistd.h>

int close(int fd);

//返回,若成功则为0,若出错则为-1

读和写文件

用用程序使用 read 函数和 write 函数来读写文件。

1
2
3
4
5
6
7
#include <unistd.h>

ssize_t read(int fd, void *buf, size_t n);
// 返回:若成功则为读的字节数,若 EOF 则为0,若出错为 -1。

ssize_t write(int fd, const void *buf, size_t n);
// 返回:若成功则为写的字节数,若出错则为 -1。

size_tssize_t 类型的最大区别就是 ssize_t 有符号。

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

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

  • 读时遇到 EOF。假设我们准备读一个文件,该文件从当前文件位置开始只含有 20 多个字节,而我们以 50 个字节的片进行读取。这样一来,下一个 read 返回的不足值为 20,此后的 read 将通过返回不足值 0 来发出 EOF 信号。
  • 从终端读文本行。如果打开文件是与终端相关联的(如键盘和显示器),那么每个 read 函数将一次传送一个文本行,返回的不足值等于文本行的大小。
  • 读和写网络套接字。如果打开的文件对应于网络套接字,那么内部缓冲约束和较长的网络延迟会引起 read 和 write 返回不值。对 Linux 管道(pipe)调用 read和 write 时,也有可能出现不足值。

用 RIO 包健壮地读写

RIO(Robust I/O)就是一个 I/O 包,它会自动处理上文中提到的不足值。RIO 提供了两类不同的函数:

  • 无缓冲的输入输出函数。这些函数直接在内存和文件之间传输数据,没有应用级的缓冲。它们对将二进制数据读写到网络和从网络读写二进制数据尤其有用。
  • 带缓冲的输入函数。这些函数允许我们高效地从文件中读取文本行和二进制数据,这些文件的内容缓存在应用级缓冲区内,类似于为 printf 这样的标准 I/O 函数提供的缓冲区。

带缓冲的 RIO 输人函数是线程安全的,它在同一个描述符上可以被交错地调用

读取文件元数据

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

1
2
3
4
5
6
7
#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 函数以文件名作为输入,并填写 stat 数据结构中的各个成员。fstat 以文件描述符作为输入。

下面是 stat 结构体的详细信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* Metadata returned by the stat and fstat functions */
struct stat {
dev_t st_dev; /* Device */
ino_t st_ino; /* inode */
mode_t st_mode; /* Protection and file type */
nlink_t st_nlink; /* Number of hard links */
uid_t st_uid; /* User ID of owner */
gid_t st_gid; /* Group ID of owner */
dev_t st_rdev; /* Device type (if inode device) */
off_t st_size; /* Total size, in bytes */
unsigned long st_blksize; /* Block size for filesystem I/O */
unsigned long st_blocks; /* Number of blocks allocated */
time_t st_atime; /* Time of last access */
time_t st_mtime; /* Time of last modification */
time_t st_ctime; /* Time of last change */
};

stat 结构体定义在 sys/stat.h 头文件中。

st_size 成员包含了文件的字节数大小。st_mode 成员则编码了文件访问许可位和文件类型。

Linux 在 sys/stat.h 中定义了宏谓词来确定 st_mode 成员的文件类型:

  • **S_ISREG(m)**。这是一个普通文件吗?
  • **S_ISDIR(m)**。这是一个目录文件吗?
  • **S_ISSOCK(m)**。这是一个网络套接字吗?

我们可以通过读取 st_mode 成员来判断文件类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include<fcntl.h>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>

int main (int argc, char **argv)
{
struct stat s;
char *type, *readok;
stat(argv[1], &s);
if (S_ISREG(s.st_mode)) /* Determine file type */
type = "regular";
else if (S_ISDIR(s.st_mode))
type = "directory";
else
type = "other";
if ((s.st_mode & S_IRUSR)) /* Check read access */
readok = "yes";
else
readok = "no";

printf("type: %s, read: %s\n", type, readok);
exit(0);
}

读取目录内容

打开:

1
2
3
4
5
6
#include <sys/types.h>
#include <dirent.h>

DIR *opendir(const char *name);

// 返回:若成功,则为处理的指针;若出错,则为 NULL。

读:

1
2
3
4
5
#include <sys/stat.h>
#include <dirent.h>

DIR *opendir(const char *name);
// 返回:若成功则为处理的指针,若出错则为 NULL。

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

1
2
3
4
struct dirent {
ino_t d_ino;/* inode number */
d_name[256]; /* Filename */char
};

关闭:

1
2
3
4
5
#include <dirent.h>

int closedir(DIR *dirp);

// 返回:成功为 0;错误为 -1。

我们可以用上面的三个函数写一个类似于 Linux 系统的 ls 命令的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<fcntl.h>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<dirent.h>
int main (int argc, char **argv)
{
struct DIR *stream = opendir("/etc/");
struct dirent *dep;
while((dep=readdir(d))!=NULL)
{
printf("%s\n",dep->d_name);
}
closedir(stream);
}

共享文件

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

  • 描述符表:进程之间独立,每个打开的文件描述符表项指向文件表中的一个表项。

  • 文件表:所有进程之间共享。每个表项的组成包括了文件位置、引用计数、以及指向v-node 表项对应的指针。每关闭一个描述符会减少相应的文件表项中的引用计数,当引用计数为 0 也就是所有进程都关闭了这个文件时,内核会删除这个表项。

  • v-node 表:所有进程共享,里面的一个表项包含了 stat 结构信息以及其它一些额外的字段。

下图描述符1和 4 通过不同的打开文件表项来引用不同的文件,例如进程中分别调用一次 open 函数和一次 write 函数。需要注意的是,打开文件表中的引用计数仅仅在 fork 的时候会增加。

如果以同一个文件调用 open 函数两次,就会发生下图这种情况。

父子进程之间 fork 时如下图:

I/O 重定向

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

1
linux> ls > foo.txt

可以把 ls 命令的输出定向到磁盘文件 foo.txt 中。

I/O 重定向是怎么工作的呢?其中一种方法是使用 dup2 函数:

1
2
3
4
5
#include <unistd.h>

int dup2(int oldfd, int newfd);

// 返回:若成功则为非负的描述符,若出错则为 -1。

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

举个例子,我们调用 dup2(4,1),调用之前,状态如共享文件中的图一一样,标准描述符 1 对应与文件 A,描述符 4 对应于文件 B。此时 A 和 B 的引用计数都等于1。

在调用后,两个描述符都指向文件 B ,文件 A 被关闭,并且它的文件表和 v-node 表表项也已经被删除了。文件 B的引用计数也已经增加。从此以后,任何写到标准输出的数据都被重定向到文件 B。

标准 I/O

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

glibc 提供了打开和关闭文件的函数(fopen 和 fclose)、读和写字节的函数(fread 和 fwrite)、读和写字符串的函数(fgets 和 fputs),以及复杂的格式化的 I/O 函数(scanf 和 printf)。

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

1
2
3
4
#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 的调用就能直接从流缓冲区得到服务。

我该使用哪些 I/O 函数?

下图是 Unix I/O、标准 I/O、和 RIO 之间的关系:

那么我们写程序的时候该使用哪个函数呢?

书中给出了几个原则:

  • G1:只要有可能就使用标准 I/O。对磁盘和终端设备 I/O 来说,标准 I/O 函数是首选方法。除了 stat 读取文件基本信息以外,使用 stdio 封装的函数。
  • G2:不要使用 scanf 或 rio_readlineb 来读二进制文件。像 scanf 或 rio_read-lineb 这样的函数是专门设计来读取文本文件的。二进制文件可能会散布很多的 0xa 字节,而 scanf 遇到会将它们识别为终止符,因此会出现错误。
  • G3:对网络套接字的 I/O 使用 RIO 函数。Linux 对网络的抽象是一种称为套接字的文件类型。就像所有的 Linux 文件一样,套接字由文件描述符来引用,在这种情况下称为套接字描述符。应用程序进程通过读写套接字描述符来与运行在其他计算机的进程实现通信。

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

  • 限制 1:跟在输出函数之后的输入函数。如果中间没有插入对 fflush、fseek、fsetpos 或者 rewind 的调用,一个输入函数不能跟随在一个输出函数之后。fflush 函数清空与流相关的缓冲区。后三个函数使用 Unix I/O lseek 函数来重置当前的文件位置。
  • 限制 2:跟在输入函数之后的输出函数。如果中间没有插入对 fseek、fsetpos 或者 rewind 的调用,一个输出函数不能跟随在一个输入函数之后,除非该输入函数遇到了一个文件结束。

原来还可以 ls > foo.txt 重定向输出流!

我是土狗

——手动分割线—-

老师为什么我们家子涵艺考这船不放假

天杀的我要报警抓你们!!


CSAPP 第十章 系统级IO
https://shmodifier.github.io/2023/12/08/CSAPP-第十章-系统级IO/
作者
Modifier
发布于
2023年12月8日
许可协议