25. 【C语言】二进制文件与随机读写

上一篇文章我们学会了用 fprintffscanf 读写文本文件。文本文件最大的好处是可读------拿记事本打开就能看懂。但它也有明显的短板:存一个 double 要写成 "3.14159265358979" 这十几字节,读回来还要解析,精度可能损失,速度也慢。

如果你需要高效地存储大量数值,或者想快速跳到文件中间读写某条记录,那就得换另一种思路------把数据在内存中的样子原封不动地写入磁盘 。这就是二进制文件。配合 C 语言提供的随机访问函数,你就能像操作数组一样操作文件中的任意位置。


一、二进制文件 vs 文本文件:底层都一样,解读方式不同

不管文本文件还是二进制文件,对于计算机来说都是 0 和 1 的字节流。区别只在于:你如何解读这些字节

比如一个整数 123456789(十六进制 0x075BCD15),在内存中占 4 字节:

复制代码
内存内容(小端): 15 CD 5B 07
  • 文本方式写入fprintf(fp, "%d", 123456789); 会把它转换为字符串 "123456789",写出 9 个 ASCII 字符:31 32 33 34 35 36 37 38 39
  • 二进制方式写入fwrite(&n, sizeof(int), 1, fp); 直接把那 4 字节 15 CD 5B 07 写入文件。

优缺点对比:

文本文件 二进制文件
可读性 人可以直接看懂 乱码(需要程序解读)
文件体积 较大(数字越长越大) 紧凑(固定大小)
读写速度 慢(需要格式转换) 快(直接搬移内存)
精度 可能损失(浮点数) 完全不损失
跨平台 安全 需注意字节序和类型大小

什么时候用二进制? 游戏存档、数据库文件、图像/音频/视频数据、大量传感器数据记录------只要数据量大、结构固定、不靠人眼阅读,就优选二进制。


二、freadfwrite:读写"裸"数据块

这两个函数是二进制 I/O 的核心。

c 复制代码
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream);
size_t fread (void *ptr, size_t size, size_t count, FILE *stream);
  • ptr:内存中数据的地址。
  • size:每个数据块的字节数(通常用 sizeof)。
  • count:要读写的数据块个数。
  • stream:文件指针。
  • 返回值:成功读/写的数据块个数(不是字节数!)。

写入二进制数据

把几个不同类型的数据一次写入文件:

c 复制代码
#include <stdio.h>

int main(void) {
    FILE *fp = fopen("data.bin", "wb");  // 注意 "wb":二进制写模式
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }

    int    n = 42;
    double pi = 3.14159265;
    char   msg[] = "Hello";

    fwrite(&n,  sizeof(int),    1, fp);   // 写入一个 int
    fwrite(&pi, sizeof(double), 1, fp);   // 写入一个 double
    fwrite(msg, sizeof(char),   5, fp);   // 写入 5 个 char(不含 '\0')

    fclose(fp);
    return 0;
}

"wb" 中的 b 明确告诉操作系统"我是二进制模式"。在 Linux/macOS 上 "w""wb" 没有实际区别,但 Windows 上文本模式会自动把 \n 转换为 \r\n,二进制模式则不会。为了跨平台,写二进制一定要加 b

读取二进制数据

c 复制代码
#include <stdio.h>

int main(void) {
    FILE *fp = fopen("data.bin", "rb");
    if (fp == NULL) {
        perror("打开文件失败");
        return 1;
    }

    int    n;
    double pi;
    char   msg[6] = {0};   // 多留一位给 '\0'

    fread(&n,  sizeof(int),    1, fp);
    fread(&pi, sizeof(double), 1, fp);
    fread(msg, sizeof(char),   5, fp);

    printf("n=%d, pi=%.8f, msg=%s\n", n, pi, msg);
    fclose(fp);
    return 0;
}

读取的顺序和类型必须与写入时严格一致。如果读的类型对不上,结果将是垃圾值。

检查 fread 的返回值

fread 可能读不到你期望的数量(文件损坏、意外结束)。应该检查返回值:

