经过上一章内容的学习,了解了对 Linux 下文件的管理方式进行简单介绍;函数返回错误的处理;退出程序 exit()、_Exit()、_exit();以及函数的使用等
本章将会接着探究文件IO,讨论如下主题内容。
空洞文件的概念;
open 函数的 O_APPEND 和 O_TRUNC 标志;
多次打开同一文件;
复制文件描述符;
一、空洞文件
1.1 概念
什么是空洞文件(hole file)?在上一章内容中,笔者给大家介绍了 lseek()系统调用,使用 lseek 可以修改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度,这是什么意思呢?譬如有一个 test_file,该文件的大小是 4K(也就是 4096 个字节),如果通过 lseek 系统调用将该文件的读写偏移量移动到偏移文件头部 6000 个字节处,大家想一想会怎样?如果笔者没有提前告诉大家,大家觉得不能这样操作,但事实上 lseek 函数确实可以这样操作。
接下来使用 write()函数对文件进行写入操作,也就是说此时将是从偏移文件头部 6000 个字节处开始写入数据,也就意味着 4096~6000 字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。
1.2 特点
文件大小与实际占用磁盘空间不同:空洞文件的“大小”指的是文件的逻辑大小,而不是实际在磁盘上占用的空间。文件中没有实际数据的区域被视为空洞(hole)。
空洞部分不会占用磁盘空间:空洞区域被视为“零”数据,但实际上并不占用磁盘空间,直到写入数据到该区域时,才会分配物理空间。
使用 ls 查看文件大小:通过 ls -l 命令查看文件大小时,显示的是文件的逻辑大小,而非实际占用的磁盘空间。
1.3 实验测试
这里我们进行相关的测试,新建一个文件把它做成空洞文件,示例代码如下所示:
#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数据才是真正写入的数据。
二、O_APPEND 和 O_TRUNC 标志
在上一章给大家讲解 open 函数的时候介绍了一些 open 函数的 flags 标志,譬如 O_RDONLY、O_WRONLY、O_CREAT、O_EXCL 等,本小节再给大家介绍两个标志,分别是 O_APPEND 和 O_TRUNC,接下来对这两个标志分别进行介绍。
2.1 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 标志的作用了,大家可以自己动手试试。
2.2 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 修改位置偏移量,这里笔者就不再给大家测试了,我还是那句话,编程是一个实践性很强的工作,大家只需要把示例代码进行简单地修改即可!
其实关于第二点细节原因很简单,当执行 write()函数时,检测到 open 函数携带了 O_APPEND 标志,所以在 write 函数内部会自动将写位置偏移量移动到文件末尾,当然这里也只是笔者的一个简单地猜测,至于是不是这样,笔者也无从考证。
2.3 总结
-
O_APPEND:将写入的数据追加到文件末尾,不会覆盖文件中的现有内容。 -
O_TRUNC:打开文件时,如果文件已存在,将文件内容清空(即截断文件)。 -
同时使用:如果
O_TRUNC和O_APPEND一起使用,文件会被清空,然后从文件的开头开始追加数据。
三、多次打开同一个文件
在 Linux 中,多个进程或线程可以同时打开同一个文件,甚至同一进程可以多次打开同一个文件,文件的行为由 open() 系统调用的参数和文件访问模式来决定。下面是关于多次打开同一个文件的一些关键点:
3.1 文件描述符(File Descriptor)
每次调用 open() 打开文件时,内核会为该文件分配一个 文件描述符(FD)。每个文件描述符对应文件的一份“引用”,通过它进行后续的读写操作。
-
同一文件多次打开:一个进程或不同进程可以多次调用
open()打开同一个文件,系统会为每次打开返回一个新的文件描述符。即使文件是同一个,操作系统也会为每次open()调用维护独立的文件描述符。 -
独立的文件描述符:不同的文件描述符会保持独立的文件指针。每个文件描述符都有自己的文件偏移量(即文件指针位置),它们之间不会相互影响。即使是同一个文件,它们的读写操作也是独立的。
3.2 文件描述符的继承
- 在某些情况下,进程可能会继承文件描述符。例如,通过
fork()创建新进程时,子进程会继承父进程的文件描述符。 - 如果进程中的多个线程打开了同一个文件,它们会共享同一个文件描述符,但是每个线程在进行读写时仍然使用各自的文件偏移量。
3.3 文件锁定
- 如果多个进程或线程同时打开同一个文件进行读写,可能会发生 竞争条件,比如两个进程同时修改文件的内容。
- 为了避免这种情况,可以使用 文件锁定机制,例如使用
flock()或fcntl()函数来对文件进行加锁,保证在某一时刻只有一个进程/线程可以对文件进行写操作,避免数据丢失或损坏。
3.4 访问模式
当同一个文件被多次打开时,文件的访问模式会影响文件操作的结果:
O_RDONLY:以只读方式打开文件,不能进行写操作。O_WRONLY:以只写方式打开文件,不能进行读操作。O_RDWR:以读写方式打开文件,可以同时进行读和写操作。O_APPEND:如果以追加模式打开,写操作会被附加到文件末尾。
3.5 文件的文件指针
对于同一文件的多个打开实例,每个文件描述符会维护一个独立的文件偏移量(即文件指针)。这些文件指针独立管理,因此:
- 如果你用一个文件描述符进行读写操作,文件指针会移动。
- 其他文件描述符对同一个文件的读写操作不会影响前一个文件描述符的文件指针,反之亦然。
这意味着,如果一个文件被多次打开,多个文件描述符会有独立的文件指针,每个文件描述符都保持其自己的读取或写入进度。
3.6 示例:多个打开文件描述符
#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 关闭各个文件描述符。
在图中,细心的读者可能会发现,调用 open 函数得到的最小文件描述符是 6,在上一章节内容中给大家提到过,程序中分配得到的最小文件描述符一般是 3,但这里竟然是 6!这是为何?其实这个问题跟vscode 有关,说明 3、4、5 这 3 个文件描述符已经被 vscode 软件对应的进程所占用了,而当前这里执行testApp 文件是在 vscode 软件提供的终端下进行的,所以 vscode 可以认为是 testApp 进程的父进程,相反,testApp 进程便是 vscode 进程的子进程,子进程会继承父进程的文件描述符。关于子进程和父进程这些都是后面的内容,这里暂时不给大家进行介绍,这是只是给大家简单地解释一下,免得大家误会!
其实可以直接在 Ubuntu 系统的 Terminal 终端执行 testApp,这时你会发现打印出来的文件描述符分别是 3、4、5,这里就不给大家演示了。
3.7 多进程/多线程下的行为
- 多进程:每个进程都会获得自己独立的文件描述符,它们的文件指针独立。这意味着,如果一个进程修改了文件内容,其他进程会看到该文件的更改,除非有锁定机制。
- 多线程:在同一个进程中,不同的线程可以共享同一文件描述符,但它们的文件偏移量是独立的。
3.8 文件关闭后的行为
当一个文件描述符关闭后,它所占用的资源会被释放。如果一个文件已经关闭,再次尝试使用该文件描述符将会导致错误。
- 如果一个进程中的文件描述符被关闭,它不会影响到其他进程或线程中的文件描述符。
四、复制文件描述符
在 Linux 系统中,open 返回得到的文件描述符 fd 可以进行复制,复制成功之后可以得到一个新的文件描述符,使用新的文件描述符和旧的文件描述符都可以对文件进行 IO 操作,复制得到的文件描述符和旧的文件描述符拥有相同的权限,譬如使用旧的文件描述符对文件有读写权限,那么新的文件描述符同样也具有读写权限;在 Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制,本小节就给大家介绍这两个函数的用法以及它们之间的区别。
复制得到的文件描述符与旧的文件描述符都指向了同一个文件表,假设 fd1 为原文件描述符,fd2 为复制得到的文件描述符,如下图所示:

