【Linux内核系列】:深入理解缓冲区
🔥 本文专栏:Linux
🌸作者主页:努力努力再努力wz



★★★ 本文前置知识:
文件系统以及相关系统调用接口
输入以及输出重定向
那么在此前的学习中,我们了解了文件的概念以及相关的系统调用接口,并且我们也知道了输入以及输出重定向的一个原理以及实现,那么今天这篇文章的内容将会着重讲解以及解析用户缓冲区以及有了用户缓冲区这个概念之后,我们可以结合之前所学的系统接口来自己实现一个诸如fopen以及fwrite这样的c库函数,那么废话不多说,进入我们正文的学习
1.引入
那么在正式介绍我们用户缓冲区的概念之前,我们先来看一些场景来引入我们的用户缓冲区:
那么我用c语言写了一段简单的代码,那么代码的逻辑也是十分简单,那么也就是我们用三个c式的库函数也就是printf以及fprintf和fwrite分别向显示器文件当中写入一段字符串,然后最后再调用我们的系统接口write向显示器文件写入一段字符串,然后运行这段代码
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{char* str="hello fwrite\n";char* str1="hello fprintf\n";printf("hello printf\n");fwrite(str,strlen(str),1,stdout);fprintf(stdout,str1);char* str2="hello write\n";write(1,str2,strlen(str2));return 0;
}
那么我们可以在终端看到打印了4次字符串,那么分别对应我们调用的3个库函数以及一个系统调用接口往显示器写入的字符串,那么非常符合我们的预期,是很正常的一个现象,但是我们的场景还没完,这是我们的第一个场景
接下来我们不往显示器做打印,而是输出重定向到一个long.txt的文件当中,那么我们再来打印我们此时重定向的目标文件的文件内容
发现我们往long.txt文件写入了四个字符串和我们之前在显示器打印的内容是一模一样的,其实这个结果也是符合我们的预期,没问题,那么接下来在引入我们的第三个场景
那么在此前的代码的基础上,我们在代码的结尾调用一个fork系统调用接口,那么此时我们在来运行这段修改过的代码,那么根据我们对于fork系统调用的理解,我们调用fork接口,那么会创建一个子进程,那么此时父子进程会共同执行fork调用之后的代码段的内容,但是由于我们在代码结尾调用的fork接口,而我们的写入操作是在fork调用之前就写入完毕,所以我们即使创建了子进程,那么它不会往显示器做任何内容的写入,只能父进程做写入,所以按照预期来说执行这个代码的结果终端还是会打印4个字符串,那么我们接下来就执行这段代码,来验证我们之前的推导
#include<stdio.h>
#include<unistd.h>
#include<string.h>
int main()
{char* str="hello fwrite\n";char* str1="hello fprintf\n";printf("hello printf\n");fwrite(str,strlen(str),1,stdout);fprintf(stdout,str1);char* str2="hello write\n";write(1,str2,strlen(str2));fork();return 0;
}
那么我们发现结果还是符合我们的预期,那么接下来就来引入的第四个场景了,那么此时我们在我们修改的代码的基础上再来输出重定向到我们的long.txt文件当中,那么打印long.txt的文件内容,那么这里我们前三个场景都是符合我们的预期,那么这第四个场景,我们的预期也许就是long.txt文件的内容还是会输出我们之前向显示器文件写入的那4个字符串,那么我们来看一下结果是否是如我们所料:
那么我们发现我们此时long.txt中打印出来的结果不是打印了之前的那四个字符串,而是七个字符串,并且我们发现write写入的字符串只被打印了一次,而其他三个c库函数也就是printf和fwrite以及fprintf写入的字符串分别各自打印了两次,而之所以会出现场景四这样的现象,那么就和我们这篇文章要讲的用户缓冲区有关
那么一旦理解了用户缓冲区的概念,那么第四个场景的解释就顺理成章了
2.缓冲区
那么观察上文的第四个场景,我们知道我们的c库函数所写入的字符串被打印了2次,而系统接口write则是被正常打印一次,而之所以c库函数会出现这种情况,那么是因为在语言层面上,c语言定义了一个缓冲区,那么我们诸如fprintf以及fwrite的c库函数,我们知道他们在实现的时候,底层一定封装了我们的系统调用接口也就是write系统调用接口,但是我们fprintf以及fwrite库函数在获取到向显示器等文件写入的数据时,它不会立马就交给write接口来做写入,而是先保存到它所定义的一个缓冲区当中
那么现在我们无非就有两点疑问:
缓冲区是什么?为什么要有缓冲区?
那么首先解释第一个问题,缓冲区是什么,那么我们得先从我们的fopen函数的原理以及实现来说起,那么fopen函数的功能就是用来打开一个文件,而我们知道fopen函数实现会封装open系统调用接口,那么open系统接口想必我们一定很熟悉了,那么它会首先为打开的文件定义一个file结构体,然后扫描打开该文件的进程的文件描述表也就是指针数组,然后找到一个位置没有指向任何一个file结构体,然后将其指向该创建的file结构体对象,然后返回该位置的数组下标,也就是文件描述符
而fopen函数内部调用了open接口,那么它必然是内部先调用open接口使其为该文件创建一个操作系统层面上的内核的一个file结构体,然后获取到该文件的文件描述符,然后接着会为该文件定义一个数组,而没错,该数组就是我们保存输入或者输出内容的缓冲区,那么它会申请开辟一个固定大小的一个动态数组,获取到指向该数组的指针也就是数组首元素的地址,那么此时fopen函数内部会定义一个struct FILE结构体,那么该结构体会封装该文件的文件描述符以及将指向该数组首元素的指针以及当前数组保存的有效内容的长度和缓冲策略等等,那么它会在堆上申请该FILE结构体并且进行相应属性的初始化,其中就包括文件描述符以及缓冲区的大小,然后返回该FILE结构体的地址
所以fopen返回的结构体是我们语言层面上定义的一个结构体,其中封装了该文件的文件描述符以及缓冲区的地址以及相关属性等,那么不要与操作系统内核的file结构体给混淆了,所以缓冲区的本质就是一个动态数组,
那么根据fopen函数的原理,那么每次调用一个fopen函数来打开一个文件,那么此时fopen打开的每一个文件都会有对应的FILE结构体,同理也会有各自对应的缓冲区
而对于第二个问题,这里我们知道文件是保存在磁盘当中,而我们要向文件当中写入,那么必定要与外部设备也就是磁盘进行交互,而对于磁盘来说其中访问以及读写磁盘的数据的效率是很慢的,所以这里设计写入数据的时候,采取的方式是多级缓冲,也就是我们语言层面上获取到用户向文件中写入的数据,那么它先保存到用户层面上的缓冲区,然后根据特定的缓冲策略在来决定什么时候调用write接口来刷新到我们操作系统内核层面上的缓冲区,
所以我们将我们用户层面上的缓冲区复制交给write写入,也不是直接向磁盘中写入,而是交给内核的缓冲区,那么最后在将内核的缓冲区的数据在刷新写入到磁盘中,那么理解这个过程就可以来类比我们生活中发快递的例子,我们假设从成都发一个快递送到新疆,我们肯定不会自己亲自去跑到新疆去递交快递,而是交给我们楼下的菜鸟驿站,那么菜鸟驿站此时获取到我们的快递后,那么它不会一获取到顾客的快递,那么就派一架飞机或者火车来送该顾客的快递,那样效率太低下了,而是它有自己的不同的发送策略,比如我们要么该驿站的每个货架都装满了快递,那么就清空所有的货架的快递然后发送或者说我的袋子装满了其中的快递,那么就先发送这个袋子里面的所有快递,然后交给航班或者火车,那么航班公司或者火车公司采取的发送的策略肯定就是我们飞机或者火车装满了,那么我们才进行发送
所以在这个例子中,菜鸟驿站就是用户层面上的缓冲区,而航班公司就是系统层面上的缓冲区,那么有了用户层面上的缓冲区,我们就不用频繁的调用write函数从而优化提高效率,那么这就是缓冲区存在的意义
而刚才在上面的例子我们还提及过菜鸟驿站有自己的发送策略,那么这里就对应我们的缓冲区的缓冲策略,
而我们的缓冲区有三个缓冲策略,分别是:
行缓冲
:那么我们将我们的数据保存到缓冲区直到遇到换行符然后刷新此时包括换行符之前所所有保存的数据全缓冲
:那么全缓冲则是无视换行符,那么直到保存的数据到达了我们该缓冲区的容量的上限,那么我们就刷新该缓冲区的所有内容,然后写入到内核的缓冲区中无缓冲
:那么不保存到用户层面的缓冲区,直接写入到内核层面的缓冲区中
而至于选择什么样的缓冲策略,那么则和我们写入的目标文件的类型有关,如果我们是要往显示器文件当中写入,那么我们由于显示器是给我们人阅读的,那么人的阅读习惯是一行一行来阅读的,所以我们向显示器文件采取的一个缓冲策略则是行缓冲,而对于普通文件来写入的则是采取的是全缓冲的策略,而对于向保存错误信息的显示器文件,那么一旦进程遇到异常,那么就得立马输出错误信息,所以采取的是无缓冲策略
注:我们将一个打开文件的关闭,其中就要调用close接口,那么它的工作不仅是将当前文件描述符的file结构体的引用计数减一,如果引用计数为0的话回清理其内核的file结构体以及内存中的数据,而它在进行该清理工作之前会刷新当前文件对应的内核缓冲区,而fclose内部则会封装了close,那么在调用close之前,那么它会刷新用户缓冲区的所有内容,然后写入到内核的缓冲区中,最后在close该打开的文件,并且进程结束也会自动刷新用户以及内核缓冲区
那么有了用户缓冲区以及缓冲策略的概念,那么我们就能解释第四个场景出现的原因了,那么我们调用fork函数然后创建了一个子进程,那么创建子进程本质就是就是子进程会拷贝父进程的task_struct结构体,然后修改其中的部分属性比如PID以及PPID得到自己独立的一份task_struct结构体,而拷贝父进程的task_struct结构体,那么意味着子进程也有自己独立的一份文件描述表并且由于父子进程共享物理内存页,那么意味着子进程也同样有与父进程相同内容的用户缓冲区,而由于我们此时是向普通文件做写入,那么我们的fprintf以及printf等c库函数所写入的字符串都会保存到缓冲区,并且此时缓冲区未被写满,那么此时他们是不会刷新到内核的缓冲区中,而进程一旦结束,那么操作系统会刷新用户以及内核缓冲区的内容,所以我们就能够看到c库函数写入的字符串各自写入了两次因为父子进程各种刷新了对应的缓冲区,而由于write接口是不经过用户缓冲区,而它是在fork调用之前就写入到了内核缓冲区中,所以write写入的字符换只打印了一次
3.模拟实现一个fopen函数以及fwrite以及fclose函数
那么在知道了缓冲区的概念之后,那么我们就可以自己简单实现这三个库函数来加深我们对于缓冲区的理解
1.fopen函数的实现
那么fopen函数的参数列表就是我们要打开的目标文件的文件名,那么它是一个字符指针,以及我们打开该文件的模式,也就是一个字符指针指向一个字符串,所以这里我们第一步首先是判断这两个指针是否为空,那么不为空的话,我们就调用open函数来打开该目标文件,不过打开的时候,还有根据我们fopen函数的第二个参数也就是打开的模式来确定我们open打开的模式也就是宏,如果是w模式的话,那么则是从清空目标文件的内容从文本起始位置处写入,那么对应open接口的宏就是O_CREAT|O_WRONLY|O_TRUNC,而如果是a也就是追加模式,那么则是从文本末尾处接着写入,那么对应open接口的宏是O_CREAT|O_APPEND,那么这部分的代码逻辑我们就用if else逻辑其中用strcmp的匹配来判断
然后open调用成功后,我们会获得该文件的描述符,失败则返回NULL,那么接下来就是定义一个FILE结构体,然后进行初始化,那么其中FILE结构体就应该包括文件描述符以及保存输入以及输出的动态数组也就是缓冲区以及两个记录该缓冲区有效内容的长度的变量和当前的打开标志位以及缓冲策略,而之所以记录打开的标志位是因为我们open接口会对打开的模式的行为进行检查,而如果之后我们进行不对应该标志位的行为比如标志位为读,而你却进行写操作,那么写操作没有进行相应的权限检查就会引发错误,那么标志位我们就是用一个int类型的变量来记录,它的值就是open接口的第二参数也就是宏定义
那么我们malloc申请完FILE结构体以及动态数组并且进行部分属性的初始化之后,那么接下来就判断目标文件的类型确定缓冲策略,但是这里我由于对一些判断文件的系统接口的知识的缺失,所以我这里没有实现这个功能,我统一都是将其设置为行缓冲,其中不同缓冲策略我分别用整形0,1,2来表示并且有对应的宏定义,那么这里读者想要实现完整的fopen函数可以去查阅相关的判断文件类型的接口完善这个环节
最终创建成功并且完成初始化,就返回该FILE结构体的指针
代码实现:
#define N 1024
#define FLUSH_NOW 0
#define FLUSH_LINE 1
#define FLUSH_ALL 2typedef struct IO_FILE{int _fd;char* inbuff;int in_size;char* outbuff;int out_size;int _flags;int _mode;}_FILE;_FILE* fopen(char* filename,char* flag ){assert(filename&&flag);int flag_mode;if(strcmp(flag,"w")==0){flag_mode=O_CREAT|O_WRONLY|O_TRUNC;}else if(strcmp(flag,"a")==0){flag_mode=O_CREAT|O_APPEND;}else if(strcmp(flag,"r")==0){flag_mode=O_RDONLY;}else{return NULL;}int fd=open(filename,flag_mode,0666);if(fd<0){perror("open");return NULL;}_FILE* f=(_FILE*)malloc(sizeof(_FILE));if(f==NULL){close(fd);return f;}f->_fd=fd;f->inbuff=NULL;f->outbuff=NULL;f->in_size=0;f->out_size=0;f->_flags=flag_mode;f->_mode=FLUSH_LINE;if((flag_mode&O_WRONLY)!=0){f->inbuff=(char*)malloc(N*sizeof(char));f->in_size=N;if(f->inbuff==NULL){close(fd);free(f);return NULL;}} if((flag_mode&O_RDONLY)!=0){f->outbuff=(char*)malloc(N*sizeof(char));f->out_size=N;if(f->outbuff==NULL){close(fd);free(f);return NULL;}}return f;}
2.fwrite的实现
那么fwrite的参数分别是你要写入的内容的数组,以及写入的长度size,单位是字节,以及要写入几组该长度的块nmemb,和写入的目标文件的FILE结构体的指针,返回值则是成功写入的块
那么第一步则是判断两个指针是否为空以及写入的长度是否为空或者块的数量是否为空,如果满足那么返回0,那么第二步则是检查我们该文件的标志位是否有写权限,没有则返回为空,然后我们下一个环节再是判断缓冲的策略,在判断之前,我们首先先计算了要写入的总长度total,那么它的大小则是nmemb*size,然后接着在定义一个变量written记录当前已经写入了多少字节的内容,初始化written为0,然后判断缓冲策略
如果是无缓冲,那么我们就直接调用write函数,那么它会返回成功写入的字节长度,我们得到返回值再除以size得到成功写入的块并返回
而对于全缓冲以及行缓冲,我们则是做一个while循环,那么这里退出的条件就是我们写完total字节数退出,也就是written等于total,那么在循环内部判断这两个缓冲策略,并且每次判断之前都要判断我们当前缓冲区的剩余容量是否够保存当前剩余字节,如果够直接保存当前剩余字节,不够则是先写满当前缓冲区剩余字节数,然后再判断是否为行缓冲还是全缓冲,而其中对于行缓冲来说,我们会调用memchr得到第一个换行符的位置,然后将之后的位置移动到前面,那么此时计算当前缓冲区有效长度,然后written加上该换行符之前内容的所有字节数,
而全缓冲则是判断当前有效长度是否写满缓冲区,如果写满,直接调用write将缓冲区的内容写入到内核缓冲区
那么这三个函数其中最难实现的就是fwrite,那么在当时我自己去实现的时候那是非常的坐牢,那么相信你看完fwrite的实现原理后,能够轻松掌握并且实现
代码实现:
int fwrite(const void* ptr, int size, int nmemb, _FILE* stream) {if (!ptr || !stream || size == 0 || nmemb == 0) return 0;if ((stream->_flags & (O_WRONLY | O_RDWR)) == 0) {return 0;}const char* data = (const char*)ptr;int total_bytes = size * nmemb;int written = 0;if (stream->_mode == FLUSH_NOW) {int res = write(stream->_fd, data, total_bytes);return (res > 0) ? res / size : 0; }while (written < total_bytes) {int avail = N - stream->in_size;int to_copy = (total_bytes - written < avail) ? (total_bytes - written) : avail;memcpy(stream->inbuff + stream->in_size, data + written, to_copy);stream->in_size += to_copy;written += to_copy;if (stream->_mode == FLUSH_LINE) {char* start = stream->inbuff;char* end = start + stream->in_size;while (start < end) {char* newline = memchr(start, '\n', end - start);if (!newline) break;int line_length = newline - start + 1;int res = write(stream->_fd, start, line_length);if (res != line_length) {return written / size; }int remaining = end - (newline + 1);memmove(stream->inbuff, newline + 1, remaining);stream->in_size = remaining;start = stream->inbuff;end = start + remaining;}}if (stream->in_size == N) {int res = write(stream->_fd, stream->inbuff, N);if (res != N) {return written / size;}stream->in_size = 0;}}return nmemb;
}
3.fclose函数
那么fclose函数实现非常简单了,那么它会接受关闭的FILE结构体的指针
那么我们首先第一步还是判断指针是否为空,接下来则是先将用户缓冲区的内容给刷新,那么就需要判断两个缓冲区是否有有效内容,有就调用write接口写入,由于FILE结构体以及缓冲区都是在堆上申请开辟,所以先释放掉缓冲区,然后再释放整个FILE结构体,最后调用close接口释放操作系统的内核的file结构体,成功返回0,失败返回-1
int fclose(_FILE* stream)
{assert(f);if(stream->inbuff&&stream->in_size!=0){int q=write(stream->_fd,stream->inbuff,stream->in_size);if(q<0){perror("write");return -1;}}close(stream->_fd);free(stream->inbuff);free(stream->outbuff);free(stream);return 0;
}
完整实现
mystdio.h文件:
#pragma once#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<assert.h>
#include<stdlib.h>
#include<stdio.h>
#define N 1024
#define FLUSH_NOW 0
#define FLUSH_LINE 1
#define FLUSH_ALL 2typedef struct IO_FILE{int _fd;char* inbuff;int in_size;char* outbuff;int out_size;int _flags;int _mode;}_FILE;
int _fclose(_FILE* stream);
_FILE* _fopen(char* filename,char* flag );int _fwrite( void* ptr, int size, int nmemb, _FILE* stream);
mystdio.c文件:
#include"mystdio.h"
_FILE* _fopen(char* filename,char* flag ){assert(filename&&flag);int flag_mode;if(strcmp(flag,"w")==0){flag_mode=O_CREAT|O_WRONLY|O_TRUNC;}else if(strcmp(flag,"a")==0){flag_mode=O_CREAT|O_APPEND;}else if(strcmp(flag,"r")==0){flag_mode=O_RDONLY;}else{return NULL;}int fd=open(filename,flag_mode,0666);if(fd<0){perror("open");return NULL;}_FILE* f=(_FILE*)malloc(sizeof(_FILE));if(f==NULL){close(fd);return f;}f->_fd=fd;f->inbuff=NULL;f->outbuff=NULL;f->in_size=0;f->out_size=0;f->_flags=flag_mode;f->_mode=FLUSH_LINE;if((flag_mode&O_WRONLY)!=0){f->inbuff=(char*)malloc(N*sizeof(char));f->in_size=N;if(f->inbuff==NULL){close(fd);free(f);return NULL;}} if((flag_mode&O_RDONLY)!=0){f->outbuff=(char*)malloc(N*sizeof(char));f->out_size=N;if(f->outbuff==NULL){close(fd);free(f);return NULL;}}return f;}int _fwrite(const void* ptr, int size, int nmemb, _FILE* stream) {if (!ptr || !stream || size == 0 || nmemb == 0) return 0;if ((stream->_flags & (O_WRONLY | O_RDWR)) == 0) {return 0;}const char* data = (const char*)ptr;int total_bytes = size * nmemb;int written = 0;if (stream->_mode == FLUSH_NOW) {int res = write(stream->_fd, data, total_bytes);return (res > 0) ? res / size : 0; }while (written < total_bytes) {int avail = N - stream->in_size;int to_copy = (total_bytes - written < avail) ? (total_bytes - written) : avail;memcpy(stream->inbuff + stream->in_size, data + written, to_copy);stream->in_size += to_copy;written += to_copy;if (stream->_mode == FLUSH_LINE) {char* start = stream->inbuff;char* end = start + stream->in_size;while (start < end) {char* newline = memchr(start, '\n', end - start);if (!newline) break;int line_length = newline - start + 1;int res = write(stream->_fd, start, line_length);if (res != line_length) {return written / size; }int remaining = end - (newline + 1);memmove(stream->inbuff, newline + 1, remaining);stream->in_size = remaining;start = stream->inbuff;end = start + remaining;}}if (stream->in_size == N) {int res = write(stream->_fd, stream->inbuff, N);if (res != N) {return written / size;}stream->in_size = 0;}}return nmemb;
}
int _fclose(_FILE* stream)
{assert(f);if(stream->inbuff&&stream->in_size!=0){int q=write(stream->_fd,stream->inbuff,stream->in_size);if(q<0){perror("write");return -1;}}close(stream->_fd);free(stream->inbuff);free(stream->outbuff);free(stream);return 0;
}
main.c文件:
#include"mystdio.c”
int main()
{
char* tt="hello Linux\n";_FILE* fp=_fopen("log.txt","w");_fwrite(tt,strlen(tt),1,fp);_fclose(fp);return 0;
}
Linux上运行截图:
结语
那么这就是本篇关于用户缓冲区的所有知识啦,那么下来也推荐大家可以自己去实现这三个c库函数甚至还可以去实现fprintf函数等,那么我的下一期文章将是文件系统的讲解,请大家多多期待,那么我会持续更新,那么本篇博客创作不易,还请大家多多三连加关注,你的支持,就是我创作的最大动力!