简介:这份面向C语言初学者的文件读写入门文档,围绕文件指针、标准库函数调用和文本文件处理流程展开,帮助刚接触持久化存储的读者理解打开、写入、读取与关闭文件的基本方法,并建立对返回值和错误检查的初步认识。压缩包内共1个doc文件,整体约14KB,内容以概念说明配合完整示例代码为主,版面轻量,便于快速浏览、课堂补充和对照练习。目前已有104人学习,适合自学入门、实验课前预习或复习基础接口时查阅。文档通过写入与读取两个示例,把FILE指针定义、文件模式选择、fprintf格式化输出、fgets缓冲区读取以及循环判断等环节串联起来,读者可据此观察程序结构、理解常见参数含义,并尝试修改文件名、写入内容或补充异常处理,逐步形成可复用的文件操作思路,对后续学习二进制文件与文件定位也有铺垫作用。
1. 从一次「文件写进去了,读出来却是空的」说起
有个 A 同学把课程设计里的学生成绩直接fprintf到data.txt,编译运行都过了,屏幕上还打印了「写入完成」,结果第二天打开文件一看,里面干干净净。查了半天才发现,程序在fclose之前被一个return提前退出了,缓冲区里的数据根本没落盘。这类问题不属于语法错误,编译器不会提醒你,但它是 C 语言 文件读写 里最常见的一类翻车。
这份资源给的就是最朴素的一套东西:fopen打开、fprintf/fgets读写、fclose关闭,两个完整的示例程序,写一个文件再读回来。它不涉及任何第三方库,纯<stdio.h>,能直接gcc编译运行。适合刚学完指针和数组、要交课程设计、或者第一次需要把程序结果持久化到磁盘上的新手。下面把它拆开:先讲清楚FILE和打开模式的行为边界,再把写和读分别讲透,最后落到五个真实踩过的坑和一套自检清单。
2. 先把 FILE 指针和 fopen 模式吃透:六种打开方式的行为差异
很多人写文件读写的第一个误区,是把FILE *当成「文件本身」。它其实只是标准库给你的一张凭据,真正干活的是它背后那套缓冲机制。模式选错、缓冲区没刷,后面写再多代码都是白搭。
2.1 FILE 不是文件:三层结构决定了数据什么时候落盘
调用fopen之后,标准库会在堆上分配一个FILE结构体,里面装着三样关键东西:一块缓冲区(通常是 4KB 或 8KB)、一个读写位置指示器、一个底层文件描述符。你的fprintf调用并不是直接写磁盘,而是先往这块缓冲区里塞字符,塞满了才触发一次系统调用,把整块数据交给内核。这就是为什么程序崩溃时数据会丢——缓冲区还在用户态内存里,进程一死就没了。
从这个结构能推出两个实操结论。第一,fclose不只是「关一下文件」,它内部会先做一次fflush,把残留缓冲区刷出去,所以任何路径退出前都必须走到fclose。第二,FILE *是标准库管理的资源,不要自己free,也不要把栈上的局部变量强行转成FILE *传出去,那是野指针。
提示:
stdin、stdout、stderr是三个预先打开好的FILE *,其中stderr是无缓冲的,所以perror和fprintf(stderr, ...)的输出永远比printf先出现,排错时优先用它。
2.2 fopen 六种模式的对照表:加不加 + 到底差在哪
资源里的示例只用了"w"和"r",但实际项目里模式选错导致数据被清空的事故太多了。下面这张表是必须背下来的:
| 模式 | 文件不存在 | 文件已存在 | 起始位置 | 是否清空原内容 |
|---|---|---|---|---|
"r" | 返回 NULL | 正常打开 | 开头 | 否 |
"w" | 创建新文件 | 打开 | 开头 | 是,截断为 0 字节 |
"a" | 创建新文件 | 打开 | 末尾 | 否 |
"r+" | 返回 NULL | 正常打开 | 开头 | 否 |
"w+" | 创建新文件 | 打开 | 开头 | 是,截断为 0 字节 |
"a+" | 创建新文件 | 打开 | 读在开头、写在末尾 | 否 |
最容易出问题的是"w"。它只要打开成功就立刻把文件长度截成 0,不管你后面写不写。有人为了「先打开看看文件里有什么」用了"w",结果原数据当场没了。要读用"r",要追加用"a",要既读又写且保留原内容用"r+",只有确定要覆盖重写时才用"w"。
模式字符串后面还可以加b,比如"rb"、"wb"。在 Linux 上b是空操作,但在 Windows 上它会关掉换行符转换:文本模式下写\n会被自动变成\r\n,读的时候又把\r\n还原成\n。一旦你把同一个文件在两种平台间来回拷,这个转换就会让文件长度对不上,二进制文件(结构体、图片)必须带b。
2.3 把 fopen 包一层:让失败信息能直接定位问题
fopen失败只返回NULL,光打印一句「无法打开文件」等于没排错。标准库留了errno这个全局错误码,配合strerror能翻译成人话。我一般会先写一个小的打开封装,后面所有示例都用它:
#include <stdio.h> #include <errno.h> #include <string.h> /* 打开文件,失败时把路径、模式和具体错误原因一起打到 stderr */ FILE *open_or_report(const char *path, const char *mode) { FILE *fp = fopen(path, mode); if (fp == NULL) { /* strerror(errno) 会把 ENOENT、EACCES 这类错误码翻译成可读文本 */ fprintf(stderr, "fopen(%s, \"%s\") 失败: %s\n", path, mode, strerror(errno)); return NULL; } return fp; }三个参数各自的作用:path是文件路径,mode是上面表格里的模式串,返回值要么是有效FILE *,要么是NULL。errno是<errno.h>里的宏,fopen失败时会被置成具体值,常见的几个是ENOENT(路径不存在)、EACCES(权限不足)、EMFILE(进程打开的文件句柄超限)。strerror接收错误码返回静态字符串,不要对它返回的指针做修改或free。
这里还有一个容易被忽略的点:errno只在函数失败的那一刻有意义,如果你在fopen和打印之间又调用了别的库函数,errno可能已经被覆盖。所以要么立刻打印,要么先把errno存到一个int变量里再处理。
路径本身也是坑源。写"example.txt"是相对路径,相对于进程的当前工作目录,不是相对于源文件所在目录。在 IDE 里点运行和在终端里./a.out,工作目录可能完全不同,于是同一份代码在一处能跑、另一处报ENOENT。新手阶段我的建议是先用绝对路径确认逻辑通不通,再改成相对路径,并且把getcwd打印出来看一眼。
3. 写文件:fprintf、fputs、fwrite 的适用边界与返回值
写入这块,资源里只演示了fprintf,但实际场景里文本和二进制是两条完全不同的路线。选错路线,轻则文件里多出一堆看不懂的字符,重则换台机器就读不出来。
3.1 fprintf:格式化写入,以及必须检查的返回值
fprintf的用法和printf几乎一样,只是第一个参数多了个FILE *。它适合写人类可读的文本,比如 CSV、日志、配置。下面这段把三个学生的成绩写成 CSV,同时演示返回值检查:
#include <stdio.h> int main(void) { FILE *fp = fopen("scores.csv", "w"); if (fp == NULL) { perror("fopen"); return 1; } const char *names[] = {"A同学", "B同学", "C同学"}; int scores[] = {88, 92, 76}; for (int i = 0; i < 3; i++) { /* fprintf 返回实际写入的字符数,负数表示出错(比如磁盘满) */ if (fprintf(fp, "%s,%d\n", names[i], scores[i]) < 0) { perror("fprintf"); fclose(fp); /* 出错也要关,否则句柄泄漏 */ return 1; } } /* fclose 自身也可能失败,缓冲区刷盘失败时它会返回 EOF */ if (fclose(fp) != 0) { perror("fclose"); return 1; } return 0; }逐个参数说:第一个参数是打开好的FILE *;第二个是格式串,%s对应字符串、%d对应 int、%f对应 double;后面的可变参数必须和格式符一一对应,类型不匹配是未定义行为,printf系列不会帮你检查。返回值是写入的字符数,写失败返回负值。
fclose的返回值经常被无视。它返回 0 表示成功,返回EOF表示刷盘或关闭失败。磁盘写满的情况只有到fclose才会暴露出来,因为前面fprintf只是往缓冲区里塞。所以判断「写成功没有」,光看fprintf不够,必须把fclose的返回值也接住。
3.2 fputs 与 fputc:纯文本场景更省事的两个函数
不需要格式化的时候,fputs比fprintf更直接。它接受一个字符串和一个FILE *,把字符串写进去,不追加换行:
fputs("Hello, World!\n", fp); /* 换行要自己写在字符串里 */ fputc('\n', fp); /* 单字符写入,适合逐字符生成内容 */fputs遇到字符串结束符\0就停,所以它不能用来写含\0的二进制数据。fputc每次写一个字符,看起来效率低,但在有缓冲的前提下,实际的系统调用次数是一样的,只是多了一层函数调用开销。真正需要极致吞吐时用fwrite一次性写一大块。
注意:
puts和fputs长得像但不一样,puts自动追加换行且只能写stdout,fputs不追加换行且可以写任意流。新手混用之后经常发现文件末尾少一个空行或者多一个空行。
3.3 fwrite:结构体落盘的 sizeof 陷阱与平台差异
要存结构体数组,fwrite是最省事的:
#include <stdio.h> typedef struct { int id; char name[32]; double score; } Record; int main(void) { Record list[2] = { {1, "A同学", 91.5}, {2, "B同学", 87.0}, }; FILE *fp = fopen("records.bin", "wb"); /* 二进制模式,必须带 b */ if (fp == NULL) { perror("fopen"); return 1; } /* 参数:数据首地址、单个元素字节数、元素个数、文件指针 */ size_t n = fwrite(list, sizeof(Record), 2, fp); if (n != 2) { fprintf(stderr, "只写入了 %zu 条\n", n); fclose(fp); return 1; } fclose(fp); return 0; }fwrite的返回值是成功写入的元素个数,不是字节数。上面返回 2 才代表两条都写进去了,返回 1 说明只写成功一条。
结构体直写有三个隐藏问题。第一,sizeof(Record)包含了编译器插入的对齐填充字节,int后面可能有 4 字节空洞,这些字节的内容是未定义的,写进文件里也没意义,但会让文件变大。第二,不同编译器、不同架构的对齐规则可能不同,在一台机器上写的.bin换一台机器fread回来可能错位,所以这种格式只适合「本机写、本机读」的临时缓存。第三,name是定长数组,超过 31 个字符就会溢出,赋值时必须用snprintf而不是strcpy。
如果结构体里有char *指针,绝对不能直接fwrite整个结构体——写进去的是地址值,换次运行地址就变了,读回来是野指针。正确做法是先把字符串内容序列化成「长度 + 字节」或者定长数组再写。
3.4 fflush 与 fclose:数据到底什么时候真正落到磁盘
前面提过缓冲区的存在。标准库的缓冲策略分三种:文件流默认全缓冲(缓冲区满、fflush、fclose时才真正写),终端流是行缓冲(遇到\n就刷),stderr无缓冲。
需要「写完立刻让别人能读到」的场景,就得手动fflush:
fprintf(fp, "进度: %d%%\n", 50); fflush(fp); /* 强制把缓冲区内容交给内核,此时另一个进程就能读到 */典型场景是写日志文件,程序还在跑,你想用tail -f实时看。不fflush的话,日志会攒在缓冲区里,看起来像卡住了。
但fflush只保证数据交给了内核,不保证落到物理磁盘。要抗断电,还得fsync(fileno(fp)),这是 POSIX 的范畴,标准 C 不提供。新手阶段知道这一层就够了:fflush解决「别的进程看不见」,fclose解决「数据不丢」,两件事别混。
还有一个常见误用:对只读流调用fflush。标准规定对输入流用fflush是未定义行为,虽然很多实现默默容忍了,但换个编译器可能出问题。写完要刷,读完不用刷。
4. 读文件:fgets 的行边界,以及 feof 为什么不能当循环条件
读取比写入更容易出隐蔽 bug,因为错误往往表现为「内容对了但多了一行」或者「最后一行少了半截」,程序不崩,你以为对了,交上去被扣分。
4.1 fgets 的第二个参数:sizeof(buffer) 和 sizeof(指针) 的天壤之别
资源里的读示例用了fgets(buffer, sizeof(buffer), fp),这是正确写法,因为buffer是数组,sizeof能拿到真实长度。但如果把它抽成一个函数:
void read_all(FILE *fp) { char buf[256]; char *p = buf; /* 错误示范:sizeof(p) 在 64 位下是 8,一行只能读到 7 个字符 */ while (fgets(p, sizeof(p), fp) != NULL) { } }sizeof是编译期运算符,对数组返回整个数组字节数,对指针返回指针本身大小(8 字节或 4 字节)。参数一退化就出错。稳妥做法是把长度一起传进来:
void read_lines(FILE *fp, char *buf, size_t cap) { /* fgets 最多读 cap-1 个字符,遇到 \n 或 EOF 提前结束,末尾自动补 \0 */ while (fgets(buf, (int)cap, fp) != NULL) { printf("%s", buf); } }fgets的语义要记牢三条:最多读n-1个字符;读到换行符就停,并且把换行符保留在缓冲区里;读完自动在末尾补\0。所以打印时用printf("%s", buf)就够了,不要额外加\n,否则会多出空行——很多人第一次读文件发现行距变大,就是这个原因。
当一行长度超过n-1时,fgets会截断,剩下的部分留在流里,下一次调用继续读。这意味着你不能假设「一次fgets等于一行」。判断是否读到完整一行的办法是看缓冲区末尾是不是\n:
size_t len = strlen(buf); if (len > 0 && buf[len - 1] == '\n') { /* 完整一行 */ } else { /* 被截断了,需要继续读并拼接 */ }4.2 用 feof 做循环条件为什么会多打印一行
这是 C 语言文件读写的经典坑,几乎每个人都踩过:
/* 错误写法 */ while (!feof(fp)) { fgets(buf, sizeof(buf), fp); printf("%s", buf); }问题在于feof的语义是「上一次读操作是否遇到了文件末尾」,它是事后标记,不是事前预测。文件最后一行读完后,feof还没置位,循环再进一次,fgets这次失败返回NULL,缓冲区内容没变,于是最后一行被原样打印了第二遍。
正确写法是拿读函数的返回值当条件:
/* 正确写法:fgets 返回 NULL 就说明读完了或出错 */ while (fgets(buf, sizeof(buf), fp) != NULL) { printf("%s", buf); } /* 循环结束后再区分是正常读完还是出错 */ if (ferror(fp)) { perror("读文件出错"); }feof不是不能用,而是只能在循环结束后用来判断「是读完了还是出错了」,配合ferror一起。同理,fread的循环条件也不能写!feof。
4.3 fscanf 与 sscanf 组合:把一行拆成多个字段
fscanf可以直接按格式从流里取数据,但它有两个毛病:一是遇到格式不匹配就停下,剩下的内容留在流里,很难恢复;二是对换行的处理很模糊,%d会自动跳过所有空白包括换行,容易把两行数据混在一起。
我的习惯是先用fgets读整行,再用sscanf在内存里拆。这样格式错一行只影响这一行,不会污染后续读取:
#include <stdio.h> int main(void) { FILE *fp = fopen("scores.csv", "r"); if (fp == NULL) { perror("fopen"); return 1; } char line[256]; int line_no = 0; while (fgets(line, sizeof(line), fp) != NULL) { int id; char name[32]; double score; line_no++; /* %31s 限定最多读 31 个字符,留 1 字节给 \0,防缓冲区溢出 */ int got = sscanf(line, "%d,%31[^,],%lf", &id, name, &score); if (got != 3) { fprintf(stderr, "第 %d 行格式不对,跳过: %s", line_no, line); continue; } printf("id=%d name=%s score=%.1f\n", id, name, score); } fclose(fp); return 0; }关键参数说明:%31[^,]是扫描集,表示「读最多 31 个非逗号字符」,这比%s安全得多,%s没有长度限制,遇到超长字段会直接写穿栈。sscanf的返回值是成功赋值的字段个数,等于 3 才算完整解析了一行。循环里用continue跳过坏行而不是break,这样单个脏数据不会让整个文件读不下去。
4.4 fread 读二进制:返回值是条目数,不是字节数
和fwrite对称,fread的返回值也是成功读到的元素个数:
Record buf[16]; size_t n = fread(buf, sizeof(Record), 16, fp); /* n 可能小于 16,说明读到了文件末尾或出错 */ for (size_t i = 0; i < n; i++) { printf("%d %s %.1f\n", buf[i].id, buf[i].name, buf[i].score); } if (ferror(fp)) { perror("fread"); }判断「读完了」应该看n < 请求数,再看feof(fp)确认是正常结束,看ferror(fp)确认是否出错。不能只看n == 0就以为是空文件,也可能是格式不对导致一个完整元素都凑不齐。
二进制读还有一个前提:写和读必须用同一个结构体定义、同一套编译选项。中途改了结构体字段顺序,或者换了个编译器,读出来的数据就全乱了。
5. 避坑与排查:五个真实翻车现场的现象、原因和解决
这一章是这份资源里最值钱的部分。示例代码只有几十行,但把它放到真实环境里,下面五类问题至少会碰到三类。
5.1 现象:文件里明明有中文,读出来是乱码
现象:写入时屏幕上中文正常,用记事本打开文件也正常,但程序读回来打印到终端变成一堆问号或者方块。
原因:两个环节可能出问题。一是源文件编码和终端编码不一致,源文件存的是 UTF-8,Windows 控制台默认代码页是 936(GBK),输出时按 GBK 解释 UTF-8 字节,自然乱码。二是文件本身被写成了 UTF-8,但读取端按 GBK 解析。
解决:先统一编码。Linux 和 macOS 终端默认 UTF-8,一般不用管;Windows 上可以在程序开头调用SetConsoleOutputCP(65001)把控制台切到 UTF-8,或者把源文件另存为 GBK。判断文件真实编码不要靠猜,用十六进制查看器看前几个字节,UTF-8 的中文是三个字节一组,GBK 是两个字节一组。注意这只是文本编码问题,和"b"模式无关,二进制模式不会帮你转换编码。
5.2 现象:程序在 fclose 处崩溃,或者报 double free
现象:写入过程一切正常,执行到最后一行fclose(fp)时程序直接挂掉,调试器指向fclose内部。
原因:三种可能。一是同一个fp被fclose了两次,第二次操作的是已经释放的内存。二是fp在某个分支里被fclose后没有置空,后续代码又用了一次。三是把栈上的局部变量地址强转成FILE *传给了fclose。
解决:养成「关完置空」的习惯:
if (fp != NULL) { fclose(fp); fp = NULL; /* 防止后续误用,重复 close 前先判空 */ }所有提前return的分支都要走同一个清理出口,用goto cleanup在 C 里是完全合理的写法,比在每个分支里复制一遍fclose更不容易漏。
5.3 现象:写入返回成功,重新打开文件却是空的
现象:fprintf没报错,屏幕上「写入完成」也打印了,但用编辑器打开文件是 0 字节。
原因:按可能性排序。第一,程序在fclose之前异常退出或走了return,缓冲区没刷。第二,用了"w"模式打开一个已有文件做「读一下看看」,打开瞬间就清空了。第三,写的是相对路径,实际写到了另一个工作目录下的同名文件,你打开的是旧文件。
解决:第一步在fclose后立刻用fopen(path, "r")重新打开验证长度,fseek到末尾用ftell拿大小,为 0 就说明没写进去。第二步打印getcwd确认工作目录。第三步检查所有return路径是否都经过了fclose。这个排查顺序是从最常见到最罕见,一般第一步就能定位。
5.4 现象:读大文件时最后一行不完整,或者程序卡死
现象:几千行的日志文件,读出来最后一行少了一半;或者某个文件读着读着进入死循环。
原因:前者是fgets缓冲区太小,一行超过n-1个字符被截断,而代码没有拼接逻辑,直接把半行当完整行处理了。后者通常是循环条件写错,比如用了while (!feof(fp))但循环体里fgets失败后没有break,或者fscanf遇到不匹配的字符后不消费输入,指针原地打转。
解决:把缓冲区开到 1024 或 4096,并且在读完之后检查末尾有没有\n,没有就说明被截断,需要再读一次拼上。对于fscanf循环,固定加上返回值判断:
while (fscanf(fp, "%d", &v) == 1) { /* 处理 v */ } /* 返回值不等于 1 就退出,不会死循环 */处理不确定行长的文件,更稳的方案是用getline(POSIX)或者自己写一个动态扩容的读取函数,按需realloc,不要赌一个固定长度。
5.5 现象:Windows 上写的文件,Linux 上解析多出一个字符
现象:在 Windows 下生成的 CSV,拿到 Linux 上用sscanf解析,最后一个字段总是多一个不可见字符,strcmp比较失败。
原因:Windows 文本模式写入时把\n转成了\r\n,Linux 读取时不做反向转换,于是每行末尾留着\r。printf打出来看不明显,但字符串比较和数值解析都会受影响。
解决:跨平台传输的文件统一用二进制模式"wb"/"rb"打开,自己控制换行符;或者在读取后手动剥掉行尾的\r:
size_t len = strlen(line); while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r')) { line[--len] = '\0'; }这段清理逻辑建议封装成函数,所有按行读取的地方都过一遍,成本很低,能省掉大量「为什么字符串比较不相等」的排查时间。
6. 进阶一步:用 fseek/ftell 做文件大小校验与随机读写
前面所有操作都是顺序读写,实际项目里经常需要「跳到某个位置读一条」或者「先量一下文件多大再决定怎么读」。这两个需求靠fseek和ftell解决。
#include <stdio.h> /* 返回文件字节数,失败返回 -1 */ long file_size(const char *path) { FILE *fp = fopen(path, "rb"); if (fp == NULL) { perror("fopen"); return -1; } /* SEEK_END 把位置指针移到末尾,偏移 0 表示就停在末尾 */ if (fseek(fp, 0, SEEK_END) != 0) { perror("fseek"); fclose(fp); return -1; } long size = ftell(fp); /* 当前位置距文件开头的字节偏移,此时就是文件大小 */ fclose(fp); return size; }fseek的三个参数是文件指针、偏移量、基准位置。基准位置有三个常量:SEEK_SET从头算、SEEK_CUR从当前位置算、SEEK_END从末尾算。返回值 0 表示成功,非 0 表示失败,对普通磁盘文件基本不会失败,但对管道和终端会。
随机读一条记录的写法是先算出偏移再定位:
/* 读第 index 条记录(从 0 开始) */ int read_record_at(FILE *fp, long index, Record *out) { long offset = index * (long)sizeof(Record); if (fseek(fp, offset, SEEK_SET) != 0) return -1; /* fread 返回 1 才算完整读出一条 */ return (fread(out, sizeof(Record), 1, fp) == 1) ? 0 : -1; }这个技巧能成立的前提是记录定长,也就是 3.3 节说的「结构体直写」格式,变长文本用不了。
有个边界必须知道:ftell返回long,32 位 Linux 上long是 4 字节,文件超过 2GB 就会溢出返回 -1。要处理大文件得用fseeko/ftello(POSIX)或者 Windows 的_fseeki64/_ftelli64。新手项目一般碰不到,但写工具类程序时要提前想到。
下面这张表是我每次写完文件相关代码都会过一遍的自检清单:
| 检查项 | 合格标准 | 常见不合格表现 |
|---|---|---|
| 打开返回值 | 每个fopen后都判NULL | 直接使用,段错误 |
| 模式选择 | 覆盖用w,追加用a,保留原内容用r+ | 全部用w,数据被清空 |
| 关闭路径 | 所有return分支都经过fclose | 提前返回,数据丢失 |
fclose返回值 | 写入场景检查是否为EOF | 磁盘满时静默失败 |
| 读循环条件 | 用fgets/fread返回值判断 | 用!feof,末行重复 |
| 缓冲区长度 | 数组用sizeof,指针传cap | sizeof(指针),只读 7 字节 |
| 字符串格式符 | %31s或%31[^,]带宽度 | 裸%s,栈溢出 |
| 换行符处理 | 跨平台读取时剥掉\r | 字符串比较莫名失败 |
这套清单里的每一条,我都在不同项目里被咬过一次。最惨的是fclose返回值那条:一个批量导出功能在测试机上跑了三个月都正常,上线后某台机器磁盘配额满了,导出函数返回成功、文件却是半截,用户拿着残缺数据来问,查了两天才定位到fclose的返回值被忽略了。从那以后我每次写文件相关的代码,打开判空、循环判返回值、关闭判 EOF 这三步都强制走一遍,不管代码多短。希望帮到你。
本文还有配套的精品资源,点击获取