乌市泽哥 发表于 2024-8-24 13:05:11

linux文件——用户缓冲区——概念深度探索、IO模仿实现

        媒介:本篇文章主要讲授文件缓冲区。 讲授的方式是通过抛出题目, 然后通过分析题目, 将缓冲区的概念与原理一步一步地讲授。同时, 本节内容在末了一部分还会带友友们模仿实现一下c语言的printf, fprintf接口, 加深友友们对于缓冲区的理解。
        ps: 本节内容适合相识linux历程和linux文件重定向的友友们进行观看。  
目次
缓冲区概念——使用close引出
exit和用户缓冲区
缓冲区刷新方案
为什么要有用户缓冲区
FILE
fork和缓冲区读写
模仿实现c语言标准库
只包含fd的接口——不带缓冲区
c语言的跨平台性
带缓冲区的c语言接口
fflush

缓冲区概念——使用close引出

        讲述缓冲区, 我们需要先看一下下面的接口:
https://i-blog.csdnimg.cn/direct/61468896949e4110b5e4fbf3321befbe.png
        对于上面的接口, 我们可以预测一下——就是打印4行内容, 分别是hello printf、hello fprintf、hello fwrite、hello write:
   https://i-blog.csdnimg.cn/direct/b211454a2f954057bafa25a3807d957d.png
        如图,代码运行效果符合我们的预期。对于上面的代码我们知道, 对于c语言的接口, stdoutFILE文件流内里就封装了1号文件描述符。 他们的底层肯定是调用了write函数。
        对于上面的函数,我们重定向到文件是这样, 和直接在显示器上面输出是一样的。
https://i-blog.csdnimg.cn/direct/96a7590d575a4367bce6cc6e66c6f4a5.png
   

[*]但是, 如果加上close(1), 将显示器文件关闭, 那么情况就有些不同了, 下面是有\n的:
https://i-blog.csdnimg.cn/direct/cefef72f0a36446fb099b54f43dfee87.png
https://i-blog.csdnimg.cn/direct/a6186f7611764a66a3d77f74d2e598fd.png



[*]下面是没有\n的:
https://i-blog.csdnimg.cn/direct/14de689a94fb4f328e3d8ddc7c59699b.png
            如果没有了\n, 对于c语言的打印接口, 就不能输出了。
        如下图, 只输出了系统调用write:
https://i-blog.csdnimg.cn/direct/a04f830ae9054e9bb787f1d5a266c50b.png
        我们知道对于上面printf、fprintf、fwrite来说, 这三个是c语言接口, 他们的底层肯定是封装的write接口。 这就有题目了, 为什么printf、fprintf、fwrite的底层是write, 而write可以大概打印数据, printf、fprintf、fwrite都不能打印数据呢?
        友友们如果学习过进度条, 那么就会知道一个缓冲区的概念。 这个缓冲区遇到\n大概缓冲区满了之后就会刷新自己。 
        一样平常情况下, 我们使用printf、fprintf、fwrite不是直接写向显示器, 而是先写向缓冲区。 比及遇到\n大概缓冲区满了之后再刷新缓冲区,将数据刷新到缓冲区。
        也就是说, 对于上面的这个代码来说, 当程序运行到close(1)前一行, 那么数据就已经写到缓冲区了。 只是这个缓冲区肯定不在操作系统内部, 这个缓冲区不是系统级别的缓冲区!!!
        详细的底层逻辑关系如下:
https://i-blog.csdnimg.cn/direct/640fcbe1ed52475baf7f886e6491223a.png
           我们使用的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写到内核中。
https://i-blog.csdnimg.cn/direct/f920947a5e76478c9e671f7909065cdc.png
https://i-blog.csdnimg.cn/direct/1bc66965d34d4ec3a020d59462c50ee5.png

