二进制读写只有两个函数:out.write((char *)&变量, sizeof(变量)) 和 in.read((char *)&变量, sizeof(变量))。
本质一句话:把内存里的那段字节,原样搬到磁盘上(读的时候原样搬回来)。
三个必守规矩:① 打开要加 ios::binary;② 参数必须 (char *) 强转;③ 长度用 sizeof。
而最大的坑是"能不能写结构体":能,但有四个前提 ——否则写出来的文件换一次运行就是垃圾。
一句话概括:二进制写的是"内存的复本",所以内存里有哪些坑(对齐、指针),文件里就有哪些坑。
15.3.2 / 15.3.3 走了文本路,本节走二进制路,是本章技术上最"深"的一节:
- 8.1.5 的"结构体大小 ≠ 成员相加"(对齐)在这里变成真实存在的文件内容;
- 10.2.2 的"浅拷贝之雷"在这里换个形式重演:写入 string/指针成员=写入一个地址;
- 15.4.4 的"随机访问"要建立在本节的基础上(定长记录才能直接跳到第 N 条);
- 15.3.5 的文件复制是本节技术的直接应用。
一句话:本节讲"字节级搬运",这是"文件"最本来的面目。
一、文本模式 vs 二进制模式:一个能看见的差别
先做一个 10 秒就能验证的实验:
{ ofstream t("t.txt"); t << "A\nB\n"; } // 文本模式
{ ofstream b("b.txt", ios::binary); b << "A\nB\n"; } // 二进制模式
看两个文件的大小(Windows 上):
| 文件 | 大小 | 磁盘上真实的内容 |
|---|---|---|
t.txt(文本) |
6 字节 | A \r \n B \r \n |
b.txt(二进制) |
4 字节 | A \n B \n |
你在代码里只写了 4 个字符,文本模式却存了 6 个字节 ——因为 Windows 的换行惯例是 \r\n,文本模式自动帮你把 \n 换成了 \r\n。
读的时候它也会反过来转换(\r\n → \n)——所以用文本模式读文本,你完全感觉不到这件事。
但对二进制数据,这个转换就是灾难:
一个
.png里如果恰好有一个字节是0x0A(正好等于\n),文本模式会往里插一个0x0D——图片就废了。
结论:处理非文本内容(图片、压缩包、exe、结构体、定长记录),必须 ios::binary。
二、write / read 的用法
// 写:从 s 开始,原样写 n 个字节
out.write(const char *s, streamsize n);
// 读:从 s 开始,原样读 n 个字节
in.read(char *s, streamsize n);
三个值都得你自己给:
| 给什么 | 怎么写 | 为什么 |
|---|---|---|
| 数据的地址 | (char *)&变量 |
它只认字节,不认类型 |
| 数据的长度 | sizeof(变量) |
要搬几个字节 |
| 打开模式 | ios::binary |
别让系统改你的字节 |
最基本的用法:
int n = 12345;
out.write((char *)&n, sizeof(n)); // 写 4 个字节
int m = 0;
in.read((char *)&m, sizeof(m)); // 读回来恰好还是 12345
注意 &:写的是"变量所在的那段内存",不是变量的"值"。这就是二进制文件的核心:它保存的是内存的复本。
一次写一个数组(更好用,因为数组本来就在内存里连续排着):
int arr[5] = {1, 2, 3, 4, 5};
out.write((char *)arr, sizeof(arr)); // 一次写 20 字节
注意这里不需要 & ——数组名本身就是首地址。
三、能不能直接写结构体?能,但有四个前提
这是本节最需要想清楚的地方。 先看一个能跑的版本:
struct Record {
int id;
char name[16]; // ★ 用定长字符数组,不用 string
double score;
};
Record r = {1, "Ada", 95.5};
out.write((char *)&r, sizeof(r)); // 合法、能跑、能读回来
它能成立的前提有四个:
前提一:不能有指针成员(否则写进去的是"地址")
struct Bad { int *p; }; // ❌ 千万不能这么写文件
写进去的是"那个地址的数值"(比如 0x7ffd1234)。下次运行程序,内存布局全变了,这个地址指向的东西完全不确定 ——读回来的就是一个野指针,用了就崩。
前提二:不能用 string / vector 成员(它们内部就有指针)
struct AlsoBad { string name; }; // ❌ 同样不行
string 对象本身只有"指针 + 长度 + 容量"这几个字段(实际数据的字符在堆上)。你把它写进文件,写进去的是那些内部字段,不是字符本身。 ——读回来必然崩。
所以真正能写文件的结构体,里面的"变长东西"必须换成"定长数组"(像上面那样
char name[16])。这就是 C 时代"姓名最多 15 个字符"这种限制的由来。
前提三:对齐填充字节也会被写进去(文件比你想的大)
cout << sizeof(Record); // 32
// 成员相加 = 4 (int) + 16 (char[16]) + 8 (double) = 28
多出来的 4 个字节是"对齐填充"(8.1.5 讲过规则:double 要放在 8 的倍数偏移处,所以 name 后面被塞了 4 个填充字节)。
偏移 0 4 20 24 32
┌────────────┬────────────────────┬─────┬─────────────┐
│ int id(4) │ char name[16] │填充4│ double(8) │
└────────────┴────────────────────┴─────┴─────────────┘
填充字节里是什么? 是垃圾值(从未初始化)。
所以"同样的数据,不同编译器写出来的文件可能不一样" ——文件里掺了"内存的随机残渣"。 严格来说,这不是错误(读的时候会被忽略),但说明"二进制文件不跨编译器"。
前提四:字节序和类型长度由平台决定
- 大端/小端:不同 CPU 存
int的字节顺序可能相反; - 类型长度:
int通常是 4 字节,但标准只保证"不小于short"。
结论:
二进制文件适合"同一个程序自己读自己写"(存档、缓存、临时数据)。 不适合"跨平台交换" ——要跨平台,就用文本 + 明确的格式(JSON / CSV),或者自己定义字节序(网络协议里就是这么做的)。
四、gcount():这一次到底读到了几个字节
in.read((char *)&r, sizeof(r));
cout << in.gcount(); // 这一次 read 真的读到了几个字节
它解决什么问题? "文件长度不是记录长度的整数倍"的情况。
比如文件有 98 字节,记录是 32 字节 ——第 3 条读了一半就没了:
read会失败(因为没读满)——但前 66 字节已被读走;gcount()告诉你"这次读到了 66 - 64 = 2 字节";- 循环
while (in.read(...))会正确地停下(因为read失败了)——最后那条残缺记录不会被处理。
所以 while (in.read(...)) 是二进制读的标准写法,和 while (getline(...)) 一个道理:把读操作当条件。
五、实例06:使用 read 读取文件
教材这道题要做的:用 read 把二进制文件里的记录读出来。
#include <iostream>
#include <fstream>
using namespace std;
struct Record {
int id;
char name[16];
double score;
};
int main() {
const string fname = "records.dat";
// ══ ① 写 3 条记录 ══════════════════════════
{
Record recs[3] = {
{1, "Ada", 95.5},
{2, "Bob", 88.0},
{3, "Cid", 72.5}
};
ofstream out(fname, ios::binary);
if (!out) { cerr << "写不了 " << fname << endl; return 1; }
out.write((char *)recs, sizeof(recs)); // 一次把 3 条都写出去
out.close();
}
cout << "① sizeof(Record) = " << sizeof(Record) << " 字节" << endl;
cout << " 文件里应该有 3 条 × 32 = 96 字节" << endl;
// ══ ② 一条一条读回来 ════════════════════════
{
ifstream in(fname, ios::binary);
if (!in) { cerr << "读不了 " << fname << endl; return 1; }
Record r;
int idx = 0;
while (in.read((char *)&r, sizeof(r))) { // ★ 读一条、判一条
idx++;
cout << "② 第 " << idx << " 条(读到 " << in.gcount() << " 字节): "
<< "id=" << r.id
<< " name=" << r.name
<< " score=" << r.score << endl;
}
cout << "③ 循环结束后 gcount() = " << in.gcount()
<< "(最后一次读失败,一个字节都没读到)" << endl;
in.close();
}
return 0;
}
看到什么算成功:
① sizeof(Record) = 32 字节
文件里应该有 3 条 × 32 = 96 字节
② 第 1 条(读到 32 字节): id=1 name=Ada score=95.5
② 第 2 条(读到 32 字节): id=2 name=Bob score=88
② 第 3 条(读到 32 字节): id=3 name=Cid score=72.5
③ 循环结束后 gcount() = 0(最后一次读失败,一个字节都没读到)
数字怎么来的(本 Demo 最值得琢磨的地方):
sizeof(Record) = 32,而成员相加只有 28(4 + 16 + 8)——多出的 4 字节是对齐填充(double必须从 8 的倍数偏移开始,所以name后面塞了 4 个字节)。这就是"结构体大小 ≠ 成员相加"在文件里的实锤。- 文件总大小 = 3 × 32 = 96 字节,不是 3 × 28 = 84。
- ③ 的
gcount()是 0:第 4 次读时文件已到末尾,一个字节都没读到,所以read失败、循环退出。
读结果:
- ② 里的
name直接cout就能打印 ——因为它是定长字符数组(char name[16]),内容是"Ada"加上后面的'\0'(write连那些'\0'一起写进去了)。 while (in.read((char *)&r, sizeof(r)))这个写法请记住 ——和while (getline(...))是同一个道理:把读操作当条件,读成功就处理、失败就退出。score=88(不是88.0) ——这是cout对double的默认输出,和文件无关(文件里就是那 8 个字节)。
两个值得做的小实验:
- 把
char name[16]改成string name,再跑 → 程序很可能直接崩(0xC0000005 之类的访问冲突)。因为string内部有个指针,读回来的是个野指针。 ——这就是本节第三段"前提二"的实测。 - 把
int id和double score的顺序对调(写成double在前、int在后)→sizeof可能变(不一定,取决于对齐)。体会"成员顺序影响被写入的字节数"。
六、30 秒实验
- 把
ios::binary从两处都删掉,两端仍然用write/read→ 在这个例子上可能"照样对"(因为数据里没有\n那个字节)。但这是运气。 把name填成含\n的字符串再试 → 大小会变、内容会错。永远别省binary。 - 用记事本打开
records.dat→ 乱码。用十六进制查看器打开 → 你会看到:01 00 00 00(小端存的1)+Ada+ 一堆00+ 8 字节的95.5。亲眼看到"内存的复本"长什么样。 - 把文件手动截短几个字节(在十六进制编辑器里)再跑读程序 → 第 3 条不再被打印,
while (in.read(...))正确地停下了。证明"读操作当条件"在数据损坏时也安全。 - 把
sizeof(recs)改成sizeof(Record)(只写一条) → 文件只有 32 字节,只能读出 1 条。
七、常见误区
- "二进制文件就是加密文件" ——不是。它只是"没有转成文字",内容和内存一模一样。打开是乱码,但只要按同样的结构读,就能完整还原。
- "结构体可以随便
write出来" ——不能。有指针、有string/vector成员的结构体写出去就是"埋了个雷"(读回来是野指针)。 - "
write不用写sizeof,它自己知道多大" ——不知道。它只认字节,多大完全由你说。 ——这也是为什么它参数是char *。 - "二进制文件比文本文件小,所以一定更优" ——不一定:二进制不跨平台、不能手工改、不抗版本变化(换个结构体定义就读不出来了)。它是"高性能",不是"更高级"。
- "写了
ios::binary只是'更快'" ——不是速度问题,是"准不准"的问题。不加binary,Windows 上你的字节会被改。 - "读二进制时也用
while (!in.eof())就行" ——同样的错(15.3.3 / 15.4.3)。要用while (in.read(...))。
自测三问(点开对答案)
1. **二进制读写用哪两个函数?三个必须写对的东西是什么?** ——**`write` / `read`**。**① 地址要 `(char *)&变量` 强转;② 长度用 `sizeof(变量)`;③ 打开时必须加 `ios::binary`。** 2. **为什么含 `string` 或指针成员的结构体不能直接 `write` 到文件?** ——**因为 `string` 和指针的"内容"不在对象自身内部,而在别的地方(堆/别处)**:**写进去的只是"那个地址的数值"**。**读回来时内存布局已变,这个地址就是野指针**,用一下就崩。 3. **`sizeof(Record)` 是 32,但成员相加只有 28,多出来的 4 个字节是什么?会写进文件吗?** ——**是对齐填充**(`double` 需要 8 字节对齐,所以 `char[16]` 后面补了 4 个字节)。**会写进文件**——**所以文件比"数据本身"大**。**填充字节里是未初始化的垃圾值**,这也说明二进制文件"不跨编译器"。- ☐ 下一节(15.3.5 实现文件复制)
相关笔记
- 读取文本文件 —— 上一节:文本路的读法(含实例05)
- 实现文件复制 —— 下一节:把本节的技术用起来
- 结构体的大小 —— 对齐填充规则(本节
sizeof=32的出处) - 复制构造函数 —— 浅拷贝之雷的"文件版"
- 在指定位置读-写文件 —— 定长记录 + 定位 = 随机访问
- 🔷 C++基础 · 索引 —— 全课路线图