24. 【C语言】把数据存下来:文件操作基础
前面二十三篇文章,我们写的所有程序都有一个共同特征:数据只在运行时存在。变量在内存里,程序一退出,一切烟消云散。
但真正的软件不是这样的。游戏要保存存档,编辑器要读写文档,数据库要把数据永久保存到磁盘。这就需要一个程序与外部存储世界沟通的桥梁——文件操作。
C 语言通过标准库提供了一套简洁的文件操作函数,核心思路是:把文件看作一个字节流,打开它、读/写它、关闭它。今天我们就来掌握这套“磁盘功夫”。
一、文件指针 FILE *:操作文件的“手柄”
在 C 语言里,操作文件不是靠文件名,而是靠一个文件指针——FILE *。你打开一个文件,操作系统会返回一个不透明的指针,之后所有的读写操作都通过这个指针进行。
可以把 FILE * 理解为“遥控器”:你用它来操控对应的文件,不需要知道内部细节。
#include <stdio.h>
int main(void) {
FILE *fp; // 声明文件指针
fp = fopen("test.txt", "w"); // 打开文件
// ... 使用 fp ...
fclose(fp); // 关闭文件
return 0;
}
二、打开文件:fopen
FILE *fopen(const char *filename, const char *mode);
filename:文件路径(字符串)。mode:打开模式(字符串),决定读还是写、是否清空、是否追加等。- 返回值:成功返回
FILE*,失败返回NULL。
常用模式一览
| 模式 | 含义 | 文件不存在时 | 文件存在时 |
|---|---|---|---|
"r" |
只读 | 返回 NULL | 从头读 |
"w" |
只写 | 创建新文件 | 清空内容,从头写 |
"a" |
追加写 | 创建新文件 | 从末尾追加,不清空 |
"r+" |
读写 | 返回 NULL | 从头读写 |
"w+" |
读写 | 创建新文件 | 清空内容 |
"a+" |
读+追加 | 创建新文件 | 从末尾追加 |
永远检查 fopen 的返回值:如果文件打不开(比如路径不存在、权限不够、磁盘满),fopen 返回 NULL。不检查就直接用,会导致空指针解引用,程序崩溃。
FILE *fp = fopen("data.txt", "r");
if (fp == NULL) {
perror("打开文件失败"); // perror 打印系统错误信息
return 1;
}
perror 是个很方便的函数,它会根据全局错误码 errno 打印出具体的错误原因(比如 “No such file or directory”)。
三、关闭文件:fclose
int fclose(FILE *fp);
- 将缓冲区中尚未写入磁盘的数据刷新到文件。
- 释放系统资源(文件描述符)。
- 返回 0 表示成功,
EOF(通常是 -1)表示失败。
省略 fclose 在程序正常退出时,操作系统通常会帮你关掉。但养成手动关闭的习惯非常必要——尤其是写操作,不关可能导致数据丢失;长期运行的程序不关会耗尽文件描述符。
四、写文件:fprintf 和 fputs
1. fprintf —— 格式化写入
fprintf 和 printf 几乎一模一样,只是多了一个 FILE* 参数指明写入到哪里。
fprintf(fp, "格式字符串", 参数...);
示例:把学生信息写入文件
#include <stdio.h>
int main(void) {
FILE *fp = fopen("students.txt", "w");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
fprintf(fp, "%-20s %-10s %-6s\n", "姓名", "学号", "成绩");
fprintf(fp, "%-20s %-10d %-6.1f\n", "Alice", 1001, 92.5);
fprintf(fp, "%-20s %-10d %-6.1f\n", "Bob", 1002, 85.0);
fprintf(fp, "%-20s %-10d %-6.1f\n", "Carol", 1003, 78.5);
fclose(fp);
printf("数据已写入 students.txt\n");
return 0;
}
运行后打开 students.txt,你会看到格式整齐的表格。%-20s 表示左对齐占 20 列,和终端输出完全一样。
2. fputs —— 写入整个字符串
fputs("一行文字\n", fp);
比 fprintf(fp, "%s", str) 更简洁,但不带格式化功能,也不会自动加换行(你需要手动 \n)。
五、读文件:fscanf 和 fgets
1. fscanf —— 格式化读取
和 scanf 类似,从文件指针读取而非键盘:
fscanf(fp, "格式字符串", 地址...);
读回刚才保存的学生数据:
#include <stdio.h>
int main(void) {
FILE *fp = fopen("students.txt", "r");
if (fp == NULL) {
perror("打开文件失败");
return 1;
}
char name[50];
int id;
float score;
// 跳过标题行
char header[100];
fgets(header, sizeof(header), fp);
printf("读取的学生数据:\n");
while (fscanf(fp, "%s %d %f", name, &id, &score) == 3) {
printf("姓名: %s, 学号: %d, 成绩: %.1f\n", name, id, score);
}
fclose(fp);
return 0;
}
注意:fscanf 遇到空格、换行会停止读字符串。如果 name 包含空格,会被截断——这时用 fgets 逐行读更可靠。
判断读取是否结束:fscanf 的返回值是成功匹配并赋值的项数。读到文件末尾返回 EOF(通常是 -1),匹配失败返回小于期望值的数。
2. fgets —— 安全地读一行
char *fgets(char *buffer, int size, FILE *fp);
buffer:存放读取内容的字符数组。size:最多读取size-1个字符(留一位给'\0')。- 读到换行符会保留它,读到文件末尾返回
NULL。
char line[256];
while (fgets(line, sizeof(line), fp) != NULL) {
printf("%s", line); // line 里已包含换行符
}
fgets 不会溢出,是读取文本文件的推荐方式。
六、文本文件 vs 二进制文件
上面的例子都是文本文件——数据以人类可读的字符形式存储,数字 92.5 被写成 ‘9’ ‘2’ ‘.’ ‘5’ 四个字符。文本文件优点是直接用编辑器打开查看,缺点是有转换开销、精度可能丢失、文件较大。
二进制文件则把内存中的位模式原样写入磁盘。int 就写 4 字节,double 写 8 字节。优点:更紧凑、读写更快、精度不丢失。缺点:用编辑器打开是乱码。
我们现在先只关注文本文件(下一篇文章会系统讲 fread/fwrite 的二进制操作)。现在你只需知道,C 把文件都看作字节流,文本和二进制只是解释方式不同。
七、完整实战:学生成绩文件的读写
把结构体(第二十一篇)和文件操作结合起来,做一个简单的成绩单保存/读取工具。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_NAME 50
#define FILENAME "grades.txt"
typedef struct {
char name[MAX_NAME];
float score;
} Student;
void save_students(Student *students, int count) {
FILE *fp = fopen(FILENAME, "w");
if (fp == NULL) {
perror("保存失败");
return;
}
fprintf(fp, "%d\n", count); // 第一行记录学生数量
for (int i = 0; i < count; i++) {
fprintf(fp, "%s %.1f\n", students[i].name, students[i].score);
}
fclose(fp);
printf("已保存 %d 条记录到 %s\n", count, FILENAME);
}
int load_students(Student *students, int max_count) {
FILE *fp = fopen(FILENAME, "r");
if (fp == NULL) {
perror("加载失败");
return 0;
}
int count;
fscanf(fp, "%d", &count);
if (count > max_count) count = max_count;
for (int i = 0; i < count; i++) {
fscanf(fp, "%s %f", students[i].name, &students[i].score);
}
fclose(fp);
return count;
}
int main(void) {
Student students[100];
int count = 0;
int choice;
while (1) {
printf("\n1.添加学生 2.显示全部 3.保存 4.加载 0.退出\n");
printf("选择: ");
scanf("%d", &choice);
if (choice == 0) break;
else if (choice == 1) {
if (count >= 100) {
printf("已满\n");
continue;
}
printf("姓名 成绩: ");
scanf("%s %f", students[count].name, &students[count].score);
count++;
}
else if (choice == 2) {
printf("当前 %d 条记录:\n", count);
for (int i = 0; i < count; i++) {
printf(" %s: %.1f\n", students[i].name, students[i].score);
}
}
else if (choice == 3) {
save_students(students, count);
}
else if (choice == 4) {
count = load_students(students, 100);
printf("已加载 %d 条记录\n", count);
}
}
return 0;
}
这个程序综合了结构体、数组、循环、分支、文件读写。运行它,添加几个学生,保存后退出;再重新运行,加载文件,数据就恢复了——你第一次实现了真正的“持久化”。
八、常见错误与陷阱
1. 忘记检查 fopen 的返回值
FILE *fp = fopen("missing.txt", "r");
fprintf(fp, "hello"); // fp 是 NULL,崩溃
任何文件操作前先判空。
2. 用 "w" 打开已有文件,内容被清空
FILE *fp = fopen("important.txt", "w"); // 旧内容瞬间消失!
如果只是想添加,用 "a"(追加)模式。
3. 读写后忘记 fclose
尤其是在写操作后,不关可能导致缓冲区里的数据还没写到磁盘,造成文件内容不完整。
4. 用 fscanf 读字符串不限制宽度
char name[20];
fscanf(fp, "%s", name); // 文件里若有一行超长,就溢出了
应该用 %19s 限制宽度,或者用 fgets。
5. 多次调用 fgets / fscanf 时没考虑上一行的换行符残留
fscanf(fp, "%d", &n); // 读完数字,换行符还在
fgets(line, 100, fp); // 立即读完那个换行符,得到空行
混合使用 fscanf 和 fgets 时,可以在 fscanf 后用 getchar() 或 fgetc(fp) 吃掉换行符。或者统一用 fgets + sscanf 解析。
九、小结
今天你让程序有了“记忆力”。核心知识:
FILE *是操作文件的手柄,用fopen获取,用fclose归还。"r"、"w"、"a"等模式控制了读、写、追加行为。fprintf/fputs写文件,fscanf/fgets读文件。fgets是安全读行的首选,混合输入时要小心换行符残留。- 结构体 + 文件操作 = 简单的数据持久化系统。
文本文件足以应对配置、日志、简单数据存储的需求。但如果你想高效存储大量数值、实现随机读写、或者构建一个简易数据库文件,就需要二进制文件与随机读写。下一篇,我们就来学习 fread、fwrite、fseek、ftell——打开文件操作的另一扇大门。
课后小练习
- 编写一个程序,把 1 到 100 的整数逐行写入
numbers.txt,每行一个数。 - 读取上一题生成的
numbers.txt,计算所有整数的总和并打印。 - 实现一个简单的“日志记录器”:每次运行程序,让用户输入一行文字,以追加模式写入
log.txt,并在每行前加上时间戳(可以用time库的time()和ctime()获取当前时间字符串)。 - (陷阱题)下面的代码有什么问题?如何修正?
FILE *fp = fopen("data.txt", "w"); if (fp = NULL) { printf("错误\n"); } fprintf(fp, "Hello"); fclose(fp);
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。
更多推荐

所有评论(0)