exit和用户缓冲区

        我们之前说过exit和_exit, exit是封装的上层接口, 而_exit是系统调用。 两者的区别是_exit退出前不会刷新缓冲区(我们口中的缓冲区, 都是用户级缓冲区), 而exit会刷新缓冲区(我们口中的缓冲区, 都是用户级缓冲区)。 这是由于_exit身为系统调用, 它在用户层的下层, 它根本看不到用户级缓冲区。 也就没办法刷新它, 而exit处在用户层, 它底层肯定是先刷新缓冲区flush, 然后再调用系统调用_exit。
        从上面我的叙述中就可以发现, 其实当数据到达内核的时候, 那么这个数据就可以到达硬件了。 ——这也是我们现在认为的, 只要是将数据刷新到了内核, 数据就可以到达硬件了!!!
缓冲区刷新方案

        缓冲区刷新题目:——这里的缓冲区思量的是语言层, 也就是用户层缓冲区。
   

[*]        无缓冲: 直接刷新, 不管printf, fprintf等接口打印的数据是什么, 直接刷新。
[*]        行缓冲: 不刷新,直到遇到\n再刷新。
[*]        全缓冲: 缓冲区满了再刷新, 不满的话, 无论怎样都不刷新。
 所以整个的刷新流程如下图:
https://i-blog.csdnimg.cn/direct/49d26a16d64247dd94f29bc241b273cd.png
        在向显示器中写入的时候, 采用的是行缓冲。
        在向普通文件中写入的时候, 采用的是全缓冲。

为什么要有用户缓冲区

        历程退出的时候也会刷新缓冲区, 为什么要有这个缓冲区呢? 
        首先就是效率层面——这里可以使用一个例子来说明这件事, 就好比我们给朋友送东西, 如果没有快递公司, 我们就必须亲自去送东西给朋友, 但是现在有了快递公司, 快递公司就可以帮助我们去送快递, 我们节流的这些时间就可以去做自己的事情。——这内里的快递公司就是缓冲区, 我们就是用户。 所以, 缓冲区解决的是谁的效率题目?——其实就是用户的效率题目。 (也就是使用c语言的人的效率题目)
        其次是语言设计层面——拥有缓冲区的目的是为了配合c语言接口:printf,fprintf的输出格式化。——这里我们需要想一个题目, 就是我们在显示器上面打印的789, 这样的数字, 打印的是字符呢? 打印的还是数字呢? ——答案是打印的字符789。 也就是说, 将789转化成字符7, 字符8, 字符9, 然后再打印到显示器上面。 当数据打印的时候, 先将数据打印到用户缓冲区。 当某个时机的时候, 就将用户缓冲区的数据打印到硬件内里。 由于这种数据进入, 数据流出, 很像一条河流, 所以用户缓冲区也被叫做流。
 
FILE

请问, FILE是属于用户呢? 还是属于操作系统呢? 答案是用户, 语言都是属于用户的。
        知道这个之后, 我们就可以知道, 对于fopen来说
https://i-blog.csdnimg.cn/direct/c7ea190f86774d5385f9a8600441b80d.png
        如果使用fopen打开了一个文件, 那么就会获取这个文件的文件描述符fd。 然后再malloc一块内存空间保存在FILE, 这个fd和malloc的内存空间都保存在FILE内里!!也就是下面这张图:
https://i-blog.csdnimg.cn/direct/7ba674f09e274573b9b14708d068f7c2.png

fork和缓冲区读写

现在,我这里有另外一个关于fork函数的题目。 我们看下面这串代码:
https://i-blog.csdnimg.cn/direct/2b275c8ef2d74df9bc5e6186f2209878.png
        我们天生程序后, 将数据重定向到文件中。 如果按照我们以往的经验的话, 这里我们预测会打印四行内容, 分别是hello printf、hello fprintf、hello fwrite、hello write。
        但是, 我们实际运行的效果如下:
   https://i-blog.csdnimg.cn/direct/ed65e01f0cb5428d90901fb1cc23d1f9.png
        如上图, 我们可以发现c语言接口的打印都被打印了两次, 只有write系统调用被打印了一次。
        这是由于当使用重定向, 重定向到了文件之后, 缓冲区的刷新方案就变成了全缓冲, 遇到\n不再刷新, 而是等缓冲区被写满之后才刷新。
        那么我们下载来证明一下重定向到文件是全缓冲的
           首先我们写下面这个程序;
