

前引:当你调用write写入文件时,数据真的 “立刻” 写到磁盘了吗?其实不然 ——Linux 内核引入了文件缓冲区,让数据先暂存于内存,再 “批量写入” 磁盘(延迟写策略)。这种设计是平衡 “IO 性能” 与 “系统开销” 的关键,但也带来了 “数据何时落盘” 的疑问。本文将解析内核文件缓冲区的工作机制,带你理解 “缓冲 IO” 为何能成为 Linux 性能优化的幕后功臣!
注意:IO(输入 / 输出)是计算机系统与外部进行数据交互的核心机制
目录
【一】C语言文件接口
(1)fopen()
(2)fwrite()
(3)输出流
【二】系统调用open()
实现C接口创建文件
【三】系统调用接口write()
实现C接口文件写入
【四】文件描述符
(1)文件的管理
(2)描述符0、1、2
【五】重定向
(1)输出重定向
(2)追加重定向
(3)输入重定向
【六】Linux之下一切皆文件
【七】缓冲区
(1)引入
(2)语言缓冲区
(3)子进程与缓冲区
为什么对文件没有添加路径,会默认创建在当前工作目录?
文件的操作由当前进程完成的,而每个进程都有自己的“当前工作目录”,进程启动时会继承父进程的“当前工作目录”,如果我们在创建文件时没有指定路径,会采用当前进程的工作目录为默认路径
如何查看当前进程的工作目录:
由当前进程的PID->指令查询(ls -l /proc/PID/cwd)cwd代表当前工作目录

【一】C语言文件接口
(1)fopen()
在C语言中,我们一般打开文件是由 fopen()函数完成,函数原型:
#include <stdio.h>
FILE *fopen(const char *filename, const char *mode);
第一个参数:打开的文件名,可以带路径
第二个参数:打开文件的方式,常见的选项如下:
| "r" | read(只读) | 文件必须存在,否则打开失败 |
| "w" | write(只写) | 清空原内容;若文件不存在则创建(每次写入会清除目标文件内容) |
| "a" | append(追加) | 在文件末尾追加内容;若文件不存在则创建(末尾继续写入目标文件) |
例如:对文件的打开/写入和关闭(对文件的写入可以不加“\\n”或者“\\0”,因为这是文件操作)


(2)fwrite()
函数原型:
#include <stdio.h>
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
参数解释:
第一个参数:写入内容 第二个参数:写入大小
第三个参数:写入次数
第四个参数:文件指针
例如:


(3)输出流
C程序执行时,会默认打开三个输⼊输出流,分别是stdin,stdout,stderr,返回类型都是FILE*类型
stdin:类似键盘文件
stdout:类似显示器文件
stderr:类似显示器文件
既然类似都是 FILE* 我们也可以直接向对应的流里面写,例如:


