第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 知识体系总览

好了,这一章的内容比较多,我画了一张图帮你梳理一下整个文件操作的知识体系:

文件操作(二)知识体系 格式化读写 二进制读写 文件定位 fprintf:格式化写入 fscanf:格式化读取 适用:文本数据、配置文件 fwrite:二进制写入 fread:二进制读取 适用:结构体、原始数据 fseek:跳转到指定位置 ftell:获取当前位置 rewind:重置到开头 核心原则:文本用fprintf/fscanf,二进制用fwrite/fread 随机访问用fseek/ftell,别忘了检查返回值!

这张图把三种文件操作方式并列展示,你可以看到每种方式的核心函数和适用场景。我个人建议你把它保存下来,写代码时对照着看。

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