写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.txt",Windows 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与fputc
fgetc从流中读取一个字符,返回类型是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);读取时使用fscanf:
if (fscanf(fp, "%d %s %lf", &stu.id, stu.name, &stu.score) == 3) { // 成功读取三个字段 }fscanf返回成功赋值的参数个数,这里要求等于3。如果文件里有一行数据格式不对,比如某个成绩写成了abc,fscanf会在第一个%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和一个int,char后面可能会被填充3个字节对齐到4字节边界。同一个程序在不同设备上生成的二进制文件可能不兼容。如果二进制文件要跨平台使用,最好把每个字段分开按固定字节序写入,或者用#pragma pack(1)取消对齐,但pack会影响访问速度,只适合明确要按字节打包的场景。
5. 文件指针定位:fseek、ftell与rewind
5.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);也可以在关键写入后主动fflush:
fprintf(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或flock,Windows下有LockFileEx,或者退一步用单个日志进程统一写文件,其他进程通过队列把消息发给它。
6.4 中文乱码与换行符问题
C语言标准从始至终不管文件编码。Windows中文系统默认文本编码通常是GBK,Linux默认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语言的人,都老老实实把文件这块练扎实。你用它做的第一个小工具可能只是存成绩、记日志,但以后写采集程序、做配置管理、搭嵌入式设备,这套思路全都能复用。