【unix高级编程系列】文件I/O

背景

作为linux 开发者,我们不可避免会接触到文件编程。比如通过文件记录程序配置参数,通过字符设备与外设进行通信。因此作为合格的linux开发者,一定要熟练掌握文件编程。在文件编程中,我们一般会有两类接口函数:标准I/O(带缓冲)和POXIS.1 I/O(不带缓冲)。本章节主要介绍不带缓冲的相关API及注意事项。

open 接口

open函数的作用是打开或创建一个文件。其声明如下:

int open(const char* path, int oflag, .../*mode_t mode*/);

参数解析:

  • path :是打开或需要创建的文件名称;
  • oflag : 设置打开文件的权限,该参数取值范围较广。并且需要区分。大致可以分为两类:
  1. 权限类型标识。需要关注的有O_RDONLY(只读权限)、O_WRONLY(只写权限)、O_RDWR(可读写权限)。这三个标识位必须指定一个且只能指定一个。(O_ECEC(只执行)和O_SEARCH(只搜索)已被移除)。
  2. 特性类标识。这些标识可多选,常见的有如下:
标识常量 含义
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 的值相关。
  1. whenceSEEK_SET,则将该文件的偏移量设置为距文件开始处的offset个字节。
  2. whenceSEEK_CUR,则将该文件的偏移量设置为当前值加上offset个字节,offset可为正或负。
  3. whenceSEEK_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

文件空洞的特性:分配了文件偏移量范围,但是实际却没有分配磁盘空间。我们一般可在两个方向应用:

  1. 多线程下载。当创建一个巨大的文件时,单个线程逐步构建文件会耗费大量时间。一种优化思路是将文件划分为多个段,利用多线程同时操作,每个线程负责写入其中一段数据。这类似于现实生活中修路的场景,如修建高速公路时,单个施工队的进度可能较慢,但通过安排多个施工队,每个队负责修建一段,最终将它们连接起来,大大提高了效率。
  2. 共享内存。当不同进程需要共享内存时,并不清楚实际需要多大的文件,可以先开辟一个大文件。比如:在创建虚拟机时,如果一开始就分配了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的值相同,否则标识出错。其出错原因:

  1. 磁盘已写满
  2. 超过文件限定长度

linux 内核标识打开文件的方式

linux 内核通过三个数据结构表示打开的文件。记录项、文件表项、V节点。其三者关系大致如下:

  • 进程表项中,记录中文件描述符文件表项的关系
  • 文件表项中,记录文件状态标志位当前文件偏移量V节点指针
  • V节点中,记录文件类型各种操作函数指针指向i节点。而i节点包含文件的详细信息,比如:文件的所有者、文件长度、指向文件实际数据块再磁盘上所在位置的指针等。

注意:每一个文件只有一个唯一的V节点表;多个文件表项可以指向同一个V节点表,每调用一次open,则创建一个新的文件表项;不同fd可以指向同一个文件表项;即可能存在以下场景:

正是这样的机制原理,linux 让我们可以多任务同时访问同一文件。但是在写文件时,我们需要关注写入时序以及数据错乱问题。

  • 在完成每一个readwrite操作后,文件表项中的当前文件偏移量增加所读写的字节数。
  • 如果使用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 系统提供了syncfsyncfdatasync接口,应用层主动要求内核将缓冲区中的数据进行落盘。原型声明如下:

#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,人生尽是坦途

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/xieyihua1994/article/details/140229198