1)实验平台:正点原子阿尔法Linux开发板 2)平台购买地址:https://item.taobao.com/item.htm?id=603672744434 2)全套实验源码+手册+视频下载地址:http://www.openedv.com/thread-300792-1-1.html 3)对正点原子Linux感兴趣的同学可以加群讨论:935446741 4)关注正点原子公众号,获取最新资料更新
第三章深入探究文件I/O
经过上一章内容的学习,相信各位读者对Linux系统应用编程中的基础文件I/O操作有了一定的认识和理解了,能够独立完成一些简单地文件I/O编程问题,如果你的工作中仅仅只是涉及到一些简单文件读写操作相关的问题,其实上一章的知识内容已经够你使用了。 当然作为大部分读者来说,我相信你不会止步于此、还想学习更多的知识内容,那本章笔者将会同各位读者一起,来深入探究文件I/O中涉及到的一些问题、原理以及所对应的解决方法,譬如Linux系统下文件是如何进行管理的、调用函数返回错误该如何处理、open函数的O_APPEND、O_TRUNC标志以及等相关问题。 好了,废话不多说,开始本章的学习吧,加油! 本章将会讨论如下主题内容。
对Linux下文件的管理方式进行简单介绍; 函数返回错误的处理; 退出程序exit()、_Exit()、_exit(); 空洞文件的概念; open函数的O_APPEND和O_TRUNC标志; 多次打开同一文件; 复制文件描述符; 文件共享介绍; 原子操作与竞争冒险; 系统调用fcntl()和ioctl()介绍; 截断文件;
3.1Linux系统如何管理文件 3.1.1静态文件与inode 文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。 文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存512字节(相当于0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。这种由多个扇区组成的“块”,是文件存取的最小单位。“块”的大小,最常见的是4KB,即连续八个sector组成一个block。 所以由此可以知道,静态文件对应的数据都是存储在磁盘设备不同的“块”中,那么问题来了,我们在程序中调用open函数是如何找到对应文件的数据存储“块”的呢,难道仅仅通过指定的文件路径就可以实现?这里我们就来简单地聊一聊这内部实现的过程。 我们的磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是inode区,用于存放inode table(inode表),inode table中存放的是一个一个的inode(也成为inode节点),不同的inode就可以表示不同的文件,每一个文件都必须对应一个inode,inode实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的block(块)位置等等信息,如图 3.1.1中所示(这里需要注意的是,文件名并不是记录在inode中,这个问题后面章节内容再给大家讲)。
图 3.1.1 inode table与inode 所以由此可知,inode table表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个inode,每一个inode都有一个与之相对应的数字编号,通过这个数字编号就可以找到inode table中所对应的inode。在Linux系统下,我们可以通过"ls -i"命令查看文件的inode编号,如下所示:
图 3.1.2 ls查看文件的inode编号 上图中ls打印出来的信息中,每一行前面的一个数字就表示了对应文件的inode编号。除此之外,还可以使用stat命令查看,用法如下:
图 3.1.3 stat查看inode编号 由以上的介绍大家可以联系到实际操作中,譬如我们在Windows下进行U盘格式化的时候会有一个“快速格式化”选项,如下所示:
图 3.1.4 Windows下格式化磁盘 如果勾选了“快速格式化”选项,在进行格式化操作的时候非常的快,而如果不勾选此选项,直接使用普通格式化方式,将会比较慢,那说明这两种格式化方式是存在差异的,其实快速格式化只是删除了U盘中的inode table表,真正存储文件数据的区域并没有动,所以使用快速格式化的U盘,其中的数据是可以被找回来的。 通过以上介绍可知,打开一个文件,系统内部会将这个过程分为三步: 1)系统找到这个文件名所对应的inode编号; 2)通过inode编号从inode table中找到对应的inode结构体; 3)根据inode结构体中记录的信息,确定文件数据所在的block,并读出数据。 3.1.2文件打开时的状态 当我们调用open函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。 当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中。由此我们也可以联系到实际操作中,譬如说: 打开一个大文件的时候会比较慢; 文档写了一半,没记得保存,此时电脑因为突然停电直接掉电关机了,当重启电脑后,打开编写的文档,发现之前写的内容已经丢失。 想必各位读者在工作当中都遇到过这种问题吧,通过上面的介绍,就解释了为什么会出现这种问题。好,我们再来说一下,为什么要这样设计? 因为磁盘、硬盘、U盘等存储设备基本都是Flash块设备,因为块设备硬件本身有读写限制等特征,块设备是以一块一块为单位进行读写的(一个块包含多个扇区,而一个扇区包含多个字节),一个字节的改动也需要将该字节所在的block全部读取出来进行修改,修改完成之后再写入块设备中,所以导致对块设备的读写操作非常不灵活;而内存可以按字节为单位来操作,而且可以随机操作任意地址数据,非常地很灵活,所以对于操作系统来说,会先将磁盘中的静态文件读取到内存中进行缓存,读写操作都是针对这份动态文件,而不是直接去操作磁盘中的静态文件,不但操作不灵活,效率也会下降很多,因为内存的读写速率远比磁盘读写快得多。 在Linux系统中,内核会为每个进程(关于进程的概念,这是后面的内容,我们可以简单地理解为一个运行的程序就是一个进程,运行了多个程序那就是存在多个进程)设置一个专门的数据结构用于管理该进程,譬如用于记录进程的状态信息、运行特征等,我们把这个称为进程控制块(Process control block,缩写PCB)。 PCB数据结构体中有一个指针指向了文件描述符表(File descriptors),文件描述符表中的每一个元素索引到对应的文件表(File table),文件表也是一个数据结构体,其中记录了很多文件相关的信息,譬如文件状态标志、引用计数、当前文件的读写偏移量以及i-node指针(指向该文件对应的inode)等,进程打开的所有文件对应的文件描述符都记录在文件描述符表中,每一个文件描述符都会指向一个对应的文件表,其示意图如下所示:
图 3.1.5 文件描述符表、文件表以及inode之间的关系 前面给大家介绍了inode,inode数据结构体中的元素会记录该文件的数据存储的block(块),也就是说可以通过inode找到文件数据存在在磁盘设备中的那个位置,从而把文件数据读取出来。 以上就是本小节给大家介绍到所有内容了,上面给大家所介绍的内容后面的学习过程中还会用到,虽然这些理论知识对大家的编程并没有什么影响,但是会帮助大家理解文件IO背后隐藏的一些理论知识,其实这些理论知识还是非常浅薄的、只是一个大概的认识,其内部具体的实现是比较复杂的,当然这个不是我们学习Linux应用编程的重点,操作系统已经帮我们完成了这些具体的实现,我们要做的仅仅只是调用操作系统提供API函数来完成自己的工作。 好了,废话不多说,我们接着看下一小节内容。 3.2返回错误处理与errno 在上一章节中,笔者给大家编写了很多的示例代码,大家会发现这些示例代码会有一个共同的特点,那就是当判断函数执行失败后,会调用return退出程序,但是对于我们来说,我们并不知道为什么会出错,什么原因导致此函数执行失败,因为执行出错之后它们的返回值都是-1。 难道我们真的就不知道错误原因了吗?其实不然,在Linux系统下对常见的错误做了一个编号,每一个编号都代表着每一种不同的错误类型,当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给errno变量,每一个进程(程序)都维护了自己的errno变量,它是程序中的全局变量,该变量用于存储就近发生的函数执行错误编号,也就意味着下一次的错误码会覆盖上一次的错误码。所以由此可知道,当程序中调用函数发生错误的时候,操作系统内部会通过设置程序的errno变量来告知调用者究竟发生了什么错误! errno本质上是一个int类型的变量,用于存储错误编号,但是需要注意的是,并不是执行所有的系统调用或C库函数出错时,操作系统都会设置errno,那我们如何确定一个函数出错时系统是否会设置errno呢?其实这个通过man手册便可以查到,譬如以open函数为例,执行"man 2 open"打开open函数的帮助信息,找到函数返回值描述段,如下所示:
图 3.2.1 查看返回值描述信息 从图中红框部分描述文字可知,当函数返回错误时会设置errno,当然这里是以open函数为例,其它的系统调用也可以这样查找,大家可以自己试试! 在我们的程序当中如何去获取系统所维护的这个errno变量呢?只需要在我们程序当中包含头文件即可,你可以直接认为此变量就是在头文件中的申明的,好,我们来测试下:
#include
#include
int main(void)
{
printf("%d\n", errno);
return 0;
}
以上的这段代码是不会报错的,大家可以自己试试! 3.2.1strerror函数 前面给大家说到了errno变量,但是errno仅仅只是一个错误编号,对于开发者来说,即使拿到了errno也不知道错误为何?还需要对比源码中对此编号的错误定义,可以说非常不友好,这里介绍一个C库函数strerror(),该函数可以将对应的errno转换成适合我们查看的字符串信息,其函数原型如下所示(可通过"man 3 strerror"命令查看,注意此函数是C库函数,并不是系统调用):
#include
char *strerror(int errnum);
首先调用此函数需要包含头文件。
函数参数和返回值如下:
errnum:错误编号errno。
返回值:对应错误编号的字符串描述信息。
测试
接下来我们测试下,测试代码如下:
示例代码 3.2.1 strerror测试代码
#include
#include
#include
#include
#include
#include
#include
int main(void)
{
int fd;
/* 打开文件 */
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
printf("Error: %s\n", strerror(errno));
return -1;
}
close(fd);
return 0;
}
编译源代码,在Ubuntu系统下运行测试下,在当前目录下并不存在test_file文件,测试打印结果如下:
图 3.2.2 strerror测试结果 从打印信息可以知道,strerror返回的字符串是"No such file or directory",所以从打印信息可知,我们就可以很直观的知道open函数执行的错误原因是文件不存在! 3.2.2perror函数 除了strerror函数之外,我们还可以使用perror函数来查看错误信息,一般用的最多的还是这个函数,调用此函数不需要传入errno,函数内部会自己去获取errno变量的值,调用此函数会直接将错误提示字符串打印出来,而不是返回字符串,除此之外还可以在输出的错误提示字符串之前加入自己的打印信息,函数原型如下所示(可通过"man 3 perror"命令查看):
#include
void perror(const char *s);
需要包含头文件。
函数参数和返回值含义如下:
s:在错误提示字符串信息之前,可加入自己的打印信息,也可不加,不加则传入空字符串即可。
返回值:void无返回值。
测试
接下来我们进行测试,测试代码如下所示:
示例代码 3.2.2 perror测试代码
#include
#include
#include
#include
#include
int main(void)
{
int fd;
/* 打开文件 */
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
return -1;
}
close(fd);
return 0;
}
编译源代码,在Ubuntu系统下运行测试下,在当前目录下并不存在test_file文件,测试打印结果如下:
图 3.2.3 perror测试结果 从打印信息可以知道,perror函数打印出来的错误提示字符串是"No such file or directory",跟strerror函数返回的字符串信息一样,"open error"便是我们附加的打印信息,而且从打印信息可知,perror函数会在附加信息后面自动加入冒号和空格以区分。 以上给大家介绍了strerror、perror两个C库函数,都是用于查看函数执行错误时对应的提示信息,大家用哪个函数都可以,这里笔者推荐大家使用perror,在实际的编程中这个函数用的还是比较多的,当然除了这两个之外,其它其它一些类似功能的函数,这里就不再给大家介绍了,意义不大! 3.3exit、_exit、_Exit 当程序在执行某个函数出错的时候,如果此函数执行失败会导致后面的步骤不能在进行下去时,应该在出错时终止程序运行,不应该让程序继续运行下去,那么如何退出程序、终止程序运行呢?有过编程经验的读者都知道使用return,一般原则程序执行正常退出return 0,而执行函数出错退出return -1,前面我们所编写的示例代码也是如此。 在Linux系统下,进程(程序)退出可以分为正常退出和异常退出,注意这里说的异常并不是执行函数出现了错误这种情况,异常往往更多的是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等,这里我们只讨论正常退出的情况。 在Linux系统下,进程正常退出除了可以使用return之外,还可以使用exit()、_exit()以及_Exit(),下面我们分别介绍。 3.3.1_exit()和_Exit()函数 main函数中使用return后返回,return执行后把控制权交给调用函数,结束该进程。调用_exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。_exit()函数原型如下所示: #include
void _exit(int status); 调用函数需要传入status状态标志,0表示正常结束、若为其它值则表示程序执行过程中检测到有错误发生。使用示例如下:
示例代码 3.3.1 _exit()使用示例
#include
#include
#include
#include
#include
int main(void)
{
int fd;
/* 打开文件 */
fd = open("./test_file", O_RDONLY);
if (-1 == fd) {
perror("open error");
_exit(-1);
}
close(fd);
_exit(0);
}
用法很简单,大家可以自行测试!
_Exit()函数原型如下所示:
#include
void _Exit(int status);
_exit()和_Exit()两者等价,用法作用是一样的,这里就不再讲了,需要注意的是这2个函数都是系统调用。 3.3.2exit()函数 exit()函数_exit()函数都是用来终止进程的,exit()是一个标准C库函数,而_exit()和_Exit()是系统调用。执行exit()会执行一些清理工作,最后调用_exit()函数。exit()函数原型如下: #include
void exit(int status); 该函数是一个标准C库函数,使用该函数需要包含头文件,该函数的用法和_exit()/_Exit()是一样的,这里就不再多说了。 本小节就给大家介绍了3中终止进程的方法: main函数中运行return; 调用Linux系统调用_exit()或_Exit(); 调用C标准库函数exit()。 不管你用哪一种都可以结束进程,但还是推荐大家使用exit(),其实关于return、exit、_exit/_Exit()之间的区别笔者在上面只是给大家简单地描述了一下,甚至不太确定我的描述是否正确,因为笔者并不太多去关心其间的差异,对这些概念的描述会比较模糊、笼统,如果大家看不明白可以自己百度搜索相关的内容,当然对于初学者来说,不太建议大家去查找这些东西,至少对你现阶段来说,意义不是很大。好,本小节就介绍这么多,我们接着学习下一小节的内容。 3.4空洞文件 3.4.1概念 什么是空洞文件(hole file)?在上一章内容中,笔者给大家介绍了lseek()系统调用,使用lseek可以修改文件的当前读写位置偏移量,此函数不但可以改变位置偏移量,并且还允许文件偏移量超出文件长度,这是什么意思呢?譬如有一个test_file,该文件的大小是4K(也就是4096个字节),如果通过lseek系统调用将该文件的读写偏移量移动到偏移文件头部6000个字节处,大家想一想会怎样?如果笔者没有提前告诉大家,大家觉得不能这样操作,但事实上lseek函数确实可以这样操作。 接下来使用write()函数对文件进行写入操作,也就是说此时将是从偏移文件头部6000个字节处开始写入数据,也就意味着4096~6000字节之间出现了一个空洞,因为这部分空间并没有写入任何数据,所以形成了空洞,这部分区域就被称为文件空洞,那么相应的该文件也被称为空洞文件。 文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的,这点需要注意。 那说了这么多,空洞文件有什么用呢?空洞文件对多线程共同操作文件是及其有用的,有时候我们创建一个很大的文件,如果单个线程从头开始依次构建该文件需要很长的时间,有一种思路就是将文件分为多段,然后使用多线程来操作,每个线程负责其中一段数据的写入;这个有点像我们现实生活当中施工队修路的感觉,比如说修建一条高速公路,单个施工队修筑会很慢,这个时候可以安排多个施工队,每一个施工队负责修建其中一段,最后将他们连接起来。 来看一下实际中空洞文件的两个应用场景: 在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势; 在创建虚拟机时,你给虚拟机分配了100G的磁盘空间,但其实系统安装完成之后,开始也不过只用了3、4G的磁盘空间,如果一开始就把100G分配出去,资源是很大的浪费。 关于空洞文件,这里就介绍这么多,上述描述当中多次提到了线程这个概念,关于线程这是后面的内容,这里先不给大家讲。 3.4.2实验测试 这里我们进行相关的测试,新建一个文件把它做成空洞文件,示例代码如下所示:
示例代码 3.4.1 空洞文件测试代码
#include
#include
#include
#include
#include
#include
#include
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
关注
打赏
最近更新
- 深拷贝和浅拷贝的区别(重点)
- 【Vue】走进Vue框架世界
- 【云服务器】项目部署—搭建网站—vue电商后台管理系统
- 【React介绍】 一文带你深入React
- 【React】React组件实例的三大属性之state,props,refs(你学废了吗)
- 【脚手架VueCLI】从零开始,创建一个VUE项目
- 【React】深入理解React组件生命周期----图文详解(含代码)
- 【React】DOM的Diffing算法是什么?以及DOM中key的作用----经典面试题
- 【React】1_使用React脚手架创建项目步骤--------详解(含项目结构说明)
- 【React】2_如何使用react脚手架写一个简单的页面?