1. 标准库I/O接口回顾

https://laihaodong.cn/2008.html

2. 文件描述符

在标准库I/O接口中,库函数提供的操作句柄叫做文件指针或者文件流指针,其在库函数实现中指向一个FILE结构体,用以表示一个打开文件。而对于系统调用I/O接口来说,操作句柄是一个被称为文件描述符(一个非负整数)的东西。文件描述符可以表示所有类型的已打开文件。

每个进程的文件描述符各自独立,分别表示各自进程的打开文件。

在Shell的日常操作中有三个始终默认打开的文件,而所有在Shell中运行的程序都会继承到这三个文件。

文件描述符 用途 stdio流
0 标准输入 stdin
1 标准输出 stdout
2 标准错误 stderr

3. 概述与开胃菜

下面是系统调用I/O操作的4个主要的接口(编程语言和软件包通常会利用I/O函数库对它们进行间接调用),这些接口的具体介绍放在后面。

  • fd = open(pathname, flags, mode) 函数打开pathname所标识的文件,并返回文件描述符,用以在后续函数调用中指代这个打开的文件。可以通过设置flags来新建一个pathname不存在的文件,flags还可以指定打开文件的方式,只读只写或者读写。mode参数则制定了新建文件的权限。
  • numread = read(fd, buffer, count) 调用从fd所指代的的打开文件中读取至多count字节的数据,并存储到buffer中。返回值numread为实际读取到的字节数
  • numwritten = write(fd, buffer, count) 调用从buffer中读取最多count个字节并写入fd所表示的文件中。返回值numwritten表示实际写入文件的字节数。
  • status = close(fd) 在所有输入/输出操作完成后,调用close(),释放文件描述符fd以及与之相关的内核资源。

下面是一个运用这些接口的小例子,暂且可以当作一个开胃菜。

#include <stdlib.h>
#include <stdio.h>
#include <fcntl.h>

#ifndef BUF_SIZE
#define BUF_SIZE 1024
#endif

int main(int argc, char* argv[])
{
    if(argc != 3) {
        return 0;
    }   

    // open input and output file

    int inputFd = open(argv[1], O_RDONLY);
    if(inputFd == -1) {
        perror("open input file error");
        exit(-1);
    }   

    int outputFd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if(outputFd == -1) {
        perror("open output file error");
        exit(-1);
    }   

    // transfer data

    char buffer[BUFSIZ] = { 0 };

    int nn; 
    while((nn = read(inputFd, buffer, BUFSIZ)) > 0) {                                   
        write(outputFd, buffer, nn);
    }

    // close

    if(close(inputFd) == -1) {
        perror("close input file error");
    }
    if(close(outputFd) == -1) {
        perror("close output file error");
    }

    return 0;
}

4. 通用性

UNIX I/O模型的显著特点之一是其输入/输出的通用性概念。即open()close()read()write()这一套屠龙宝典可以应对所有类型的文件。因此,用这些系统调用编写的程序,将对任何文件都有效。

5. 接口介绍

5.1. 打开文件open()

int open(const char *pathname, int flags, mode_t mode);
// returns file descriptor on success, or -1 on error

mode_t 为整数类型

返回值

SUSv3规定,如果open()调用成功,必须保证其返回值为进程未用文件描述符中数值最小者。

flags

读取位于/proc/PID/fdinfo 目录下的linux系统专有文件,可以获取系统内任一进程中文件描述符的相关信息。针对进程中每一个已打开的文件描述符,该目录下都有相应文件,以对应文件描述符的数值命名。文件中pos字段表示当前文件的偏移量。flags字段为一个八进制数,表征文件访问标志和已打开文件的状态标志。

flags参数表示打开文件时所要指定一些选项,其中有些是必选的,有些是可选的。下面介绍几个最常用的选项:

标志 用途
O_RDONLY 以只读方式打开
O_WRONLY 以只写方式打开
O_RDWR 以读写方式打开
O_CREAT 如果文件不存在则创建之
O_EXCL 与O_CREAT搭配使用,表示如果文件存在,则不会打开文件并open()调用失败,并返回错误,errno设置为EEXIST。换言之,此操作确保了调用者就是创建文件的进程。
O_TRUNC 截断已有文件,使其长度为0
O_APPEND 总在文件末尾追加数据,不论是否用了lseek()
O_CLOEXEC 给打开的文件描述符增加 close-on-exec 标志

其中前三项必须三选一,后面的选项可以任意加。

mode

mode参数指定了如果要创建文件的话,该文件的给定权限是多少。如果没加O_CREAT选项则这个参数可以不给。

注意这个参数的形式是类似于0664而不是664

