第十八章 文件操作(二):格式化读写、二进制读写与文件定位

文件操作这块,上一章我们聊了基础的文件打开、关闭和字符读写。说实话,那些只是开胃菜。真正在实际项目中频繁使用的,是今天要讲的这几个家伙——fscanf/fprintffread/fwrite,还有fseek/ftell

我个人习惯把文件操作分成两类:一类是“给人看的”,比如配置文件、日志文件;另一类是“给机器吃的”,比如图片、音频、数据库文件。前者用格式化读写,后者用二进制读写。至于文件定位,嗯,那是所有文件操作的基础能力。

文件操作(二)知识体系 格式化读写 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

文件操作不总是从头读到尾。有时候你想跳到文件的某个位置,读取中间某一段数据,或者想知道文件有多大。这时候就需要 fseekftell 出场了。

函数原型:

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 可能不够用,这时候需要用 fseekoftello(使用 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:实现随机访问,配合二进制读写可以实现高效的文件数据库
  • 二进制模式打开文件可以避免文本模式下的换行符转换问题

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