本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。
Linux管理文件的方式
文件在没有被打开的情况下一般都是存放在磁盘中的,譬如电脑硬盘、移动硬盘、U 盘等外部存储设备,文件存放在磁盘文件系统中,并且以一种固定的形式进行存放,我们把他们称为静态文件。
硬盘中存储的最小单位是扇区,但是如果每次查找文件都是以扇区为单位来查找则太慢了,因此系统是以8个扇区为一次读取,即一个块。为了找到这个文件所在的块,系统会根据这个文件名所对应的inode号来寻找,通过 inode 编号从 inode table 中找到对应的 inode 结构体,最后根据 inode 结构体中记录的信息,确定文件数据所在的 block,并读出数据。
磁盘在进行分区、格式化的时候会将其分为两个区域,一个是数据区,用于存储文件中的数据;另一个是
inode区,用于存放inode table,inode table中存放的是一个一个的inode,每一个文件都必须对应一个inode,inode实质上是一个结构体,这个结构体中有很多的元素,不同的元素记录了文件了不同信息,譬如文件字节大小、文件所有者、文件对应的读/写/执行权限、文件时间戳(创建时间、更新时间等)、文件类型、文件数据存储的block(块)位置等等信息。但是文件名并不是记录在inode中的。
当我们调用 open 函数去打开文件的时候,内核会申请一段内存,并且将静态文件的数据内容从磁盘这些存储设备中读取到内存中进行管理、缓存(也把内存中的这份文件数据叫做动态文件、内核缓冲区)。打开文件后,以后对这个文件的读写操作,都是针对内存中这一份动态文件进行相关的操作,而并不是针对磁盘中存放的静态文件。当我们对动态文件进行读写操作后,此时内存中的动态文件和磁盘设备中的静态文件就不同步了,数据的同步工作由内核完成,内核会在之后将内存这份动态文件更新到磁盘设备中。
这也会导致两个缺点:打开大文件比较慢;文档写到一半没保存,掉电后之前写的内容消失。之所以要这么设置还是因为为了保证兼容存储设备的同时,还要保证读写效率。
对于一个进程来说,其中都有一个专门管理进程的进程控制块,这个进程控制块中存在一个文件描述表,其中存放了目前调用过open后打开的文件,即只有调用后才会新增文件表项。当调用close关闭文件后才会消失。如图所示:

