Linux应用开发:文件IO进阶

Posted JeckXu666

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux应用开发:文件IO进阶相关的知识,希望对你有一定的参考价值。

Linux应用开发:文件IO进阶

一、管理文件方式

1.1 存储文件方式

文件存放在磁盘文件系统中,以一种固定的形式进行存放,称为静态文件

硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存 512 字节(相当于 0.5KB), 操作系统读取硬盘的时候是一次性连续读取多个扇区组成的“块”,‘’块‘’是文件存取的最小单位。“块”的大小,常见的是 4KB,即连续八个 sector 组成一个 block

1.2 操作文件方式

静态文件的数据存放在磁盘设备不同的“块”中,那调用文件操作函数时是如何找到对应的‘’块‘’呢?

方法就是对磁盘进行分区,最开始格式化的时候会将其分为两个区域: 数据区和 inode 区

分区功能
数据区用于存储文件中的数据
inode 区用于存放 inode table(inode 表)

inode table 中存放的是一个一个的 inode(也成为 inode 节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inode,inode 实质上是一个结构体,这个结构体中有很多的元素来记录文件的不同信息,如文件字节大小、文件所有者、文 件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的 block(块) 位置等等信息

inode table 和 inode 关系图

每一个文件都有唯一的一个 inode,每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode

可以通过 ls -i 或者 stat查看文件 inode 编号:

综上,系统打开文件的方式一般分为三步:

  • 操作系统找到这个文件名所对应的 inode 编号;

  • 通过 inode 编号从 inode table 中找到对应的 inode 结构体;

  • 根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。

1.3 文件打开时状态

当调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件内容从磁盘读取到内存中进行管理、缓存

这段内存中的文件数据叫做动态文件或者内核缓冲区

打开文件后,对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作, 而并不是针对磁盘中存放的静态文件,主要因为静态文件存放在磁盘上,操作单元是以块为单位,且读写速率低,如果在内存中,操作单元就是以字节为单位,读写灵活且速度快!

动态文件和静态文件不是自动同步的,当读写完成后,系统内核会自动同步动态文件到静态文件

我们知道在 RTOS 里面有任务控制块 TCB ,在 Linux 系统中也有进程控制块 PCB ,就是一个专门的数据结构用于管理该进程,譬如用于记录进程的状态信息、运行特征等,在 Linux 的 PCB 中有有一个指针指向了文件描述符表(File descriptors),文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、引用计数、当前文件的读写偏移量以及 i-node 指针(指向该文件对应的 inode)等,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表, 其示意图如下所示:

二、错误处理

2.1 errno变量

代码执行时肯定会遇到各种错误,所以 Linux 系统对常见的错误做了一个编号,每一个 编号都代表着每一种不同的错误类型,当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给 errno 变量,每一个进程(程序)都维护了自己的 errno 变量,它是程序中的全局变量,该变量用于存储就近发生的函数执行错误编号,也就意味着下一次的错误码会覆盖上一次的错误码

errno 是一个 int 类型的变量,调用系统调用或者库函数出错时,Linux 会设置 errno ,具体函数的错误可以通过 man 手册进行查询,如 man 2 open

想要在进程中使用 errno 方法就是包含他的头文件,直接调用就行

#include <errno.h>

之后就可以在程序中直接使用 errno !

2.2 strerror 函数

2.1节中的 errno变量是一个错误编号,而strerror() 该函数可以将对应的 errno 转换成适合我们查看的字符串信息,函数原型:

#include <string.h>

char *strerror(int errnum);

传入 errno ,函数返回错误字符串

代码示例:

打开一个不存在的文件,看看 strerror 会返回什么保存:

  1 #include <sys/types.h>
  2 #include <sys/stat.h>
  3 #include <fcntl.h>
  4 #include <unistd.h>
  5 #include <stdio.h>
  6 #include <errno.h>
  7 #include <string.h>
  8 
  9 int main(void)
 10 {
 11     int fd;
 12     /* 打开文件 */
 13     fd = open("./file", O_RDONLY);
 14     if (-1 == fd) {
 15         printf("Error: %s\\n", strerror(errno));
 16         return -1;
 17     }
 18     close(fd);
 19     return 0;
 20 }

编译执行,报错找不到路径,很直观的显示错误信息

2.3 perror 函数

perror 函数直接用于查看错误信息, 调用此函数不需要传入 errno,函数内部会自己去获取 errno 变量的值,调用此函数会直接将错误提示字符串打印出来,而不是返回字符串,除此之外还可以在输出的错误提示字符串之前加入自己的打印信息,函数原型如下:

#include <stdio.h>
void perror(const char *s);

参照2.2里面的代码,将 strerror 换成如下代码

perror("ERR Test:");

编译执行结果:

三、程序退出

Linux 系统下,进程(程序)退出可以分为正常退出和异常退出,异常退出一般是程序错误,或者系统异常报错,这里不研究,这里研究一下正常退去,及程序手动退出,进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit() 以及 _Exit(),三者函数原型如下:

exit() :

#include <stdlib.h>
void exit(int status);

_exit() :

#include <unistd.h>
void _exit(int status);

_Exit() :

#include <stdlib.h>
void _Exit(int status);

具体功能:

函数功能
_exit()调用_exit()函数会 清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统,属于系统调用
_Exit()用法功能同 _exit(),属于系统调用
exit()exit()是一个标准 C 库函数,执行 exit()会执行一些清理工作,然后调用_exit()函数退出

四、空洞文件

空洞文件就是文件里面存在空洞,没有存储任何数据,然依然占据了一部分文件空间,以下面的图为例子

文件总大小为 3k ,首尾各 1k 大小的实际数据,中间 1k 字节为空,称为空洞,该文件就是空洞文件

空洞文件的主要作用就是 多线程操作文件,如果一个文件不同部分需要几个进程一起写入,空洞文件就可以预留对应要写入数据的空间位置

五、open函数的标志补充

补充上一章 文件IO 入门中 open 函数的 flags 标志

5.1 O_TRUNC 标志

使用 O_TRUNC 标志,调用 open 函数打开文件的时候会原文件的内容全部丢弃,文件大小变为 0 ,相对于清空后打开,编写例程:

  1 #include <sys/types.h>
  2 #include <sys/stat.h>
  3 #include <fcntl.h>
  4 #include <unistd.h>
  5 #include <stdio.h>
  6 #include <stdlib.h>
  7 int main(void)
  8 {
  9     int fd;
 10     fd = open("./test.c", O_WRONLY | O_TRUNC);
 11     if (-1 == fd) {
 12         perror("Error");
 13         exit(-1);
 14     }   
 15     close(fd);
 16     exit(0);
 17 }   

程序执行,将 test.c 清空了

5.2 O_APPEND 标志

O_APPEND 标志,调用 open 函数打开文件, 当每次使用 write()函数对文件进行写操作时,都会自动把文件当前位置偏移量移动到文件末尾,从文件末尾开始写入数据,续写文件,使用方式和上面差不多,不做示例了

六、文件重复打开

一个文件在一个进程或者说多个进程中是可以被多次打开的,有如下的一些注意要点

  • 一个进程内多次 open 打开同一个文件,那么会得到多个不同的文件描述符 fd,同理在关闭文件的 时候也需要调用 close 依次关闭各个文件描述符
  • 一个进程内多次 open 打开同一个文件,在内存中并不会存在多份动态文件,即使多次打开同一个文件,内 存中也只有一份动态文件
  • 一个进程内多次 open 打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的
  • 加入了 O_APPEND 标志后,分别写已经变成了接续写,相对于打开的多个文件描述符的偏移量被关联起来了

七、文件描述符的复制

open 返回得到的文件描述符 fd 可以进行复制,复制成功之后可以得到一个新的文件描述符,复制得到的文件描述符与旧的文件描述符指向的是同一个文件表,使用新的文件描述符和旧的文件描述符都可以对文件进行 IO 操作,复制得到的文件描述符和旧的文件描述符拥有相同的权限,譬如使用旧的文件描述符对文件有读写权限,那么新的文件描述符同样也具 有读写权限;在 Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制

7.1 dup 函数

函数原型

#include <unistd.h>
int dup(int oldfd);

oldfd: 需要被复制的文件描述符。

返回值: 成功时将返回一个新的文件描述符,由操作系统分配,分配置原则遵循文件描述符分配原则; 如果复制失败将返回-1,并且会设置 errno 值。

7.2 dup2 函数

函数原型

#include <unistd.h>
int dup2(int oldfd, int newfd);

oldfd: 需要被复制的文件描述符。

newfd: 指定一个文件描述符(需要指定一个当前进程没有使用到的文件描述符)。

返回值: 成功时将返回一个新的文件描述符,也就是手动指定的文件描述符 newfd;如果复制失败将返 回-1,并且会设置 errno 值。

八、共享文件

文件共享指的是同一个文件(譬如磁盘上的同一个文件,对应同一个 inode)被 多个独立的读写体同时进行 IO 操作。多个独立的读写体大家可以将其简单地理解为对应于同一个文件的多 个不同的文件描述符,譬如多次打开同一个文件所得到的多个不同的 fd,或使用 dup()(或 dup2)函数复制 得到的多个不同的 fd 等

文件共享的核心是:如何制造出多个不同的文件描述符来指向同一个文件。其实方法在上面的内容中都 已经给大家介绍过了,譬如多次调用 open 函数重复打开同一个文件得到多个不同的文件描述符、使用 dup() 或 dup2()函数对文件描述符进行复制以得到多个不同的文件描述符

常见的三种文件共享的实现方式

  • 同一个进程中多次调用 open 函数打开同一个文件,各数据结构之间的关系如下图所示:

多次调用 open 函数打开同一个文件会得到多个不同的文件描述符,并且多个文件描述符对应多个不同的文件表,所有的文件表都索引到了同一个 inode 节点,也就是磁盘上的同一个文件

  • 不同进程中分别使用 open 函数打开同一个文件,其数据结构关系图如下所示:

上两个独立的进程(理解为两个独立的程序),在他们各自的 程序中分别调用 open 函数打开同一个文件,获得对应的文件描述符,文件描述符各自的文件表都索引到了同一个 inode 节 点,从而实现共享文件

  • 同一个进程中通过 dup(dup2)函数对文件描述符进行复制,其数据结构关系如下图所示:

两个文件描述符共同使用一个文件表!

九、原子操作与竞争冒险

9.1 竞争冒险

操作系统的一个特色就是支持多进程、多线程,有的时候会遇到多个进程对有限的资源进行竞争,打断对方的操作,导致另外一个进程的程序执行错误,这种现象称为竞争冒险

比如有两个进程A和B,A和B共享文件1,初始时偏移量都是0,进程A写入数据到文件1,写到100位置时,A的偏移量在100,时间片耗尽,任务切换到B,此时B的偏移量为0,B开始写入100个字节,覆盖掉了A,造成了A的不正常执行,产生了竞争与冒险

简单理解一下就是:每个进程(或线程)去操作文件的顺序是不可预期的,即这些进程获得 CPU 使用权的先后顺序是不可预期的,完全由操作系统调配,进程与线程处于竞争状态

为了避免竞争状态的进程与线程互相打断,Linux 引入了原子操作

9.2 原子操作

原子操作就是将多步操作组成的一个操作,原子操作要么一步也不执行,要么一旦执行,必须要执行完所有步骤,不能只执行所有步骤中的一个子集

以 9.1 的示例为例子,因为A和B覆盖写入,我们引入原子操作,在读写之前加入一个 lseek 定位,定位到文件尾部进行写入,比如系统调用 pread() 和 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);