c 复制代码
size_t read_count = fread(&n, sizeof(int), 1, fp);
if (read_count != 1) {
    printf("读取失败或文件结束\n");
}

三、结构体 + 二进制 = 天然绝配

结构体的内存布局是连续的(考虑对齐),因此可以一次性把整个结构体写入或读出,极其方便。

c 复制代码
#include <stdio.h>

typedef struct {
    char name[20];
    int  id;
    float score;
} Student;

int main(void) {
    // 写入
    FILE *fp = fopen("students.bin", "wb");
    Student s1 = {"Alice", 1001, 92.5};
    Student s2 = {"Bob",   1002, 85.0};
    fwrite(&s1, sizeof(Student), 1, fp);
    fwrite(&s2, sizeof(Student), 1, fp);
    fclose(fp);

    // 读取
    fp = fopen("students.bin", "rb");
    Student students[10];
    int count = 0;
    while (fread(&students[count], sizeof(Student), 1, fp) == 1) {
        count++;
    }
    fclose(fp);

    for (int i = 0; i < count; i++) {
        printf("%s %d %.1f\n", students[i].name, students[i].id, students[i].score);
    }
    return 0;
}

注意:如果结构体中有指针成员(比如 char *name),不能直接 fwrite。因为写入的是指针的值(一个地址),而不是指针指向的内容,读回来时那个地址早已无效。包含指针的结构体需要手动序列化(逐个成员处理)。


四、随机访问:在文件中"跳来跳去"

到目前为止,我们读写文件都是顺序的------从头往后,不能回头,不能直接定位。但很多时候我们想直接跳到第 100 条记录、或者回到开头重读。这就需要随机访问

每个文件流内部维护一个当前位置指示器,记录下一次读写将在哪个字节偏移处进行。C 语言提供了三个关键函数来操作它。

1. fseek:定位到指定位置

c 复制代码
int fseek(FILE *stream, long offset, int whence);
  • stream:文件指针。
  • offset:偏移量(字节),正数向后移,负数向前移。
  • whence:参照点,可选:
    • SEEK_SET:文件开头
    • SEEK_CUR:当前位置
    • SEEK_END:文件末尾

示例:

c 复制代码
fseek(fp, 0, SEEK_SET);          // 回到文件开头
fseek(fp, sizeof(Student) * 5, SEEK_SET);  // 跳到第 6 个学生(索引 5)
fseek(fp, 0, SEEK_END);          // 跳到文件末尾
fseek(fp, -100, SEEK_END);       // 从文件末尾往前退 100 字节
fseek(fp, 10, SEEK_CUR);         // 从当前位置往后跳 10 字节

返回值:成功返回 0,失败返回非 0。

2. ftell:获取当前位置

c 复制代码
long ftell(FILE *stream);

返回当前字节偏移(从文件开头算起),出错返回 -1L

c 复制代码
fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);  // 文件大小(字节数)

3. rewind:快捷回到开头

c 复制代码
rewind(fp);

等价于 fseek(fp, 0, SEEK_SET);,但同时会清除文件流的错误标志。


五、实战:小型学生信息管理系统(二进制存储 + 随机读写)

把结构体、动态内存、随机访问结合,做一个完整的小型管理系统。数据以二进制存储,支持添加、列表、修改、查找、删除功能。

student_system.c

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define FILENAME "students.dat"
#define MAX_NAME 20

typedef struct {
    char name[MAX_NAME];
    int  id;
    float score;
} Student;

// 追加一个学生记录
void add_student(void) {
    FILE *fp = fopen(FILENAME, "ab");
    if (fp == NULL) {
        perror("打开文件失败");
        return;
    }

    Student s;
    printf("姓名 学号 成绩: ");
    scanf("%s %d %f", s.name, &s.id, &s.score);
    fwrite(&s, sizeof(Student), 1, fp);
    fclose(fp);
    printf("已添加。\n");
}

