很多人学到结构体、链表之后都会冒出一个念头我辛辛苦苦在内存里建好的数据程序一关就全没了那做出来的东西还有什么意义这个念头很正常而且它正好指向了 C 语言学习里承上启下的一章——文件操作。前面的数组、函数、指针、结构体解决的是“程序运行期间怎么组织数据”文件操作解决的则是“程序结束之后数据怎么留下来”。这篇作为第 35 章文件操作入门的第一篇我打算把最核心的打开、读写、关闭这条链路讲透再把缓冲区、EOF、文本与二进制模式这几个最容易让人卡住的概念一次性说清楚。适合刚学完指针和结构体、正在准备实训项目或课程设计的读者。我不会堆一堆冷冰冰的 API 文档而是按我自己当初从懵到懂的顺序来写先讲清楚文件操作到底解决了什么再上手写完整代码最后把那些“教科书不会明说、但一跑就翻车”的细节都摊开给你看。1. 先想清楚文件操作解决的是程序结束后的数据去留1.1 内存数据的寿命关掉程序就归零你写的变量、数组、结构体、链表节点全部活在内存里。内存的特点是通电时飞快断电后清零程序退出时操作系统会把这块空间回收里面的数据也随之消失。我当年写程序时经常有这种经历运行一个学生成绩统计程序辛辛苦苦敲了几十条记录结构体数组也排得整整齐齐结果一个 CtrlC 或者关掉终端再重新打开什么都没有了。这就是典型的“只用了内存没用文件”。文件存在的意义就是打破这个限制程序结束后数据以文件形式留在磁盘上下次启动再从文件里读回来。现实中的例子太多了文本编辑器保存文档、游戏存档、软件配置文件比如 .ini、.json、日志系统本质上都是文件操作。C 语言里这些东西的基础就是标准库提供的一整套文件读写函数。1.2 入门文件操作需要的前置基础很多人学文件操作觉得吃力不是因为文件函数本身有多难而是因为前面章节的基础没有串起来。指针fopen返回的是FILE *你得习惯拿一个指针去操作文件数组和缓冲区fgets要往char数组里读数据fwrite要给定缓冲区地址结构体二进制写文件时经常把整个结构体直接落盘循环与分支逐行读文件、判断文件是否打开成功都离不开流程控制。所以如果你前面的链表、指针还不算太熟也不影响你先把文件操作跑通但对“地址”和“缓冲区”这两个概念一定要有个基本感知。文件操作本质上是内存缓冲区和磁盘文件之间的搬运地址就是搬运的起点或终点。1.3 一条主线打开、读写、关闭文件操作所有的花样都逃不开一条主线打开文件fopen → 读写文件fputs/fgets/fwrite/fread等 → 关闭文件fclose。打个比方这就是打电话。拨号相当于打开文件通话相当于读写挂断相当于关闭。你拨通电话才能说话通话结束必须挂断否则一直占线。文件操作也一样不打开就没法读写不关闭程序可能会丢数据、占资源极端情况下还会导致文件写不完整。先记住这条主线后面不管看到多少函数都不会迷失方向。2. 把文件当成一条“水管”流、FILE 指针与缓冲机制2.1 FILE 指针只是一个“遥控器”别把它当文件内容C 标准库把文件抽象成“流”stream也就是说它把一串数据看成是可以流动的数据流。fopen成功之后返回一个FILE *指针很多初学者误以为这个指针指向文件内容本身。其实不是。FILE是标准库定义的一个结构体里面保存着和这个文件相关的状态信息当前读写位置、缓冲区指针、错误标志、底层文件描述符等。不同编译器的FILE内部字段不一样但用法一致。你能做的就是把它当成一个“遥控器”fread、fwrite、fclose都靠它来操作对应的文件。每个程序启动时其实已经有三个默认打开的流stdin标准输入、stdout标准输出、stderr标准错误输出。你平时用的scanf和printf本质上也是在读写文件——只不过读写的是标准输入输出流。这个视角一旦建立文件操作就只是“换了一根水管”而已。2.2 缓冲区决定“你看见了没写进去”这是新手最容易疑惑的一点明明调了写入函数代码也没报错打开文件却看不到数据等程序退出之后再去看数据又出现了。问题的核心是缓冲区。为了提高效率标准库不会每次写入都立刻访问磁盘磁盘 I/O 很慢而是先在内存里攒一批数据等缓冲区满了或者主动刷新时才真正写入磁盘。就好比你不会寄一件快递就跑一趟快递站而是攒一袋再集中寄一次。日常使用中通常有三种缓冲策略全缓冲磁盘文件常用缓冲区满了才写行缓冲终端交互常用遇到换行就刷新无缓冲stderr默认无缓冲错误信息立刻输出。所以“写了但没看到”很正常。想要强制把缓冲区数据刷到磁盘可以做两件事调用fclose(fp)关闭前会刷新缓冲区调用fflush(fp)只刷新不关闭适合需要边写边读的场景。注意如果程序中途异常退出比如崩溃缓冲区里没来得及写入磁盘的数据就丢了。养成及时fclose的习惯比什么都重要。2.3 fopen 模式r、w、a 以及它们带 的组合打开文件的姿势由fopen的第二个参数决定这个参数是个字符串叫“模式”。看起来简单但选错模式会直接影响后续读写能否成功。模式含义文件不存在时文件存在时可读可写r只读打开失败从头读取是否w只写创建清空内容否是a追加写创建末尾追加否是r读写打开失败从头读写是是w读写创建清空内容是是a追加读写创建末尾追加是是rb/wb/ab二进制模式参照上方对应规则同上同左同左两个需要重视的点w和w会清空原文件。如果你只是想往已有文件里加内容一定要用a只读模式下文件必须存在否则fopen返回NULL。很多程序崩溃的起点就是没检查这个返回值。入门阶段不需要把模式全部背下来先把r、w、a、rb、wb这五个玩熟后面遇到带的需求再回头补。3. 写一个完整的读写程序逐行存储与逐行回读3.1 打开文件之前先检查 fopen 的返回值直接看一段完整可跑的代码。它的功能是用户在终端里输入若干行文字输入空行结束程序把所有行写入note.txt随后重新打开文件逐行打印出来并加上行号。#include stdio.h #define LINE_MAX 256 int main(void) { char line[LINE_MAX]; FILE *fp; // 第一步写入 fp fopen(note.txt, w); if (fp NULL) { perror(fopen for write); return 1; } printf(请输入内容输入空行结束\n); while (fgets(line, sizeof(line), stdin) ! NULL) { if (line[0] \n) { break; // 空行结束 } fputs(line, fp); } fclose(fp); // 第二步回读 fp fopen(note.txt, r); if (fp NULL) { perror(fopen for read); return 1; } int no 1; while (fgets(line, sizeof(line), fp) ! NULL) { printf(%3d | %s, no, line); } fclose(fp); return 0; }注意两次fopen之后都立刻判断是否为NULL。如果文件打开失败程序直接退出而不是带着一个无效指针继续往下走。perror会把系统错误信息打印出来比如“No such file or directory”或“Permission denied”排查问题全靠它。3.2 写入fputs 与 fprintf 怎么选写入部分我用的是fputs(line, fp)。它把字符串写入文件不会自动加回车也不会做格式化。如果你需要写入带变量内容的文本fprintf更方便用法和printf几乎一样只是第一个参数多了文件指针int score 90; fprintf(fp, 姓名: %s, 成绩: %d\n, 张三, score);选择建议很简单纯文本整行搬运用fputs需要拼变量、拼格式时用fprintf。两种都可以别在这个选择上纠结太久。3.3 回读fgets 的换行符处理回读的关键是理解fgets的行为它会一直读到换行符为止并把换行符也保留在缓冲区里然后再补一个\0。很多人第一次用fgets都会抱怨“怎么读出来的字符串多了一个换行”其实不是 bug而是它的设计如此。因为换行符被保留下来了所以我在printf(%3d | %s, no, line);里没有再加\n否则每行之间会多空一行。还有一个细节判断空行时我用的是line[0] \n。因为fgets遇到一行只有一个回车符时缓冲区里就是\n\0所以判断第一个字符是不是换行符就足够。3.4 别忘了 fclose 这个“挂断电话”动作代码里每次用完文件都调用了fclose(fp)。这一步的价值之前说过刷新缓冲区、释放文件资源。如果你在一个程序里反复打开同一个文件而不关闭前面的文件一直占着资源后续操作可能失败。更现实的问题是某些系统上文件被占用时你从外部打开看是空的别的程序也没法正常访问。挂断电话这个动作永远不要省。4. 文本模式与二进制模式换行符才是分叉口4.1 不同操作系统的换行符差异很多代码在 Windows 上跑和 Linux 上跑表现不一样根源常常是换行符。Linux 和 macOS 如今都用 LF\nWindows 用 CRLF\r\n。在文本模式不加b下C 标准库会在读写时自动做翻译写入时把\n转成系统格式读取时把系统格式转成\n。所以你在 Windows 上写文件实际写入的可能是\r\n但程序里读出来又变回\n。这套翻译对文本文件很友好但如果你处理的是图片、程序文件翻译就是灾难。这也是二进制模式存在的原因rb、wb打开文件后不做任何转换数据是什么就原样读写。文本模式只会处理\n和\r\n的转换二进制模式则连这一步都不做。4.2 二进制模式适合哪些场景文件不是给人看的文本时基本都要用二进制模式。典型包括图片jpg、png、音频、视频压缩包、可执行文件程序自己定义的二进制记录文件比如结构体数组的落盘任何你希望“存进去什么样、读出来就是什么样”的数据。举例来说如果拿文本模式去读一个图片文件一旦碰上0x0A对应\n和0x0D对应\r这种字节系统就会尝试做换行翻译把数据改掉。这会导致读出来的内容和原文件不一致甚至损坏数据。判断标准一句话文本文件用文本模式其他一切文件用二进制模式。4.3 结构体直接落盘指针成员是大忌文件操作里一个很酷的用法是把结构体直接写入文件、再直接读回来。比如保存学生成绩#include stdio.h typedef struct { int id; double score; } Student; int main(void) { Student stu[3] { {1, 88.5}, {2, 91.0}, {3, 76.5} }; FILE *fp fopen(students.dat, wb); if (fp NULL) { perror(fopen for binary write); return 1; } fwrite(stu, sizeof(Student), 3, fp); fclose(fp); fp fopen(students.dat, rb); if (fp NULL) { perror(fopen for binary read); return 1; } Student tmp[3]; fread(tmp, sizeof(Student), 3, fp); fclose(fp); for (int i 0; i 3; i) { printf(id%d, score%.2f\n, tmp[i].id, tmp[i].score); } return 0; }fwrite的参数很直接数据起始地址、每个元素大小、元素个数、文件指针。fread对应读回来。但这个用法有两个前提必须记住结构体里不能有指针成员。指针存的是内存地址这个地址换个程序、换个时间就失效了。你写进去的是一个毫无意义的编号读出来也指向错误的地方二进制文件在不同编译器、不同机器之间的可移植性差。结构体可能有内存对齐和填充字节不同平台布局会有差异。入门阶段在同一个环境里读写自己写的文件没问题但别指望把它拿到别的环境还能原样读出来。5. 文件读到哪算完EOF 与 feof 的经典误解5.1 feof 的返回值是“上一个操作之后”产生的C 语言里判断文件结尾很多人第一反应是feof(fp)。但feof的行为和直觉不太一样feof只在上一次读写操作尝试越过文件末尾之后才会返回非零值。换句话说如果你用fgetc刚刚读到了文件的最后一个字符此时feof(fp)仍然返回 0只有当你再调用一次fgetc发现没有可读内容了、读取失败时EOF 标志才会被设置。5.2 不同读取函数各自的“结束信号”因为不同函数判断结束的方式不一样所以要有“分别对待”的意识读取函数判断结束的正确方式fgets返回NULLfgetc返回EOF即 -1fread返回值小于请求读取的元素个数用fgetc时有一个经典细节返回值必须存成int不能存成char。因为char有时带符号有时不带符号可能产生意想不到的混淆比如读到一个值是 255 的字节被转成 -1误判为 EOF。保险写法int c; while ((c fgetc(fp)) ! EOF) { putchar(c); }5.3 为什么 while(!feof(fp)) 会多跑一圈网上很多旧代码喜欢写while (!feof(fp)) { fgets(buf, sizeof(buf), fp); printf(%s, buf); }这个写法有 bug当fgets已经读完最后一行、文件里没有更多内容时feof还没被触发循环继续进入然后fgets读取失败buf里的内容保持不变最后一次打印相当于把上一行重复打印了一遍。正确写法是在读取的时候就判断返回值while (fgets(buf, sizeof(buf), fp) ! NULL) { printf(%s, buf); }一次读取、一次判断把“读不到了”作为循环结束条件干净利落。记住这个原则能靠返回值判断就绝不依赖feof做循环条件。feof更适合在循环结束后用来区分“读到了末尾”和“读取出错”。6. 在文件里跳着读写fseek、ftell 与 rewind 实战6.1 文件内的位置指示器是怎么移动的每个打开的文件内部都有一个“读写位置指示器”file position indicator它记录下次读写操作从文件的哪个位置开始。每次fread、fgets之后这个位置会自动向后移动。如果你想跳过一部分内容或者想回头修改文件中某个位置的数据就要用到fseek。它的原型是int fseek(FILE *fp, long offset, int whence);offset是偏移量单位是字节whence是基准位置有三个取值SEEK_SET文件开头、SEEK_CUR当前位置、SEEK_END文件末尾。rewind(fp)等价于fseek(fp, 0L, SEEK_SET)把位置挪回文件开头。6.2 获取文件大小二进制模式更靠谱一个很常见的面试题不借助系统函数怎么拿到一个文件的大小C 标准库的答案是fseek配合ftellFILE *fp fopen(data.bin, rb); if (fp NULL) { perror(fopen); return 1; } fseek(fp, 0, SEEK_END); // 跳到文件末尾 long size ftell(fp); // 当前位置就是文件大小字节数 fseek(fp, 0, SEEK_SET); // 跳回开头准备正常读写 printf(文件大小: %ld 字节\n, size); fclose(fp);有一个细节在文本模式下ftell返回的值不一定是真实的字节数因为换行符翻译会让位置指示器的“路程”和真实磁盘字节数不完全一致。所以获取文件体积这种操作最好在二进制模式下做。6.3 覆盖写指定位置的记录在文件里“跳着写”最典型的场景是覆盖某条记录。继续用前面学生结构体的例子把第二条记录的成绩改成 95.0。FILE *fp fopen(students.dat, rb); if (fp NULL) { perror(fopen); return 1; } Student newStu {2, 95.0}; fseek(fp, 1 * sizeof(Student), SEEK_SET); // 跳过第一条记录 fwrite(newStu, sizeof(Student), 1, fp); fclose(fp);注意这里用的是rb不是rb。因为我们要读改文件所以必须带又因为不想破坏原文件内容所以不能用wb它会清空文件。每次感觉“明明写了却没效果”时先回去检查打开模式。7. 初学文件操作最容易踩的坑我帮你先踩了7.1 fopen 返回 NULL 之后的连锁反应文件打开失败的场景比想象中多文件名写错、路径不存在、文件被其他程序占用、权限不足、磁盘已满甚至当前工作目录和你以为的不一样。在这些情况下fopen返回NULL。此时你继续调fgets、fputc、fclose都会操作一个无效指针轻则什么都没发生重则直接崩溃。所以我在前面所有例子里都加了判断这不是啰嗦而是必要的防御fp fopen(note.txt, r); if (fp NULL) { perror(fopen); return 1; }配合perror你能立刻知道失败原因。宁可程序早退也别让它带着错误继续执行。7.2 缓冲区没刷新就急着看文件有些同学写文件后程序还开着就去外部编辑器里翻看文件内容发现是空的于是怀疑写入函数写了个寂寞。其实数据还在缓冲区没落到磁盘。如果你需要“写入一点立刻让外部程序看到”在写入后调用fflush(fp)。如果你是在同一个程序里写完再读记得先fclose或者用freopen、rewind之类的方式确保写和读之间文件状态是闭合的而不是让缓冲区“卡”在中间。7.3 路径问题相对路径指的是工作目录刚学文件操作时最容易踩的另一个坑是路径。相对路径不是相对于“可执行文件所在位置”而是相对于当前工作目录。在命令行里运行程序、在 IDE 里按运行按钮、双击 exe这三种方式的工作目录很可能都不是同一个位置。所以你在 IDE 里创建的data.txt明明就在工程目录下程序却可能找不到。排查方法很简单在程序里打印一下当前工作目录心里就有底了。#include stdio.h int main(void) { char cwd[512]; if (getcwd(cwd, sizeof(cwd)) ! NULL) { printf(当前工作目录: %s\n, cwd); } return 0; }另外在 Windows 上写绝对路径时要小心反斜杠转义C:\data\file.txt在 C 字符串里要把每个反斜杠写成两个比如C:\\data\\file.txt或者直接用正斜杠/很多系统也认。7.4 fgets 读超长行时的“半行”问题fgets(buf, size, fp)最多读入size - 1个字符。如果文件里某一行特别长超过了缓冲区大小fgets会先读前半段返回一个不带换行符的字符串下一次调用继续读同一行剩余的部分。这会导致一个奇怪的现象明明是一行数据打印出来却分成两行甚至更多而且有些行还看不到换行符。解决方案有三条路把缓冲区设大覆盖预期中最长的一行如果一行可能极长就结合读到的长度判断如果buf[strlen(buf) - 1]不是\n说明没读完一行继续用fgets拼接处理换用getlinePOSIX 扩展非 C 标准让它自动分配缓冲区。不过这个函数不能完全跨平台入门阶段不建议依赖它。我建议初学就选第一个方案定义一个足够大的LINE_MAX比如 4096日常练习完全够用。等以后真要做日志解析、大文件处理再考虑动态方案。最后补一个可供反复练习的方向读完这些内容你可以试着做一个“记账小工具”启动时从文件读取历史账单并显示运行中可以追加新的收入或支出记录退出时把所有记录写回文件。这个练习会把结构体、数组、fprintf、fgets、二进制或文本写入、文件存在性判断全部串在一起比照着书抄代码有用得多。文件操作在 C 语言里看着函数不多但每个函数都有它的脾气。缓冲区什么时候刷盘、EOF 什么时候置位、文本模式和二进制模式差在哪、fopen的模式选错会带来什么后果这些细节单个拎出来都不难凑在一起就足够让人熬几晚。我写这篇就是想把它们摊开讲明白让你少走一点我当年走过的弯路。等你亲自跑通第一个“写入-关闭-再读出来”的循环你会发现文件操作并没有想象的那么神秘它只是给程序开了另一扇门。