系统的错误处理
errno函数(C库函数)
根据上一篇文章,我们处理程序发生错误的方法是在系统错误时返回-1,这种方法无法判断具体发生的是什么错误,因此系统提供了一个int类型的全局变量errno,每一个进程都维护了一个自己的 errno 变量,这个变量存储着最近一次发生函数错误执行时的错误码。部分系统调用或者C库函数出错的时候(有些不会,可以通过man指令查询),操作系统都会设置 errno。在使用时,需要在我们程序当中包含<errno.h>头文件
但是errno变量只是一个编号,对应关系还需要查表,为了更加直观的看到发生的具体错误,一般使用C库函数strerror(),来获取编号对应的字符串,也就是具体错误信息。
c
#include <string.h>
char *strerror(int errnum);
c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
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;
}
perror函数(C库函数)
perror 由于在调用的时候不需要传入 errno变量,用起来可以少引几个h文件,所以平时用的更多。
c
#include <stdio.h>
void perror(const char *s);
c
#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"); //可以在打印出的字符串中的前方自定义字符串,perror 函数会在附加信息后面自动加入
//冒号和空格以区分。
return -1;
}
close(fd);
return 0;
}
进程关闭
进程正常退出除了可以使用 return 之外,还可以使用 exit()、_exit()以及_Exit()。
- 在主函数
return时,会把控制权交给调用函数,这个函数我查了一下,应该是启动代码。在返回后,会调用exit()函数,从而关闭进程。 exit()函数会在终止进程前执行一些清理工作,然后再关闭进程(可以防止一些数据丢失)。exit()函数需要<stdlib.h>。_exit()函数和_Exit()函数等价,对比exit()函数不同的是他会强制退出,而不会在终止进程前执行清理工作。_exit()函数需要<unistd.h>。_Exit()函数需要<stdlib.h>。
空洞文件
在调用lseek函数时,可以改变位置偏移量,并且还允许文件偏移量超出文件长度,比如文件大小是4K,lseek系统调用可以把偏移量定在超过4K的位置,那么此时4K~此位置就出现了一个空洞,没有写入任何数据,这样的文件被称为空洞文件。文件空洞部分实际上并不会占用任何物理空间,直到在某个时刻对空洞部分进行写入数据时才会为它分配对应的空间,但是空洞文件形成时,逻辑上该文件的大小是包含了空洞部分的大小的。
空洞文件是非常有用的,比如在使用迅雷下载文件时,还未下载完成,就发现该文件已经占据了全部文件大小的空间,这也是空洞文件;下载时如果没有空洞文件,多线程下载时文件就只能从一个地方写入,这就不能发挥多线程的作用了;如果有了空洞文件,可以从不同的地址同时写入,就达到了多线程的优势。
c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
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 < 4; i++) {
ret = write(fd, buffer, sizeof(buffer));
if (-1 == ret) {
perror("write error");
goto err;
}
}
ret = 0;
err:
/* 关闭文件 */
close(fd);
exit(ret);
}
在 Linux 系统中,新建文件大小是 0,也就是没有任何数据写入,此时使用lseek函数将读写偏移量移动到4K字节处,再使用write函数写入数据0xFF,每次写入 1K,一共写入 4 次,也就是写入了 4K 数据,也就意味着该文件前 4K 是文件空洞部分,而后 4K 数据才是真正写入的数据。使用ls命令查看的是文件的逻辑大小,du命令查看的是文件实际占用存储块的大小。
若使用
read函数读取文件的空洞部分 (即从未写入过数据的偏移量区域),read会成功返回 ,并且读取出来的数据全部是0(空字符,即'\0') 。
多次打开同一个文件
一个进程内多次 open 打开同一个文件,会得到多个不同的文件描述符 fd,在关闭文件的时候也需要调用 close 依次关闭各个文件描述符。
- 一个进程内多次
open打开同一个文件,在内存中并不会存在多份动态文件。也就是打开的同一文件指向相同的动态文件。 - 一个进程内多次
open打开同一个文件,不同文件描述符所对应的读写位置偏移量是相互独立的(即fd1的写了多个数据后的位置偏移不会影响到fd2,如果想要连续写,那么两个文件打开时都需要加入O_APPEND标志)。见本文的上图。每打开同一个文件都会生成一个新的文件表,虽然偏移量是独立的,但是inode是相同的。
文件表中的引用计数实际上
inode的共享计数,即每存在一个指向inode的文件表,就会加一。
复制文件
在 Linux 系统下,可以使用 dup 或 dup2 这两个系统调用对文件描述符进行复制。复制得到的文件描述符与旧的文件描述符都指向了同一个文件表。因此两个文件描述符的属性是一样,譬如对文件的读写权限、文件状态标志、文件偏移量等。
dup 函数
复制得到的文件描述符与原文件描述符都指向同一个文件表,所以它们的文件读写偏移量是一样的,可以在不使用O_APPEND标志的情况下,通过文件描述符复制来实现接续写。
c
#include <unistd.h>
int dup(int oldfd);
oldfd:需要被复制的文件描述符。返回值:成功时将返回一个新的文件描述符,由操作系统分配,分配置原则遵循文件描述符分配原则;如果复制失败将返回-1,并且会设置errno值。
dup2 函数
dup 系统调用分配的文件描述符是由系统分配的,遵循文件描述符分配原则,并不能自己指定一个文件描述符。而 dup2 系统调用可以手动指定文件描述符,而不需要遵循文件描述符分配原则。
c
#include <unistd.h>
int dup2(int oldfd, int newfd);
oldfd:需要被复制的文件描述符。newfd:指定一个文件描述符(需要指定一个当前进程没有使用到的文件描述符)。返回值:成功时将返回一个新的文件描述符,也就是手动指定的文件描述符newfd;如果复制失败将返回-1,并且会设置errno值。
文件共享
竞争冒险
对于Linux这种多任务、多进程的操作系统,会常常存在多个任务、进程对同一个文件进行操作的情况,这时就会存在竞争冒险现象。比如:
- 读写竞争 :A与B同时打开同一个文件(没有使用
O_APPEND)。A进程对文件偏移量1500处要进行修改,可是刚刚把偏移量放在了1500处的时候时间片耗尽,轮到了B进程,此时B进程调用了write写入了100个字节的数据,返回A进程时,A进程继续刚刚的写入任务,但是对于A进程来说,它的偏移量还在1500,此时就会覆盖B进程写入的数据。 - 文件创建竞争 :在文件创建的时候,进程A先调用
open("./file", O_RDWR)尝试打开文件,发现文件不存在,正准备通过open("./file", O_RDWR | O_CREAT, ...)创建此文件,此时被进程B所打断,而B先调用open("./file", O_RDWR)尝试打开文件,发现文件不存在,通过open("./file", O_RDWR | O_CREAT, ...)创建此文件并成功,此时返回进程A,进程 A 也调用open("./file", O_RDWR | O_CREAT, ...)创建文件,此时就发生了竞争。
原子操作
为了解决竞争冒险问题,实际上是因为使用了两个分开的函数调用。为了解决这一问题,将这两个操作步骤合并成一个操作,这就是原子操作。原子操作,是有多步操作组成的一个操作,原子操作要么一步也不执行,一旦执行,必须要执行完所有步骤,不可能只执行所有步骤中的一个子集。
O_APPEND 实现原子操作
当 open 函数的 flags 参数中包含了 O_APPEND 标志,每次执行 write 写入操作时都会将文件当前写位置偏移量移动到文件末尾,然后再写入数据,这里"移动当前写位置偏移量到文件末尾、写入数据"这两个操作步骤就组成了一个原子操作,加入 O_APPEND 标志后,不管怎么写入数据都会是从文件末尾写,这样就不会导致出现"进程 A 写入的数据覆盖了进程 B 写入的数据"这种情况了。
pread() 和 pwrite() (系统调用)
pread()和 pwrite()可用于实现原子操作,调用 pread 函数或 pwrite 函数可传入一个位置偏移量 offset 参数,用于指定文件当前读或写的位置偏移量,所以调用 pread 相当于调用 lseek 后再调用 read;同理,调用 pwrite相当于调用 lseek 后再调用 write。调用 pread 函数时,无法中断其定位和读操作,且不更新文件表中的当前位置偏移量。
c
#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);
ret = pread(fd, buffer, sizeof(buffer), 1024);//buffer中存在100个字节
通过 pread调用后,可以读取从1024开始的100个字节,但是偏移量仍然是打开文件后初始的0,并不会变为1124。
O_EXCL 实现原子操作
为了实现刚刚举的第二个例子的原子操作,可以使用 O_EXCL 标志,实现判断文件是否存在、创建文件这两个步骤的统一,和O_APPEND类似。
文件操作函数 fcntl 和ioctl
fcntl函数
fcntl()函数可以对一个已经打开的文件描述符执行一系列控制操作,譬如复制一个文件描述符(与 dup、dup2 作用相同)、获取/设置文件描述符标志、获取/设置文件状态标志等,类似于一个多功能文件描述符管理工具箱。
c
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ )
fd: 文件描述符cmd:操作命令。此参数表示我们将要对fd进行什么操作,cmd 参数支持很多操作命令,可通过cmd查看.后续的参数与具体的操作命令有关.- 执行失败情况下,返回-1,并且会设置
errno;执行成功的情况下,其返回值与 cmd(操作命令)有关,譬如cmd=F_DUPFD(复制文件描述符)将返回一个新的文件描述符、cmd=F_GETFD(获取文件描述符标志)将返回文件描述符标志、cmd=F_GETFL(获取文件状态标志)将返回文件状态标志等。
复制文件描述符 F_DUPFD
当 cmd=F_DUPFD 时,它的作用会根据 fd 复制出一个新的文件描述符,此时需要传入第三个参数,第三个参数用于指出新复制出的文件描述符是一个大于或等于该参数的可用文件描述符(没有使用的文件描述符);如果第三个参数等于一个已经存在的文件描述符,则取一个大于该参数的可用文件描述符。
获取/设置文件状态标志符 F_GETFL 和 F_SETFL
这些标志指的就是我们在调用 open 函数时传入的 flags 标志,可以指定一个或多个但是文件权限标志(O_RDONLY、O_WRONLY、O_RDWR)以及文件创建标志(O_CREAT、O_EXCL、O_NOCTTY、O_TRUNC)不能被设置、会被忽略.对于一个已经打开的文件描述符,可以通过这种方式添加或移除标志.
c
flag = fcntl(fd, F_GETFL); //先获取文件标识符的flag
ret = fcntl(fd, F_SETFL, flag | O_APPEND); //通过或的方式添加flag
ioctl函数
ioctl()可以认为是一个文件 IO 操作的杂物箱,可以处理的事情非常杂、不统一,一般用于操作特殊文件或硬件外设.
c
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
fd:文件描述符。request:此参数与具体要操作的对象有关,没有统一值,表示向文件描述符请求相应的操作;...:此函数是一个可变参函数,第三个参数需要根据request参数来决定,配合request来使用。返回值:成功返回 0,失败返回-1。
截断文件
使用系统调用 truncate()或 ftruncate()可将普通文件截断为指定字节长度.
c
#include <unistd.h>
#include <sys/types.h>
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);
这两个函数的区别在于:ftruncate()使用文件描述符 fd 来指定目标文件,而 truncate()则直接使用文件路径 path 来指定目标文件,其功能一样。
两个函数都可以对文件进行截断操作,但是必须要有可写权限,将文件截断为参数 length 指定的字节长度.如果文件目前的大小大于参数 length 所指定的大小,则多余的数据将被丢失.如果文件目前的大小小于参数 length 所指定的大小,则将其进行扩展,对扩展部分进行读取将得到空字节"\0"。
调用这两个函数并不会导致文件读写位置偏移量发生改变,所以截断之后一般需要重新设置文件当前的读写位置偏移量,以免由于之前所指向的位置已经不存在而发生错误(如果被截断,此时写会产生空洞文件,虽然系统不会报错,是合法操作,但是后续程序的逻辑可能要基于正常文件而不是空洞文件,会导致读到空字节,最后程序的逻辑出错).