// 列表所有学生
void list_students(void) {
    FILE *fp = fopen(FILENAME, "rb");
    if (fp == NULL) {
        printf("暂无记录。\n");
        return;
    }

    Student s;
    int count = 0;
    printf("---- 学生列表 ----\n");
    while (fread(&s, sizeof(Student), 1, fp) == 1) {
        printf("#%d: %s, 学号=%d, 成绩=%.1f\n", count, s.name, s.id, s.score);
        count++;
    }
    if (count == 0) printf("(无记录)\n");
    fclose(fp);
}

// 根据学号查找
int find_student_by_id(int target_id, Student *out) {
    FILE *fp = fopen(FILENAME, "rb");
    if (fp == NULL) return -1;

    int index = 0;
    while (fread(out, sizeof(Student), 1, fp) == 1) {
        if (out->id == target_id) {
            fclose(fp);
            return index;  // 返回记录索引
        }
        index++;
    }
    fclose(fp);
    return -1;
}

// 修改学生成绩
void update_score(void) {
    int target_id;
    printf("输入要修改的学号: ");
    scanf("%d", &target_id);

    FILE *fp = fopen(FILENAME, "r+b");  // 二进制读写模式
    if (fp == NULL) {
        printf("文件不存在。\n");
        return;
    }

    Student s;
    long pos;
    int found = 0;
    while ((pos = ftell(fp)) >= 0 && fread(&s, sizeof(Student), 1, fp) == 1) {
        if (s.id == target_id) {
            printf("当前成绩: %.1f,新成绩: ", s.score);
            scanf("%f", &s.score);
            fseek(fp, pos, SEEK_SET);   // 回到这条记录的开头
            fwrite(&s, sizeof(Student), 1, fp);  // 覆盖写入
            found = 1;
            break;
        }
    }
    fclose(fp);
    printf(found ? "修改成功。\n" : "未找到该学号。\n");
}

// 删除学生(通过创建新文件并跳过删除项)
void delete_student(void) {
    int target_id;
    printf("输入要删除的学号: ");
    scanf("%d", &target_id);

    FILE *fp = fopen(FILENAME, "rb");
    if (fp == NULL) {
        printf("文件不存在。\n");
        return;
    }

    FILE *temp = fopen("temp.dat", "wb");
    if (temp == NULL) {
        perror("临时文件创建失败");
        fclose(fp);
        return;
    }

    Student s;
    int found = 0;
    while (fread(&s, sizeof(Student), 1, fp) == 1) {
        if (s.id == target_id) {
            found = 1;   // 跳过这条记录
        } else {
            fwrite(&s, sizeof(Student), 1, temp);
        }
    }
    fclose(fp);
    fclose(temp);

    remove(FILENAME);
    rename("temp.dat", FILENAME);
    printf(found ? "删除成功。\n" : "未找到该学号。\n");
}

int main(void) {
    int choice;
    while (1) {
        printf("\n1.添加  2.列表  3.查找  4.修改  5.删除  0.退出\n");
        printf("选择: ");
        scanf("%d", &choice);

        switch (choice) {
            case 1: add_student(); break;
            case 2: list_students(); break;
            case 3: {
                int id;
                printf("输入学号: ");
                scanf("%d", &id);
                Student s;
                int idx = find_student_by_id(id, &s);
                if (idx >= 0) printf("找到: %s 成绩 %.1f\n", s.name, s.score);
                else printf("未找到。\n");
                break;
            }
            case 4: update_score(); break;
            case 5: delete_student(); break;
            case 0: return 0;
            default: printf("无效选项。\n");
        }
    }
}

重点分析

  • 追加 :用 "ab" 模式,新记录自动加在末尾。
  • 修改 :用 "r+b" 模式,先用 ftell 记录读取位置,读到目标后,用 fseek 回退到该位置,再 fwrite 覆盖。这就用到了随机定位。
  • 删除:因为从文件中"挖掉"一块很难,常用策略是创建临时文件,把要保留的记录复制过去,跳过目标,再替换原文件。这也是随机读写的一种变通应用。

六、常见错误与陷阱

