资讯动态

C语言文件操作实战:掌握fopen/fread/fwrite,实现数据持久化

发布时间:2026/10/17 1:03:28 来源:尧图企业网站定制
很多人刚开始学C语言的时候最头疼的就是指来指去的指针好不容易把指针搞明白了一遇到文件操作又懵了。其实文件操作没那么玄乎说白了就是把内存里的数据搬到硬盘上再从硬盘上读回来。这个能力几乎是所有正经项目的基石没有它你的程序每次一关就一切归零写再牛的算法也存不住数据。C语言的文件操作核心就是围绕fopen、fclose、fread、fwrite、fgets、fputs、fscanf、fprintf这一族标准函数展开。不管你是打算做学生管理系统、图书借阅系统还是什么简单的数据采集Demo只要涉及“程序关了再开数据还在”这件事都要走这套流程。这篇文章我会用尽量直白的话把文件打开的细节、读写函数的取舍、常见的坑一次讲清楚代码我亲自跑过你在自己机器上照着抄就能用。1. 文件操作的整体认知与设计思路1.1 文件流先搞懂数据是怎么流动的我见过不少人一上来就背函数名结果写代码的时候一会儿用fread一会儿用fscanf完全没想明白它们之间到底什么关系。其实C语言里所有文件操作都建立在“流”的概念上。所谓“流”就是数据像水管里的水一样从一个地方连续流向另一个地方。从磁盘流向内存的叫输入流从内存流向磁盘的叫输出流。每个被打开的文件系统都会给你一个FILE*指针你可以把它理解成一根水管的阀门读写操作全都是通过操作这个阀门来完成的。打开文件就是把这根水管接到指定的文件上关闭文件就是把水管拆掉。这里有个常被忽略的东西——缓冲区。C标准的I/O并不是直接读写磁盘而是先在内存里划一块区域作为缓冲数据攒到一定量再一次性写入磁盘或者预先读一块数据到内存再慢慢消化。这样做是为了减少昂贵的磁盘I/O次数。缓冲模式大致有三种全缓冲一般文件默认攒够了才写、行缓冲遇到换行就刷出去终端就是这种、无缓冲立即写stderr是这样。理解缓冲很重要因为很多人栽在“写了文件老不生效”这个现象上往往不是数据没写进去而是没刷到磁盘上光躺在缓冲区里。函数fflush(fp)可以强制把缓冲区的数据立刻送到磁盘但我不建议一上来就到处加这个先搞清楚文件生命周期再说。1.2 为什么选对模式比写对代码更重要fopen这个函数很多人是会用但很少去深究它的第二个参数。r、w、a这三个基础模式加上b和两个修饰符组合起来有十来种每一种的行为完全不同选错了模式代码写得再漂亮都白搭。直接放一张我整理的对照表这张表值得你存下来模式文件不存在时是否清空原内容文件指针初始位置是否可读是否可写r打开失败不清空文件开头是否w自动创建清空文件开头否是a自动创建不清空文件末尾否是r打开失败不清空文件开头是是w自动创建清空文件开头是是a自动创建不清空文件末尾是是我看到的最多的问题是有人用w打开文件然后回来读结果读了个空或者本来想追加数据结果每次运行都把上次的数据清空了。这些都是模式没选对。另外我要提醒一个细节r和w虽然都是可读可写但w会无情地把原文件清空所以如果你只是想修改文件里的某一条记录千万不要用w那等于先删光再吃饭。还有b这个修饰符只对Windows系统有实际影响。在Windows下文本模式会把换行符翻译成\r\n两个字节二进制模式则不做任何转换。如果你是用w写一个图片文件或者任何二进制格式Windows下要么加b要么等待图片打开后变成一堆乱码。Linux和macOS下t和b没有区别但为了跨平台兼容写二进制时带上b永远是稳妥的选择。1.3 程序的基本骨架打开-读写-关闭没写过文件操作的朋友我建议你不要一上来就盯着各种奇怪的函数先把这个骨架刻在脑子里FILE *fp fopen(data.txt, r); if (fp NULL) { perror(文件打开失败); return 1; } // 这里做读写操作 fclose(fp);三步走打开、操作、关闭。fopen返回一个指针如果打开失败返回NULL所以判空是必须的。perror可以把错误原因打印出来比你自己猜“到底是不存在还是没权限”清楚得多。fclose不只是把门关上它还会把缓冲区里残存的数据刷到磁盘。所以你要是写完不关文件不仅占着文件句柄数据还可能悬在缓冲区里没落盘。一个程序同时打开的文件数量是有限制的通常1024个左右反复打开不关早晚会用完句柄到时候新文件打不开找半天也不知道原因。2. 字符与字符串读写实操要点2.1 fgetc/fputc小文件练手也能暴露大问题fgetc一次从文件里读一个字符fputc一次写一个字符。看着简单但这里藏着一个C语言初学者最容易踩的经典大坑——EOF的判断。先看一段我见过无数人写错的代码char ch; while ((ch fgetc(fp)) ! EOF) { putchar(ch); }这段代码在大部分情况下能跑但在某些平台上会出问题。原因在于EOF是整型常量-1而char类型有可能是无符号的unsigned char如果是无符号的那么fgetc返回的-1会被截断成0xFF也就是ASCII 255永远不等于-1循环就会死循环。正确做法是声明一个int变量来接收fgetc的返回值int c; while ((c fgetc(fp)) ! EOF) { putchar(c); }为什么fgetc要返回int而不是char因为它不仅要返回所有可能的字符值0~255还要额外返回一个文件结束标志EOF一个char装不下这么多信息所以要用int来容纳。下面是一个用fgetc逐字符拷贝文件的完整例子虽然效率不如后面要讲的fread但逻辑非常清晰FILE *in fopen(input.txt, r); FILE *out fopen(output.txt, w); if (in NULL || out NULL) { perror(文件打开失败); return 1; } int c; while ((c fgetc(in)) ! EOF) { fputc(c, out); } fclose(in); fclose(out);2.2 fgets/fputs按行读写最稳的文本处理方式如果你要处理的文件是配置文件、日志文件、CSV这类文本格式那最舒服的读写方式是按行处理用fgets和fputs。fgets的原型是char *fgets(char *s, int size, FILE *stream)它的行为我建议你背下来最多读取size - 1个字符遇到换行符\n就停下包含换行符读到文件末尾返回NULL读完自动在末尾补一个字符串结束符\0。有一个细节经常有人忽略fgets会保留行尾的换行符。也就是说读进去的字符串里自带一个\n。这在拼接字符串时特别容易造成问题。下面我写一个逐行读取并去掉换行符的模板这个我几乎每个项目都在用char line[256]; while (fgets(line, sizeof(line), fp) ! NULL) { line[strcspn(line, \n)] \0; // 去掉尾部换行符 printf(读取到: %s\n, line); }strcspn这个函数可能有人不熟它的作用是找到第一个出现指定字符的位置然后用\0把这个位置替换掉效果就是把换行符剪掉。如果你不剪掉它后面处理字符串的时候换行符会混在你的数据里尤其是做字符串比较时怎么比都不相等排查半天才发现是换行符的锅。fputs则比较简单把一个字符串写入文件但它不会自动添加换行符。它的逻辑就是“原样输出”所以如果你之前用fgets读进来带\n的字符串再用fputs写回去那是完美还原的。这里必须提一句标准C里已经废弃了gets这个函数因为它不检查缓冲区大小读入超长内容时直接内存越界写入属于危险函数。如果你的编译器还在警告你还用gets赶紧换fgets吧。2.3 警惕缓冲区边界一行太长会发生什么这个坑是很多人在处理真实数据时才会遇到。假设你的缓冲区定的是char line[100]但文件里有一行居然有150个字符fgets会怎么做它不会报错它会先读取99个字符加上\0一共100个填满缓冲区然后返回给你。而这一行的剩余字符还留在文件流里等你下一次调用fgets它会继续从刚才那个位置往后再读99个字符。也就是说一行逻辑数据被拆成了两段。如果你不处理这种情况你的程序会把一行数据当成两行来解析结果条数对不上数据少一半查来查去怪自己逻辑不对实际是缓冲区尺寸不够。解决方案有两种。一是把缓冲区设置得足够大比如char line[4096]一般文本行也就几十到几百字节4096够用了二是用循环反复fgets拼接直到读完一整行为止char buffer[1024]; size_t total_len 0; while (fgets(buffer total_len, sizeof(buffer) - total_len, fp) ! NULL) { total_len strlen(buffer total_len); if (total_len 0 buffer[total_len - 1] \n) { break; } }说实话对于绝大多数练手项目设个大缓冲区就完全够了。但我把这个现象讲出来是希望大家理解fgets的边界行为不要等到线上环境数据一多才突然卡壳。3. 格式化与二进制读写、文件定位3.1 fprintf/fscanf格式化读写注意参数陷阱读文本文件时如果你想直接按格式解析数据fscanf是一个便捷选择。比如一个文件里存的是“学号 姓名 分数”一行一条你可以这样读int id; char name[32]; float score; while (fscanf(fp, %d %s %f, id, name, score) 3) { printf(学号:%d 姓名:%s 分数:%.1f\n, id, name, score); }这里最值得学习的是检查返回值。fscanf返回成功匹配并赋值的参数个数我上面写了 3意思是必须三个字段都成功解析才继续循环。如果不检查返回值遇到格式不一致的数据fscanf卡在中间数据读写就会错乱。fprintf则是格式化输出到文件用法和printf一模一样多一个文件指针参数fprintf(fp, %d %s %.1f\n, id, name, score);这两种函数适合处理人眼能看懂、结构规则的文本文件。但要注意fscanf遇到空格的跳过规则比较机械如果数据里有包含空格的中文字符串人名里带空格%s就会截断。老实说当数据格式复杂到一定程度我更推荐用fgets读一行然后自己在内存里解析而不是硬靠fscanf去硬啃。另外写文件时如果字符集不是UTF-8比如Windows下中文用的是GBK用fprintf写进去再读出来可能出现乱码。这不是fprintf的问题是编码转换的问题。我的建议是自己练手项目一律用UTF-8编码保存源文件和文本文件能避开一大半乱码问题。3.2 fread/fwrite二进制读写结构体数组文本文件的好处是可读性强缺点是解析开销大、浮点数有精度损失、占空间大。如果你要存的是结构体数组比如几百上千条学生记录最合适的方式是用fread和fwrite直接做二进制读写。看这段代码一条学生记录一个结构体数组整体一次性写进文件再一次性读出来typedef struct { int id; char name[32]; float score; } Student; Student stu[100] { /* 初始化数据 */ }; // 写入 FILE *fp fopen(students.dat, wb); if (fp NULL) { perror(打开失败); return 1; } fwrite(stu, sizeof(Student), 100, fp); fclose(fp); // 读取 FILE *fp2 fopen(students.dat, rb); if (fp2 NULL) { perror(打开失败); return 1; } Student loaded[100]; size_t n fread(loaded, sizeof(Student), 100, fp2); fclose(fp2);fwrite的参数含义是把100个sizeof(Student)大小的数据块写入文件返回实际写入的块数。如果磁盘满了返回值会小于100这个返回值值得检查一下。这种方式的优点非常直观结构体在内存里是什么样文件里就是什么样读写速度极快也不需要做任何格式解析。缺点则是跨平台性差不同的编译器可能会在结构体字段之间填充不同的字节结构体对齐问题不同CPU的大小端也可能不同。所以这种方案适合自己练手、或者程序只在同一台机器上读写的情况。如果你的数据将来要在不同的程序、不同的平台之间交换建议还是老老实实使用文本格式或者用JSON等标准格式。这里要特别强调文本模式和二进制模式的区别不只是换行符。Windows下用文本模式w打开文件每遇到一个\n实际写入磁盘的是\r\n两个字节而用wb则原样写入。这正是为什么二进制数据一定要用带b的模式打开否则一个结构体里万一有个字节值恰好是0x0A也就是\n就会被翻译成0x0D 0x0A读回来数据就变了。3.3 随机存取fseek/ftell/rewind的使用场景大多数文件读写都是从头到尾顺序进行的但有时候你只想读取文件中某一条记录比如一个数据库文件有1000条结构体记录你想直接读第500条。如果顺序扫描浪费时间不说代码还显得很笨。fseek和ftell是C语言提供随机存取的两个主力函数。fseek可以把文件指针移动到指定位置然后从那个位置开始读写。它有三个基准点SEEK_SET文件开头、SEEK_CUR当前位置、SEEK_END文件末尾。想读文件中的第500条结构体记录可以这样Student one; fseek(fp, (500L - 1) * sizeof(Student), SEEK_SET); fread(one, sizeof(Student), 1, fp);这里的偏移量是(500-1)*sizeof(Student)因为第一条记录的偏移是0。这里注意第500条的位置是499乘以结构体大小不要减一减错位置。ftell的作用是返回当前文件指针相对于文件开头的字节偏移量。它最常用的场景是获取文件大小fseek(fp, 0, SEEK_END); long size ftell(fp); fseek(fp, 0, SEEK_SET);先跳到文件末尾用ftell拿到偏移量这个偏移量就等于文件大小然后再跳回开头。这招简单实用但它只能用于二进制模式。在文本模式下因为换行符被翻译了ftell返回的偏移量和磁盘上的真实字节数不一致这个要留心。rewind只有一个作用把文件指针移回开头相当于fseek(fp, 0, SEEK_SET)。有一个注意点fseek只能定位普通文件对于管道、终端设备这类没有固定字节数的“流”fseek调用会失败。如果你的程序读的是标准输入那基本无法定位。另外ftell的返回类型是long在32位系统上最多只能表示2GB的文件。如果你的文件有可能超过这个上限就要考虑用fseeko和ftello需要定义宏来开启或者换用其他平台特定的API。一般练手项目碰到这种问题的概率不大但知道有这回事免得日后踩坑时毫无头绪。3.4 判断文件是否读完feof和ferror的正确打开方式这是C语言文件操作里我被问过最多的一个问题。很多人写读文件的循环时喜欢这么写while (!feof(fp)) { fgets(buf, sizeof(buf), fp); // 处理buf }这个写法的错误在于feof(fp)在文件还没有读到过文件末尾的时候返回0只有在读取操作已经越过了文件末尾后它才会变成真。上面的循环在读到最后一个有效数据后还会再进入一次循环此时fgets已经返回NULL缓冲区里的内容是上一次残留的旧数据你会发现自己多处理了一条“脏数据”。正确做法非常简单用读取函数的返回值来判断是否结束。比如fgets返回NULL就代表结束fread返回0就代表没读到fscanf返回值不等于期望数也要结束。feof只适合用来在读取结束之后确认“是不是因为到达文件末尾才结束的”不要拿它作为循环的前置判断条件。char line[256]; while (fgets(line, sizeof(line), fp) ! NULL) { // 处理line }如果文件读取出错可以用ferror(fp)检测错误标志用clearerr(fp)清除错误标志。如果fread返回了少于请求数量的块数你可以分别判断feof和ferror区分是文件被截断还是读取时报错。4. 实战项目通讯录数据存储与加载4.1 需求拆解用什么方案最合适把前面这些知识点串起来我带着你做一个非常典型的练手项目通讯录系统。这个系统可以把联系人信息姓名、电话、备注保存到磁盘下次程序启动时可以自动加载。第一步是确定存储方案。这里做一个选型分析方案优点缺点适用场景文本格式按行存可直接查看和编辑解析稍麻烦配置文件、日志、少量数据二进制结构体直存读写快代码简单跨平台差不可读本地单机小项目CSV/JSON通用性好需要额外解析库数据跨系统交换对于通讯录这种个人练手项目我选择二进制结构体直存因为代码量最少、出错概率最低而且能顺便展示fwrite/fread的用法。4.2 完整代码示例写入联系人数组并加载下面是完整的代码我加了一些注释方便你直接抄走跑一遍#include stdio.h #include string.h #define MAX_CONTACTS 100 typedef struct { char name[32]; char phone[16]; char note[64]; } Contact; Contact contacts[MAX_CONTACTS]; int count 0; // 保存到文件 int save_to_file(const char *filename) { FILE *fp fopen(filename, wb); if (fp NULL) { perror(保存失败); return -1; } // 先写入联系人数量 fwrite(count, sizeof(int), 1, fp); // 写入所有联系人数据 fwrite(contacts, sizeof(Contact), count, fp); fclose(fp); return 0; } // 从文件加载 int load_from_file(const char *filename) { FILE *fp fopen(filename, rb); if (fp NULL) { perror(加载失败); return -1; } fread(count, sizeof(int), 1, fp); size_t read_count fread(contacts, sizeof(Contact), count, fp); fclose(fp); if (read_count ! (size_t)count) { fprintf(stderr, 读取数量不匹配文件可能已损坏\n); return -1; } return 0; } int main(void) { // 模拟添加两条联系人 strcpy(contacts[count].name, 张三); strcpy(contacts[count].phone, 13800138000); strcpy(contacts[count].note, 大学同学); count; strcpy(contacts[count].name, 李四); strcpy(contacts[count].phone, 13900139000); strcpy(contacts[count].note, 同事); count; save_to_file(contacts.dat); // 试一试清空内存 count 0; memset(contacts, 0, sizeof(contacts)); load_from_file(contacts.dat); for (int i 0; i count; i) { printf(%s %s %s\n, contacts[i].name, contacts[i].phone, contacts[i].note); } return 0; }这里用一个int count先存储联系人数量然后再把结构体数组写进文件。读的时候先读数量再根据数量读对应大小这样以后数组增长或裁剪文件里都能正确还原。memset(contacts, 0, sizeof(contacts))是我故意加的一步用来验证加载函数真正起作用了。如果你把这一步去掉可能内存里本来就有数据加载成败看不出来。4.3 三个真实踩过的坑这个项目我前后带过好几拨人做下面几个坑是大家反复踩的我单独拎出来讲。第一个坑写完后马上读读出来的内容是旧的。这是因为写入的数据可能还悬在缓冲区里没有真正落盘。解决办法是写入后先fclose再用rb重新打开读取或者写完以后调用fflush(fp)。不要在做完写入之后、未关闭文件的情况下直接用同一个指针去读读写共用一个流且不做fflush或fseek缓冲区会很混乱。第二个坑路径分隔符和反斜杠。在Windows下文件夹路径如果用D:\data\contacts.dat这种写法\d和\c会被当作转义字符而乱掉。我建议写法是D:/data/contacts.dat用正斜杠Windows完全认Linux也认。如果是当前目录下的文件直接写文件名就行不要加./contacts.dat以外的复杂相对路径更不要以为\data是根目录——在C语言里\data只是被转义后的data它真的会以当前目录为基准去找文件。第三个坑文件被占用或者没有权限。在Windows下如果你打开一个已经用Excel占用的CSV文件用a模式追加时会直接失败返回NULL。这类问题用perror能看出来比如显示Permission denied。遇到这种情况先关掉占用文件的其他程序再跑代码。还有一种情况是程序自己上一次运行没关闭文件这个进程还在后台挂着文件句柄占用着导致这次运行读写失败。检查一下后台有没有跑着的旧程序这个问题我遇到不止一次。5. 文件操作常见问题速查与其他建议5.1 我整理的常见问题速查表把这些年遇到的问题汇总一下做了一张排查表。你在排错的时候可以按图索骥故障现象可能原因排查思路fopen返回NULL文件不存在、无权限、路径错误、文件被占用用perror看好具体提示检查文件和目录权限确认路径无转义字符读文件多出一行用while(!feof(fp))做循环判断了改用读取函数返回值判断循环终止写入中文乱码源文件编码、文本文件编码不一致统一使用UTF-8编码Windows控制台若乱码则设置代码页为65001二进制文件读完数据不对模式没加b结构体对齐问题Windows下二进制模式必须加b结构体字段固化大小写入数据不落盘缓冲区没刷新写完调用fclose或fflush程序报句柄太多文件指针没有关闭用fclose配对检查一次fopen必须有一次fclose文件指针越界定位失败fseek定位到了文件末尾以外注意偏移量不能为负不能用fseek操作管道或设备流读取结构体数组只读了一半fread返回数量小于请求数量可能是文件被截断检查返回值同时用feof/ferror区分情况这张表只是最常见的一些场景。真到了复杂环境你还要留意文件系统本身的限制比如单个文件不能超过4GBFAT32或者目录层级过深导致路径太长这些属于环境问题程序本身逻辑没有问题。5.2 最后再分享两个小习惯我自己的代码里有两个已经形成肌肉记忆的习惯建议你也尽快养成。第一个习惯是每次fopen后立刻写一个等会要fclose的空行。这样写代码时开文件的地方和关文件的地方在视觉上离得够远也没关系等会儿补代码时就不会因为函数嵌得太深而忘了关闭。很多内存泄漏、句柄泄漏问题都不是逻辑复杂纯粹是忘了在分支里关闭文件。第二个习惯是用perror而不是printf来报告错误。printf(打开失败)只能告诉我失败了但perror(打开失败)会把系统的错误描述也打印出来比如“No such file or directory”“Permission denied”。这对定位问题是天壤之别。尤其在不同操作系统上跑同一段代码错误描述往往直接告诉你答案。还有一个小技巧利用fopen的返回值做调试。比如有时候文件打开失败是因为目录不存在fopen不会帮你创建目录。解决方案就是在fopen之前检查目录是否存在或者干脆用绝对路径把目录提前建好。不过对于练手项目把文件放在程序当前目录下永远是最少踩坑的做法。文件操作这个东西看着是C语言里不起眼的一小块但太多项目倒在数据持久化这一步上。把fopen的模式吃透把fgets/fread的边界行为搞明白把缓冲区刷新弄懂你就掌握了文件操作最基本的闭环能力。后面不论是学SQLite做嵌入式存储还是学JSON做数据交换本质上都是在这个基础上扩展。先把这个通讯录项目跑通把文件里的数据导出来看一眼你就知道这一套流程到底是怎么回事了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