第十八章 文件操作(二):格式化读写、二进制读写与文件定位
文件操作这块,上一章我们聊了基础的文件打开、关闭和字符读写。说实话,那些只是开胃菜。真正在实际项目中频繁使用的,是今天要讲的这几个家伙——fscanf/fprintf、fread/fwrite,还有fseek/ftell。
我个人习惯把文件操作分成两类:一类是“给人看的”,比如配置文件、日志文件;另一类是“给机器吃的”,比如图片、音频、数据库文件。前者用格式化读写,后者用二进制读写。至于文件定位,嗯,那是所有文件操作的基础能力。
18.1 格式化读写:fscanf 和 fprintf
这两个函数,说白了就是 scanf/printf 的文件版本。你平时怎么在屏幕上格式化输入输出,就怎么在文件里操作。
函数原型:
int fprintf(FILE *stream, const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
返回值:成功时返回写入/读取的项数,出错或到达文件末尾返回 EOF。
实战案例:学生成绩管理系统
#include <stdio.h>
#include <stdlib.h>
typedef struct {
char name[32];
int id;
float score;
} Student;
int main() {
Student stu[] = {
{"张三", 1001, 88.5},
{"李四", 1002, 92.0},
{"王五", 1003, 76.5}
};
int count = 3;
// 写入文件
FILE *fp = fopen("students.txt", "w");
if (!fp) {
perror("打开文件失败");
return 1;
}
for (int i = 0; i < count; i++) {
fprintf(fp, "%s %d %.1f\n",
stu[i].name, stu[i].id, stu[i].score);
}
fclose(fp);
// 读取文件
fp = fopen("students.txt", "r");
if (!fp) {
perror("打开文件失败");
return 1;
}
Student read_stu;
printf("从文件读取的学生信息:\n");
while (fscanf(fp, "%s %d %f",
read_stu.name, &read_stu.id, &read_stu.score) == 3) {
printf("姓名:%s,学号:%d,成绩:%.1f\n",
read_stu.name, read_stu.id, read_stu.score);
}
fclose(fp);
return 0;
}
我曾经踩过的坑:用 fscanf 读取字符串时,如果字符串中包含空格,读取会提前终止。比如学生姓名是"欧阳 小明",%s 只能读到"欧阳"。后来我改用 fgets 配合 sscanf 才解决这个问题。
小技巧:格式化读写适合配置文件、日志文件这类需要人类直接查看的场景。但要注意,每次读写都有格式转换开销,数据量大时性能会下降。
18.2 二进制读写:fread 和 fwrite
二进制读写,说白了就是直接搬运内存数据。没有格式转换,没有字符编码问题,速度飞快。你想想看,一个包含 100 万个浮点数的数组,用 fprintf 写入要转成字符串,再读回来又要解析,多慢啊。用 fwrite 直接整个数组写进去,一秒钟搞定。
函数原型:
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:文件指针
返回值:实际读取/写入的元素个数。正常情况下等于 nmemb,出错或到文件尾时小于 nmemb。
实战案例:结构体数组的二进制读写
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[32];
int id;
float score;
} Student;
int main() {
Student stu[3] = {
{"张三", 1001, 88.5},
{"李四", 1002, 92.0},
{"王五", 1003, 76.5}
};
// 二进制写入
FILE *fp = fopen("students.dat", "wb");
if (!fp) {
perror("打开文件失败");
return 1;
}
size_t written = fwrite(stu, sizeof(Student), 3, fp);
if (written != 3) {
fprintf(stderr, "写入失败,只写入了 %zu 个元素\n", written);
}
fclose(fp);
// 二进制读取
fp = fopen("students.dat", "rb");
if (!fp) {
perror("打开文件失败");
return 1;
}
Student read_stu[3];
size_t read_count = fread(read_stu, sizeof(Student), 3, fp);
if (read_count != 3) {
fprintf(stderr, "读取失败,只读取了 %zu 个元素\n", read_count);
} else {
printf("从二进制文件读取的学生信息:\n");
for (int i = 0; i < 3; i++) {
printf("姓名:%s,学号:%d,成绩:%.1f\n",
read_stu[i].name, read_stu[i].id, read_stu[i].score);
}
}
fclose(fp);
return 0;
}
注意:二进制文件在不同平台间移植时要注意字节序(大端/小端)和结构体对齐问题。我在做嵌入式项目时,经常需要在 ARM(小端)和 PowerPC(大端)之间交换数据文件,这时候就得手动处理字节序转换。
我的习惯:对于需要跨平台传输的二进制数据,我会定义一个明确的数据结构,并在文件头写入一个魔数(Magic Number)和版本号,这样读取时可以校验文件格式是否正确。
18.3 文件定位:fseek 和 ftell
文件操作不总是从头读到尾。有时候你想跳到文件的某个位置,读取中间某一段数据,或者想知道文件有多大。这时候就需要 fseek 和 ftell 出场了。
函数原型:
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
whence 参数有三个选项:
| 常量 | 值 | 含义 |
|---|---|---|
| SEEK_SET | 0 | 从文件开头偏移 |
| SEEK_CUR | 1 | 从当前位置偏移 |
| SEEK_END | 2 | 从文件末尾偏移 |
实战案例:获取文件大小并读取指定位置数据
#include <stdio.h>
#include <stdlib.h>
int main() {
FILE *fp = fopen("students.dat", "rb");
if (!fp) {
perror("打开文件失败");
return 1;
}
// 获取文件大小
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
printf("文件大小:%ld 字节\n", file_size);
// 跳转到文件开头
fseek(fp, 0, SEEK_SET);
// 跳转到第 2 个学生记录(索引从 0 开始)
// 假设 Student 结构体大小为 40 字节
fseek(fp, 1 * sizeof(Student), SEEK_SET);
Student stu;
fread(&stu, sizeof(Student), 1, fp);
printf("第 2 个学生:%s,学号:%d,成绩:%.1f\n",
stu.name, stu.id, stu.score);
fclose(fp);
return 0;
}
我曾经犯过的错:在文本模式下使用 fseek 定位。Windows 下文本模式会把 \n 转换成 \r\n,导致 ftell 返回的位置和实际文件位置对不上。后来我统一用二进制模式打开文件,再也没出过这种问题。
实用技巧:用 fseek(fp, 0, SEEK_END) 配合 ftell(fp) 获取文件大小,是 C 语言中最常用的方法。但要注意,对于大于 2GB 的文件,long 可能不够用,这时候需要用 fseeko 和 ftello(使用 off_t 类型)。
18.4 三种读写方式的对比
| 特性 | 格式化读写 | 二进制读写 |
|---|---|---|
| 可读性 | 高(文本文件,可直接查看) | 低(乱码,需专用工具) |
| 效率 | 低(有格式转换开销) | 高(直接内存拷贝) |
| 存储空间 | 较大(数字转字符串后变长) | 较小(二进制存储) |
| 跨平台性 | 好(文本文件通用) | 差(字节序、对齐问题) |
| 适用场景 | 配置文件、日志、人类可读数据 | 图片、音频、数据库、大数据量 |
18.5 综合实战:简易文件数据库
把今天学的三个知识点串起来,写一个简单的文件数据库。支持添加记录、按学号查询、列出所有记录。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
char name[32];
int id;
float score;
} Student;
// 添加记录
void add_record(const char *filename) {
FILE *fp = fopen(filename, "ab");
if (!fp) return;
Student s;
printf("输入姓名:");
scanf("%s", s.name);
printf("输入学号:");
scanf("%d", &s.id);
printf("输入成绩:");
scanf("%f", &s.score);
fwrite(&s, sizeof(Student), 1, fp);
fclose(fp);
printf("记录添加成功\n");
}
// 按学号查询
void query_by_id(const char *filename, int target_id) {
FILE *fp = fopen(filename, "rb");
if (!fp) return;
Student s;
int found = 0;
while (fread(&s, sizeof(Student), 1, fp) == 1) {
if (s.id == target_id) {
printf("找到:%s,学号:%d,成绩:%.1f\n",
s.name, s.id, s.score);
found = 1;
break;
}
}
if (!found) {
printf("未找到学号为 %d 的记录\n", target_id);
}
fclose(fp);
}
// 列出所有记录
void list_all(const char *filename) {
FILE *fp = fopen(filename, "rb");
if (!fp) return;
Student s;
int count = 0;
while (fread(&s, sizeof(Student), 1, fp) == 1) {
printf("[%d] %s,学号:%d,成绩:%.1f\n",
++count, s.name, s.id, s.score);
}
if (count == 0) {
printf("数据库为空\n");
}
fclose(fp);
}
int main() {
const char *db_file = "student_db.dat";
int choice, id;
while (1) {
printf("\n=== 学生数据库 ===\n");
printf("1. 添加记录\n");
printf("2. 按学号查询\n");
printf("3. 列出所有\n");
printf("4. 退出\n");
printf("请选择:");
scanf("%d", &choice);
switch (choice) {
case 1: add_record(db_file); break;
case 2:
printf("输入学号:");
scanf("%d", &id);
query_by_id(db_file, id);
break;
case 3: list_all(db_file); break;
case 4: return 0;
default: printf("无效选择\n");
}
}
return 0;
}
核心要点回顾:
fscanf/fprintf:适合人类可读的文本数据,但效率较低fread/fwrite:适合大数据量和结构体数据,效率高但跨平台需注意fseek/ftell:实现随机访问,配合二进制读写可以实现高效的文件数据库- 二进制模式打开文件可以避免文本模式下的换行符转换问题