【Linux系统编程】理解“缓冲区”
2026/9/24 20:50:47 网站建设 项目流程

文章目录

  • 引入
  • 1. 什么是缓冲区
  • 2. 缓冲类型与刷新策略
  • 3. FILE
    • C语言文件IO全流程
  • 4. 为什么要有缓冲区
  • 5. 解决历史遗留问题
  • 6. 研究一段有趣的代码
  • 7. 内核缓冲区何时刷新
  • 8. 聊聊标准错误

之前我们简单了解过缓冲区,知道有用户级缓冲区和内核级缓冲区。今天这篇文章我们来更加深入地了解一下缓冲区。

引入


这张图我们已经很熟悉了。
这里我们来强调三个要素:
第一点:

通过struct file,我们可以直接或间接地找到文件相关的属性,诸如:
f_flags:打开标志。如O_RDONLY、O_NONBLOCK、O_SYNC。由open系统调用传递。
f_mode:文件模式(访问权限)。表示FMODE_READ、FMODE_WRITE。与文件系统inode权限分开。
f_pos:文件偏移量。当前读写位置(逻辑文件中的偏移量)。对于顺序I/O至关重要。锁/更新需要谨慎。

等等。
第二点:
我们上一篇文章讲到的

通过struct file还可以找到文件操作方法集。
第三点,内核中,一定维护了与文件IO相关的内核缓冲区。

【Linux系统编程】基础IO第二讲——文件描述符文章中2.3的补充了解大家可以再次回顾一下。

再来回忆一下:

之前我们进程退出的时候,我们对比了exit函数 和 系统调用_exit的区别

exit() 是库函数:它会在调用 _exit() 系统调用之前,先执行用户态的清理工作,其中就包括刷新所有标准I/O流的缓冲区。然后再调用 _exit() 陷入内核进行相关的各种操作(比如进程相关资源的释放等)
_exit() 是系统调用:它直接进入内核,不会进行任何用户态的缓冲区刷新。因此,如果程序直接调用 _exit() 退出,仍在用户态缓冲区中的数据就会丢失。
甚至当时我们给出了结论:

这个缓冲区一定不在内核中!
那怎么回事呢?
刚才说了内核维护了缓冲区,这里有提到这个缓冲区一定不在内核。
原因就在于:一个是用户缓冲区、一个是内核缓冲区!

1. 什么是缓冲区

缓冲区的本质,就是一段内存空间!
也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据其对应的是输入设备还是输出设
备,分为输入缓冲区和输出缓冲区。

下面我们举一个例子来理解缓冲区:

假如你的朋友马上要过生日了,你想要送他一个礼物,比如一个键盘。但是现在你在宁夏上学、你朋友在河南上学。那你要怎么送过去呢?
难道你要骑一个小电驴过去送吗?
那这样你要耗费大量的时间和精力。

那我们知道其实并不需要这么麻烦。
因为在你和你朋友学校都有菜鸟驿站,那么此时:

你只需下楼把礼物交给菜鸟驿站邮寄即可,然后就可以直接回去了。回去你的室友问你,你的键盘去哪了,你说送给我朋友了,但是此时你真的把礼物送到朋友手里了嘛。
其实并没有,还在菜鸟驿站暂存着呢。但是站在你的角度,你就可以认为礼物已经送出去了,因为后面菜鸟驿站会通过某种快递把礼物真的送到你朋友那里。
那这里的菜鸟驿站就对应了缓冲区,送的礼物就对应IO的数据,我们把礼物拿给菜鸟驿站,就相当于调用了诸如fwrite这样的函数,本质就是拷贝。
那对于进程来说,调用完fwrite就可以继续执行后面的代码了。相比于第一种方式就节省了时间,就有时间去做更多其它的事情了。
所以,缓冲区最大的意义就是:可以提高使用缓冲区的进程的效率。
等到了某个时刻,菜鸟驿站把礼物寄送出去,这就对应缓冲区的刷新

对于菜鸟驿站来说:

你来邮寄键盘,假设现在只有这一件包裹,菜鸟驿站会单独只把这一件货物进行运输嘛?
来一个人邮寄东西,就单独运输一次,会这样做吗?
那这样成本就太高了,这个菜鸟驿站干不了多久就得倒闭。
正常应该怎么做:
你把键盘拿过去,菜鸟驿站并不会立即发出去,而是先暂存起来,等到满足某种条件,比如仓库放满了/货物够装一车了,然后一次运输多件货物,这样效率不就更高了嘛。
即缓冲区允许数据在缓冲区中暂存积压,然后一次刷新更多的数据,减少IO的次数。

