18、文件操作(上):fopen/fclose/fread/fwrite/fprintf/fscanf,文本与二进制文件

文件操作,说白了就是让程序跟硬盘上的数据打交道。我刚开始学C语言时,觉得文件操作可有可无——不就是存个数据嘛,变量不也能存?后来做嵌入式项目才明白,没有文件操作,你的程序就是个“失忆症患者”。断电重启,一切归零。

这一章,咱们把文件操作的基础打牢。我会从最常用的几个函数讲起,再聊聊文本文件和二进制文件的区别。嗯,这些都是我实际项目中反复踩过的坑,你仔细看。

18.1 文件操作的“三板斧”:fopen、fread、fwrite

文件操作有个固定套路:打开 → 读写 → 关闭。少了哪一步都不行。

18.1.1 fopen:打开文件

fopen 是文件操作的入口。它的原型很简单:

FILE *fopen(const char *filename, const char *mode);

第一个参数是文件名,第二个参数是打开模式。模式决定了你能对文件做什么。

模式 含义 文件不存在时
"r" 只读 返回NULL
"w" 只写 创建新文件
"a" 追加 创建新文件
"r+" 读写 返回NULL
"w+" 读写(清空) 创建新文件
"a+" 读写(追加) 创建新文件
注意: 用 "w" 或 "w+" 模式打开文件时,如果文件已存在,内容会被清空。我曾经在项目中不小心用 "w" 模式打开了一个配置文件,结果所有配置数据瞬间消失……嗯,从那以后我每次打开文件前都会先检查文件是否存在。

18.1.2 fread 和 fwrite:二进制读写

这两个函数是二进制文件操作的“主力军”。它们的原型是:

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

参数解释:

  • ptr:数据缓冲区的指针
  • size:每个数据元素的大小(字节数)
  • nmemb:要读写的数据元素个数
  • stream:文件指针

举个例子,读写一个结构体数组:

typedef struct {
    int id;
    char name[32];
    float score;
} Student;

Student students[100];
// 写入文件
fwrite(students, sizeof(Student), 100, fp);
// 从文件读取
fread(students, sizeof(Student), 100, fp);
个人习惯: 我通常用 fread 的返回值来判断是否读到了足够的数据。如果返回值小于 nmemb,说明文件提前结束了或者发生了错误。

18.2 文本文件的读写:fprintf 和 fscanf

文本文件,说白了就是人能直接看懂的文件。每个字节都对应一个可打印字符(或换行符)。

18.2.1 fprintf:格式化写入

fprintfprintf 几乎一样,只是多了一个文件指针参数:

int fprintf(FILE *stream, const char *format, ...);

举个例子:

FILE *fp = fopen("data.txt", "w");
if (fp == NULL) {
    perror("fopen");
    return -1;
}
fprintf(fp, "ID: %d, Name: %s, Score: %.2f\n", 1, "Alice", 95.5);
fclose(fp);

这样写出来的文件,用记事本打开就能看到内容。调试时特别方便。

18.2.2 fscanf:格式化读取

fscanffprintf 的逆操作:

int fscanf(FILE *stream, const char *format, ...);

读取刚才写入的数据:

FILE *fp = fopen("data.txt", "r");
int id;
char name[32];
float score;
fscanf(fp, "ID: %d, Name: %s, Score: %f", &id, name, &score);
printf("id=%d, name=%s, score=%.2f\n", id, name, score);
fclose(fp);
避坑指南: 我曾经用 fscanf 读取一个格式不匹配的文件,结果程序直接崩溃。后来我养成了一个习惯:每次用 fscanf 前,先检查返回值。如果返回值不等于期望的匹配项数,说明格式有问题。

18.3 文本文件 vs 二进制文件

这是很多初学者容易混淆的地方。我简单说下区别:

