4. 文件的顺序读写(二):fscanf()、fprintf()、fread()、fwrite() 函数的使用与区别

好,咱们接着聊文件读写。上一节我们讲了 fgetc()fputc()fgets()fputs() 这几个按字符和按行读写的函数。这一节要上点强度了——fscanf()fprintf()fread()fwrite()。这四个函数,说白了就是「格式化读写」和「二进制块读写」的代表。

我个人习惯把前两个叫做「文本格式化兄弟」,后两个叫做「二进制搬运工」。它们各有各的脾气,用错了地方,程序就给你脸色看。

4.1 fscanf() 和 fprintf():文本格式化的老搭档

这两个函数,跟你在控制台用的 scanf()printf() 几乎一模一样。唯一的区别就是多了一个 FILE * 参数,告诉它们「别往屏幕上写,往这个文件里写」。

4.1.1 fprintf():把数据格式化写入文件

原型长这样:

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

返回值是成功写入的字符数。如果写失败了,返回负数。

举个例子,我经常用它来写日志文件:

FILE *fp = fopen("log.txt", "w");
if (fp == NULL) {
    perror("fopen");
    return -1;
}

int id = 1001;
char name[] = "温度传感器";
float value = 23.56;

fprintf(fp, "[%d] %s: %.2f\n", id, name, value);
fclose(fp);

你看,跟 printf 的用法一模一样。格式化字符串怎么写,文件里就怎么写。我在项目中遇到过一种情况:有人用 fprintf 写二进制数据,结果文件打开一看全是乱码。嗯,这里要注意——fprintf 是文本模式,它会把数字转成字符串再写进去。你写个整数 255,文件里存的是三个字符 '2'、'5'、'5',不是 0xFF。

4.1.2 fscanf():从文件里按格式读取

原型:

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

返回值是成功匹配并赋值的参数个数。如果读到文件末尾,返回 EOF

接着上面的例子,我们把刚才写的日志读回来:

FILE *fp = fopen("log.txt", "r");
if (fp == NULL) {
    perror("fopen");
    return -1;
}

int id;
char name[64];
float value;

fscanf(fp, "[%d] %s: %f\n", &id, name, &value);
printf("读取到: id=%d, name=%s, value=%.2f\n", id, name, value);
fclose(fp);

这里有个坑,我曾经踩过。如果文件里的格式跟格式化字符串对不上,fscanf 就会匹配失败,而且文件指针不会自动跳过错误位置。你想想看,如果文件里有一行格式乱了,后面的数据全读不出来了。所以我的建议是:用 fscanf 读文件时,一定要检查返回值。

避坑指南: 我曾经在一个嵌入式项目里用 fscanf 读取配置文件,结果某次升级后配置文件格式变了,程序直接卡死在读取循环里。从那以后,我每次用 fscanf 都会检查返回值,并且配合 feof() 和 ferror() 来判断文件状态。

4.2 fread() 和 fwrite():二进制数据的搬运工

这两个函数跟前面那俩完全不同。它们不关心数据是什么格式,只管「从内存搬一块到文件」或者「从文件搬一块到内存」。说白了,就是内存的字节拷贝。

4.2.1 fwrite():把内存块写入文件

原型:

size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
  • ptr:要写入的数据的起始地址
  • size:每个元素的大小(字节数)
  • nmemb:要写入的元素个数
  • stream:文件指针

返回值是成功写入的元素个数。正常情况下应该等于 nmemb

举个例子,保存一个结构体数组:

typedef struct {
    int id;
    char name[32];
    float value;
} Sensor;

Sensor sensors[3] = {
    {1, "温度", 25.3},
    {2, "湿度", 68.5},
    {3, "气压", 1013.2}
};

FILE *fp = fopen("sensors.dat", "wb");
if (fp == NULL) {
    perror("fopen");
    return -1;
}

size_t written = fwrite(sensors, sizeof(Sensor), 3, fp);
if (written != 3) {
    fprintf(stderr, "写入失败,只写入了 %zu 个元素\n", written);
}
fclose(fp);

注意我打开文件时用了 "wb",二进制写模式。如果你用了 "w",在 Windows 系统上可能会出问题——它会自动把换行符 \n 转成 \r\n,把二进制数据搞乱。

4.2.2 fread():从文件读取二进制块

原型:

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

参数含义跟 fwrite 一样。返回值是成功读取的元素个数。

把刚才写进去的数据读回来:

Sensor read_sensors[3];
FILE *fp = fopen("sensors.dat", "rb");
if (fp == NULL) {
    perror("fopen");
    return -1;
}

size_t read_count = fread(read_sensors, sizeof(Sensor), 3, fp);
if (read_count != 3) {
    fprintf(stderr, "读取失败,只读到 %zu 个元素\n", read_count);
}
fclose(fp);
个人经验: 我建议你在用 fread 读取结构体时,先检查文件大小是否足够。可以用 fseek() 和 ftell() 获取文件大小,再跟 sizeof(Sensor) * nmemb 比较。否则读到一半文件结束了,数据不完整,程序可能崩溃。

4.3 四者的核心区别

咱们用一张表来对比一下:

函数 读写模式 数据格式 适用场景 效率
fprintf() 文本 格式化字符串 日志、配置文件、人类可读的数据 低(需要格式化转换)
fscanf() 文本 格式化字符串 读取配置文件、解析文本数据 低(需要解析字符串)
fwrite() 二进制 内存原始字节 保存结构体、数组、大块数据 高(直接内存拷贝)
fread() 二进制 内存原始字节 加载二进制文件、恢复数据 高(直接内存拷贝)

说白了,选哪个取决于你的需求:

  • 如果文件要给人看,用 fprintf / fscanf
  • 如果文件给程序读,追求速度和精确性,用 fread / fwrite

4.4 知识体系图

下面这张图帮你理清这四个函数的关系和选择逻辑:

文件顺序读写四函数选择逻辑 文件读写操作 文本模式 二进制模式 fprintf() fscanf() fwrite() fread() 格式化写入 格式化读取 二进制写入 二进制读取 文本模式:人类可读,效率低 | 二进制模式:机器可读,效率高

4.5 实战中的选择建议

我做了这么多年嵌入式,总结了几条经验:

  1. 配置文件用 fprintf / fscanf:因为要让人能看懂、能手动修改。比如 Wi-Fi 配置、传感器阈值等。
  2. 数据采集结果用 fwrite:比如每秒采集 1000 个传感器数据,用文本写的话文件巨大,解析也慢。二进制写进去,读取时直接 fread 到数组,效率高得多。
  3. 跨平台传输数据时慎用 fwrite:不同平台的结构体对齐、字节序(大小端)可能不同。我曾经在 ARM 上写的数据,放到 x86 上读出来全是错的。解决办法是定义明确的数据格式,或者用 #pragma pack 控制对齐。
  4. 不要混用文本和二进制模式:一个文件要么全文本,要么全二进制。混着用会让你自己都搞不清楚。
核心要点:
  • fprintf/fscanf 处理文本,适合人类阅读
  • fread/fwrite 处理二进制,适合程序高效读写
  • 二进制读写速度快,但可移植性差
  • 文本读写速度慢,但跨平台兼容性好
  • 始终检查返回值,不要假设读写一定成功

好了,这一节的内容就到这儿。这四个函数你只要记住「文本用 fprintf/fscanf,二进制用 fread/fwrite」这个原则,基本不会用错。下一节我们聊聊文件指针的随机定位,那个玩好了,你能在文件里跳来跳去,想读哪读哪。


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