Linux操作系统之文件(三):缓冲区

发布于:2025-07-05 ⋅ 阅读:(18) ⋅ 点赞:(0)

前言:

上节课我们讲授重定向的概念时,曾提到了一点缓冲区的概念。本文将会为大家更详细的带来缓冲区的有关内容:用户级缓冲区是什么,以及其与内核级缓冲区的关系,最后,我会为大家模拟实现一下stdio.h的关于FILE结构体的有关内容,当然,这个模拟只是从原理上实现。真正的stdio.h肯定会更加复杂。

一、再谈缓冲区

我们上篇文章链接曾做了一个实验:

这里我们加了一个fflush之后,就可以在文件中看到我们的输出结果了。当时我们解释的是“ printf 的输出被缓冲在内存中,尚未写入文件,而程序结束时没有触发缓冲区的自动刷新。” 

这句话还是太抽象了,那么更加具体点的解释是什么呢?

我们曾经说过,内核级缓冲区的出现,是为了提高IO的效率,让数据积累后再一次性保存在文件中,而不是一次一次的连续IO。

那么我们的C语言,同样也是为了提高效率,也实现了一个缓冲区,而这个缓冲区被我们称为用户级缓冲区。我们调用的printf,fprintf,fputs实际上都是C语言封装后的函数。这个缓冲区也恰好对应了这些函数。

在stdio.h文件中,FILE实际上是struct _IO_FILE 结构体的重命名。源码部分如下:
 

 

struct _ IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
# define _IO_file_flags _flags
// 缓冲区相关
  /* The following pointers correspond to the C++ streambuf protocol. */
  /* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
  char * _IO_read_ptr; /* Current read pointer */
  char * _IO_read_end; /* End of get area. */
  char * _IO_read_base; /* Start of putback+get area. */
  char * _IO_write_base; /* Start of put area. */
  char * _IO_write_ptr; /* Current put pointer. */
  char * _IO_write_end; /* End of put area. */
  char * _IO_buf_base; /* Start of reserve area. */
char * _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
  struct _ IO_marker *_ markers ;
  struct _ IO_FILE *_ chain ;
  int _fileno; // 封装的⽂件描述符
  # if 0
  int _blksize;
  # else
  int _flags2;
  # endif
  _IO_off_t _old_offset; /* This used to be _offset but it's too small. */
  # define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
  unsigned short _cur_column;
  signed char _vtable_offset;
  char _shortbuf[ 1 ];
/* char* _save_gptr; char* _save_egptr; */
  _IO_lock_t *_lock;
  # ifdef _IO_USE_OLD_IO_FILE
  };

大家可以看到,在 _IO_FILE结构体中,有一段关于缓冲区的代码,而这个缓冲区,就是我们说得用户级的缓冲区。

它存在的意义与内核级缓冲区是一模一样的,调用printf等C语言封装后的函数,把数据拷贝到用户级缓冲区里,随后根据一定的条件,把堆积的数据拷贝到内核级缓冲区。

所以printf等一系列的C语言函数本质上,也是一个拷贝函数。

如果我们的目标文件是显示器文件,那么这个拷贝条件就是行刷新,当检测到\n时就会拷贝到内核级缓冲区。如果是普通文件,对应的就是全缓冲,即等待缓冲区写满后再刷新。

只要把数据从用户级缓冲区拷贝到了内核级缓冲区,我们就认为把数据交给了操作系统,这个数据就和用户无关了。


二、重定向与缓冲区

仍然是这个代码:

#include <stdio.h>

#include <unistd.h>

#include <fcntl.h>

int main()

{

    close(1);

    int fd=open("log1.txt",O_WRONLY | O_CREAT | O_TRUNC, 0666);

    printf("%d\n",fd);

    return 0;

}

如果我们把close(fd)删除掉,那又是什么情况呢?
 

我们可以看见,重定向后,虽然不能打印到显示屏,但是文件中是存在打印的的结果的。

为什么把后面的close(fd)去掉后,文件中就存在打印结果了呢?

 先不着急回答,我们再把后面的close换成fclose:

可以看见,fclose也可以达成目标。

所以我们可以告诉大家原因了,当一个进程退出时, 它会自动刷新自己的用户级缓冲区,但当你调用系统的close把fd关掉后,它想要把数据刷新到操作系统内部都没有机会了。

而C语言的fflush与close也都只是把用户级缓冲区的数据刷新到内核级缓冲区,最多也就是fclose会释放 FILE 结构体,并调用 close(fd) 关闭底层文件描述符。

