各位读者大佬好,我是落羽!一个坚持不断学习进步的学生。
如果您觉得我的文章还不错,欢迎多多三连分享交流,一起学习进步!
欢迎关注我的blog主页: 落羽的落羽
文章目录
- 一、C语言中的文件IO操作
- 二、Linux中的文件IO系统调用
-
- [1. open、write、close](#1. open、write、close)
- 三、文件描述符fd
-
- [1. 文件描述符的本质](#1. 文件描述符的本质)
- [2. 文件描述符的分配规则](#2. 文件描述符的分配规则)
- [3. 重定向](#3. 重定向)
- 四、缓冲区与刷新操作
-
- [1. 为什么要有缓冲区](#1. 为什么要有缓冲区)
- [2. 语言级缓冲区与内核级缓冲区](#2. 语言级缓冲区与内核级缓冲区)
- [3. 刷新方式](#3. 刷新方式)
一、C语言中的文件IO操作
在学习C语言时,我们了解过一些关于文件打开关闭读写的C库函数 :fopen fwrite fputs fread fclose fseek rewind ftell等等。详见:【C语言篇】文件操作
其中,值得关注的是fopen函数

它的第一个参数是文件名,第二个参数是文件的打开模式,有以下模式及其作用:
| 文件打开模式 | 含义 | 如果指定文件不存在 |
|---|---|---|
| r(只读) | 为了输入数据,打开一个文本文件 | 返回空指针 |
| w(只写) | 为了输出数据,打开一个文本文件 | 创建一个新的文件(如果文件存在,会清空原内容) |
| a(追加) | 向文本文件尾添加数据 | 创建一个新的文件 |
| rb(只读) | 为了输入数据,打开一个二进制文件 | 返回空指针 |
| wb(只写) | 为了输出数据,打开一个二进制文件 | 创建一个新的文件 |
| ab(追加) | 向二进制文件尾添加数据 | 创建一个新的文件 |
| r+(读写) | 为了读写数据,打开一个文本文件 | 返回空指针 |
| w+(读写) | 为了读写数据,打开一个文本文件 | 创建一个新的文件 |
| a+(读写) | 在文本文件尾读写数据 | 创建一个新的文件 |
| rb+(读写) | 为了读写数据,打开一个二进制文件 | 返回空指针 |
| wb+(读写) | 为了读写数据,创建一个二进制文件 | 创建一个新的文件 |
| ab+(读写) | 在二进制文件尾读写数据 | 创建一个新的文件 |
最常用的就是r(只读) w(只写) a(追加),其中w模式会在打开文件时清空原内容。
我们知道,大多数文件是放在磁盘上的,根据冯诺依曼体系规则,必须先把文件从磁盘加载到内存才能被CPU访问,这个过程需要硬件层面的 I/O 操作支持,硬件操作一定不是靠C语言完成的,操作系统一定会提供相关的系统调用!
二、Linux中的文件IO系统调用
C语言中,一套正确的文件IO流程是:打开文件、读/写内容、关闭文件。
实际上,在其他语言或操作系统中,基本都是这样的流程,先打开文件,再读写,再关闭!
1. open、write、close
open:
系统调用open,用来打开一个文件:

- 第一个参数
pathname是目标文件 - 第三个参数
mode可以不带,用于控制文件权限,传递一个权限八进制数,如传递0666,实际文件权限值是0666-权限掩码 - 第二个参数是打开文件的选项,选项是宏定义常量,同时有多个选项,选项之间以
|(或)连接 ,常见的有:- 以下三个选项必须有且仅有指定一个:
O_RDONLY(只读打开)O_WRONLY(只写打开)O_RDWR(读写打开)
- 以下选项可以同时指定:
O_CREAT(若指定文件不存在则创建它,使用该选项时必须带mode参数,否则新文件初始权限是乱的)O_APPEND(追加写打开)O_TRUNC(打开文件时清空原内容)
- 以下三个选项必须有且仅有指定一个:
open是返回值是一个int,它代表着目标文件的文件描述符fd !这个概念一会再细说,目前可以理解为标识一个文件的数字,文件描述符是一个非负整数! 如果open返回值小于0,说明打开文件失败。
write :
系统调用write的作用是向某个文件中写内容:

- 第一个参数是目标文件的文件描述符,第二个参数是待写入的数据,第三个参数是你想要写入的字节数。
- 返回值是:
- 写入成功时,返回实际写入的字节数(可能小于 count,比如磁盘空间不足时)。
- 失败时,返回 -1,并设置全局变量 errno 来指示错误原因。
close:
系统调用close用于关闭一个文件:

参数是目标文件的文件描述符,很简单。
我们演示一下使用这些系统调用完成文件读写:
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0000); // 设置权限掩码
int fd = open("log.txt", O_WRONLY|O_CREAT, 0644); // 只读打开,若文件不存在则创建
if(fd < 0)
{
perror("打开文件失败");
return 1;
}
const char* msg = "hello!\n";
write(fd, msg, strlen(msg));
close(fd);
return 0;
}

系统调用还有read、lseek等等。此时我们可以初步得出结论:C库函数fopen、fwrite、fread、fclose、fseek等,本质都是封装了相应的系统调用open、write、read、close、lseek等!
三、文件描述符fd
1. 文件描述符的本质
文件描述符,是一个非负整数。
Linux进程默认情况下会有三个自动打开的文件描述符,分别是:stdin标准输入0、stdout标准输出1、stderr标准错误2,对应的物理设备是:键盘、显示器、显示器。Linux中一切皆文件,从键盘打字就是从键盘文件中读取数据、显示器显示就是将数据写到显示器文件中!
所以,我们想从键盘读取数据,在屏幕上打印出来,也可以这样实现:
c
char buf[16];
ssize_t s = read(0, buf, sizeof(buf)); // 从0号文件(标准输入,也就是键盘)中读取
if(s > 0)
{
buf[s] = '\0';
write(1, buf, strlen(buf)); // 写入1号文件(标准输出1,也就是显示器)
}
一个进程可以打开多个文件,每个文件的状态可能都不一样,所以进程一定需要管理文件!当我们打开文件时,操作系统要在内存中创建相应的数据结构来描述组织文件,于是就可以看到在task_struct中的struct files_struct* files结构!


可以看到,struct files_struct包含着一个数组fd_array。数组元素是struct file*类型,这个结构体类型,就对应着每一个具体的文件,里面包含着文件各种详细属性!

结论:本质上,文件描述符 fd 就是这个数组 fd_array 的下标,每个文件的 fd 就是它在这个数组中的下标!
2. 文件描述符的分配规则
我们来看几段代码和现象:
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("myfile.txt", O_WRONLY | O_CREAT);
if(fd < 0)
{
perror("打开文件失败");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}

- 现象1:直接打开一个新的文件,fd是3。
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(0);
int fd = open("myfile.txt", O_WRONLY | O_CREAT);
if(fd < 0)
{
perror("打开文件失败");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}

- 现象2:先关闭标准输入0,再打开一个新文件,它的fd是0。
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(0);
int fd = open("myfile.txt", O_WRONLY | O_CREAT);
if(fd < 0)
{
perror("打开文件失败");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}

- 现象3:先关闭标准错误2,再打开一个新文件,它的fd是2。
这三个现象可以得出结论了:文件描述符的分配规则是,在files_struct的fd_array数组中,找到一个当前没有被使用的最小的下标,作为新打开文件的文件描述符!当文件被关闭时,它的文件描述符就"空"出来了。
3. 重定向
在上面的现象中,如果先关闭标准输出1呢?
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(1);
int fd = open("myfile.txt", O_WRONLY | O_CREAT, 0644);
if(fd < 0)
{
perror("打开文件失败");
return 1;
}
printf("fd: %d\n", fd);
return 0;
}

我们发现,printf函数并没有打印到显示器上,而是写入到了 myfile.txt 文件中。
原因:
文件描述符1被关闭了,而1正是标准输出stdout的默认文件描述符。
再新打开一个文件myfile.txt,根据刚才说的文件描述符分配规则,它的 fd 就成了1。
printf 库函数,内部本来封装了write(1, ...);这样的系统调用,所以正常情况下就是向标准输出1显示器写入,可是现在1变成myfile.txt,但printf不知道,它只认文件描述符1,于是执行后内容被写进了myfile.txt!
这种现象叫做输出重定向!命令行中< > >>也是重定向!
在 Linux 系统中,重定向的本质是修改文件描述符 fd 与文件的关系,用一个文件描述符的内容指向另一个文件描述符的内容 。例如,文件描述符 x 原本指向文件A的 struct file,文件描述符 y 原本指向文件B的 struct file。若通过某些系统调用让 x 也指向文件B的 struct file,则后续所有通过 x 的文件操作,都会作用于文件B而非文件A。
实现这个过程的系统调用是dup2:

调用后,第二个参数 fd 的指针会指向第一个参数的 fd 指向的文件,也就是第一个 fd 覆盖了第二个 fd。
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
int fd = open("log.txt", O_CREAT | O_WRONLY, 0644);
dup2(fd, 1); // fd覆盖了1, 1也指向fd指向的文件
const char* s = "hello!\n";
write(1, s, strlen(s)); // 本来想写入1,但实际上写入了fd
close(fd);
return 0;
}

四、缓冲区与刷新操作
1. 为什么要有缓冲区
缓冲区是内存空间中的一部分,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间叫做缓冲区。
读写文件时,如果不会开辟文件操作的缓冲区,直接通过系统调用对磁盘进行IO操作,那么每一次执行系统调用都涉及到CPU状态的切换,这将付出一定的效率成本,所以频繁的磁盘访问会对程序的执行效率造成很大影响!
我们可以采用缓冲机制,比如从磁盘中读文件时,可以一次性从文件中读取大量的数据到缓冲区中,然后这部分的访问就不用再调用系统调用了,以减少磁盘的读写次数,再加上计算机对缓冲区的操作大大快于对磁盘的操作速度,所以缓冲区可以提高计算机的运行速度。
2. 语言级缓冲区与内核级缓冲区
缓冲区其实分为:语言级缓冲区、内核级缓冲区。
语言级缓冲区是由语言维护的,属于用户层。
内核级缓冲区是系统维护的,用户不必关心。
举例,你执行fprintf(fp, "hello");:
- 数据先被写入C库维护的语言级缓冲区(用户态),此时既没有触发系统调用,也没有写内核;
- 刷新时,C库调用
write()系统调用,把数据从语言级缓冲区拷贝到内核级缓冲区(内核态),此时仍未写硬盘; - 内核会异步地把内核级缓冲区的数据写到磁盘;
- 如果你想强制写到磁盘,需要调用
fsync(fd)系统调用,把内核级缓冲区的数据强制刷到物理磁盘中。
刷新(Flush)就是把缓冲区里暂存的数据,强制写入到目标文件中,并清空缓冲区。
本质上,语言级缓冲区刷新就是语言级缓冲区用write()系统调用将数据拷贝到内核文件缓冲区。
理论上,数据从C语言缓冲区刷新到内核文件缓冲区后,后面全由OS控制了,用户已经可以认为数据写入了磁盘中,但实际上还要等到内核缓冲区的刷新才是真正的完成。
你也可以使用系统调用fsync来强制让内核缓冲区刷新到磁盘文件中:

C语言缓冲区是由FILE结构体维护的!这个结构体封装了文件描述符、缓冲区等信息.在/usr/include/stdio.h中可以看到:

/usr/include/libio.h中:

3. 刷新方式
语言级缓冲区有几种刷新方式:
- 进程结束时,会自动刷新,除非是系统调用
_exit()结束的进程。 - 当目标文件是显示器时,缓冲类型是"行缓冲",遇到 \n 换行符时自动刷新。
- 当目标文件是普通文件时,缓冲类型是"全缓冲",缓冲区写满了才会刷新。
- 通过
fflush(FILE*)函数强制刷新指定的语言级文件缓冲区。
举个例子:
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY, 0644);
printf("hello!\n");
close(fd);
return 0;
}
先关闭了1,然后打开log.txt文件,执行printf函数,然后close(fd)。按照刚才的逻辑,hello!语句应该会写入log.txt文件中,可是执行程序后却发现log.txt没有内容!

原本,目标文件是stdout显示器时,语言级缓冲区会执行行缓冲,执行printf时遇到 \n 就会立刻刷新一次。
可是,现在重定向到了log.txt文件,这是一个普通文件,执行的是全缓冲!只有当缓冲区满或进程结束才会刷新。但是显然此时缓冲区没满,没有刷新。进程结束前,代码close(fd)使log.txt文件关闭了!"hello!\n"只存在于C语言的缓冲区中,既没有被刷新到内核级缓冲区,也没有写入log.txt,最终随着程序退出,缓冲区被释放,数据丢失。
另一个例子:
c
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
close(1);
int fd = open("log.txt", O_CREAT | O_WRONLY, 0644);
printf("hello, printf!\n");
const char* s = "hello, write!\n";
write(fd, s, strlen(s));
fork();
return 0;
}
向log.txt中分别用库函数printf和系统调用write写入,然后创建一个子进程,结果是:

原因:
- write是系统调用,直接操作内核级缓冲区,调用write时,数据直接传递到内核态缓冲区(后续由内核刷新到log.txt),子进程不会影响。
- printf是C库函数,数据存在C语言缓冲区中,目标文件是普通文件,缓冲区没满,只有进程结束后才刷新。而fork()会完整拷贝父进程的内存空间,包括C库的缓冲区,导致父子进程的C语言缓冲区里各有一份
"hello, printf!\n"。父子进程退出时,都会刷新自己的缓冲区,也就输出了两次!
本篇完,感谢阅读!