【二】系统调用open()
open() 是 Unix/Linux 系统调用的相关文件操作接口,不是 C 标准库函数,用来打开或者创建一个文件,需要设置文件的权限等信息,因此各种语言中的创建文件可能就是封装此接口!
函数原型:
#include <fcntl.h> // 包含 open() 的声明
#include <sys/stat.h> // 包含文件权限宏(S_IRUSR, S_IWUSR 等)
#include <unistd.h> // 包含 read(), write(), close() 等
int open(const char *pathname, int flags, mode_t mode);
参数解释:
第一个参数:打开或者创建的文件名(可以带路径)
第二个参数:打开文件的标志,必须包含以下三个之一,仅可选一个:
| O_RDONLY | 只读打开 |
| O_WRONLY | 只写打开 |
| O_RDWR | 可读可写 |
还可以 按位或(|)添加其他选项,常用的有:
| O_CREAT | 如果文件不存在,就创建它(需要提供 mode 参数) |
| O_EXCL | 与 O_CREAT 一起使用,如果文件已存在则报错(防止覆盖) |
| O_TRUNC | 如果文件已存在且以写方式打开,则清空原有内容 |
| O_APPEND | 每次写操作都追加到文件末尾 |
| O_NONBLOCK | 非阻塞模式(对管道、设备文件很有用) |
例如:O_WRONLY | O_CREAT | O_TRUNC
第三个参数:当第二个参数选择创建文件时,必须填此参数(文件权限掩码)
当 flags 包含 O_CREAT 时,必须提供此参数,用来设置新文件的权限
它是一个八进制数,常用的宏定义在 <sys/stat.h> 中:
| S_IRUSR | 0400 | 所有者可读 |
| S_IWUSR | 0200 | 所有者可写 |
| S_IXUSR | 0100 | 所有者可执行 |
| S_IRGRP | 0040 | 组可读 |
| S_IWGRP | 0020 | 组可写 |
| S_IXGRP | 0010 | 组可执行 |
| S_IROTH | 0004 | 其他用户可读 |
| S_IWOTH | 0002 | 其他用户可写 |
| S_IXOTH | 0001 | 其他用户可执行 |
返回值:
- 成功:返回一个非负整数(文件描述符 fd,通常是 3,4,5…,因为 0、1、2 被标准输入、输出、错误占用)
- 失败:返回 -1,并且设置全局变量 errno 表示错误原因(可用 perror() 打印)
实现C接口创建文件


可以看到,文件的创建或者打开,“w”和"a"这些选项,我们可以通过系统调用的选项来完成!
【三】系统调用接口write()
write 是 Linux/Unix 系统的系统调用(直接与内核交互),用于将内存缓冲区的数据写入文件、设备或网络,是程序向外部输出数据的基础接口!
函数原型:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
注意:write 会在写入后把偏移量更新到写入的最后一个字节的下一个位置
如果要读取对应文件把文件偏移量使用 lseek() 移动到文件开头移动到文件开头,例如:
lseek(fd, 0, SEEK_SET); // 移动到文件开头
参数解释:
第一个参数:文件描述符(通过open()获取)
第二个参数:指向待写入数据的缓冲区的指针(const 表示函数不会修改缓冲区内容)
第三个参数:要写入的字节数(即从 buf 中取多少字节写入目标)
返回值:
成功返回写入的字节数;失败返回-1
实现C接口文件写入


【四】文件描述符
(1)文件的管理
我们知道文件的创建或者打开都是通过进程来完成的,而每个进程都会创建一个task_struct的结构对象来进行管理,在这个结构对象里面有一个 FILE* 的结构体指针,来指向一个文件描述表,这个文件描述表是一个指针数组,存放的是文件指针,来指向打开的文件,fd 即是数组的下标,例如:

在各种语言(比如C语言中),fopen()函数返回 FILE 类型的指针,和操作系统的FILE有何区别:
| 位置 | C 标准库 | 内核空间 |
| 可见性 | 对应用程序员可见 | 对应用程序员不可见 |
| 作用 | 封装内核接口间接操控文件 | 操作系统内部管理文件的结构 |
| 平台 | 任何支持 C 标准库的系统都有 | 仅 Linux 内核有 |
(2)描述符0、1、2
当我们开始用语言编写程序的时候,会默认占用文件描述表的三个位置,即stdin,stdout,stderr
stdin:类似键盘文件
stdout:类似显示器文件(用于普通输出,通常用来将不同类型的输出写到不同的文件)
stderr:类似显示器文件(用于错误、异常信息输出将不同类型的输出写到不同的文件)
验证:


【五】重定向
在上面我们已经知道了每个进程中文件描述表的存在,来管理打开的文件:

因为文件指针指向着文件,下标对应该文件指针的位置,所以我们如果直接移动文件指针在文件描述表的位置,就改变了该文件在描述表中的位置,操作系统根据文件描述表的下标来锁定文件的位置,当打开一个新的文件,会从上到下寻找空缺的数组位置来存放该文件指针,即该文件的位置
在系统调用接口中有这样一个接口:dup2()可以更改文件指针的下标位置
函数原型:
#include <unistd.h>
int dup2(int oldfd, int newfd);
第一、二个参数都是文件下标,将 oldfd对应的下标文件指针 覆盖到 newfd对应下标(左移到右)
返回值:成功返回 newfd,失败返回-1

