15、文件操作(下):fscanf/fprintf、fread/fwrite、文件的随机读写

上一章我们把文件操作的基本功——fopen、fclose、fgetc/fputc、fgets/fputs——都过了一遍。说实话,那些函数应付简单场景够用,但真到了项目里,你会发现它们有点「不够劲」。

比如你要从文件里读一个结构体,用fgets一行行解析?那得写多少解析代码啊。再比如你要处理二进制文件,比如图片、音频、固件升级包,文本读写函数根本派不上用场。

所以这一章,我们来聊点硬核的:格式化读写、二进制读写、还有文件的随机定位。这些才是嵌入式开发里真正高频使用的技能。

15.1 格式化读写:fscanf 与 fprintf

这两个函数,说白了就是scanf和printf的文件版本。它们按你指定的格式,直接从文件里读数据,或者把数据按格式写进文件。

原型长这样:

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

用法很简单,第一个参数是文件指针,后面跟格式化字符串和参数列表。

举个例子,把几个传感器的数据写进配置文件:

typedef struct {
    int id;
    float value;
    char name[16];
} SensorData;

SensorData sensor = {1, 23.5, "temp"};

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

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

读回来也一样简单:

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

fscanf(fp, "%d %f %s", &read_sensor.id, &read_sensor.value, read_sensor.name);
fclose(fp);

注意:fscanf的返回值一定要检查。它返回成功匹配并赋值的参数个数。如果文件格式不对,或者读到文件末尾,返回值会小于预期。我曾经在一个日志解析模块里,因为没检查fscanf返回值,导致读了一堆垃圾数据进去,排查了整整一个下午。

我个人习惯是,对于配置文件、日志文件这类文本格式,用fprintf/fscanf非常方便。但要注意,浮点数的精度问题——你写进去的是"23.500000",读出来可能变成23.499999。如果对精度敏感,建议用整数放大法,或者直接上二进制读写。

15.2 二进制读写:fread 与 fwrite

这才是嵌入式开发的「重武器」。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:文件指针

返回值是实际成功读写的元素个数。正常情况下应该等于nmemb,如果小于nmemb,说明出错了或者到文件尾了。

来看一个实际例子。假设我们要把整个结构体数组存到文件里:

typedef struct {
    uint32_t timestamp;
    int16_t  accel_x;
    int16_t  accel_y;
    int16_t  accel_z;
} IMUData;

IMUData buffer[100];
// ... 填充数据 ...

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

size_t written = fwrite(buffer, sizeof(IMUData), 100, fp);
if (written != 100) {
    fprintf(stderr, "写入失败,只写了 %zu 个\n", written);
}

fclose(fp);

读回来的时候,一模一样的操作:

IMUData read_buffer[100];
FILE *fp = fopen("imu_data.bin", "rb");
if (fp == NULL) {
    perror("fopen");
    return -1;
}

size_t read_count = fread(read_buffer, sizeof(IMUData), 100, fp);
if (read_count != 100) {
    if (feof(fp)) {
        printf("文件提前结束,读到了 %zu 个\n", read_count);
    } else if (ferror(fp)) {
        perror("fread 出错");
    }
}

fclose(fp);

小技巧:fread/fwrite的第二个参数和第三个参数,很多人搞混。我的经验是:第二个参数写sizeof(元素类型),第三个参数写元素个数。这样语义最清晰,而且如果文件截断,返回值直接告诉你少了几个元素。

二进制读写的好处很明显:速度快、精度无损、结构体直接存取。但缺点也很突出——文件不跨平台。不同架构的字节序(大端/小端)、结构体对齐方式都不一样。你在STM32上写的二进制文件,拿到x86的PC上可能读出来全是乱码。

我在项目中遇到过这个问题。当时做OTA固件升级,MCU端用fwrite写了一个配置块,PC端工具用fread读,结果结构体里的uint32_t字段全反了。后来统一约定用小端字节序,手动做字节序转换,才搞定。

15.3 文件的随机读写

