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 是救命稻草,关键数据写入后立即调用
  • 理解缓冲机制,才能写出健壮的程序
文件缓冲区工作机制 用户空间 应用程序 (fprintf/fwrite) 文件缓冲区 全缓冲 / 行缓冲 / 无缓冲 系统调用 (write/read) 内核空间 内核缓冲区 (Page Cache) 磁盘 / 硬件 物理 I/O 操作 内核缓冲区 (读缓存) 数据拷贝 fflush 用户缓冲区 内核缓冲区 磁盘硬件 fflush 触发点

一句话总结:缓冲区是用户空间和内核空间之间的"蓄水池",setbuf/setvbuf 决定蓄水池的大小和策略,fflush 则是手动开闸放水。理解这三者的关系,你的文件操作代码才能既高效又可靠。