文章目录
- [1. 理解文件的本质](#1. 理解文件的本质)
-
- [1.1 文件 = 内容 + 属性(元数据)](#1.1 文件 = 内容 + 属性(元数据))
- [1.2 进程、工作路径与文件访问](#1.2 进程、工作路径与文件访问)
- [1.3 打开文件的本质:加载到内存](#1.3 打开文件的本质:加载到内存)
- [1.4 文件操作模式详解](#1.4 文件操作模式详解)
- [2. Linux 系统级文件操作](#2. Linux 系统级文件操作)
-
- [2.1 位图与标志位机制(`|` 与 `&`)](#2.1 位图与标志位机制(
|与&)) - [2.2 核心系统调用:`open`、`close`、`write`](#2.2 核心系统调用:
open、close、write) -
- [2.2.1 `open`打开文件](#2.2.1
open打开文件) - [2.2.3 `close`关闭文件](#2.2.3
close关闭文件) - [2.2.2 `write`写入文件](#2.2.2
write写入文件)
- [2.2.1 `open`打开文件](#2.2.1
- [2.3 文件描述符(fd):本质是数组下标](#2.3 文件描述符(fd):本质是数组下标)
-
- [2.3.1 文件描述符是什么?](#2.3.1 文件描述符是什么?)
- [2.3.2 系统文件操作与C等语言的关联](#2.3.2 系统文件操作与C等语言的关联)
- [2.3.3 证明C语言库函数是系统调用封装来的](#2.3.3 证明C语言库函数是系统调用封装来的)
- [2.4 C 语言库函数封装的意义(跨平台与缓冲)](#2.4 C 语言库函数封装的意义(跨平台与缓冲))
- [2.1 位图与标志位机制(`|` 与 `&`)](#2.1 位图与标志位机制(
- [3. 深入理解重定向](#3. 深入理解重定向)
-
- [3.1 文件描述符的分配规则](#3.1 文件描述符的分配规则)
- [3.2 深入理解重定向](#3.2 深入理解重定向)
-
- [3.2.1 输出重定向的本质](#3.2.1 输出重定向的本质)
- [3.2.2 追加重定向的本质](#3.2.2 追加重定向的本质)
- [3.2.3 输入重定向的本质](#3.2.3 输入重定向的本质)
- [3.3 dup2 系统调用](#3.3 dup2 系统调用)
-
- [3.3.1 输入重定向(使用 dup2)](#3.3.1 输入重定向(使用 dup2))
- [3.3.2 输出重定向(使用 dup2)](#3.3.2 输出重定向(使用 dup2))
- [3.3.3 追加重定向(使用 dup2)](#3.3.3 追加重定向(使用 dup2))
- [4. 理解一切皆文件](#4. 理解一切皆文件)
- [5. 缓冲区深度剖析](#5. 缓冲区深度剖析)
-
- [5.1 文件读写的基本过程与数据流转](#5.1 文件读写的基本过程与数据流转)
- [5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区](#5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区)
-
- [5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区](#5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区)
- [5.2.2 内核缓冲区](#5.2.2 内核缓冲区)
- [5.3 标准IO提供了三种缓冲方式](#5.3 标准IO提供了三种缓冲方式)
-
- [5.3.1 全缓冲](#5.3.1 全缓冲)
- [5.3.2 行缓冲](#5.3.2 行缓冲)
- [5.3.3 无缓冲](#5.3.3 无缓冲)
- [5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制](#5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制)
-
- [5.4.1 实验一:重定向后直接关闭文件描述符](#5.4.1 实验一:重定向后直接关闭文件描述符)
- [5.4.2 实验二:进程终止时的缓冲区行为](#5.4.2 实验二:进程终止时的缓冲区行为)
- [5.4.3 实验三:`fork` 后的缓冲区现象](#5.4.3 实验三:
fork后的缓冲区现象)
- [5.4 补充:如何理解标准错误](#5.4 补充:如何理解标准错误)
- [6. 实战:手写简易 Stdio 库](#6. 实战:手写简易 Stdio 库)
-
- [6.1 头文件定义(`mystdio.h`)](#6.1 头文件定义(
mystdio.h)) - [6.2 核心逻辑实现(`mystdio.c`)](#6.2 核心逻辑实现(
mystdio.c)) - [6.3 测试与验证(`main.c`)](#6.3 测试与验证(
main.c))
- [6.1 头文件定义(`mystdio.h`)](#6.1 头文件定义(
- 总结

1. 理解文件的本质
1.1 文件 = 内容 + 属性(元数据)
提出问题:我现在新建一个空文件,需不需要占据磁盘空间呢?
答案是需要的只要你创建一个空文件,那这个文件在磁盘上是客观存在的,也就是文件的属性(文件的元信息是存在的)只是内容为空。
文件 = 文件的内容 + 文件的属性(元数据)
未来我们对文件的操作就两类:1. 修改内容 2.修改属性
1.2 进程、工作路径与文件访问
- 访问文件之前,必须先打开文件,更需要先找到对应的文件。所以我们需要通过路径唯一标识文件。但是有时候我们访问文件不带路径仅仅使用文件名?那么打开文件是谁打开的呢?又是怎么打开的?
- 文件若仅仅只是被编译好,只要没有运行起来,不执行
fopen根本也就没有打开文件,文件是进程打开的。 - 我们在学习文件的哪些操作,都是文件变成进程了才开始操作的。我们本质是在学习进程和文件的关系 。
测试代码:利用代码创建文件
c
#include<stdio.h>
int main()
{
const char *filename = "log.txt";
FILE *fp = fopen(filename,"w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
fclose(fp);
return 0;
}
提出问题
- 上述代码并没有带路径,OS怎么知道新建在哪里?程序一旦跑起来,就有自己的 pid,每一个进程都会默认记录下来自己所在的工作路径。进程一旦启动,会动态维持住自己的当前工作路径 cwd。
- 若你不带路径,则系统层面会
cwd/log.txt。所以同样的如果我们改变当前工作路径,新建文件的位置就会发生变化。 - 系统会在当前工作路径下找你的文件,所以你不带路径的时候,不是因为不需要路径,而是系统知道路径。访问任何文件,必须有路径,要么用户自己提供,要么用户使用进程的 cwd。这就正面的回答了上面的问题
1.3 打开文件的本质:加载到内存
打开文件,其实是在做什么?打开的本质是将文件从磁盘加载到内存。那到底是加载内容还是属性呢?进程要访问文件的什么,那就把什么加载进来。
对文件的操作,本质是进程通过 CPU 访问内存中的文件
Linux 存在大量的文件,我们可以将文件理解为两类:打开的文件 和没有被打开的文件 。
文件从位置上可以分为:
- 内存级文件--被打开的文件
- 磁盘文件--文件系统
Linux 系统当中,可以同时存在很多个被打开的文件。OS 要不要对这些被打开的文件进行管理?肯定是要的。如何管理这些被打开的文件?答案是先描述,再组织。我们一定会有一个结构体来保存文件属性,我们可以定义链接结构将各个文件链接,就可以将对文件的管理变成对链表的增删查改。
1.4 文件操作模式详解
回顾文件操作:fopen打开文件

打开文件的方式:

如果以写的方式打开文件:如果不存在则新建,存在则清空。实际上输出重定向打开文件的方式就是 w。既如此,我们可不可以利用此来清空文件?
示例代码:清空文件
c
#include<stdio.h>
int main(int argc,char *argv[])
{
if(argc != 2){
printf("Usage: %s filename\n",argv[0]);
return 1;
}
const char *filename = "log.txt";
FILE *fp = fopen(filename,"w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
return 0;
}

这个实际上是 w 选项在起作用。实际上,这个命令的含义是:
bash
>log.txt
即将该文件以 w 的形式打开,再立马关掉,文件就被清空了。
a 也就是 appending 也就是追加的意思,也就是往文件的末尾写入,存在就追加,不存在就创建。
追加示例代码
c
#include<stdio.h>
int main(int argc,char *argv[])
{
if(argc != 2){
printf("Usage: %s filename\n",argv[0]);
return 1;
}
const char *filename = "log.txt";
FILE *fp = fopen(filename,"a");
if(fp == NULL)
{
perror("fopen");
return 1;
}
int cnt = 1;
while(cnt <= 10)
{
cnt++;
const char *s = "hello word\n";
fputs(s,fp);
}
return 0;
}
这就想起了我们的追加重定向:
bash
echo "bbbb" >> log.txt
r+ 选项是即为了读又为了写,要是不存在 r+ 不会新建。a+ 是为了读和追加。
读入文件内容示例:
c
#include<stdio.h>
int main()
{
const char *filename = "log.txt";
FILE *fp = fopen(filename,"r");
if(fp == NULL)
{
perror("fopen");
return 1;
}
while(1)
{
char buffer[128];
if(!fgets(buffer,sizeof(buffer),fp))
break;
printf("from file: %s\n",buffer);
}
fclose(fp);
return 0;
}

从操作角度理解文件:
读写位置,本质就是一个整数。这就引入了你是如何看待文件内容的?文件内容实际上都是字符,那么文件实际上就类似于一个一维数组,所以所谓的读写位置实际上就是数组下标。

2. Linux 系统级文件操作
OS 也能对文件进行操作。
2.1 位图与标志位机制(| 与 &)
使用宏定制输出功能:
c
#include<stdio.h>
#include<unistd.h>
#define ONE (1<<0)
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
void Print(int flag)
{
if(flag & ONE)
printf("one\n");
if(flag & TWO)
printf("two\n");
if(flag & THREE)
printf("three\n");
if(flag & FOUR)
printf("four\n");
}
int main()
{
Print(ONE);
printf("\n");
Print(ONE|TWO);
printf("\n");
Print(ONE|TWO|THREE);
printf("\n");
Print(ONE|TWO|THREE|FOUR);
printf("\n");
Print(ONE|FOUR);
printf("\n");
return 0;
}
通过位图,一次向一个函数传递若干个标志位,那些标志位被设置了用与操作检测,同时传递位操作用或操作来集联。

这一段操作就足以说明我们可以用与操作和或操作了。一个位代表一个信息,或起来就可以将信息进行组合。
2.2 核心系统调用:open、close、write
2.2.1 open打开文件

打开存在的文件,两个参数的 open 就够了。如果文件不存在?就创建它,新建一个文件是要有权限的。

文件创建成功会返回一个文件描述符,错误会返回一个 -1。
示例代码:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
// Linux系统下,你自己不添加写自己是不会写的,你必须按位或上一个创建
// 第三个参数是其权限,如果不指定创建出来的新文件的权限就是乱的
if(fd < 0)
{
perror("open");
}
return 0;
}
我第三个参数的权限是 666,为什么该文件权限最后是 664?因为 umask 权限掩码(0002),凡是出现的选项最终都应该去掉,所以我的权限是 664。所以我们创建一个文件还受到系统的影响。
那如果我就想让他的权限就是 666 呢?
c
umask(0); // 在代码内部直接将权限掩码设置为 0
手动设置掩码,但是系统掩码依旧是 002,但是掩码的使用遵循就近原则,所以使用我自己设置的。
文件描述符:
打开文件后,返回值是多少?
c
printf("fd:%d\n", fd);

为什么是 3?因为 0、1、2 被占用了:
- 0:标准输入(对应键盘)
- 1:标准输出(对应显示器)
- 2:标准错误(对应显示器)
程序启动时默认会打开三个流:标准输入流,标准输出流,标准错误流。
2.2.3 close关闭文件
c
close(fd);
2.2.2 write写入文件

第一个参数:文件描述符;第二个参数:写的起始位置;第三个参数:写几个数,真实写入几个,返回值就是几。
测试代码:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if(fd < 0)
{
perror("open");
}
printf("fd: %d\n", fd);
const char *msg = "asdf";
write(fd, msg, strlen(msg)); // 要不要加一?不需要,\0 是我们 C 语言的规定,和你的文件有什么关系?
close(fd);
return 0;
}
写入加一会出现乱码:

要是我写入字符串(log.txt 本来就有内容):
c
const char *msg = "12";
C 语言写的时候,写之前会清空,但是这个没有清空,将 as 覆盖了。
所以写之前,要将选项先清空(类似于C 语言中的 w):
c
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
以追加的形式写入,不要覆盖(C 语言中的 a):
c
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
我们使用系统调用实现在 C 语言中一样的效果。
2.3 文件描述符(fd):本质是数组下标
2.3.1 文件描述符是什么?
对文件做任何操作,都必须把文件加载到缓冲区中。进程与文件之间是1:n的,进程和文件之间是如何产生关联的?可以说进程通过文件描述符找到文件。文件描述符的本质:数组下标。

2.3.2 系统文件操作与C等语言的关联
C 语言库函数是系统调用封装来的, C 语言中不需要关心权限,是因为系统调用封装好了。
测试代码:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd1 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
}
printf("fd: %d\n", fd);
printf("fd1: %d\n", fd1);
printf("fd2: %d\n", fd2);
close(fd);
return 0;
}

出错了是 -1,上面当然没有错,那 0、1、2 去哪里了?
0、1、2 被占用了,这几个保留数字的含义实际上可以与C语言对应:
- 0:标准输入 键盘
- 1:标准输出 显示器
- 2:标准错误 显示器 --内核视角
程序启动时默认会打开的三个流,对应:

2.3.3 证明C语言库函数是系统调用封装来的
今天我要向显示器打印,有几种方法?按照上述的说法,进程通过文件描述符来找到文件并对文件进行操作,1 号文件是不是就可以直接使用?
测试代码:向显示器写入
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
const char *msg = "hhhhhhh\n";
write(1, msg, strlen(msg)); // 这个是系统调用
fputs(msg, stdout); // 等价于这个库函数
return 0;
}
答案是,是的!!!将目标文件换为1,就实现了直接在显示器上的输出:

向显示器写入,本质上是向硬件写入,只有 OS 才有资格向硬件写入,用户想要写入就必须使用 OS 提供的系统调用!
所以我们上面的 FILE* 文件对象的本质是一个结构体对象,这个实际上是一个结构体指针。这个结构体会拥有什么成员?它一定封装了文件描述符 ,OS 使用文件只认文件描述符!
如何证明?测试代码:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);
return 0;
}

所以上述三个函数封装的就是系统调用!
2.4 C 语言库函数封装的意义(跨平台与缓冲)
FILE -> fd
fopen / fwrite / fclose与系统中的open / write / close
C++ 也有文件操作:ifstream 和 ofstream,它们是类,是类就有成员属性,成员属性里就有 fd。
任何语言在 Linux 在访问文件的时候,都要调用操作系统,因为你只要向文件里去写,本质都是向操作系统去写,都会转化成文件描述符,都要通过系统调用。所以各个语言的 IO 库的本质都是将系统调用封装了一遍。

直接用不好吗?为什么要封装?
直接用 Linux 系统调用,能在 Windows 下用吗?系统不一样,要是不封装,Linux 的系统调用只能在 Linux 系统下使用,直接用系统调用,不具有很好的跨平台性。
为什么语言要有跨平台性?每个平台下对应的是成千上万的用户,是要有人用的。跨平台性是在提升潜在的用户数!
所以,就不能让用户直接使用系统调用,库函数直接将系统调用各自封装一份,适配各个 OS,C 语言要将操作系统平台的各种常见的系统调用各自封装一份,不同平台使用的时候你直接迁移过去就行,这就支持了很好的跨平台性。所以你安装的时候会有 Windows 版、macOS 版、Linux 版,虽然接口不一样,但是底层封装的库不一样。
所以 C/C++、Java、Python 每一种语言都要搞一个自己的文件操作,本质都是为了跨平台性而对系统调用的封装。
3. 深入理解重定向
3.1 文件描述符的分配规则
文件描述符的分配规则是什么?用一个代码来体现:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(0);
int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fda: %d\n", fda);
int fdb = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fdb: %d\n", fdb);
int fdc = open("log3.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fdc: %d\n", fdc);
return 0;
}
如果不关掉是 3,4,5。将 0 关掉,0 文件描述符的位置会被占掉。对于 0、1、2 关掉,都会被占用掉。

所以,分配规则是:在文件描述符表数组中,找最小且没有被使用的 fd。
3.2 深入理解重定向
3.2.1 输出重定向的本质
但是把 1 关掉(标准输出),就没有输出了。本来应该被打印出来的内容,竟然内容跑到了 log1.txt 里面。

像这样,原本要输出在显示器上的内容,突然跑到了 log1.txt 中,这个叫什么?--输出重定向!
可是为什么会触发这样的问题呢?
首先,三个打印的 printf 函数是 C 标准库中的函数,其本质上是对 stdout 打印,其实就相当于调用 fprintf(stdout, ...),stdout 封装的是文件描述符 1。
其次,上面我们说,实际上进程管理内存级文件是用指针指向文件描述符表。原本指向 1 的内容被关掉,在数组层面上也就是内容被释放然后置为空了。后面你创建文件的时候,新建的文件就按照规则分配到了 1 的位置。但问题在于 stdout 底层封装的文件描述符为 1 这一点没有改变,但是 1 所对应的文件已经变了,所以向显示器文件写入就变成了向你补进去的 log1.txt 文件内写入。
属于是狸猫换太子,"芯"变了,语言层只认数字 1。这就是这里发生输出重定向的本质!
重定向的本质:其实就是只改变数组特定下标内的内容。
3.2.2 追加重定向的本质
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(1);
int fda = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
return 0;
}

本质区别是打开方式不一样(O_TRUNC vs O_APPEND)。
3.2.3 输入重定向的本质
先引入一个新代码,从键盘中获取字符并输出:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
char buffer[64];
fgets(buffer, sizeof(buffer), stdin);
printf("%s\n", buffer);
return 0;
}

多输入了一个回车,空了一行没毛病。我们修改代码:
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(0);
int fda = open("log1.txt", O_RDONLY);
printf("fda: %d\n", fda); // 0
char buffer[64];
fgets(buffer, sizeof(buffer), stdin); // stdin->0,只认数字 0
printf("%s\n", buffer);
return 0;
}
预先要往 log1.txt 中写点东西。

本来应该从键盘中读数据,现在转向了从文件中读取数据,这个叫做输入重定向!
3.3 dup2 系统调用
上述,我们使用系统调用模拟得 出了重定向的实现原理。但是上述方法很粗糙:能不能直接将数组里面的内容拷贝到对应的下标处实现重定向?这样就不用关了。
关于对已经打开的文件进行重定向,OS 为我们提供了系统调用:

使用说明:这里拷贝的是文件描述符数组下标的内容,newfd 将来是 oldfd 的一份拷贝,oldfd 拷贝到 newfd 本质上是 newfd 消失的内容,fd 对应的就是 oldfd。
3.3.1 输入重定向(使用 dup2)
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fda = open("log1.txt", O_RDONLY);
printf("fda: %d\n", fda); // 3
dup2(fda, 0);
char buffer[64];
fgets(buffer, sizeof(buffer), stdin); // stdin->0,只认数字 0
printf("%s\n", buffer);
return 0;
}
3.3.2 输出重定向(使用 dup2)
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fda: %d\n", fda); // 3
dup2(fda, 1);
printf("hahahahahaha\n");
printf("hahahahahaha\n");
printf("hahahahahaha\n");
printf("hahahahahaha\n");
fprintf(stdout, "hhhhhhhhhhhh\n");
return 0;
}

可以看出,既没有关闭,也实现了重定向。
3.3.3 追加重定向(使用 dup2)
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
int fda = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("fda: %d\n", fda); // 3
dup2(fda, 1);
printf("lalalalalala\n");
return 0;
}

重定向的本质是进行数组内容的拷贝。
那么我们之前学的:
bash
echo "aaaaaa" >> log1.txt
算什么?比命令更早执行的是 shell,shell 是可以分析命令行字符串的,所以系统可以通过 >> 直接得到文件名,再进行相应的程序替换。
程序替换,会不会影响进程打开的文件?答案是不会的。
4. 理解一切皆文件
首先,在windows中是文件的东西,他们在Linux中也是文件;其次一些在windows中不是文件的东西,比如进程、显示器、键盘这样的硬件设备也被抽象成了文件,我们可以使用访问文件的而方式访问他们获得信息;甚至管道也是文件,将来我们要学习网络编程中的Sorket(套接字)这样的东西,使用的接口和文件的接口也是一样的。
可以使用结构体对硬件进行管理,站在方法的角度,对硬件进行读写都叫做 I/O。
我们打开硬件也要有对应的 struct file,struct file 里面的函数指针就是实现一切皆文件的底层原理,在我们看来,我们操作每一种硬件的方式都是相同的。进程在访问文件的时候是使用文件描述符访问 file 对象的,在进程角度看,一切皆文件的底层含义就是,一切皆 struct file 对象 。进程是用户,用户也认为一切皆文件。

实际上它是用 C 语言模仿多态的形式。
5. 缓冲区深度剖析
5.1 文件读写的基本过程与数据流转
文件打开和读写的基本过程:
- 读文件要求将数据载入到内核级缓冲区,如果进程在读取文件的时候发现文件并没有在内核级缓冲区中,那么操作系统会将这个进程阻塞住,然后自己将数据从磁盘搬到缓冲区中。
read函数本质是拷贝函数,将文件数据从内核级缓冲区中拷贝到用户级缓冲区buffer中。 - 写文件,你需要向缓冲区中写,写入也就是拷贝到文件内核级缓冲区中,
write函数的本质也是拷贝。 - 拷贝到文件内核级缓冲区中,OS 会自动刷新。
- 从磁盘加载或者刷新用的是一切皆文件的读写方法,本质上也是一个 I/O 的过程。
- 如何修改?在操作系统中,你就算要修改也需要先将内容先写到缓冲区中,然后再刷新。系统中更改文件读写位置的函数:
lseek,更改读和写位置的偏移量,其实也就是更改文件读写的位置:

修改其实也是先加载、再修改、再刷新。
在文件 I/O 中,大部分情况下都是要先加载,再操作、再刷新。内容的拷贝工作,必须是内存级的。数据流转的本质其实也就是拷贝。
一段测试代码:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
const char *filename = "log.txt";
int fd = open(filename, O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
char buffer[1024]; // 用户级缓冲区
ssize_t n = read(fd, buffer, sizeof(buffer) - 1);
if(n > 0)
{
buffer[n] = 0; // 0 == '\0'
printf("buffer: %s\n", buffer);
}
close(fd);
return 0;
}

用户级缓冲区最大的特点是可以通过指针去访问里面的内容。
调用系统调用是有成本的,一次
read就是一次系统调用。说人话也就是比较慢。
5.2 用户缓冲区 vs 语言级缓冲区 vs 内核缓冲区
5.2.1 用户缓冲区->语言级缓冲区->内核级别缓冲区
理解一下为什么慢:我们在使用 malloc 或者 new 时必须调用系统调用,底层封装 brk 等系统调用来帮助我们做空间申请,操作系统腾空间的这个过程是需要花费时间的。vector 的两倍扩容能有效地减少系统调用的次数。
如何解决慢这个问题?核心思想就是尽可能的减少系统调用的次数,减少 read 或者 write 的次数。
有没有一种方式能同时让用户写入,并减少系统调用的方法?C 语言就出场了。我们以前所说的缓冲区,本质不是内核缓冲区,而是语言级缓冲区 (类似于菜鸟驿站送快递,你将你的快递送达驿站,驿站是不会立马送出的,肯定要凑凑数才划算的嘛),提高 C 语言的 I/O 函数运行效率。fputs 实际上就是先将内容从用户级缓冲区加载到语言缓冲区中,等到一定程度再从语言缓冲区加载到内核缓冲区中。
输入输出缓冲区在哪里?我们输入内容实际上就是将内容拷贝到 FILE 结构体当中,后续再刷新到缓冲区中减少我们系统调用的次数。所以缓冲区在每一个文件的 FILE 对象中 。
FILE 对象在哪里创建的?在 fopen 内部创建的。所以 fclose 到底在做什么?
- 刷新缓冲区
- 关闭文件描述符
- 释放指针所指向的
FILE对象
所以调用fputs快还是调用write快?肯定是调用fputs快,从缓冲区中读写。
C++ 中的 I/O 流本质上是类,类里面必然包括:
- 文件描述符
- 缓冲区
5.2.2 内核缓冲区
细节一:只要把数据从用户缓冲区,拷贝到了内核文件缓冲区,就相当于交给了硬件。
细节二:客观上,就是写给了 file 对应的文件内核缓冲区 -> OS -> 磁盘。
OS 有自己的刷新策略:
- 立即刷新
- 等 OS 不忙了,再自主刷新
若是要求 OS 立即刷新,我们也提供了系统调用fsync,要立即把缓冲区的内容刷新到硬件上。

在内核态将数据同步到外部存储设备上。
查看标准库的源代码:
bash
vim /usr/include/stdio.h
5.3 标准IO提供了三种缓冲方式
5.3.1 全缓冲
这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
5.3.2 行缓冲
在行缓冲情况下,当在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准 I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行 I/O系统调用操作,默认行缓冲区的大小为1024。
5.3.3 无缓冲
无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。
除了上述列举的默认刷新方式,下面特殊情况也会引发缓冲区的刷新:
- 缓冲区漫时
- 执行flush语句
- 进程结束
5.4 理解语言级缓冲区的刷新策略与内核缓冲区的刷新机制
5.4.1 实验一:重定向后直接关闭文件描述符
c
#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(1);
int fda = open("log1.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
printf("fda: %d\n", fda);
close(fda);
return 0;
}

这个时候,问题来了,重定向到 log1.txt 的内容怎么没有了?
上述内容被写到了 stdout 的缓冲区里,直接调用 close,close 会关闭文件描述符,是一个系统调用。还没来得及刷新,调用 write 写给操作系统,你就直接 close 了。所以进程结束时,想把内容刷新到外设上,你这就刷不过去了,write 出现了系统调用报错,因为文件描述符被关掉了,它是空的。
所以在关闭之前刷新一下就可以看到了:
c
fflush(stdout); // 强制性将缓冲区中的内容通过系统调用写到内核里
语言级缓冲区刷新的本质是
write(fd),拷贝到特定文件内核缓冲区里。再往深了讲,是将数据交给操作系统,潜台词是不一定写到磁盘文件中。
语言级缓冲区的刷新方式:
- 进程结束时会自动刷新
- 如果目标文件是显示器,刷新策略是行刷新(因为人看东西是一行一行看的)
- 普通文件,一般是全缓冲(缓冲区写满了,才会刷新)
5.4.2 实验二:进程终止时的缓冲区行为
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
printf("hello word");
sleep(3);
_exit(0);
return 0;
}
_exit(0) 让 printf 的数据消失,是因为缓冲区在进程内部的语言层,而不在操作系统内核里。进程一死,没刷新的缓冲区数据就跟着没了。
5.4.3 实验三:fork 后的缓冲区现象
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
int main()
{
// C 库函数
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char *s = "hello fputs\n";
fputs(s, stdout);
// 系统调用
const char *ss = "hello write\n";
write(1, ss, strlen(ss));
fork();
return 0;
}

为什么上面出现了三个打印结果,下面出现了七个?
首先重复的是 C 库函数打印的方法,printf和fwrite库函数+会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后,进程退出之后,会统一刷新,写入文件当中。但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
内核的系统调用为什么没有刷新两次?因为它把数据已经写给操作系统了,所以只会刷新一次。要明白重定向的潜台词:重定向改变了缓冲区的刷新策略:从"行缓冲"变成了"全缓冲"。
刷新缓冲区的数据本质就是修改缓冲区的数据。
5.4 补充:如何理解标准错误
如何理解标准输出?编程的本质还是进程对数据进行处理,进程->是完成任务的,完成任务实际上是对数据进行加工处理,对数据进行处理就有一个数据源头,和一个处理结果,出错就是方便debug。
标准输入 0 stdin
标准输出 1,stdout
标准错误 2 ,stderr 显示器
2存在的意义是分离错误信息,验证代码
C
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{
//标准输出:1
printf("这是一个正常消息\n");
fprintf(stdout,"这也是一个正常的日志消息\n");
const char *s1 = "这是一个正常消息,write\n";
write(1,s1,strlen(s1));
//标准错误:2
fprintf(stderr,"这是一个错误消息\n");
const char *s2 = "这是一个错误消息,write\n";
write(2,s2,strlen(s2));
perror("perror,hello");
return 0;
}

为什么重定向之后,为什么normal.txt中就只有正确信息

因为你重定向的时候只是改变了标准输出,将文件标识符为1的转到了文件normal.txt中,所以normal.txt只会保存标准输出的内容
我们可以这样:
bash
./text 2>err.txt

(注意不能写空格呦~)
上述指令的意思就是,将err.txt的文件描述符打开之后,内核的文件对象拷贝到2号下标里,所以这个时候你打印的都是正常消息,错误消息都被写到err.txt中了,所以第一个示例的重定向是少了一个数字1的:
二者综合起来就相当于这个:
bash
./a.out 1>normal.txt 2>err.txt
理解分离错误信息:
我们在使用程序的时候,会打印大量的输出,可以将大量的调试信息写到标准错误上,正常的执行写道标准输出上,想看到什么信息,就把什么信息重定向到文件里。
bash
./text >ok.txt 2>&1
1重定向到ok,1里面的内容也写到2里面
6. 实战:手写简易 Stdio 库
6.1 头文件定义(mystdio.h)
c
#pragma once
#include <stdio.h>
#define SIZE 1024
#define NON_BUFFER 1 // 1
#define LINE_BUFFER 2 // 10
#define FULL_BUFFER 4 // 100 位图的形式表示
#define MODE 0666 // 默认模式
typedef struct myFILE
{
int fd; // 文件描述符
int flags; // 打开模式是什么?
int flush_mode;
char outbuffer[SIZE]; // 缓冲区
int pos; // buffer 位置
int cap;
} myFILE;
myFILE *myfopen(const char *pathname, const char *mode); // r, w, a, r+, w+
void myfclose(myFILE *fp);
int myfputs(const char *str, myFILE *fp);
void myfflush(myFILE *fp);
6.2 核心逻辑实现(mystdio.c)
c
#include "mystdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#define TRY_FLUSH 1
#define MUST_FLUSH 2
myFILE *myfopen(const char *pathname, const char *mode) // r, w, a, r+, w+..
{
int fd = -1;
int flags = 0;
if(strcmp(mode, "r") == 0)
{
flags = O_RDONLY;
fd = open(pathname, flags);
}
else if(strcmp(mode, "w") == 0)
{
flags = O_WRONLY | O_CREAT | O_TRUNC;
fd = open(pathname, flags, MODE);
}
else if(strcmp(mode, "a") == 0)
{
flags = O_WRONLY | O_CREAT | O_APPEND;
fd = open(pathname, flags, MODE);
}
else
{
// TODO
}
if(fd < 0)
return NULL;
myFILE *fp = (myFILE *)malloc(sizeof(myFILE));
if(fp == NULL)
return NULL;
fp->fd = fd;
fp->flags = flags;
fp->flush_mode = LINE_BUFFER;
fp->cap = SIZE;
fp->pos = 0;
return fp;
}
static void myfflushcore(myFILE *fp, int flag)
{
if(fp->pos == 0)
return;
if((fp->flush_mode & LINE_BUFFER) || (flag & MUST_FLUSH))
{
// "abcd\n"
if((fp->outbuffer[fp->pos-1] == '\n') || (flag & MUST_FLUSH))
{
// 写到内核中
write(fp->fd, fp->outbuffer, fp->pos);
fp->pos = 0; // 清空缓冲区
}
}
else if(fp->flush_mode & FULL_BUFFER)
{
// if(fp->pos == fp->cap)
// 只有当缓冲区写满(pos >= cap) 或者 强制刷新时才写入
if (fp->pos >= fp->cap || (flag & MUST_FLUSH))
{
write(fp->fd, fp->outbuffer, fp->pos);
fp->pos = 0; memset(fp->outbuffer, 0, SIZE);
}
}
else if(fp->flush_mode & NON_BUFFER)
{
// write();
}
}
void myfflush(myFILE *fp)
{
myfflushcore(fp, MUST_FLUSH);
}
int myfputs(const char *str, myFILE *fp)
{
if(strlen(str) == 0)
return 0;
// step 1: 向文件流里面写,本质是:文件缓冲区 -> 拷贝
memcpy(fp->outbuffer + fp->pos, str, strlen(str));
fp->pos += strlen(str);
// step 2: 如果条件允许,可以自己刷新
myfflushcore(fp, TRY_FLUSH);
return strlen(str);
}
void myfclose(myFILE *fp)
{
// 1. 强制刷新到内核
myfflush(fp);
// 2. 强制刷新到磁盘(不是必选的)
fsync(fp->fd);
// 3. 关闭文件
close(fp->fd);
// 4. free
free(fp);
}
6.3 测试与验证(main.c)
c
#include "mystdio.h"
#include <unistd.h>
int main()
{
myFILE *fp = myfopen("log.txt", "a");
if(fp == NULL)
{
printf("myfopen error!\n");
return 0;
}
const char *msg = "hello";
int cnt = 10;
while(cnt--)
{
myfputs(msg, fp);
sleep(1);
printf("debug: outbuffer = %s, pos = %d\n", fp->outbuffer, fp->pos);
}
myfclose(fp);
printf("write file done!\n");
return 0;
}
总结
核心结论
- 文件 = 内容 + 属性,对文件的操作就是对这两类的操作。
- 文件描述符本质是数组下标,0、1、2 默认被 stdin/stdout/stderr 占用。
- 重定向的本质是改变数组特定下标内的内容 (
dup2实现数组内容的拷贝)。 - 一切皆文件的本质是 C 语言模拟多态 (
struct file_operations函数指针)。 - 语言级缓冲区是为了减少系统调用次数,提高 I/O 效率。
- 封装的本质是为了跨平台性。