背景
作为linux 开发者,我们不可避免会接触到文件编程。比如通过文件记录程序配置参数,通过字符设备与外设进行通信。因此作为合格的linux开发者,一定要熟练掌握文件编程。在文件编程中,我们一般会有两类接口函数:标准I/O(带缓冲)和POXIS.1 I/O(不带缓冲)。本章节主要介绍不带缓冲的相关API及注意事项。
open 接口
open
函数的作用是打开或创建一个文件。其声明如下:
int open(const char* path, int oflag, .../*mode_t mode*/);
参数解析:
- path :是打开或需要创建的文件名称;
- oflag : 设置打开文件的权限,该参数取值范围较广。并且需要区分。大致可以分为两类:
- 权限类型标识。需要关注的有O_RDONLY(只读权限)、O_WRONLY(只写权限)、O_RDWR(可读写权限)。这三个标识位必须指定一个且只能指定一个。(O_ECEC(只执行)和O_SEARCH(只搜索)已被移除)。
- 特性类标识。这些标识可多选,常见的有如下:
标识常量 | 含义 |
---|---|
O_APPEND | 每次写都追加到文件的尾端。即使你显式的调用lseek改变文件当前偏移量,但是在write 时,依然会追加到文件末尾 |
O_CREAT | 若文件不存在则创建它。 |
O_EXCL | 如果同时指定了O_CREAT,而文件已经存在,则出错。经常用于判断文件是否存在,与access() 函数功能类似。 |
O_NOBLOCK | 如果path引用的是一个FIFO、块特殊文件、字符特殊文件。那么本文件描述符后续的I/O操作,都设置为非阻塞方式。 |
O_SYNC | 每次write 都会等待物理I/O操作完成,包括文件属性更新。 在linux ext4 系统中,该标识可能不生效 |
O_TRUNC | 如果文件存在,且以只写或读写权限打开。则将长度截断为0。常见的业务场景就是更新配置文件。 |
- mode 可选参数。只有当oflag参数中,具备O_CREAT属性时,才需要指定新创建的文件权限。
知识点: open 函数返回的文件描述符一定时最小的未用描述符数值。
基于上述知识点,经常会被用来重定向程序的标准输入、标准输出或标准错误输出。
场景如下:有一个封装库内部是通过采用的是printf
进行日志打印,无法体现在我们日志系统中。我们如何观察其日志输出呢?常见做法如下:
#include<stdlib.h>
#include<stdio.h>
int main()
{
printf("hello world\n");
return 0;
}
默认情况下,日志输出到终端:
xieyihua@xieyihua:~/test$ gcc 1.c -o 1
xieyihua@xieyihua:~/test$ ./1
hello world
xieyihua@xieyihua:~/test$
可以做以下修改:
#include<stdlib.h>
#include<stdio.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(1); /* 关闭 文件描述符1'*/
open("./log",O_WRONLY|O_CREAT,0755);/* 此时文件描述符1 与 ./log文件绑定*/
printf("hello world\n");
return 0;
}
输出如下:
xieyihua@xieyihua:~/test$ gcc 1.c -o 1
xieyihua@xieyihua:~/test$ ./1
xieyihua@xieyihua:~/test$ cat log
hello world
xieyihua@xieyihua:~/test$
creat 接口
creat
函数主要用于创建一个新文件。函数原型声明如下:
#include <fcntl.h>
int creat(const char* path, mode_t mode);
其等效于:open(path,O_WRONLY|O_CREAT|O_TRUNC,mode);
但是由于creat
只能以只写方式打开文件,使用场景便较少,渐渐被‘冷落’了。
close 接口
close
函数关闭一个打开文件。其函数原型声明如下:
#include<unistd.h>
int close(int fd);
知识点: 当一个进程终止时,内核会自动关闭它所有的打开文件。很多程序都利用了这一功能而不显示调用
close
。
lseek 接口
每个打开文件都有一个与其相关的“当前文件偏移量”。它通常是一个非负整数(/dev/kmem/
支持负的偏移量),用于度量从文件开始处计算的字节数。通常读、写操作都是从当前文件偏移处开始的,并使偏移量增加所读写的字节数。
lseek
接口就可以显式的为一个打开的文件设置偏移量。其函数原型声明如下:
#include <unistd.h>
off_t lseek(int fd,off_t offset,int whence);
- fd, 文件描述符
offset
,其含义与whence
的值相关。
- 若
whence
是SEEK_SET
,则将该文件的偏移量设置为距文件开始处的offset
个字节。 - 若
whence
是SEEK_CUR
,则将该文件的偏移量设置为当前值加上offset
个字节,offset
可为正或负。 - 若
whence
是SEEK_END
,则将该文件的偏移量设置为文件长度加上offset
个字节,offset
可为正或负。
知识点: 系统默认情况下,当打开一个文件时,除非指定
O_APPEND
选项,否则该偏移量被设置为0。
lseek 仅是将当前的文件偏移量记录在内核中,并不引起任何I/O操作,其目的是用于接下来的读写操作。
空洞文件
文件偏移量可以被设置为大于文件当前长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞。位于文件中没有写过的字节都被读为0。并且文件中的空洞并不要求在磁盘上占用存储区。示例代码如下:
#include<fcntl.h>
#include<unistd.h>
#include<stdlib.h>
#include<stdio.h>
int main()
{
int fd;
char* buf1 = "123456789a";
char* buf2 = "abcdefghij";
if((fd = creat("file.hole",0755)) < 0)
{
printf("creat error\n");
return -1;
}
printf("fd = %d\n",fd);
if(write(fd,buf1,10) != 10)
{
printf("write buf1 error\n");
}
if(lseek(fd,16384,SEEK_SET) == -1)
{
printf("lseek error\n");
}
if(write(fd,buf2,10) != 10)
{
printf("write buf1 error\n");
}
return 0;
}
结果如下:
/*文件长度有16394 Byte*/
xieyihua@xieyihua:~/test$ ls -la file.hole
-rwxr-xr-x 1 xieyihua xieyihua 16394 Jul 4 01:58 file.hole
/*文本的实际内容也只有两个字符串*/
xieyihua@xieyihua:~/test$ od -c file.hole
0000000 1 2 3 4 5 6 7 8 9 a \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0010000 a b c d e f g h i j
0010012
xieyihua@xieyihua:~/test$ cat file.hole
123456789aabcdefghijxieyihua@xieyihua:~/test$
/* 没有空洞的文件其占用了16个磁盘块*/
xieyihua@xieyihua:~/test$ ls -ls file.*
8 -rwxr-xr-x 1 xieyihua xieyihua 16394 Jul 4 01:58 file.hole
16 -rwxr-xr-x 1 xieyihua xieyihua 16384 Jul 4 01:58 file.nohole
文件空洞的特性:分配了文件偏移量范围,但是实际却没有分配磁盘空间。我们一般可在两个方向应用:
- 多线程下载。当创建一个巨大的文件时,单个线程逐步构建文件会耗费大量时间。一种优化思路是将文件划分为多个段,利用多线程同时操作,每个线程负责写入其中一段数据。这类似于现实生活中修路的场景,如修建高速公路时,单个施工队的进度可能较慢,但通过安排多个施工队,每个队负责修建一段,最终将它们连接起来,大大提高了效率。
- 共享内存。当不同进程需要共享内存时,并不清楚实际需要多大的文件,可以先开辟一个大文件。比如:在创建虚拟机时,如果一开始就分配了100GB的磁盘空间,而实际上系统安装完成后可能只使用了3、4GB的空间,这就是空洞文件的应用。通过空洞文件,可以避免一开始就分配过多的资源,节约了存储空间的浪费。
read 接口
read
接口用于向打开文件中读数据。其原型声明如下:
#include<unistd.h>
ssize_t read(int fd, void* bff, size_t nbyte);
若read
成功,则返回读到的字节数,如果已经达到文件的尾端,则返回0。
知识点: 大多数文件系统为改善性能都会采用某种预读技术,即即使你每次仅读取100Byte内容,但是实际上会从磁盘中读取一页数据,保存在内存中。从而减少磁盘I/O操作,提高系统性能。但是也会增加内存使用压力。
write 接口
write
接口用于向打开文件写数据。其接口声明如下:
#include <unistd.h>
ssize_t write(int fd, const void* buf,size_t nbytes);
其返回值通常与参数nbytes的值相同,否则标识出错。其出错原因:
- 磁盘已写满
- 超过文件限定长度
linux 内核标识打开文件的方式
linux 内核通过三个数据结构表示打开的文件。记录项、文件表项、V节点。其三者关系大致如下:
- 进程表项中,记录中文件描述符与文件表项的关系;
- 文件表项中,记录文件状态标志位、当前文件偏移量、V节点指针;
- V节点中,记录文件类型、各种操作函数指针、指向i节点。而i节点包含文件的详细信息,比如:文件的所有者、文件长度、指向文件实际数据块再磁盘上所在位置的指针等。
注意:每一个文件只有一个唯一的V节点表;多个文件表项可以指向同一个V节点表,每调用一次open
,则创建一个新的文件表项;不同fd可以指向同一个文件表项;即可能存在以下场景:
正是这样的机制原理,linux 让我们可以多任务同时访问同一文件。但是在写文件时,我们需要关注写入时序以及数据错乱问题。
- 在完成每一个
read
或write
操作后,文件表项中的当前文件偏移量增加所读写的字节数。 - 如果使用O_APPEND标志打开一个文件,则响应标志也被设置到文件表项的文件状态标志中。每次进行写操作时,文件表项中的当前文件偏移量首先会被设置为表项中的文件长度。这就确保每次写入的数据追加到当前尾端处。
- lseek 函数只是修改文件表项中的当前文件偏移量,不进行任何I/O操作。
- 每一个进程都有它自己的文件表项和进程表项。
dup和dup2
这两个接口用于复制一个现有的文件描述符。其接口声明如下:
#include<unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
/*两函数的返回值:若成功,返回新的文件描述符;若出错,返回-1*/
dup
返回新的文件描述符一定是当前可用文件描述符中的最小数值。其效果就是多个fd指向同一个文件表项。其关系与上图中多线程访问文件一致。
sync、fsync、fdatasync接口
传统的linux 系统中设备缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候在写入磁盘。这种方式称为“延迟写”。
“延迟写”虽然提高了write的响应速度(不需要等待数据经过IO,写入磁盘)。但是也带来了风险:当应用层认为已经将数据写入文件了,但实际数据还并没有落入磁盘。若此时系统出现异常,则会将这部分数据丢失。为了避免这种情况,linux 系统提供了sync
、fsync
、fdatasync
接口,应用层主动要求内核将缓冲区中的数据进行落盘。原型声明如下:
#include<unistd.h>
int fsync(int fd);
int fdatasync(int fd);
void sync(void);
- sync 只是将所有修改过的块缓冲区排入写队列,然后就返回。它并不等待实际写磁盘操作结束。
- fsync 函数只对文件描述符fd指定的文件起作用,并等待写磁盘操作结束才返回。
- fdatasync 函数类似于 fsync,但只影响文件的数据部分。
注: open 接口中有一个标识 O_SYNC含义标识同步写,但经过验证,似乎并不起作用,与预期不一致。建议为了保险起见,还是调用fsync接口。
总结
文件编程是Linux开发者必须掌握的技能。本文介绍了Linux文件编程中常用的API及其注意事项,包括open、creat、close、lseek、read、write、dup和dup2等。还介绍了sync、fsync和fdatasync等接口,用于确保数据安全。此外,文章还解释了Linux内核如何标识打开的文件,以及文件表项、V节点和进程表项之间的关系。希望能给您带来帮助。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途
参考文章:https://applink.feishu.cn/client/message/link/open?token=AmX27V1AQAADZjdT9KRAgAQ%3D