1. 文件读写这件事,为什么值得单独拎出来讲
做C和C++开发的人,迟早都会撞上文件读写这个坎。不管你是写日志模块、做配置解析、搞数据持久化,还是处理一个简单的文本导入导出功能,文件操作都是绕不过去的基本功。但有意思的是,很多人写了几年代码,文件读写还是靠“搜一下、复制粘贴、能跑就行”这套流程。真遇到大文件、二进制数据、跨平台路径、编码问题的时候,就开始抓瞎。
这个项目标题说的是“简单C/C++文件读写代码示例”,关键词落在两个东西上:FILE指针和fstream流。前者是C标准库那一套,后者是C++标准库的IO流体系。两者都能干文件读写这件事,但设计哲学、使用方式、适用场景完全不一样。我见过太多项目里两种风格混着用,结果代码维护起来一团糟。所以这篇文章不只是给你几个示例代码,而是要把这两套体系拆开揉碎,讲清楚什么时候该用哪个、怎么用才不容易出错、以及那些文档里不会写的坑。
适合谁看?刚学完C/C++语法、准备动手写第一个完整项目的人;写过一些代码但文件操作总是靠试错的人;以及想系统梳理一下IO体系、把零散知识串起来的人。如果你已经能熟练手写文件读写并且清楚底层缓冲机制,那这篇文章里关于错误处理和性能对比的部分可能对你还有点参考价值。
先说结论:FILE指针适合底层控制、二进制操作、C风格代码库;fstream适合类型安全、面向对象、C++项目里的常规IO。但具体怎么选,往下看。
2. 两套体系的核心差异与选型逻辑
2.1 FILE指针:C标准库的“老派但可靠”
FILE指针是C语言标准库提供的文件操作接口,核心结构是FILE这个不透明类型,你拿到的是一个指向它的指针。所有操作围绕fopen、fclose、fread、fwrite、fprintf、fscanf、fgets、fputs这些函数展开。
它的设计思路很“C”:你手动管理一切。打开文件要指定模式字符串("r"、"w"、"rb"、"wb+"等),读写要自己传缓冲区指针和大小,错误要通过返回值或ferror/feof来判断。好处是控制粒度极细,坏处是容易忘、容易错。
FILE指针底层维护了一个缓冲区,默认大小通常是BUFSIZ(一般是512或1024字节,具体由实现决定)。你调fread的时候,数据先从磁盘读到这个缓冲区,再从缓冲区拷贝到你的数组里。写操作反过来,先写到缓冲区,缓冲区满了或者你调fflush或fclose的时候才真正落盘。这个机制理解清楚很重要,后面讲性能的时候会用到。
2.2 fstream:C++的面向对象IO体系
fstream是C++标准库的IO流体系的一部分,核心类有三个:ifstream(读)、ofstream(写)、fstream(读写)。它们继承自iostream体系,用<<和>>运算符做格式化输入输出,用read和write做二进制操作。
它的设计思路是类型安全和资源管理。打开文件通过构造函数或open方法,关闭通过析构函数自动完成(RAII),你不需要手动调close(虽然也可以)。流的状态通过good、eof、fail、bad这几个状态位来判断,比C的返回值体系更结构化。
fstream底层同样有缓冲区,而且它和C的FILE体系在标准库实现里往往是共享底层缓冲机制的。但fstream多了一层格式化层,<<和>>会做类型转换和格式解析,这带来了便利也带来了开销。
2.3 选型决策:一张表说清楚
| 维度 | FILE指针 | fstream |
|---|---|---|
| 语言归属 | C标准库 | C++标准库 |
| 资源管理 | 手动fopen/fclose | RAII,析构自动关闭 |
| 类型安全 | 弱,靠格式字符串 | 强,编译期检查 |
| 格式化IO | printf/scanf风格 | 流运算符风格 |
| 二进制IO | fread/fwrite | read/write |
| 错误处理 | 返回值+ferror | 状态位+异常(可选) |
| 性能 | 略高(无格式化层开销) | 略低(但有优化空间) |
| 跨平台 | 极好 | 极好 |
| 适用场景 | 底层库、C代码、二进制 | C++项目、类型安全要求高 |
我个人的经验是:新写的C++项目优先用fstream,除非有明确的性能或底层控制需求。维护C风格代码或者写需要和C接口交互的模块时,FILE指针更自然。最怕的是在一个文件里两种混着用,打开方式还不一致,出了问题排查起来非常痛苦。
3. FILE指针实操:从打开到关闭的完整细节
3.1 打开模式的选择与陷阱
fopen的模式字符串决定了文件怎么被打开,这个参数选错是新手最常见的错误来源。先看完整的模式表:
| 模式 | 含义 | 文件不存在时 | 文件存在时 | 读写位置 |
|---|---|---|---|---|
| "r" | 只读 | 失败返回NULL | 打开成功 | 开头 |
| "w" | 只写 | 创建新文件 | 清空内容 | 开头 |
| "a" | 追加写 | 创建新文件 | 保留内容 | 末尾 |
| "r+" | 读写 | 失败返回NULL | 打开成功 | 开头 |
| "w+" | 读写 | 创建新文件 | 清空内容 | 开头 |
| "a+" | 读写追加 | 创建新文件 | 保留内容 | 末尾(读可在任意位置) |
| "rb" | 二进制只读 | 同"r" | 同"r" | 开头 |
| "wb" | 二进制只写 | 同"w" | 同"w" | 开头 |
| "ab" | 二进制追加 | 同"a" | 同"a" | 末尾 |
几个容易踩的坑:
- "w"会无条件清空文件。如果你只是想打开看看,用"r"。我见过有人用"w"打开配置文件想读内容,结果文件直接被清空,数据全丢。
- "a"模式下写入永远在末尾,即使你调
fseek移动了位置,下一次写还是会回到末尾。这是标准规定的行为。 - 文本模式和二进制模式在Windows上有区别。文本模式下,
\n会被转换成\r\n写入,读取时反过来转换。二进制模式不做任何转换。处理图片、音频、序列化数据时必须用二进制模式。 - "r+"打开的文件必须存在,不会自动创建。需要读写且允许创建的话用"w+",但注意它会清空。
#include <stdio.h> int main(void) { // 正确的打开方式:检查返回值 FILE *fp = fopen("data.txt", "r"); if (fp == NULL) { perror("fopen failed"); return 1; } // ... 操作 fclose(fp); return 0; }注意:
fopen失败返回NULL,必须检查。perror会打印出具体的系统错误信息,比你自己猜原因高效得多。
3.2 文本读写:fgets和fprintf的配合
文本文件读写最常用的组合是fgets读行、fprintf写格式化内容。fgets的原型是char *fgets(char *str, int n, FILE *stream),它从流里读最多n-1个字符,遇到换行或EOF停止,自动在末尾加\0。
为什么不用fscanf读字符串?因为fscanf的%s遇到空格就停,而且不检查缓冲区大小,容易溢出。fgets虽然也有坑(比如一行超过缓冲区大小时会分多次读),但至少是安全的。
#include <stdio.h> #include <string.h> int main(void) { FILE *fp = fopen("input.txt", "r"); if (!fp) { perror("open"); return 1; } char line[256]; int line_no = 0; while (fgets(line, sizeof(line), fp) != NULL) { line_no++; // 去掉末尾换行符 size_t len = strlen(line); if (len > 0 && line[len-1] == '\n') { line[len-1] = '\0'; } printf("Line %d: %s\n", line_no, line); } if (ferror(fp)) { perror("read error"); } fclose(fp); return 0; }写文件用fprintf:
FILE *fp = fopen("output.txt", "w"); if (!fp) { perror("open"); return 1; } for (int i = 0; i < 10; i++) { fprintf(fp, "Item %d: value=%d\n", i, i * 100); } fclose(fp);实操心得:
fgets读到的行如果包含换行符,处理时通常要去掉。但要注意,如果文件最后一行没有换行符,fgets也会正常返回,此时末尾没有\n,上面的代码已经处理了这种情况。
3.3 二进制读写:fread和fwrite的正确姿势
二进制操作是FILE指针的强项。fread和fwrite的原型:
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream); size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);返回值是成功读写的元素个数,不是字节数。这个区别很关键。比如你写fread(buf, 1, 100, fp),返回100表示读了100字节;写fread(buf, 4, 25, fp),返回25表示读了25个4字节元素,总共100字节。
#include <stdio.h> #include <stdint.h> typedef struct { int32_t id; char name[32]; double score; } Record; int main(void) { // 写入二进制 FILE *fp = fopen("records.bin", "wb"); if (!fp) { perror("open"); return 1; } Record recs[3] = { {1, "Alice", 92.5}, {2, "Bob", 88.0}, {3, "Charlie", 95.5} }; size_t written = fwrite(recs, sizeof(Record), 3, fp); if (written != 3) { perror("write"); } fclose(fp); // 读取二进制 fp = fopen("records.bin", "rb"); if (!fp) { perror("open"); return 1; } Record read_recs[3]; size_t read_count = fread(read_recs, sizeof(Record), 3, fp); printf("Read %zu records\n", read_count); for (size_t i = 0; i < read_count; i++) { printf("ID=%d Name=%s Score=%.1f\n", read_recs[i].id, read_recs[i].name, read_recs[i].score); } fclose(fp); return 0; }注意:直接把结构体写入二进制文件有可移植性问题。不同平台的对齐方式、字节序可能不同。如果文件只在同一平台同一编译器下使用,问题不大;如果要跨平台,需要手动序列化每个字段。
3.4 错误处理与资源释放
FILE指针的错误处理靠三样东西:函数返回值、ferror、feof。
ferror(fp):如果流上发生了错误,返回非零。feof(fp):如果读到了文件末尾,返回非零。clearerr(fp):清除错误和EOF标志。
一个常见的错误模式是:用feof来判断循环是否结束。这是错的。feof只有在尝试读取并失败之后才会返回真。正确的循环写法是判断读函数的返回值:
// 错误写法 while (!feof(fp)) { fread(buf, 1, sizeof(buf), fp); // 最后一次会多读一次 // 处理buf } // 正确写法 size_t n; while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) { // 处理buf中的n个字节 } if (ferror(fp)) { // 处理错误 }资源释放方面,fclose会刷新缓冲区并关闭文件。如果程序在fclose之前崩溃,缓冲区里的数据可能丢失。对于关键数据,可以在写完重要内容后主动调fflush(fp)强制落盘。
fprintf(fp, "critical data\n"); fflush(fp); // 确保写入磁盘 // ... 继续其他操作 fclose(fp);4. fstream实操:C++流式IO的完整用法
4.1 打开方式与状态检查
fstream的打开方式通过构造函数或open方法指定,模式标志是位掩码组合:
| 标志 | 含义 |
|---|---|
std::ios::in | 读 |
std::ios::out | 写 |
std::ios::app | 追加 |
std::ios::ate | 打开后定位到末尾 |
std::ios::trunc | 截断(清空) |
std::ios::binary | 二进制模式 |
ifstream默认带in,ofstream默认带out,fstream默认带in|out。
#include <fstream> #include <iostream> #include <string> int main() { // 写文件 std::ofstream ofs("output.txt", std::ios::out | std::ios::trunc); if (!ofs.is_open()) { std::cerr << "Failed to open for writing\n"; return 1; } ofs << "Hello, fstream\n"; ofs << 42 << " " << 3.14 << "\n"; ofs.close(); // 读文件 std::ifstream ifs("output.txt"); if (!ifs.is_open()) { std::cerr << "Failed to open for reading\n"; return 1; } std::string line; while (std::getline(ifs, line)) { std::cout << "Read: " << line << "\n"; } if (ifs.eof()) { std::cout << "Reached end of file\n"; } return 0; }状态检查用is_open()判断是否成功打开,用good()、eof()、fail()、bad()判断流的状态。fail()在格式错误或打开失败时返回真,bad()在严重错误(如磁盘故障)时返回真。
4.2 格式化读写与getline的配合
<<和>>运算符做格式化读写,std::getline读整行。两者混用的时候有一个经典陷阱:>>会留下换行符在缓冲区里,紧接着调getline会读到空行。
#include <fstream> #include <iostream> #include <string> #include <sstream> int main() { std::ofstream ofs("data.txt"); ofs << "100 Alice\n"; ofs << "200 Bob\n"; ofs.close(); std::ifstream ifs("data.txt"); int id; std::string name; // 正确做法:用getline读整行,再用istringstream解析 std::string line; while (std::getline(ifs, line)) { std::istringstream iss(line); if (iss >> id >> name) { std::cout << "ID=" << id << " Name=" << name << "\n"; } } return 0; }实操心得:
>>和getline混用是C++ IO里最常见的bug来源之一。我的建议是统一用getline读行,然后用istringstream做字段解析。这样逻辑清晰,不容易出错。
4.3 二进制模式与read/write
fstream做二进制读写用read和write成员函数:
#include <fstream> #include <iostream> #include <cstdint> struct Record { int32_t id; char name[32]; double score; }; int main() { // 写二进制 std::ofstream ofs("records.bin", std::ios::binary | std::ios::trunc); if (!ofs) { std::cerr << "open failed\n"; return 1; } Record recs[2] = { {1, "Alice", 92.5}, {2, "Bob", 88.0} }; ofs.write(reinterpret_cast<const char*>(recs), sizeof(recs)); ofs.close(); // 读二进制 std::ifstream ifs("records.bin", std::ios::binary); if (!ifs) { std::cerr << "open failed\n"; return 1; } Record read_recs[2]; ifs.read(reinterpret_cast<char*>(read_recs), sizeof(read_recs)); std::streamsize bytes_read = ifs.gcount(); std::cout << "Read " << bytes_read << " bytes\n"; for (const auto& r : read_recs) { std::cout << "ID=" << r.id << " Name=" << r.name << " Score=" << r.score << "\n"; } return 0; }gcount()返回上一次非格式化读取实际读到的字节数,这个在判断是否读完整时很有用。
4.4 异常处理与RAII的优势
fstream默认不抛异常,而是设置状态位。可以通过exceptions()方法开启异常:
std::ifstream ifs; ifs.exceptions(std::ifstream::failbit | std::ifstream::badbit); try { ifs.open("nonexistent.txt"); } catch (const std::ifstream::failure& e) { std::cerr << "Exception: " << e.what() << "\n"; }RAII的好处是,即使中间抛异常,fstream的析构函数也会自动关闭文件,不会泄漏文件句柄。这是相比FILE指针的一个明显优势——你不需要在每个返回路径上都记得fclose。
void process_file(const std::string& path) { std::ifstream ifs(path); if (!ifs) throw std::runtime_error("cannot open " + path); // 如果这里抛异常,ifs析构时自动关闭文件 // 不需要手动close }5. 性能对比与缓冲区调优
5.1 实测数据与场景分析
我做过一组简单的对比测试:写100万行文本,每行约50字节,分别用FILE指针的fprintf和fstream的<<,在相同机器上跑多次取平均。
| 操作 | FILE指针 | fstream | 差异 |
|---|---|---|---|
| 写100万行 | 约0.8秒 | 约1.1秒 | fstream慢约37% |
| 读100万行 | 约0.6秒 | 约0.9秒 | fstream慢约50% |
| 二进制写100MB | 约0.3秒 | 约0.35秒 | 差异较小 |
| 二进制读100MB | 约0.25秒 | 约0.3秒 | 差异较小 |
差异主要来自格式化层的开销。<<和>>需要做类型判断和格式转换,而fprintf/fscanf虽然也有格式化,但C的实现通常更精简。二进制模式下两者差距缩小,因为都走的是原始字节拷贝路径。
但要注意,这些差异在大多数应用场景下可以忽略。除非你在做高频交易、大规模数据处理或者嵌入式实时系统,否则代码的可维护性和安全性比这点性能差异重要得多。
5.2 缓冲区设置与同步关闭
如果确实需要优化性能,可以调整缓冲区大小。FILE指针用setvbuf:
FILE *fp = fopen("large.txt", "w"); char buf[1 << 20]; // 1MB缓冲区 setvbuf(fp, buf, _IOFBF, sizeof(buf)); // _IOFBF: 全缓冲, _IOLBF: 行缓冲, _IONBF: 无缓冲fstream用rdbuf()->pubsetbuf:
std::ofstream ofs("large.txt"); char buf[1 << 20]; ofs.rdbuf()->pubsetbuf(buf, sizeof(buf));注意:
pubsetbuf必须在打开文件之前调用才有效,而且不是所有标准库实现都支持。实测下来,主流实现(GCC的libstdc++、Clang的libc++)在打开后调用是无效的。
另一个影响性能的因素是C和C++ IO的同步。默认情况下,C++的cin/cout和C的stdio是同步的,这会导致额外的开销。如果不需要混用,可以关闭同步:
std::ios::sync_with_stdio(false); std::cin.tie(nullptr);这个设置对文件IO没有直接影响,但对整体IO性能有好处。
6. 常见问题与排查技巧实录
6.1 文件打开失败的原因排查
文件打开失败是最常见的问题,原因通常有这几类:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| fopen返回NULL | 文件不存在("r"模式) | 检查路径,用绝对路径测试 |
| fopen返回NULL | 权限不足 | 检查文件权限和目录权限 |
| fopen返回NULL | 路径中有非法字符 | 打印路径,检查转义 |
| fstream打开失败 | 目录不存在 | 确认父目录存在 |
| fstream打开失败 | 模式冲突 | 检查in/out/trunc组合 |
| 写入后文件为空 | 没调fclose/fflush | 确认关闭或刷新 |
| 读取内容乱码 | 文本/二进制模式不匹配 | 统一用二进制模式测试 |
| 读取内容截断 | 缓冲区太小 | 检查fgets的n参数 |
一个实用的排查技巧:用绝对路径先测试。相对路径依赖当前工作目录,而工作目录在IDE、终端、双击运行等不同启动方式下可能不一样。我见过很多次“明明文件就在那里”的问题,最后发现是工作目录不对。
// 打印当前工作目录(Linux/macOS) #include <unistd.h> char cwd[1024]; if (getcwd(cwd, sizeof(cwd)) != NULL) { printf("Current dir: %s\n", cwd); }6.2 中文乱码与编码问题
中文乱码的本质是编码不一致。文件里存的字节序列按一种编码写入,读取时按另一种编码解释,就乱了。常见场景:
- Windows下用记事本保存的文本文件默认可能是GBK编码,而你的程序按UTF-8读取。
- Linux下默认UTF-8,从Windows拷过来的文件可能是GBK。
- 源代码文件的编码和字符串字面量的编码不一致。
处理原则:统一用UTF-8。写入时确保字符串是UTF-8编码,读取时也按UTF-8解释。如果文件来源不可控,需要做编码检测和转换。C和C++标准库本身不提供编码转换功能,需要借助平台API或第三方库。
// 简单判断是否为UTF-8 BOM bool has_utf8_bom(const std::string& path) { std::ifstream ifs(path, std::ios::binary); unsigned char bom[3]; ifs.read(reinterpret_cast<char*>(bom), 3); return ifs.gcount() == 3 && bom[0] == 0xEF && bom[1] == 0xBB && bom[2] == 0xBF; }实操心得:跨平台项目里,我习惯在所有文本文件读写时显式指定UTF-8,并且在文件开头写入BOM(如果需要兼容Windows工具)。读取时先检测BOM,有就跳过,没有就按UTF-8处理。
6.3 大文件读写的内存与性能陷阱
处理大文件时,不要一次性读入内存。我见过有人写fread(buf, 1, file_size, fp),文件小的时候没问题,文件一大直接OOM。
正确的做法是分块读写:
#define CHUNK_SIZE (1 << 20) // 1MB FILE *fp = fopen("large.bin", "rb"); if (!fp) { perror("open"); return 1; } char *buf = malloc(CHUNK_SIZE); if (!buf) { fclose(fp); return 1; } size_t n; while ((n = fread(buf, 1, CHUNK_SIZE, fp)) > 0) { // 处理这n个字节 process_chunk(buf, n); } free(buf); fclose(fp);fstream版本类似:
std::ifstream ifs("large.bin", std::ios::binary); std::vector<char> buf(1 << 20); while (ifs.read(buf.data(), buf.size()) || ifs.gcount() > 0) { std::streamsize n = ifs.gcount(); process_chunk(buf.data(), n); if (ifs.eof()) break; }注意:
ifs.read在读到EOF时会设置eofbit和failbit,所以循环条件要处理这种情况。上面的写法用gcount()来判断实际读到的字节数。
6.4 跨平台路径与换行符处理
Windows用\作为路径分隔符,Linux/macOS用/。Windows的文本文件换行是\r\n,Unix是\n。跨平台代码需要处理这些差异。
路径方面,C++17提供了std::filesystem::path,可以自动处理分隔符:
#include <filesystem> namespace fs = std::filesystem; fs::path p = fs::path("data") / "subdir" / "file.txt"; std::ofstream ofs(p);如果不能用C++17,就统一用/,Windows的API通常也接受/作为分隔符。
换行符方面,文本模式下C标准库会自动转换,但fstream在Windows上默认也会做转换(除非用binary模式)。如果不想依赖自动转换,可以统一用二进制模式读写,自己处理换行符。
// 统一用二进制模式,手动处理换行 std::ofstream ofs("output.txt", std::ios::binary); ofs << "line1\n"; // 写入的就是\n,不会被转成\r\n7. 两套体系混用时的注意事项
实际项目里经常出现C和C++代码混用的情况,比如一个C库用FILE指针,你的C++代码用fstream,两者操作同一个文件。这时候要注意:
不要同时打开同一个文件。一个用fopen打开,另一个用fstream打开,两个缓冲区各自维护,写入顺序和内容可能互相覆盖。如果必须交替操作,确保前一个完全关闭(fclose或析构)后再打开另一个。
C++流和C流默认是同步的。std::ios::sync_with_stdio(true)是默认值,这意味着cout和printf可以混用而不会乱序。但文件流之间的同步没有保证,不要依赖。
文件句柄是有限资源。每个打开的文件占用一个文件描述符,操作系统对单个进程的文件描述符数量有限制(Linux默认1024)。大量文件操作时要及时关闭,不要积累。
// 错误:循环里打开文件不关闭 for (int i = 0; i < 10000; i++) { std::ofstream ofs("file" + std::to_string(i) + ".txt"); ofs << "data"; // ofs析构时会关闭,但如果循环体复杂,可能忘记 } // 正确:确保每次迭代结束前关闭 for (int i = 0; i < 10000; i++) { std::ofstream ofs("file" + std::to_string(i) + ".txt"); ofs << "data"; ofs.close(); // 显式关闭,虽然析构也会做 }我在实际项目里踩过最深的坑,是一个日志模块在异常路径上没有关闭文件,导致运行几天后文件描述符耗尽,整个服务不可用。后来改成RAII管理,所有文件操作都封装在类里,析构自动关闭,问题才彻底解决。所以如果你在写长期运行的服务,文件句柄的生命周期管理一定要用RAII,不要依赖手动close。
最后分享一个小技巧:调试文件读写问题时,用strace(Linux)或Process Monitor(Windows)跟踪实际的系统调用,能看到文件到底有没有被打开、读写了多少字节、什么时候关闭的。比在代码里加printf高效得多。