10、文件存储中的序列化:配置文件读写、日志文件的序列化存储

说到文件存储,我脑子里第一个蹦出来的场景就是:嵌入式设备断电重启后,配置参数全丢了。嗯,这滋味我尝过。那次是在一个工业控制项目里,设备跑得好好的,突然掉电,再上电时所有校准参数归零,现场工程师差点没把我电话打爆。

从那以后,我对文件存储中的序列化就格外上心。说白了,就是把内存里的结构体、变量,变成一串能存进文件、能再读回来的字节流。今天咱们就聊聊两个最常见的场景:配置文件读写和日志文件的序列化存储。

10.1 配置文件读写:结构体与文本的转换

配置文件,嵌入式系统里几乎天天见。WiFi密码、传感器阈值、PID参数……这些数据掉电不能丢。我个人的习惯是:用结构体管理配置,用序列化函数完成读写。

先看一个典型的结构体定义:

typedef struct {
    uint8_t  magic;        // 魔数,校验文件有效性
    uint16_t version;      // 配置版本号
    char     ssid[32];     // WiFi名称
    char     password[64]; // WiFi密码
    float    temp_thresh;  // 温度阈值
    uint16_t interval;     // 采样间隔(秒)
    uint32_t crc32;        // 校验和
} SystemConfig_t;

为什么要有magic和version?我在项目里吃过亏。有一次升级固件后,配置结构体里新增了一个字段,旧文件读进来直接错位,整个系统跑飞。后来我学乖了:用magic判断文件是不是我们家的,用version决定怎么解析。

序列化写入的代码长这样:

int Config_Serialize(const SystemConfig_t *cfg, const char *filename) {
    FILE *fp = fopen(filename, "wb");
    if (!fp) return -1;

    // 先写魔数和版本号
    fwrite(&cfg->magic, 1, 1, fp);
    fwrite(&cfg->version, 2, 1, fp);

    // 写字符串:固定长度,不存长度前缀
    fwrite(cfg->ssid, 1, 32, fp);
    fwrite(cfg->password, 1, 64, fp);

    // 写数值
    fwrite(&cfg->temp_thresh, 4, 1, fp);
    fwrite(&cfg->interval, 2, 1, fp);

    // 最后写CRC32
    fwrite(&cfg->crc32, 4, 1, fp);

    fclose(fp);
    return 0;
}

反序列化读取时,我建议做三层校验:

  1. 魔数校验:文件是不是我们写的
  2. 版本校验:结构体定义是否匹配
  3. CRC校验:数据有没有被篡改或损坏

核心原则:序列化时写入什么格式,反序列化时就必须按完全相同的格式读取。字节顺序、填充字节、字符串长度,一个都不能错。

10.2 日志文件的序列化存储

日志和配置文件不一样。配置是结构化的,日志是流式的。但流式日志也有序列化的问题——怎么把日志数据高效地存起来,方便后续解析?

我曾经在一个数据采集项目里,每秒产生几百条日志。如果每条都写成文本,一天下来文件能有好几个GB。后来我改用二进制序列化,体积直接缩到原来的十分之一。

看一个日志条目的结构:

typedef struct {
    uint32_t timestamp;   // Unix时间戳
    uint8_t  level;       // 日志级别:0=DEBUG,1=INFO,2=WARN,3=ERROR
    uint16_t module_id;   // 模块ID
    uint16_t msg_len;     // 消息长度
    char     message[0];  // 柔性数组,变长消息
} __attribute__((packed)) LogEntry_t;

这里用了柔性数组,消息体紧跟在固定头部后面。写入时:

int Log_Serialize(FILE *fp, uint32_t ts, uint8_t level, 
                  uint16_t mod, const char *msg) {
    uint16_t len = strlen(msg);
    if (len > 1024) len = 1024; // 截断保护

    // 先写头部
    fwrite(&ts, 4, 1, fp);
    fwrite(&level, 1, 1, fp);
    fwrite(&mod, 2, 1, fp);
    fwrite(&len, 2, 1, fp);

    // 再写消息体
    fwrite(msg, 1, len, fp);

    return 0;
}

读取时,先读固定头部,拿到msg_len,再读对应长度的消息体。这种变长序列化方式,比固定长度省空间,比文本格式解析快。

小技巧:日志文件建议加上文件头,记录格式版本、创建时间、每条日志的固定头部大小。这样即使以后格式变了,也能兼容旧文件。

10.3 序列化格式对比

我整理了一个表格,方便你选型时参考:

格式 优点 缺点 适用场景
纯文本(JSON/XML) 可读性好,调试方便 体积大,解析慢 少量配置、调试日志
二进制定长 解析快,体积小 扩展性差,字段增减麻烦 固定配置、传感器数据
二进制变长 灵活,节省空间 实现稍复杂,需防溢出 日志文件、消息队列
TLV(Type-Length-Value) 自描述,扩展性好 额外开销,解析略慢 协议通信、通用存储

注意:二进制序列化时,一定要考虑字节序(大小端)和结构体对齐。跨平台传输时,建议统一使用小端序,或者显式指定字节序。我曾经因为没注意这个,ARM和x86之间传配置文件,读出来全是乱码。

10.4 避坑指南

这些年踩过的坑,我总结成几条:

  • 字符串长度:写入时一定要限制最大长度,防止缓冲区溢出。读取时也要检查,别让恶意文件搞崩你的程序。
  • 文件完整性:写入过程中掉电,文件可能只有一半。我习惯先写临时文件,写成功后再rename覆盖原文件。
  • 版本兼容:结构体里加字段时,version加1。读取时根据version决定解析逻辑,旧版本字段填默认值。
  • 不要用memcpy直接读写结构体:结构体里有指针的话,memcpy只复制了指针值,不复制指针指向的数据。序列化必须逐字段处理。

10.5 知识体系图

下面这张图,把文件存储序列化的核心脉络梳理了一遍:

文件存储序列化知识体系 配置文件读写 日志文件存储 结构体定义 序列化写入 反序列化读取 三层校验 固定头部 变长消息体 文件头管理 截断保护 共同关注点 字节序处理 版本兼容 文件完整性 安全校验 序列化 = 结构 + 格式 + 校验 + 兼容

你看,不管是配置文件还是日志文件,核心就四个字:结构、格式、校验、兼容。把这四点想明白了,文件存储的序列化就不会出大问题。

最后说一句:别嫌校验麻烦。我吃过一次亏,配置文件里一个字节错位,整条产线停了两个小时。从那以后,我的序列化函数里,校验代码比业务代码还长。嗯,值得。


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