12、跨平台序列化:不同平台的数据类型差异、处理字节序问题、可移植性设计
说实话,跨平台序列化这个话题,是我在嵌入式开发中踩坑最多的领域之一。你想想看,一个数据在ARM芯片上存得好好的,传到x86服务器上就全乱了——这种事我遇到过不止一次。今天咱们就把这块硬骨头啃下来。
12.1 不同平台的数据类型差异
先说说最基础的问题:不同平台的数据类型,长度不一样。我刚开始做跨平台开发时,天真地以为int就是4个字节。直到有一次,我在一个16位MCU上跑程序,发现sizeof(int)返回2——嗯,那天我加班到很晚。
核心问题: C语言标准只规定了最小范围,没规定固定长度。
| 数据类型 | 32位平台 | 64位平台 | 16位平台 |
|---|---|---|---|
| char | 1字节 | 1字节 | 1字节 |
| short | 2字节 | 2字节 | 2字节 |
| int | 4字节 | 4字节 | 2字节 |
| long | 4字节 | 8字节 | 4字节 |
| long long | 8字节 | 8字节 | 8字节 |
| 指针 | 4字节 | 8字节 | 2字节 |
看到没?int和long在不同平台上长度不一样。我个人习惯是:永远不要直接用int、long做序列化。用stdint.h里定义的类型,比如int32_t、uint16_t,这些是固定长度的。
我的经验: 在项目开始前,就在代码里加上静态断言,确保关键类型长度符合预期。比如:
static_assert(sizeof(int32_t) == 4, "int32_t must be 4 bytes");
12.2 字节序问题——大端与小端的战争
字节序,说白了就是数据在内存里怎么排列。大端模式把高位字节放在低地址,小端模式把低位字节放在低地址。ARM芯片大多是小端,但网络协议栈里常用大端——这就是矛盾的根源。
我曾经在一个项目中,把传感器数据打包后通过网络发送。在本地测试一切正常,一上现场就全乱套了。查了两天,最后发现是字节序没处理。嗯,从那以后我再也不敢忽略字节序了。
12.2.1 判断当前平台的字节序
#include <stdint.h>
#include <stdio.h>
int is_little_endian(void) {
uint16_t test = 0x0102;
uint8_t *p = (uint8_t *)&test;
return (p[0] == 0x02);
}
int main() {
if (is_little_endian()) {
printf("当前平台:小端模式\n");
} else {
printf("当前平台:大端模式\n");
}
return 0;
}
12.2.2 字节序转换函数
我建议你直接封装一组转换函数,别每次都手写移位操作。容易出错,而且代码可读性差。
// 小端转大端(网络字节序)
uint16_t htons_custom(uint16_t val) {
if (is_little_endian()) {
return (val >> 8) | (val << 8);
}
return val;
}
uint32_t htonl_custom(uint32_t val) {
if (is_little_endian()) {
return ((val >> 24) & 0xFF) |
((val >> 8) & 0xFF00) |
((val << 8) & 0xFF0000) |
((val << 24) & 0xFF000000);
}
return val;
}
// 大端转小端(主机字节序)
uint16_t ntohs_custom(uint16_t val) {
return htons_custom(val); // 对称操作
}
uint32_t ntohl_custom(uint32_t val) {
return htonl_custom(val);
}
注意: 不要假设所有平台都有htons/htonl这些函数。在嵌入式裸机环境下,这些函数可能不存在。自己实现一套,心里踏实。
12.3 可移植性设计——从架构层面解决问题
说白了,跨平台序列化的核心就三个字:定协议。你定好协议,所有平台都按这个协议来,问题就解决了一大半。
12.3.1 协议设计原则
- 固定宽度类型:只用int32_t、uint16_t这类固定长度类型
- 统一字节序:我习惯统一用大端(网络字节序)作为传输格式
- 对齐处理:结构体成员按4字节对齐,避免padding差异
- 版本号:协议头部加版本号,方便后续扩展
12.3.2 一个可移植的序列化示例
#include <stdint.h>
#include <string.h>
// 协议头部
typedef struct {
uint32_t magic; // 魔数,用于校验
uint32_t version; // 协议版本
uint32_t length; // 数据长度
uint32_t checksum; // 校验和
} __attribute__((packed)) PacketHeader;
// 序列化:将结构体转为字节流
void serialize_packet(const PacketHeader *pkt, uint8_t *buffer) {
uint32_t magic = htonl_custom(pkt->magic);
uint32_t version = htonl_custom(pkt->version);
uint32_t length = htonl_custom(pkt->length);
uint32_t checksum = htonl_custom(pkt->checksum);
memcpy(buffer, &magic, 4);
memcpy(buffer + 4, &version, 4);
memcpy(buffer + 8, &length, 4);
memcpy(buffer + 12, &checksum, 4);
}
// 反序列化:将字节流转为结构体
void deserialize_packet(const uint8_t *buffer, PacketHeader *pkt) {
uint32_t magic, version, length, checksum;
memcpy(&magic, buffer, 4);
memcpy(&version, buffer + 4, 4);
memcpy(&length, buffer + 8, 4);
memcpy(&checksum, buffer + 12, 4);
pkt->magic = ntohl_custom(magic);
pkt->version = ntohl_custom(version);
pkt->length = ntohl_custom(length);
pkt->checksum = ntohl_custom(checksum);
}
小技巧: 用
__attribute__((packed))告诉编译器不要加填充字节。但要注意,某些ARM平台上访问未对齐地址会触发异常。所以序列化时最好逐字节拷贝,别直接强转指针。
12.4 跨平台序列化的知识体系
下面这张图,是我自己总结的跨平台序列化核心逻辑。你看一眼,基本就明白整个流程了。
12.5 避坑指南
最后,我把自己踩过的坑总结一下,你遇到了能少走弯路。
我曾经犯过的错:
- 直接用
memcpy拷贝结构体到网络缓冲区——不同平台的padding不同,数据全错位了 - 在序列化时用了
sizeof(struct)——不同平台结构体大小不一样,接收方解析会出问题 - 忘了处理浮点数——IEEE 754标准虽然统一,但字节序还是要转的
- 没有加协议版本号——后来协议升级,老设备解析新数据直接崩溃
我的建议: 写一个跨平台序列化库,把类型转换、字节序处理、打包解包都封装好。每个平台只调用接口,底层实现各自适配。这样维护起来轻松很多。
说白了,跨平台序列化没那么玄乎。记住三个要点:固定类型、统一字节序、逐字段处理。做到这三点,你的数据就能在ARM、x86、RISC-V之间自由穿梭了。
公众号:蓝海资料掘金营,微信deep3321