调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数, 用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite 相当于调用 lseek 后再调用 write,lseek 和 读写绑定执行,形成原子操作,Linux 的原子操作很多,后面遇到再分析

十、文件管理函数

本小节介绍两个新的系统调用:fcntl()和 ioctl()

10.1 fcntl 函数

fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作,譬如复制一个文件描述符、获取/设置文件描述符标志、获取/设置文件状态标志等,类似于一个多功能文件描述符管理工具箱,函数原型如下:

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ )

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);

10.2 ioctl 函数

ioctl()可以认为是一个文件 IO 操作的杂物箱,可以处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设,譬如可以通过 ioctl 获取 LCD 相关信息等,后面学习到在详细分析,函数原型

#include <sys/ioctl.h>

int ioctl(int fd, unsigned long request, ...);

十一、截断文件

用系统调用 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()对文件进行截断操作,将文件截断为参数 length 指定的字节长度,如 果文件目前的大小大于参数 length 所指定的大小,则多余的数据将被丢失,类似于多余的部分被“砍”掉 了;如果文件目前的大小小于参数 length 所指定的大小,则将其进行扩展,对扩展部分进行读取将得到空字 节"\\0"(空洞文件)
truncate()功能同上,区别是 ftruncate() 使用文件描述符 fd 来指定目标文件,truncate() 则直接使用文件路 径 path 来指定目标文件

以上是关于Linux应用开发:文件IO进阶的主要内容,如果未能解决你的问题,请参考以下文章

进阶在Angular应用中使用socket.io

我的Android进阶之旅关于Android平台获取文件的mime类型:为啥不传小写后缀名就获取不到mimeType?为啥android 4.4系统获取不到webp格式的mimeType呢?(代码片段

我的Android进阶之旅关于Android平台获取文件的mime类型:为啥不传小写后缀名就获取不到mimeType?为啥android 4.4系统获取不到webp格式的mimeType呢?(代码片段

Linux进阶系列 1 --- 进程通信

Linux——Linux工具进阶——性能优化(待续)

我的OpenGL学习进阶之旅NDK开发中find_library查找的系统动态库在哪里?