【Linux】深入理解缓冲区

目录

  • 一、理解缓冲区
    • [1.1 文件打开和读写的基本过程](#1.1 文件打开和读写的基本过程)
    • [1.2 为什么要有缓冲区?](#1.2 为什么要有缓冲区?)
    • [1.3 语言级的输入输出缓冲区具体在哪里?](#1.3 语言级的输入输出缓冲区具体在哪里?)
  • 二、理论验证
    • [2.1 没有刷新的问题](#2.1 没有刷新的问题)
    • [2.2 语言级缓冲区的刷新问题](#2.2 语言级缓冲区的刷新问题)
    • [2.3 exit 与 _exit](#2.3 exit 与 _exit)
    • [2.4 向显示器打印的几种做法,以及一个问题](#2.4 向显示器打印的几种做法,以及一个问题)
    • [2.5 内核文件缓冲区](#2.5 内核文件缓冲区)
  • 三、封装体验缓冲
  • 三、理解标准错误

个人主页:矢望

个人专栏:C++LinuxC语言数据结构Coze-AI

一、理解缓冲区

1.1 文件打开和读写的基本过程

如上图,如果你打开一个文件,在进程PCB中的文件描述表里就会找一个文件描述符给这个被打开的文件,这个struct file的结构体里直接或间接的包含被打开文件的属性,其中重要的是有一个文件内核级缓冲区

如果你要 文件,系统提供了相应的系统调用read

fd是被打开的文件的文件描述符,buf是你自己定义的用户缓冲区数组,count是你要获取的字节数。

当你在程序中调用read时,这个系统调用就会通过fd找到对应的struct file,并且详细的去看文件内核级缓冲区,如果这个缓冲区中没有内容,read就和scanf一样,进程就堵塞在那里了。所以使用read函数首先OS会将磁盘中的文件内容加载到文件内核级缓冲区,之后read函数就会将内核级缓冲区的数据拷贝到buf用户级缓冲区中 。所以read函数本质是拷贝函数。

当你要 文件,系统提供了相应的系统调用,并且我们之前已经使用过了,就是write

它的参数和read相同,只不过它是将用户缓冲区buf的数据写入到fd对应的struct file结构体里的文件内核级缓冲区 中,之后OS会自动将文件内核级缓冲区里的数据刷新到磁盘文件中。所以它本质也是拷贝函数。

如果要修改文件的内容也一样,也是先将磁盘中的文件内容加载到文件内核级缓冲区,然后将这个缓冲区中的内容做修改,最后再刷新到磁盘文件中完成修改

所以readwrite它们都不是直接访问物理磁盘,它们做不到!

  • read的简单使用

我们的scanf函数底层封装的就是read read函数:

返回值含义n > 0,实际读取的字节数;n = 0,已到达文件末尾EOFn = -1,发生错误。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    const char* filename = "log.txt";
    int fd = open(filename, O_RDONLY);
    if(fd < 0)
    {
        perror("open");
        return 1;
    }

    char buff[256]; // 用户级缓冲区
    ssize_t n = read(fd, buff, sizeof(buff) - 1); // -1 是为了给 '\0' 留空间
    if(n > 0)
    {
        buff[n] = '\0';
        printf("下面是读取到的内容\n");
        printf("%s\n", buff);
    }

    close(fd);

    return 0;
}

上面的程序是从fd对应的文件中读取数据到用户缓冲区,然后打印出来查看内容,这是log.txt中的内容:

cpp 复制代码
这就是log.txt中的内容
Hello World!
Hello Shell!

运行结果

1.2 为什么要有缓冲区?

调用系统调用是有成本的,系统调用的过程会比较慢,比如调用readwrite过程中OS会将磁盘中的内容加载到文件内核级缓冲区,或者将文件内核级缓冲区的内容加载到磁盘,这个过程是很消耗时间的。

比如我们在学习vector的时候,它的一次扩容都是扩1.5-2倍的,它为什么不每次需要多少扩容多少呢?比如它需要一个整型的空间就开辟一个整型的空间不好吗? 不好,这样它调用系统调用的次数太多了,这就会导致时间的浪费,而一下子开辟1-2倍的空间,这样你需要少量的空间时,直接从这个已申请好的空间拿就好了,不需要频繁进行系统调用。

所以如果解决成本呢? 就是尽量减少系统调用的次数

当我们进行写入的时候,成本就是readwrite,所以我们需要减少它们调用的次数。C语言中给我们提供了输入缓冲区输出缓冲区 ,它们都是语言级缓冲区

当我们使用我们熟知的int fputs(const char *s, FILE *stream);进行输出数据时,实际是这个函数将数据从用户级缓冲区打印到了语言级缓冲区等到写完或者语言级输出缓冲区打印满时,就会调用一次write将缓冲区中的数据刷新到文件内核级缓冲区!

所以我们之前所说的缓冲区,都指的是语言级缓冲区,而不是文件内核级缓冲区!有了语言级的输入输出缓冲区,就提高了C语言的IO函数的运行效率,这就是为什么C/C++要有语言级缓冲区的原因!

1.3 语言级的输入输出缓冲区具体在哪里?

上图是fopen函数,它的返回值是FILE*类型,我们知道FILE是一个结构体,它里面封装了文件描述符fd,但它里面还封装了缓冲区!

cpp 复制代码
struct FILE {
	// ...
    int fd;
    char inbuffer[BUFSIZ];  // 语言级输入缓冲区
    char outbuffer[BUFSIZ]; // 语言级输出缓冲区
    // ...
};

所以调用它int fputs(const char *s, FILE *stream);是将用户级缓冲区中的数据拷贝到语言级缓冲区,本质就是拷贝到了fputs接收的streamFILE结构体里。

因此语言级缓冲区在每一个文件的FILE对象中。

fopen是一个C语言的库函数可是它返回的是一个FILE*的指针,FILE是需要创建的,这个FILE对象是在哪里创建的呢? 它是在fopen内部创建的,所以最后还需要进行fclosefclose(fp)所做的工作关闭文件描述符,刷新缓冲区,释放fp指针所指向的FILE对象

相关FILE结构体,在/usr/include/stdio.h中:

cpp 复制代码
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
};
typedef struct _IO_FILE FILE;

二、理论验证

2.1 没有刷新的问题

现在有这么一段代码,是一段关于输出重定向的代码。

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    printf("fd is %d\n", fd);
    printf("fd is %d\n", fd);
    printf("fd is %d\n", fd);
    
    return 0;
}

运行结果

如上,我们的打印信息果然打印在了log.txt中,现在我要在代码末尾加上close(fd);我们再次看现象。

这次再次运行log.txt中竟然没有内容了,为什么?

我们分析一下,printf是向stdout打印的,它就是将用户级数据拷贝到stdout对象的语言级缓冲区中的 ,也就是说打印完成后,现在数据在语言级缓冲区中,但还没来的及刷新到文件内核级缓冲区就执行了close(fd),那自然log.txt中就没有数据被写入了。

那么我就要看到它被写入该怎么办,可以使用fflush(stdout)

fflush(stdout)的本质就是强制把语言级缓冲区的数据刷新到文件内核级缓冲区

2.2 语言级缓冲区的刷新问题

刷新的本质是将语言级缓冲区的数据拷贝到文件内核级缓冲区,是把你的数据交给操作系统。

语言级缓冲区刷新的三个核心规则

1、当进程结束的时候,会自动刷新,默认会调用fflush

2、如果目标文件是显示器,默认时进行行缓冲 (行刷新),因为人在看的时候是以行为单位的。

3、普通文件一般是全缓冲,就是缓冲区写满了才会刷新。

扩充调用fclose会刷新语言级缓冲区

2.3 exit 与 _exit

进程控制这一期博客中,我们提到exit会刷新缓冲区,而_exit不会刷新缓冲区时,说"这也侧面显示出缓冲区与刷新缓冲区的操作,一定不在内核中进行"。

如上图,今天我们就知道缓冲区与刷新缓冲区的操作在语言层。

2.4 向显示器打印的几种做法,以及一个问题

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
    // C库函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char* ch = "hello fputs\n";
    fputs(ch, stdout);

    // 系统调用
    const char* ch1 = "hello write\n";
    write(1, ch1, strlen(ch1));

    return 0;
}

如上就是向显示器打印的几种做法,接下来我们看看运行效果:

如上就是我们的预期结果。

如上,这样重定向运行之后为什么系统调用在最前面呢? 因为系统调用是直接向文件内核级缓冲区写的,所以它在最前面,C库函数的都是向语言级缓冲区写的,所以会在系统调用的后面

  • 问题

接下来,我在代码的结尾加上一个fork,我们再来看看现象。

如上图,为什么向显示器打印的是四条消息,而向文件重定向之后却是七条消息?

首先来看向显示器打印,显示器打印是行刷新,也就是fork之前,它们都已经打印到显示器上了,系统调用就更不用说了,它直接就写了,不需要刷新

其次再来看重定向,重定向就将向显示器写,转化成了向普通文件里写。看上图的打印结果,我们发现重复的都是使用C库函数打印的消息。

向普通文件中写入是全缓冲,只有当语言级缓冲区写满时才会刷新,很明显这三句话根本写不满语言级缓冲区,之后就fork创建子进程了,此时父子进程的语言级缓冲区里都有这三条消息,之后就return了,此时父子进程都要主动进行刷新,所以我们才看到了重复的那三条消息。而系统调用早在fork之前就已经写给文件内核级缓冲区了,它不需要刷新

2.5 内核文件缓冲区

只要把数据从用户缓冲区拷贝到了内核文件缓冲区,就相当于将数据交给了硬件。客观上讲,就是将数据写给了FILE对应的文件内核级缓冲区,之后OS就会对这些数据进行自主刷新,OS有自己的刷新策略,比如立即刷新或者等OS空闲时再自主刷新。总之,这个工作是OS自己决定的。

OS向磁盘做刷新也是有成本的,这也是文件内核级缓冲区存在的原因!这样就可以提高OS的效率

如果用户要求OS立即进行刷新,操作系统也给了相关的系统调用,fsync

三、封装体验缓冲

上面我们了解了那么多,现在我们也要对上面所描述的系统调用进行封装,我们要封装实现fopen、fputs、fflush、fclose等等。

上面我们看到了FILE结构体,typedef struct _IO_FILE FILE;,接下来我们就简单实现一下这个结构体。我们整体采用头文件+头文件实现+源文件的形式展开。

mystdio.h:

cpp 复制代码
#pragma once

#include <stdio.h>

#define SIZE 1024

#define NONE_BUFFER  1 // 无缓冲
#define LINE_BUFFER  2 // 行缓冲
#define FULL_BUFFER  4 // 全缓冲

#define MODE 0666 // 默认创建文件的权限

typedef struct my_IO_FILE
{
    int fd; // 文件描述符
    int flags; // 打开文件方式
    int flush_mode; // 缓冲区刷新方式
    char outbuffer[SIZE]; // 输出缓冲区
    int pos; // 在缓冲区的书写位置
    int capcaity; // 缓冲区容量
}myFILE;

myFILE* myfopen(const char* filename, const char* mode); // a、w、r
int myfputs(const char* str, myFILE* fp);
void myfflush(myFILE* fp);
void myfclose(myFILE* fp);

如上,我只设置了一个语言级输出缓冲区。

myfopen函数的具体实现:

cpp 复制代码
myFILE* myfopen(const char* filename, const char* mode) // a、w、r
{
    int fd = -1;
    int flags = 0; //记录文件打开方式
    if(strcmp(mode, "r") == 0) // 只读
    {
        flags = O_RDONLY;
        fd = open(filename, flags);
    }
    else if(strcmp(mode, "w") == 0) // 写
    {
        flags = O_WRONLY | O_CREAT | O_TRUNC;
        fd = open(filename, flags, MODE);
    }
    else if(strcmp(mode, "a") == 0) // 追加
    {
        flags = O_WRONLY | O_CREAT | O_APPEND;
        fd = open(filename, flags, MODE);
    }
    else
    {
        // 其它模式,暂不添加
    }

    if(fd < 0)
        return NULL;

    myFILE* fp = (myFILE*)malloc(sizeof(myFILE)); // 申请 myFILE 对象
    if(fp == NULL)
        return NULL;

    // 维护 myFILE 结构体中的内容
    fp->fd = fd; 
    fp->flags = flags;
    fp->flush_mode = LINE_BUFFER;
    fp->capcaity = SIZE;
    fp->pos = 0;

    return fp;
}

我们在之前了解到FILE对象就是在fopen内部申请的,所以我们也是这样实现的。具体实现了三种文件的打开方式,有r、w、a

fflush函数的具体实现:

这里我们的fflush设计的是会调用我们内部实现的一个函数。因为C库函数中的fputs中也会有尝试刷新的逻辑,但C库中是将尝试刷新函数和强制刷新函数fflush分开实现的,感觉有点麻烦,不如我们自己实现一个函数,尝试刷新和强制刷新都调用这一个函数。

cpp 复制代码
#define TRY_FLUSH  1 // 尝试刷新
#define MUST_FLUSH 2 // 强制刷新

static void myFFlush(myFILE* fp, int mode)
{
    if(fp->pos == 0)
    {
        return;
    }

    if(fp->flush_mode == LINE_BUFFER) // 行缓冲
    {
        if((fp->outbuffer[fp->pos - 1] == '\n') || (mode == MUST_FLUSH))
        {
            // 写到文件内核级缓冲区
            write(fp->fd, fp->outbuffer, fp->pos);
            fp->pos = 0; // 清空缓冲区
        }
    }
    else if(fp->flush_mode == FULL_BUFFER)
    {
        // ...
    }
    else if(fp->flush_mode == NONE_BUFFER)
    {
        // ...
    }
}

void myfflush(myFILE* fp)
{
    myFFlush(fp, MUST_FLUSH);
}

如上,我们的FILE结构体中的刷新模式默认设置的就是行刷新,所以剩下的刷新方式就不实现了。

fputs的具体实现:

cpp 复制代码
int myfputs(const char* str, myFILE* fp)
{
    if(strlen(str) == 0)
        return 0;

    // 将数据拷贝到语言级缓冲区
    memcpy(fp->outbuffer + fp->pos, str, strlen(str));
    fp->pos += strlen(str);

    // 尝试刷新
    myFFlush(fp, TRY_FLUSH);

    return strlen(str);
}

这里实现的时候使用到了C库拷贝函数memcpy,具体用法见博客:点击跳转

fclose的具体实现:

它只做这几件事:强制刷新,关闭文件,释放FILE对象

cpp 复制代码
void myfclose(myFILE* fp)
{
    // 1、强制刷新到内核
    myFFlush(fp, MUST_FLUSH);
    
    // 1.2 强制刷新到磁盘
    //fsync(fp->fd); // 不必选
    
    // 2、关闭文件
    close(fp->fd);

    // 3、释放 FILE 对象
    free(fp);
}

主函数的内容:

cpp 复制代码
#include "mystdio.h"
#include <unistd.h>

int main()
{
    myFILE* fp = myfopen("log1.txt", "w");
    if(fp == NULL)
    {
        perror("myfopen file");
        return 1;
    }

    int cnt = 3;
    const char* str = " hello world!";
    while(cnt--)
    {
        myfputs(str, fp);
        printf("outbuffer: %s, pos: %d\n", fp->outbuffer, fp->pos);
        sleep(1);
    }

    myfclose(fp);

    return 0;
}

现在我们要进行程序运行测试,如上我们的fputs一共执行3次,打印一次暂停一秒,现在我们要在程序运行的时候,同时观测log1.txt中的内容变化。上面的程序中,我们要输入的内容没有加\n,也就是不会触发行缓冲,但我们最后调用了myclose函数,所以我们观察到的现象应该是最后一下子出现了三条内容。

测试结果

如上,我们再右边每隔一秒观测一次这个文件,结果果然是运行的前三秒文件中没有内容,等到程序结束时,fclose强制刷新缓冲区后文件中才有内容。

我们再把要打印的内容加上\n

cpp 复制代码
const char* str = " hello world!\n";

再次运行测试:

如上,每打印一次就刷新一次,这就是行缓冲。

三、理解标准错误

我们执行进程是为了完成任务,也就是我们要对数据做加工处理,只要有数据就存在数据的源头也就是输入,也就存在数据的处理结果也就是输出,所以标准输入,0,stdin标准输出,1,stdout的存在我们都能理解,它们的存在也方便我们对程序进行debug。那么为什么要存在标准错误呢?

在你的程序处理好的数据结果里面,有你想看的正常数据,也可能有你不想看的错误信息,而有了标准错误,2,stderr,我们就可以对这些结果数据进行分流处理。

标准错误的存在确实是为了让程序的输出意图更清晰,让用户能够选择性地接收和处理不同类型的程序输出

重定向的灵活性

cpp 复制代码
# 只重定向标准输出,错误信息仍显示在终端
./program > output.txt

# 只重定向标准错误
./program 2> error.log

# 分别重定向输出和错误到不同文件
./program > output.txt 2> error.log

# 合并输出和错误到同一文件
./program > combined.txt 2>&1

我们接下来写一个程序,你就知道上面这些指令是干嘛用的了。

cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main()
{
    // 正常数据, 标准输入:1
    printf("正常数据1\n");
    fprintf(stdout, "正常数据2\n");
    const char* str = "这是由 write 打印的正常消息\n";
    write(1, str, strlen(str));

    // 错误信息, 标准错误:2
    fprintf(stderr, "错误数据1\n");
    const char* str1 = "这是由 write 打印的错误消息\n";
    write(2, str1, strlen(str1));
    perror("hello perror");

    return 0;
}

这是分别向stdout打印和向stderr打印的信息。

正常运行

标准输出和标准错误默认都是向显示器打印,其中标准错误是无缓冲,它是直接打印的,而标准输出面对显示器是行缓冲

执行./program > output.txt./program 2> error.log命令。

如上当执行./test > log.txt时,>默认是将1文件描述符中的内容指向log.txt,这是一种省略写法,其实是1>。所以这个命令就会将向stdout: 1写的正常消息重定向到log.txt文件中。

当执行./test 2> log1.txt时,2>默认是将2文件描述符中的内容指向log1.txt,所以这个命令就会将向stderr: 2写的错误消息重定向到log1.txt文件中。
而完整的命令应该是./test > log.txt 2> log1.txt,这样一条命令就分别将正常消息打印到了log.txt中,将错误消息打印到了log1.txt

如果你想要合并输出和错误到同一文件,你就执行./test > test.txt 2>&1 ,这条命令是先重定向stdout,再让stderr跟随,其中2>&1是让stderr指向stdout当前指向的地方,也就是让文件描述符2指向的也是test.txt文件了,这样信息就打印到同一个文件中了。

对数据的理解 :操作系统在存储数据的时候它可不管你存的是字符串、整型还是图片,在它看来你存储的都是void*的。那么这些字符串、图片等等都有实际类型,这是谁在区分呢?是语言和用户在区分,因为用户可以区分不同的类型,所以语言给了不同的类型接口。

最佳实践:对文件进行操作使用语言级函数,除非特殊情况使用系统调用。

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~

相关推荐
Doro再努力8 小时前
【Linux操作系统12】Git版本控制与GDB调试:从入门到实践
linux·运维·服务器·git·vim
Lsir10110_8 小时前
【Linux】进程信号(上半)
linux·运维·服务器
开开心心就好8 小时前
发票合并打印工具,多页布局设置实时预览
linux·运维·服务器·windows·pdf·harmonyos·1024程序员节
予枫的编程笔记9 小时前
【Linux进阶篇】从基础到实战:grep高亮、sed流编辑、awk分析,全场景覆盖
linux·sed·grep·awk·shell编程·文本处理三剑客·管道命令
Sheep Shaun9 小时前
揭开Linux的隐藏约定:你的第一个文件描述符为什么是3?
linux·服务器·ubuntu·文件系统·缓冲区
Tfly__9 小时前
在PX4 gazebo仿真中加入Mid360(最新)
linux·人工智能·自动驾驶·ros·无人机·px4·mid360
陈桴浮海9 小时前
【Linux&Ansible】学习笔记合集二
linux·学习·ansible
生活很暖很治愈9 小时前
Linux——环境变量PATH
linux·ubuntu
?re?ta?rd?ed?9 小时前
linux中的调度策略
linux·运维·服务器