1. 忘记 b 模式(Windows 下最要命)

c 复制代码
fopen("data.bin", "w");  // Windows 下会把 0x0A 变成 0x0D 0x0A

在 Windows 上,文本模式会转换换行符,破坏二进制数据。写二进制文件永远加 b

2. 读写的结构体包含指针

c 复制代码
typedef struct {
    char *name;  // 指针!
    int id;
} Bad;
fwrite(&bad, sizeof(Bad), 1, fp);  // 写入的是指针值,不是字符串

包含指针的结构体不能直接二进制读写。只对不含指针的"纯数据"结构体使用。

3. 对 fread 返回值检查不足

c 复制代码
fread(&s, sizeof(Student), 5, fp);  // 期望读 5 个,实际可能只读 3 个

永远用返回值判断实际读到了多少块。

4. fseek 偏移量计算错误

c 复制代码
fseek(fp, 5, SEEK_SET);  // 跳到第 5 字节,不是第 5 个记录!

若想跳第 n 条记录,偏移量应为 n * sizeof(Student)

5. 跨平台字节序和大小不一致

一台机器上写入的二进制文件,另一台可能读出来是错的(比如大端 vs 小端,int 4 字节 vs 2 字节)。如果要在不同平台间交换二进制数据,需要设计固定的字节序和类型大小(如使用 int32_t 进行序列化)。


七、小结

今天你打开了文件操作的另一半世界:

  • 二进制 I/Ofwritefread 直接搬移内存,高效紧凑,与结构体配合尤其方便。
  • 随机访问fseek 让你在文件中任意定位,ftell 告诉你当前位置,rewind 一键回开头。
  • 实战组合:用二进制 + 随机读写实现了学生信息的增、查、改、删,体验了"文件即数据库"的雏形。

现在你对文件操作已经有了相当全面的掌握。但 C 语言还有一个非常强大却容易被滥用的部分------预处理器 。从下一篇开始,我们将进入预处理指令的世界:宏定义的技巧与陷阱,条件编译如何让一套代码适配多平台,以及 #include 背后更深层的管理艺术。准备好了吗?


课后小练习

  1. 写一个程序,用二进制方式把 100 以内的所有偶数写入 evens.bin,然后再读取回来验证数据是否正确。
  2. fseekftell 实现一个函数 long file_size(const char *filename),返回文件的大小(字节数)。如果文件不存在,返回 -1。
  3. 在上面的学生管理系统中增加一个"交换第 i 和 第 j 条记录"的功能:通过 fseek 定位两条记录,读取到内存,然后交换并写回。
  4. (小挑战)设计一个简单的"键值数据库"文件格式:每条记录是 int key + int value。实现 put(key, value)get(key) 操作。提示:可以将整个文件读入数组,在内存中查找/修改,再写回;或者用 fseek 随机遍历。哪种方式更高效?什么时候用哪一种?

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库

相关推荐
北极星日淘1 小时前
中古货品品相评级算法实战|Java权重计分实现标准化五级品相体系
开发语言·python
小巧的砖头1 小时前
C#会重蹈覆辙吗?系列之2:反射及元数据的性能问题
开发语言·前端·c#
凯瑟琳.奥古斯特1 小时前
力扣1009补码解法C++实现
开发语言·c++·算法·leetcode·职场和发展
2601_963645921 小时前
【2026最新】MATLAB教程(附安装包,非常详细)
开发语言·matlab·信息可视化
十月的皮皮2 小时前
C语言学习笔记20260706-栈的压入、弹出序列验证
c语言·笔记·学习
闲猫3 小时前
Python 虚拟环境 virtualenv & uvicorn 服务搭建 & FAstAPI 使用
开发语言·python
三月不知肉味y3 小时前
2026-07-05-JAVA面试场景题训练
java·开发语言·面试
豆瓣鸡3 小时前
封装 API 请求日志切面——从注解定义到 Starter 自动装配
java·开发语言·spring boot
zh_xuan4 小时前
c++ optional用法
开发语言·c++·optional