所以,目前对于缓冲区,两点理解:

第一点:站在使用者进程的角度,可以提高使用者进程的效率
第二点:缓冲区可以暂存挤压数据,然后做批量化刷新
基于这两点,缓冲区就可以实现提高效率的功能。

2. 缓冲类型与刷新策略

那菜鸟驿站应该在什么时候发快递呢?即缓冲区即刷新策略,这与缓冲类型密切相关。。
标准I/O提供了3种类型的缓冲区:

  1. 全缓冲

这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用(write)刷新缓冲区。对于磁盘文件的操作通常使用全缓冲的方式访问。

  1. 行缓冲

在行缓冲情况下,当在输入和输出中遇到换行符时,会触发缓冲区刷新。当流涉及终端(如stdin/stdout)时,使用行缓冲。
由于标准I/O库每行的缓冲区长度是固定的,一旦缓冲区填满,即使没有换行符,也会执行I/O系统调用刷新。

  1. 无缓冲

无缓冲区是指标准I/O库不对数据进行缓存,立即调用系统调用刷新。

除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:

1.缓冲区满时;
2.进程强制刷新,如执行fflush;
3.进程退出

3. FILE

下面我们来写个代码:


这个例子其实我们之前已经演示过了。
运行看看结果:

先打印,然后休眠3秒,结束。
然后,我们把\n去掉

重新运行:


会发现这次是先休眠,再打印。
那来问大家一下,这里printf和sleep谁先运行?
毋庸置疑是printf先运行,那为什么去掉\n我们看到的现象是先休眠,再打印呢?
不管我们有没有加\n,我们的这个字符串都会被暂存到缓冲区里面。
那为什么两个程序打印的时间不一样呢?
原因其实是因为两个程序的缓冲区刷新的时机不同。
上面我们提到stdin/stdout使用行缓冲,所以第一次有\n就触发了缓冲区刷新,底层调用系统调用把数据从用户缓冲区拷贝到内核缓冲区,接着在合适的时机刷新到终端。我们就看到是先打印,再休眠。
而去掉\n,就不会触发缓冲区刷新,字符串就一直暂存在缓冲区,直到休眠完,最后程序退出,刷新缓冲区,然后我们才看到。

那sleep期间,这个数据在哪呢?

当然就是在缓冲区中!
是哪种缓冲区呢?我们上面提到有用户级缓冲区和内核级缓冲区。
🆗,这里当然是用户级缓冲区(或者叫语言级缓冲区)!
那这个缓冲区在哪呢?
我们之前有提过!
在FILE结构体内部!FILE结构体内部封装了用户级缓冲区,并且必定封装了文教描述符fd
C语言标准库中,所有文件访问函数(fopen、fread、fwrite、fprintf、fclose)都必须通过 FILE* 流指针来操作。
这是C标准规定的抽象接口,保证了代码在不同操作系统(Windows、Linux、macOS)上的可移植性。
你无法在标准C中直接使用文件描述符,因为那是操作系统特有的概念。
我们可以来看一下Linux平台下FILE结构体的定义:

C语言文件IO全流程

一个进程被成功创建,默认打开三个流,标准输入、标准输出、标准错误。
假设现在这个进程要向一个文件中写入一个字符串(比如一个字符数组存的字符串/一个常量字符串存在常量区,都在用户空间),那首先打开这个文件,会获得一个FILE*的文件指针。
然后比如我们调用fputs向打开的文件中写入字符串,那此时就直接写入到文件中了嘛?
并不是,而是先会把数据从用户空间拷贝到FILE中维护的用户级缓冲区。
FILE内部封装了文件描述符fd,而fputs底层又封装了write系统调用。
等到合适的时机,比如缓冲区满或者进程退出(磁盘文件),此时用户级缓冲区会触发刷新,那么就会调用write系统调用,把数据从用户及缓冲区拷贝到内核缓冲区。然后再由操作系统在合适的时机将数据刷新到文件中。

那么再来改一下上面的代码:


结果肯定还是先休眠,再打印


大家看到,我们这里调用了好多次printf,但是实际是调用一次,缓冲区就刷新一次嘛。
并不是,很明显这里是:
所有的数据都先挤压到了用户级缓冲区,没有\n也没有填满缓冲区,所以不会触发刷新,直到休眠完进程退出,底层调用系统调用刷新了用户缓冲区到内核缓冲区,然后由操作系统刷新到显示器。

这是什么?

不就是我们上面说的菜鸟驿站并不会来一个货物,单独邮寄一次,而是会先暂存堆积起来,然后一次运输大量货物,提高效率。
即缓冲区允许数据挤压,然后批量化刷新,提高效率(C库函数底层封装了系统调用,这样就减少了系统调用的次数,系统调用是有成本的,所以这样做性能自然大幅提升)。