前面讲的读写操作,都是顺序的——从文件头开始,一路读到尾。但很多场景下,我们需要跳到文件的任意位置去读写。比如数据库索引、日志文件的快速定位、固件包的头部解析。

C语言提供了三个函数来实现随机定位:

函数 作用 原型
fseek 设置文件位置指针 int fseek(FILE *stream, long offset, int whence);
ftell 获取当前文件位置 long ftell(FILE *stream);
rewind 重置到文件开头 void rewind(FILE *stream);

fseek的第三个参数whence有三个取值:

  • SEEK_SET:从文件开头算起
  • SEEK_CUR:从当前位置算起
  • SEEK_END:从文件末尾算起

offset是偏移量,单位是字节。正数向后移,负数向前移。

来看一个实际应用场景。假设我们有一个日志文件,每条日志固定长度128字节。我想直接跳到第50条日志去读:

#define LOG_ENTRY_SIZE 128

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

// 跳到第50条日志(索引从0开始)
long offset = 50 * LOG_ENTRY_SIZE;
if (fseek(fp, offset, SEEK_SET) != 0) {
    perror("fseek 失败");
    fclose(fp);
    return -1;
}

char entry[LOG_ENTRY_SIZE];
size_t n = fread(entry, 1, LOG_ENTRY_SIZE, fp);
if (n == LOG_ENTRY_SIZE) {
    // 成功读取第50条日志
    process_entry(entry);
}

fclose(fp);

另一个常用技巧是用fseek + SEEK_END来获取文件大小:

fseek(fp, 0, SEEK_END);
long file_size = ftell(fp);
rewind(fp);  // 或者 fseek(fp, 0, SEEK_SET);

踩坑提醒:fseek/ftell的返回值类型是long,在32位系统上最大只能表示2GB的文件。如果你处理的是大文件(比如视频流、海量数据采集),要用fseeko/ftello(返回off_t类型)。另外,文本模式下fseek的行为是受限的,因为系统可能会做换行符转换。二进制模式下最安全。

rewind这个函数,说白了就是fseek(fp, 0, SEEK_SET)的快捷方式。但它不返回错误码,所以我个人更喜欢用fseek,至少能检查一下是否成功。

15.4 知识体系总览

这一章的内容比较多,我画了一张图帮你理清思路:

文件操作(下) 格式化读写 fprintf:按格式写入 fscanf:按格式读取 适用:配置文件、日志 二进制读写 fwrite:内存→文件 fread:文件→内存 注意:字节序、对齐 随机读写 fseek:定位到任意位置 ftell:获取当前位置 rewind:回到文件头 核心原则 文本数据用 fprintf/fscanf | 结构化数据用 fread/fwrite | 需要跳转时用 fseek/ftell

嗯,这张图把三种读写方式的关系和适用场景都串起来了。你写代码的时候,先想清楚数据是什么格式、需不需要随机访问,再选对应的函数组合。

15.5 避坑指南

最后,我把自己这些年踩过的坑总结一下,你遇到了能少走弯路:

  • 文件打开模式别搞错:写二进制用"wb",读二进制用"rb"。文本模式"w"/"r"在Windows下会做换行符转换(\n→\r\n),二进制数据会被破坏。
  • fread/fwrite的返回值一定要检查:不要假设一次就能读写完。磁盘满了、文件被截断、硬件故障,都可能让实际读写数量少于预期。
  • fseek之后记得检查错误:fseek返回0表示成功,非0表示失败。我曾经在一个循环里忘了检查,结果文件指针跑飞了,读出来的数据全是错的。
  • 结构体直接读写要考虑对齐:不同编译器、不同架构的结构体填充字节不一样。如果要在不同平台间交换二进制文件,建议手动序列化,或者用#pragma pack(1)强制1字节对齐。
  • ftell返回long,大文件会溢出:处理超过2GB的文件时,用fseeko/ftello,或者用平台相关的API(如Windows的_filelengthi64)。

文件操作这部分,说白了就是「打开→读写→关闭」这个循环。但每个环节都有细节,细节决定稳定性。你在项目里多写几次,自然就熟练了。


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