30、文件操作(二进制):二进制读写、read/write、文件定位

说到文件操作,很多新手第一反应就是文本读写。但说实话,在实际工程里,二进制文件才是真正的主力。为什么?因为二进制直接操作内存数据,没有编码转换,没有格式化开销,读写效率高出一个量级。我早期做嵌入式项目时,一个日志文件用文本存,解析一次要几百毫秒;改成二进制结构体直接读写,瞬间降到几微秒。嗯,从那以后,我对二进制操作就格外上心。

二进制 vs 文本:到底差在哪?

先看一个最直观的区别:

  • 文本文件:存的是字符序列。比如整数 12345,文本里存的是 '1','2','3','4','5' 五个字节,还要考虑换行符、编码问题。
  • 二进制文件:存的是内存映像。整数 12345 在 32 位系统里就是 4 个字节,直接按内存布局写入,读出来直接赋值给变量。

你想想看,如果你要存一个结构体,里面有 int、double、char 数组,文本方式要逐个字段格式化、解析,麻烦不说,还容易出错。二进制方式呢?write(&obj, sizeof(obj)) 一行搞定。

核心原则:二进制读写适合结构化数据性能敏感的场景。文本读写适合人类可读跨平台交换的场景。

二进制读写核心:read / write

C++ 的 fstream 家族提供了两个关键成员函数:

// 写入
ostream& write(const char* buffer, streamsize size);

// 读取
istream& read(char* buffer, streamsize size);

注意,参数类型是 char*,所以如果你要读写其他类型,必须做强制类型转换。我个人习惯用 reinterpret_cast<char*>,清晰且安全。

来看一个完整的例子:

#include <iostream>
#include <fstream>
#include <cstring>

struct Student {
    char name[32];
    int age;
    double score;
};

int main() {
    // 写入二进制文件
    Student stu1 = {"张三", 20, 95.5};
    std::ofstream out("student.dat", std::ios::binary);
    if (!out) {
        std::cerr << "打开文件失败" << std::endl;
        return 1;
    }
    out.write(reinterpret_cast<char*>(&stu1), sizeof(Student));
    out.close();

    // 读取二进制文件
    Student stu2;
    std::ifstream in("student.dat", std::ios::binary);
    if (!in) {
        std::cerr << "打开文件失败" << std::endl;
        return 1;
    }
    in.read(reinterpret_cast<char*>(&stu2), sizeof(Student));
    in.close();

    std::cout << "姓名: " << stu2.name << std::endl;
    std::cout << "年龄: " << stu2.age << std::endl;
    std::cout << "成绩: " << stu2.score << std::endl;

    return 0;
}

小技巧:写结构体时,尽量用固定大小的数组(如 char[32])代替 std::string,因为 string 内部有指针,直接写内存会丢失堆数据。如果非要用 string,需要自己实现序列化。

文件定位:seekg / seekp

二进制文件操作中,随机访问是家常便饭。C++ 提供了两组定位函数:

  • seekg(offset, origin) — 用于输入流(读位置)
  • seekp(offset, origin) — 用于输出流(写位置)

origin 有三个选项:

标志 含义
std::ios::beg 从文件开头算起
std::ios::cur 从当前位置算起
std::ios::end 从文件末尾算起

举个例子,假设你有一个存了 100 个 Student 的二进制文件,想直接读取第 50 个:

std::ifstream in("students.dat", std::ios::binary);
in.seekg(49 * sizeof(Student));  // 跳到第50条记录(索引从0开始)
Student stu;
in.read(reinterpret_cast<char*>(&stu), sizeof(Student));

为什么是 49?因为偏移量是字节数,第 1 条记录偏移 0,第 2 条偏移 sizeof(Student),第 n 条偏移 (n-1)*sizeof(Student)。

我曾经踩过的坑:用 seekg 定位后,一定要检查文件流状态。如果定位到文件末尾之后,read 会失败,但不会抛异常。我习惯在每次 seek 后调用 in.good() 确认一下,否则读到垃圾数据都不知道。

获取当前位置:tellg / tellp

这两个函数返回当前读写位置的字节偏移量:

std::streampos pos = in.tellg();  // 读位置
std::streampos pos = out.tellp(); // 写位置

常用场景:记录某个关键数据的位置,方便后续回跳修改。比如写一个文件头,先预留 4 字节存数据块大小,等写完数据再回填。

std::fstream file("data.bin", std::ios::binary | std::ios::in | std::ios::out);
// 先写一个占位符
int dataSize = 0;
file.write(reinterpret_cast<char*>(&dataSize), sizeof(int));

// 记录当前位置
std::streampos dataPos = file.tellp();

// 写入实际数据...
// ...

// 回跳到开头,更新数据大小
file.seekp(0);
dataSize = 1024;
file.write(reinterpret_cast<char*>(&dataSize), sizeof(int));

// 再跳回数据末尾继续写
file.seekp(dataPos + dataSize);

知识体系总览

下面这张图帮你理清二进制文件操作的核心脉络:

二进制文件操作核心知识体系 读写操作 • write() 写入内存数据 • read() 读取到内存 • 必须用 binary 模式打开 • 类型强转 reinterpret_cast • 结构体直接读写 • 注意字符串指针问题 • 检查 gcount() 实际读取 文件定位 • seekg() 设置读位置 • seekp() 设置写位置 • tellg() 获取读位置 • tellp() 获取写位置 • 三种偏移基准: beg / cur / end • 随机访问任意记录 注意事项 • 检查文件打开状态 • 定位后检查 good() • 跨平台字节序问题 • 结构体对齐影响大小 • 不要用 string 直接写 • 文件末尾检测 • 读写模式要匹配

避坑指南:我踩过的那些雷

二进制操作看似简单,但坑不少。我把自己踩过的几个典型问题列出来,你遇到了能少走弯路:

  1. 忘记用 binary 模式打开:文本模式下,某些平台会做换行符转换(\n → \r\n),导致二进制数据被破坏。记住,读写二进制文件一定要加 std::ios::binary
  2. 结构体对齐问题:编译器默认会对结构体做内存对齐,比如 32 位系统里 int 通常对齐到 4 字节边界。这导致 sizeof(Student) 可能比你想象的大。如果你在不同编译器或不同平台间交换文件,建议用 #pragma pack(1) 取消对齐,或者手动序列化每个字段。
  3. 字节序(Endian)问题:x86 是小端,ARM 可以配置成大端。如果你在 x86 上写文件,拿到 ARM 设备上读,整数字节顺序是反的。跨平台时,我习惯用网络字节序(大端)统一存储,或者写文件时标记字节序。
  4. 读取时没检查实际读了多少字节read() 不一定能读到你要的字节数,尤其是文件被截断或损坏时。用 gcount() 检查实际读取的字节数,别直接拿缓冲区里的数据当有效数据。

一句话总结:二进制读写是 C++ 文件操作的核心技能,掌握 read/write + seek/tell 四件套,你就能高效处理结构化数据。记住:打开加 binary,定位后检查状态,跨平台注意对齐和字节序。

好了,这一章的内容就到这里。二进制操作其实不难,多写几次就熟练了。下次遇到需要存配置、缓存数据、或者做简单数据库的场景,优先考虑二进制文件——你会感谢自己的选择。

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