经过上一章内容的学习,相信各位读者对 Linux 系统应用编程中的基础文件 I/O 操作有了一定的认识和理解了,能够独立完成一些简单地文件 I/O 编程问题,如果你的工作中仅仅只是涉及到一些简单文件读写操作相关的问题,其实上一章的知识内容已经够你使用了。
当然作为大部分读者来说,我相信你不会止步于此、还想学习更多的知识内容,那本章笔者将会同各位读者一起,来深入探究文件 I/O 中涉及到的一些问题、原理以及所对应的解决方法,譬如 Linux 系统下文件是如何进行管理的、调用函数返回错误该如何处理、open 函数的 O_APPEND、O_TRUNC 标志以及等相关问题。
一、Linux 系统如何管理文件
1、静态文件与 inode
文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U 盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。
文件储存在硬盘上,硬盘的最小存储单位叫做“扇区”(Sector),每个扇区储存 512 字节(相当于 0.5KB),操作系统读取硬盘的时候,不会一个个扇区地读取,这样效率太低,而是一次性连续读取多个扇区,即一次性读取一个“块”(block)。这种由多个扇区组成的“块”,是文件存取的最小单位。“块”的大小,最常见的是 4KB,即连续八个 sector 组成一个 block。
所以由此可以知道,静态文件对应的数据都是存储在磁盘设备不同的“块”中,那么问题来了,我们在程序中调用 open 函数是如何找到对应文件的数据存储“块”的呢,难道仅仅通过指定的文件路径就可以实现?这里我们就来简单地聊一聊这内部实现的过程。
我们的磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是 inode 区,用于存放 inode table(inode 表),inode table 中存放的是一个一个的 inode(也成为 inode节点),不同的 inode 就可以表示不同的文件,每一个文件都必须对应一个 inode,inode 实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的 block(块)位置等等信息,如图1 中所示(这里需要注意的是,文件名并不是记录在 inode 中,这个问题后面章节内容再给大家讲)。
所以由此可知,inode table 表本身也需要占用磁盘的存储空间。每一个文件都有唯一的一个 inode,每一个 inode 都有一个与之相对应的数字编号,通过这个数字编号就可以找到 inode table 中所对应的 inode。在 Linux 系统下,我们可以通过"ls -i"命令查看文件的 inode 编号,如下所示:
上图中 ls 打印出来的信息中,每一行前面的一个数字就表示了对应文件的 inode 编号。除此之外,还可 以使用 stat 命令查看,用法如下:
由以上的介绍大家可以联系到实际操作中,譬如我们在 Windows 下进行 U 盘格式化的时候会有一个 “快速格式化”选项
通过以上介绍可知,打开一个文件,系统内部会将这个过程分为三步:
1) 系统找到这个文件名所对应的 inode 编号;
2) 通过 inode 编号从 inode table 中找到对应的 inode 结构体;
3) 根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。
2、文件打开时的状态
当我们调用 open 函数去打开文件的时候,内核会申请一段内存(一段缓冲区),并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。
当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新(同步)到磁盘设备中。由此我们也可以联系到实际操作中,譬如说:
打开一个大文件的时候会比较慢;
文档写了一半,没记得保存,此时电脑因为突然停电直接掉电关机了,当重启电脑后,打开编写的文档,发现之前写的内容已经丢失。
想必各位读者在工作当中都遇到过这种问题吧,通过上面的介绍,就解释了为什么会出现这种问题。
二、返回错误处理与 errno
在上一章节中,笔者给大家编写了很多的示例代码,大家会发现这些示例代码会有一个共同的特点,那就是当判断函数执行失败后,会调用 return 退出程序,但是对于我们来说,我们并不知道为什么会出错,什么原因导致此函数执行失败,因为执行出错之后它们的返回值都是-1。
难道我们真的就不知道错误原因了吗?其实不然,在 Linux 系统下对常见的错误做了一个编号,每一个编号都代表着每一种不同的错误类型,当函数执行发生错误的时候,操作系统会将这个错误所对应的编号赋值给 errno 变量,每一个进程(程序)都维护了自己的 errno 变量,它是程序中的全局变量,该变量用于存储就近发生的函数执行错误编号,也就意味着下一次的错误码会覆盖上一次的错误码。所以由此可知道,当程序中调用函数发生错误的时候,操作系统内部会通过设置程序的 errno 变量来告知调用者究竟发生了什么错误!
errno 本质上是一个 int 类型的变量,用于存储错误编号,但是需要注意的是,并不是执行所有的系统调用或 C 库函数出错时,操作系统都会设置 errno,那我们如何确定一个函数出错时系统是否会设置 errno 呢?其实这个通过 man 手册便可以查到,譬如以 open 函数为例,执行"man 2 open"打开 open 函数的帮助信息,找到函数返回值描述段,如下所示:
当函数返回错误时会设置 errno,当然这里是以 open 函数为例,其它的系统调用也可以这样查找你可以直接认为此变量就是在<errno.h>头文件中的申明的,好,我们来测试下:
#include <stdio.h>
#include <errno.h>
int main(void)
{
printf("%d\n", errno);
return 0;
}
1、strerror 函数
strerror
函数是一个标准库函数,用于根据错误代码返回一个描述性字符串,该字符串详细说明了错误的原因。该函数是
<string.h>
头文件的一部分
#include <string.h>
char *strerror(int errnum);
errnum
:这是一个整数,表示错误代码,通常是由系统调用或库函数返回的错误代码。常见的错误代码是由<errno.h>
头文件定义的常量,例如EIO
,ENOMEM
,EINVAL
等。
- 返回一个指向描述该错误的字符串的指针。如果错误代码有效,返回一个以 null 结尾的错误描述字符串。如果传入的错误代码无效或无法识别,返回一个指向标准错误消息的字符串。
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main() {
FILE *file = fopen("nonexistent_file.txt", "r");
if (file == NULL) {
printf("Error opening file: %s\n", strerror(errno));
}
return 0;
}
在这个示例中,如果文件 nonexistent_file.txt
无法打开,fopen
会返回 NULL
,并且 errno
会被设置为一个表示错误的代码。strerror(errno)
会返回一个与该错误代码相关的描述性字符串,比如 "No such file or directory"
。
常见的错误代码与对应的描述
EINVAL
:无效的参数。ENOMEM
:内存不足。EIO
:输入/输出错误。EBADF
:坏的文件描述符。EACCES
:权限被拒绝。ENOSPC
:没有足够的空间。
2、perror 函数
在 C 语言中,perror
函数是一个用于打印错误信息的标准库函数,它将标准错误流 stderr
中输出一条错误消息。该消息是由一个给定的字符串和当前 errno
错误代码的描述组成的。
#include <stdio.h>
void perror(const char *s);
-
s
:这是一个字符串参数,它会被输出在错误信息前面,用来描述错误的上下文。s
后面会附带一个冒号和空格(如果s
为空,错误信息只包含系统默认的错误消息)。
功能,perror
会使用全局变量 errno
来生成错误信息。errno
是由操作系统或 C 库的系统调用或库函数设置的,它代表了最近一次的错误代码。perror
会将 errno
对应的错误描述与 s
字符串一起打印到标准错误流(stderr
)。
具体来说,它会输出如下格式的消息:
s: <error_description>
其中,<error_description>
是由 errno
值所指示的错误信息。例如,如果发生了 "文件未找到" 错误,perror
会打印相关的错误描述。
返回值:perror
函数没有返回值(void
类型)
#include <stdio.h>
#include <errno.h>
int main() {
FILE *file = fopen("nonexistent_file.txt", "r");
if (file == NULL) {
perror("Error opening file");
}
return 0;
}
在这个例子中,程序试图打开一个不存在的文件。如果文件打开失败,perror
会输出如下信息:
Error opening file: No such file or directory
三、exit、_exit、_Exit
当程序在执行某个函数出错的时候,如果此函数执行失败会导致后面的步骤不能在进行下去时,应该在出错时终止程序运行,不应该让程序继续运行下去,那么如何退出程序、终止程序运行呢?有过编程经验的读者都知道使用 return,一般原则程序执行正常退出 return 0,而执行函数出错退出 return -1,前面我们所编写的示例代码也是如此。
在 Linux 系统下,进程(程序)退出可以分为正常退出和异常退出,注意这里说的异常并不是执行函数出现了错误这种情况,异常往往更多的是一种不可预料的系统异常,可能是执行了某个函数时发生的、也有可能是收到了某种信号等,这里我们只讨论正常退出的情况。
在 Linux 系统下,进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit()以及_Exit(),下面我们分别介绍。
1、_exit()和_Exit()函数
main 函数中使用 return 后返回,return 执行后把控制权交给调用函数,结束该进程。调用_exit()函数会清除其使用的内存空间,并销毁其在内核中的各种数据结构,关闭进程的所有文件描述符,并结束进程、将控制权交给操作系统。_exit()函数原型如下所示:
#include <unistd.h>
void _exit(int status);
调用函数需要传入 status 状态标志,0 表示正常结束、若为其它值则表示程序执行过程中检测到有错误发生。使用示例如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
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 <stdlib.h>
void _Exit(int status);
_exit()和_Exit()两者等价,用法作用是一样的,这里就不再讲了,需要注意的是这 2 个函数都是系统调用。
2、exit()函数
exit()函数_exit()函数都是用来终止进程的,exit()是一个标准 C 库函数,而_exit()和_Exit()是系统调用。执行 exit()会执行一些清理工作,最后调用_exit()函数。exit()函数原型如下:
#include <stdlib.h>
void exit(int status);
本小节就给大家介绍了 3 中终止进程的方法:
main 函数中运行 return;
调用 Linux 系统调用_exit()或_Exit();
调用 C 标准库函数 exit()。
不管你用哪一种都可以结束进程,但还是推荐大家使用 exit(),其实关于 return、exit、_exit/_Exit()之间的区别笔者在上面只是给大家简单地描述了一下,甚至不太确定我的描述是否正确,因为笔者并不太多去关心其间的差异,对这些概念的描述会比较模糊、笼统,如果大家看不明白可以自己百度搜索相关的内容,当然对于初学者来说,不太建议大家去查找这些东西,至少对你现阶段来说,意义不是很大。好,本小节就介绍这么多,我们接着学习下一小节的内容。
本小节内容到此结束。下一篇介绍:空洞文件的概念;open 函数的 O_APPEND 和 O_TRUNC 标志;多次打开同一文件;复制文件描述符;