4. 为什么要有缓冲区

所以我们之前经常提到的缓冲区,是指用户级缓冲区:

他在哪呢?
在FILE结构体中维护,那为什么需要缓冲区呢?
第一点,就刚才上面提到的:
缓冲区允许数据挤压,然后批量化刷新,提高效率(C库函数底层封装了系统调用,这样就减少了系统调用的次数,系统调用是有成本的,所以这样做性能自然大幅提升)
第二点:我们这里提到的用户级缓冲区是C语言提供的,也叫语言级缓冲区,那C语言为什么要提供呢?
如果C语言不提供缓冲区,那么我们调用库函数进行IO,数据就没地方暂存,就需要直接调用底层系统调用(你自己送礼物)。
但有了缓冲区的话,我调用库函数,只需先把数据拷贝到缓冲区,然后就可以直接返回了,等到缓冲区刷新的时候由操作系统调用系统调用一次IO多组数据(使用菜鸟驿站送礼物)。
这就加快了IO函数的调用速度,单位时间内就可以执行更多的C代码了。

之前我们提过printf和scanf是格式化输入输出:

格式化后的内容放到哪里了,直接赋给变量或直接打印到显示器了嘛。
不是的,也是先放到了缓冲区里!
然后看,如果你的数据带了\n,那就触发刷新(调用write系统调用),如果没有,就先暂存起来。

总结:

读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。

5. 解决历史遗留问题

文件描述符那篇文章,我们遗留了一个问题,当时我们写了这样一个代码:


先把1号文件描述符关闭,然后我们打开一个文件,那这个文件就被分配到了1号文件描述符,所以fd变量的值就是1。
然后我们printf打印这个fd
但是:

我们发现什么都没有打印,为什么呢?
因为printf固定是往1号文件描述符对应的文件打印的,它认为1号文件描述符对应的就是标准输出。
正常情况下这没有问题,但是我们现在把1关闭了,然后新打开一个文件,所以

文件描述符1就不再指向标准输出,而是指向我们打开的log.txt文件。
所以我们打印的信息就不会写入到显示器了,而是写到我们自己新打开的文件了。
但是:

我们会发现log.txt里面也没有任何东西。

为什么呢?今天我们就可以解释这个原因了。

现在文件描述符1确实指向log.txt了,但是我们调用printf它会立即写入到文件中吗?
现在我们知道,这里会先放到FILE中的用户缓冲区中,而现在1指向的是一个磁盘文件(全缓冲),不是显示器(行缓冲)了。
所以虽然有\n,但是不会触发刷新,而且也没有把缓冲区写满。
那就要等到程序结束时候才会刷新。
但是,程序结束之前我们又把这个文件描述符关掉了,那程序结束的时候还怎么往这个文件刷新呢!
所以,最后我们看到的结果是,这个文件确实被创建存在了,但是fd并没有写入进去。

所以我们当时做了这样的事情:

第一种方法:把close(fd)注释掉


就可以了。因为这样没有关闭文件描述符,所以进程退出的时候,就可以刷新到这个文件中了。
第二种方法:close(fd)之前强制刷新stdout的缓冲区,stdout 所封装的整数文件描述符就是 1

那这样当然也是可以的了

相信现在大家就明白了。

扩展:

在C++中,cin、cout、cerr分别对应标准输入流对象、标准输出流对象、标准错误流对象,它们的类定义中,必定也封装了文件描述符,维护了用户级缓冲区!

6. 研究一段有趣的代码

下面我们来看这样一段代码:

#include<stdio.h>#include<unistd.h>#include<sys/types.h>#include<sys/stat.h>#include<fcntl.h>#include<string.h>intmain(){// Cconstchar*s1="hello printf\n";printf("%s",s1);constchar*s2="hello fprintf\n";fprintf(stdout,"%s",s2);constchar*s3="hello fwrite\n";fwrite(s3,strlen(s3),1,stdout);// 系统调用constchar*s4="hello write[syscall]\n";write(1,s4,strlen(s4));// fork 创建子进程fork();return0;}

我们一起来看一下:

很简单,首先分别用C语言的三个IO函数向标准输出打印了三个字符串,然后又用系统调用write向标准输出打印了一个字符串。
最后,fork创建了一个子进程。

我们来编译运行一下看看结果:


没有问题,四个打印。
好像很简单啊,这有什么好看的!
接下来