对比项 文本文件 二进制文件
存储方式 每个字节存一个字符 按内存中的二进制格式存储
可读性 人可以直接阅读 需要程序解析
存储效率 较低(数字要转成字符串) 较高(直接存二进制)
跨平台性 较好(换行符可能不同) 较差(字节序、结构体对齐)
典型用途 配置文件、日志文件 图片、音频、数据库文件

举个例子,存储整数 12345:

  • 文本文件: 占用5个字节,分别存储 '1'、'2'、'3'、'4'、'5' 的ASCII码
  • 二进制文件: 占用4个字节(int类型),直接存储 0x00003039

你想想看,如果存的是浮点数或者结构体,文本文件的存储效率会更低。但文本文件的好处是——出问题了,你用记事本打开就能看到数据对不对。

我的建议: 如果是嵌入式项目,需要频繁读写大量数据,用二进制文件。如果是配置文件或者日志,用文本文件。别纠结,看场景选。

18.4 fclose:别忘了关文件

这个函数看起来简单,但很多人会忘。它的原型:

int fclose(FILE *fp);

为什么一定要关文件?

  • 释放系统资源(文件描述符是有限的)
  • 刷新缓冲区(确保数据真正写到磁盘)
  • 避免文件损坏(程序崩溃时未关闭的文件可能不完整)

我记得有一次,一个同事写了个日志程序,每次写日志都 fopen 但不 fclose。跑了几天后,程序突然打不开任何文件了——文件描述符耗尽了。嗯,这种问题排查起来特别头疼。

18.5 知识体系图

下面这张图帮你理清文件操作的核心逻辑:

文件操作核心流程 fopen 打开文件 打开成功? 返回 NULL 读写操作 fread / fwrite 或 fprintf / fscanf fclose 关闭文件 提示:每次 fopen 后必须 fclose,否则会泄漏文件描述符

18.6 综合示例:一个简单的配置读写器

最后,我写个小例子,把今天讲的内容串起来。这个程序会读写一个配置文件:

#include <stdio.h>
#include <string.h>

typedef struct {
    int baudrate;
    char port[16];
    int timeout_ms;
} Config;

int save_config(const char *filename, Config *cfg) {
    FILE *fp = fopen(filename, "w");
    if (fp == NULL) {
        perror("fopen");
        return -1;
    }
    fprintf(fp, "baudrate=%d\n", cfg->baudrate);
    fprintf(fp, "port=%s\n", cfg->port);
    fprintf(fp, "timeout=%d\n", cfg->timeout_ms);
    fclose(fp);
    return 0;
}

int load_config(const char *filename, Config *cfg) {
    FILE *fp = fopen(filename, "r");
    if (fp == NULL) {
        perror("fopen");
        return -1;
    }
    if (fscanf(fp, "baudrate=%d\n", &cfg->baudrate) != 1) {
        fprintf(stderr, "读取波特率失败\n");
        fclose(fp);
        return -1;
    }
    if (fscanf(fp, "port=%s\n", cfg->port) != 1) {
        fprintf(stderr, "读取端口失败\n");
        fclose(fp);
        return -1;
    }
    if (fscanf(fp, "timeout=%d\n", &cfg->timeout_ms) != 1) {
        fprintf(stderr, "读取超时时间失败\n");
        fclose(fp);
        return -1;
    }
    fclose(fp);
    return 0;
}

int main() {
    Config cfg = {115200, "COM3", 1000};
    save_config("config.txt", &cfg);
    
    Config loaded = {0};
    load_config("config.txt", &loaded);
    printf("波特率: %d, 端口: %s, 超时: %dms\n", 
           loaded.baudrate, loaded.port, loaded.timeout_ms);
    return 0;
}

这个例子虽然简单,但涵盖了文件操作的核心流程。你可以在自己的项目中直接套用这个模板。

最后说一句: 文件操作看似简单,但坑不少。我建议你每次写完文件操作代码后,都检查一下返回值。别嫌麻烦——这点小习惯,能帮你省下大把调试时间。

公众号:蓝海资料掘金营,微信deep3321