第18章 文件操作(二):格式化读写、二进制读写与文件定位
文件操作这块,说实话是很多C语言学习者的分水岭。能搞定文件读写,才算真正迈入了实用编程的门槛。上一章我们聊了基础的文件打开关闭和字符读写,这一章咱们来点硬核的——格式化读写、二进制读写,还有文件指针的定位操作。
我个人习惯把文件操作分成三个层次:文本行的读写、格式化数据的读写、二进制数据的读写。今天要讲的,就是后两个层次。嗯,咱们直接开干。
18.1 格式化文件读写:fscanf 与 fprintf
先说说格式化读写。你想想看,如果我们要往文件里写一个结构化的数据,比如学生信息(姓名、年龄、成绩),用fputc或者fputs一个个拼字符串,那得多累?fprintf就是干这个的。
核心函数原型:
int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
fprintf和printf的区别?就多了一个文件指针参数。说白了,printf是往屏幕写,fprintf是往文件写。fscanf同理,从文件读而不是从键盘读。
我在项目中遇到过这样一个场景:需要把传感器采集的数据(时间戳、温度、湿度)记录到日志文件里。用fprintf简直不要太方便:
#include <stdio.h>
#include <time.h>
typedef struct {
int id;
char name[32];
float score;
} Student;
int main() {
FILE *fp = fopen("students.txt", "w");
if (fp == NULL) {
perror("文件打开失败");
return 1;
}
Student stu = {1001, "张三", 92.5};
fprintf(fp, "%d %s %.1f\n", stu.id, stu.name, stu.score);
fclose(fp);
return 0;
}
读回来的时候,用fscanf按同样的格式解析就行:
FILE *fp = fopen("students.txt", "r");
Student stu;
fscanf(fp, "%d %s %f", &stu.id, stu.name, &stu.score);
printf("ID: %d, 姓名: %s, 成绩: %.1f\n", stu.id, stu.name, stu.score);
fclose(fp);
避坑指南:我曾经在解析配置文件时,因为fscanf的格式字符串里多写了一个空格,导致数据读取错位,排查了整整两个小时。记住:fscanf的格式必须和写入时的格式严格一致,包括空格、换行符的位置。
另外要注意,fscanf遇到空白字符(空格、制表符、换行)会自动跳过,但%s读取字符串时遇到空白就会停止。所以如果字符串本身包含空格,就别用fscanf了,改用fgets。
18.2 二进制文件读写:fread 与 fwrite
文本文件虽然人类可读,但效率低、精度差。你想想看,一个float类型的数据,在内存里占4个字节,转成文本可能变成"3.141592"这样的字符串,占8个字节甚至更多。而且浮点数转文本再转回来,精度会有损失。
二进制读写就不一样了。它直接读写内存中的原始字节,不经过任何转换。速度快、精度高、存储紧凑。
核心函数原型:
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);
参数说明:ptr是数据缓冲区指针,size是每个元素的大小(字节数),nmemb是要读写的元素个数,stream是文件指针。返回值是实际读写的元素个数。
我个人习惯用fwrite来保存结构体数据,尤其是嵌入式设备上的配置参数。比如:
#include <stdio.h>
typedef struct {
unsigned char version;
unsigned short baud_rate;
unsigned char parity;
unsigned char stop_bits;
} SerialConfig;
int main() {
SerialConfig config = {1, 115200, 0, 1}; // 版本1, 115200波特率, 无校验, 1停止位
// 写入二进制文件
FILE *fp = fopen("config.bin", "wb");
if (fp == NULL) return 1;
size_t written = fwrite(&config, sizeof(SerialConfig), 1, fp);
if (written != 1) {
printf("写入失败\n");
}
fclose(fp);
// 读取二进制文件
SerialConfig read_config;
fp = fopen("config.bin", "rb");
if (fp == NULL) return 1;
size_t read = fread(&read_config, sizeof(SerialConfig), 1, fp);
if (read == 1) {
printf("版本: %d, 波特率: %d\n", read_config.version, read_config.baud_rate);
}
fclose(fp);
return 0;
}
经验之谈:二进制文件有个坑——跨平台兼容性。不同平台的结构体可能有不同的内存对齐方式,字节序(大端/小端)也可能不同。我在做嵌入式项目时,经常需要在代码里手动处理字节序,或者用#pragma pack(1)强制1字节对齐。如果你只是在本机上用,那没问题;如果要跨平台传输,就得小心了。
fread和fwrite的返回值一定要检查。我曾经在读取文件时,没检查返回值就直接用数据,结果文件损坏导致程序崩溃。记住:fread返回0不代表文件结束,可能是读取错误;fwrite返回0也不代表写入成功,可能是磁盘满了。
18.3 文件定位:fseek、ftell 与 rewind
文件操作不只是顺序读写。很多时候我们需要跳转到文件的某个位置,比如读取一个数据库文件的第100条记录,或者获取文件的总大小。这时候就需要文件定位函数了。
核心函数原型:
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
fseek的三个参数:stream是文件指针,offset是偏移量(字节数),whence是起始位置。whence有三个取值:
| 宏定义 | 数值 | 含义 |
|---|---|---|
| SEEK_SET | 0 | 从文件开头算起 |
| SEEK_CUR | 1 | 从当前位置算起 |
| SEEK_END | 2 | 从文件末尾算起 |
ftell返回当前文件指针的位置(相对于文件开头的字节数)。rewind相当于fseek(fp, 0, SEEK_SET),把指针重置到文件开头。
我经常用fseek+ftell来获取文件大小:
long get_file_size(const char *filename) {
FILE *fp = fopen(filename, "rb");
if (fp == NULL) return -1;
fseek(fp, 0, SEEK_END); // 跳到文件末尾
long size = ftell(fp); // 获取当前位置(即文件大小)
fclose(fp);
return size;
}
这个技巧我在项目中用过无数次。比如在嵌入式设备上,需要先知道固件升级包的大小,再分配内存来接收数据。
注意:fseek/ftell返回的是long类型,在32位系统上最大只能表示2GB的文件。如果你要处理超大文件(比如视频文件),得用fseeko/ftello(返回off_t类型)或者平台相关的64位API。我曾经在做一个日志分析工具时,就踩过这个坑——文件超过2GB后,ftell返回了负数。
再来看一个随机读写的例子。假设我们有一个学生成绩文件,每条记录固定大小(sizeof(Student)),想直接读取第5个学生的信息:
typedef struct {
int id;
char name[32];
float score;
} Student;
Student read_student_by_index(FILE *fp, int index) {
Student stu;
fseek(fp, index * sizeof(Student), SEEK_SET);
fread(&stu, sizeof(Student), 1, fp);
return stu;
}
这就是所谓的"随机访问"——不需要从头读到尾,直接跳到目标位置。数据库文件、索引文件都是这么干的。
18.4 知识体系总览
好了,这一章的内容比较多,我画了一张图帮你梳理一下整个文件操作的知识体系:
这张图把三种文件操作方式并列展示,你可以看到每种方式的核心函数和适用场景。我个人建议你把它保存下来,写代码时对照着看。
18.5 综合示例:学生成绩管理系统(文件版)
最后,咱们来个综合练习。假设我们要实现一个简单的学生成绩管理系统,支持添加、查询、修改和删除操作。这里用二进制文件存储,支持随机访问:
#include <stdio.h>
#include <string.h>
#define MAX_STUDENTS 100
#define FILENAME "students.dat"
typedef struct {
int id;
char name[32];
float score;
int valid; // 1表示有效记录,0表示已删除
} Student;
// 添加学生
void add_student(Student stu) {
FILE *fp = fopen(FILENAME, "ab");
if (fp == NULL) return;
stu.valid = 1;
fwrite(&stu, sizeof(Student), 1, fp);
fclose(fp);
}
// 按ID查询学生
Student* find_student(int id) {
static Student stu;
FILE *fp = fopen(FILENAME, "rb");
if (fp == NULL) return NULL;
while (fread(&stu, sizeof(Student), 1, fp) == 1) {
if (stu.id == id && stu.valid) {
fclose(fp);
return &stu;
}
}
fclose(fp);
return NULL;
}
// 修改学生成绩(随机访问)
int update_score(int id, float new_score) {
FILE *fp = fopen(FILENAME, "rb+");
if (fp == NULL) return -1;
Student stu;
while (fread(&stu, sizeof(Student), 1, fp) == 1) {
if (stu.id == id && stu.valid) {
stu.score = new_score;
fseek(fp, -sizeof(Student), SEEK_CUR); // 回退一个记录
fwrite(&stu, sizeof(Student), 1, fp);
fclose(fp);
return 0;
}
}
fclose(fp);
return -1; // 未找到
}
int main() {
// 添加几个学生
Student s1 = {1001, "张三", 85.5, 1};
Student s2 = {1002, "李四", 92.0, 1};
add_student(s1);
add_student(s2);
// 查询
Student *p = find_student(1001);
if (p) {
printf("找到: %s, 成绩: %.1f\n", p->name, p->score);
}
// 修改
update_score(1001, 95.0);
p = find_student(1001);
if (p) {
printf("修改后: %s, 成绩: %.1f\n", p->name, p->score);
}
return 0;
}
这个例子把fwrite、fread、fseek都用上了。注意看update_score函数里的操作:先fread读到目标记录,然后用fseek回退一个记录的位置,再fwrite覆盖写入。这就是典型的"读-改-写"模式。
小技巧:用"rb+"模式打开文件,既可以读也可以写。但要注意,fread之后文件指针已经移动了,写之前必须用fseek定位到正确的位置。我刚开始学的时候经常忘记这一步,结果数据写到了错误的位置。
好了,这一章的内容就到这里。文件操作是C语言里非常实用的技能,尤其是二进制读写和随机访问,在嵌入式开发、游戏开发、数据库实现等领域都离不开它们。多写几个例子,你就能掌握其中的门道了。
公众号:蓝海资料掘金营,微信deep3321