文件操作(下):文件随机读写、文件缓冲区、标准设备文件
好,咱们接着聊文件操作。上一章我们把文件的基本读写、打开关闭这些基本功过了一遍。这一章要聊的,才是真正让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语言的文件操作函数(比如 fread、fwrite)比直接调用系统调用(比如 read、write)要快?
答案就是——缓冲区。
说白了,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(用
printf或fprintf(stdout, ...)) - 错误信息输出到 stderr(用
fprintf(stderr, ...))
为什么要分开?因为你可以把正常结果重定向到文件,同时错误信息仍然显示在屏幕上:
./myprogram > result.txt 2> error.log
这样,正常输出进 result.txt,错误信息进 error.log。调试的时候特别有用。
个人经验: 我在写命令行工具时,一定会把错误信息输出到stderr。这样用户用管道重定向时,不会把错误信息混进正常数据里。这个小习惯,能省去很多排查问题的时间。
四、知识体系总览
下面这张图,把本章的核心知识点串起来了。你可以把它当作一个思维导图来看:
五、避坑指南与实战建议
聊了这么多,最后分享几个我踩过的坑:
- 不要混用文本模式和二进制模式。特别是在Windows下,文本模式会做换行符转换,导致
fseek定位不准。我建议:只要涉及随机读写,一律用二进制模式。 - fseek 之后一定要检查返回值。不是每次定位都能成功,特别是文件末尾或者设备文件。
- 缓冲区不是越大越好。64KB对于大多数场景已经足够,再大边际效益递减,反而浪费内存。
- stderr 的无缓冲设计是有原因的。别自作聪明把stderr改成全缓冲,关键时刻会误事。
我曾经在一个数据采集项目中,为了追求性能,把stderr改成了全缓冲。结果程序崩溃时,最后的错误信息还没来得及输出就没了。排查问题花了整整两天。从那以后,我再也不动stderr的缓冲模式了。
好了,这一章的内容就到这里。文件操作说难不难,说简单也不简单。关键是要理解背后的机制——随机读写让你精准控制,缓冲区让你高效运转,标准设备文件让你灵活重定向。把这些搞明白了,你写出来的代码,就会有一种「专业感」。
公众号:蓝海资料掘金营,微信deep3321