因为复制得到的文件描述符与旧的文件描述符指向的是同一个文件表,所以可知,这两个文件描述符的属性是一样,譬如对文件的读写权限、文件状态标志、文件偏移量等,所以从这里也可知道“复制”的含义实则是复制文件表。同样,在使用完毕之后也需要使用 close 来关闭文件描述符。
4.1 dup 函数
#include <unistd.h>
int dup(int oldfd); 函数参数和返回值含义如下:
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 分配原则),打印出来的数据显示为接续写,所以可知,通过复制文件描述符可以实现接续写。
4.2 dup2 函数
dup 系统调用分配的文件描述符是由系统分配的,遵循文件描述符分配原则,并不能自己指定一个文件描述符,这是 dup 系统调用的一个缺陷;而 dup2 系统调用修复了这个缺陷,可以手动指定文件描述符,而不需要遵循文件描述符分配原则,当然在实际的编程工作中,需要根据自己的情况来进行选择。
dup2 函数原型如下所示(可以通过"man 2 dup2"命令查看):
#include <unistd.h>
int dup2(int oldfd, int newfd); 函数参数和返回值含义如下:
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);
} 
由打印信息可知,复制得到的文件描述符 fd2 等于 100,正是我们在 dup2 函数中指定的文件描述符。本小节的内容到这里结束了,最后再强调一点,文件描述符并不是只能复制一次,实际上可以对同一个文件描述符 fd 调用 dup 或 dup2 函数复制多次,得到多个不同的文件描述符。
本小节内容到此结束。下一篇介绍:文件共享介绍;原子操作与竞争冒险;系统调用 fcntl()和 ioctl()介绍;截断文件;