5.2. 读取文件内容read()

ssize_t read(int fd, void *buffer, size_t count);
// returns number of bytes read, 0 on EOF, or -1 on error

尝试读取count个字节的数据到buffer中,返回实际读取到的字节数。

5.3. 数据写入文件write()

ssize_t write(int fd, void* buffer, size_t count);
// returns number of bytes written, or -1 on error

尝试从buffer中写入count个字节的数据,返回实际写入的字节数。当磁盘已满的情况下返回值可能不等于count

5.4. 关闭文件close()

int close(int fd);
// returns 0 on success, or -1 on error

关闭一个文件描述符,所以之后这个文件描述符不再表示一个文件并且可能被重新使用(即另一个新打开的文件可能使用相同的文件描述符)。

如果fd是最后一个指向同一个底层的打开文件表条目(后面介绍什么是打开文件表)的文件描述符的话,则与该打开文件表条目相关的文件资源被释放。

5.5. 改变文件读写偏移量lseek()

对于每个打开的文件,系统内核会记录其文件偏移量,也可以将文件偏移量称为读写偏移量或指针。文件偏移量是执行下一个read()write()操作的起始位置,会以相对于文件起始位置相差多少字节来表示。

文件打开时,会将文件偏移量设置为指向文件开始,以后每次read()write()调用将自动对其调整,会指向已读或已写数据后的下一个字节。

off_t lseek(int fd, off_t offset, int whence);
// returns new file offset if successful, or -1 on error

offset 参数指定了一个以字节为单位的数值。(SUSv3规定 off_t 数据类型为有符号整型数。)whence参数表明应按照哪个基点来移动偏移量,应为下列其中之一:

whence 解释
SEEK_SET 以文件头部开始计算偏移量
SEKK_CUR 以当前文件偏移量开始计算偏移量
SEEK_END 以文件末尾开始计算偏移量

如果whence参数值为 SEEK_CUR 或 SEEK_END ,offset参数可以为正数也可以为负数。但如果whence为 SEEK_SET ,offset必须是非负数。

lseek()并不适用于所有类型的文件。不能用于管道、FIFO、socket或者终端。

文件空洞

如果文件偏移量已然跨越了文件结尾,read() 调用将返回0,表示文件结尾。但可以继续 write() 。

从文件结尾到新写入的数据间的这段空间被称为文件空洞(file holes)。从编程角度来看,文件空洞中是存在字节的,读取空洞将返回0(空字节)填充的缓冲区。

然而文件空洞其实不存储字节也就是不占用物理硬盘空间或者占用的很少,下面这张图比较形象:

具体细节不深究,需要时google把(google真好用,wiki真好用 xD)

6. 原子操作与竞争条件

所有系统调用都是以原子操作方式执行的。之所以这么说,是因为内核保证了某系统调用中所有步骤会作为独立操作而一次性加以执行,其间不会为其他进程或线程中断。(即该系统调用运行期间,不会把时间片再分给别的进程)

原子操作是某些操作得以圆满成功的关键所在。特别是它规避了竞争状态(race conditions)。竞争状态就是:操作共享资源的两个进程(或线程),其运行结果取决于一个无法预期的顺序,即取决于CPU时间片的分配。

下面讨论两种在文件I/O中的竞争状态的例子,并展示这些竞争状态可能会产生什么危害,最终展示了如何用open()系统调用的标志位来规避这种竞争状态 。

6.1. 以独占方式创建一个文件

上面的open()曾提到,如果同时指定O_EXCL 和 O_CREAT 作为open()的标志位时,如果要打开的文件已经存在,则open()将返回一个错误。这提供了一种机制,保证进程是打开文件的创建者。对文件是否存在的检查和创建文件属于同一原子操作。要理解这一重要性:需要思考下面的代码:

fd = open(argv[1], O_WRONLY);  // open if file exists
if(fd != -1) {  // open succeeded
    printf("PID [%ld] file %s already exists\n", getpid(), argv[1]);
}
else { 
    if(errno != ENOENT) {  // falied for unexpected reason
        perror("open error");
    }
    else {  // file or directory dont exist
        fd = open(argv[1], O_WRONLY | O_CREAT, 0666);
        if(fd == -1) {
            perror("open error");
        }

        printf("PID [%ld] created file %s exclusively\n", getpid(), argv[1]);
    }
}

代码意思是,先以不加O_CREAT调用一次open(),如果返回值不是-1说明调用成功,即文件本来就存在。而如果返回-1说明调用出错,当errno为ENOENT时表示由于文件不存在所以调用出错。此时再加上O_CREAT调用一次open(),之后宣称这个文件是自己创建的,即调用进程为文件的所有者。

