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;
}
反序列化读取时,我建议做三层校验:
- 魔数校验:文件是不是我们写的
- 版本校验:结构体定义是否匹配
- 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