由于我们重定向了,导致原本打印1到显示器文件的行缓冲模式变成了全缓冲,导致不会因为‘\n’而刷新数据。

把用户级缓冲区的数据刷新到内核级我们可以调用fflush,那有没有什么方式把内核级缓冲区数据刷新到文件里?

有的:

我们可以调用fsync系统调用接口。


总的来说,C语言之所以存在缓冲区,就是为了提高效率,也就是说,C语言从设计上,就十分注重效率。

那C++有没有自己的用户级缓冲区呢?

肯定是有的。但是C++的缓冲区效率没有C语言高,所以才会出现在一些十分注重时间复杂度的算法题上面,出现同样结构的思维的代码,出现使用printf可以通过,但是使用cout无法通过的现象。


 

三、子进程与缓冲区

请看下面的代码:

int main()

{

    //C库函数

    pinrtf("hello printf\n");

    fprintf(stdout,"hello fprintf\n");

    const char*message="hello fwrite\n";

    fwrite(message,strlen(message),1,stdout);

    //系统调用

    const char*w="hello write";

    write(1,w,strlen(w));

   

   

    fork();

    return 0;

}

它的运行结果是: 

很好,看起来没有问题,那我们试着重定向一下呢?

 

诶,为什么重定向后,C语言的打印就都执行了两次,系统调用的打印只执行了一次呢?

这是因为重定向后,用户级缓冲区的缓冲方式变成全缓冲,父子进程结束后各自fflush了一次到内核级缓冲区 ,导致C语言中,拷贝到用户级缓冲区的数据,各自被刷新到了内核级缓冲区一次,于是当内核级缓冲区的数据刷新到文件中时,就出现了这个现状。

当我们把\n取消时:

int main()

{

    //C库函数

    printf(" hello printf ");

    fprintf(stdout," hello fprintf ");

    const char*message=" hello fwrite ";

    fwrite(message,strlen(message),1,stdout);

    //系统调用

    const char*w="hello write\n";

    write(1,w,strlen(w));

   

   

    fork();

    return 0;

}

打印结果如下:
 

这就是因为,在打印到显示器文件时,缓冲方式为行缓冲,我们没有遇见\n,同样导致了重复的刷新。

如果想要达成正确的打印效果,就需要子进程之前使用fflush刷新用户级缓冲区。


四、模拟实现stdio.h 

相信前面的用例,已经能够让大家较为清楚的理解到缓冲区与重定向之间的精密联系了。

那么现在我就来带大家简答的模拟实现一下stdio中的FILE结构体,与fwrite,fclose,fflush,fopen函数吧!!

(注意,本次模拟只是为了让大家更能理解原理,真正的实现肯定有所不同)

 

我们先不管C标准库中是什么样子的,我们只知道,有.h文件,就自然要有.c文件来实现;

首先,我们先根据所知道的知识,把基础的FILE结构体与这四个函数的声明写到.h文件里:

#pragma once

#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<stdlib.h>

#define SIZE 1024

//定义刷新方式
#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

struct _OI_FILE
{
    int flag;//刷新方式
    int fileno;//文件描述符
    char outbuffer[SIZE];//缓冲区
    int size;
    int cap;

    //TODO
};

typedef struct _OI_FILE mFILE;

mFILE *mfopen(const char *pathname, const char *mode);
int mfflush(mFILE *_stream);
size_t mfwrite(const void *ptr,int size, mFILE *stream);
int mfclose(mFILE *stream);

随后,我们要在.c文件里实现:
第一个就是fopen函数了,首先,我们要先根据传入的打开方式,通过open把文件描述符获取到。而打开方式的判断,我们选择用if语句与strcmp相结合。

随后,我们要为这个文件创建一个FILE结构体对象,并对里面的参数进行初始化:
设置缓冲区的大小,文件标识符,刷新方式,以及容量,这里的容量我们选择在.h中定义一个SIZE为1024常数,当然,要记得判断一下是否失败。

