跳转至

10 系统级IO

输入/输出(I/O)是在主存和外部设备之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备。在Linux系统中,是通过使用由内核提供的系统级Unix I/O函数来实现较高级别的I/O函数的。

Unix I/O

一个Linux文件就是一个m个字节的序列:B_0, B_1, ..., B_k, ..., B_{m-1}。所有的I/O设备都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。Linux内核使用一个简单、低级的应用接口即Unix I/O来统一且一致的执行所有的输入和输出:

  • 打开文件。一个应用程序通过要求内核打开相应的文件,内核返回一个小的非负整数,称为描述符。内核记录有关这个打开文件的所有信息,应用程序只需记住这个描述符。
  • Linux shell创建的每个进程开始时都有三个打开的文件:标准输入(描述符为0)、标准输出(描述符为1)和标准错误(描述符为2)。
  • 改变当前的文件位置。对于每个打开的文件,内核保持着一个文件位置k,初始为0。这个文件位置是从文件开头起始的字节偏移量。应用程序能够通过执行seek操作,显示地设置文件的当前位置为k
  • 读写文件。一个读操作就是从文件复制n>0个字节到内存,从当前文件位置k开始,然后将k增加到k+n。给定一个大小为m字节的文件,当k\ge m时执行读操作会触发一个称为end-of-file(EOF)的条件,应用程序能检测到这个条件。在文件结尾处并没有明确的"EOF符号"。类似的,写操作就是从内存复制n>0个字节到一个文件,从当前文件位置k开始,然后更新k
  • 关闭文件。当应用完成了对文件的访问之后,它就通知内核关闭这个文件。作为响应,内核释放文件打开时创建的数据结构,并将这个描述符恢复到可用的描述符池中。无论一个进程因为何种原因终止时,内核都会关闭所有打开的文件并释放它们的内存资源。

文件

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

  • 普通文件(regular file)包含任意数据。
  • 目录(directory)是包含一组链接(link)的文件,其中每个链接都将一个文件名映射到一个文件。每个目录至少包含两个条目:.是到该目录自身的链接,以及..是到父目录(parent directory)的链接。
  • 套接字(socket)是用来与另一个进程进行跨网络通信的文件。

其他文件类型包含命名管道(named pipe)、符号链接(symbolic link)等。

Linux内核将所有文件都组织成一个目录层次结构(directory hierarchy),由名为/的根目录确定。下图显示了Linux系统的目录层次结构的一部分。

directory_hierarchy_of_linux

打开和关闭文件

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

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

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

  • O_RDONLY只读、O_WRONLY只写、O_RDWR可读可写,O_EXEC只执行
  • O_APPEND追加、O_CREAT创建、O_TRUNC截断
  • O_SYNC:把数据写入内核空间缓冲,一直阻塞直到数据从缓冲写入到存储设备
  • O_DIRECT: 绕过内核空间缓冲,从用户空间直接写入到存储设备,一般和O_SYNC一起使用确保同步

mode参数指定了新文件的访问权限位。作为上下文的一部分,每个进程都有一个umaskumaskchmod的效果刚好相反,设置的是权限"补码"。当进程通过带某个mode参数的open函数调用来创建一个新文件时,文件的访问权限位被设置为mode & ~ umask

umask

用户登录系统之后创建一个文件总是有一个默认权限的,那么这个权限是怎么来的呢?这就是umask干的事情。文件的默认创建权限是666和目录777。要计算新文件的权限,请从默认值中减去umask值。例如,要计算unask 022将如何影响新创建的文件和目录:

  • 文件:666 - 022 = 644。所有者可以读取和修改文件。 组和其他人只能读取文件。
  • 目录:777 - 022 = 755。所有者可以进入目录并列出读取,修改,创建或删除目录中的文件。 组和其他人可以进入目录并列出并读取文件。

下面是fopen()modeopen()flags的对比1

fopen() Mode open() Flags
r O_RDONLY
w O_WRONLY
a O_WRONLY
r+ O_RDWR
w+ O_RDWR
a+ O_RDWR

如果需要创建文件,也可以使用create:

int creat(const char *path, mode_t mode);

等效于:

open(path, O_WRONLYO_CREATO_TRUNC, mode);

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

int close(int fd);

读和写文件

应用程序是分别调用readwrite函数来执行输入和输出的。

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

size_t/ssize_t

在x86-64系统中, size_t被定义为unsigned long, 而ssize_t(有符号的大小)被定义为long。

读取文件元数据

每个文件的元数据由内核管理,可以通过statfstat函数访问。

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

结构体stat如下:

/* 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; /* Blocksize 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 */
};

共享文件

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

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

how_the_unix_kernel_represents_open_files

多个描述符可以通过不同的文件表表项来引用同一个文件。例如如果以同一个文件名调用open函数两次,就会发生这种情况。这时每个描述符都有它自己的文件位置,所以对不同描述符的读操作可以从文件的不同位置获取数据。

calling open twice with the same filename argument

父子进程是共享文件的,子进程有一个父进程描述符表的副本。父子进程共享相同的打开文件表集合,因此共享相同的文件位置。在内核删除相应文件表表项之前,父子进程都必须关闭了它们的描述符。

a child process inherits its parents open files

I/O重定向

Linux shell提供了I/O重定向操作符。它使用了dup2函数,它复制描述符表项oldfd到描述符表项newfd,覆盖描述符表项new-fd以前的内容。

# 返回:若成功则为非负的描述符,若出错则为-1
int dup2(int oldfd, int newfd);

dup2

标准I/O

C语言定义了一组高级输入输出函数(fopen/fclose/fread/fwrite等),称为标准I/O库,为程序员提供了Unix I/O的较高级别的替代。标准I/O库将一个打开的文件模型化为一个流,它是对文件描述符和流缓冲区的抽象,其目的是使开销较高的Linux I/O系统调用的数量尽可能得小。

详见Unix环境高级编程


  1. https://pubs.opengroup.org/onlinepubs/9699919799/functions/fopen.html