一、引言:为什么必须掌握文件操作
程序跑在内存里,内存是易失的------断电清零,进程结束就回收。但用户的数据要长期保存:配置文件要存下来、日志要落盘、游戏存档要保留、数据库要持久化。这一切都离不开文件操作。
C语言作为一门系统级语言,把文件操作的能力封装在了标准库 <stdio.h> 中,提供了一套完整的函数族。掌握了它们,你就掌握了"程序与磁盘对话"的能力。本篇文章从打开文件讲起,把读、写、定位、判断、刷新、关闭全部串起来,最后给出一个完整的文件复制程序,帮你彻底打通文件IO这条链路。
先记住文件操作的万能四步:
- 打开 :
fopen拿到文件指针 - 读写:fgetc / fgets / fread 等按需使用
- 判断:feof / ferror 检查流状态
- 关闭:fclose 释放资源、刷新缓冲区
后面所有内容,都是围绕这四步展开的细节。
二、fopen:一切的起点
文件操作的第一步是打开文件,函数原型:
FILE *fopen(const char *path, const char *mode);
参数只有两个:path 是文件路径,mode 是打开模式。返回值是指向 FILE 结构体的指针------FILE 是stdio库定义的结构体,内部维护了文件描述符、缓冲区、当前读写位置等信息。打开失败返回 NULL,所以拿到返回值第一件事就是判空:
FILE *fp = fopen("data.txt", "r");
if (fp == NULL)
{
perror("fopen"); // 打印具体失败原因
return -1;
}
mode 模式是文件操作的重灾区,一张表说清楚:
| 模式 | 含义 | 文件不存在时 | 文件已存在时 | 写位置 |
|---|---|---|---|---|
| r | 只读 | 打开失败 | 从头读 | 不可写 |
| w | 只写 | 创建 | 清空原内容 | 从头写 |
| a | 追加写 | 创建 | 保留原内容 | 末尾写 |
| r+ | 读写 | 打开失败 | 从头读写 | 可写 |
| w+ | 读写 | 创建 | 清空原内容 | 可写 |
| a+ | 读写追加 | 创建 | 保留原内容 | 读从头,写末尾 |
三个最容易踩的坑:
w和w+会无条件清空文件 ,如果你只是想追加数据却用了w,原文件内容直接没了,而且是静默的、不可恢复的r系列要求文件必须存在,否则返回 NULLa模式下写操作永远在文件末尾,即使你用 fseek 移动了指针,写入还是会落在末尾
选模式的黄金法则:只读用 r,覆盖写用 w,保留旧数据往后追加用 a。
三、fclose:有始必须有终
int fclose(FILE *fp);
关闭文件,成功返回 0,失败返回 EOF。为什么必须关闭?
- 刷新缓冲区:写入的数据可能还在用户缓冲区里,fclose 会把它们真正写进磁盘
- 释放资源:每个打开的文件都占一个文件描述符,进程的描述符数量有限,长期不关会耗尽,导致"Too many open files"
不关文件最典型的后果:程序运行完,文件里什么都没写进去 ------数据还在缓冲区里躺着,程序退出时来不及刷盘。所以请把 fclose 当成习惯,像 free 一样刻进肌肉记忆。
四、单字符读写:fgetc 与 fputc
最细粒度的读写,一次一个字符。
fgetc
int fgetc(FILE *fp);
从文件流读取一个字符。重点:返回值是 int,不是 char 。为什么?因为正常字符的范围是 0255,而文件末尾的标志 EOF 是 -1,char 只能存 0127 或 0~255(取决于有无符号),存不下 -1。用 int 接收,才能区分"读到字符"和"读到末尾":
int ch;
while ((ch = fgetc(fp)) != EOF)
{
putchar(ch); // 逐个输出
}
fputc
int fputc(int c, FILE *fp);
写入一个字符,成功返回写入的字符,失败返回 EOF。注意参数也是 int------把字符以 int 形式传入即可。
配套的两个标准流版本:
getchar()等价于fgetc(stdin),从键盘读putchar(c)等价于fputc(c, stdout),往屏幕写
它们出现在几乎所有教材的第一个程序里,但大多数人不知道它们其实是文件函数------stdin 和 stdout 也是文件流。
五、按行读写:fgets 与 fputs
处理文本文件的主力,一次一行。
char *fgets(char *s, int size, FILE *fp);
从 fp 读一行,最多读 size - 1 个字符,自动在末尾补 \0。读到换行符时,\n 会被一起存进缓冲区。这是新手最容易被坑的地方:fgets 读进来的字符串自带换行,如果你想跟别的字符串拼接,会发现中间多了个换行。
去掉换行的标准做法:
char line[256];
fgets(line, sizeof(line), fp);
line[strcspn(line, "\n")] = '\0'; // 用 strcspn 找到 \n 的位置,替换成 \0
注意两点:
- 第二参数是缓冲区容量 ,必须写
sizeof(buf)而不是手写数字,避免数组大小改变时漏改 - fgets 能读空格,而
scanf("%s")遇空格就停,所以读整行优先 fgets
fputs
int fputs(const char *s, FILE *fp);
写入字符串,不会自动追加换行 。很多人以为 fputs 像 puts 一样自带换行,结果写出的文件所有内容挤在一行。想换行就自己加 "\n"。
六、块读写:fread 与 fwrite
二进制读写的主力,也是参数最容易写反的一对。
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *fp);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *fp);
四个参数:ptr 是缓冲区,size 是单个数据块 的字节数,nmemb 是块的个数 ,fp 是文件流。返回值是成功读写的块个数,不是字节数。
最常用的写法是把 size 设为 1,让返回值直接等于字节数:
char buf[128];
size_t n = fread(buf, 1, sizeof(buf) - 1, fp); // 一次最多读127字节
buf[n] = '\0';
printf("%s", buf);
如果你反过来写成 fread(buf, sizeof(buf) - 1, 1, fp),含义就变成了"读1块,每块127字节"------如果文件不足127字节,返回值是0,你完全不知道到底读到了多少数据。参数顺序错了,程序不会报错,但行为完全不对,这是最阴险的错误类型。
fwrite 同理,写结构体数组时特别方便:
struct Student stu[10];
fwrite(stu, sizeof(struct Student), 10, fp); // 一次性写10个结构体
fread 与 fgets 怎么选
| 场景 | 推荐 |
|---|---|
| 读文本、按行处理 | fgets |
| 读二进制文件(图片、结构体、序列化数据) | fread |
| 按固定字节块流式读取 | fread |
| 需要精确控制每次读多少字节 | fread |
七、文件指针偏移:fseek / ftell / rewind
文件流内部有一个"当前位置"的概念,读写都从这个位置开始。控制它的三个函数:
fseek
int fseek(FILE *fp, long offset, int whence);
把位置移动到 whence 基准加上 offset 偏移处。whence 有三个取值:
SEEK_SET:文件开头,offset 一般取正数SEEK_CUR:当前位置,offset 可正可负SEEK_END:文件末尾,offset 一般取负数(往回退)
典型用法:
fseek(fp, 0, SEEK_END); // 跳到文件末尾
fseek(fp, -10, SEEK_END); // 跳到末尾前10字节
fseek(fp, 5, SEEK_SET); // 跳到开头后第5字节
ftell
long ftell(FILE *fp);
返回当前位置距离文件开头的偏移量,常用来获取文件大小:
fseek(fp, 0, SEEK_END);
long size = ftell(fp); // 末尾的偏移量就是文件大小
rewind(fp); // 别忘了回到开头
rewind
void rewind(FILE *fp);
等价于 fseek(fp, 0, SEEK_SET),把位置重置到开头,顺带清除错误标志。
八、流状态判断:feof / ferror / clearerr
判断文件读到哪了、有没有出错,靠这三个函数。
feof
int feof(FILE *fp);
返回非0表示已经读到文件末尾 。但有个关键特性:feof 标志是"读失败之后"才置位的。也就是说,你必须先尝试读一次、读到 EOF 之后,feof 才会成立。因此它不能作为循环的前置条件:
// 错误示范:文件还没读,feof 一定为0,但万一读出错会死循环
while (!feof(fp))
{
fread(...);
}
// 正确示范:先读,再判断
char buf[128];
size_t n;
while ((n = fread(buf, 1, sizeof(buf) - 1, fp)) > 0)
{
// 处理数据
}
循环条件用"读到的数量 > 0",让读取结果本身决定是否继续,这是最稳妥的写法。
ferror
int ferror(FILE *fp);
返回非0表示流上发生了读写错误(比如磁盘坏道、权限变化)。配合 feof 一起判断,能区分"正常读完"和"出错中断"。
clearerr
void clearerr(FILE *fp);
清除 EOF 和错误标志。当你读出错后想重新读(比如换个位置再读),必须先 clearerr,否则标志残留会干扰后续操作。
九、缓冲区与 fflush:数据到底什么时候进磁盘
stdio 的文件操作是带用户缓冲区的。fread/fwrite 并不是每次都直接访问磁盘,而是先在用户空间的缓冲区里倒腾,缓冲区满了(或 flush)才真正读写磁盘。这是为了减少系统调用次数,大幅提升效率。
正因为有缓冲区,才引出 fflush:
int fflush(FILE *fp);
把输出缓冲区里还没写盘的数据强制刷到文件。fflush(NULL) 刷新所有输出流。什么时候需要:
- 程序需要立即落盘(比如日志系统、崩溃前必须保存的数据)
- 进程即将阻塞等待输入(比如 printf 提示后 scanf,可能因缓冲导致提示不显示)
- 调试时想看中间输出
要记住一个不等式:缓冲区刷新 ≠ 数据一定已写盘。fclose 和 fflush 只负责把数据从用户缓冲区交给操作系统,而操作系统可能还有自己的页缓存。真正的持久化保证需要 fsync,但那已经超出 stdio 的范畴了。
十、三个标准流:程序自带的"文件"
程序启动时,系统自动打开三个流,不需要你 fopen:
stdin:标准输入,默认键盘stdout:标准输出,默认屏幕,行缓冲stderr:标准错误,默认屏幕,无缓冲
为什么 printf 里 \n 能"立即"显示?因为 stdout 是行缓冲,遇到换行就刷新。而 stderr 无缓冲,所以用 fprintf(stderr, ...) 打错误信息能立刻显示,甚至崩溃前也能看到。这也是调试时推荐用 stderr 打日志的原因。
三个流也是 FILE* 类型,可以直接传给任何文件函数:
fputs("hello", stdout); // 等价于 puts
fgetc(stdin); // 等价于 getchar
fprintf(stderr, "error!\n"); // 错误输出
理解了这一点,你会突然发现:原来 printf 全家桶只是文件函数的"语法糖"。
十一、避坑指南:新手必踩的六大坑
把前面所有重点浓缩成一份清单:
- fgetc 返回类型必须是 int:用 char 接收,EOF(-1) 会被截断成 0xFF,永远不等于 EOF,死循环
- fread 参数别写反 :
fread(buf, 1, count, fp)才是按字节读,count在第三位 - feof 不能当循环前置条件:它读完之后才置位,先读再判断
- w 模式会清空文件:想追加用 a,想保留原内容别用 w
- fgets 会带换行 :用
strcspn或手动处理\n - 不 fclose 数据会丢:缓冲区没刷盘,程序结束全没了
打开文件必须判空 这条我单独拎出来说:fopen 失败返回 NULL,不判空直接读写就是空指针解引用,轻则崩溃,重则被利用成漏洞。每行代码都要有这种防御意识。
十二、实战:写一个完整的文件复制程序
把所有知识串起来,实现一个 cp 命令的简化版:
#include <stdio.h>
int main(int argc, char *argv[])
{
if (argc != 3)
{
printf("用法: %s 源文件 目标文件\n", argv[0]);
return 1;
}
FILE *src = fopen(argv[1], "rb"); // 二进制只读
FILE *dst = fopen(argv[2], "wb"); // 二进制只写,自动清空旧文件
if (src == NULL || dst == NULL)
{
printf("打开文件失败\n");
return 2;
}
char buf[4096]; // 4KB 缓冲区,减少系统调用
size_t n;
// 先读,读到多少写多少,n>0 才继续
while ((n = fread(buf, 1, sizeof(buf), src)) > 0)
{
fwrite(buf, 1, n, dst);
}
fclose(src);
fclose(dst);
printf("复制完成\n");
return 0;
}
这个程序里浓缩了今天所有的要点:判空、二进制模式、块读写、先读后判的循环、及时关闭。如果你能独立写出这个程序并讲清楚每一行的理由,C语言文件操作这一章就算真正过关了。
十三、总结
回顾全文,C语言文件操作的骨架就一句话:fopen 打开 → 读写函数处理 → feof/ferror 判断 → fclose 关闭。
- 字符级:fgetc / fputc
- 行级:fgets / fputs
- 块级:fread / fwrite
- 定位:fseek / ftell / rewind
- 状态:feof / ferror / clearerr
- 刷新:fflush
文件操作是所有后续高级编程(网络、数据库、日志系统)的地基。你现在写的每一行 fread,将来都会变成网络 recv、数据库 IO 的理解基础------它们共享同一套"缓冲、状态、错误处理"的心智模型。
把这篇文章里的示例代码自己敲一遍、编译、运行、故意制造几种错误(比如用 w 覆盖重要文件、用 char 接收 fgetc),你踩过的每一个坑,都会变成你将来调试时的直觉。