简介:面向C语言初学者的文件读写学习文档,围绕文件打开、写入、读取与关闭这一完整流程展开,适合刚接触<stdio.h>、希望理解fopen、fprintf、fgets、fclose等标准库函数使用方式的学习者,也可作为课堂示例或课下练习参考。压缩包内共1个doc文件,大小约14KB,内容轻量,便于快速通读和边看边练。已有104人学习下载。文档以可直接运行的示例代码演示以“w”模式写入文本、以“r”模式逐行读取,并包含文件指针定义、打开失败返回NULL的判断、缓冲区读取以及关闭文件释放资源等关键环节,能够帮助读者建立“打开—操作—关闭”的文件处理思路。对需要夯实C语言文件读写基础、理解常见函数参数与错误处理写法的初学者较有参考价值。
1. 写入成功却读不到内容:C 语言文件读写的第一层错觉
学 C 语言文件读写的人,几乎都撞过同一堵墙:程序里fprintf一路畅通,“写入完成”也打印出来了,可打开文件一看,最后几行不见了,甚至整个文件空着。同样的代码换成读操作,fgets又莫名其妙少读最后一行。看上去像玄学,其实是同一件事的两面——你手里的FILE *从来不是磁盘上的那个文件,而是一块内存缓冲区的句柄。写进去的字节先落在缓冲里,什么时候真正落盘由缓冲策略决定;读出来的字节也是成批预取的,停在哪个位置由游标决定。搞清这块缓冲区怎么分配、什么时候刷新、游标怎么移动,那些“丢数据”“少一行”的现象会自己解释清楚。
2. FILE 结构体的真相:文件指针指向的是内存,不是磁盘
2.1 为什么标准库不肯让你看见 FILE 的内部
C 标准只规定FILE是一个能标识文件的对象,具体长什么样完全交给实现决定。glibc 里它是struct _IO_FILE,几十个字段堆在一起;到了 musl 又被精简到十来个字段。这种“只给指针、不给定义”的手法叫不透明指针(opaque pointer),动机有三个:一是 ABI 稳定,字段增删不影响已经编译好的二进制;二是各平台实现差异极大,把结构体写死在头文件里等于放弃可移植性;三是防止有人绕过fgets直接改读写指针,把用户态缓冲和内核里的文件偏移改成互相打架的状态。
常见的实现思路是,标准库把FILE对象放在堆上,fopen里分配、fclose里释放。所以你手里的指针一次都没指向过磁盘,它指向的是一块内存里的控制块,控制块里再存一个整数_fileno,那才是内核认得的文件描述符。
注意:
FILE *、int fd、磁盘上的 inode,是三样东西。混淆它们,是后面几乎所有困惑的源头。
2.2 缓冲区、读写游标与状态标志
glibc 的_IO_FILE精简后,真正决定行为的只有几组字段。下面是一个示意版结构,字段名做了简化,机制与真实实现一致:
/* 结构示意,非真实头文件定义 */ struct _IO_FILE { int _fileno; /* 系统调用层的文件描述符 */ char *_buf_base; /* 用户态缓冲区起始 */ char *_buf_end; /* 用户态缓冲区结束 */ char *_read_ptr; /* 下一个要交给调用者的字节 */ char *_read_end; /* 缓冲区里已从内核取回的最后一个字节之后 */ char *_write_base; /* 待刷出数据的第一字节 */ char *_write_ptr; /* 待刷出数据的下一空位 */ int _flags; /* 可读/可写/EOF/出错/是否行缓冲 */ int _blksize; /* 底层块大小,决定默认缓冲大小 */ };读的时候,_read_ptr到_read_end之间是“已经预取、还没交给你”的字节;_read_ptr一旦追上_read_end,下一次fgets才会触发系统调用。写的时候正好反过来,_write_base到_write_ptr是攒着还没发出去的数据,_write_ptr追到_buf_end就强制刷一次。同一块内存被读、写两个游标复用,这也是fopen要求你在读写之间必须插入fflush、fseek、fsetpos或rewind之一的原因——不切换状态,两个游标会互相污染。
_flags里还挂着_IO_EOF_SEEN和_IO_ERR_SEEN两个标记,它们的区别正是后面feof一直误判的关键:EOF 标记只在一次读操作“撞到文件末尾之后”才被置上,而不是在读到最后一个字节时置上。
2.3 从 fopen 到 open:一次调用穿过两层世界
fopen做的事比看上去多。大致可以拆成四步:解析模式字符串成O_标志、发起open系统调用拿到 fd、在堆上分配FILE、挂上缓冲区。伪代码如下:
FILE *fopen(const char *path, const char *mode) { int flags = parse_mode(mode); /* "w" → O_WRONLY|O_CREAT|O_TRUNC */ int fd = open(path, flags, 0666); /* 从用户态进入内核,真正碰文件 */ if (fd < 0) return NULL; /* 失败原因留在 errno,不在返回值里 */ FILE *f = malloc(sizeof(FILE)); /* 堆上分配控制块 */ if (!f) { close(fd); return NULL; } f->_fileno = fd; f->_flags = readable(flags) ? _IO_RD : 0; f->_flags |= writable(flags) ? _IO_WR : 0; /* 缓冲区一般延迟到第一次读写时才分配: 用 fstat 取 st_blksize,取不到就用 BUFSIZ */ return f; }这段逻辑解释了两件事。第一,fopen返回NULL只表示“内核不让你打开”,具体是路径不存在、权限不够还是路径指向了一个目录,得看errno,所以紧跟一句perror("fopen")比打印“无法打开文件”有用得多。第二,创建模式传的0666只是“期望权限”,最终会被进程 umask 削减,想要固定的权限位就得在open之后补chmod。
顺带说一句,fopen成功的瞬间,磁盘上可能什么都没变——Open 阶段只建立了 fd 和控制块,缓冲区往往还空着。这就是“打开成功不等于写入成功”的机制来源。
3. 打开模式:一个字母如何决定文件的命运
3.1 “w” 的破坏力:O_TRUNC 在 open 那一刻就执行
"w"是最常用的模式,也是最容易翻车的模式。它对应的标志是O_WRONLY | O_CREAT | O_TRUNC,其中O_TRUNC由内核在open内部原子执行:文件长度瞬间被截成 0。这跟“后面有没有真的写入”完全无关,只跟有没有成功fopen有关。
所以这种写法没有后悔药:
FILE *fp = fopen("demo.txt", "w"); /* 此刻 demo.txt 已被清空 */ if (fp == NULL) { /* 打开失败,文件反而安全 */ } /* ...程序在这里因为别的原因 return 1; 文件已经没了 */想保留原内容做覆盖式修改,正确做法是"r+",它不创建、不截断,从文件头开始写,写多少覆盖多少,剩下的旧内容还在。如果新内容比旧内容短,尾部会残留旧字节——这不是 bug,是r+的定义。真要截断,在打开之后调用ftruncate(fileno(fp), 新长度)显式做。
"a"的行为常被误解。它对应O_APPEND,追加动作由内核在每次write时保证“先定位到文件末尾再写”,所以多进程同时追加也不会互相覆盖。但这只对写有效;"a+"模式下的初始读位置在文件头,读之前不rewind一下,你会以为自己读的是空文件。
3.2 文本模式与二进制模式:两个字节的悄悄改写
"r"和"rb"在 POSIX 系统上没有区别,换行符就是\n一个字节。但在 Windows 的 C 运行库里,文本模式会做一层翻译:读的时候把\r\n折叠成\n,写的时候把每个\n展开成\r\n。这层翻译是流式的,发生在缓冲区填充和刷出的环节,而不是在文件里真改了字节。
麻烦出在两个地方。第一,ftell返回的不是真实字节偏移。写入了k个\n,文件里实际是k个额外的\r,ftell在文本模式下给的是“翻译后”的计数。第二,fseek在文本模式下只保证支持ftell的返回值和 0 这几个位置,其它偏移是未定义行为——你按字节算出来的位置,落到文件里可能正好卡在\r和\n之间。
血泪经验是:只要文件里存的不是纯文本,或者你需要按字节精确定位,就用"rb"/"wb"。图片、结构体数组、序列化后的数据,一律二进制模式,别指望文本模式“反正也读得出来”。
3.3 模式组合映射表
把六种基本模式的语义摊开对比,比死记要牢:
| 模式 | 等价的 O_ 标志 | 文件不存在 | 文件已存在 | 初始位置 |
|---|---|---|---|---|
"r" | O_RDONLY | 返回 NULL | 正常打开 | 文件头 |
"w" | O_WRONLY|O_CREAT|O_TRUNC | 创建 | 截断为 0 | 文件头 |
"a" | O_WRONLY|O_CREAT|O_APPEND | 创建 | 保留内容 | 文件尾 |
"r+" | O_RDWR | 返回 NULL | 正常打开 | 文件头 |
"w+" | O_RDWR|O_CREAT|O_TRUNC | 创建 | 截断为 0 | 文件头 |
"a+" | O_RDWR|O_CREAT|O_APPEND | 创建 | 保留内容 | 读在头部,写在尾部 |
表里最值得盯的是"r+"与"w+"那一列:两者都是可读可写,差别只在“文件已存在时要不要清空”。很多人以为自己需要w+,其实要的是r+,结果每次启动程序都把配置清空一次,排查半天才发现错在一个字母。
4. 缓冲区与刷新:写入和读取共用同一条链路
4.1 fprintf 的三段式:格式化、拷贝、必要时落盘
fprintf不是“写文件”,它是“格式化后塞进缓冲区”。内部大致分三段:先把可变参数按格式串渲染到一块临时区,再把渲染结果拷进FILE的写缓冲区并把_write_ptr前移,最后判断缓冲区是否已满、流是否行缓冲、有没有遇到换行,满足条件才发起write系统调用把整块数据交给内核。
#include <stdio.h> #include <errno.h> #include <string.h> int main(void) { FILE *fp = fopen("demo.txt", "w"); if (fp == NULL) { /* 失败原因在 errno 里 */ fprintf(stderr, "open: %s\n", strerror(errno)); return 1; } /* 返回值是实际写入的字符数,负值代表失败 */ int n = fprintf(fp, "line %d: %s\n", 1, "hello"); if (n < 0) { perror("fprintf"); fclose(fp); /* 出错也要关,别泄漏 fd */ return 1; } /* 此刻数据大概率还躺在用户态缓冲区,没进内核 */ if (fflush(fp) != 0) /* 强制把整块缓冲推给内核 */ perror("fflush"); fclose(fp); /* 内部再 flush 一次,然后 close(fd) */ return 0; }参数说明:fprintf的第一个参数是流指针,后面的%d、%s与printf完全同源,区别只在于输出目的地。返回值必须看,写满磁盘(ENOSPC)这类错误只会在返回值或errno上体现,程序不会崩,数据也不会到。中间那次fflush是为了把“数据已交给内核”这件事变成确定的,而不是等fclose顺带完成——两者效果一样,但显式刷一次能让你在出问题时更早定位到是哪一段出的错。
4.2 全缓冲、行缓冲、无缓冲:判据是设备类型
标准只给了原则,具体阈值由实现定。常见的实现思路是:stderr默认无缓冲,保证错误信息立刻可见;stdout接终端时用行缓冲,接管道或文件时切成全缓冲;其它流一律全缓冲。glibc 在分配缓冲区时会fstat一次拿st_blksize,终端通常是 1024 字节,普通文件常见 4096 字节,BUFSIZ那个宏(glibc 里是 8192)只是“建议值”,不代表实际分配大小。
这三档的差别直接决定了“什么时候数据会丢”。行缓冲意味着每写一个\n就刷一次,所以终端上你能看到逐行输出;全缓冲意味着要攒满一块才发,程序中途被杀,最后不满一块的内容就留在内存里消失了。
要自己定,用setvbuf,必须在任何 IO 之前调用:
setvbuf(fp, NULL, _IOFBF, 1 << 16); /* 全缓冲,64KB,缓冲区由库自己 malloc */ setvbuf(fp, NULL, _IOLBF, 0); /* 行缓冲,遇 \n 刷出 */ setvbuf(fp, NULL, _IONBF, 0); /* 无缓冲,每次写都直接落到 write */第三个参数是模式,第四个是缓冲区大小,第二个参数传NULL表示“缓冲区你来分配”,传自己的数组则要保证它在fclose之前一直有效。写日志这种“宁可慢一点也不能丢”的场景,_IOLBF或者干脆_IONBF更稳;批量导数据反过来,大缓冲能少掉几个数量级的系统调用。
4.3 fgets 的读取路径与 EOF 的真面目
fgets(buf, n, fp)的语义是:从流里取字符,直到遇到\n、取满n-1个、或者遇到文件结束。取到的内容存进buf,末尾补\0。注意它是“最多 n-1 个”,不是“恰好一行”——超长行会被切成几段,每段都当成一次成功返回。
当用户态缓冲区被消费空,底层会触发一次read(fd, _buf_base, _blksize),把一大块数据预取进来。这就是为什么读大文件时fgets很快:真正的系统调用次数是“文件大小除以块大小”,而不是“行数”。
char line[256]; while (fgets(line, sizeof(line), fp) != NULL) { size_t len = strlen(line); /* 换行符被原样保留,很多字符串比较的 bug 由此而来 */ if (len > 0 && line[len - 1] == '\n') line[--len] = '\0'; printf("[%s]\n", line); } /* 循环退出后要区分:正常读完,还是读出错 */ if (ferror(fp)) perror("read");参数sizeof(line)包含了结尾的\0,所以单次最多装 255 个字符;line传数组名即退化为指针,等价于&line[0]。循环结束后那三行判断非常关键:fgets返回NULL有两种可能,文件正常结束或读出错,ferror才是分辨它们的依据,不看它就会把磁盘故障当成“读完了”静默处理。
4.4 关闭与刷新:fclose 到底做了什么
fclose的动作顺序是固定的:先把写缓冲区里的残留刷出去(内部就是一次fflush),再close(fd),最后free掉FILE对象。任何一步失败都会返回EOF,但 fd 该关还是关了,重复fclose是未定义行为。
exit()会在进程退出前遍历所有打开的流逐个刷新,所以正常return或exit不会丢数据。_exit()和abort()直接走内核退出路径,用户态缓冲区里的东西一律丢弃。这就是“程序加了一行调试输出就正常了”的机制解释:多出来的那次调用恰好触发了刷新,把原本会丢的尾巴救了回来。别信这种偶然,该fclose就fclose。
5. 避坑排查:五条踩出来的血泪经验
5.1 现象:文件末尾少几行,重跑一次有时又正常
原因几乎总是刷新时机不确定。数据攒在用户态缓冲区里,程序走上某条提前return的分支,或者被信号杀掉,缓冲区就跟着进程一起消失了。还有一种变体:子进程fork之后直接_exit,父进程缓冲区里的内容被子进程复制了一份,两边各刷一次,尾部出现重复行。
解决办法是把“关流”当成资源释放的固定动作,所有出口路径统一走fclose;子进程在fork之后立刻fflush(NULL)或者干脆只调_exit,不要碰父进程留下的流。写关键日志时开_IOLBF,用确定性换性能。
5.2 现象:fgets 循环读出来比预期少一行,最后一行凭空消失
原因是拿feof当循环条件。feof的语义是“上一次读操作是否撞到了文件末尾”,它总比真正的结束晚一步:读到最后一个字节时它还是假,要再读一次失败才置位。写成while (!feof(fp)) { fgets(...); ... },最后一轮fgets返回NULL,buffer里还是上一轮的旧内容,于是要么多处理一遍脏数据,要么被后面的判空逻辑吞掉。
解决是永远以fgets的返回值为准,feof只在循环之后用来解释“为什么结束”。同理,ferror也要在循环后检查一次,把“正常读完”和“读挂了”分开。
5.3 现象:读到的第一行是空的,或者读出来的内容和写进去的错位
常见于“先scanf再fgets”的组合。scanf("%d")只消费数字,行尾的\n留在输入缓冲区里,紧接着的fgets立刻撞上这个换行,返回一个只含\n的字符串。改用fgets读整行再sscanf解析能一次绕开。
另一种错位来自读写混用没切换状态。同一流上先写后读,中间必须插fflush(fp);先读后写,中间必须插fseek(fp, 0, SEEK_CUR)之类的定位操作。省掉这一步,两个游标指向的位置对不上,读出来的自然不是刚写进去的。
5.4 现象:长行被截成两半,或者缓冲区边界处出现乱码
fgets的第二个参数是缓冲区总长度(含\0),不是“可读字符数”。传sizeof(buf)是安全的,传一个手算的数字很容易少算结尾那一个字节,把\0写到缓冲区外面去。
更隐蔽的是超长行。一行 1000 个字符,缓冲区 256,fgets会分四次返回,每次都是一段“看起来像完整行”的内容。要正确处理,得判断这次的字符串末尾有没有\n:没有就说明还没读完,继续拼接到一个动态增长的缓冲区里,直到看见\n或fgets返回NULL。这一步不做,后面按行解析的逻辑全是错的。
5.5 现象:二进制文件读到一半数据错位,或者图片打开是花的
九成是模式字符串漏了b。在 Windows 上,文本模式会在读的时候把\r\n折叠成\n,写的时候反过来展开,二进制数据里恰好出现0x0D 0x0A的地方就被悄悄改掉了一个字节,长度跟着变,后面的结构全乱。ftell在这种模式下给出的偏移也不是真实字节位置。
解决就是三个字:用"rb"、"wb"。顺带一提,结构体直接fwrite落盘本身也不可靠,编译器会在成员之间插填充字节,跨编译器、跨架构读回来字段就错位了,正经做法是逐字段序列化成固定字节序。
6. 设计取舍:为什么 fgets 逼你管缓冲区,而不是替你分配
读到这里,一个合理的疑问会浮上来:既然fgets有超长行截断、换行符残留这些毛病,标准库为什么不做成“自动分配一整行”?答案是历史包袱和可控性之间的取舍。fgets诞生在一个栈空间以 KB 计、没有标准内存分配保证的年代,定长缓冲区 + 调用者持有内存,是最不容易出事的契约。同一时期还有个gets,它不检查长度,因为无法知道缓冲区多大,最终被从标准里删掉——fgets多出来的那个参数n,就是这场事故留下的安全阀。
把三种读法摆在一起看,取舍很清楚:
| 方式 | 边界安全 | 超长行处理 | 空白处理 | 典型问题 |
|---|---|---|---|---|
gets | 无 | 直接溢出 | 保留 | 已从标准移除 |
fgets | 有(需传对 n) | 切成多段 | 保留\n | 需自己拼接、去换行 |
fscanf("%s") | 有上限 | 截断 | 遇空白停 | 读不了含空格的整行 |
getline(POSIX) | 有 | 自动扩容 | 保留\n | 需free,非标准 C |
getline是后来补上的答案:传NULL和0,它自己malloc并随行长度扩容,返回读到的字节数,用-1表示结束。代价是你得接管这块内存的生命周期,多一次free。在 Linux 上写解析器,我一般直接上getline;写需要跨平台的代码,还是老老实实fgets加手写拼接。
缓冲大小也是一笔账。默认块大小跟着st_blksize走,是为了让每次read恰好覆盖一个文件系统块,避免内核里再多一次拆分;调大了能减少系统调用次数,但会增加首次读的延迟和内存占用;调到极小则退化成逐字节系统调用,性能断崖式下跌。setvbuf给的就是这个旋钮,但绝大多数程序用默认值就是对的,除非你 profiling 出来 IO 是瓶颈。
错误处理同样是一种“把责任交回去”的设计。fopen返回NULL而不抛异常,fprintf用负返回值而不中断,feof和ferror分开两个标记——这一整套约定逼着调用者在每个环节显式检查。啰嗦,但换来了两件事:任何一行都可能失败而程序不会莫名其妙跳走;以及在嵌入式这类连异常机制都没有的环境里,同一份代码能原样跑。
想验证上面这些说法,不用读源码,两个办法就够。一是把strace -e trace=open,read,write,close挂在程序上,看系统调用次数和时机——你会发现fopen之后并没有立刻write,一段连续输出最后合并成一两次write,fclose时补上最后一次。二是用setvbuf把缓冲改成_IONBF再跑一遍,write的次数会暴涨到和fprintf调用次数同量级,两相对照,缓冲区到底在哪一层工作就一目了然了。
我自己的习惯是:只要动手写文件,先把fopen的返回值检查和fclose的出口路径写死,再往里填业务逻辑。早年被“写入完成”这四个字骗过一次,几百行统计结果最后只落了半截,翻遍逻辑没找到错,最后发现是某个分支return之前忘了关流。从那以后,我每写一个fopen,都强制在同一次编辑里把对应的fclose敲完,中间的逻辑才往里塞。希望帮到你。
本文还有配套的精品资源,点击获取