4. 标准I/O库:FILE流、fopen/fclose、fread/fwrite、fseek、缓冲机制

好,咱们今天聊聊标准I/O库。说实话,这是系统编程里最常用、也最容易出坑的一块。很多新手觉得文件操作嘛,不就是打开、读写、关闭?嗯,表面上看确实是这样。但等你真正遇到线上程序莫名其妙丢数据、性能上不去的时候,才会意识到——哦,原来缓冲机制才是灵魂。

4.1 FILE流:一个“包装盒”

先说说FILE流。你想想看,系统调用(比如read、write)是直接跟内核打交道的,每次调用都要陷入内核态,开销不小。标准I/O库在中间加了一层——FILE结构体。它把文件描述符fd、缓冲区、当前读写位置等信息打包在一起。

我个人习惯把FILE流理解成一个“包装盒”。你往盒子里放数据,它攒够了再一次性交给内核。这样效率高很多。我在项目中遇到过一个大文件处理的场景,用裸的系统调用逐字节读,慢得让人抓狂。换成fread后,速度直接起飞。

核心要点:FILE流是带缓冲的,系统调用是不带缓冲的。这是两者最本质的区别。

4.2 fopen / fclose:开门与关门

fopen打开一个文件,返回一个FILE*指针。fclose关闭它,释放资源。这俩成对出现,缺一不可。

FILE *fp = fopen("test.txt", "r");
if (fp == NULL) {
    perror("fopen failed");
    return -1;
}
// ... 读写操作 ...
fclose(fp);

模式参数有很多:

模式 含义 文件不存在时
"r" 只读 返回NULL
"w" 只写,清空原内容 创建新文件
"a" 追加写 创建新文件
"r+" 读写,不会清空 返回NULL
"w+" 读写,清空原内容 创建新文件

我曾经犯过一个低级错误:用"r"模式打开一个不存在的文件,没检查返回值就直接往下读写。结果程序崩溃了。记住:每次fopen后一定要检查fp是否为NULL。

4.3 fread / fwrite:批量搬运工

fread和fwrite是标准I/O库的核心读写函数。它们操作的是缓冲区,不是直接跟磁盘打交道。

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

参数有点绕:size是每个元素的大小,nmemb是元素个数。返回值是实际读写的元素个数。举个例子:

char buf[1024];
size_t ret = fread(buf, 1, sizeof(buf), fp);
if (ret != sizeof(buf)) {
    if (feof(fp)) {
        printf("读到文件末尾了\n");
    } else if (ferror(fp)) {
        printf("读取出错\n");
    }
}

这里有个坑:fread返回值小于请求数,不一定是出错。可能是读到文件末尾了。所以要用feof和ferror来区分。

我在项目中遇到过一个问题:用fwrite写数据,程序正常退出,但文件内容不完整。后来发现是缓冲区没刷出去。嗯,这就引出了缓冲机制。

4.4 缓冲机制:三种模式

标准I/O库有三种缓冲模式:

  • 全缓冲:缓冲区满了才刷。普通磁盘文件默认是全缓冲。
  • 行缓冲:遇到换行符就刷。终端设备(stdout)默认是行缓冲。
  • 无缓冲:每次写入立即刷。stderr默认是无缓冲。

为什么会这样设计?你想想看,磁盘文件追求吞吐量,攒一批再写效率高。终端追求实时性,打一行字就显示出来。错误信息需要立即输出,所以无缓冲。

可以用setvbuf来修改缓冲模式:

setvbuf(fp, NULL, _IONBF, 0);  // 无缓冲
setvbuf(fp, NULL, _IOLBF, 0);  // 行缓冲
setvbuf(fp, buf, _IOFBF, sizeof(buf)); // 全缓冲,使用自定义缓冲区

避坑指南:如果你在写日志文件,程序崩溃了,最后几条日志可能还在缓冲区里没写出去。我建议关键日志用fflush强制刷,或者用无缓冲模式。

4.5 fseek:文件定位

fseek可以移动文件读写位置。原型:

int fseek(FILE *stream, long offset, int whence);

whence有三种:

  • SEEK_SET:从文件开头算起
  • SEEK_CUR:从当前位置算起
  • SEEK_END:从文件末尾算起

举个例子:

fseek(fp, 0, SEEK_END);  // 跳到文件末尾
long size = ftell(fp);    // 获取文件大小
fseek(fp, 0, SEEK_SET);  // 跳回开头

这里要注意:fseek会清除缓冲区中的未写入数据。如果你先fwrite了一些数据,还没刷出去,然后fseek到别的位置,那些数据就丢了。

我曾经在写一个数据库索引文件时,先写了几条记录,然后fseek回去修改头部信息。结果发现之前写的数据没了。排查了半天,才发现是fseek把缓冲区清掉了。解决方案是先fflush,再fseek。

4.6 知识体系总览

下面这张图展示了标准I/O库的核心逻辑:

用户空间 应用程序 标准I/O库 (FILE流) fopen / fclose / fread / fwrite / fseek 用户态缓冲区 内核空间 系统调用 (read/write) 磁盘 / 文件系统 全缓冲 / 行缓冲 / 无缓冲

说白了,标准I/O库就是在应用程序和系统调用之间加了一层缓冲。这一层让我们的代码更简洁、更高效,但也带来了缓冲相关的坑。理解缓冲机制,是掌握标准I/O库的关键。

总结一下:

  • FILE流是带缓冲的,系统调用是不带缓冲的
  • fopen/fclose要成对使用,记得检查返回值
  • fread/fwrite的返回值要结合feof/ferror来判断
  • 缓冲模式有三种:全缓冲、行缓冲、无缓冲
  • fseek会清除缓冲区,先fflush再fseek

好了,标准I/O库就聊到这儿。记住一句话:缓冲区是你的朋友,也是你的敌人。用好了效率翻倍,用不好数据丢失。多写代码,多踩坑,自然就熟练了。