💘作者:泠沫
💘博客主页:泠沫的博客
💘专栏:Linux系统编程,文件认识与理解…
💘觉得博主写的不错的话,希望大家三连(✌关注,✌点赞,✌评论),多多支持一下!!
目录
🏠 C语言FILE结构体缓冲区
🚀 缓冲区现象
在介绍缓冲区之前,我们先来看这样一段代码:
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg0 = "hello printf\n";
const char *msg1 = "hello fwrite\n";
const char *msg2 = "hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
}
测试结果如下:
从上面的测试可以发现,我们如果直接将数据打印到显示器上,观察到的是正常现象。但是如果我们把数据重定向到文件当中,我们发现C语言的函数被重复打印了两次,而系统接口函数只打印了一次。
对于上述现象,在学习完缓冲区的刷新策略和原理之后,文章最后会给出合理的解释。
🚀 认识FILE结构体
typedef struct _IO_FILE FILE;
-
这就是C语言中的FILE结构体,框起来的就是C语言所对应的各种缓冲区,这里不做深入探究。
-
仔细观察就会发现里面有一个_fileno对象,其实本质上就是Linux操作系统中的文件描述符fd。
-
C语言文件操作函数fwrite在进行写入的时候其实就是把数据写入由FILE结构体所维护的缓冲区当中,后面通过刷新策略在将数据刷新。而我们平时说的缓冲区,就是指语言级别上的缓冲区。
🏠 C语言缓冲区的刷新
🚀 刷新策略
在见识到了缓冲区之后,我先给大家介绍一下缓冲区的刷新策略,方便读者能够对于缓冲区的原理有一个更为清晰的认知。
缓冲区的刷新方式一般分为三种:
- 直接刷新:就是先写到缓冲区,然后就直接刷新到文件(这里其实是先刷新到系统缓冲区)。
- 行刷新:遇到换行符"\n",就把之前的数据进行刷新。
- 满刷新:只有当缓冲区写满了,才开始刷新。
对于C语言的显示器而言,一般都采用的是行刷新。而对于文件来说,一般都是采用满刷新。
但是有两种特殊情况:
- 调用fflush函数,会强制刷新缓冲区。
- 进程退出时也会强制刷新缓冲区。
🚀 刷新原理
- 首先,判断根据刷新策略是否要刷新。
- 如果要刷新,则调用系统接口write把数据写到Linux系统缓冲区,然后由操作系统决定何时刷新到外设。
- 所以,其实fflush本质上也是封装了write接口。只不过和fwrite相比较,fflush不需要做判断。
🚀 调用系统接口封装C语言的文件函数
这里的代码封装过程并不严谨,主要是为了让读者更清楚的观察到缓冲区,对缓冲区由深刻的认识,而并不是真正手写C语言文件函数底层实现。
此处默认是行刷新。代码主要是为了体现上层函数和底层接口的关联,并不是来手动造轮子。
代码实现的主要逻辑:
- 定义一个结构体FILE_,里面由int fno表示文件描述符,int refresh_way表示刷新方式,char buffer[1024]表示缓冲区,int capacity和size表示容量和以使用空间
- fopen对open进行封装,fopen的打开方式const char *mode与open的打开方式int flags一一对应,打开文件成功后创建一个FILE_结构体,并对其初始化。
- fwrite先把数据拷贝到FILE_里面的缓冲区buffer中,然后再根据刷新方式判断,如果要刷新,则调用write接口将数据写入到文件。
- fflush则是直接封装write接口,把FILE_缓冲区buffer里面的数据写入文件。
- flcose则是先调用fflush,然后再调用write系统接口。
#pragma once
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<assert.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
//刷新策略-标记位
#define REFRESH_NOW 1
#define REFRESH_LINE 2
#define REFRESH_FULL 4
typedef struct FILE_
{
int fno;//文件描述符
int refresh_way;//缓冲区刷新方式
char buffer[1024];//缓冲区
int capacity;//缓冲区容量
int size;//缓冲区已使用空间
}FILE_;
FILE_* fopen_(const char *path, const char *mode);
void fwrite_(const char *mesg, int len, FILE_* fp);
void fflush_(FILE_* fp);
void fclose_(FILE_* fp);
#include"file.h"
FILE_* fopen_(const char *path, const char *mode)
{
int flags = 0, fd = 0;
if(strcmp(mode,"r") == 0) flags |= O_RDONLY;
else if(strcmp(mode,"w") == 0) flags |= (O_WRONLY | O_CREAT | O_TRUNC);
else if(strcmp(mode, "a") == 0) flags |= (O_WRONLY | O_CREAT | O_APPEND);
if(flags & O_RDONLY) fd = open(path,flags);
else fd = open(path,flags, 0666);
if(fd < 0)
{
perror("fopen: ");
return NULL;
}
FILE_* fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp);
fp->fno = fd;
fp->capacity = fp->size = 0;
fp->refresh_way = REFRESH_LINE;
return fp;
}
void fwrite_(const char *mesg, int len, FILE_* fp)
{
memcpy(fp->buffer + fp->size, mesg, len);
fp->size += len;
if(fp-> refresh_way & REFRESH_NOW)
{
fflush_(fp);
}
if(fp->refresh_way & REFRESH_FULL)
{
if(fp->size == fp->capacity)
{
fflush_(fp);
}
}
else if(fp->refresh_way & REFRESH_LINE)
{
if(fp->buffer[fp->size-1] == '\n')
{
fflush_(fp);
}
}
}
void fflush_(FILE_* fp)
{
if(fp->size > 0)
{
write(fp->fno, fp->buffer, fp->size);
fp->size = 0;
}
}
void fclose_(FILE_* fp)
{
fflush_(fp);
close(fp->fno);
}
#include "file.h"
int main()
{
FILE_ * fp = fopen_("log.txt","w");
if(fp == NULL) return 1;
const char *mesg = "hello log.txt\n";
fwrite_(mesg, strlen(mesg),fp);
fclose_(fp);
return 0;
}
🏠 Linux系统缓冲区的刷新
在见识到了语言级别的缓冲区之后,我们可能会认为fwrite和write是写入函数,其实我更倾向于这两个函数是拷贝函数。因为fwrite是把数据拷贝到缓冲区当中,并没有写入到文件。write虽然是系统接口,但是其实这个接口函数也不会将数据从缓冲区直接写入到文件中。
由冯诺依曼体系结构我们知道,CPU并不会直接和外设有直接的交互,CPU一般只和内存交互。因为CPU访问外设的速度过慢。所以,其实write也只是将数据从语言级别的缓冲区拷贝到系统级别的缓冲区。而文件一般都是存放在磁盘上磁盘属于外设,操作系统访问外设的效率是很低的,所以为了提高效率,操作系统也有自己的缓冲区,由操作系统自己维护。
当然,如果我们非要把数据立即刷新到文件(外设)中,操作系统也给我们提供了对应的接口。
下面是fsync接口的介绍:
该接口函数的作用是强制操作系统将系统缓冲区的数据立即刷新到指定的文件当中。
🏠 缓冲区实验现象的解释
到这里相比读者对于缓冲区有了一定的认识与理解,接下来我们再来解释一下最开始的实验现象:
- 如果我们不在代码最后面加上fork()创建子进程,那么不论是将数据打印到显示器上还是重定向到文件当中,所有的数据都只会打印一份。
- 如果我们创建子进程,什么都不做。对于打印到显示器上,缓冲区采用的是行刷新,所以在子进程之前,缓冲区的数据就已经被刷新到显示其上了,所以不会有任何影响,那么每一个打印函数都会执行且数据都只打印一份。
- 如果我们创建子进程,什么都不做。这个时候我们采用重定向,将数据打印到文件当中。那么缓冲区的刷新策略由原来的行刷新变成了满刷新,仅仅两个打印函数所打印的数据并不能将缓冲区写满,所以即使printf和fwrite函数已经执行完了,但是这两个函数仅仅是将数据拷贝到缓冲区当中。write是把数据拷贝到系统缓冲区中。对于子进程来说,他的进程PCB几乎就是以父进程的进程PCB为模板构建的,所以对于语言级别的缓冲区里面的数据子进程也看得到,也就是说两个进程同时拥有缓冲区的数据。最后在进程结束的时候,一旦进程退出,那么缓冲区被强制刷新,刷新缓冲区本质上就是对数据做修改,此时为了保证进程的独立性,先退出的进程会发生写时拷贝,那么两个进程就都各自拥有了一份数据,所以使用C语言打印的数据最终会被打印两份。
本次分享到这里也就结束啦,如果各位看官有收获的话还希望给个三连支持一波,有什么意见或者建议也请评论区指出,谢谢大家!💛💛💛