经过上一章内容的学习,了解了 Linux 下空洞文件的概念;open 函数的 O_APPEND 和 O_TRUNC 标志;多次打开同一文件;复制文件描述符;等内容
本章将会接着探究文件IO,讨论如下主题内容。
文件共享介绍;
原子操作与竞争冒险;
系统调用 fcntl() 和 ioctl() 介绍;
截断文件;
一、文件共享
什么是文件共享?所谓文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个 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 系统中,我们必须要留意到多进程环境下可能会导致的竞争冒险。
1、竞争冒险简介
本小节给大家竞争冒险这个概念,如果学习过 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 刚才写入到该文件中的数据。其上述假设工作流程图如下图所示:
2、原子操作
在上一章给大家介绍 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 个字节。
三、fcntl 和 ioctl
1、fcntl 函数
cntl()函数可以对一个已经打开的文件描述符执行一系列控制操作,譬如复制一个文件描述符(与 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);
}
以上给大家介绍了 fcntl 函数的两种用法,除了这两种用法之外,还有其它多种不同的用法,这里暂时先不介绍了,后面学习到相应知识点的时候再给大家讲解。
2、ioctl 函数
ioctl()可以认为是一个文件 IO 操作的杂物箱,可以处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设,此函数将会在进阶篇中使用到,譬如可以通过 ioctl 获取 LCD 相关信息等,本小节只是给大家引出这个系统调用,暂时不会用到。此函数原型如下所示(可通过"man 2 ioctl"命令查看):
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
函数参数和返回值含义如下:
fd:文件描述符。
request:此参数与具体要操作的对象有关,没有统一值,表示向文件描述符请求相应的操作;后面用到的时候再给大家介绍。
...:此函数是一个可变参函数,第三个参数需要根据 request 参数来决定,配合 request 来使用。
返回值:成功返回 0,失败返回-1。
四、截断文件
使用系统调用 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 字节,与测试代码想要实现的功能是一致的。