C++ 二进制文件读写方式及示例详解

发布时间:2026/10/20 17:14:29
C++ 二进制文件读写方式及示例详解
前言二进制文件读写指的是按字节原样存取不做任何格式转换内存里是什么位模式文件里就是什么位模式。C 用fstream里的std::ifstream、std::ofstream、std::fstream完成这件事核心 API 只有四个read、write、seekg/seekp、tellg/tellp。最常见的误解是「打开文件加个binary就行了」。实际上std::ios::binary只关闭平台层的换行转换不解决结构体填充、字节序、指针成员、变长字段这些问题。另一个误解是「out.write(reinterpret_castconst char*(obj), sizeof(obj))可以把任意对象存下来」——只要对象里有std::string、虚函数指针或裸指针存下去的就是地址读回来必然失效。本文先讲打开模式与流状态再讲「对象 → 字节」和「字节 → 对象」两端的写法然后专门讨论结构体布局与字节序最后给出一个带魔数、版本号和长度前缀的完整存档示例。代码基准为 C17示例只用标准库。一、打开模式与流状态1.1 必须显式加 binary// 适用于 C17#include fstreamstd::ofstream out(data.bin, std::ios::binary | std::ios::trunc);std::ifstream in(data.bin, std::ios::binary);在 Windows 上文本模式会把\n写成\r\n读的时候再转回来二进制模式不做这个转换。Linux 上两者在fstream层面差别很小但为了可移植性二进制文件一律显式写std::ios::binary。std::ios::trunc表示打开时清空文件std::ios::app表示每次写都定位到末尾。ofstream默认自带truncifstream默认自带in。模式含义常见用途in只读ifstream默认out只写ofstream默认binary关闭换行转换所有二进制文件trunc打开时截断覆盖写app每次写入前定位到末尾追加日志ate打开后立即定位到末尾先取文件大小ate和app容易混ate只是初始位置在末尾之后可以随意seekpapp则强制每次写都去末尾。1.2 流状态与错误检查std::ios维护一组状态位状态位含义触发例子goodbit一切正常初始状态eofbit读到文件末尾读到最后一个字节之后failbit格式化/读写失败read没读满请求的字节数badbit底层 I/O 错误磁盘故障、流缓冲损坏read在没读满指定字节数时会置failbit和eofbit所以「读成功」要判断流本身而不是只看eof()// 适用于 C17char buf[16];in.read(buf, sizeof(buf));if (!in) {std::streamsize got in.gcount(); // 实际读到的字节数}gcount()返回上一次无格式输入实际提取的字符数只在read、get、getline这类函数之后有意义。1.3 输出流必须显式检查ofstream的析构函数会 flush但不会报告失败。磁盘满、权限不足这类错误如果不主动检查程序会以为写成功了// 适用于 C17out.write(data, size);out.flush();if (!out) {// 处理写入失败}更稳妥的做法是写完先flush()再检查流状态最后才让对象析构。二、写从对象到字节2.1 基本调用// 适用于 C17#include cstdint#include fstream#include vectorstd::vectorstd::uint32_t values{1, 2, 3, 4};std::ofstream out(values.bin, std::ios::binary);out.write(reinterpret_castconst char*(values.data()),static_caststd::streamsize(values.size() * sizeof(std::uint32_t)));write的第二个参数是std::streamsize有符号整数类型size_t转过去要显式static_cast否则在开启-Wconversion时会告警。2.2 结构体能不能直接写可以但有严格前提类型必须是可平凡复制trivially copyable的且不含指针、引用、虚函数// 适用于 C17#include type_traitsstruct Sample {std::uint32_t id;std::uint32_t value;};static_assert(std::is_trivially_copyable_vSample,binary dump requires trivially copyable type);即使满足这个条件直接 dump 仍然有两个隐患填充字节和字节序。编译器可能在成员之间插入 padding不同编译器、不同#pragma pack设置下布局可能不同多字节整数在大小端机器上的字节顺序也不同。要长期存储或跨平台交换应该逐字段写入并显式指定字节序。std::is_trivially_copyable_v是 C17 的变量模板写法C11 里要写std::is_trivially_copyableT::value。2.3 字符串与变长数据std::string不能按sizeof(std::string)写——那写的是对象内部指针和容量不是字符内容。正确做法是先写长度再写内容// 适用于 C17void write_string(std::ostream os, const std::string s) {const auto len static_caststd::uint32_t(s.size());os.write(reinterpret_castconst char*(len), sizeof(len));os.write(s.data(), static_caststd::streamsize(s.size()));}读的时候先读长度校验它不超过合理上限再resize并读入。不校验长度是解析二进制文件时最典型的漏洞一个伪造的长度字段就能让程序尝试分配几个 GB 内存。三、读从字节到对象3.1 读定长数据// 适用于 C17std::vectorstd::uint32_t values(count);in.read(reinterpret_castchar*(values.data()),static_caststd::streamsize(count * sizeof(std::uint32_t)));if (!in) {// 文件被截断或 count 不合理}read要求缓冲区至少有count * sizeof(T)字节。用vector时先resize不是reserve否则data()指向的区域没有构造元素写入是 UB。3.2 取文件大小// 适用于 C17std::ifstream in(data.bin, std::ios::binary | std::ios::ate);if (!in) { /* ... */ }const std::streamoff size in.tellg();in.seekg(0, std::ios::beg);ate让打开后位置直接在末尾tellg()返回std::streampos转成std::streamoff才能做算术。不要用seekg(0, std::ios::end)之后再tellg()的老写法——效果一样但多一次调用。3.3 定位与随机访问// 适用于 C17in.seekg(offset, std::ios::beg); // 从文件头偏移in.seekg(-4, std::ios::end); // 从文件尾倒退in.seekg(8, std::ios::cur); // 从当前位置前进偏移量类型是std::streamoff。对二进制文件做记录级随机访问时只有定长记录才能用「记录下标 × 记录大小」直接定位变长记录必须顺序扫描或额外维护索引。四、字节序与可移植性4.1 显式小端写入不同 CPU 的字节序不同直接把std::uint32_t的内存写进文件在大端机器上读出来就是反的。网络协议和多数文件格式约定使用小端可以手写转换// 适用于 C17#include cstdint#include ostreamvoid write_u32_le(std::ostream os, std::uint32_t v) {unsigned char buf[4];buf[0] static_castunsigned char(v 0xFFu);buf[1] static_castunsigned char((v 8) 0xFFu);buf[2] static_castunsigned char((v 16) 0xFFu);buf[3] static_castunsigned char((v 24) 0xFFu);os.write(reinterpret_castconst char*(buf), 4);}这段代码不依赖主机字节序无论在哪台机器上运行写出的都是小端序列。读的时候反向拼接即可。C20 提供了std::endianbitC23 提供了std::byteswap可以用它们做条件判断和转换但跨标准库支持不一手写四行最稳。4.2 浮点数double的二进制表示标准并未规定为 IEEE 754只是绝大多数实现如此。需要长期存档时可以考虑存十进制字符串或者明确记录「本文件使用 IEEE 754 binary64 小端」。不要假设所有平台完全一致。4.3 版本与校验一个能长期使用的二进制格式通常包含字段作用魔数magic快速判断文件类型、拒绝误读版本号格式演进时做兼容分支记录数/长度预分配、防御畸形输入校验和可选检测截断或损坏魔数用 4 个固定字节即可比如TLST。读的时候先比较魔数不匹配就直接报错避免把任意文件当成本格式解析。五、完整示例// 适用于 C17#include cstdint#include cstring#include fstream#include iostream#include string#include vectorstruct Record {std::uint32_t id;std::uint32_t value;};namespace {constexpr char kMagic[4] {T, L, S, T};constexpr std::uint32_t kVersion 1;constexpr std::uint32_t kMaxRecords 1000000;void write_u32(std::ostream os, std::uint32_t v) {unsigned char buf[4];buf[0] static_castunsigned char(v 0xFFu);buf[1] static_castunsigned char((v 8) 0xFFu);buf[2] static_castunsigned char((v 16) 0xFFu);buf[3] static_castunsigned char((v 24) 0xFFu);os.write(reinterpret_castconst char*(buf), 4);}bool read_u32(std::istream is, std::uint32_t v) {unsigned char buf[4];if (!is.read(reinterpret_castchar*(buf), 4)) return false;v static_caststd::uint32_t(buf[0]) |(static_caststd::uint32_t(buf[1]) 8) |(static_caststd::uint32_t(buf[2]) 16) |(static_caststd::uint32_t(buf[3]) 24);return true;}} // namespacebool save(const std::string path, const std::vectorRecord records) {std::ofstream out(path, std::ios::binary | std::ios::trunc);if (!out) return false;out.write(kMagic, 4);write_u32(out, kVersion);write_u32(out, static_caststd::uint32_t(records.size()));for (const Record r : records) {write_u32(out, r.id);write_u32(out, r.value);}out.flush();return static_castbool(out);}bool load(const std::string path, std::vectorRecord records,std::string error) {std::ifstream in(path, std::ios::binary);if (!in) {error cannot open file;return false;}char magic[4] {};if (!in.read(magic, 4) || std::memcmp(magic, kMagic, 4) ! 0) {error bad magic;return false;}std::uint32_t version 0;std::uint32_t count 0;if (!read_u32(in, version) || !read_u32(in, count)) {error truncated header;return false;}if (version ! kVersion) {error unsupported version;return false;}if (count kMaxRecords) {error record count exceeds limit;return false;}records.clear();records.reserve(count);for (std::uint32_t i 0; i count; i) {Record r{};if (!read_u32(in, r.id) || !read_u32(in, r.value)) {error truncated record;return false;}records.push_back(r);}return true;}int main() {std::vectorRecord data{{1, 100}, {2, 200}, {3, 300}};if (!save(records.bin, data)) {std::cerr save failed\n;return 1;}std::vectorRecord loaded;std::string error;if (!load(records.bin, loaded, error)) {std::cerr load failed: error \n;return 1;}for (const Record r : loaded) {std::cout r.id r.value \n;}return 0;}这份示例刻意没有直接 dumpRecord而是逐字段写小端。这样做代码略长但换编译器、换平台、改结构体成员顺序都不会破坏文件格式。count的上限校验是防御畸形文件的关键一步缺少它一个 4 字节的伪造长度就能让reserve抛出std::bad_alloc或触发过量分配。常见坑点❌ 打开二进制文件时忘记std::ios::binary✅std::ifstream in(path, std::ios::binary);Windows 文本模式会改写\r\n读出来的字节数对不上。❌out.write(reinterpret_castconst char*(str), sizeof(str));✅ 先写长度再write(str.data(), str.size())写std::string对象本身存的是指针和容量读回来必然无效。❌ 用reserve之后直接往data()里read✅ 先resize再读reserve不构造元素访问其存储是 UB。❌ 只看in.eof()判断读取是否成功✅ 判断if (!in)必要时看gcount()读满最后一个字节时eofbit还没置位逻辑会多走一轮。❌ 直接把结构体 dump 到文件并假设布局固定✅ 逐字段写或用static_assert加固定宽度类型并记录格式 填充字节、字节序、编译器差异都会让布局变化。❌ 相信文件里的长度字段并直接resize(length)✅ 先校验上限再分配 畸形文件可以让程序尝试分配巨量内存。❌ 写完不检查流状态直接依赖析构函数✅ 显式flush()后检查if (!out)ofstream析构不报告写入失败磁盘满时数据会静默丢失。❌ 用seekg在变长记录之间按固定步长跳转✅ 定长记录才能直接定位变长记录顺序扫描或建索引 变长记录的下标与偏移不是线性关系。总结主题关键结论打开模式二进制文件一律加std::ios::binary写入write(const char*, streamsize)长度显式转换读取read(char*, streamsize)读后检查流状态与gcount()对象序列化只对可平凡复制类型直接 dump且注意填充与字节序字符串先写长度再写字符内容字节序手写小端转换不依赖主机顺序错误处理输出流必须flush后检查析构不报错安全长度字段必须校验上限二进制读写的 API 很少难的是「文件格式」这一层布局是否稳定、字节序是否统一、长度是否可信、写入失败是否被发现。把这几件事在写第一行代码之前定清楚比事后调试一个「读出来是乱码」的问题要省力得多。对需要长期保存的数据建议始终带魔数和版本号并优先选择逐字段写入而不是结构体内存直拷。