mFILE *mfopen(const char *pathname, const char *mode)
{
    int fd=-1;
    // 根据打开模式选择不同的文件打开方式 
    if(strcmp(mode,"w")==0)  // 写入模式:创建/截断文件 
    {
        fd=open(pathname,O_WRONLY | O_CREAT | O_TRUNC, 0666);
    }
    else if(strcmp(mode,"r")==0)  // 只读模式 
    {
        fd=open(pathname,O_RDONLY);
    }
    else if(strcmp(mode,"a")==0)  // 追加模式 
    {
        fd=open(pathname,O_WRONLY | O_CREAT | O_APPEND, 0666);
    }
    // 检查文件是否成功打开 
    if(fd<0)
    {
        return NULL;
    }

    // 分配mFILE结构体内存 
    mFILE* mf=malloc(sizeof(mFILE));
    // 内存分配失败处理 
    if(!mf)
    {
        close(fd);
        return NULL;
    }

    // 初始化结构体字段 
    mf->flag=FLUSH_LINE;     // 默认行缓冲模式 
    mf->fileno=fd;           // 设置文件描述符 
    mf->size=0;              // 初始化缓冲区大小为0 
    mf->cap=SIZE;            // 设置缓冲区容量 

    return mf;
}

随后就是fwrite拷贝函数,他最主要的作用就是通过memcpy函数将数据拷贝到用户级缓冲区:

我们要注意的是,size参数始终代码我们的大小,所以outbuffer[size-1]就代表当前数据的最后一个字符。所以我们可以通过这个特性找到memcpy的初始地址,以及判断是否最后一个字符为\n。根据我们的刷新方式参数以及size的大小是否不为0,来调用我们的mfflush刷新。

而fflush的刷新就更加简单,我们只需要判断size大小是否大于0,随后调用系统接口write,将用户级缓冲区的内容拷贝到内核级缓冲区就行了。

int mfflush(mFILE *_stream)
{
    // 检查缓冲区是否有数据需要刷新 
    if(_stream->size>0)
    {
        // 将缓冲区数据写入文件 
        write(_stream->fileno,_stream->outbuffer,_stream->size);
        _stream->size=0;  // 重置缓冲区大小 
    }
    return 0;  // 总是返回成功 
}

size_t mfwrite(const void *ptr,int size, mFILE *stream)
{
    // 将数据拷贝到缓冲区 
    memcpy(stream->outbuffer+stream->size,ptr,size);
    stream->size+=size; // 更新缓冲区当前大小 
    // 根据刷新模式决定是否立即刷新 

    if(stream->flag==FLUSH_LINE && stream->size>0&&stream->outbuffer[stream->size-1]=='\n')  // 行缓冲且遇到换行符 
    {
        mfflush(stream);
    }
    else if(stream->flag==FLUSH_FULL&&stream->size>=stream->cap)  // 全缓冲且缓冲区满 
    {
        mfflush(stream);
    }

    return size;  // 返回成功写入的字节数
}

最后只剩下一个mfclose。这个函数又该怎么实现呢?

我们需要先明确一下该函数应该完成的任务:

  1. 刷新缓冲区:如果缓冲区还有未写入的数据(size > 0),调用mfflush写入内核缓冲区。

  2. 关闭文件描述符:使用close() 关闭 fileno文件描述符的文件。

  3. 释放内存:释放 mFILE 结构体占用的内存。

所以具体实现如下:

int mfclose(mFILE *stream) 
{
    if (stream == NULL) 
    {
        return -1;  // 错误:传入空指针
    }

    // 1. 刷新缓冲区(如果还有未写入的数据)
    if (stream->size > 0) 
    {
        mfflush(stream);
    }

    // 2. 关闭文件描述符
    int ret = close(stream->fileno);
    if (ret < 0) 
    {
        // 关闭失败,但仍然需要释放内存
        free(stream);
        return -1;
    }

    // 3. 释放 mFILE 结构体
    free(stream);

    return 0;  // 成功
}

 实现还是比较简单的,最重要的就是理解这些函数干了什么事情,达成了什么效果,在内部一一通过系统调用或者函数的复用来实现。

另外,请注意我们应该把在.c文件中用到的各种函数、系统调用的相关头文件,在.h中进行声明。

最后,我们可以添加一个测试用例:

 

int main()
{
    mFILE *mf = mfopen("test.txt", "w");
    if (!mf) {
        perror("mfopen failed");  // 打印错误信息
        return 1;
    }
    size_t written = mfwrite("Hello", 5, mf);  // written = 5
    mfclose(mf);
    return 0;
}

 


 

总结:
 

 本文我们继续详细谈了缓冲区有关的概念,并为大家模拟实现了FILE结构体的有关内容,希望通过这些知识点,能够帮助你更加了解操作系统中,文件的缓冲区的相关知识。

如果有任何疑问与指正欢迎私信或者评论区留言