** 前言:**本篇文章主要讲解文件缓冲区。 讲解的方式是通过抛出问题, 然后通过分析问题, 将缓冲区的概念与原理一步一步地讲解。同时, 本节内容在最后一部分还会带友友们模拟实现一下c语言的printf, fprintf接口, 加深友友们对于缓冲区的理解。
** ps: 本节内容适合了解linux进程和linux文件重定向的友友们进行观看。 **
缓冲区概念——使用close引出
讲述缓冲区, 我们需要先看一下下面的接口:
对于上面的接口, 我们可以预测一下——就是打印4行内容, 分别是**hello printf**、**hello fprintf**、**hello fwrite**、**hello write**:
** 如图,代码运行结果符合我们的预期。对于上面的代码我们知道, 对于c语言的接口, stdoutFILE文件流里面就封装了1号文件描述符。 他们的底层一定是调用了write函数。**
对于上面的函数,我们重定向到文件是这样, 和直接在显示器上面输出是一样的。
- 但是, 如果加上close(1), 将显示器文件关闭, 那么情况就有些不同了, 下面是有\n的:
- 下面是没有\n的:
如果没有了\n, 对于c语言的打印接口, 就不能输出了。 **如下图, 只输出了系统调用write:**
我们知道对于上面printf、fprintf、fwrite来说, 这三个是c语言接口, 他们的底层一定是封装的write接口。 这就有问题了, **为什么printf、fprintf、fwrite的底层是write, 而write能够打印数据, printf、fprintf、fwrite都不能打印数据呢?**
友友们如果学习过进度条, 那么就会知道一个缓冲区的概念。 这个缓冲区遇到\n或者缓冲区满了之后就会刷新自己。
**一般情况下, 我们使用printf、fprintf、fwrite不是直接写向显示器, 而是先写向缓冲区**。 等到遇到\n或者缓冲区满了之后再刷新缓冲区,将数据刷新到缓冲区。
也就是说, 对于上面的这个代码来说, 当程序运行到close(1)前一行,** 那么数据就已经写到缓冲区了。 只是这个缓冲区一定不在操作系统内部, 这个缓冲区不是系统级别的缓冲区**!!!
具体的底层逻辑关系如下:
我们使用的c语言接口的时候, 如果是将数据直接拷贝到了系统级别的缓冲区中, 那么等到close(1)的时候, 这个close(1)就能找到对应的文件struct file, 然后找到对应的系统级别的文件缓冲区, 将缓冲区内容刷新到磁盘里面。 也就是说, 如果c语言接口是将数据只拷贝到系统级别的缓冲区, 那么我们应该也能看到c语言接口的结果, 而事实上并没有。 而write是系统调用接口, 是直接写到系统级别的文件缓冲区, 那么当close(1)的时候, 就能直接将数据刷新到磁盘里面。 **我们平时口中的缓冲区, 不是这个系统级别的缓冲区, 而是语言提供的, 这里也就是c语言提供的一个缓冲区, 这个缓冲区是一个用户级别的缓冲区**。 也就是说, **c语言的printf, fprintf等接口是将数据写到语言级别的缓冲区中, 然后等到某一个时机就调用write接口, 将数据刷新到系统级别的缓冲区中,最后再刷新到磁盘中**。 ![](https://i-blog.csdnimg.cn/direct/14539aaaddc54214ad62a5fed5810978.png)
所以, 当我们printf、fprintf等等的时候, 这些数据其实是保存在语言级别的缓冲区, 他们还没有进入系统级别的缓冲区, 当close(1)的时候。1号文件struct file, 系统文件缓冲区都被关掉了。 就无法再向磁盘写入数据了, 也就没有显示结果。
显示器对应的刷新方案是行刷新, 当我们在c语言接口要打印的数据中加上\n的时候, 如下图, 那么即便有close(1), 但是对于用户缓冲区来说, 每一行都会被刷新到系统级别的文件缓冲区。 所以可以打印出来。 用户缓冲区刷新的本质就是将数据通过1 + write写到内核中。
exit和用户缓冲区
我们之前说过exit和_exit, exit是封装的上层接口, 而_exit是系统调用。 两者的区别是_exit退出前不会刷新缓冲区(我们口中的缓冲区, 都是用户级缓冲区), 而exit会刷新缓冲区(我们口中的缓冲区, 都是用户级缓冲区)。 这是因为_exit身为系统调用, 它在用户层的下层, 它根本看不到用户级缓冲区。 也就没办法刷新它, 而exit处在用户层, 它底层一定是先刷新缓冲区flush, 然后再调用系统调用_exit。
从上面我的论述中就可以发现, 其实当数据到达内核的时候, 那么这个数据就可以到达硬件了。 ——这也是我们目前认为的, 只要是将数据刷新到了内核, 数据就可以到达硬件了!!!
缓冲区刷新方案
缓冲区刷新问题:——这里的缓冲区考虑的是语言层, 也就是用户层缓冲区。
- ** 无缓冲: 直接刷新, 不管printf, fprintf等接口打印的数据是什么, 直接刷新。**
- ** 行缓冲: 不刷新,直到遇到\n再刷新。**
- ** 全缓冲: 缓冲区满了再刷新, 不满的话, 无论如何都不刷新。**
所以整个的刷新流程如下图:
在向显示器中写入的时候, 采用的是行缓冲。
在向普通文件中写入的时候, 采用的是全缓冲。
为什么要有用户缓冲区
进程退出的时候也会刷新缓冲区, 为什么要有这个缓冲区呢?
首先就是效率层面——这里可以使用一个例子来说明这件事, 就比如我们给朋友送东西, 如果没有快递公司, 我们就必须亲自去送东西给朋友, 但是现在有了快递公司, 快递公司就可以帮助我们去送快递, 我们节省的这些时间就可以去做自己的事情。——这里面的快递公司就是缓冲区, 我们就是用户。 所以, 缓冲区解决的是谁的效率问题?——其实就是用户的效率问题。 (也就是使用c语言的人的效率问题)
其次是语言设计层面——拥有缓冲区的目的是**为了配合c语言接口:printf,fprintf的输出格式化。**——这里我们需要想一个问题, 就是我们在显示器上面打印的789, 这样的数字, 打印的是字符呢? 打印的还是数字呢? ——答案是打印的字符789。 也就是说, 将789转化成字符7, 字符8, 字符9, 然后再打印到显示器上面。 当数据打印的时候, 先将数据打印到用户缓冲区。 当某个时机的时候, 就将用户缓冲区的数据打印到硬件里面。 由于这种数据进入, 数据流出, 很像一条河流, 所以用户缓冲区也被叫做流。
FILE
请问, FILE是属于用户呢? 还是属于操作系统呢? 答案是用户, 语言都是属于用户的。
知道这个之后, 我们就可以知道, 对于fopen来说
如果使用fopen打开了一个文件, 那么就会获取这个文件的文件描述符fd。 然后再malloc一块内存空间保存在FILE, 这个fd和malloc的内存空间都保存在FILE里面!!也就是下面这张图:
fork和缓冲区读写
现在,我这里有另外一个关于fork函数的问题。 我们看下面这串代码:
我们生成程序后, 将数据重定向到文件中。 如果按照我们以往的经验的话, 这里我们预测会打印四行内容, 分别是**hello printf、hello fprintf、hello fwrite、hello write**。
但是, 我们实际运行的结果如下:
如上图, 我们可以发现c语言接口的打印都被打印了两次, 只有write系统调用被打印了一次。
这是因为当使用重定向, 重定向到了文件之后, 缓冲区的刷新方案就变成了全缓冲, 遇到\n不再刷新, 而是等缓冲区被写满之后才刷新。
那么我们下载来证明一下重定向到文件是全缓冲的
首先我们写下面这个程序;
这个程序如果按照我们的猜想。 前三秒不会打印任何数据, 这些数据都会被打印到了缓冲区之中。 当三秒过后, write会被打印。然后再五秒所有数据才会全部被打印。 我们先打开监视窗口进行观察:
然后运行这个程序, 就会得到:
那么这是为什么呢?——首先我们知道, 当我们三个c语言接口执行完的时候, 数据都被写到了缓冲区中。 而缓冲区也是在进程里面的, 所以**当fork创建子进程后, 再刷新缓冲区, 而刷新缓冲区就相当于数据的修改, 需要进行写时拷贝**。 所以就有两份缓冲区, 两个缓冲区的数据都被刷新后就产生了上面的现象!!!
模拟实现c语言标准库
为了模拟实现c语言的标准库, 我们创建三个文件。 这里的_my_func.h就相当于stdio.h这样的标准库, 而_my_func.c就是用来模拟实现c标准库接口。
我们打开_my_func.h, 首先做好预备工作。 先让该头文件不可重复包含, 也就是下面的**ifdef, define, endif**。 然后再包含一个头文件string.h。 我们先来简单的实现以下三个函数, **_fopen, _fwrite, _fclose**。
只包含fd的接口——不带缓冲区
下面是代码:
#include<string.h>
//定义缓冲区最大长度
#define SIZE 1024
//flag用来表示刷新方案
#define FLUSH_NOw 0
#define FLUSH_LINE 1
#define FLUSH_ALL 2
typedef struct IO_FILE
{
int fileno; //文件fd
int flag;
//输入缓冲区
//char inbuffer[SIZE];
//int in_pos;
//输出缓冲区
char outbuffer[SIZE];
int out_pos;
}_FILE;
_FILE* _fopen(const char* filename, const char* mode);
int _fwrite(_FILE* fp, const char* s, int len);
void _fclose(_FILE* fp);
void _fflush(_FILE* fp);
#endif
然后我们定义FILE类型的结构体。 但是为了区分标准库。 我们这里改成_FILE(以后的函数等等为了区分都会加上"_")。
对于这个结构体里面的内容, 根据我们前面讲到的只是可以知道, 这里面一定有两个字段一个是文件描述符fd, 一个是文件缓冲区。 这里我们先将fd包含进来。如下图:
然后我们就可以先简单的实现一下fopen, fwrite, fclose函数了。
fopen
如下为代码:
_FILE* _fopen(const char* filename, const char* mode) { //先打开文件 //open函数打开文件, 返回文件描述符。第二个参数是打开方式 //先判断打开文件的方式。确定第二个参数 int f = 0; int fd = -1; //一开始令fd 为-1 if (strcmp(mode, "w") == 0) f = (O_CREAT|O_WRONLY|O_TRUNC); else if (strcmp(mode, "a") == 0) f = (O_CREAT|O_WRONLY|O_APPEND); else if (strcmp(mode, "r") == 0) f = (O_RDONLY); else return NULL; //打开文件, 默认权限是FILEMODE fd = open(filename, f, FILEMODE); //如果fd是-1, 那么直接return。 否则就是正常打开文件。 正常进行操作 if (fd == -1) return NULL; //else _FILE* fp = (_FILE*)malloc(sizeof(_FILE)); if (fp == NULL) return NULL;//如果fp == NULL说明空间不够了。 //else fp->fileno = fd;//让将fd赋值给fp的fd。 return fp; }
fwrite
如下为代码:
int _fwrite(_FILE* fp, const char* s, int len) { //fd, 要写入的字符串, 字节长度 return write(fp->fileno, s, len); }
** fclose**
如下为代码:
//关闭文件 void _fclose(_FILE* fp) { if (fp == NULL) return; close(fp->fileno); free(fp); }
我们自己实现一串代码进行测试, 打开的方式是清空写:
运行结果如下:
** 其实从上面的代码我们就能看到封装的好处。 有了fopen, fwrite这些封装之后, 以后再向文件中写内容, 我们想要修改写入的方式, 就不需要再使用O_RONLY、O_WRONLY这些标志了, 直接使用w, a, r, 然后程序就会自动帮助我们判断如何打开文件!!!**
c语言的跨平台性
** 我们在上面实现的接口是符合linux环境的接口。 如果我们再windows下, 想实现同样的接口, 那么为了能够在windows正常工作,就要实现一份适合在windows下面跑的代码。 同样的macos也是一样的。 这三份代码我们可以使用if else if, endif条件编译。 如果在linux下面就是用linux下面的代码。 如果在windows下面就是用windows下面的代码。 如果在macos下面, 就是用macos下的代码。 这个就叫做c语言的跨平台性!!!**
带缓冲区的c语言接口
我们在我们的接口里面加入缓冲区, 下图是引入缓冲区的方法。图中的in_pos和out_pos是为了标记缓冲区的使用情况。 它指向缓冲区已经使用的最后一个位置。 这个位置的左边是已经使用的, 右边是没有使用的。而且由于我们此次模拟实现的接口用不到输入缓冲区, 所以将输入缓冲区注释掉。
接下来我们修改我们的代码:
**fopen **
首先修改fopen, 要初始化缓冲区, 那么就是将标记缓冲区使用情况的out_pos置为0. 就代表缓冲区没有被使用过。 并且我们要初始化缓冲区的刷新方案。也就是_FILE结构体里面的flag。 我们这里默认初始化为行刷新, 下图的黄色代码就是初始化缓冲区。(只需要在原本的fopen下面添加黄色框框的代码就可以实现)
fwrite
如下图是写方法的含有缓冲区的实现方式。** 就是先将数据拷贝到缓冲区中, 然后判断刷新方式, 是直接刷新, 行刷新还是缓冲区满才刷新。刷新后标记位out_pos置为0.**
下面是代码:
//写方法 int _fwrite(_FILE* fp, const char* s, int len) { //使用memcpy将数据拷贝到输出缓冲区中。 memcpy(&fp->outbuffer[fp->out_pos], s, len); fp->out_pos += len; //判断刷新方式 //直接刷新 if (fp->flag == FLUSH_NOw) { write(fp->fileno, fp->outbuffer, fp->out_pos); fp->out_pos = 0; } //遇到反斜杠n刷新 else if ((fp->flag == FLUSH_LINE) && (fp->outbuffer[fp->out_pos - 1] == ' \n')) { //只要遇到反斜杠n就行刷新 write(fp->fileno, fp->outbuffer, fp->out_pos); fp->out_pos = 0; } else if (fp->flag == FLUSH_ALL && fp->out_pos == SIZE) { write(fp->fileno, fp->outbuffer, fp->out_pos); fp->out_pos = 0; } else return len; return len; }
fclose
关闭文件和之前没有什么区别
测试:
我们可以使用下面这个监控脚本, 以及我们要测试的代码, 来测试一下我们模拟实现的是否正确:
#include"_my_func.h" #include<unistd.h> int main() { _FILE* fp = _fopen("mytest.txt", "a"); if (fp == NULL) return 1; int cnt = 10; const char* mes = "hello linux\n"; while (cnt) { _fwrite(fp, mes, strlen(mes)); sleep(1); cnt--; } _fflush(fp); _fclose(fp); return 0; }
运行结果如下, 因为我们是以行刷新, 并且每一秒都会追加一行。 所以会出现下图的情况。 验证结果我们的代码是正确的。
fflush
fflush这里我们也要模拟一下, 博主模拟是为了应对全刷新的情况(并不是说fflush只是为了应对全刷新而存在的)。因为我们使用全刷新的时候, 写到缓冲区的内容不容易被刷新出来。就如同下图我们已经运行了程序, 但是仍旧刷新不出东西, 也就是没有向文件写入:
** 那么实现fflush, 我们要怎么实现呢?代码如下图:**
代码:
void _fflush(_FILE* fp) { if (fp->out_pos > 0) { write(fp->fileno, fp->outbuffer, fp->out_pos); fp->out_pos = 0; } } //关闭文件 E>void _fclose(_FILE* fp) { if (fp == NULL) return; _fflush(fp);//因为关闭文件要将缓冲区中的东西都放出来。 close(fp->fileno); free(fp); }
我们在运行就能在10秒胡进程退出的时候打出来了。下图就是测试——前十秒没有打印任何东西, 但是后面打印了一串数据。 这是因为进程退出的时候刷新了缓冲区。
以上就是本节的全部内容。 下面是博主的笔记:
版权归原作者 打鱼又晒网 所有, 如有侵权,请联系我们删除。