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);
知识体系总览
下面这张图帮你理清二进制文件操作的核心脉络:
避坑指南:我踩过的那些雷
二进制操作看似简单,但坑不少。我把自己踩过的几个典型问题列出来,你遇到了能少走弯路:
- 忘记用 binary 模式打开:文本模式下,某些平台会做换行符转换(\n → \r\n),导致二进制数据被破坏。记住,读写二进制文件一定要加
std::ios::binary。 - 结构体对齐问题:编译器默认会对结构体做内存对齐,比如 32 位系统里 int 通常对齐到 4 字节边界。这导致
sizeof(Student)可能比你想象的大。如果你在不同编译器或不同平台间交换文件,建议用#pragma pack(1)取消对齐,或者手动序列化每个字段。 - 字节序(Endian)问题:x86 是小端,ARM 可以配置成大端。如果你在 x86 上写文件,拿到 ARM 设备上读,整数字节顺序是反的。跨平台时,我习惯用网络字节序(大端)统一存储,或者写文件时标记字节序。
- 读取时没检查实际读了多少字节:
read()不一定能读到你要的字节数,尤其是文件被截断或损坏时。用gcount()检查实际读取的字节数,别直接拿缓冲区里的数据当有效数据。
一句话总结:二进制读写是 C++ 文件操作的核心技能,掌握 read/write + seek/tell 四件套,你就能高效处理结构化数据。记住:打开加 binary,定位后检查状态,跨平台注意对齐和字节序。
好了,这一章的内容就到这里。二进制操作其实不难,多写几次就熟练了。下次遇到需要存配置、缓存数据、或者做简单数据库的场景,优先考虑二进制文件——你会感谢自己的选择。