1. 你以为fread/fwrite只是read/write的封装吗先搞清楚它到底解决什么问题很多人学习Linux文件编程时第一个接触的就是open/read/write这一套POSIX系统调用然后看到fopen/fread/fwrite这套标准C库函数会习惯性地认为这不就是read/write加了个缓冲嘛用哪个都行。这个理解不算错但太粗糙了。真实工作里你在什么场景下必须用fread/fwrite什么场景下用它反而会坑到自己很多写了两年C的人都没完全想明白。先看一段最常见的代码我见过无数项目里是这么写读文件的#include stdio.h #include stdlib.h int main(void) { FILE *fp fopen(data.bin, rb); if (!fp) { perror(fopen); return 1; } char buf[4096]; size_t n fread(buf, 1, sizeof(buf), fp); printf(read %zu bytes\n, n); fclose(fp); return 0; }这段代码看起来人畜无害但至少有三个问题第一你没检查fread的返回值是否等于你要读的字节数第二你没区分“读到文件末尾”和“发生错误”这两种情况第三你也没搞清楚fread一次性读4096字节和你以为的“读一次就完整了”之间到底有多大差距。fread/fwrite这套函数说白了是C标准库在系统调用之上做的一层“带用户态缓冲的字节流读写接口”。它的核心价值在于三件事减少系统调用次数。read/write每调用一次就要进入内核一次频繁小数据量读写时开销很大。fread/fwrite会把多次小读写合并成大块读写。提供标准化的按对象读写能力。你可以直接读一个int、一个结构体而不用自己算字节数和偏移。让代码具备可移植性。因为它是C标准定义的你在Windows上的fread和Linux上的fread语义一致但read/write在不同平台上的行为细节不太一样。但这也意味着你为“方便和通用”付出的代价是——你失去了对读写过程的精确控制。哪些东西失控了缓冲区何时真正落盘、读到的数据是否已经过期、两个进程同时写一个文件时会不会互相覆盖这些都需要你自己去处理。这篇文章我就把fread/fwrite从原理到实战从正常用法到坑系统地过一遍。内容基于我在实际项目中跟文件打交道攒下来的经验不是手册复读。2. 读完没读全、写了一半没报错fread返回值是怎么骗你的2.1 fread返回值三态你只记住了“返回字节数”fread的原型长这样size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);它返回的是“成功读取的对象个数”不是字节数。如果size1那返回的数值才等于字节数。如果size4、nmemb100你试图一次读100个int它返回30意思是成功读了30个int也就是120字节而不是“读了30字节”。这里有三态返回nmemb全部读满一切正常。返回0且feof(stream)为真文件已经读到末尾没有更多数据了。返回0且ferror(stream)为真读的过程中发生了错误比如底层设备IO故障、权限被改、磁盘断开。但最麻烦的是第四种情况——返回一个介于0和nmemb之间的值。这表示文件在你计算的“理想块大小”之前就结束了或者数据流被截断。这是正常现象不是bug但很多人把它当成bug来处理。我之前帮一个同事排查过一个数据解析程序他用fread读一个结构体数组文件明明只有5个完整结构体的长度他按照100个结构体去读fread返回了5。他的代码里没检查这个返回值然后他直接用返回的缓冲区后面95个结构体全是上次内存里的残值解析出来的数据有时看起来正常有时是野数据。这种bug特别难查因为它不会崩溃只会“偶尔算错”。2.2 正确的读法循环fread直到读满或确实结束正确的通用读取模式是循环调用fread直到返回值为0再判断是EOF还是error#include stdio.h #include errno.h int read_full(FILE *fp, char *buf, size_t len) { size_t total 0; while (total len) { size_t n fread(buf total, 1, len - total, fp); if (n 0) { if (ferror(fp)) { perror(fread); return -1; } // EOF数据不足 return (int)total; } total n; } return (int)total; }注意这里我每次用buf total说明fread不是“从头开始覆盖”而是从你给的指针位置继续写。另外读完之后立刻判断ferror和feof不要隔了很久再判断因为流状态在你做其他操作之后可能会变化。2.3 fwrite返回值一个被忽略的“短写”陷阱很多人的认知里fwrite要么全写成功要么报错。实际上它在磁盘满了、磁盘配额超了、管道破裂等情况下也会“部分成功”也就是说只写了几个对象就返回了。所以你同样要循环检查size_t write_full(FILE *fp, const void *data, size_t len) { const char *p (const char *)data; size_t total 0; while (total len) { size_t n fwrite(p total, 1, len - total, fp); if (n 0) { if (ferror(fp)) { perror(fwrite); return total; } break; } total n; } return total; }fwrite写不完的情况在实际生产里不算特别高频但一旦发生就是数据损坏级别的灾难。尤其在网络文件系统上或者写入奇慢的设备时更容易暴露。我现在写文件相关代码默认都是“循环读写检查ferror”的模板不给自己留侥幸空间。提示判断文件读写是否成功“返回值是否等于目标长度”是第一道门但有的错误是延迟报出来的。比如fclose失败也会导致你之前写的数据没落盘后面细说。3. fread/fwrite和read/write混用缓冲区的“双重人格”是怎么制造脏数据的3.1 标准I/O的缓冲机制你以为写进去了其实还在内存里fwrite调用成功不等于你的数据已经写到了磁盘上的文件。它只是把数据拷贝到了标准库为你分配的用户态缓冲区里至于什么时候真正调用write系统调用把数据交给内核由缓冲策略决定。C标准库有三种缓冲策略全缓冲缓冲区满了才刷出。适用于普通磁盘文件。行缓冲遇到换行符就刷出。适用于终端交互设备典型的是stdout在终端模式下。无缓冲立即写出。典型的是stderr。这种设计是为了效率——如果你每次fwrite一个字节它会在缓冲区内累积攒到一定量再一次性write。这个“量大从优”的思路本身没毛病但会造成一个认知偏差你写完一个文件马上断电数据可能全丢因为缓冲区里的东西还没刷到内核。3.2 fileno与fdopen混用的正确姿势有人喜欢在同一个文件上既用fread/fwrite又用open/read/write直接搞混。我先说一个结论同一个打开的文件流不要直接混用stdio函数和裸系统调用除非你知道自己在做什么。如果你必须混用比如你要在一个用open打开的文件描述符上做fprintf或者你在一个FILE*上调用fcntl之类正确的打通方式是用fileno(fp)拿到FILE*底层的文件描述符再用fsync(fileno(fp))强制落盘。用fdopen(fd, r)把文件描述符包装成FILE*之后统一用fread/fwrite操作。但有个经典问题如果你先调用fread读了一些数据这时候标准库的缓冲区里可能还有一堆预读的数据底层的fd文件位置已经比你的逻辑位置超前了。此时你再拿fileno(fd)去做lseek或者read你会读到“跳过的数据”造成错乱。反过来如果不小心先调用了底层write又去调fread缓冲区的内容和文件当前偏移可能不一致。我在一个项目里就踩过这种坑先用open打开文件然后手动用lseek跳过一个头部再fdopen接着用fread读数据。结果数据头部错位了4个字节。原因就是open之后我操作fd的位置和fdopen创建缓冲区时感知的文件位置没对齐。解决方法是在fdopen之前先让fd的位置确定好或者干脆从头走一套接口到底。我的建议很简单——向文件写东西要么全用stdio要么全用系统调用别混。真需要混的时候中间加一个fflush或lseek强制同步位置然后才能交叉使用。3.3 刷缓冲的完整链路fflush只到内核fsync才到磁盘再往下拆一层。写文件的落盘过程是三级的用户态缓冲区由stdio管fflush负责把它刷到内核。内核页缓存page cache由内核管write成功只是进了这层write返回不代表数据在磁盘上。磁盘介质本身由磁盘控制器管需要fsync/fdatasync主动要求内核把数据块刷新到物理介质。这三级各有不同的刷写命令fflush(fp); // stdio缓冲 - 内核 fsync(fileno(fp)); // 内核 - 磁盘 fdatasync(fileno(fp)); // 只刷数据不刷元数据比fsync轻如果只是让数据“对别的进程可见”fflush就够。如果要保证断电之后数据还在必须fsync或fdatasync。数据库系统、日志系统、配置中心这类对持久性要求高的场景光调fflush等于亡羊补牢断电一样丢。有一个容易被忽略的细节fclose其实隐含了fflush但不管它隐含不隐含你最好显式检查fclose的返回值。如果fclose失败说明最后一段缓冲没刷干净而你的业务代码可能已经认为“文件保存成功”了直接造成数据丢失。4. fread/fwrite的性能边界什么时候它的“缓冲”反而拖后腿4.1 一次读多大最划算我简单做个情景分析。假设你要处理一个1GB的文件fread的缓冲区大小分别为512字节、4KB、64KB、1MB系统调用的次数差异是巨大的512字节需要约200万次系统调用频繁陷入内核CPU和上下文切换成本高。4KB约25万次这个量级是大多数程序的默认选择。64KB约1.6万次系统调用开销已经可以忽略。1MB约1000次实际耗时提升开始不明显甚至因为缓存不友好而变慢。在我的实测里普通SSD上4KB和64KB的吞吐差距可以到3倍以上64KB和1MB之间的差距往往基本没了。所以不要过分迷信“缓冲区越大越好”。一般来说16KB到64KB之间是性价比很高的区间兼顾了系统调用次数和内存缓存命中。另一个反直觉的点如果你用fread读单个字节一亿次理论上它是“带缓冲的”每次fread不会触发系统调用性能不会差到离谱。但它的开销在于每次调用都要经过函数指针、内部状态检查、缓冲游标移动一亿次也是上亿次函数调用同样不可忽视。所以我在读固定块、逻辑上不依赖字节流的场景一定用大块fread而不是一个字节一个字节地磨洋工。4.2 大文件顺序读写的正确姿势对于纯顺序读写几十GB级别的大文件fread/fwrite的性能不见得比直接read/write差前提是你把缓冲区调到足够大而且一次性读入的数据在后续处理时是“连续访问”的不是反复跳跃。我用过的一个比较稳的模板是这样#define BUFSZ (64 * 1024) static char io_buf[BUFSZ]; FILE *fp fopen(path, rb); while (1) { size_t n fread(io_buf, 1, BUFSZ, fp); if (n 0) { if (ferror(fp)) { // 处理错误 } break; } process(io_buf, n); }注意一个细节io_buf是全局变量或者静态变量不是在循环内反复fopen出来的局部大数组。栈上放64KB在默认栈大小8MB的Linux系统上勉强可以但你要是开线程线程栈可能只有几MB栈上放1MB缓冲很容易爆栈。堆上malloc也要记得释放。全局缓冲区没有栈溢出风险但对多线程不友好多线程里建议用线程内堆缓冲区。4.3 O_DIRECT和fread/fwrite基本不搭有个朋友问我为什么他用O_DIRECT打开文件再用fread读缓冲区总是读不到数据。这其实是概念冲突O_DIRECT要求读写绕过用户态和内核态缓存直接对齐到磁盘块访问。但fread是标准库缓冲区模型它本身就在做用户态缓存这两者叠加没有任何意义。如果你真的需要绕过缓存做大块裸IO应该用open配合read/write并且注意对齐缓冲区地址、偏移、长度都要按照块大小通常512字节或4KB对齐。fread/fwrite的目标场景是通用字节流处理不是高性能裸IO。选错工具就会出现“优化了半天反而更慢”的情况。5. 二进制数据读写直接fwrite结构体两年后你会后悔5.1 结构体落盘的三大隐患对齐、填充、大小端新手最快的落盘方式是这样struct item { int id; char name[32]; float price; }; struct item it {...}; fwrite(it, sizeof(it), 1, fp);这会遇到三个跨平台/跨版本的大坑第一结构体填充字节。int加char[32]加float理论上是40字节但对齐规则可能让它在内存里占40字节也可能在中间插入padding变成44字节甚至更多。不同编译器、不同平台、不同编译选项sizeof的结果可能不一样。这个文件今天在x86上写出来明天放到某个嵌入式ARM平台读结构体尺寸一变整个数据流全错。第二大小端问题。x86和ARM小端环境下int的字节序一致还好但你要是跑在一些网络设备或者老式PowerPC平台上int的字节序是相反的。直接按结构体读出来的int数值会翻转。第三版本兼容。你往结构体里加一个字段老文件读新代码、新文件读老代码都会出问题。没有版本号的裸结构体就是给未来埋雷。5.2 一个多数项目里更稳妥的二进制格式设计我不是说不能用二进制而是说你应该设计一个稳定的序列化协议而不是直接倾倒内存布局。简单的做法是定一个文件头加记录的布局文件头 magic : uint32 0x5A5A1234 version : uint16 record_count : uint32 记录体 len : uint16 // 这条记录总字节数 type : uint8 data : 按字段逐项写出的、长度明确的数据写每条记录时用fwrite按字段逐个写并在关键字段上做显式字节序转换static uint32_t hton_u32(uint32_t v) { // 假设本机是小端做一个显式的字节序swap return ((v 0xFF000000u) 24) | ((v 0x00FF0000u) 8) | ((v 0x0000FF00u) 8) | ((v 0x000000FFu) 24); } uint32_t net_id hton_u32(it.id); fwrite(net_id, sizeof(net_id), 1, fp); fwrite(it.name, 1, sizeof(it.name), fp); float net_price hton_float(it.price); // 按IEEE754的字节展开处理 fwrite(net_price, sizeof(net_price), 1, fp);这么做看起来麻烦但它保证了数据文件不依赖编译器和CPU架构。文件格式一旦确定新旧系统之间就能稳定交换数据。在这个基础上再谈版本升级才是安全的。5.3 为什么“先写长度再写数据”这么重要如果你一条记录变长了比如code字段从32字节扩到64字节老文件里的每一跳长度都变化数据边界会从头错到尾。所以每条记录一定要带上自己的长度字段。读的时候先读长度再读数据遇到超长记录就跳过或按版本解析。我在写一个日志存储模块时就是用这个思路每条日志是一个变长记录开头是uint32的长度接着是uint32的seq编号再是payload。读取时循环fread长度、fread整个记录、处理。结果就是文件尾部截断了也能知道“最后一条记录不完整”可以安全恢复不会把半截数据当完整日志解析。如果当初直接fwrite一个定长结构体一旦尾部截断后面所有记录全都错位。6. 多线程与多进程操作同一个文件fwrite不保证你想的那种原子性6.1 fwrite对写入单元不保证“一个调用完整落盘”很多人以为fwrite(ptr, size, nmemb, fp)是一次原子操作。在单进程单线程里它确实是“逻辑上连续”的但在多线程或者多进程写同一个文件时事情完全不同。假设两个线程同时调用fwrite每个线程写一行内容线程A的fwrite内部先把数据放进同一个FILE*的用户态缓冲区但是这个缓冲区本身会被A、B交替访问。如果缓冲没满A的数据和B的数据在内存里就可能交错了。即使缓冲区刷出去底层的write系统调用也不保证“一次写到底”。写普通文件时write通常一次写完整个缓冲区但如果你写的是管道、socket或者文件系统很诡异write可能只写了一部分。底层write对普通磁盘文件一般是一次性写进去的但对非普通文件不是。你如果把一个FILE*指向一个socket再fwrite就必须自己处理“部分写入”的情况。6.2 多进程写同一个文件的对策O_APPEND、文件锁、独立偏移最常遇到的场景是一堆worker进程同时往一个日志文件或数据文件追加内容。这时候有三个层级的手段低配打开文件时带上O_APPEND标志。在Linux上O_APPEND保证每次write的写入偏移在文件末尾而且这个“取末尾再写”的操作对普通本地文件是原子的。但注意用fopen打开文件时怎么传O_APPEND用fopen的a模式就会带上O_APPEND语义。中配用fcntl加POSIX记录锁保证一个完整写入期间别人不写。这能解决“一条日志被拆开”的问题。高配每个进程独立打开文件、独立维护自己的偏移然后由下一步程序做合并。适用于写入量极大的场景但合并逻辑复杂。实际中我推荐“a模式单条记录尽量小写完fsync”的组合。a模式解决偏移原子性小记录降低交错概率fsync保证持久化顺序。如果你要写超大记录比如一条几十KB建议还是加锁#include fcntl.h struct flock fl { .l_type F_WRLCK, .l_whence SEEK_SET, .l_start 0, .l_len 0, // 0表示锁整个文件 }; int fd fileno(fp); if (fcntl(fd, F_SETLKW, fl) -1) { perror(fcntl lock); } // fwrite... fl.l_type F_UNLCK; fcntl(fd, F_SETLK, fl);这里有个经验加锁只保护你的代码段不保护别的程序。别的进程如果用“裸write、不加锁”你这里锁了也白搭。所以多进程写文件的协作前提是“所有写方都遵守同一套规则”。6.3 feof和ferror读结束时别搞反了再回到读取端fread读文件读到最后判断结束的标准是feof(fp)返回真。但有个常见的顺序错误你先fread了一次返回值是0你立刻用feof判断是否结束。这在大多数情况下是对的但严格意义上feof是“上次发生读取时碰到了EOF”不是“我现在到了EOF”。在部分边缘场景比如底层设备返回异常或者缓冲预读已经透支下feof的时机可能比你以为的晚一次。更稳的判断逻辑是fread返回0时先看ferror是否有错误再看feof是否到达末尾。不要反过来。很多库函数内部也是这个顺序。if (fread(buf, 1, sz, fp) 0) { if (ferror(fp)) { // 真正的IO错误 } else if (feof(fp)) { // 正常结束 } else { // 理论上不会出现但存在就说明有什么地方不对劲 } }这个“else分支”很有意思按理说fread返回0且ferror、feof都是0是不可能的。但我在一个虚拟文件系统驱动项目里真见过一次这种状态——文件系统的回调返回了0但没有设置任何状态标志stdio就被卡在一个尴尬的位置。所以代码里留一个兜底分支不至于静默处理掉未知状态。7. 实用工程模式一个稳定可靠的读写工具函数与异常定位思路说了这么多坑最后给一个我自己项目里一直用的、比较顺手的文件写入工具函数和一些排查问题的固定套路。7.1 完整的写入流程模板#include stdio.h #include string.h #include errno.h #include unistd.h int save_file_atomic(const char *path, const void *data, size_t len) { // 先写到临时文件再改名尽量避免写一半破坏原文件 char tmp[512]; snprintf(tmp, sizeof(tmp), %s.tmp.XXXXXX, path); int fd mkstemp(tmp); if (fd 0) { perror(mkstemp); return -1; } FILE *fp fdopen(fd, wb); if (!fp) { close(fd); return -1; } size_t written fwrite(data, 1, len, fp); if (written ! len || fclose(fp) ! 0) { unlink(tmp); return -1; } // 加一层fsync把内核缓存刷到磁盘 FILE *check fopen(tmp, rb); if (check) { fsync(fileno(check)); fclose(check); } if (rename(tmp, path) ! 0) { unlink(tmp); return -1; } return 0; }逻辑很简单先写一个带随机后缀的临时文件写完检查长度、关流检查错误再fsync最后rename覆盖目标文件。这套流程能防住绝大多数“写入中途断电/崩溃导致目标文件损坏”的场景。代价是写一次文件会多一次rename性能损耗可接受。注意mkstemp创建的临时文件权限是0600如果你需要让别人能读记得chmod否则后面换了个用户读取就会莫名失败。7.2 定位文件I/O问题的固定排查顺序如果你在一个程序里遇到了“文件读出来不对”“写着写着数据丢”之类的问题我的排查顺序是先确认文件打开方式。是w还是awb还是w少了b在Linux上没区别在Windows上会有区别。确认返回值都被检查了。fread/fwrite的返回值,不要只检查非0。确认缓冲层级。是不是fflush都调了但没fsync是不是以为fclose之后数据就稳了确认进程生命周期里是否还有fork。fork之后子进程如果也操作了同一个FILE*缓冲区和fd都会被复制可能导致双写和锁错乱。确认文件格式是否跨平台。结构体布局、大小端、字段顺序在另一个环境上解析一下试试。用strace跟踪真实系统调用。strace -f -e traceread,write,fsync,fdatasync ./your_program看write系统调用的次数、字节数、是否发生短写。这一步能快速区分“问题在stdio层还是内核层”。用strace有几个典型的观察点如果fread已经读到数据但strace里read系统调用数量很少说明数据大部分是stdio缓冲里来的问题可能在缓冲区一致性和偏移如果write系统调用每次都完整但fwrite返回值还是小于预期那问题出在文件系统/设备上报错的方式上perror的输出会给你更多指向。7.3 最后一个经验别把“能跑”当“正确”文件编程和内存编程最大的差别是内存里错了会崩、会噶文件里错了常常只是“数据偏差了几个字节”程序照常运行表面看起来一切正常。等到数据被下游系统消费时才表现出各种诡异问题。所以我建议在项目里专门做一个文件读写自检流程哪怕是写测试的时候顺手加两步写完文件之后用fopen再读一遍比对关键字段。用fseek把文件偏移打到头和尾部确认文件长度和你预想一致。对文件做一次“截断尾巴再读”的异常测试确保读取代码能正确处理不完整记录。做到这三点能帮你在一开始就抓出七八成潜在的文件读写问题。文件编程很多需求量不大但一踩就是大坑尽早用这些基础手段给自己做一层保护比出了问题再补强得多。
阅读完成 · 觉得有帮助?