简介:面向C语言初学者与刚接触持久化存储的编程学习者,这份文件读写专题文档围绕 fopen、fclose、fprintf、fgets 等标准库函数展开,帮助读者理解从打开文件、写入内容到逐行读取并关闭文件的完整流程。资源包共1个doc文件,约14KB,内容以可直接对照的示例代码和分步说明为主,涵盖写模式与读模式的差异、文件指针使用、打开失败返回 NULL 的判断,以及缓冲区逐行读取等常见入门要点。已有104人学习,适合课堂实验、课后练习或自学时快速查阅。读者可从中获得一套结构清晰的文件读写基础知识与代码模板,用于理解文本文件的基本操作,并为后续学习二进制文件读写、文件定位等内容打基础。
1. 一个反直觉的结论:fprintf 返回了正数,文件里却可能什么都没有
刚接触 C语言文件读写 的人几乎都会撞上同一个场景:fprintf明明返回了正数,printf也打印了「写入完成」,可另一个终端cat那个文件,空空如也;或者程序在fclose之前因为一个空指针崩了,文件里一个字都没留下。很多人第一反应是「我路径写错了」「权限不对」,反复排查半天,最后发现代码逻辑完全正确——问题出在FILE *这个指针压根就不是文件本身,而是一层带缓冲的代理。
这一层代理是 C 标准库 stdio 的全部精髓。搞懂它,你才能解释为什么fclose不能省、为什么fflush有时救命有时没用、为什么while(!feof(fp))会多读一行。下面从FILE结构体开始,一层层把这块黑匣子拆开。
2. FILE 不是文件:一个指针背后藏着的三层结构
2.1 fopen 返回的到底是什么
FILE在 C 标准里是个不透明类型,标准只规定「它是一个能记录文件状态的对象」,具体长什么样交给实现。glibc 里它叫struct _IO_FILE,typedef struct _IO_FILE FILE;。把里面和读写行为强相关的字段抽出来,大致是这么一副骨架:
/* 简化后的 FILE 结构,字段名取自 glibc 的 struct _IO_FILE */ struct _IO_FILE { int _flags; /* 状态位:读/写/追加/EOF/错误 都在这里 */ int _fileno; /* 底层文件描述符,open(2) 的返回值 */ char *_IO_read_ptr; /* 读缓冲区:下一次读从这里开始 */ char *_IO_read_end; /* 读缓冲区:有效数据到此为止 */ char *_IO_read_base; /* 读缓冲区:起始地址 */ char *_IO_write_ptr; /* 写缓冲区:下一次写从这里开始 */ char *_IO_write_end; /* 写缓冲区:容量边界 */ char *_IO_write_base; /* 写缓冲区:起始地址 */ char *_IO_buf_base; /* 缓冲区整块的起点 */ char *_IO_buf_end; /* 缓冲区整块的终点 */ int _fileno_mark; /* 占位,实际还有 _markers 链表用于 ungetc */ struct _IO_FILE *_chain; /* 把所有打开的流串成链表,exit 时统一 flush */ int _lock; /* 多线程下的递归锁 */ };这几个字段一看就明白了:_fileno说明 stdio 并不是自己跟磁盘打交道,它最终还是落到read(2)/write(2)这两个系统调用上;_IO_read_base到_IO_read_end这一组指针,说明读操作是「先把一大块搬进内存,再从内存里一点点发给你」;_chain这个链表指针解释了另一件事——exit()之所以能替你 flush 所有流,是因为库把所有FILE挂在了一条全局链上,退出时挨个走一遍。
关键参数在于_IO_buf_base/_IO_buf_end划出的那块内存。glibc 默认给它BUFSIZ字节,通常是 8192。也就是说你在代码里写fprintf(fp, "Hello, World!\n")那 14 个字节,实际上只是被memcpy进了这块 8KB 的内存里,_IO_write_ptr往后挪了 14,磁盘上一个字节都没动。这就是第 1 章那个现象的完整解释。
常见的实现思路是:只有当_IO_write_ptr撞到_IO_write_end,或者你显式fflush/fclose/ 程序正常exit,才会触发一次真正的write(2)。缓冲区越小,系统调用越频繁,性能越差;缓冲区越大,进程崩溃时丢的数据越多。这是一个纯粹的取舍,没有对错。
2.2 一次写操作要经过几次拷贝
把fprintf的完整路径摊开,一次「写文件」其实穿了四层:
你的字符串 → [1] memcpy 进 FILE 的 _IO_write_base 缓冲区 (用户态,无系统调用) → [2] write(2) 把缓冲区整块交给内核 (用户态 → 内核态,一次上下文切换) → [3] 内核写进页缓存 page cache (仍在内存里) → [4] 内核回写线程/fsync 把脏页刷到磁盘块设备 (真正的持久化)第 [2] 步的触发时机由缓冲模式决定,setvbuf就是改这个的:
#include <stdio.h> int main(void) { FILE *fp = fopen("log.txt", "w"); if (fp == NULL) return 1; /* 三种模式:_IOFBF 全缓冲 / _IOLBF 行缓冲 / _IONBF 无缓冲 */ static char mybuf[4096]; /* 传 NULL 让库自己 malloc;传 mybuf 用调用方的内存,必须保证生命周期 */ setvbuf(fp, mybuf, _IOFBF, sizeof(mybuf)); fprintf(fp, "no newline, still buffered"); /* 全缓冲下不会立即落盘 */ fflush(fp); /* 手动把 _IO_write_base..ptr 刷出去 */ fclose(fp); /* 内部先 fflush,再 close(_fileno),最后释放 FILE */ return 0; }setvbuf三个参数的含义:第一个是流,第二个是缓冲区地址(NULL表示让库自己分配),第三个是模式,第四个是字节数。_IOFBF全缓冲,缓冲区满才刷;_IOLBF行缓冲,遇到\n就刷;_IONBF完全不缓冲,每次写都直接进write(2)。
这里有个容易被忽略的默认值:stdout连到终端时是行缓冲,重定向到文件时变成全缓冲;stderr永远是_IONBF。所以你在.c里printf的调试信息重定向到日志文件后,程序崩了却一条都没看到——不是没执行,是死在缓冲区里了。用stderr打日志、或者调试时加fflush(stdout),是血泪经验攒出来的习惯。
第 [3] 步到第 [4] 步之间还有一层缓冲:内核的页缓存。这意味着即使write(2)返回成功,数据也可能只在内存页里,断电一样丢。要强制落盘得用fsync(fileno(fp))。fclose只保证数据交给了内核,不保证写到了盘片。这个区别在做数据持久化时是致命的,很多人以为fclose返回 0 就万事大吉,其实只到了中途。
2.3 读缓冲区的指针舞蹈
读侧的逻辑比写侧绕一点,因为要处理「用户只要 10 个字节,但我一次读了 8192」的落差。fgets(buffer, sizeof(buffer), fp)的实际动作是:
- 看
_IO_read_ptr是否已经等于_IO_read_end,相等说明内存里没货了,触发一次read(2)把最多 8192 字节搬进_IO_read_base,然后把_IO_read_end设到实际读到的位置,_IO_read_ptr归位到_IO_read_base。 - 从
_IO_read_ptr开始逐字节扫,遇到\n停,或者扫够n-1个字节停。 - 把扫过的内容
memcpy到你的buffer,末尾补'\0',然后_IO_read_ptr前进到停下的位置。
所以你在一个循环里连续调fgets,绝大多数调用根本不会碰磁盘,只是在这块 8KB 内存里挪指针。这也是为什么按行读一个大文件并不像想象中那么慢——系统调用次数是「文件大小 / 8192」,不是「行数」。
_flags里的_IO_EOF_SEEN和_IO_ERR_SEEN两个位就是feof()和ferror()的返回值来源。这一点非常关键,第 4 章会专门讲它为什么会导致一个经典错误。
3. 打开模式的语义边界:为什么 "w" 是破坏性的
3.1 模式字符串到 open(2) 标志位的映射
fopen的第二个参数看起来只有几个字母,但它决定了底层open(2)的flags,进而决定了内核对这个文件描述符的全部态度。把常见组合摊成一张表:
| 模式 | 底层 flags | 文件不存在 | 文件已存在 | 初始读写位置 |
|---|---|---|---|---|
"r" | O_RDONLY | 打开失败 | 正常打开 | 文件开头 |
"w" | O_WRONLY|O_CREAT|O_TRUNC | 创建 | 清空为 0 字节 | 文件开头 |
"a" | O_WRONLY|O_CREAT|O_APPEND | 创建 | 保留内容 | 文件末尾 |
"r+" | O_RDWR | 打开失败 | 正常打开 | 文件开头 |
"w+" | O_RDWR|O_CREAT|O_TRUNC | 创建 | 清空为 0 字节 | 文件开头 |
"a+" | O_RDWR|O_CREAT|O_APPEND | 创建 | 保留内容 | 读在开头,写在末尾 |
表里加粗的两格是翻车高发区。"w"和"w+"带O_TRUNC,内核在open成功的瞬间就把文件长度截成 0,跟你后面写不写、写多少毫无关系。所以「先fopen检查一下文件在不在」这种写法是自毁式的——检查动作本身就把文件清空了。
"a"的O_APPEND是内核级保证:每次write(2)之前,内核自动把文件偏移量挪到当前末尾,再写。这意味着多个进程同时往一个文件追加日志,不会互相覆盖(但单次写入超过PIPE_BUF时仍可能交错)。这一点和「先fseek到末尾再写」有本质区别,后者在并发下必然出问题。
权限位是另一个参数细节:fopen创建文件时用的是0666,再和进程的umask相与。如果你的umask是022,实际得到0644;如果是077,得到0600。所以同一份代码在不同机器上生成的文件权限不一样,不是玄学,是umask在起作用。要精确控制得用open(2)传第三个参数,fopen没给你这个口子。
3.2 读写切换的那条隐形红线
"r+"、"w+"、"a+"这类「更新模式」的流,C 标准给了一条硬性约束,很多人不知道:
在一个更新流上,如果没有调用过
fflush或文件定位函数(fseek/fsetpos/rewind),那么「写之后读」和「读之后写」之间的直接切换是未定义行为。
为什么这么规定?回到FILE结构体就懂了:读缓冲和写缓冲共用同一块_IO_buf_base内存,靠_flags里的位标记当前是读态还是写态。你fprintf写了一半,数据还在缓冲区里没刷,这时候直接fgets,库要么把半截写缓冲当成读缓存解释,要么把写缓冲丢掉——两种结果都是灾难。标准要求你先fflush(把写缓冲刷干净)或fseek(fp, 0, SEEK_CUR)(一个「空定位」,强制同步读写状态),就是逼你把状态机切干净。
#include <stdio.h> int main(void) { FILE *fp = fopen("data.txt", "w+"); /* 可读可写,打开即清空 */ if (fp == NULL) return 1; fprintf(fp, "12345"); /* 关键一步:不 fflush 直接读,属于未定义行为 */ fflush(fp); /* 或 fseek(fp, 0, SEEK_SET); 效果等同 */ rewind(fp); /* 定位回开头,同时清掉 EOF/ERR 标志 */ char buf[16]; if (fgets(buf, sizeof(buf), fp) != NULL) { printf("读回: %s\n", buf); /* 输出 12345 */ } fclose(fp); return 0; }fflush在这里的作用不只是「刷盘」,更是「把流的内部状态从写态切回可读态」。而rewind除了定位到开头,还会顺手清掉_IO_EOF_SEEN和_IO_ERR_SEEN两个标志位——这一点fseek不做(fseek只清 EOF,不清错误标志)。在需要重读同一个文件的场景里,rewind往往比fseek(fp, 0, SEEK_SET)更省心。
顺带说一句"a"模式的坑:即使你fseek到文件开头再fprintf,写入照样落在末尾,因为O_APPEND是内核强制的,fseek改的只是库里的偏移量记录。很多人以为fseek能覆盖,试了半天发现文件越来越长,这就是原因。
4. 读取循环的真相:fgets 的边界与 EOF 的真实含义
4.1 fgets 的三个终止条件与截断行为
fgets(buffer, n, fp)的返回值语义很干净:成功返回buffer的地址,失败返回NULL。但「成功」的判定条件有三个,任何一个满足就返回:
- 读到了
\n,把它一起放进buffer,后面补'\0'; - 已经读了
n-1个字符,停止,补'\0',剩下的内容留在流里等下次读; - 遇到了文件末尾或读错误,如果此时一个字符都没读到,返回
NULL;如果读到了若干字符,返回buffer。
第二条是最容易被忽略的。n是缓冲区总容量,函数最多写n-1个有效字符,最后一个字节永远留给'\0'。如果你的行有 1000 个字符,而buffer只有 256,那么第一次调用返回前 255 个字符(没有换行符),第二次调用接着返回后面的,第三次才读到换行。看起来「一行」被拆成了三段。
这就引出一个判断陷阱:不能靠「返回的字符串末尾有没有\n」来判断是否读完整行。正确做法是比较strlen(buffer)和n-1,如果相等且末尾不是\n,说明发生了截断,需要继续读。反过来说,如果你的缓冲区总是够大,这个判断可以省;但只要输入来自外部(用户输入、网络、别人的文件),就永远不能假设行长度有上界。
fgets保留换行符这件事也常被抱怨。它是有意为之:保留换行,你才能区分「读到换行停了」和「缓冲区满了停了」。想要去掉,就在拿到之后做一次buffer[strcspn(buffer, "\n")] = '\0';,strcspn返回第一个\n的下标,没有则返回字符串长度,这个写法比strlen再减一要安全。
4.2 while(!feof(fp)) 为什么必然多读一次
这是 C 语言文件读写里最经典的一个错误写法:
/* 错误示范:会在文件末尾多处理一次残留数据 */ while (!feof(fp)) { fgets(buffer, sizeof(buffer), fp); printf("%s", buffer); /* 最后一次 buffer 里是上次的旧内容 */ }问题出在feof的语义上。feof不是「预判下一次读会不会到末尾」,而是「上一次读有没有撞到末尾」。它读的是_flags里的_IO_EOF_SEEN位,而这个位只有在一次实际的read(2)返回 0 之后才会被置上。
推演一遍:文件有 3 行,最后一次fgets成功读到第 3 行,此时还没试过读第 4 次,_IO_EOF_SEEN依然是 0,feof返回假,循环继续;第 4 次fgets去读,发现没数据了,返回NULL,同时置上_IO_EOF_SEEN;但你的循环体里没有检查返回值,直接printf(buffer),打印的是第 3 次的残留内容——于是最后一行被打印了两遍。
正确写法是把「读」本身放进循环条件里,让返回值当判据:
#include <stdio.h> int main(void) { FILE *fp = fopen("example.txt", "r"); if (fp == NULL) { perror("fopen"); /* perror 会把 errno 翻译成人话,比 printf 有用得多 */ return 1; } char buffer[256]; /* 赋值表达式的值就是 fgets 的返回值,NULL 时循环自然结束 */ while (fgets(buffer, sizeof(buffer), fp) != NULL) { fputs(buffer, stdout); /* 不追加换行,原样输出 */ } /* 循环结束后再判断:是正常读完,还是出错了 */ if (ferror(fp)) { perror("read error"); } fclose(fp); return 0; }这段代码的每一处都有讲究。fgets放条件里,一次调用同时完成「读」和「判」两件事,不存在残留数据被二次使用;循环结束之后用ferror(fp)区分「读完了」和「读挂了」,因为两种情况fgets都返回NULL;perror依赖全局的errno,而fopen/fgets失败时都会设置它,比打印"无法打开文件"这种没信息量的字符串强太多。
4.3 fgetc、fread 与 fgets 的适用边界
fgets只适合文本行。如果文件里是二进制数据,中间可能夹着0x00,fgets会因为内部的strlen类逻辑提前截断。这时候要用fread:
#include <stdio.h> int main(void) { FILE *fp = fopen("blob.bin", "rb"); if (fp == NULL) return 1; unsigned char chunk[4096]; size_t n; /* fread 返回实际读到的元素个数,短读(n < 4096)通常意味着到末尾 */ while ((n = fread(chunk, 1, sizeof(chunk), fp)) > 0) { /* 处理这 n 个字节,注意不能用 strlen 判断长度 */ } if (ferror(fp)) perror("fread"); fclose(fp); return 0; }fread的参数含义:第二个是单个元素大小,第三个是元素个数,两者相乘才是期望读到的字节数。返回值是「成功读到的元素个数」,不是字节数——当你把元素大小设成 1 时两者才相等。这个设计是为了让「读 N 个结构体」这类操作能直接判断读到了几个完整结构体。
fgetc一次读一个字节,看起来最慢,但因为它在缓冲区里只挪指针,实际性能并不比fgets差多少,做词法分析这类逐字符扫描时反而最直观。真正慢的是fscanf("%c"),因为格式串解析本身有开销。这个差别在小文件上看不出来,处理几十 MB 的日志时能差出十几倍。
5. 排查手册:文件读写里的五个高频翻车现场
5.1 写入侧的三个坑
现象一:程序跑完,文件是空的或者只有一半。
原因九成是没走到fclose。数据躺在FILE的 8KB 缓冲区里,进程被Ctrl+C杀掉、或者自己abort()、或者中间return提前退出了,缓冲区没有机会刷出去。exit()会遍历_chain链表 flush 所有流,但_exit()和信号终止不会。
解决:所有打开的文件都用「打开—使用—关闭」的单出口结构,或者用goto cleanup统一收口;关键数据在写完一段之后主动fflush(fp),再配合fsync(fileno(fp))确保落盘。别指望操作系统替你擦屁股,崩溃面前没有后悔药。
现象二:日志文件内容错乱,每次运行都只剩最后一次的内容。
原因:用了"w"模式打开一个本该追加的日志文件。O_TRUNC在open那一刻就把历史清空了,你写多少都只是覆盖后的新内容。
解决:日志一律用"a"(或"a+")。如果确实需要「首次创建、之后追加」,就用"a"配合启动时判断文件是否存在来决定是否写表头,而不是用"w"赌一把。
现象三:多进程同时写同一个文件,行与行粘在一起。
原因:用了"w"或"r+"再加fseek定位到末尾,两个进程各自维护自己的偏移量,互相覆盖。或者虽然用了"a",但一次fprintf的内容超过 4096 字节被拆成多次write(2),中间被另一个进程插入。
解决:统一用"a"模式,让内核的O_APPEND保证原子性;并且把单次写入控制在PIPE_BUF以内,或者干脆每个进程写自己的文件。跨进程写同一个文件这件事,缓冲层帮不了你,只能靠内核语义。
5.2 读取侧的两个坑
现象四:读出来的最后一行总是重复两遍,或者多出一个空行。
原因:while(!feof(fp))的经典误用,第 4.2 节已经完整推演过。另一种变体是把fgets的返回值忽略了,文件打不开时fp是NULL,fgets(NULL, ...)直接段错误,但有的平台不崩,只是把栈上的垃圾当数据读出来。
解决:循环条件必须是读函数本身,循环结束后用ferror收尾。fopen之后立刻判NULL,这一条没有例外。
现象五:中文内容读出来是乱码,或者只读到了第一行就停了。
原因有两种。一是源文件、写入端、读取端三处的编码不一致,fprintf只是把字节原样搬过去,它不做任何转码。二是文件里混进了\r\n(Windows 换行),在文本模式下,某些平台的库会把\r\n转成\n,但如果你用fread或者二进制模式读,\r会原样留在buffer里,导致后续的字符串比较、哈希、strcmp全部对不上。
解决:跨平台交换的文本统一用"rb"/"wb"打开,自己处理换行;不要在文本模式下假设换行只有一个字节。编码问题用file命令或十六进制查看器确认真实字节,别盯着终端猜。
提示:
perror和strerror(errno)是排查fopen失败的第一手信息。把errno打出来,ENOENT(文件不存在)、EACCES(权限不足)、EISDIR(拿目录当文件开)一目了然,比任何自定义错误码都好使。
6. 设计取舍:流抽象到底值不值
把 stdio 这套设计拆完,能看清它做的三个取舍。
第一,把文件抽象成「流」,而不是「随机访问的字节数组」。流的核心假设是「顺序读写是常态」,所以库可以预读一大块、缓存一大块,用小指针挪动代替系统调用。代价是你失去了对真实文件偏移量的精确感知——ftell返回的是库的逻辑位置,lseek才是内核位置,两者在缓冲区有数据时会不一致。用freopen切流、用fseek跨越大段、用"a"模式时,这种不一致就会咬人。要精确控制,就得放弃FILE,直接用open/read/write。
第二,用缓冲换吞吐,代价是可见性延迟。8KB 缓冲区让「写 1000 行日志」从 1000 次系统调用降到个位数,这是巨大的胜利。但由此衍生出的fflush、fclose、setvbuf、exit与_exit的区别、崩溃丢数据,全都是这一层缓冲的税。常见的实现思路是:交互式输出用行缓冲或无缓冲,批量写入用全缓冲加显式fflush,需要持久化保证的场景加fsync。三档各有各的适用面,没有万能配置。
第三,文本模式与二进制模式的分离,是历史包袱也是必要的抽象。文本模式下库可以替你做换行转换、字符集转换(虽然大多数现代实现只做换行),让同一份源码在 CRLF 和 LF 的世界里都能跑。代价是字节层面的不可预测——同样是 5 个字符的文件,在两个平台上fseek(fp, 0, SEEK_END)得到的值可能不同。跨平台数据交换必须用二进制模式并自己定义格式,这条规矩至今没变。
我自己的习惯是:凡是打开文件,先问三个问题——这个文件的生命周期谁负责?数据丢了能不能重来?跨不跨平台?三个答案决定用不用FILE、用哪个模式、要不要fflush加fsync。刚开始写代码时我总嫌这套检查啰嗦,直到有一次线上任务在fclose之前被信号打断,几万条记录凭空蒸发,才发现fprintf返回成功这几个字有多不可信。从那以后我每次写文件都会强制走一遍「打开判 NULL、写入后 fflush、关闭查返回值、关键路径 fsync」四步,哪怕是最简单的 demo 也不省。希望帮到你。
本文还有配套的精品资源,点击获取