目录
- Linux 系统如何管理文件
- 静态文件与inode
- 文件打开时的状态
- 返回错误处理与errno
- strerror 函数
- perror 函数
- exit、_exit、_Exit
- _exit()和_Exit()函数
- exit()函数
- 空洞文件
- 概念
- 实验测试
- O_APPEND 和O_TRUNC 标志
- O_TRUNC 标志
- O_APPEND 标志
- 多次打开同一个文件
- 验证一些现象
- 多次打开同一文件进行读操作与O_APPEND 标志
- 复制文件描述符
- dup 函数
- dup2 函数
- 文件共享
- 原子操作与竞争冒险
- 竞争冒险简介
- 原子操作
- fcntl 和ioctl
- fcntl 函数
- ioctl 函数
- 截断文件
- I/O 缓冲
- 文件I/O 的内核缓冲
- 刷新文件I/O 的内核缓冲区
- ㈠、fsync()函数
- ㈡、fdatasync()函数
- ㈢、sync()函数
- 直接I/O:绕过内核缓冲
- stdio 缓冲
- ㈠、setvbuf()函数
- ㈡、setbuf()函数
- ㈢、setbuffer()函数
- fflush刷新stdio缓冲区
- I/O 缓冲小节
- 文件描述符与FILE 指针互转
- 检查或复位状态
- feof()函数
- ferror()函数
- clearerr()函数
经过上一章内容的学习,相信各位读者对Linux 系统应用编程中的基础文件I/O 操作有了一定的认识和理解了,能够独立完成一些简单地文件I/O 编程问题,如果你的工作中仅仅只是涉及到一些简单文件读写操作相关的问题,其实上一章的知识内容已经够你使用了。
当然作为大部分读者来说,我相信你不会止步于此、还想学习更多的知识内容,那本章笔者将会同各位读者一起,来深入探究文件I/O 中涉及到的一些问题、原理以及所对应的解决方法,譬如Linux 系统下文件是如何进行管理的、调用函数返回错误该如何处理、open 函数的O_APPEND、O_TRUNC 标志以及等相关问题。
Linux 系统如何管理文件
静态文件与inode
文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U 盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。
文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存512 字节(相当于0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。这种由多个扇区组成的“块”,是文件存取的最小单位。“块”的大小,最常见的是4KB,即连续八个sector 组成一个block。
所以由此可以知道,静态文件对应的数据都是存储在磁盘设备不同的“块”中,那么问题来了,我们在程序中调用open 函数是如何找到对应文件的数据存储“块”的呢,难道仅仅通过指定的文件路径就可以实现?这里我们就来简单地聊一聊这内部实现的过程。
我们的磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是inode 区,用于存放inode table(inode 表),inode table 中存放的是一个一个的inode(也成为inode
节点),不同的inode 就可以表示不同的文件,每一个文件都必须对应一个inode,inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的block(块)位置等等信息,如图3.1.1 中所示(这里需要注意的是,文件名并不是记录在inode 中,这个问题后面章节内容再给大家讲)。
图3.1.1 inode table 与inode
所以由此可知,inode table 表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个inode,每一个inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到inode table 中所对应的inode。在Linux 系统下,我们可以通过"ls -i"命令查看文件的inode 编号,如下所示:
所以由此可知,inode table 表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个inode,每一个inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到inode table 中所对应的inode。在Linux 系统下,我们可以通过"ls -i"命令查看文件的inode 编号,如下所示:
上图中ls 打印出来的信息中,每一行前面的一个数字就表示了对应文件的inode 编号。除此之外,还可以使用stat 命令查看,用法如下:
由以上的介绍大家可以联系到实际操作中,譬如我们在Windows 下进行U 盘格式化的时候会有一个“快速格式化”选项,如下所示:
如果勾选了“快速格式化”选项,在进行格式化操作的时候非常的快,而如果不勾选此选项,直接使用普通格式化方式,将会比较慢,那说明这两种格式化方式是存在差异的,其实快速格式化只是删除了U 盘中的inode table 表,真正存储文件数据的区域并没有动,所以使用快速格式化的U 盘,其中的数据是可以被找回来的。
通过以上介绍可知,打开一个文件,系统内部会将这个过程分为三步:
- 系统找到这个文件名所对应的inode 编号;
- 通过inode 编号从inode table 中找到对应的inode 结构体;
- 根据inode 结构体中记录的信息,确定文件数据所在的block,并读出数据。
文件打开时的状态
当我们调用open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。
当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中。由此我们也可以联系到实际操作中,譬如说:
⚫ 打开一个大文件的时候会比较慢;
⚫ 文档写了一半,没记得保存,此时电脑因为突然停电直接掉电关机了,当重启电脑后,打开编写的文档,发现之前写的内容已经丢失。
想必各位读者在工作当中都遇到过这种问题吧,通过上面的介绍,就解释了为什么会出现这种问题。好,我们再来说一下,为什么要这样设计?
因为磁盘、硬盘、U 盘等存储设备基本都是Flash 块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节),一个字节的改动也需要将该字节所在的block 全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活;而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。
在Linux 系统中,内核会为每个进程(关于进程的概念,这是后面的内容,我们可以简单地理解为一个运行的程序就是一个进程,运行了多个程序那就是存在多个进程)设置一个专门的数据结构用于管理该进程,譬如用于记录进程的状态信息、运行特征等,我们把这个称为进程控制块(Process control block,缩写
PCB)。
PCB 数据结构体中有一个指针指向了文件描述符表(File descriptors),文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、引用计数、当前文件的读写偏移量以及i-node 指针(指向该文件对应的inode)等,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示:
前面给大家介绍了inode,inode 数据结构体中的元素会记录该文件的数据存储的block(块),也就是说可以通过inode 找到文件数据存在在磁盘设备中的那个位置,从而把文件数据读取出来。
以上就是本小节给大家介绍到所有内容了,上面给大家所介绍的内容后面的学习过程中还会用到,虽然这些理论知识对大家的编程并没有什么影响,但是会帮助大家理解文件IO 背后隐藏的一些理论知识,其实这些理论知识还是非常浅薄的、只是一个大概的认识,其内部具体的实现是比较复杂的,当然这个不是我们学习Linux 应用编程的重点,操作系统已经帮我们完成了这些具体的实现,我们要做的仅仅只是调用操作系统提供API 函数来完成自己的工作。
好了,废话不多说,我们接着看下一小节内容。
返回错误处理与errno
在上一章节中,笔者给大家编写了很多的示例代码,大家会发现这些示例代码会有一个共同的特点,那就是当判断函数执行失败后,会调用return 退出程序,但是对于我们来说,我们并不知道为什么会出错,什么原因导致此函数执行失败,因为执行出错之后它们的返回值都是-1。
难道我们真的就不知道错误原因了吗?其实不然,在Linux 系统下对常见的错误做了一个编号,每一个编号都代表着每一种不同的错误类型,当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给errno 变量,每一个进程(程序)都维护了自己的errno 变量,它是程序中的全局变量,该变量用于存储就近发生的函数执行错误编号,也就意味着下一次的错误码会覆盖上一次的错误码。所以由此可知道,当程序中调用函数发生错误的时候,操作系统内部会通过设置程序的errno 变量来告知调用者究竟发生了什么错误!
errno 本质上是一个int 类型的变量,用于存储错误编号,但是需要注意的是,并不是执行所有的系统调用或C 库函数出错时,操作系统都会设置errno,那我们如何确定一个函数出错时系统是否会设置errno 呢?其实这个通过man 手册便可以查到,譬如以open 函数为例,执行"man 2 open"打开open 函数的帮助信息,找到函数返回值描述段,如下所示:
从图中红框部分描述文字可知,当函数返回错误时会设置errno,当然这里是以open 函数为例,其它的系统调用也可以这样查找,大家可以自己试试!
在我们的程序当中如何去获取系统所维护的这个errno 变量呢?只需要在我们程序当中包含<errno.h>头文件即可,你可以直接认为此变量就是在<errno.h>头文件中的申明的,好,我们来测试下:
#include <stdio.h>
#include <errno.h>
int main(void)
{
printf("%d\n", errno);
return 0;
}
以上的这段代码是不会报错的,大家可以自己试试!
strerror 函数
前面给大家说到了errno 变量,但是errno 仅仅只是一个错误编号,对于开发者来说,即使拿到了errno
也不知道错误为何?还需要对比源码中对此编号的错误定义,可以说非常不友好,这里介绍一个C 库函数
strerror(),该函数可以将对应的errno 转换成适合我们查看的字符串信息,其函数原型如下所示(可通过"man 3 strerror"命令查看,注意此函数是C 库函数,并不是系统调用):
#include <string.h>
char *strerror(int errnum);
首先调用此函数需要包含头文件<string.h>。
函数参数和返回值如下:
errnum:错误编号errno。
返回值:对应错误编号的字符串描述信息。
测试
接下来我们测试下,测试代码如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
int main(void)
{
int fd;
/* 打开文件*/
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
printf("Error: %s\n", strerror(errno));
return -1;
}
close(fd);
return 0;
}
编译源代码,在Ubuntu 系统下运行测试下,在当前目录下并不存在test_file 文件,测试打印结果如下:
从打印信息可以知道,strerror 返回的字符串是"No such file or directory",所以从打印信息可知,我们就可以很直观的知道open 函数执行的错误原因是文件不存在!
perror 函数
除了strerror 函数之外,我们还可以使用perror 函数来查看错误信息,一般用的最多的还是这个函数,调用此函数不需要传入errno,函数内部会自己去获取errno 变量的值,调用此函数会直接将错误提示字符串打印出来,而不是返回字符串,除此之外还可以在输出的错误提示字符串之前加入自己的打印信息,函数原型如下所示(可通过"man 3 perror"命令查看):
#include <stdio.h>
void perror(const char *s);
需要包含<stdio.h>头文件。
函数参数和返回值含义如下:
s:在错误提示字符串信息之前,可加入自己的打印信息,也可不加,不加则传入空字符串即可。
返回值:void 无返回值。
测试
接下来我们进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int fd;
/* 打开文件*/
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
return -1;
}
close(fd);
return 0;
}
编译源代码,在Ubuntu 系统下运行测试下,在当前目录下并不存在test_file 文件,测试打印结果如下:
从打印信息可以知道,perror 函数打印出来的错误提示字符串是"No such file or directory",跟strerror 函数返回的字符串信息一样,"open error"便是我们附加的打印信息,而且从打印信息可知,perror 函数会在附加信息后面自动加入冒号和空格以区分。
以上给大家介绍了strerror、perror 两个C 库函数,都是用于查看函数执行错误时对应的提示信息,大家用哪个函数都可以,这里笔者推荐大家使用perror,在实际的编程中这个函数用的还是比较多的,当然除了这两个之外,其它其它一些类似功能的函数,这里就不再给大家介绍了,意义不大!
exit、_exit、_Exit
当程序在执行某个函数出错的时候,如果此函数执行失败会导致后面的步骤不能在进行下去时,应该在出错时终止程序运行,不应该让程序继续运行下去,那么如何退出程序、终止程序运行呢?有过编程经验的读者都知道使用return,一般原则程序执行正常退出return 0,而执行函数出错退出return -1,前面我们所编写的示例代码也是如此。
在Linux 系统下,进程(程序)退出可以分为正常退出和异常退出,注意这里说的异常并不是执行函数出现了错误这种情况,异常往往更多的是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等,这里我们只讨论正常退出的情况。
在Linux 系统下,进程正常退出除了可以使用return 之外,还可以使用exit()、_exit()以及_Exit(),下面我们分别介绍。
_exit()和_Exit()函数
main 函数中使用return 后返回,return 执行后把控制权交给调用函数,结束该进程。调用_exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。_exit()函数原型如下所示:
#include <unistd.h>
void _exit(int status);
调用函数需要传入status 状态标志,0 表示正常结束、若为其它值则表示程序执行过程中检测到有错误发生。使用示例如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void)
{
int fd;
/* 打开文件*/
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
_exit(-1);
}
close(fd);
_exit(0);
}
用法很简单,大家可以自行测试!
_Exit()函数原型如下所示:
#include <stdlib.h>
void _Exit(int status);
_exit()和_Exit()两者等价,用法作用是一样的,这里就不再讲了,需要注意的是这2 个函数都是系统调用。
exit()函数
exit()函数_exit()函数都是用来终止进程的,exit()是一个标准C 库函数,而_exit()和_Exit()是系统调用。执行exit()会执行一些清理工作,最后调用_exit()函数。exit()函数原型如下:
#include <stdlib.h>
void exit(int status);
该函数是一个标准C 库函数,使用该函数需要包含头文件<stdlib.h>,该函数的用法和_exit()/_Exit()是一样的,这里就不再多说了。
本小节就给大家介绍了3 中终止进程的方法:
⚫ main 函数中运行return;
⚫ 调用Linux 系统调用_exit()或_Exit();
⚫ 调用C 标准库函数exit()。
不管你用哪一种都可以结束进程,但还是推荐大家使用exit(),其实关于return、exit、_exit/_Exit()之间的区别笔者在上面只是给大家简单地描述了一下,甚至不太确定我的描述是否正确,因为笔者并不太多去关心其间的差异,对这些概念的描述会比较模糊、笼统,如果大家看不明白可以自己百度搜索相关的内容,当然对于初学者来说,不太建议大家去查找这些东西,至少对你现阶段来说,意义不是很大。好,本小节就介绍这么多,我们接着学习下一小节的内容。
空洞文件
概念
什么是空洞文件(hole file)?在上一章内容中,笔者给大家介绍了lseek()系统调用,使用lseek 可以修改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度,这是什么意思呢?譬如有一个test_file,该文件的大小是4K(也就是4096 个字节),如果通过lseek 系统调用将该文件的读写偏移量移动到偏移文件头部6000 个字节处,大家想一想会怎样?如果笔者没有提前告诉大家,大家觉得不能这样操作,但事实上lseek 函数确实可以这样操作。
接下来使用write()函数对文件进行写入操作,也就是说此时将是从偏移文件头部6000 个字节处开始写入数据,也就意味着4096~6000 字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。
文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的,这点需要注意。
那说了这么多,空洞文件有什么用呢?空洞文件对多线程共同操作文件是及其有用的,有时候我们创建一个很大的文件,如果单个线程从头开始依次构建该文件需要很长的时间,有一种思路就是将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入;这个有点像我们现实生活当中施工队修路的感觉,比如说修建一条高速公路,单个施工队修筑会很慢,这个时候可以安排多个施工队,每一个施工队负责修建其中一段,最后将他们连接起来。
来看一下实际中空洞文件的两个应用场景:
⚫ 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势;
⚫ 在创建虚拟机时,你给虚拟机分配了100G 的磁盘空间,但其实系统安装完成之后,开始也不过只用了3、4G 的磁盘空间,如果一开始就把100G 分配出去,资源是很大的浪费。
关于空洞文件,这里就介绍这么多,上述描述当中多次提到了线程这个概念,关于线程这是后面的内容,这里先不给大家讲。
实验测试
这里我们进行相关的测试,新建一个文件把它做成空洞文件,示例代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(void)
{
int fd;
int ret;
char buffer[1024];
int i;
/* 打开文件*/
fd = open("./hole_file", O_WRONLY | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 将文件读写位置移动到偏移文件头4096 个字节(4K)处*/
ret = lseek(fd, 4096, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err;
}
/* 初始化buffer 为0xFF */
memset(buffer, 0xFF, sizeof(buffer));
/* 循环写入4 次,每次写入1K */
for (i = 0; i < 4; i++) {
ret = write(fd, buffer, sizeof(buffer));
if (-1 == ret) {
perror("write error");
goto err;
}
}
ret = 0;
err:
/* 关闭文件*/
close(fd);
exit(ret);
}
示例代码中,我们使用open 函数新建了一个文件hole_file,在Linux 系统中,新建文件大小是0,也就是没有任何数据写入,此时使用lseek函数将读写偏移量移动到4K 字节处,再使用write 函数写入数据0xFF,每次写入1K,一共写入4 次,也就是写入了4K 数据,也就意味着该文件前4K 是文件空洞部分,而后4K
数据才是真正写入的数据。
接下来进行编译测试,首先确保当前文件目录下不存在hole_file 文件,测试结果如下:
使用ls 命令查看到空洞文件的大小是8K,使用ls 命令查看到的大小是文件的逻辑大小,自然是包括了空洞部分大小和真实数据部分大小;当使用du 命令查看空洞文件时,其大小显示为4K,du 命令查看到的大小是文件实际占用存储块的大小。
本小节内容就讲解完了,最后再向各位抛出一个问题:若使用read 函数读取文件空洞部分,读取出来的将会是什么?关于这个问题大家可以先思考下,至于结果是什么,笔者这里便不给出答案了,大家可以自己动手编写代码进行测试以得出结论。
O_APPEND 和O_TRUNC 标志
在上一章给大家讲解open 函数的时候介绍了一些open 函数的flags 标志,譬如O_RDONLY、
O_WRONLY、O_CREAT、O_EXCL 等,本小节再给大家介绍两个标志,分别是O_APPEND 和O_TRUNC,接下来对这两个标志分别进行介绍。
O_TRUNC 标志
O_TRUNC 这个标志的作用非常简单,如果使用了这个标志,调用open 函数打开文件的时候会将文件原本的内容全部丢弃,文件大小变为0;这里我们直接测试即可!测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd;
/* 打开文件*/
fd = open("./test_file", O_WRONLY | O_TRUNC);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 关闭文件*/
close(fd);
exit(0);
}
在当前目录下有一个文件test_file,测试代码中使用了O_TRUNC 标志打开该文件,代码中仅仅只是打开该文件,之后调用close 关闭了文件,并没有对其进行读写操作,接下来编译运行来看看测试结果:
在测试之前test_file 文件中是有数据的,文件大小为8760 个字节,执行完测试程序后,再使用ls 命令查看文件大小时发现test_file 大小已经变成了0,也就是说明文件之前的内容已经全部被丢弃了。这就是
O_TRUNC 标志的作用了,大家可以自己动手试试。
O_APPEND 标志
接下里聊一聊O_APPEND 标志,如果open 函数携带了O_APPEND 标志,调用open 函数打开文件,当每次使用write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾,从文件末尾开始写入数据,也就是意味着每次写入数据都是从文件末尾开始。这里我们直接进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char buffer[16];
int fd;
int ret;
/* 打开文件*/
fd = open("./test_file", O_RDWR | O_APPEND);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 初始化buffer 中的数据*/
memset(buffer, 0x55, sizeof(buffer));
/* 写入数据: 写入4 个字节数据*/
ret = write(fd, buffer, 4);
if (-1 == ret) {
perror("write error");
goto err;
}
/* 将buffer 缓冲区中的数据全部清0 */
memset(buffer, 0x00, sizeof(buffer));
/* 将位置偏移量移动到距离文件末尾4 个字节处*/
ret = lseek(fd, -4, SEEK_END);
if (-1 == ret) {
perror("lseek error");
goto err;
}
/* 读取数据*/
ret = read(fd, buffer, 4);
if (-1 == ret) {
perror("read error");
goto err;
}
printf("0x%x 0x%x 0x%x 0x%x\n", buffer[0], buffer[1],
buffer[2], buffer[3]);
ret = 0;
err:
/* 关闭文件*/
close(fd);
exit(ret);
}
测试代码中会去打开当前目录下的test_file 文件,使用可读可写方式,并且使用了O_APPEND 标志,前面笔者给大家提到过,open 打开一个文件,默认的读写位置偏移量会处于文件头,但测试代码中使用了
O_APPEND 标志,如果O_APPEND 确实能生效的话,也就意味着调用write 函数会从文件末尾开始写;代码中写入了4 个字节数据,都是0x55,之后,使用lseek 函数将位置偏移量移动到距离文件末尾4 个字节处,读取4 个字节(也就是读取文件最后4 个字节数据),之后将其打印出来,如果上面笔者的描述正确的话,打印出来的数据就是我们写入的数据,如果O_APPEND 不能生效,则打印出来数据就不会是0x55,接下来编译测试:
从上面打印信息可知,读取出来的数据确实等于0x55,说明O_APPEND 标志确实有作用,当调用write()
函数写文件时,会自动把文件当前位置偏移量移动到文件末尾。
当然,本小节内容还并没有结束,这其中还涉及到一些细节问题需要大家注意,首先第一点,O_APPEND
标志并不会影响读文件,当读取文件时,O_APPEND 标志并不会影响读位置偏移量,即使使用了O_APPEND
标志,读文件位置偏移量默认情况下依然是文件头,关于这个问题大家可以自己进行测试,编程是一个实践性很强的工作,有什么不能理解的问题,可以自己编写程序进行测试。
大家可能会想到使用lseek 函数来改变write()时的写位置偏移量,其实这种做法并不会成功,这就是笔者给大家提的第二个细节,使用了O_APPEND 标志,即使是通过lseek 函数也是无法修改写文件时对应的位置偏移量(注意笔者这里说的是写文件,并不包括读),写入数据依然是从文件末尾开始,lseek 并不会该变写位置偏移量,这个问题测试方法很简单,也就是在write 之前使用lseek 修改位置偏移量,这里笔者就不再给大家测试了,我还是那句话,编程是一个实践性很强的工作,大家只需要把示例代码3.5.2 进行简单地修改即可!
其实关于第二点细节原因很简单,当执行write()函数时,检测到open 函数携带了O_APPEND 标志,所以在write 函数内部会自动将写位置偏移量移动到文件末尾,当然这里也只是笔者的一个简单地猜测,至于是不是这样,笔者也无从考证。
到这里本小节的内容就暂时介绍完了,为什么说是“暂时”?因为后面的内容中还会聊到O_APPEND
标志,最后笔者再给大家出一个小问题,大家可以自己动手测试。
◆ 当open 函数同时携带了O_APPEND 和O_TRUNC 两个标志时会有什么作用?
多次打开同一个文件
大家看到这个小节标题可能会有疑问,同一个文件还能被多次打开?事实确实如此,同一个文件可以被多次打开,譬如在一个进程中多次打开同一个文件、在多个不同的进程中打开同一个文件,那么这些操作都是被允许的。本小节就来探讨下多次打开同一个文件会有一些什么现象以及相应的细节问题?
验证一些现象
⚫ 一个进程内多次open 打开同一个文件,那么会得到多个不同的文件描述符fd,同理在关闭文件的时候也需要调用close 依次关闭各个文件描述符。
针对这个问题,我们编写测试代码进行测试,如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd1, fd2, fd3;
int ret;
/* 第一次打开文件*/
fd1 = open("./test_file", O_RDWR);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 第二次打开文件*/
fd2 = open("./test_file", O_RDWR);
if (-1 == fd2) {
perror("open error");
ret = -1;
goto err1;
}
/* 第三次打开文件*/
fd3 = open("./test_file", O_RDWR);
if (-1 == fd3) {
perror("open error");
ret = -1;
goto err2;
}
/* 打印出3 个文件描述符*/
printf("%d %d %d\n", fd1, fd2, fd3);
close(fd3);
ret = 0;
err2:
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
上述示例代码中,通过3 次调用open 函数对test_file 文件打开了3 次,每一个调用传参一样,最后将
3 次得到的文件描述符打印出来,在当前目录下存在test_file 文件,接下来编译测试,看看结果如何:
从打印结果可知,三次调用open 函数得到的文件描述符分别为6、7、8,通过任何一个文件描述符对文件进行IO 操作都是可以的,但是需要注意是,调用open 函数打开文件使用的是什么权限,则返回的文件描述符就拥有什么权限,文件IO 操作完成之后,在结束进程之前需要使用close 关闭各个文件描述符。
在图3.6.1 中,细心的读者可能会发现,调用open 函数得到的最小文件描述符是6,在上一章节内容中给大家提到过,程序中分配得到的最小文件描述符一般是3,但这里竟然是6!这是为何?其实这个问题跟
vscode 有关,说明3、4、5 这3 个文件描述符已经被vscode 软件对应的进程所占用了,而当前这里执行
testApp 文件是在vscode 软件提供的终端下进行的,所以vscode 可以认为是testApp 进程的父进程,相反,
testApp 进程便是vscode 进程的子进程,子进程会继承父进程的文件描述符。关于子进程和父进程这些都是后面的内容,这里暂时不给大家进行介绍,这是只是给大家简单地解释一下,免得大家误会!
其实可以直接在Ubuntu 系统的Terminal 终端执行testApp,这时你会发现打印出来的文件描述符分别是3、4、5,这里就不给大家演示了。
⚫ 一个进程内多次open 打开同一个文件,在内存中并不会存在多份动态文件。
当调用open 函数的时候,会将文件数据(文件内容)从磁盘等块设备读取到内存中,将文件数据在内存中进行维护,内存中的这份文件数据我们就把它称为动态文件!这是前面给大家介绍的内容,这里再简单地提一下。这里出现了一个问题:如果同一个文件被多次打开,那么该文件所对应的动态文件是否在内存中也存在多份?也就是说,多次打开同一个文件是否会将其文件数据多次拷贝到内存中进行维护?
关于这个问题,各位读者可以简单地思考一下,这里我们直接编写代码进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
char buffer[4];
int fd1, fd2;
int ret;
/* 创建新文件test_file 并打开*/
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 再次打开test_file 文件*/
fd2 = open("./test_file", O_RDWR);
if (-1 == fd2) {
perror("open error");
ret = -1;
goto err1;
}
/* 通过fd1 文件描述符写入4 个字节数据*/
buffer[0] = 0x11;
buffer[1] = 0x22;
buffer[2] = 0x33;
buffer[3] = 0x44;
ret = write(fd1, buffer, 4);
if (-1 == ret) {
perror("write error");
goto err2;
}
/* 将读写位置偏移量移动到文件头*/
buffer[0] = 0x11;
buffer[1] = 0x22;
buffer[2] = 0x33;
buffer[3] = 0x44;
ret = write(fd1, buffer, 4);
if (-1 == ret) {
perror("write error");
goto err2;
}
/* 将读写位置偏移量移动到文件头*/
ret = lseek(fd2, 0, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err2;
}
/* 读取数据*/
memset(buffer, 0x00, sizeof(buffer));
ret = read(fd2, buffer, 4);
if (-1 == ret) {
perror("read error");
goto err2;
}
printf("0x%x 0x%x 0x%x 0x%x\n", buffer[0], buffer[1],
buffer[2], buffer[3]);
ret = 0;
err2:
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
当前目录下不存在test_file 文件,上述代码中,第一次调用open 函数新建并打开test_file 文件,第二次调用open 函数再次打开它,新建文件时,文件大小为0;首先通过文件描述符fd1 写入4 个字节数据(0x11/0x22/0x33/0x44),从文件头开始写;然后再通过文件描述符fd2 读取4 个字节数据,也是从文件头开始读取。假如,内存中只有一份动态文件,那么读取得到的数据应该就是0x11、0x22、0x33、0x44,如果存在多份动态文件,那么通过fd2 读取的是与它对应的动态文件中的数据,那就不是0x11、0x22、0x33、
0x44,而是读取出0 个字节数据,因为它的文件大小是0。
接下来进行编译测试:
上图中打印显示读取出来的数据是0x11/0x22/0x33/0x44,所以由此可知,即使多次打开同一个文件,内存中也只有一份动态文件。
⚫ 一个进程内多次open 打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的。
同一个文件被多次打开,会得到多个不同的文件描述符,也就意味着会有多个不同的文件表,而文件读写偏移量信息就记录在文件表数据结构中,所以从这里可以推测不同的文件描述符所对应的读写偏移量是相互独立的,并没有关联在一起,并且文件表中i-node 指针指向的都是同一个inode,如下图所示:
测试的方法很简单,只需在示例代码3.6.2 中简单地修改即可,将lseek 函数调用去掉,然后在编译测试,如果读出的数据依然是0x11/0x22/0x33/0x44,则表示第三点结论成立,这里不再给大家演示。
Tips:多个不同的进程中调用open()打开磁盘中的同一个文件,同样在内存中也只是维护了一份动态文件,多个进程间共享,它们有各自独立的文件读写位置偏移量。
动态文件何时被关闭呢?当文件的引用计数为0 时,系统会自动将其关闭,同一个文件被打开多次,文件表中会记录该文件的引用计数,如图3.1.5 所示,引用计数记录了当前文件被多少个文件描述符fd 关联。
多次打开同一文件进行读操作与O_APPEND 标志
重复打开同一个文件,进行写操作,譬如一个进程中两次调用open 函数打开同一个文件,分别得到两个文件描述符fd1 和fd2,使用这两个文件描述符对文件进行写入操作,那么它们是分别写(各从各的位置偏移量开始写)还是接续写(一个写完,另一个接着后面写)?其实这个问题,3.6.1 小节中已经给出了答案,因为这两个文件描述符所对应的读写位置偏移量是相互独立的,所以是分别写,接下来我们还是编写代码进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
unsigned char buffer1[4], buffer2[4];
int fd1, fd2;
int ret;
int i;
/* 创建新文件test_file 并打开*/
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 再次打开test_file 文件*/
fd2 = open("./test_file", O_RDWR);
if (-1 == fd2) {
perror("open error");
ret = -1;
goto err1;
}
/* buffer 数据初始化*/
buffer1[0] = 0x11;
buffer1[1] = 0x22;
buffer1[2] = 0x33;
buffer1[3] = 0x44;
buffer2[0] = 0xAA;
buffer2[1] = 0xBB;
buffer2[2] = 0xCC;
buffer2[3] = 0xDD;
/* 循环写入数据*/
for (i = 0; i < 4; i++) {
ret = write(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("write error");
goto err2;
}
ret = write(fd2, buffer2, sizeof(buffer2));
if (-1 == ret) {
perror("write error");
goto err2;
}
}
/* 将读写位置偏移量移动到文件头*/
ret = lseek(fd1, 0, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err2;
}
/* 读取数据*/
for (i = 0; i < 8; i++) {
ret = read(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("read error");
goto err2;
}
printf("%x%x%x%x", buffer1[0], buffer1[1],
buffer1[2], buffer1[3]);
}
printf("\n");
ret = 0;
err2:
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
重复两次打开test_file 文件,分别得到两个文件描述符fd1、fd2;首先通过fd1 写入4 个字节数据(0x11、0x22、0x33、0x44)到文件中,接着再通过fd2 写入4 个字节数据(0xaa、0xbb、
0xcc、0xdd)到文件中,循环写入4 此;最后再将写入的数据读取出来,将其打印到终端。如果它们是分别写,那么读取出来的数据就应该是aabbccdd……,因为通过fd1 写入的数据被fd2 写入的数据给覆盖了;如果它们是接续写,那么读取出来的数据应该是11223344aabbccdd……,接下里我们编译测试:
从打印结果可知,它们确实是分别写。如果想要实现接续写,也就是当通过fd1 写入完成之后,通过fd2
写入的数据是接在fd1 写入的数据之后,那么该怎么做呢?当然可以写入数据之前通过lseek 函数将文件偏移量移动到文件末尾,如果是这样做,会存在一些问题,关于这个问题后面再给大家介绍;这里我们给大家介绍使用O_APPEND 标志来解决这个问题,也就是将分别写更改为接续写。
前面给大家介绍了open 函数的O_APPEND 标志,当open 函数使用O_APPEND 标志,在使用write 函数进行写入操作时,会自动将偏移量移动到文件末尾,也就是每次写入都是从文件末尾开始;这里结合本小节的内容,我们再来讨论O_APPEND 标志,在多次打开同一个文件进行写操作时,使用O_APPEND 标志会有什么样的效果,接下来进行测试:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
unsigned char buffer1[4], buffer2[4];
int fd1, fd2;
int ret;
int i;
/* 创建新文件test_file 并打开*/
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL | O_APPEND,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 再次打开test_file 文件*/
fd2 = open("./test_file", O_RDWR | O_APPEND);
if (-1 == fd2) {
perror("open error");
ret = -1;
goto err1;
}
/* buffer 数据初始化*/
buffer1[0] = 0x11;
buffer1[1] = 0x22;
buffer1[2] = 0x33;
buffer1[3] = 0x44;
buffer2[0] = 0xAA;
buffer2[1] = 0xBB;
buffer2[2] = 0xCC;
buffer2[3] = 0xDD;
/* 循环写入数据*/
for (i = 0; i < 4; i++) {
ret = write(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("write error");
goto err2;
}
ret = write(fd2, buffer2, sizeof(buffer2));
if (-1 == ret) {
perror("write error");
goto err2;
}
}
/* 将读写位置偏移量移动到文件头*/
ret = lseek(fd1, 0, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err2;
}
/* 读取数据*/
for (i = 0; i < 8; i++) {
ret = read(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("read error");
goto err2;
}
printf("%x%x%x%x", buffer1[0], buffer1[1],
buffer1[2], buffer1[3]);
}
printf("\n");
ret = 0;
err2:
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
open 函数添加了O_APPEND 标志,其它内容并没有动过,接下来编译测试。
从打印出来的数据可知,加入了O_APPEND 标志后,分别写已经变成了接续写。关于O_APPEND 标志还涉及到一个原子操作的问题,后面再给大家介绍,本小节内容到此!
复制文件描述符
在Linux 系统中,open 返回得到的文件描述符fd 可以进行复制,复制成功之后可以得到一个新的文件描述符,使用新的文件描述符和旧的文件描述符都可以对文件进行IO 操作,复制得到的文件描述符和旧的文件描述符拥有相同的权限,譬如使用旧的文件描述符对文件有读写权限,那么新的文件描述符同样也具有读写权限;在Linux 系统下,可以使用dup 或dup2 这两个系统调用对文件描述符进行复制,本小节就给大家介绍这两个函数的用法以及它们之间的区别。
复制得到的文件描述符与旧的文件描述符都指向了同一个文件表,假设fd1 为原文件描述符,fd2 为复制得到的文件描述符,如下图所示:
因为复制得到的文件描述符与旧的文件描述符指向的是同一个文件表,所以可知,这两个文件描述符的属性是一样,譬如对文件的读写权限、文件状态标志、文件偏移量等,所以从这里也可知道“复制”的含义实则是复制文件表。同样,在使用完毕之后也需要使用close 来关闭文件描述符。
dup 函数
dup 函数用于复制文件描述符,此函数原型如下所示(可通过"man 2 dup"命令查看):
#include <unistd.h>
int dup(int oldfd);
首先使用此函数需要包含头文件<unistd.h>。
函数参数和返回值含义如下:
oldfd:需要被复制的文件描述符。
返回值:成功时将返回一个新的文件描述符,由操作系统分配,分配置原则遵循文件描述符分配原则;如果复制失败将返回-1,并且会设置errno 值。
测试
由前面的介绍可知,复制得到的文件描述符与原文件描述符都指向同一个文件表,所以它们的文件读写偏移量是一样的,那么是不是可以在不使用O_APPEND 标志的情况下,通过文件描述符复制来实现接续写,接下来我们编写一个程序进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
unsigned char buffer1[4], buffer2[4];
int fd1, fd2;
int ret;
int i;
/* 创建新文件test_file 并打开*/
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 复制文件描述符*/
fd2 = dup(fd1);
if (-1 == fd2) {
perror("dup error");
ret = -1;
goto err1;
}
printf("fd1: %d\nfd2: %d\n", fd1, fd2);
/* buffer 数据初始化*/
buffer1[0] = 0x11;
buffer1[1] = 0x22;
buffer1[2] = 0x33;
buffer1[3] = 0x44;
buffer2[0] = 0xAA;
buffer2[1] = 0xBB;
buffer2[2] = 0xCC;
buffer2[3] = 0xDD;
/* 循环写入数据*/
for (i = 0; i < 4; i++) {
ret = write(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("write error");
goto err2;
}
ret = write(fd2, buffer2, sizeof(buffer2));
if (-1 == ret) {
perror("write error");
goto err2;
}
}
/* 将读写位置偏移量移动到文件头*/
ret = lseek(fd1, 0, SEEK_SET);
if (-1 == ret) {
perror("lseek error");
goto err2;
}
/* 读取数据*/
for (i = 0; i < 8; i++) {
ret = read(fd1, buffer1, sizeof(buffer1));
if (-1 == ret) {
perror("read error");
goto err2;
}
printf("%x%x%x%x", buffer1[0], buffer1[1],
buffer1[2], buffer1[3]);
}
printf("\n");
ret = 0;
err2:
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
测试代码中,我们使用了dup 系统调用复制了文件描述符fd1,得到另一个新的文件描述符fd2,分别通过fd1 和fd2 对文件进行写操作,最后读取写入的数据来判断是分别写还是接续写,接下来编译测试:
由打印信息可知,fd1 等于6,复制得到的新的文件描述符为7(遵循fd 分配原则),打印出来的数据显示为接续写,所以可知,通过复制文件描述符可以实现接续写。
dup2 函数
dup 系统调用分配的文件描述符是由系统分配的,遵循文件描述符分配原则,并不能自己指定一个文件描述符,这是dup 系统调用的一个缺陷;而dup2 系统调用修复了这个缺陷,可以手动指定文件描述符,而不需要遵循文件描述符分配原则,当然在实际的编程工作中,需要根据自己的情况来进行选择。
dup2 函数原型如下所示(可以通过"man 2 dup2"命令查看):
#include <unistd.h>
int dup2(int oldfd, int newfd);
同样使用该命令也需要包含<unistd.h>头文件。
函数参数和返回值含义如下:
oldfd:需要被复制的文件描述符。
newfd:指定一个文件描述符(需要指定一个当前进程没有使用到的文件描述符)。
返回值:成功时将返回一个新的文件描述符,也就是手动指定的文件描述符newfd;如果复制失败将返回-1,并且会设置errno 值。
测试
接下来编写一个简单地测试程序,如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd1, fd2;
int ret;
/* 创建新文件test_file 并打开*/
fd1 = open("./test_file", O_RDWR | O_CREAT | O_EXCL,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 复制文件描述符*/
fd2 = dup2(fd1, 100);
if (-1 == fd2) {
perror("dup error");
ret = -1;
goto err1;
}
printf("fd1: %d\nfd2: %d\n", fd1, fd2);
ret = 0;
close(fd2);
err1:
/* 关闭文件*/
close(fd1);
exit(ret);
}
测试代码使用dup2 函数复制文件描述符fd1,指定新的文件描述符为100,复制成功之后将其打印出来,结果如下所示:
由打印信息可知,复制得到的文件描述符fd2 等于100,正是我们在dup2 函数中指定的文件描述符。本小节的内容到这里结束了,最后再强调一点,文件描述符并不是只能复制一次,实际上可以对同一个文件描述符fd 调用dup 或dup2 函数复制多次,得到多个不同的文件描述符。
文件共享
什么是文件共享?所谓文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个inode)被多个独立的读写体同时进行IO 操作。多个独立的读写体大家可以将其简单地理解为对应于同一个文件的多个不同的文件描述符,譬如多次打开同一个文件所得到的多个不同的fd,或使用dup()(或dup2)函数复制得到的多个不同的fd 等。
同时进行IO 操作指的是一个读写体操作文件尚未调用close 关闭的情况下,另一个读写体去操作文件,前面给大家编写的示例代码中就已经涉及到了文件共享的内容了,譬如3.6 小节中编写的示例代码中,同一个文件对应两个不同的文件描述符fd1 和fd2,当使用fd1 对文件进行写操作之后,并没有关闭fd1,而此时使用fd2 对文件再进行写操作,这其实就是一种文件共享。
文件共享的意义有很多,多用于多进程或多线程编程环境中,譬如我们可以通过文件共享的方式来实现多个线程同时操作同一个大文件,以减少文件读写时间、提升效率。
文件共享的核心是:如何制造出多个不同的文件描述符来指向同一个文件。其实方法在上面的内容中都已经给大家介绍过了,譬如多次调用open 函数重复打开同一个文件得到多个不同的文件描述符、使用dup()
或dup2()函数对文件描述符进行复制以得到多个不同的文件描述符。
常见的三种文件共享的实现方式
(1)同一个进程中多次调用open 函数打开同一个文件,各数据结构之间的关系如下图所示:
这种情况非常简单,多次调用open 函数打开同一个文件会得到多个不同的文件描述符,并且多个文件描述符对应多个不同的文件表,所有的文件表都索引到了同一个inode 节点,也就是磁盘上的同一个文件。
(2)不同进程中分别使用open 函数打开同一个文件,其数据结构关系图如下所示:
进程1 和进程2 分别是运行在Linux 系统上两个独立的进程(理解为两个独立的程序),在他们各自的程序中分别调用open 函数打开同一个文件,进程1 对应的文件描述符为fd1,进程2 对应的文件描述符为
fd2,fd1 指向了进程1 的文件表1,fd2 指向了进程2 的文件表2;各自的文件表都索引到了同一个inode 节点,从而实现共享文件。
(3)同一个进程中通过dup(dup2)函数对文件描述符进行复制,其数据结构关系如下图所示:
这种方式上一小节已经给大家进行了详细讲解,这里不再重述!
对于文件共享,存在着竞争冒险,这个是需要大家关注的,下一小节将会向大家介绍。除此之外,我们还需要关心的是文件共享时,不同的读写体之间是分别写还是接续写,这些细节问题大家都要搞清楚。
原子操作与竞争冒险
Linux 是一个多任务、多进程操作系统,系统中往往运行着多个不同的进程、任务,多个不同的进程就有可能对同一个文件进行IO 操作,此时该文件便是它们的共享资源,它们共同操作着同一份文件;操作系统级编程不同于大家以前接触的裸机编程,裸机程序中不存在进程、多任务这种概念,而在Linux 系统中,我们必须要留意到多进程环境下可能会导致的竞争冒险。
竞争冒险简介
本小节给大家竞争冒险这个概念,如果学习过Linux 驱动开发的读者对这些概念应该并不陌生,也就意味着竞争冒险不但存在于Linux 应用层、也存在于Linux 内核驱动层。
假设有两个独立的进程A 和进程B 都对同一个文件进行追加写操作(也就是在文件末尾写入数据),每一个进程都调用了open 函数打开了该文件,但未使用O_APPEND 标志,此时,各数据结构之间的关系如图3.8.2 所示。每个进程都有它自己的进程控制块PCB,有自己的文件表(意味着有自己独立的读写位置偏移量),但是共享同一个inode 节点(也就是对应同一个文件)。假定此时进程A 处于运行状态,B 未处于等待运行状态,进程A 调用了lseek 函数,它将进程A 的该文件当前位置偏移量设置为1500 字节处(假设这里是文件末尾),刚好此时进程A 的时间片耗尽,然后内核切换到了进程B,进程B 执行lseek 函数,也将其对该文件的当前位置偏移量设置为1500 个字节处(文件末尾)。然后进程B 调用write 函数,写入了100 个字节数据,那么此时在进程B 中,该文件的当前位置偏移量已经移动到了1600 字节处。B 进程时间片耗尽,内核又切换到了进程A,使进程A 恢复运行,当进程A 调用write 函数时,是从进程A 的该文件当前位置偏移量(1500 字节处)开始写入,此时文件1500 字节处已经不再是文件末尾了,如果还从1500
字节处写入就会覆盖进程B 刚才写入到该文件中的数据。
其上述假设工作流程图如下图所示:
以上给大家所描述的这样一种情形就属于竞争状态(也成为竞争冒险),操作共享资源的两个进程(或线程),其操作之后的所得到的结果往往是不可预期的,因为每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得CPU 使用权的先后顺序是不可预期的,完全由操作系统调配,这就是所谓的竞争状态。
既然存在竞争状态,那么该如何规避或消除这种状态呢?接下来给大家介绍原子操作。
原子操作
在上一章给大家介绍open 函数的时候就提到过“原子操作”这个概念了,同样在Linux 驱动编程中,也有这个概念,相信学习过Linux 驱动编程开发的读者应该有印象。
从上一小节给大家提到的示例中可知,上述的问题出在逻辑操作“先定位到文件末尾,然后再写”,它使用了两个分开的函数调用,首先使用lseek 函数将文件当前位置偏移量移动到文件末尾、然后在使用write
函数将数据写入到文件。既然知道了问题所在,那么解决办法就是将这两个操作步骤合并成一个原子操作,所谓原子操作,是有多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。
(1)O_APPEND 实现原子操作
在上一小节给大家提到的示例中,进程A 和进程B 都对同一个文件进行追加写操作,导致进程A 写入的数据覆盖了进程B 写入的数据,解决办法就是将“先定位到文件末尾,然后写”这两个步骤组成一个原子操作即可,那如何使其变成一个原子操作呢?答案就是O_APPEND 标志。
前面已经给大家多次提到过了O_APPEND 标志,但是并没有给大家介绍O_APPEND 的一个非常重要的作用,那就是实现原子操作。当open 函数的flags 参数中包含了O_APPEND 标志,每次执行write 写入操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,这里“移动当前写位置偏移量到文件末尾、写入数据”这两个操作步骤就组成了一个原子操作,加入O_APPEND 标志后,不管怎么写入数据都会是从文件末尾写,这样就不会导致出现“进程A 写入的数据覆盖了进程B 写入的数据”这种情况了。
(2)pread()和pwrite()
pread()和pwrite()都是系统调用,与read()、write()函数的作用一样,用于读取和写入数据。区别在于,
pread()和pwrite()可用于实现原子操作,调用pread 函数或pwrite 函数可传入一个位置偏移量offset 参数,用于指定文件当前读或写的位置偏移量,所以调用pread 相当于调用lseek 后再调用read;同理,调用pwrite
相当于调用lseek 后再调用write。所以可知,使用pread 或pwrite 函数不需要使用lseek 来调整当前位置偏移量,并会将“移动当前位置偏移量、读或写”这两步操作组成一个原子操作。
pread、pwrite 函数原型如下所示(可通过"man 2 pread"或"man 2 pwrite"命令来查看):
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);
首先调用这两个函数需要包含头文件<unistd.h>。
函数参数和返回值含义如下:
fd、buf、count 参数与read 或write 函数意义相同。
offset:表示当前需要进行读或写的位置偏移量。
返回值:返回值与read、write 函数返回值意义一样。
虽然pread(或pwrite)函数相当于lseek 与pread(或pwrite)函数的集合,但还是有下列区别:
⚫ 调用pread 函数时,无法中断其定位和读操作(也就是原子操作);
⚫ 不更新文件表中的当前位置偏移量。
关于第二点我们可以编写一个简单地代码进行测试,测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
unsigned char buffer[100];
int fd;
int ret;
/* 打开文件test_file */
fd = open("./test_file", O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 使用pread 函数读取数据(从偏移文件头1024 字节处开始读取) */
ret = pread(fd, buffer, sizeof(buffer), 1024);
if (-1 == ret) {
perror("pread error");
goto err;
}
/* 获取当前位置偏移量*/
ret = lseek(fd, 0, SEEK_CUR);
if (-1 == ret) {
perror("lseek error");
goto err;
}
printf("Current Offset: %d\n", ret);
ret = 0;
err:
/* 关闭文件*/
close(fd);
exit(ret);
}
在当前目录下存在一个文件test_file,上述代码中会打开test_file 文件,然后直接使用pread 函数读取
100 个字节数据,从偏移文件头部1024 字节处,读取完成之后再使用lseek 函数获取到文件当前位置偏移量,并将其打印出来。假如pread 函数会改变文件表中记录的当前位置偏移量,则打印出来的数据应该是
1024 + 100 = 1124;如果不会改变文件表中记录的当前位置偏移量,则打印出来的数据应该是0,接下来编译代码测试:
从上图中可知,打印出来的数据为0,正如前面所介绍那样,pread 函数确实不会改变文件表中记录的当前位置偏移量;同理,pwrite 函数也是如此,大家可以把pread 换成pwrite 函数再次进行测试,不出意外,打印出来的数据依然是0。
如果把pread 函数换成read(或write)函数,那么打印出来的数据就是100 了,因为读取了100 个字节数据,相应的当前位置偏移量会向后移动100 个字节。
(3)创建一个文件
前面给大家介绍open 函数的O_EXCL 标志的时候,也提到了原子操作,其中介绍到:O_EXCL 可以用于测试一个文件是否存在,如果不存在则创建此文件,如果存在则返回错误,这使得测试和创建两者成为一个原子操作。接下来给大家,创建文件中存在着的一个竞争状态。
假设有这么一个情况:进程A 和进程B 都要去打开同一个文件、并且此文件还不存在。进程A 当前正在运行状态、进程B 处于等待状态,进程A 首先调用open(“./file”, O_RDWR)函数尝试去打开文件,结果返回错误,也就是调用open 失败;接着进程A 时间片耗尽、进程B 运行,同样进程B 调用open(“./file”, O_RDWR)尝试打开文件,结果也失败,接着进程B 再次调用open(“./file”, O_RDWR | O_CREAT, …)创建此文件,这一次open 执行成功,文件创建成功;接着进程B 时间片耗尽、进程A 继续运行,进程A 也调用
open(“./file”, O_RDWR | O_CREAT, …)创建文件,函数执行成功,如下图所示:
从上面的示例可知,进程A 和进程B 都会创建出同一个文件,同一个文件被创建两次这是不允许的,那如何规避这样的问题呢?那就是通过使用O_EXCL 标志,当open 函数中同时指定了O_EXCL 和
O_CREAT 标志,如果要打开的文件已经存在,则open 返回错误;如果指定的文件不存在,则创建这个文件,这里就提供了一种机制,保证进程是打开文件的创建者,将“判断文件是否存在、创建文件”这两个步骤合成为一个原子操作,有了原子操作,就保证不会出现图3.9.3 中所示的情况。
fcntl 和ioctl
本小节给大家介绍两个新的系统调用:fcntl()和ioctl()。
fcntl 函数
fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作,譬如复制一个文件描述符(与dup、
dup2 作用相同)、获取/设置文件描述符标志、获取/设置文件状态标志等,类似于一个多功能文件描述符管理工具箱。fcntl()函数原型如下所示(可通过"man 2 fcntl"命令查看):
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ )
函数参数和返回值含义如下:
fd:文件描述符。
cmd:操作命令。此参数表示我们将要对fd 进行什么操作,cmd 参数支持很多操作命令,大家可以打开man 手册查看到这些操作命令的详细介绍,这些命令都是以F_XXX 开头的,譬如F_DUPFD、F_GETFD、
F_SETFD 等,不同的cmd 具有不同的作用,cmd 操作命令大致可以分为以下5 种功能:
⚫ 复制文件描述符(cmd=F_DUPFD 或cmd=F_DUPFD_CLOEXEC);
⚫ 获取/设置文件描述符标志(cmd=F_GETFD 或cmd=F_SETFD);
⚫ 获取/设置文件状态标志(cmd=F_GETFL 或cmd=F_SETFL);
⚫ 获取/设置异步IO 所有权(cmd=F_GETOWN 或cmd=F_SETOWN);
⚫ 获取/设置记录锁(cmd=F_GETLK 或cmd=F_SETLK);
这里列举出来,并不需要全部学会每一个cmd 的作用,因为有些内容并没有给大家提及到,譬如什么异步IO、锁之类的概念,在后面的学习过程中,当学习到相关知识内容的时候再给大家介绍。
…:fcntl 函数是一个可变参函数,第三个参数需要根据不同的cmd 来传入对应的实参,配合cmd 来使用。
返回值:执行失败情况下,返回-1,并且会设置errno;执行成功的情况下,其返回值与cmd(操作命令)有关,譬如cmd=F_DUPFD(复制文件描述符)将返回一个新的文件描述符、cmd=F_GETFD(获取文件描述符标志)将返回文件描述符标志、cmd=F_GETFL(获取文件状态标志)将返回文件状态标志等。
fcntl 使用示例
(1)复制文件描述符
前面给大家介绍了dup 和dup2,用于复制文件描述符,除此之外,我们还可以通过fcntl 函数复制文件描述符,可用的cmd 包括F_DUPFD 和F_DUPFD_CLOEXEC ,这里就只介绍F_DUPFD ,
F_DUPFD_CLOEXEC 暂时先不讲。
当cmd=F_DUPFD 时,它的作用会根据fd 复制出一个新的文件描述符,此时需要传入第三个参数,第三个参数用于指出新复制出的文件描述符是一个大于或等于该参数的可用文件描述符(没有使用的文件描述符);如果第三个参数等于一个已经存在的文件描述符,则取一个大于该参数的可用文件描述符。
测试代码如下所示:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd1, fd2;
int ret;
/* 打开文件test_file */
fd1 = open("./test_file", O_RDONLY);
if (-1 == fd1) {
perror("open error");
exit(-1);
}
/* 使用fcntl 函数复制一个文件描述符*/
fd2 = fcntl(fd1, F_DUPFD, 0);
if (-1 == fd2) {
perror("fcntl error");
ret = -1;
goto err;
}
printf("fd1: %d\nfd2: %d\n", fd1, fd2);
ret = 0;
close(fd2);
err:
/* 关闭文件*/
close(fd1);
exit(ret);
}
在当前目录下存在test_file 文件,上述代码会打开此文件,得到文件描述符fd1,之后再使用fcntl 函数复制fd1 得到新的文件描述符fd2,并将fd1 和fd2 打印出来,接下来编译运行:
可知复制得到的文件描述符是7,因为在执行fcntl 函数时,传入的第三个参数是0,也就时指定复制得到的新文件描述符必须要大于或等于0,但是因为0~6 都已经被占用了,所以分配得到的fd 就是7;如果传入的第三个参数是100,那么fd2 就会等于100,大家可以自己动手测试。
(2)获取/设置文件状态标志
cmd=F_GETFL 可用于获取文件状态标志,cmd=F_SETFL 可用于设置文件状态标志。cmd=F_GETFL 时不需要传入第三个参数,返回值成功表示获取到的文件状态标志;cmd=F_SETFL 时,需要传入第三个参数,此参数表示需要设置的文件状态标志。
这些标志指的就是我们在调用open 函数时传入的flags 标志,可以指定一个或多个(通过位或| 运算符组合),但是文件权限标志(O_RDONLY、O_WRONLY、O_RDWR)以及文件创建标志(O_CREAT、
O_EXCL、O_NOCTTY、O_TRUNC)不能被设置、会被忽略;在Linux 系统中,只有O_APPEND、O_ASYNC、O_DIRECT、O_NOATIME 以及O_NONBLOCK 这些标志可以被修改,这里面有些标志并没有给大家介绍过,后面我们在用到的时候再给大家介绍。所以对于一个已经打开的文件描述符,可以通过这种方式添加或移除标志。
测试代码如下
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int fd;
int ret;
int flag;
/* 打开文件test_file */
fd = open("./test_file", O_RDWR);
if (-1 == fd) {
perror("open error");
exit(-1);
}
/* 获取文件状态标志*/
flag = fcntl(fd, F_GETFL);
if (-1 == flag) {
perror("fcntl F_GETFL error");
ret = -1;
goto err;
}
printf("flags: 0x%x\n", flag);
/* 设置文件状态标志,添加O_APPEND 标志*/
ret = fcntl(fd, F_SETFL, flag | O_APPEND);
if (-1 == ret) {
perror("fcntl F_SETFL error");
goto err;
}
ret = 0;
err:
/* 关闭文件*/
close(fd);
exit(ret);
}
上述代码会打开test_file 文件,得到文件描述符fd,之后调用fcntl(fd, F_GETFL)来获取到当前文件状态标志flag,并将其打印来;接着调用fcntl(fd, F_SETFL, flag | O_APPEND)设置文件状态标志,在原标志的基础上添加O_APPEND 标志。接下来编译测试:
以上给大家介绍了fcntl 函数的两种用法,除了这两种用法之外,还有其它多种不同的用法,这里暂时先不介绍了,后面学习到相应知识点的时候再给大家讲解。
ioctl 函数
ioctl()可以认为是一个文件IO 操作的杂物箱,可以处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设,此函数将会在进阶篇中使用到,譬如可以通过ioctl 获取LCD 相关信息等,本小节只是给大家引出这个系统调用,暂时不会用到。此函数原型如下所示(可通过"man 2 ioctl"命令查看):
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
使用此函数需要包含头文件<sys/ioctl.h>。
函数参数和返回值含义如下:
fd:文件描述符。
request:此参数与具体要操作的对象有关,没有统一值,表示向文件描述符请求相应的操作;后面用到的时候再给大家介绍。
…:此函数是一个可变参函数,第三个参数需要根据request 参数来决定,配合request 来使用。
返回值:成功返回0,失败返回-1。
关于ioctl 函数就给大家介绍这么多,目的仅仅只是给大家引出这个系统调用,我们将会在第二篇<进阶篇>中给大家细说。
截断文件
使用系统调用truncate()或ftruncate()可将普通文件截断为指定字节长度,其函数原型如下所示:
#include <unistd.h>
#include <sys/types.h>
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);
这两个函数的区别在于:ftruncate()使用文件描述符fd 来指定目标文件,而truncate()则直接使用文件路径path 来指定目标文件,其功能一样。
这两个函数都可以对文件进行截断操作,将文件截断为参数length 指定的字节长度,什么是截断?如果文件目前的大小大于参数length 所指定的大小,则多余的数据将被丢失,类似于多余的部分被“砍”掉了;如果文件目前的大小小于参数length 所指定的大小,则将其进行扩展,对扩展部分进行读取将得到空字节"\0"。
使用ftruncate()函数进行文件截断操作之前,必须调用open()函数打开该文件得到文件描述符,并且必须要具有可写权限,也就是调用open()打开文件时需要指定O_WRONLY 或O_RDWR。
调用这两个函数并不会导致文件读写位置偏移量发生改变,所以截断之后一般需要重新设置文件当前的读写位置偏移量,以免由于之前所指向的位置已经不存在而发生错误(譬如文件长度变短了,文件当前所指向的读写位置已不存在)。
调用成功返回0,失败将返回-1,并设置errno 以指示错误原因。
使用示例
示例代码3.11.1 演示了文件的截断操作,分别使用ftruncate()和truncate()将当前目录下的文件file1 截断为长度0、将文件file2 截断为长度1024 个字节。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
int fd;
/* 打开file1 文件*/
if (0 > (fd = open("./file1", O_RDWR))) {
perror("open error");
exit(-1);
}
/* 使用ftruncate 将file1 文件截断为长度0 字节*/
if (0 > ftruncate(fd, 0)) {
perror("ftruncate error");
exit(-1);
}
/* 使用truncate 将file2 文件截断为长度1024 字节*/
if (0 > truncate("./file2", 1024)) {
perror("truncate error");
exit(-1);
}
/* 关闭file1 退出程序*/
close(fd);
exit(0);
}
上述代码中,首先使用open()函数打开文件file1,得到文件描述符fd,接着使用ftruncate()系统调用将文件截断为0 长度,传入file1 文件对应的文件描述符;接着调用truncate()系统调用将文件file2 截断为1024
字节长度,传入file2 文件的相对路径。
接下来进行测试,在当前目录下准备两个文件file1 和file2,如下所示:
可以看到file1 和file2 文件此时均为592 字节大小,接下来运行测试代码:
程序运行之后,file1 文件大小变成了0,而file2 文件大小变成了1024 字节,与测试代码想要实现的功能是一致的。
I/O 缓冲
出于速度和效率的考虑,系统I/O 调用(即文件I/O,open、read、write 等)和标准C 语言库I/O 函数(即标准I/O 函数)在操作磁盘文件时会对数据进行缓冲,本小节将讨论文件I/O 和标准I/O 这两种I/O 方式的数据缓冲问题,并讨论其对应用程序性能的影响。
除此之外,本小节还讨论了屏蔽或影响缓冲的一些技术手段,以及直接I/O 技术—绕过内核缓冲直接访问磁盘硬件。
文件I/O 的内核缓冲
read()和write()系统调用在进行文件读写操作的时候并不会直接访问磁盘设备,而是仅仅在用户空间缓冲区和内核缓冲区(kernel buffer cache)之间复制数据。譬如调用write()函数将5 个字节数据从用户空间内存拷贝到内核空间的缓冲区中:
write(fd, "Hello", 5); //写入5 个字节数据
调用write()后仅仅只是将这5 个字节数据拷贝到了内核空间的缓冲区中,拷贝完成之后函数就返回了,在后面的某个时刻,内核会将其缓冲区中的数据写入(刷新)到磁盘设备中,所以由此可知,系统调用write()
与磁盘操作并不是同步的,write()函数并不会等待数据真正写入到磁盘之后再返回。如果在此期间,其它进程调用read()函数读取该文件的这几个字节数据,那么内核将自动从缓冲区中读取这几个字节数据返回给应用程序。
与此同理,对于读文件而言亦是如此,内核会从磁盘设备中读取文件的数据并存储到内核的缓冲区中,当调用read()函数读取数据时,read()调用将从内核缓冲区中读取数据,直至把缓冲区中的数据读完,这时,内核会将文件的下一段内容读入到内核缓冲区中进行缓存。
我们把这个内核缓冲区就称为文件I/O 的内核缓冲。这样的设计,目的是为了提高文件I/O 的速度和效率,使得系统调用read()、write()的操作更为快速,不需要等待磁盘操作(将数据写入到磁盘或从磁盘读取出数据),磁盘操作通常是比较缓慢的。同时这一设计也更为高效,减少了内核操作磁盘的次数,譬如线程
1 调用write()向文件写入数据"abcd",线程2 也调用write()向文件写入数据"1234",这样的话,数据"abcd"和
"1234"都被缓存在了内核的缓冲区中,在稍后内核会将它们一起写入到磁盘中,只发起一次磁盘操作请求;加入没有内核缓冲区,那么每一次调用write(),内核就会执行一次磁盘操作。
前面提到,当调用write()之后,内核稍后会将数据写入到磁盘设备中,具体是什么时间点写入到磁盘,这个其实是不确定的,由内核根据相应的存储算法自动判断。
通过前面的介绍可知,文件I/O 的内核缓冲区自然是越大越好,Linux 内核本身对内核缓冲区的大小没有固定上限。内核会分配尽可能多的内核来作为文件I/O 的内核缓冲区,但受限于物理内存的总量,如果系统可用的物理内存越多,那自然对应的内核缓冲区也就越大,操作越大的文件也要依赖于更大空间的内核缓冲。
刷新文件I/O 的内核缓冲区
强制将文件I/O 内核缓冲区中缓存的数据写入(刷新)到磁盘设备中,对于某些应用程序来说,可能是很有必要的,例如,应用程序在进行某操作之前,必须要确保前面步骤调用write()写入到文件的数据已经真正写入到了磁盘中,诸如一些数据库的日志进程。
联系到一个实际的使用场景,当我们在Ubuntu 系统下拷贝文件到U 盘时,文件拷贝完成之后,通常在拔掉U 盘之前,需要执行sync 命令进行同步操作,这个同步操作其实就是将文件I/O 内核缓冲区中的数据更新到U 盘硬件设备,所以如果在没有执行sync 命令时拔掉U 盘,很可能就会导致拷贝到U 盘中的文件遭到破坏!
控制文件I/O 内核缓冲的系统调用
Linux 中提供了一些系统调用可用于控制文件I/O 内核缓冲,包括系统调用sync()、syncfs()、fsync()以及fdatasync()。
㈠、fsync()函数
系统调用fsync()将参数fd 所指文件的内容数据和元数据写入磁盘,只有在对磁盘设备的写入操作完成之后,fsync()函数才会返回,其函数原型如下所示:
#include <unistd.h>
int fsync(int fd);
参数fd 表示文件描述符,函数调用成功将返回0,失败返回-1 并设置errno 以指示错误原因。
前面提到了元数据这个概念,元数据并不是文件内容本身的数据,而是一些用于记录文件属性相关的数据信息,譬如文件大小、时间戳、权限等等信息,这里统称为文件的元数据,这些信息也是存储在磁盘设备中的,在3.1 小节中介绍过。
使用示例
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define BUF_SIZE 4096
#define READ_FILE "./rfile"
#define WRITE_FILE "./wfile"
static char buf[BUF_SIZE];
int main(void)
{
int rfd, wfd;
size_t size;
/* 打开源文件*/
rfd = open(READ_FILE, O_RDONLY);
if (0 > rfd) {
perror("open error");
exit(-1);
}
/* 打开目标文件*/
wfd = open(WRITE_FILE, O_WRONLY | O_CREAT | O_TRUNC, 0664);
if (0 > wfd) {
perror("open error");
exit(-1);
}
/* 拷贝数据*/
while(0 < (size = read(rfd, buf, BUF_SIZE)))
write(wfd, buf, size);
/* 对目标文件执行fsync 同步*/
fsync(wfd);
/* 关闭文件退出程序*/
close(rfd);
close(wfd);
exit(0);
}
代码没什么好说的,主要就是拷贝完成之后调用fsync()函数,对目标文件的数据进行了同步操作,整个操作完成之后close 关闭源文件和目标文件、退出程序。
㈡、fdatasync()函数
系统调用fdatasync()与fsync()类似,不同之处在于fdatasync()仅将参数fd 所指文件的内容数据写入磁盘,并不包括文件的元数据;同样,只有在对磁盘设备的写入操作完成之后,fdatasync()函数才会返回,其函数原型如下所示:
#include <unistd.h>
int fdatasync(int fd);
㈢、sync()函数
系统调用sync()会将所有文件I/O 内核缓冲区中的文件内容数据和元数据全部更新到磁盘设备中,该函数没有参数、也无返回值,意味着它不是对某一个指定的文件进行数据更新,而是刷新所有文件I/O 内核缓冲区。其函数原型如下所示:
#include <unistd.h>
void sync(void);
在Linux 实现中,调用sync()函数仅在所有数据已经写入到磁盘设备之后才会返回;然后在其它系统中,
sync()实现只是简单调度一下I/O 传递,在动作未完成之后即可返回。
控制文件I/O 内核缓冲的标志
调用open()函数时指定一些标志也可以影响到文件I/O 内核缓冲,譬如O_DSYNC 标志和O_SYNC 标志,这些标志在2.3 小节并未向大家介绍过,联系本小节所学内容,接下来向大家简单地介绍下。
㈠、O_DSYNC 标志
在调用open()函数时,指定O_DSYNC 标志,其效果类似于在每个write()调用之后调用fdatasync()函数进行数据同步。譬如:
fd = open(filepath, O_WRONLY | O_DSYNC);
㈡、O_SYNC 标志
在调用open()函数时,指定O_SYNC 标志,使得每个write()调用都会自动将文件内容数据和元数据刷新到磁盘设备中,其效果类似于在每个write()调用之后调用fsync()函数进行数据同步,譬如:
fd = open(filepath, O_WRONLY | O_SYNC);
对性能的影响
在程序中频繁调用fsync()、fdatasync()、sync()(或者调用open 时指定O_DSYNC 或O_SYNC 标志)对性能的影响极大,大部分的应用程序是没有这种需求的,所以在大部分应用程序当中基本不会使用到。
直接I/O:绕过内核缓冲
从Linux 内核2.4 版本开始,Linux 允许应用程序在执行文件I/O 操作时绕过内核缓冲区,从用户空间直接将数据传递到文件或磁盘设备,把这种操作也称为直接I/O(direct I/O)或裸I/O(raw I/O)。
在有些情况下,这种操作通常是很有必要的,例如,某应用程序的作用是测试磁盘设备的读写速率,那么在这种应用需要下,我们就需要保证read/write 操作是直接访问磁盘设备,而不经过内核缓冲,如果不能得到这样的保证,必然会导致测试结果出现比较大的误差。
然后,对于大多数应用程序而言,使用直接I/O 可能会大大降低性能,这是因为为了提高I/O 性能,内核针对文件I/O 内核缓冲区做了不少的优化,譬如包括按顺序预读取、在成簇磁盘块上执行I/O、允许访问同一文件的多个进程共享高速缓存的缓冲区。如果应用程序使用直接I/O 方式,将无法享受到这些优化措施所带来的性能上的提升,直接I/O 只在一些特定的需求场合,譬如磁盘速率测试工具、数据库系统等。
我们可针对某一文件或块设备执行直接I/O,要做到这一点,需要在调用open()函数打开文件时,指定
O_DIRECT 标志,该标志至Linux 内核2.4.10 版本开始生效,譬如:
fd = open(filepath, O_WRONLY | O_DIRECT);
直接I/O 的对齐限制
因为直接I/O 涉及到对磁盘设备的直接访问,所以在执行直接I/O 时,必须要遵守以下三个对齐限制要求:
⚫ 应用程序中用于存放数据的缓冲区,其内存起始地址必须以块大小的整数倍进行对齐;
⚫ 写文件时,文件的位置偏移量必须是块大小的整数倍;
⚫ 写入到文件的数据大小必须是块大小的整数倍。
如果不满足以上任何一个要求,调用write()均为以错误返回Invalid argument。以上所说的块大小指的是磁盘设备的物理块大小(block size),常见的块大小包括512 字节、1024 字节、2048 以及4096 字节,那我们如何确定磁盘分区的块大小呢?可以使用tune2fs 命令进行查看,如下所示:
tune2fs -l /dev/sda1 | grep "Block size"
-l 后面指定了需要查看的磁盘分区,可以使用df -h 命令查看Ubuntu 系统的根文件系统所挂载的磁盘分区:
通过上图可知,Ubuntu 系统的根文件系统挂载在/dev/sda1 磁盘分区下,接着下使用tune2fs 命令查看该分区的块大小:
从上图可知/dev/sda1 磁盘分区的块大小为4096 个字节。
直接I/O 测试与普通I/O 对比测试
接下来编写一个使用直接I/O 方式写文件的测试程序和一个使用普通I/O 方式写文件的测试程序,进行对比。
演示了以直接I/O 方式写文件的操作,首先我们需要在程序开头处定义一个宏定义
_GNU_SOURCE,原因在于后面open()函数需要指定O_DIRECT 标志,这个宏需要我们在程序中定义了
O_DIRECT 宏之后才能使用,否则编译程序就会报错提示:O_DIRECT 未定义。
Tips:_GNU_SOURCE 宏可用于开启/禁用Linux 系统调用和glibc 库函数的一些功能、特性,要打开这些特性,需要在应用程序中定义该宏,定义该宏之后意味着用户应用程序打开了所有的特性;默认情况下,_GNU_SOURCE 宏并没有被定义,所以当使用到它控制的一些特性时,应用程序编译将会报错!定义该宏的方式有两种:
⚫ 直接在源文件中定义:#define _GNU_SOURCE
⚫ gcc 编译时使用-D 选项定义_GNU_SOURCE 宏:
gcc -D_GNU_SOURCE -o testApp testApp.c
gcc 的-D 选项可用于定义一个宏,并且该宏定义在整个源码工程中都是生效的,是一个全局宏定义。使用以上哪种方式都可以。
/** 使用宏定义O_DIRECT 需要在程序中定义宏_GNU_SOURCE
** 不然提示O_DIRECT 找不到**/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
/** 定义一个用于存放数据的buf,起始地址以4096 字节进行对其**/
static char buf[8192] __attribute((aligned (4096)));
int main(void)
{
int fd;
int count;
/* 打开文件*/
fd = open("./test_file",
O_WRONLY | O_CREAT | O_TRUNC | O_DIRECT,
0664);
if (0 > fd) {
perror("open error");
exit(-1);
}
/* 写文件*/
count = 10000;
while(count--) {
if (4096 != write(fd, buf, 4096)) {
perror("write error");
exit(-1);
}
}
/* 关闭文件退出程序*/
close(fd);
exit(0);
}
前面提到过,使用直接I/O 方式需要满足3 个对齐要求,程序中定义了一个static 静态数组buf,将其作为数据存放的缓冲区,在变量定义后加了__attribute((aligned (4096)))修饰,使其起始地址以4096 字节进行对其。
Tips:attribute 是gcc 支持的一种机制(也可以写成__attribute),可用于设置函数属性、变量属性以及类型属性等,对此不了解的读者请自行查找资料学习,本书不会对此进行介绍!
程序中调用open()函数是指定了O_DIRECT 标志,使用直接I/O,最后通过while 循环,将数据写入文件中,循环10000 次,每次写入4096 个字节数据,也就是总共写入4096*10000 个字节(约等于40MB)。首次调用write()时其文件读写位置偏移量为0,之后均以4096 字节进行递增,所以满足直接I/O 方式的位置偏移量必须是块大小的整数倍这个要求;每次写入大小均是4096 字节,所以满足了数据大小必须是块大小的整数倍这个要求。
接下来编译测试:
通过time 命令测试可知,每次执行程序需要花费2.7 秒左右的时间,使用直接I/O 方式向文件写入约
40MB 数据大小。
Tips:对于直接I/O 方式的3 个对齐限制,大家可以自行进行验证,譬如修改上述示例代码使之不满足
3 个对齐条件种的任何一个,然后编译程序进行测试,会发生write()函数会报错,均是“Invalid argument”错误。
对示例代码4.9.2 进行修改,使其变成普通I/O 方式,其它功能相同,最终修改后的示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
static char buf[8192];
int main(void)
{
int fd;
int count;
/* 打开文件*/
fd = open("./test_file", O_WRONLY | O_CREAT | O_TRUNC, 0664);
if (0 > fd) {
perror("open error");
exit(-1);
}
/* 写文件*/
count = 10000;
while(count--) {//循环10000 次,每次写入4096 个字节数据
if (4096 != write(fd, buf, 4096)) {
perror("write error");
exit(-1);
}
}
/* 关闭文件退出程序*/
close(fd);
exit(0);
}
再次进行测试:
使用time 命令得到的程序运行时间大约是0.13~0.14 秒左右,相比直接I/O 方式的2.7 秒,时间上提升了20 倍左右(测试大小不同、每次写入的大小不同,均会导致时间上的差别),原因在于直接I/O 方式每次write()调用均是直接对磁盘发起了写操作,而普通方式只是将用户空间下的数据拷贝到了文件I/O 内核缓冲区中,并没直接操作硬件,所以消耗的时间短,硬件操作占用的时间远比内存复制占用的时间大得多
直接I/O 方式效率、性能比较低,绝大部分应用程序不会使用直接I/O 方式对文件进行I/O 操作,通常只在一些特殊的应用场合下才可能会使用,那我们可以使用直接I/O 方式来测试磁盘设备的读写速率,这种测试方式相比普通I/O 方式就会更加准确。
stdio 缓冲
介绍完文件I/O 的内核缓冲后,接下来我们聊一聊标准I/O 的stdio 缓冲。
标准I/O(fopen、fread、fwrite、fclose、fseek 等)是C 语言标准库函数,而文件I/O(open、read、write、
close、lseek 等)是系统调用,虽然标准I/O 是在文件I/O 基础上进行封装而实现(譬如fopen 内部实际上调用了open、fread 内部调用了read 等),但在效率、性能上标准I/O 要优于文件I/O,其原因在于标准I/O 实现维护了自己的缓冲区,我们把这个缓冲区称为stdio 缓冲区,接下来我们聊一聊标准I/O 的stdio 缓冲。
前面提到了文件I/O 内核缓冲,这是由内核维护的缓冲区,而标准I/O 所维护的stdio 缓冲是用户空间的缓冲区,当应用程序中通过标准I/O 操作磁盘文件时,为了减少调用系统调用的次数,标准I/O 函数会将用户写入或读取文件的数据缓存在stdio 缓冲区,然后再一次性将stdio 缓冲区中缓存的数据通过调用系统调用I/O(文件I/O)写入到文件I/O 内核缓冲区或者拷贝到应用程序的buf 中。
通过这样的优化操作,当操作磁盘文件时,在用户空间缓存大块数据以减少调用系统调用的次数,使得效率、性能得到优化。使用标准I/O 可以使编程者免于自行处理对数据的缓冲,无论是调用write()写入数据、还是调用read()读取数据。
对stdio 缓冲进行设置
C 语言提供了一些库函数可用于对标准I/O 的stdio 缓冲区进行相关的一些设置,包括setbuf()、setbuffer()
以及setvbuf()。
㈠、setvbuf()函数
调用setvbuf()库函数可以对文件的stdio 缓冲区进行设置,譬如缓冲区的缓冲模式、缓冲区的大小、起始地址等。其函数原型如下所示:
#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
使用该函数需要包含头文件<stdio.h>。
函数参数和返回值含义如下:
stream:FILE 指针,用于指定对应的文件,每一个文件都可以设置它对应的stdio 缓冲区。
buf:如果参数buf 不为NULL,那么buf 指向size 大小的内存区域将作为该文件的stdio 缓冲区,因为
stdio 库会使用buf 指向的缓冲区,所以应该以动态(分配在堆内存,譬如malloc,在7.6 小节介绍)或静态的方式在堆中为该缓冲区分配一块空间,而不是分配在栈上的函数内的自动变量(局部变量)。如果buf 等于NULL,那么stdio 库会自动分配一块空间作为该文件的stdio 缓冲区(除非参数mode 配置为非缓冲模式)。
mode:参数mode 用于指定缓冲区的缓冲类型,可取值如下:
⚫ _IONBF:不对I/O 进行缓冲(无缓冲)。意味着每个标准I/O 函数将立即调用write()或者read(),并且忽略buf 和size 参数,可以分别指定两个参数为NULL 和0。标准错误stderr 默认属于这一种类型,从而保证错误信息能够立即输出。
⚫ _IOLBF:采用行缓冲I/O。在这种情况下,当在输入或输出中遇到换行符"\n"时,标准I/O 才会执行文件I/O 操作。对于输出流,在输出一个换行符前将数据缓存(除非缓冲区已经被填满),当输出换行符时,再将这一行数据通过文件I/O write()函数刷入到内核缓冲区中;对于输入流,每次读取一行数据。对于终端设备默认采用的就是行缓冲模式,譬如标准输入和标准输出。
⚫ _IOFBF:采用全缓冲I/O。在这种情况下,在填满stdio 缓冲区后才进行文件I/O 操作(read、write)。对于输出流,当fwrite 写入文件的数据填满缓冲区时,才调用write()将stdio 缓冲区中的数据刷入内核缓冲区;对于输入流,每次读取stdio 缓冲区大小个字节数据。默认普通磁盘上的常规文件默认常用这种缓冲模式。
size:指定缓冲区的大小。
返回值:成功返回0,失败将返回一个非0 值,并且会设置errno 来指示错误原因。
需要注意的是,当stdio 缓冲区中的数据被刷入到内核缓冲区或被读取之后,这些数据就不会存在于缓冲区中了,数据被刷入了内核缓冲区或被读走了。
㈡、setbuf()函数
setbuf()函数构建与setvbuf()之上,执行类似的任务,其函数原型如下所示:
#include <stdio.h>
void setbuf(FILE *stream, char *buf);
setbuf()调用除了不返回函数结果(void)外,就相当于:
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, BUFSIZ);
要么将buf 设置为NULL 以表示无缓冲,要么指向由调用者分配的BUFSIZ 个字节大小的缓冲区(BUFSIZ 定义于头文件<stdio.h>中,该值通常为8192)。
㈢、setbuffer()函数
setbuffer()函数类似于setbuf(),但允许调用者指定buf 缓冲区的大小,其函数原型如下所示:
#include <stdio.h>
void setbuffer(FILE *stream, char *buf, size_t size);
setbuffer()调用除了不返回函数结果(void)外,就相当于:
setvbuf(stream, buf, buf ? _IOFBF : _IONBF, size);
关于标准I/O 库stdio 缓冲相关的内容就给大家介绍这么多,接下来我们进行一些测试,来说明无缓冲、行缓冲以及全缓冲区之间的区别。
标准输出printf()的行缓冲模式测试
我们先看看下面这个简单地示例代码,调用了printf()函数,区别在于第二个printf()没有输出换行符。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
for ( ; ; )
sleep(1);
}
printf()函数是标准I/O 库函数,向终端设备(标准输出)输出打印信息,编译测试:
运行之后可以发现只有第一个printf()打印的信息显示出来了,第二个并没有显示出来,这是为什么呢?这就是stdio 缓冲的问题,前面提到了标准输出默认采用的是行缓冲模式,printf()输出的字符串写入到了标准输出的stdio 缓冲区中,只有输出换行符时(不考虑缓冲区填满的情况)才会将这一行数据刷入到内核缓冲区,也就是写入标准输出文件(终端设备),因为第一个printf()包含了换行符,所以已经刷入了内核缓冲区,而第二个printf 并没有包含换行符,所以第二个printf 输出的"Hello World!"还缓存在stdio 缓冲区中,需要等待一个换行符才可输出到终端。
联系4.8.2 小节介绍的格式化输入scanf()函数,程序中调用scanf()函数进行阻塞,用户通过键盘输入数据,只有在按下回车键(换行符键)时程序才会接着往下执行,因为标准输入默认也是采用了行缓冲模式。
譬如对示例代码4.9.4 进行修改,使标准输出变成无缓冲模式,修改后代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
/* 将标准输出设置为无缓冲模式*/
if (setvbuf(stdout, NULL, _IONBF, 0)) {
perror("setvbuf error");
exit(0);
}
printf("Hello World!\n");
printf("Hello World!");
for ( ; ; )
sleep(1);
}
在使用printf()之前,调用setvbuf()函数将标准输出的stdio 缓冲设置为无缓冲模式,接着编译运行:
可以发现该程序却能够成功输出两个“Hello World!”,并且白色的光标在第二个“Hello World!”后面,意味着输出没有换行,与程序中第二个printf 没有加换行符的效果是一直。
所以通过以上两个示例代码对比可知,标准输出默认是行缓冲模式,只有输出了换行符时,才会将换行符这一行字符进行输出显示(也就是刷入到内核缓冲区),在没有输出换行符之前,会将数据缓存在stdio
缓冲区中。
fflush刷新stdio缓冲区
无论我们采取何种缓冲模式,在任何时候都可以使用库函数fflush()来强制刷新(将输出到stdio 缓冲区中的数据写入到内核缓冲区,通过write()函数)stdio 缓冲区,该函数会刷新指定文件的stdio 输出缓冲区,此函数原型如下所示:
#include <stdio.h>
int fflush(FILE *stream);
参数stream 指定需要进行强制刷新的文件,如果该参数设置为NULL,则表示刷新所有的stdio 缓冲区。
函数调用成功返回0,否则将返回-1,并设置errno 以指示错误原因。
接下来我们对示例代码4.9.4 进行修改,在第二个printf 后面调用fflush()函数,修改后示例代码如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
fflush(stdout); //刷新标准输出stdio 缓冲区
for ( ; ; )
sleep(1);
}
运行测试:
可以看到,打印了两次“Hello World!”,这就是fflush()的作用了强制刷新stdio 缓冲区。
除了使用库函数fflush()之外,还有其它方法会自动刷新stdio 缓冲区吗?是的,使用库函数fflush()是一种强制刷新的手段,在一些其它的情况下,也会自动刷新stdio 缓冲区,譬如当文件关闭时、程序退出时,接下来我们进行演示。
㈠、关闭文件时刷新stdio 缓冲区
同样还是直接对示例代码4.9.4 进行修改,在调用第二个printf 函数后关闭标准输出,如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
fclose(stdout); //关闭标准输出
for ( ; ; )
sleep(1);
}
至于运行结果文档中就不贴出来了,运行结果与图4.9.7 是一样的。所以由此可知,文件关闭时系统会自动刷新该文件的stdio 缓冲区。
㈡、程序退出时刷新stdio 缓冲区
可以看到上面使用的测试程序中,在最后都使用了一个for 死循环,让程序处于休眠状态无法退出,为什么要这样做呢?原因在于程序退出时也会自动刷新stdio 缓冲区,这样的话就会影响到测试结果。同样对
示例代码4.9.4 进行修改,去掉for 死循环,让程序结束,修改完之后如下所示:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("Hello World!\n");
printf("Hello World!");
}
运行结果如下:
从结果可知,当程序退出时,确实会自动刷新stdio 缓冲区。但是,与程序退出方式有关,如果使用exit()、
return 或像上述示例代码一样不显式调用相关函数或执行return 语句来结束程序,这些情况下程序终止时会自动刷新stdio 缓冲区;如果使用_exit 或_Exit()终止程序则不会刷新,这里各位读者可以自行测试、验证。
关于刷新stdio 缓冲区相关内容,最后进行一个总结:
⚫ 调用fflush()库函数可强制刷新指定文件的stdio 缓冲区;
⚫ 调用fclose()关闭文件时会自动刷新文件的stdio 缓冲区;
⚫ 程序退出时会自动刷新stdio 缓冲区(注意区分不同的情况)。
关于本小节内容就给大家介绍这么多,笔者觉得已经非常详细了,如果还有不太理解的地方,希望大家能够自己动手进行测试、验证,然后总结出相应的结论,前面笔者一直强调,编程是一门实践性很强的工作,一定要学会自己分析、验证。
I/O 缓冲小节
本小节对前面学习的内容进行一个简单地总结,概括说明文件I/O 内核缓冲区和stdio 缓冲区之间的联系与区别,以及各种stdio 库函数,如下图所示:
从图中自上而下,首先应用程序调用标准I/O 库函数将用户数据写入到stdio 缓冲区中,stdio 缓冲区是由stdio 库所维护的用户空间缓冲区。针对不同的缓冲模式,当满足条件时,stdio 库会调用文件I/O(系统调用I/O)将stdio 缓冲区中缓存的数据写入到内核缓冲区中,内核缓冲区位于内核空间。最终由内核向磁盘设备发起读写操作,将内核缓冲区中的数据写入到磁盘(或者从磁盘设备读取数据到内核缓冲区)。
应用程序调用库函数可以对stdio 缓冲区进行相应的设置,设置缓冲区缓冲模式、缓冲区大小以及由调用者指定一块空间作为stdio 缓冲区,并且可以强制调用fflush()函数刷新缓冲区;而对于内核缓冲区来说,应用程序可以调用相关系统调用对内核缓冲区进行控制,譬如调用fsync()、fdatasync()或sync()来刷新内核缓冲区(或通过open 指定O_SYNC 或O_DSYNC 标志),或者使用直接I/O 绕过内核缓冲区(open 函数指定O_DIRECT 标志)。
文件描述符与FILE 指针互转
在应用程序中,在同一个文件上执行I/O 操作时,还可以将文件I/O(系统调用I/O)与标准I/O 混合使用,这个时候我们就需要将文件描述符和FILE 指针对象之间进行转换,此时可以借助于库函数fdopen()、
fileno()来完成。
库函数fileno()可以将标准I/O 中使用的FILE 指针转换为文件I/O 中所使用的文件描述符,而fdopen()
则进行着相反的操作,其函数原型如下所示:
#include <stdio.h>
int fileno(FILE *stream);
FILE *fdopen(int fd, const char *mode);
首先使用这两个函数需要包含头文件<stdio.h>。
对于fileno()函数来说,根据传入的FILE 指针得到整数文件描述符,通过返回值得到文件描述符,如果转换错误将返回-1,并且会设置errno 来指示错误原因。得到文件描述符之后,便可以使用诸如read()、write()、
lseek()、fcntl()等文件I/O 方式操作文件。
fdopen()函数与fileno()功能相反,给定一个文件描述符,得到该文件对应的FILE 指针,之后便可以使用诸如fread()、fwrite()等标准I/O 方式操作文件了。参数mode 与fopen()函数中的mode 参数含义相同,具体参考表4.4.1 中所述,若该参数与文件描述符fd 的访问模式不一致,则会导致调用fdopen()失败。
当混合使用文件I/O 和标准I/O 时,需要特别注意缓冲的问题,文件I/O 会直接将数据写入到内核缓冲区进行高速缓存,而标准I/O 则会将数据写入到stdio 缓冲区,之后再调用write()将stdio 缓冲区中的数据写入到内核缓冲区。譬如下面这段代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("print");
write(STDOUT_FILENO, "write\n", 6);
exit(0);
}
执行结果你会发现,先输出了"write"字符串信息,接着再输出了"print"字符串信息,产生这个问题的原因很简单,大家自己去思考下!
检查或复位状态
调用fread()读取数据时,如果返回值小于参数nmemb 所指定的值,表示发生了错误或者已经到了文件末尾(文件结束end-of-file),但fread()无法具体确定是哪一种情况;在这种情况下,可以通过判断错误标志或end-of-file 标志来确定具体的情况。
feof()函数
库函数feof()用于测试参数stream 所指文件的end-of-file 标志,如果end-of-file 标志被设置了,则调用
feof()函数将返回一个非零值,如果end-of-file 标志没有被设置,则返回0。
其函数原型如下所示:
#include <stdio.h>
int feof(FILE *stream);
当文件的读写位置移动到了文件末尾时,end-of-file 标志将会被设置。
if (feof(file)) {
/* 到达文件末尾*/
}
else {
/* 未到达文件末尾*/
}
ferror()函数
库函数ferror()用于测试参数stream 所指文件的错误标志,如果错误标志被设置了,则调用ferror()函数将返回一个非零值,如果错误标志没有被设置,则返回0。
其函数原型如下所示:
#include <stdio.h>
int ferror(FILE *stream);
当对文件的I/O 操作发生错误时,错误标志将会被设置。
if (ferror(file)) {
/* 发生错误*/
}
else {
/* 未发生错误*/
}
clearerr()函数
库函数clearerr()用于清除end-of-file 标志和错误标志,当调用feof()或ferror()校验这些标志后,通常需要清除这些标志,避免下次校验时使用到的是上一次设置的值,此时可以手动调用clearerr()函数清除标志。
clearerr()函数原型如下所示:
#include <stdio.h>
void clearerr(FILE *stream);
此函数没有返回值,调用将总是会成功!
对于end-of-file 标志,除了使用clearerr()显式清除之外,当调用fseek()成功时也会清除文件的end-of-file 标志。
使用示例
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
FILE *fp = NULL;
char buf[20] = {0};
/* 打开文件*/
if (NULL == (fp = fopen("./testApp.c", "r"))) {
perror("fopen error");
exit(-1);
}
printf("文件打开成功!\n");
/* 将读写位置移动到文件末尾*/
if (0 > fseek(fp, 0, SEEK_END)) {
perror("fseek error");
fclose(fp);
exit(-1);
}
/* 读文件*/
if (10 > fread(buf, 1, 10, fp)) {
if (feof(fp))
printf("end-of-file 标志被设置,已到文件末尾!\n");
clearerr(fp); //清除标志
}
/* 关闭文件*/
fclose(fp);
exit(0);
}