11、文件缓冲区与刷新:文件缓冲机制、setbuf与setvbuf函数、fflush函数详解
说到文件操作,很多人只盯着 open、read、write 这些函数。但我要说,真正决定程序性能的,往往是那个看不见的缓冲区。
你想想看,如果每次读写一个字节都要去磁盘走一趟,那效率得多低?磁盘的访问速度比内存慢好几个数量级。所以 C 语言标准库搞了个缓冲机制——先把数据攒一攒,凑够一批再一次性写入磁盘。这就是文件缓冲区的由来。
11.1 文件缓冲机制:为什么需要缓冲区?
我刚开始做嵌入式开发时,写过一段日志程序。每产生一条日志就调用一次 fprintf,结果发现程序跑得特别慢。后来一查,问题就出在频繁的磁盘写入上。
文件缓冲说白了就是一块内存区域。标准 I/O 库在读写文件时,先把数据放到这块内存里,等条件满足时再统一搬运到磁盘。这样做的好处很明显:
- 减少系统调用次数:每次 write 都是系统调用,开销很大
- 匹配硬件特性:磁盘按扇区读写,攒够一扇区再写效率最高
- 提高吞吐量:批量处理比零散处理快得多
C 语言标准库定义了三种缓冲模式:
| 缓冲模式 | 宏定义 | 行为说明 |
|---|---|---|
| 全缓冲 | _IOFBF | 缓冲区填满后才进行实际 I/O 操作 |
| 行缓冲 | _IOLBF | 遇到换行符时刷新缓冲区 |
| 无缓冲 | _IONBF | 每次读写都直接进行 I/O 操作 |
默认情况下,标准输入输出是行缓冲的,普通文件是全缓冲的。标准错误是无缓冲的——这设计很合理,错误信息需要立即显示,不能等缓冲区满了才输出。
核心要点:缓冲机制是标准 I/O 库的"黑箱操作",但作为开发者,你必须理解它,否则程序行为可能出乎你的意料。
11.2 setbuf 函数:简单粗暴的缓冲设置
setbuf 是最简单的缓冲设置函数。它的原型长这样:
#include <stdio.h>
void setbuf(FILE *stream, char *buf);
用法很简单:
- 如果 buf 是 NULL,关闭缓冲(相当于无缓冲模式)
- 如果 buf 指向一个缓冲区,使用全缓冲模式
注意,setbuf 不返回任何值,也没有错误检查。我个人觉得这个函数太"糙"了,生产环境中很少直接用。
#include <stdio.h>
int main() {
char buffer[BUFSIZ];
FILE *fp = fopen("test.txt", "w");
// 使用自定义缓冲区
setbuf(fp, buffer);
fprintf(fp, "Hello, buffer!\n");
fclose(fp);
return 0;
}
BUFSIZ 是标准库定义的宏,通常是 512 或 1024。你想想看,这个大小够用吗?对于大多数场景是够的,但如果你要处理大文件,可能就不太合适了。
注意:setbuf 必须在文件打开后、任何读写操作之前调用。而且缓冲区必须在整个文件生命周期内有效——如果你用局部数组做缓冲区,函数返回后缓冲区就失效了,后果很严重。
11.3 setvbuf 函数:精细控制的正确选择
setvbuf 是 setbuf 的增强版。它提供了更精细的控制,也是我推荐使用的函数。
#include <stdio.h>
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
参数说明:
- stream:文件指针
- buf:缓冲区地址,NULL 表示让系统自动分配
- mode:缓冲模式,_IOFBF、_IOLBF 或 _IONBF
- size:缓冲区大小
返回值:成功返回 0,失败返回非 0。
我曾经在一个数据采集项目中用过 setvbuf。采集器每秒产生几千条数据,每条数据很小。如果默认缓冲,写入效率很低。我手动设置了一个 64KB 的缓冲区,性能提升了将近 5 倍。
#include <stdio.h>
int main() {
FILE *fp = fopen("data.log", "w");
if (!fp) {
perror("fopen");
return -1;
}
// 设置 64KB 全缓冲
char *mybuf = malloc(64 * 1024);
if (setvbuf(fp, mybuf, _IOFBF, 64 * 1024) != 0) {
fprintf(stderr, "setvbuf failed\n");
free(mybuf);
fclose(fp);
return -1;
}
// 写入大量数据...
for (int i = 0; i < 100000; i++) {
fprintf(fp, "data point %d\n", i);
}
fclose(fp);
free(mybuf);
return 0;
}
经验之谈:缓冲区大小一般设为磁盘块大小的整数倍。Linux 下通常是 4096 字节。设得太大浪费内存,设得太小又起不到缓冲效果。我个人习惯用 4096 或 8192。
11.4 fflush 函数:手动刷新缓冲区
fflush 是手动触发缓冲区刷新的函数。它的原型很简单:
#include <stdio.h>
int fflush(FILE *stream);
调用 fflush 会把缓冲区中未写入的数据立即写入文件。如果 stream 是 NULL,则刷新所有打开的输出流。
什么时候需要手动刷新?我举几个实际场景:
- 日志系统:程序崩溃时,未刷新的日志会丢失。所以关键日志要立即刷新
- 交互式程序:提示用户输入前,确保输出缓冲区已清空
- 多进程/多线程:一个进程写入,另一个进程读取,需要及时刷新
#include <stdio.h>
#include <unistd.h>
int main() {
FILE *fp = fopen("progress.log", "w");
if (!fp) return -1;
for (int i = 0; i < 100; i++) {
fprintf(fp, "Progress: %d%%\n", i);
fflush(fp); // 立即写入,防止程序崩溃丢失数据
// 模拟耗时操作
usleep(100000);
}
fclose(fp);
return 0;
}
避坑指南:我曾经在项目中遇到过一个 bug——程序正常退出时数据完整,但异常终止时最后几条日志总是丢失。查了半天,发现是 fclose 之前没有 fflush。虽然 fclose 会自动刷新缓冲区,但如果程序在 fclose 之前崩溃了,缓冲区里的数据就没了。所以关键数据写入后立即 fflush 是个好习惯。
11.5 缓冲机制与程序退出
程序正常退出时,标准库会自动刷新所有缓冲区。但异常退出(比如调用 _exit、abort,或者收到 SIGKILL 信号)时,缓冲区不会被刷新。
这一点在嵌入式开发中尤其重要。嵌入式设备可能随时断电,如果数据还在缓冲区里没写出去,那就丢了。
我做过一个车载数据记录仪,要求断电后数据不丢失。解决方案很简单:每写入一条关键数据就调用 fflush,同时把缓冲区设小一点,减少数据丢失的风险。
#include <stdio.h>
#include <signal.h>
void sigint_handler(int sig) {
// 收到 Ctrl+C 时刷新所有缓冲区
fflush(NULL);
_exit(0);
}
int main() {
signal(SIGINT, sigint_handler);
FILE *fp = fopen("critical.dat", "w");
setvbuf(fp, NULL, _IONBF, 0); // 干脆用无缓冲
// 写入关键数据...
fprintf(fp, "critical data\n");
fclose(fp);
return 0;
}
总结一下:
- setbuf 简单但功能有限,适合快速原型
- setvbuf 灵活可控,生产环境首选
- fflush 是救命稻草,关键数据写入后立即调用
- 理解缓冲机制,才能写出健壮的程序
一句话总结:缓冲区是用户空间和内核空间之间的"蓄水池",setbuf/setvbuf 决定蓄水池的大小和策略,fflush 则是手动开闸放水。理解这三者的关系,你的文件操作代码才能既高效又可靠。