文件操作(下):文件随机读写、文件缓冲区、标准设备文件

好,咱们接着聊文件操作。上一章我们把文件的基本读写、打开关闭这些基本功过了一遍。这一章要聊的,才是真正让C语言文件操作变得灵活、高效、甚至有点「高级感」的东西。

我个人习惯把文件操作分成三个层次:能用、会用、巧用。上一章是「能用」,这一章就是「会用」和「巧用」的结合。随机读写让你能精准定位文件中的任意位置,缓冲区机制让你理解读写效率的根源,标准设备文件则让你明白——在C语言眼里,键盘、屏幕、打印机,其实都是文件。

一、文件随机读写:别总是从头读到尾

你想想看,如果一本书你每次只能从第一页读到最后一页,想翻到第100页也得从头翻起,那得多痛苦?文件操作也是这个道理。

很多时候,我们并不需要顺序读写。比如一个数据库文件,我只想修改第100条记录;一个日志文件,我只想看最后10行。这时候,随机读写就派上用场了。

1. 文件位置指针

每个打开的文件,系统都会维护一个文件位置指针(也叫文件读写位置)。它指向当前要读写的字节位置。顺序读写时,这个指针自动往后移动。随机读写,说白了就是手动移动这个指针。

核心函数就三个:fseek()ftell()rewind()

核心函数速查表

函数作用原型
fseek移动文件位置指针int fseek(FILE *stream, long offset, int whence);
ftell获取当前文件位置long ftell(FILE *stream);
rewind将指针重置到文件开头void rewind(FILE *stream);

fseek 的第三个参数 whence 有三种取值:

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

举个例子,我想读取一个二进制文件的第5个int(假设每个int占4字节):

FILE *fp = fopen("data.bin", "rb");
if (fp == NULL) {
    perror("打开文件失败");
    return -1;
}

// 跳过前4个int,定位到第5个int
fseek(fp, 4 * sizeof(int), SEEK_SET);

int value;
fread(&value, sizeof(int), 1, fp);
printf("第5个int的值是: %d\n", value);

fclose(fp);

小技巧:fseek(fp, 0, SEEK_END) 配合 ftell(fp) 可以快速获取文件大小。我在项目中经常用这招来预分配缓冲区。

2. 二进制文件的随机读写

随机读写最常用的场景就是二进制文件。因为二进制文件的数据结构是固定的,每个记录的长度相同,定位起来非常方便。

我记得有一次做嵌入式设备的数据采集系统,传感器每秒钟产生一条记录,每条记录固定32字节。我们需要在设备断电重启后,快速定位到上次写入的位置继续存储。用 fseek 配合一个索引文件,几行代码就搞定了。

// 假设每条记录32字节,要读取第n条记录
int read_record(FILE *fp, int n, void *buffer) {
    if (fseek(fp, n * 32, SEEK_SET) != 0) {
        return -1;  // 定位失败
    }
    return fread(buffer, 32, 1, fp);
}

注意: 文本模式下,fseek 的行为可能不可预测。因为文本模式下换行符会被转换(比如Windows下\r\n变成\n),导致字节偏移量计算不准。所以随机读写最好在二进制模式下进行。

二、文件缓冲区:看不见的加速器

你有没有想过一个问题:为什么C语言的文件操作函数(比如 freadfwrite)比直接调用系统调用(比如 readwrite)要快?

答案就是——缓冲区

说白了,C标准库在用户空间维护了一块内存区域,用来暂存读写数据。每次读写操作,实际上是在和这块缓冲区打交道,而不是直接和磁盘交互。只有当缓冲区满了、或者你主动刷新时,数据才会真正写入磁盘。

1. 缓冲区的三种模式

模式函数行为
全缓冲setvbuf(fp, NULL, _IOFBF, size)缓冲区满了才刷新,默认用于普通文件
行缓冲setvbuf(fp, NULL, _IOLBF, size)遇到换行符就刷新,默认用于终端
无缓冲setvbuf(fp, NULL, _IONBF, 0)每次读写都直接操作,不缓存

默认情况下:

  • 普通磁盘文件:全缓冲(通常是4096字节或8192字节)
  • 标准输入/输出(stdin/stdout):行缓冲
  • 标准错误(stderr):无缓冲

为什么stderr是无缓冲?你想想看,程序崩溃时,错误信息必须立刻显示出来,不能等缓冲区满了再输出。这就是设计上的权衡。

2. 刷新缓冲区

有时候我们需要强制把缓冲区里的数据写出去,这时候就用 fflush()

FILE *fp = fopen("log.txt", "w");
fprintf(fp, "这是一条重要日志\n");
fflush(fp);  // 立即写入磁盘,防止程序崩溃时丢失数据