但思考同时有两个进程运行这个程序的情况,如下图:

即进程A调用第一个open()后发现文件不存在,但在调用第二个open()之前时间片耗尽。此时进程B同样发现文件不存在,于是调用第二个open(),之后声称文件是自己创建的。但此时进程A得到了时间片,并调用了第二个open(),之后同样声称文件是自己创建的,这就产生了冲突,同一个文件不可能是两个进程同时创建的。虽然在这个简单的例子中看起来这种冲突并没有什么危害,但要敏锐的意识到这是一个BUG,可能会对程序产生危害。

在这种情况下就可以用O_EXCL和O_CREAT结合起来一次性调用来保证文件一定是自己创建的,即检查文件是否存在和创建文件属于一个单一的原子。

6.2. 向文件尾部追加数据

用以说明原子操作的重要性的第二个例子是:多个进程同时向同一个文件(如,全局数据文件)尾部添加数据。为了达到这一目的,考虑下面的代码:

lseek(fd, 0, SEEK_END);
write(fd, buf, len);

两个进程同时运行这段程序,假如进程A运行完第一句偏移后用完时间片,然后进程B将两条语句一次性运行完,之后进程A再运行第二句。则可知进程A写的位置就不是新的文件末尾了,而是之前的旧的文件末尾,这之后插入了进程B输入的内容。所以此时进程A再写入的话就会覆盖之前B写的内容,这就是没有保证原子操作的坏处。

这个情况同样可以通过加一个O_APPEND标志来解决,O_APPEND标志保证了每一次write()一定会写在文件末尾,不论你用了lseek()与否。即将文件偏移量的移动和文件写入纳入同一原子操作。

7. 文件描述符与打开文件的关系

终于到了我最喜欢的部分,你是否也在思索为什么文件描述符即一个非负整数就可以表示一个打开文件,为什么系统调用I/O接口会要以这个非负整数作为文件的操作句柄。下面就来介绍文件描述符与打开文件之间的关系。

在现实中,文件描述符和打开的文件并不是一一对应的。其实,多个文件描述符表示同一打开的文件,这既有可能,也很必要。这些文件描述符可以在不同的进程中打开,以使得多个进程操作同一个文件。

要理解具体的情形,需要查看由内核维护的3个数据结构:

  • 进程级的文件描述符表
  • 系统级的打开文件表
  • 文件系统的 i-node 表

针对每个进程,内核为其维护打开文件的描述符表(open file descriptor table)。该表的每一条目都记录了该进程的一个打开文件的文件描述符的相关信息,如下所示:

  • 控制文件描述符的一组标志。(目前,此类标志仅定义了一个,即 close-on-exec标志)
  • 对打开文件表条目(下面介绍)的引用

而我们一直在说的文件描述符fd其实就是这个表的索引或者说下标

内核对所有打开的文件维护有一个系统级的打开文件表(open file table)。一个打开文件表条目存储了与一个打开文件相关的全部信息,如下所示:

  • 当前文件的偏移量(调用read()write()时更新,或使用lseek()直接修改)。
  • 打开文件时所使用的状态标志(即open()的flags参数)。
  • 文件访问模式(只读、只写、读写)。
  • 与信号驱动I/O相关的设置。
  • 对该文件 i-node对象的引用。

每个文件系统都会为驻留其上的所有文件建立一个 i-node 表。下面是每个文件的 i-node 信息,如下所示:

  • 文件类型和访问权限
  • 一个指针,通常指向该文件所持有的锁的列表
  • 文件的各种属性,包括文件大小及与不同类型操作相关的时间戳

此处其实忽略了i-node在磁盘和内存中的表示差异(有关磁盘中的i-node参见这篇blog)。磁盘上的i-node记录了文件的固有属性,诸如:文件类型、访问权限和时间戳。访问一个文件时,会在内存中为i-node创建一个副本,其中额外记录了记录了引用该i-node的打开文件句柄数量以及该i-node所在设备的主从设备号,还包括一些打开文件时与文件相关的临时属性,例如:文件锁。

下图表示了这三者的关系:

为了完成文件输入输出,进程通过系统调用把文件描述符传递给内核,然后内核替进程操作文件。进程不能直接接触到文件或者是 i-node 表。

图中有多种对应关系,现在一一解读:

在进程A中,文件描述符1和20都指向同一个打开的文件表条目(标号为23)。这可能是通过调用 dup()dup2()fcntl()而形成的,即同一个进程的不同文件描述符指向同一个打开文件。

进程A的文件描述符2和进程B的文件描述符2指向同一个打开的文件表条目(标号为73)。这种情形可能在调用fork()后出现,或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程时,也会发生。