(1)输出重定向
操作符号:>
作用:将输出写入文件,且覆盖之前内容(理解:改变内容输出位置为文件“—>”)
实现:搭配open()文件操作选项每次清零文件内容+dup2()接口来改变文件描述符下标
例如:

(2)追加重定向
操作符号:>>
作用:将输出写入文件,且在末尾追加(理解:改变内容输出位置为文件“—>”)
实现:搭配open()文件操作选项调整为每次追加内容+dup2()接口来改变文件描述符下标
例如:


(3)输入重定向
操作符号:<
作用:从文件读取内容输出,且在末尾追加(理解:改变内容输入的位置“<—”)
实现:搭配open()文件操作选项调整为每次追加内容+dup2()接口来改变文件描述符下标
例如:(注意使用 write() 之后用 lseek() 移动到文件开头移动到文件开头否则读取不了)


【六】Linux之下一切皆文件
既然Linux需要管理底层的各种软硬件资源,比如:文件、网卡、显示器这些,是否是给每个资源都要生成一个独特的管理方式?NO,它把所有资源都套上 “文件的壳”,用同一套逻辑操作
管理实现:
Linux 内核有一层 虚拟文件系统(VFS),为不同类型的 “文件”(磁盘文件、设备、套接字等)提供统一接口。先描述再组织,以结构体多态继承方式实现统一接口,对各种硬件 / 文件系统完成管理,只要适配 VFS,就能被系统以 “文件” 的方式管理,无需修改上层逻辑!
【七】缓冲区
在之前我们有听过缓冲区,类似用来临时存储数据,下面我们看一个现象:


(1)引入
可以看到,只有系统调用可以正常打印,这是因为缓冲区的概念,我画图来解释:

小编个人理解:
向文件的写入、访问等都需要通过进程完成,频繁的访问不如一次性解决,因此缓冲区的存在可以提高性能,在语言层面也是如此,先将数据加载到自己对应的缓冲区,待得到某个信号再统一的刷新缓冲区的内容——缓冲区的优势
语言的标准库里面封装了系统调用,如图各种库函数需要先将内容写到语言的缓冲区,而系统调用函数直接访问系统的缓冲区,close会直接在系统层面关闭文件描述符对应的资源,也就无法再通过对应文件应描述符向系统缓冲区写入,自然的语言中的缓冲区更无法向显示器打印,包括在关闭了1号描述符资源之后,系统调用也无法向内核缓冲区写入,例如:


(2)语言缓冲区
缓冲区的刷新有三种形式:
(1)无缓冲、直接刷新——写一次刷新一次
(2)行刷新——比如‘\\n’,行语句刷新
(3)全缓冲刷新——缓冲区满了才刷新(例如:向文件写入)
(4)进程退出自动刷新缓冲
缓冲区的优势:
(1)解决效率问题(上面已经讲过)
(2)配合格式化(例如printf("Hello %d",2),它在缓冲区之前会完成数据替换)
缓冲区的存在位置:
库函数的调用完成一般会返回 FILE* 的指针,而 FILE 是一个结构体,数据应该是先存储在这个结构体成员里面,里面封装了文件描述符、缓冲区(字符串存储形式)………
(3)子进程与缓冲区
我们先来看下面的代码和现象:如果在最后fork()子进程,然后指向结果写入文件会发生什么


关键:代码段没有用特殊手段是可读的(真正共享),刚 fork() 时父子进程也是共享数据的,但是退出的时候父子进程一方退出导致另一方数据被修改,数据发生写时拷贝,因此打印两份缓冲区的内容;而 write() 是在 fork() 之前直接写道内核缓冲区,因此只打印一份