https://i-blog.csdnimg.cn/direct/499f9f11d2d34bfdb1d69208bab9f9db.png
        这个程序如果按照我们的猜想。 前三秒不会打印任何数据, 这些数据都会被打印到了缓冲区之中。 当三秒过后, write会被打印。然后再五秒所有数据才会全部被打印。
         我们先打开监视窗口进行观察:
https://i-blog.csdnimg.cn/direct/0c2eb8b0c6974319b858f34851dd6361.png
https://i-blog.csdnimg.cn/direct/138f763e1e46418a9d5dbb3794a75f8e.png
然后运行这个程序, 就会得到:
https://i-blog.csdnimg.cn/direct/1d397a9ad9f24bc28f2619f1a0923b96.png
https://i-blog.csdnimg.cn/direct/5e5c189ae2fa433e918f430c8d863af0.png
        那么这是为什么呢?——首先我们知道, 当我们三个c语言接口执行完的时候, 数据都被写到了缓冲区中。 而缓冲区也是在历程内里的, 所以当fork创建子历程后, 再刷新缓冲区, 而刷新缓冲区就相当于数据的修改, 需要进行写时拷贝。 所以就有两份缓冲区, 两个缓冲区的数据都被刷新后就产生了上面的征象!!!
https://i-blog.csdnimg.cn/direct/7906abc4bf5e44d09fca1addb709e102.png

模仿实现c语言标准库

        为了模仿实现c语言的标准库, 我们创建三个文件。 这里的_my_func.h就相当于stdio.h这样的标准库, 而_my_func.c就是用来模仿实现c标准库接口。
https://i-blog.csdnimg.cn/direct/f3ea8126d74e40ce8523d72637b238fe.png
         我们打开_my_func.h, 首先做好准备工作。 先让该头文件不可重复包含, 也就是下面的ifdef, define, endif。 然后再包含一个头文件string.h。 我们先来简单的实现以下三个函数, _fopen, _fwrite, _fclose。
只包含fd的接口——不带缓冲区

https://i-blog.csdnimg.cn/direct/23005fd8e84049f59f4cf0c986eb5833.png
下面是代码:
#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;
//int in_pos;
//输出缓冲区
char outbuffer;   
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(以后的函数等等为了区分都会加上"_")。
https://i-blog.csdnimg.cn/direct/d2b29531235b4b899d380a49138790bf.png
        对于这个结构体内里的内容, 根据我们前面讲到的只是可以知道, 这内里肯定有两个字段一个是文件描述符fd, 一个是文件缓冲区。 这里我们先将fd包含进来。如下图:
https://i-blog.csdnimg.cn/direct/3278d2b73f154a0c8aa6e1db5154e203.png
然后我们就可以先简单的实现一下fopen, fwrite, fclose函数了。
   fopen
https://i-blog.csdnimg.cn/direct/275db00a1efa4bf69bec98d8749788f0.png
如下为代码:
_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
https://i-blog.csdnimg.cn/direct/6e093d00dfc64a99a80a3c41fbc44603.png 如下为代码:
int _fwrite(_FILE* fp, const char* s, int len)                           
{
    //fd, 要写入的字符串, 字节长度
    return write(fp->fileno, s, len);
}
     fclose