此外,进程A的文件描述符0和进程B的文件描述符3分别指向不同的打开文件表条目,但这些条目均指向 i-node 表中相同的条目(1976),换言之,指向同一个文件。这种情况是因为进程各自对同一文件发起了open()调用。同一个进程打开两次同一文件也会发生类似的情况。

上面的知识可以推导出下面的几个要点:

  • 如果两个不同的文件描述符,指向同一个打开文件表条目,将共享同一文件偏移量。因此如果通过其中的一个文件描述符来修改文件偏移量,从另一个文件描述符中也会观察到这一变化。无论两个文件描述符分属于不同的进程,还是属于同一进程。
  • 与上一条类似,修改打开文件标志和文件访问模式,也会影响到指向同一打开文件表条目的不同文件描述符。
  • 相比之下,文件描述符表中进程私有的标志如close-on-exec ,修改后不会影响任何其他的文件描述符。

7.1. Linux内核中对上述结构的实现

在Linux内核表示PCB的结构体task_struct中,可以看到这么一个成员变量:

注释也指出了这一条目记录的是进程的打开文件信息,也就是上面所说的进程级的文件描述符表。打开files_struct这个结构体可以看到里面长这样:

注意其中最后一个结构体指针数组fd_array,我们一直所说的文件描述符所代表的下标就是这个数组的下标了。像上面所说,这个数组中的每一个元素指向一个系统中的打开文件,即一条系统级的打开文件表条目。

struct file又是个什么结构体呢。这个结构体就是操作系统对一个打开的文件的抽象,其中记录了描述一个打开文件的全部信息。如上面提到过的读写偏移量、文件访问模式、打开文件的状态标志,以及最重要的,指向系统中I-node表的指针,即这个结构体表示的是磁盘上具体的哪一个文件。

8. 复制文件描述符

有了上面的铺垫,我们会对这一节的内容理解得更加透彻。

Bourne shell的 I/O重定向语法 2>&1,意在通知shell把标准错误(文件描述符2)重定向到标准输出(文件描述符1)所指向的位置。因此,下面的命令会(shell按照从左到右的顺序处理 I/O 重定向语句)把标准输出和标准错误都写入到文件 results.log中。

$ ./mysrcipt > results.log 2>&1

因为 > 标志将 myscript 的标准输出重定向到resluts.log,而下一句又把标准错误重定向到标准输出,即也指向 results.log。标准输出和标准错误指向同一个打开文件表条目,共享相同的文件偏移量。

shell 通过修改文件描述符2所指向的打开文件表条目以实现了标准错误的重定向操作,因此文件描述符2此时和文件描述符1指向同一个打开文件表条目(类似于上一节的图中进程A的描述符1和20指向同一打开文件表条目的情况)。可以通过 dup()dup2()来实现此功能。

8.1. dup() / dup2()

dup() 调用复制一个打开的文件描述符oldfd,并返回一个新的文件描述符,二者都指向同一个打开文件表条目。系统会保证新文件描述符一定是编号值最低的未使用文件描述符。

int dup(int oldfd);
// return new file descriptor on success, or -1 on error

dup2() 系统调用会为oldfd参数所指定的文件描述符创建副本,其标号由newfd指定。即newfd文件描述符指向oldfd所指向的打开文件表条目。如果newfd所指定的文件描述符之前已经打开,则dup2() 会首先将其关闭,而且会忽略关闭过程中出现的任何错误。所以更安全的做法是先显示关闭newfd所指定的文件描述符。

int dup2(int oldfd, int newfd);

9. 文件描述符 fd 与文件流指针 FILE* 的关系

如前面所说,文件描述符是系统调用I/O接口的操作句柄,而文件流指针是库函数I/O接口的操作句柄。我们知道有关文件的所有操作都涉及到底层硬件的读写,所以最终一定会用到操作系统提供的对外接口即系统调用,也就是说所有的库函数I/O接口如我们常用的printf()或者fread()等在内部一定都会用到这篇博客所介绍的几种最基本的系统调用接口。这也就是说库函数一定得能通过文件流指针找到对应的文件描述符,才能在内部正确的调用系统调用。

问了搞清楚库函数是如何通过文件流指针找到文件描述符的,我们还得打开FILE这个结构体来一探究竟。

结构体FILE就是将结构体_IO_FILE进行typedef得来的,所以上面就是__IO_FILE的内部结构。可以看到其中有一个字段_fileno整型字段,这就代表了这个文件流指针所对应的文件的文件描述符,也就是因为有这个字段,库函数才能在内部方便的找到系统调用所需要的操作句柄!

Last modification:November 16th, 2019 at 12:28 pm