我曾经在一个嵌入式项目中吃过这个亏。程序写日志文件,突然掉电重启,结果最后几条日志全丢了。后来在每个关键日志后面加了 fflush,问题解决。代价是性能稍微下降了一点,但数据完整性更重要。

3. 缓冲区大小对性能的影响

缓冲区大小直接影响读写性能。太小了,频繁的磁盘IO拖慢速度;太大了,浪费内存,而且数据延迟写入的风险增加。

我一般建议:

  • 普通文件:使用默认缓冲区(4KB~8KB)就够了
  • 大文件顺序读写:可以手动设置更大的缓冲区(比如64KB)
  • 实时性要求高的场景:考虑无缓冲或行缓冲
// 手动设置64KB缓冲区
FILE *fp = fopen("bigfile.dat", "rb");
char *buffer = malloc(64 * 1024);
setvbuf(fp, buffer, _IOFBF, 64 * 1024);
// 现在读写这个文件会使用64KB的缓冲区

三、标准设备文件:一切皆文件

在C语言里,有三个文件指针是系统自动打开的,不需要你手动 fopen

  • stdin:标准输入,默认是键盘
  • stdout:标准输出,默认是屏幕
  • stderr:标准错误,默认也是屏幕

这三个东西,本质上就是文件。你可以像操作文件一样操作它们。

1. 重定向的魅力

为什么要把键盘和屏幕当成文件?因为这样我们可以轻松实现重定向

比如,你写了一个程序从stdin读数据:

#include <stdio.h>

int main() {
    char line[256];
    while (fgets(line, sizeof(line), stdin)) {
        printf("你输入了: %s", line);
    }
    return 0;
}

正常情况下,你从键盘输入,程序输出到屏幕。但如果你在命令行里这样运行:

./myprogram < input.txt > output.txt

程序就会从 input.txt 读取,输出到 output.txt。代码一行都不用改。这就是「一切皆文件」设计哲学的强大之处。

2. 标准错误 vs 标准输出

很多人刚开始写程序时,把所有输出都用 printf。但专业一点的做法是:

  • 正常结果输出到 stdout(用 printffprintf(stdout, ...)
  • 错误信息输出到 stderr(用 fprintf(stderr, ...)

为什么要分开?因为你可以把正常结果重定向到文件,同时错误信息仍然显示在屏幕上:

./myprogram > result.txt 2> error.log

这样,正常输出进 result.txt,错误信息进 error.log。调试的时候特别有用。

个人经验: 我在写命令行工具时,一定会把错误信息输出到stderr。这样用户用管道重定向时,不会把错误信息混进正常数据里。这个小习惯,能省去很多排查问题的时间。

四、知识体系总览

下面这张图,把本章的核心知识点串起来了。你可以把它当作一个思维导图来看:

文件操作(下) 随机读写 fseek() - 定位到任意位置 ftell() - 获取当前位置 rewind() - 回到文件开头 SEEK_SET / SEEK_CUR / SEEK_END 文件缓冲区 全缓冲 (_IOFBF) - 默认文件 行缓冲 (_IOLBF) - 默认终端 无缓冲 (_IONBF) - stderr fflush() - 强制刷新 标准设备文件 stdin - 标准输入(键盘) stdout - 标准输出(屏幕) stderr - 标准错误(屏幕) 重定向:< > 2> 核心思想:灵活定位 + 高效缓冲 + 统一接口 一切皆文件,操作有技巧

五、避坑指南与实战建议

聊了这么多,最后分享几个我踩过的坑:

  1. 不要混用文本模式和二进制模式。特别是在Windows下,文本模式会做换行符转换,导致 fseek 定位不准。我建议:只要涉及随机读写,一律用二进制模式。
  2. fseek 之后一定要检查返回值。不是每次定位都能成功,特别是文件末尾或者设备文件。
  3. 缓冲区不是越大越好。64KB对于大多数场景已经足够,再大边际效益递减,反而浪费内存。
  4. stderr 的无缓冲设计是有原因的。别自作聪明把stderr改成全缓冲,关键时刻会误事。

我曾经在一个数据采集项目中,为了追求性能,把stderr改成了全缓冲。结果程序崩溃时,最后的错误信息还没来得及输出就没了。排查问题花了整整两天。从那以后,我再也不动stderr的缓冲模式了。

好了,这一章的内容就到这里。文件操作说难不难,说简单也不简单。关键是要理解背后的机制——随机读写让你精准控制,缓冲区让你高效运转,标准设备文件让你灵活重定向。把这些搞明白了,你写出来的代码,就会有一种「专业感」。


公众号:蓝海资料掘金营,微信deep3321