https://i-blog.csdnimg.cn/direct/b28d430ad4da49f385dd9a5830ccb9c1.png
如下为代码:
//关闭文件                     
void _fclose(_FILE* fp)      
{                              
    if (fp == NULL) return;      
    close(fp->fileno);         
    free(fp);                  
}    我们自己实现一串代码进行测试, 打开的方式是清空写:
https://i-blog.csdnimg.cn/direct/b87ea8f5f41f44c2bb85e0463a203235.png
运行效果如下:
https://i-blog.csdnimg.cn/direct/6321788604b441c0b026d72e8b9d233f.png
            其实从上面的代码我们就能看到封装的好处。 有了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是为了标志缓冲区的使用情况。 它指向缓冲区已经使用的末了一个位置。 这个位置的左边是已经使用的, 右边是没有使用的。而且由于我们此次模仿实现的接口用不到输入缓冲区, 所以将输入缓冲区解释掉。
https://i-blog.csdnimg.cn/direct/d72b843d11b048d7a227622d3f624152.png
接下来我们修改我们的代码:
   fopen 
      首先修改fopen, 要初始化缓冲区, 那么就是将标志缓冲区使用情况的out_pos置为0. 就代表缓冲区没有被使用过。 并且我们要初始化缓冲区的刷新方案。也就是_FILE结构体内里的flag。 我们这里默认初始化为行刷新, 下图的黄色代码就是初始化缓冲区。(只需要在原本的fopen下面添加黄色框框的代码就可以实现)
https://i-blog.csdnimg.cn/direct/6a992c44a0854419b62ade573701128d.png
    fwrite
        如下图是写方法的含有缓冲区的实现方式。 就是先将数据拷贝到缓冲区中, 然后判断刷新方式, 是直接刷新, 行刷新还是缓冲区满才刷新。刷新后标志位out_pos置为0.
https://i-blog.csdnimg.cn/direct/f9aa5cbb90a84fb58de1133e1ed5bc9e.png
下面是代码:

//写方法
int _fwrite(_FILE* fp, const char* s, int len)
{
    //使用memcpy将数据拷贝到输出缓冲区中。
    memcpy(&fp->outbuffer, 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 == '\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
关闭文件和之前没有什么区别
https://i-blog.csdnimg.cn/direct/3bf5f57457df4649b55019d488956cad.png
测试:
            我们可以使用下面这个监控脚本, 以及我们要测试的代码, 来测试一下我们模仿实现的是否精确:
https://i-blog.csdnimg.cn/direct/b9ae2a84fd1045dfaa7564dedbe7ffe9.png
#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;   
}   
运行效果如下, 由于我们是以行刷新, 并且每一秒都会追加一行。 所以会出现下图的情况。 验证效果我们的代码是精确的。
 https://i-blog.csdnimg.cn/direct/ad21ad7d3d6046b98d895832fe1e007b.png
fflush

        fflush这里我们也要模仿一下, 博主模仿是为了应对全刷新的情况(并不是说fflush只是为了应对全刷新而存在的)。由于我们使用全刷新的时候, 写到缓冲区的内容不轻易被刷新出来。就如同下图我们已经运行了程序, 但是仍然刷新不出东西, 也就是没有向文件写入:
https://i-blog.csdnimg.cn/direct/eeefc08e352142d6a66aef89eb83925f.png
           那么实现fflush, 我们要怎么实现呢?代码如下图:
https://i-blog.csdnimg.cn/direct/6d48346fda1f46eab936d7d07374572d.png
代码: 

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秒胡历程退出的时候打出来了。下图就是测试——前十秒没有打印任何东西, 但是后面打印了一串数据。 这是由于历程退出的时候刷新了缓冲区。
https://i-blog.csdnimg.cn/direct/a64bddcd33264a03beb78031e21c35c1.png
以上就是本节的全部内容。 下面是博主的条记:
https://i-blog.csdnimg.cn/direct/d8110cc014ba4842a0998233af63ce87.png
https://i-blog.csdnimg.cn/direct/0e556651c9d24d369a4090adaafb4218.png https://i-blog.csdnimg.cn/direct/24308bd10d1f430786612f6e3cb8b084.png
https://i-blog.csdnimg.cn/direct/45090b16dbff43d0911f9eb1be9c26e0.png

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: linux文件——用户缓冲区——概念深度探索、IO模仿实现