如果把运行结果重定向到文件中,为什么是这样的内容呢?
系统调用打印我们放在最后了,正常打印它就是在最后,但是,但是重定向到文件的时候,它却跑到最前面了,并且,C语言IO函数的打印好像每个都打印了两次?

我们来分析一下:

首先,正常执行往显示器打印的情况:
前三个C语言的打印,显示器(标准输出)使用的是行缓冲

所以,这三个字符串虽然都是先写到用户级缓冲区中,但最后遇到\n就立即调用底层的write刷新到内核缓冲区了。
最后,又执行了一个write打印一个字符串。
所以往显示器打印的时候,就是按照顺序依次执行write系统调用,操作系统按顺序刷新到显示器,因此最后打印的结果和打印的顺序一样。
那重定向到文件呢?
首先,我们看到里面有7行,因为C语言的三个打印重复了。

我们先来分析系统调用为什么打印一次:
因为直接调用write的话就直接把这个字符串从用户空间拷贝到内核缓冲区了,那最后操作系统向文件或显示器刷新的时候,也就只有只有这一个字符串,刷新一次。
那为什么使用c库函数打印的都是出现两次呢?
因为我们使用C语言IO函数打印,都是先把数据放到stdout的用户级缓冲区中了,那\n不是会触发刷新嘛?
🆗,那是往显示器写,但是现在重定向了,是往磁盘文件写!
所以,此时底层的刷新策略就变成了全缓冲

所以这三个字符串就依次被添加到了stdout的缓冲区中,并且这三句字符串不足以填满缓冲区,因此就先被暂存在这里面,不会被刷新
那下面又调了write打印,它直接把字符串从用户空间拷到了内核缓冲区,然后操作系统就可以在合适的时机把它刷新到文件中了。
所以,我们看到write的打印跑到了最前面
此时那三个字符串还在用户级缓冲区中放着呢。
那再往下,我们又调用了fork,父子进程两个执行流,但是后面什么也没做,两进程依次退出
那进程退出,会刷新自己的缓冲区啊!
那创建子进程后,父子进程如果看待这个stdout的缓冲区呢,缓冲区里的内容属于进程的数据,刷新缓冲区,本质也是修改,所以——写时拷贝。
因此,这三个字符串被父子进程各刷新一次,最后我们看到重复出现了两次!

7. 内核缓冲区何时刷新

数据放到了内核缓冲区之后,操作系统何时把数据刷新到文件/外设呢?

操作系统把内核缓冲区中的数据刷新到外设/文件中,由什么完成?
不就是上一篇文章一切皆文件中讲的:

struct file中有一个const struct file_operations *f_op;
里面存放了一组函数指针,这些函数指针指向的是“针对特定文件类型/设备类型”所实现的读写例程。 对于磁盘文件,它指向文件系统模块(负责缓存和日志);对于键盘/串口,它指向字符设备驱动(负责直接操作硬件寄存器)。
最终不同的设备就调用到自己的不同的读写方法,把数据从内核刷新到指定设备/文件。
不同设备刷新策略是不一样的(所以底层指向的操作函数也是不一样的)
一般而言:
内核缓冲区的刷新是全缓冲的,当然如果是显示器这种设备就也是行刷新。
当然实际操作系统对内核缓冲区的刷新策略是更复杂的,不是单纯的全缓冲或者行缓冲。
内核中通常有一个单独的执行流,来根据内存的使用情况动态刷新(即使刷新条件不满足)。
那如果想手动强制刷新内核缓冲区的数据到指定的存储设备呢?

可以使用fsync这个系统调用。
像很多的数据库比如MySQL、redis(数据库本质也是文件/目录),要把数据存盘/落盘持久化存储的时候,通常在底层就会使用这个系统调用。

8. 聊聊标准错误

看下面这段代码:


使用C语言、C++分别向标准输出、标准错误打印字符串
标准输出、标准错误对应的都是显示器

没问题,都打印出来了,但是这样看两者好像没什么区别啊?
如果这样呢:

如果执行程序同时输出重定向到文件中,我们发现:
向标准错误输出的字符串为什么没有重定向到文件中,还是到显示器了?

为什么呢?

很简单:

因为输出重定向修改的是1号文件描述符的执行,跟2号没关系啊,2号文件描述符还是指向标准错误(对应显示器文件)
所以,printf和cout是往1号文件描述符指向的文件打印的;perror是往2号文件描述符指向的文件打印的。
那这就使得,在必要的时候,我们可以把正常信息和错误信息进行分离输出到不同的文件中

怎么做呢?


所以,我们之前这样的写法其实是一种简写:

这是让1号文件描述符指向log.txt,所以完整应该写法:

如果把标准输出把标准错误重定向到一个文件中:


不要这样写:

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询