前面二十三篇文章,我们写的所有程序都有一个共同特征:数据只在运行时存在。变量在内存里,程序一退出,一切烟消云散。

但真正的软件不是这样的。游戏要保存存档,编辑器要读写文档,数据库要把数据永久保存到磁盘。这就需要一个程序与外部存储世界沟通的桥梁——文件操作

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 在程序正常退出时,操作系统通常会帮你关掉。但养成手动关闭的习惯非常必要——尤其是写操作,不关可能导致数据丢失;长期运行的程序不关会耗尽文件描述符。


四、写文件:fprintffputs

1. fprintf —— 格式化写入

fprintfprintf 几乎一模一样,只是多了一个 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)。


五、读文件:fscanffgets

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);    // 立即读完那个换行符,得到空行

混合使用 fscanffgets 时,可以在 fscanf 后用 getchar()fgetc(fp) 吃掉换行符。或者统一用 fgets + sscanf 解析。


九、小结

今天你让程序有了“记忆力”。核心知识:

  • FILE * 是操作文件的手柄,用 fopen 获取,用 fclose 归还。
  • "r""w""a" 等模式控制了读、写、追加行为。
  • fprintf / fputs 写文件,fscanf / fgets 读文件。
  • fgets 是安全读行的首选,混合输入时要小心换行符残留。
  • 结构体 + 文件操作 = 简单的数据持久化系统。

文本文件足以应对配置、日志、简单数据存储的需求。但如果你想高效存储大量数值、实现随机读写、或者构建一个简易数据库文件,就需要二进制文件与随机读写。下一篇,我们就来学习 freadfwritefseekftell——打开文件操作的另一扇大门。


课后小练习

  1. 编写一个程序,把 1 到 100 的整数逐行写入 numbers.txt,每行一个数。
  2. 读取上一题生成的 numbers.txt,计算所有整数的总和并打印。
  3. 实现一个简单的“日志记录器”:每次运行程序,让用户输入一行文字,以追加模式写入 log.txt,并在每行前加上时间戳(可以用 time 库的 time()ctime() 获取当前时间字符串)。
  4. (陷阱题)下面的代码有什么问题?如何修正?
    FILE *fp = fopen("data.txt", "w");
    if (fp = NULL) {
        printf("错误\n");
    }
    fprintf(fp, "Hello");
    fclose(fp);
    

我们下期见!

💡获取本系列示例代码请访问 GitCode 仓库

更多推荐