首页 / 资讯中心 / 文章详情

C语言文件操作核心指南:FILE、fopen、fgets、feof与EOF详解

C语言文件操作核心指南:FILE、fopen、fgets、feof与EOF详解 ★ FEATURED ARTICLE
1. 文件操作到底在解决什么问题做 C 语言开发的人迟早会碰到一个场景程序跑完一遍数据全在内存里一关掉就灰飞烟灭。想让数据活下来就得把它写到磁盘上下次启动再读回来程序才真正有了“记忆”。文件操作就是干这个的——它是 C 程序和外部存储之间唯一的正规通道。标题里这几个东西FILE、fopen、fgets、feof、EOF基本构成了一套完整的文件读写骨架。FILE是文件句柄类型fopen负责打开fgets负责按行读feof判断是否读到末尾EOF是读到末尾或出错时的返回值标记。看起来简单但真正踩过坑的人都知道这套东西的细节多到能写一本书。这篇文章适合谁看如果你刚学完 C 语言基础语法准备写第一个“能存数据”的小工具那这篇就是给你准备的。如果你已经写过一些文件操作代码但经常遇到“读出来多一行”“中文乱码”“文件打开失败不知道为啥”这类问题那这篇同样值得你花时间看完。我会从设计思路讲到实操细节再把我这些年踩过的坑一个个摊开来说。先说清楚一个前提C 标准库的文件操作是带缓冲的。什么意思你调用fgets读一行它不一定真的只从磁盘读那一行而是可能一次性读一大块到内存缓冲区里后续的读取直接从缓冲区拿。这个设计是为了减少磁盘 I/O 次数提升性能。但这也意味着缓冲区的状态和磁盘上的真实内容可能不一致很多诡异问题的根源就在这里。2. 核心角色逐个拆解2.1 FILE 结构体你看不见但一直在用的幕后角色FILE在stdio.h里定义但它通常是一个不透明结构体——你拿到的只是一个指针FILE *里面的字段缓冲区位置、文件描述符、错误标志、EOF 标志等你一般不需要也不应该直接碰。为什么设计成不透明因为不同平台的FILE内部实现差异很大。你在某个平台上直接访问FILE的成员换一个编译器可能就编译不过或者行为完全变了。标准库把它藏起来就是让你只通过函数接口操作保证可移植性。你可以把FILE *理解成一张“文件通行证”。你拿着这张证去调用各种函数函数内部根据证上的信息找到真正的文件。证丢了指针为 NULL或者证是假的指向已关闭的文件后续操作就会出问题。注意FILE *为 NULL 是fopen失败的唯一信号。任何一次fopen之后第一件事就是检查返回值是不是 NULL没有例外。2.2 fopen打开文件的第一道关卡fopen的原型是FILE *fopen(const char *filename, const char *mode);filename是文件路径mode是打开模式。模式字符串决定了你接下来能干什么模式含义文件不存在时文件存在时读写位置r只读打开失败返回 NULL正常打开文件开头w只写创建新文件清空原内容文件开头a追加写创建新文件保留原内容文件末尾r读写打开失败返回 NULL正常打开文件开头w读写创建新文件清空原内容文件开头a读写追加创建新文件保留原内容读在开头写在末尾这里有个新手最容易犯的错用w打开一个已有文件以为只是“打开”结果内容全没了。w的语义是“写模式”它会无条件截断文件。如果你只是想读用r想追加用a。还有一个细节在 Windows 平台上文本模式和二进制模式是有区别的。文本模式不加b下换行符\n在写入时会被转换成\r\n读取时\r\n会被转回\n。如果你处理的是图片、音频这类二进制数据必须加b比如rb、wb。在 Linux 平台上两者没区别但为了代码可移植处理二进制数据时加上b是好习惯。2.3 fgets按行读取的主力函数fgets的原型char *fgets(char *str, int n, FILE *stream);它从stream读取最多n-1个字符遇到换行符或文件末尾就停止然后把读到的内容存到str里并自动在末尾补一个\0。返回值是str的指针如果读取失败或一开始就遇到文件末尾返回 NULL。为什么是n-1而不是n因为要留一个位置给字符串结束符\0。假设你定义char buf[100]调用fgets(buf, 100, fp)最多读 99 个字符第 100 个位置放\0。fgets和gets的区别值得说一句。gets已经被 C11 标准废弃了因为它不检查缓冲区大小用户输入过长就会溢出。fgets有长度限制安全得多。任何新代码都不应该再用gets。fgets的一个重要特性它会保留换行符。如果一行是hello\n读进来就是hello\n\0。很多人读完之后发现字符串末尾多了个换行打印出来多空一行就是因为这个。处理办法通常是手动去掉size_t len strlen(buf); if (len 0 buf[len-1] \n) { buf[len-1] \0; }2.4 feof 和 EOF判断文件结束的正确姿势feof的原型int feof(FILE *stream);它返回非零值表示“上一次读操作遇到了文件末尾”。注意关键词上一次。feof不是预测下一次读会不会到末尾而是告诉你上一次读是不是因为到了末尾才失败的。这就引出了 C 文件读取最经典的坑// 错误写法 while (!feof(fp)) { fgets(buf, sizeof(buf), fp); printf(%s, buf); }这段代码会多打印一次。原因当fgets读到最后一行的内容后文件指针到了末尾但feof此时还没被设置因为fgets成功读到了内容没有“失败”。下一次循环feof仍然返回假再调用fgets这次才真正读到末尾返回 NULL但buf里的内容没有被更新于是上一次的内容被重复打印。正确写法是用读取函数的返回值作为循环条件while (fgets(buf, sizeof(buf), fp) ! NULL) { printf(%s, buf); }EOF是一个宏通常定义为-1。很多函数如fgetc、fscanf在读到末尾或出错时返回EOF。但fgets返回的是指针失败时返回 NULL不是 EOF。这一点要分清楚别把fgets的返回值和EOF比较。3. 完整实操从打开到关闭的标准流程3.1 一个可复用的文件读取模板下面这段代码是我平时写文件读取时最常用的骨架你可以直接拿去改#include stdio.h #include string.h int main(void) { FILE *fp fopen(data.txt, r); if (fp NULL) { perror(fopen); return 1; } char buf[256]; int line_no 0; while (fgets(buf, sizeof(buf), fp) ! NULL) { line_no; size_t len strlen(buf); if (len 0 buf[len-1] \n) { buf[len-1] \0; } printf(第 %d 行: %s\n, line_no, buf); } if (ferror(fp)) { perror(读取过程中出错); } fclose(fp); return 0; }这段代码有几个关键点值得展开。第一fopen之后立刻检查 NULL并用perror打印错误原因。perror会根据全局变量errno输出人类可读的错误信息比如“No such file or directory”比你自己猜原因高效得多。第二循环条件直接用fgets的返回值而不是feof。这是最稳妥的写法。第三读完一行后去掉换行符。注意判断len 0因为空行读进来是\n\0长度是 1去掉换行后变成空字符串这是正确的。第四循环结束后用ferror检查是不是因为出错才退出的。如果fgets返回 NULL可能是正常读到末尾也可能是读取出错比如磁盘故障。feof为真说明是正常结束ferror为真说明是出错。两者要分开处理。第五fclose不能忘。文件打开后不关闭会导致资源泄漏。在长时间运行的程序里文件句柄耗尽后fopen会开始失败。3.2 缓冲区大小的选择与计算fgets的缓冲区大小怎么定这取决于你要处理的行有多长。如果你确定每行不会超过 100 个字符用char buf[128]就够了。但现实中经常遇到超长行比如日志文件里某一行特别长。如果缓冲区太小fgets会分多次读完这一行每次读一部分。这时候你需要在代码里判断如果读到的内容末尾没有换行符说明这一行还没读完需要继续读。char buf[128]; int need_continue 0; while (fgets(buf, sizeof(buf), fp) ! NULL) { size_t len strlen(buf); if (len sizeof(buf) - 1 buf[len-1] ! \n) { // 这一行还没读完 need_continue 1; } else { need_continue 0; } // 处理 buf ... }这个判断逻辑是如果读满了n-1个字符且最后一个字符不是换行符那说明这一行还有后续内容。这个技巧在处理不确定长度的文本时非常实用。缓冲区也不是越大越好。栈上的大数组可能导致栈溢出一般建议不超过几 KB。如果确实需要处理超长行可以用malloc在堆上分配或者改用getlinePOSIX 标准不是 C 标准库的一部分但主流平台都支持。3.3 写入操作的配套写法读和写是配套的。写入常用fputs或fprintfFILE *fp fopen(output.txt, w); if (fp NULL) { perror(fopen); return 1; } fprintf(fp, 姓名: %s, 年龄: %d\n, name, age); fputs(这是一行文本\n, fp); fclose(fp);fprintf的用法和printf一样只是多了一个文件指针参数。fputs写入一个字符串不自动加换行符需要你自己在字符串里带上\n。写入完成后必须fclose因为标准库是带缓冲的你不关闭文件缓冲区里的数据可能还没真正写到磁盘上。fclose会刷新缓冲区并释放资源。如果程序在fclose之前崩溃缓冲区里的数据就丢了。提示如果程序运行时间长又不想频繁开关文件可以定期调用fflush(fp)强制刷新缓冲区。但fflush有性能开销不要每次写入都调用。4. 常见问题与排查技巧实录4.1 文件打开失败的五种典型原因fopen返回 NULL 是最常见的报错。原因通常逃不出这几类现象可能原因排查方法提示 No such file or directory路径写错或文件不存在用绝对路径测试确认文件确实存在提示 Permission denied没有读/写权限检查文件权限确认程序有访问权提示 Too many open files文件句柄泄漏检查是否有fopen后没fclose的地方路径中有中文或空格编码问题或路径解析问题先用纯英文无空格路径测试Windows 下路径用反斜杠转义字符问题用双反斜杠\\或正斜杠/路径问题是重灾区。在 C 字符串里\是转义字符C:\new\test.txt里的\n会被解释成换行符。正确写法是C:\\new\\test.txt或者直接用正斜杠C:/new/test.txtWindows 也认正斜杠。4.2 读到空行、多行、少行的排查思路读文件时行数不对通常有三个原因。多一行用了while(!feof(fp))的写法最后一行被重复处理。改成用fgets返回值做循环条件。少一行文件最后一行没有换行符某些逻辑可能漏掉它。fgets在读到末尾时即使最后一行没有\n也会把内容读出来并返回非 NULL。所以用返回值做条件不会漏。但如果你用其他方式判断行结束就可能漏掉。空行处理异常空行读进来是\n去掉换行后变成。如果你的代码用strlen(buf) 0来判断是否读到空会把空行和读取失败混淆。正确做法是用fgets的返回值判断是否读取成功用strlen判断内容是否为空。4.3 中文乱码的根源与解决用fgets读中文文本有时候打印出来是乱码。根源通常是编码不匹配。文件本身可能是 UTF-8 编码但你的终端或编辑器用 GBK 解码或者反过来。C 标准库的fgets只是按字节读取它不关心编码。你读进来的是字节序列怎么解释这些字节取决于输出端。解决办法确认文件的实际编码确保读取、处理、输出的各个环节编码一致。在 Linux 终端下file命令可以查看文件编码。在代码里如果只是原样读取和输出不做字符级处理通常不会破坏编码。乱码往往出现在你手动修改了某些字节或者用printf的格式化输出改变了字节序列。另一个常见问题是 BOM字节顺序标记。UTF-8 文件开头可能有三个字节EF BB BFfgets会把它们当作内容读进来导致第一行开头多出不可见字符。处理办法是在读取第一行后检查并跳过 BOM。4.4 fgets 返回值被忽略导致的隐蔽 bugfgets返回 NULL 时buf的内容是未定义的——它可能保持上一次的内容也可能被部分修改。如果你不检查返回值就直接用buf就会用到脏数据。我见过一个真实案例某程序读取配置文件循环里调用fgets但不检查返回值结果文件读完后buf里还是最后一行内容程序把这行内容重复处理了无数次直到某个计数器溢出才崩溃。排查了半天才发现是fgets返回值没判断。养成习惯每次调用fgets要么用返回值做循环条件要么显式判断是否为 NULL。4.5 文件指针位置与 fseek 的配合有时候你需要在文件里来回跳转比如先读头部信息再跳到某个偏移量读数据。这就要用到fseek和ftell。fseek(fp, 0, SEEK_SET); // 跳到文件开头 fseek(fp, 0, SEEK_END); // 跳到文件末尾 long size ftell(fp); // 获取当前偏移量即文件大小 fseek(fp, 0, SEEK_SET); // 再跳回开头fseek的第三个参数是基准位置SEEK_SET从头算SEEK_CUR从当前位置算SEEK_END从末尾算。第二个参数是偏移量正数向后负数向前。注意fseek之后feof的标志会被清除。如果你之前读到过末尾feof返回真调用fseek跳回开头后feof会重新返回假。这个行为在标准里有明确规定但很多人不知道。另外在文本模式下fseek的偏移量必须是ftell返回过的值或者 0。随便给一个偏移量在文本模式下可能行为不确定。二进制模式下没有这个限制。5. 几个容易被忽视的细节5.1 文本模式与二进制模式的实际差异前面提过 Windows 下文本模式会转换换行符。这个转换在读取时也会发生文件里的\r\n被转成\n。如果你用文本模式读取一个二进制文件里面的\r\n字节对会被“吃掉”一个字节导致数据损坏。反过来如果你用二进制模式读取文本文件读到的内容会包含\r打印出来可能多出奇怪的回车效果。判断标准很简单文件内容是人能读的文本用文本模式文件内容是给程序用的二进制数据用二进制模式。不确定的时候用二进制模式更安全因为它不做任何转换。5.2 fclose 的返回值也要检查fclose返回 0 表示成功返回EOF表示失败。失败通常发生在缓冲区刷新到磁盘时出错比如磁盘满了。如果你不检查fclose的返回值可能以为数据写成功了实际上最后一部分数据丢了。if (fclose(fp) ! 0) { perror(fclose); // 处理错误 }这个检查在写文件时尤其重要。读文件时fclose失败的概率很低但写文件时值得检查。5.3 多次打开同一文件的注意事项同一个文件可以被多次fopen每次返回不同的FILE *。每个FILE *有自己独立的缓冲区和文件位置。两个FILE *同时写同一个文件结果取决于操作系统的文件锁机制和写入顺序通常不是你想要的。如果确实需要同时读写用一个FILE *配合fseek切换位置或者用r模式。但要注意在读写切换之间标准要求调用fseek、fsetpos或fflush否则行为未定义。这个规则很多人不知道但在某些平台上不遵守确实会出问题。5.4 错误处理与 errno 的配合fopen失败时errno会被设置为具体的错误码。perror和strerror可以根据errno输出可读信息。#include errno.h #include string.h FILE *fp fopen(test.txt, r); if (fp NULL) { fprintf(stderr, 打开文件失败: %s\n, strerror(errno)); }strerror返回错误码对应的描述字符串比perror更灵活因为你可以把它嵌入到自己的格式化输出里。注意errno只在函数失败后立即有效。如果你在fopen失败后先调用了其他函数errno可能被覆盖。所以要么立即处理要么先把errno存到另一个变量里。6. 性能与安全的进阶考量6.1 缓冲区大小对性能的影响标准库的默认缓冲区大小通常是 4KB 或 8KB。对于大文件读取这个大小影响 I/O 次数。如果你要读一个几百 MB 的文件默认缓冲区意味着几万次系统调用。可以用setvbuf调整缓冲区char my_buf[65536]; setvbuf(fp, my_buf, _IOFBF, sizeof(my_buf));_IOFBF表示全缓冲_IOLBF表示行缓冲_IONBF表示无缓冲。全缓冲适合大文件批量读写行缓冲适合交互式输出。但要注意setvbuf必须在任何其他 I/O 操作之前调用而且my_buf的生命周期要覆盖整个文件使用期间。如果my_buf是局部数组函数返回后缓冲区就失效了后续操作会出问题。所以通常用静态数组或全局数组。6.2 用 fread 和 fwrite 处理二进制数据文本用fgets二进制用fread和fwriteunsigned char data[1024]; size_t n fread(data, 1, sizeof(data), fp); if (n sizeof(data)) { if (feof(fp)) { // 正常读到末尾 } else if (ferror(fp)) { // 读取出错 } }fread返回实际读到的元素个数。参数是“每个元素的大小”和“元素个数”返回值是成功读到的元素个数。如果返回值小于请求的个数可能是到了末尾或出错了用feof和ferror区分。fwrite类似返回实际写入的元素个数。写文件时检查返回值可以及时发现磁盘满等问题。6.3 临时文件的安全创建有时候需要创建临时文件。tmpfile函数创建一个匿名临时文件程序结束时自动删除FILE *tmp tmpfile(); if (tmp NULL) { perror(tmpfile); }tmpfile以wb模式打开返回的FILE *可以直接读写。文件在关闭或程序退出时自动删除不需要手动清理。这个函数在需要临时缓存数据时很好用。如果需要在磁盘上创建有名字的临时文件用tmpnam生成文件名再fopen但tmpnam有安全风险生成文件名和实际创建文件之间有竞争窗口不推荐在安全敏感的场景使用。7. 我踩过的几个真实坑第一个坑是关于fgets和sizeof的。有次我写了个函数参数是char *buf里面调用fgets(buf, sizeof(buf), fp)。结果sizeof(buf)在函数参数里是指针大小8 字节不是数组大小。读进来的内容被截断成 7 个字符排查了好久才发现。数组作为函数参数会退化成指针sizeof拿不到数组长度。解决办法是把长度也作为参数传进来。第二个坑是文件路径拼接。我用sprintf拼接路径缓冲区开得不够大路径被截断fopen打开了一个不存在的文件。后来改用snprintf并检查返回值确保缓冲区够用。第三个坑是忘记fclose。程序跑一段时间后fopen开始返回 NULL提示“Too many open files”。查了半天发现有个分支里fopen之后直接return了没走fclose。后来养成了习惯fopen之后的所有return路径都要确保fclose或者用goto统一清理。第四个坑是feof的误用。前面讲过while(!feof(fp))会多读一次我当年就是这么写的多打印了一行最后的内容还以为是文件本身的问题。后来改成用fgets返回值做条件问题消失。第五个坑是中文路径。在某个平台上fopen打开中文路径的文件一直失败换成英文路径就好了。后来查资料发现是编码问题需要把路径转成平台特定的宽字符格式。这个坑在跨平台开发时特别常见如果项目需要处理中文路径建议统一用宽字符 API 或者确保源文件编码和运行环境编码一致。8. 一套完整的文件读写工具函数最后分享一套我常用的工具函数封装了打开、读取、关闭的常见逻辑#include stdio.h #include stdlib.h #include string.h #include errno.h // 读取整个文本文件到内存返回字符串调用者负责 free char *read_file(const char *path) { FILE *fp fopen(path, rb); if (fp NULL) { fprintf(stderr, 打开 %s 失败: %s\n, path, strerror(errno)); return NULL; } fseek(fp, 0, SEEK_END); long size ftell(fp); if (size 0) { fclose(fp); return NULL; } fseek(fp, 0, SEEK_SET); char *buf malloc(size 1); if (buf NULL) { fclose(fp); return NULL; } size_t n fread(buf, 1, size, fp); buf[n] \0; fclose(fp); return buf; } // 按行读取并处理每行调用回调函数 int process_lines(const char *path, void (*handler)(const char *, int, void *), void *userdata) { FILE *fp fopen(path, r); if (fp NULL) { fprintf(stderr, 打开 %s 失败: %s\n, path, strerror(errno)); return -1; } char buf[4096]; int line_no 0; while (fgets(buf, sizeof(buf), fp) ! NULL) { line_no; size_t len strlen(buf); if (len 0 buf[len-1] \n) { buf[len-1] \0; } handler(buf, line_no, userdata); } int ret 0; if (ferror(fp)) { fprintf(stderr, 读取 %s 出错\n, path); ret -1; } fclose(fp); return ret; }read_file用二进制模式读取整个文件适合配置文件、JSON 等小文件。process_lines按行读取并回调适合日志分析、逐行处理等场景。这两个函数覆盖了我日常开发中大部分文件读取需求。用的时候注意read_file返回的指针需要调用者free否则内存泄漏。process_lines的回调函数里不要做耗时太长的操作否则会拖慢整体处理速度。文件操作这东西看起来简单但细节决定成败。把FILE、fopen、fgets、feof、EOF这几个东西的脾气摸透了写出来的代码才稳。我上面列的那些坑每一个都是实际调试出来的教训希望你看完之后能少走点弯路。
阅读完成 · 觉得有帮助?
咨询建站