
写C语言的文件读取与写入操作是所有靠C吃饭的人迟早都要啃的一块硬骨头。很多教程把fopen、fclose、fread、fprintf拆开讲得很漂亮可一旦到了真实工程里你会碰到缓冲区没落盘、权限不足、文本乱码、feof多读一行这些文档里不肯明说的事。这篇文章我按自己实际开发时踩坑的顺序把文件操作从底层原理到常用API再到排查思路完整过一遍。适合正在学C语言基础的朋友也适合想回头把文件这一块补扎实的嵌入式开发者。1. 文件在C语言里的真实身份流、缓冲区与打开模式1.1 文件指针FILE*背后到底存了什么很多人初学C语言时会误以为FILE *fp里的fp指向的是磁盘上的一个文件。实际上不是。C标准库把键盘、屏幕、磁盘文件、网络socket这类输入输出设备全部抽象成“流”FILE结构体保存的是这个流的状态信息当前读写位置、缓冲区指针、错误标志、文件结束标志、底层文件描述符等。这就解释了一个很常见的现象你调用fwrite往fp里写数据数据并没有立刻跑到磁盘上而是先进了缓冲区。缓冲区属于FILE结构体管理的内存区域之后由库函数选择合适时机把内容真正交给操作系统。理解这一层后面遇到的“文件内容丢了”“日志没实时写进去”之类的问题就都好解释了。FILE结构体在stdio.h里通常是指向一个不透明类型的指针你不需要知道它内部每个字段的布局但要记住它是一个句柄不是文件本身。这跟socket、mutex这类资源的思路完全一致。文件打开的瞬间操作系统会维护文件当前状态FILE结构体相当于你在用户态的一个遥控器。1.2 缓冲区机制与三种缓冲策略C标准库默认的缓冲策略和你的文件类型有关。普通磁盘文件走全缓冲意思是要攒够一批数据才真正调用系统函数写入这样可以减少IO次数。终端设备走行缓冲遇到换行符就输出。标准错误stderr不走缓冲就是为了让错误信息能立刻显示出来。缓冲区大小通常是512字节或4096字节具体由库决定。不过什么时候写盘不完全是库决定的还叠加了操作系统自己的页缓存。所以fwrite成功返回只表示数据进了用户态缓冲区不代表进了磁盘。如果此时程序异常结束这部分数据可能就丢了。想调整缓冲策略可以使用setvbuf函数。比如你写日志文件希望实时落盘可以使用无缓冲setvbuf(fp, NULL, _IONBF, 0);也可以使用行缓冲setvbuf(fp, NULL, _IOLBF, 0);这三种模式分别是全缓冲_IOFBF、行缓冲_IOLBF、无缓冲_IONBF。阶段性工作需要实时写盘但又不至于每条都调fflush行缓冲是个不错的折中。不过行缓冲要求每写一条日志都带\n否则还是没有实际效果。1.3 文本模式与二进制模式差异fopen时的模式字符串里有个b例如rb、wb。这个b不是可选项它代表二进制模式。不带b就属于文本模式。在Linux上这两种模式几乎没区别。但在Windows上区别非常大Windows换行符是两个字节\r\n文本模式下写\n系统会自动变成\r\n写入磁盘读的时候反向转换\r\n变成\n。这个设计本意是方便代码跨平台处理文本实际项目中反而经常踩坑。比如你在Windows文本模式下fseek到一个固定偏移或者用ftell计算文件大小得到的数字可能跟文件真实字节数不一致因为回车换行转换会让长度对不上。所以跨平台传输文件统一用二进制模式更可控。如果你要操作的是真正的人类可读文本文件在Windows上建议明确使用文本模式让库函数处理换行转换如果做的是协议数据、数据库文件、结构体快照这类内容必须用二进制模式。Linux下无所谓但开发习惯上我仍然建议保持一致。2. fopen的打开方式与文件关闭的那些事2.1 打开模式是文件操作的“第一道阀门”fopen的第一个参数是路径第二个参数是模式字符串。模式的选择决定了接下来能读还是能写、文件指针从哪里开始、文件不存在时会不会创建、已有文件会不会被清空。常见模式如下模式可读可写文件不存在文件已存在r只读失败从头读w只写创建清空内容a只写创建追加到末尾r读和写失败从头读写w读和写创建清空内容a读和写创建追加到末尾读可以从头这里最危险的是w和w。一旦打开成功文件原有内容立刻被清空没有任何确认环节。我见过有人本来想往配置里追加数据结果用了w整个配置文件变成0字节这种事故非常难抢救。写文件前一定要确认模式尤其涉及对外配置、用户数据时谨慎使用w系模式。a和a也有容易忽略的细节。追加模式下所有写操作都强制发生在文件末尾即使你先调fseek把位置移到前面写的时候系统还是会写到最后。这一点在生成日志、流水记录时很合适但如果想修改文件中间某个字段追加模式完全做不到。r允许读写但不会自动创建文件也不会清空文件。写入的位置从当前指针开始写之前通常要考虑是否需要先定位。很多新手第一次用r以为会在文件里插入内容实际是覆盖因为write操作不做中间插入。2.2 相对路径与绝对路径的选择fopen的路径参数里Windows下最常见的坑是反斜杠转义。C:\temp\data.txt在C字符串里会把\t当成制表符路径彻底变样。至少要写成C:\\temp\\data.txt或者干脆用正斜杠C:/temp/data.txtWindows API同样能认识。Linux下路径没有盘符/开头是绝对路径./和../是相对路径。C语言进程的工作目录通常和执行程序时所在的目录一致但如果你用systemd、nginx这类服务拉起进程工作目录可能完全不是你想的那样。稳妥做法是程序启动时通过getcwd打印当前目录或者使用绝对路径拼接配置。相对路径有个好处可移植性强工程目录挪到哪里都能跑。绝对路径在服务器部署环境中定位更稳。如果项目需要同时兼容Windows和Linux我习惯用相对路径统一约定再在启动日志里打印当前路径方便排查“找不到文件”之类的问题。2.3 目录无法写入与权限问题的排查套路很多开发者在Windows上遇到“输入的路径目录无法写入文件”这种报错第一反应是代码问题实际上大部分是目录权限或路径根本不存在。Windows下Program Files目录、系统盘根目录普通用户默认不能直接创建文件杀毒软件也可能拦截。Linux下需要检查目录写权限尤其是以普通用户运行的程序试图写root目录。排查顺序可以固定下来。首先确认目录是否存在用access(path, F_OK)检查然后确认是否有写权限用access(path, W_OK)或直接看文件系统权限属性接着确认文件是否被其他进程占用Windows下文件被另一个程序以独占方式打开时fopen会返回NULL最后再看磁盘空间是否满了。错误处理是文件操作最容易偷懒的地方。很多教程代码只写if(fp NULL) return;这等于告诉用户“出错了但我不知道原因”。正确姿势是FILE *fp fopen(path, r); if (fp NULL) { perror(open file failed); exit(EXIT_FAILURE); }perror会把你刚才打开失败的系统错误原因打印出来比如No such file or directory、Permission denied。这一步省掉后面所有排查都会变成猜谜。2.4 fclose不只是收尾返回值与缓冲区落盘fclose做两件事把缓冲区里还没写的剩余数据刷新到操作系统然后释放FILE结构体和底层文件描述符。如果不调用fclose大多数情况下程序退出时系统会回收资源缓冲数据也能保住但这不等于你可以不关。长期运行的后台进程如果反复打开文件但忘记关闭文件描述符会泄漏最终导致无法再打开新文件或者某些文件在Windows下被锁定删除不了。还有一个容易被忽略的点fclose也是有返回值的成功返回0失败返回EOF。写入过程中如果磁盘空间满了、磁盘IO报错错误可能在fclose时才浮出水面。所以严谨的代码应该检查fclose返回值if (fclose(fp) EOF) { perror(fclose failed); }不过实际项目里fclose返回值检查确实不如fopen重要因为即便你知道了失败也多半来不及恢复。至少你要知道fclose失败说明数据完整落盘这一点存疑。3. 文本文件读取与写入实战3.1 逐字符复制fgetc与fputcfgetc从流中读取一个字符返回类型是int而不是char这是C语言里一个经典设计。因为文件结束标志EOF是-1而char可能是有符号也可能是无符号无符号char的取值范围到255根本无法表示-1只能用int来容纳所有可能的字符值和EOF。一个简单的文件复制函数可以这样写int copy_file(const char *src, const char *dst) { FILE *in fopen(src, rb); if (in NULL) { perror(open source file); return -1; } FILE *out fopen(dst, wb); if (out NULL) { perror(open destination file); fclose(in); return -1; } int ch; while ((ch fgetc(in)) ! EOF) { fputc(ch, out); } fclose(out); fclose(in); return 0; }这段代码有几个细节值得说。第一打开目标文件失败时必须把已经打开的输入文件关闭再返回否则就是资源泄漏。第二循环条件判断的是fgetc的返回值是否等于EOF不要写成while (!feof(in))后面会细说。第三复制字节流使用二进制模式避免文本模式下换行符被转换导致源文件和目标文件字节不完全一致。逐字符复制在功能上没问题但性能一般。fgetc每次调用都有函数调用开销和缓冲区判断逻辑对于大文件可以考虑用fread整块读、fwrite整块写性能能提升不少。3.2 按行读取fgets与fputs的正确姿势处理文本文件时一行一行读取是最自然的思路。fgets的签名char *fgets(char *s, int size, FILE *stream);它最多读入size-1个字符并在末尾补\0。如果这一行在size-1范围内正常结束会保留末尾的换行符\n。如果行太长缓冲区不够那这次只读一部分剩余内容还留在文件流里下次fgets继续读。千万不要以为fgets一次一定能读一整行写解析逻辑时务必考虑这种半截行的情况。读配置文件的典型写法char line[256]; while (fgets(line, sizeof(line), fp) ! NULL) { line[strcspn(line, \n)] \0; if (line[0] # || line[0] \0) { continue; } char key[64] {0}; char value[128] {0}; if (sscanf(line, %63s %127s, key, value) 2) { printf(%s - %s\n, key, value); } }这里用strcspn(line, \n)把换行符去掉比手动写循环找\n更简洁。还要注意如果最后一个字符不是换行符文件末尾没有空行strcspn也能正确工作因为strcspn会找到\0的位置把\0前那个位置赋值为\0并没有实际影响。fputs和fgets配对使用它把一个字符串写入文件不会自动添加换行符。所以fputs(hello\n, fp);需要自己带\n很多人把fputs当成println用写进去的内容全部连成一行排查半天才发现是这里少了换行符。3.3 格式化读写fprintf与fscanf以及更稳的组合拳fprintf和fscanf是printf、scanf的文件版本。写入时非常方便fprintf(fp, %d %s %.2lf\n, stu.id, stu.name, stu.score);读取时使用fscanfif (fscanf(fp, %d %s %lf, stu.id, stu.name, stu.score) 3) { // 成功读取三个字段 }fscanf返回成功赋值的参数个数这里要求等于3。如果文件里有一行数据格式不对比如某个成绩写成了abcfscanf会在第一个%lf处失败返回2或者更少。这时文件指针停留在出错位置后面所有读取都会卡住。我把fscanf读文件的代码统称为“不健壮的快乐”因为文件内容一旦格式混乱程序就进入了很难恢复的状态。而真实世界中文件是会被手动编辑、被其他工具生成、被网络传输损坏的。更稳的套路是fgets按行读取再用sscanf解析这一行char line[256]; while (fgets(line, sizeof(line), fp) ! NULL) { if (sscanf(line, %d %63s %lf, id, name, score) 3) { // 处理这一行 } else { fprintf(stderr, bad line: %s, line); } }这种写法最大的优点是一行解析失败不会影响下一行程序能打印出具体哪一行有问题然后继续处理剩余内容。对于成绩表、账单、配置文件这类逐行格式我强烈建议用fgets加sscanf而不是直接fscanf。4. 二进制文件的读取与写入4.1 为什么需要二进制精度、速度与体积文本文件的好处是能直接阅读和编辑但代价也很明显。浮点数用%f打印成文本再读回来哪怕格式保留足够多的小数位数也可能存在误差。而二进制文件直接保存浮点数的IEEE 754字节表示读回来就是原来的二进制序列精度无损。速度上也差很多。假设一个结构体占用64字节写1万条记录文本模式还要做格式化转换二进制模式直接连续内存拷贝时间和CPU开销低得多。体积方面数字文本通常比二进制数据更占空间一个double在文本格式下可能要20个字符二进制固定8字节。嵌入式开发里采集的传感器数据、配置文件中的校准参数、固件升级包很多都采用二进制格式。因为运行环境和存储空间都紧张每一点开销都值得计较。4.2 fread/fwrite参数详解fwrite的签名size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);size是每个元素的大小nmemb是要写的元素个数。它返回实际写入的元素个数正常情况下应该等于nmemb。fread同样道理。写一个结构体数组的例子typedef struct { int id; char name[64]; double score; } Student; Student students[100]; size_t n fwrite(students, sizeof(Student), 100, fp); if (n ! 100) { perror(fwrite failed); }这里第二个参数是单个结构体大小第三个参数是元素个数。分开写的好处是fwrite返回值告诉你完整写成功了多少个元素如果想检查写入字节数是否为sizeof(Student) * 100需要自己做乘法。读取时对称使用Student stu[100]; size_t m fread(stu, sizeof(Student), 100, fp); if (m 100) { if (feof(fp)) { // 文件结束m是实际读到的完整元素个数 } else if (ferror(fp)) { // 发生了IO错误 } }注意fread只保证读入m个完整元素剩下的部分可能存在半个元素数据文件被截断不要想当然用m去遍历时读取半段结构体字段这种内存里已经有值但字段不完整的情况比直接崩溃还难排查。如果希望直接定位到文件末尾再算元素个数可以使用fseek(fp, 0, SEEK_END); long size ftell(fp); long count size / sizeof(Student); rewind(fp);但这个方法对动态写入过程中的文件不适用文件可能正在被追加size会变。另外文本模式下不要这样算大小。4.3 含指针的结构体为什么不能直接落盘fwrite写结构体本质是把内存中的一段字节原样拷贝到文件。如果结构体里有指针比如typedef struct { int id; char *name; double score; } Student;那么fwrite会把name这个指针变量本身存进去而不是指针指向的字符串内容。程序再启动时这个指针值可能指向一个已经无效的内存地址或者恰好指向了别的东西直接访问会崩溃甚至读到脏数据。解决办法是使用固定长度数组代替指针或者分别处理先写id和score再把name字符串的长度和内容依次写入。字符串字段用定长数组char name[64]虽然浪费一点空间但换来的是结构体可以直接安全落盘。这在嵌入式场景中尤其常见因为很多协议都是定长字段方便定位和校验。另外结构体存在对齐padding。不同编译器、不同平台下sizeof(Student)可能不同比如结构体里有个char和一个intchar后面可能会被填充3个字节对齐到4字节边界。同一个程序在不同设备上生成的二进制文件可能不兼容。如果二进制文件要跨平台使用最好把每个字段分开按固定字节序写入或者用#pragma pack(1)取消对齐但pack会影响访问速度只适合明确要按字节打包的场景。5. 文件指针定位fseek、ftell与rewind5.1 fseek三锚点与典型用途文件打开时文件指针的位置由模式决定。r和w从文件头开始a在末尾。fseek可以改变读写位置签名int fseek(FILE *stream, long offset, int whence);whence有三个选项SEEK_SET表示从文件头开始计算偏移SEEK_CUR表示从当前位置开始SEEK_END表示从文件末尾开始。offset单位是字节。一个常见场景是需要更新文件中某条记录。比如成绩文件里第5个学生的成绩要改可以先根据记录大小算出偏移Student stu; fseek(fp, 4 * sizeof(Student), SEEK_SET); fread(stu, sizeof(Student), 1, fp); stu.score 99.5; fseek(fp, 4 * sizeof(Student), SEEK_SET); fwrite(stu, sizeof(Student), 1, fp);这个操作必须先fread把记录读出来修改后再fseek到同一个位置写回。不写回的话你改的只是内存里的副本。还有个细节读完后文件指针已经前进了sizeof(Student)所以写之前必须再定位一次这个漏掉的人非常多。fseek还常用于获取文件大小前面说过fseek(fp, 0, SEEK_END); long filesize ftell(fp);不过在文本模式下Windows的\r\n转换会让ftell返回的值不等于真实字节数所以跨平台文件大小检测建议用二进制模式。5.2 ftell获取偏移量与长文件隐患ftell返回当前文件指针相对于文件头的偏移量成功返回非负值失败返回-1L。配合fseek用时注意它返回的是long类型。在Linux 64位系统上long是64位基本够用但在Windows上long是32位处理超过2GB的文件时ftell和fseek直接用会溢出得到负数或错误位置。类似的长度敏感问题操作二进制数据时也要小心。实际项目中处理超过2GB的大文件建议不要依赖标准库fseek/ftell可以使用平台相关接口比如POSIX的fseeko/ftello或者Windows的_fseeki64/_ftelli64。但它们不属于标准C代码可移植性会变差这是工程权衡的问题。5.3 rewind与清除错误状态rewind把文件指针移到文件头同时清除流上的错误标志和EOF标志。它的功能可以看作fseek(fp, 0, SEEK_SET); clearerr(fp);fseek本身不会清除EOF标志所以很多时候你fopen一个文件读到末尾如果不clearerr即使fseek回了文件头再次读取时feof仍可能返回非0。rewind一步到位解决了这个问题。使用时需要注意rewind没有返回值也无法判断是否出错如果你很在意移动失败还是用fseek加clearerr更可控。6. 常见问题排查从EOF误读到缓冲区丢失6.1 千万别用while(!feof(fp))读文件这是C语言社区最经典的文件读取错误。feof并不是用来预判下一次读取是否会到文件末尾它只表示“已经发生过一次读取操作并且那次读取跨过了文件末尾”。所以用while (!feof(fp))循环时循环体通常会多执行一次最后一次读到的数据是无效的。例如// 错误示范 while (!feof(fp)) { fgets(line, sizeof(line), fp); printf(%s, line); }假设文件有3行第一次循环读第1行第二次读第2行第三次读第3行此时fgets已经触发了EOF标志但循环条件结束前第四次循环仍会进入此时fgets返回NULL但line里还残留着第3行的内容结果多打印一次。正确的通用做法是让读取函数本身作为循环条件while (fgets(line, sizeof(line), fp) ! NULL) { printf(%s, line); }对于fread则判断返回值是否小于期望个数再结合feof和ferror区分是由于读到结尾还是出错。6.2 缓冲区未刷新导致日志丢失我调试嵌入式采集程序时遇到过一个问题程序每秒钟往日志文件里写一条数据运行期间一切正常但一断电最后几十条日志全没了。查了半天原因就是fwrite后数据还在用户态缓冲区还没来得及刷到磁盘。程序正常关闭时操作系统会清理但突然掉电或进程被强杀缓冲区里的数据就彻底丢了。解决思路有三个。如果每条日志都很关键可以让日志文件无缓冲setvbuf(fp, NULL, _IONBF, 0);也可以在关键写入后主动fflushfprintf(fp, %s\n, msg); fflush(fp);如果写入频率高又不想每次刷可以设置行缓冲只要每条日志都有换行符系统会在出现\n时自动刷新。对串口日志、服务器访问日志这类场景这些技巧能省很多麻烦。6.3 权限、路径与并发写入冲突写入失败时最常见的就是路径不存在。程序运行目录和你以为的目录可能不一样。检查方法很简单在程序开始时打印当前工作目录char cwd[256]; if (getcwd(cwd, sizeof(cwd)) ! NULL) { printf(cwd: %s\n, cwd); }接下来看目录写权限。Linux下用ls -l看目录的rwx权限Windows下看文件夹属性是否“只读”以及当前用户是否有该目录的写权限。如果文件正被Excel、编辑器或其他程序打开独占锁fopen一样可能失败。多个进程同时写同一个文件是另一个大坑。两个进程都用a模式追加日志系统调用层面理论上每次write是原子性的但用户态缓冲区不保证。进程A的fprintf尚未flush进程B已经打开文件并写入可能造成交错错乱。更实际的是两个进程同时以r或w打开同一个文件必然互相干扰。解决办法是引入文件锁Linux下用fcntl或flockWindows下有LockFileEx或者退一步用单个日志进程统一写文件其他进程通过队列把消息发给它。6.4 中文乱码与换行符问题C语言标准从始至终不管文件编码。Windows中文系统默认文本编码通常是GBKLinux默认UTF-8。你用Windows记事本写了个UTF-8文件拿到Linux下用fgets读取并打印在UTF-8终端上表现正常反之Linux下的UTF-8文件用英文版Windows记事本打开可能是乱码。这个问题和C代码本身无关但确实是文件读写中最常遇到的用户投诉。处理思路是项目里明确文件编码优先UTF-8在文件头部写BOM或单独存编码元信息。解析文件时按约定的编码读入字节再转成内部编码。C语言里如果想跨平台处理中文建议统一使用UTF-8字符串程序内部不做转换只在显示层让终端或GUI识别UTF-8。换行符方面文本模式下Windows自动转换Linux和macOS都使用\n旧版macOS使用\r。如果二进制模式读取从Windows来的文本文件你会看到每行末尾多出来的\r解析时记得去掉。6.5 常见错误速查表症状可能原因常用排查手段文件内容丢失或为空fopen用了w导致清空、没有fflush/fclose打印打开模式检查fclose返回值读取时多最后一行while(!feof(fp))误用改用读取函数返回值作为循环条件写入中文后乱码编码不一致统一UTF-8文件头加BOM或约定编码fwrite返回值正确但文件打不开权限不足、路径不存在、被占用用perror打印原因检查目录权限结构体文件读回后字段错乱结构体对齐padding、跨平台差异分开写字段或#pragma pack(1)程序退出前日志缺失缓冲区未刷新调用fflush或setvbuf设置无缓冲文件指针位置不对fread/fwrite后忘了重新fseek读写之间显式定位7. 完整案例简易成绩系统与日志记录7.1 需求与文件设计这个案例来自C语言课程里非常典型的习题也接近实际工作中配置文件加业务数据的写法。需求很简单程序支持往学生列表里添加学生、显示所有学生、保存到文件、从文件加载、并把每次操作用追加日志记录下来。我设计两个文件。scores.txt保存学生数据一行一个学生格式id name scorelog.txt以追加模式写入操作日志每行一条记录。用文本格式而不是二进制是为了方便初学者直接打开文件检查写入是否正确。7.2 可复现的完整代码#include stdio.h #include stdlib.h #include string.h #define MAX_NAME 64 #define MAX_STUDENTS 128 typedef struct { int id; char name[MAX_NAME]; double score; } Student; void write_log(const char *msg) { FILE *fp fopen(log.txt, a); if (fp NULL) { perror(open log file); return; } fprintf(fp, [%ld] %s\n, (long)time(NULL), msg); fclose(fp); } int save_students(const char *path, const Student *stu, int n) { FILE *fp fopen(path, w); if (fp NULL) { perror(open scores file); return -1; } for (int i 0; i n; i) { fprintf(fp, %d %s %.2lf\n, stu[i].id, stu[i].name, stu[i].score); } if (fclose(fp) EOF) { perror(close scores file); return -1; } return 0; } int load_students(const char *path, Student *stu, int maxn) { FILE *fp fopen(path, r); if (fp NULL) { perror(open scores file); return 0; } int count 0; char line[256]; while (count maxn fgets(line, sizeof(line), fp) ! NULL) { line[strcspn(line, \n)] \0; if (line[0] \0) { continue; } if (sscanf(line, %d %63s %lf, stu[count].id, stu[count].name, stu[count].score) 3) { count; } else { fprintf(stderr, bad line: %s\n, line); } } fclose(fp); return count; } void print_students(const Student *stu, int n) { for (int i 0; i n; i) { printf(%d %s %.2lf\n, stu[i].id, stu[i].name, stu[i].score); } } int main(void) { Student students[MAX_STUDENTS]; int count 0; count load_students(scores.txt, students, MAX_STUDENTS); printf(loaded %d students\n, count); write_log(program started); int choice; while (1) { printf(\n1. add student\n); printf(2. show students\n); printf(3. save and exit\n); printf(choose: ); if (scanf(%d, choice) ! 1) { break; } while (getchar() ! \n); if (choice 1) { if (count MAX_STUDENTS) { printf(capacity full\n); continue; } Student *s students[count]; printf(enter id: ); scanf(%d, s-id); printf(enter name: ); scanf(%63s, s-name); printf(enter score: ); scanf(%lf, s-score); while (getchar() ! \n); count; write_log(added a student); } else if (choice 2) { print_students(students, count); } else if (choice 3) { if (save_students(scores.txt, students, count) 0) { write_log(saved and exited); } break; } else { printf(invalid choice\n); } } return 0; }这个例子用到了fopen的多个模式w保存、r加载、a追加日志。也覆盖了fgets加sscanf解析行、fprintf格式化写入、strcspn去除换行符。其中日志文件每写一条都fclose一次虽然开销大但保证日志不会丢适合小型工具。如果日志量很大应改成打开一次写入后fflush即可不必每次关闭。运行效果大约如下no scores.txt, loaded 0 students [timestamp] program started 1. add student 2. show students 3. save and exit choose: 1 enter id: 1 enter name: Alice enter score: 92.5再次启动程序会从score.txt读出之前保存的学生数据日志文件继续追加。7.3 还能怎么扩展如果认真做这个案例有非常多扩展方向。例如把接口改成流式处理支持学生数量动态增长把解析功能封装成函数最终给读者比较正规的文件读写框架讲解或者从简单的文本存储升级到结构化格式比如JSON、CSV甚至用SQLite代替手工文件。C语言文件操作本身就是一套完整的小型工程能力。把文件当作一种对外接口设计好格式、错误处理、并发控制整个程序的健壮性会提升一个台阶。写代码时顺手检查每个fopen、每次写入的返回值不是固执而是对数据和用户负责的一种习惯。我在实际项目中吃过不少亏缓冲区没刷新导致日志丢了结构体带指针直接落盘导致二次加载崩溃路径判断失误导致配置文件被清空。文件操作本身不难难的是在任何意外情况下都能保持正确。这也是我为什么建议每个学C语言的人都老老实实把文件这块练扎实。你用它做的第一个小工具可能只是存成绩、记日志但以后写采集程序、做配置管理、搭嵌入式设备这套思路全都能复用。