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 跨平台序列化的知识体系

下面这张图,是我自己总结的跨平台序列化核心逻辑。你看一眼,基本就明白整个流程了。

跨平台序列化核心流程 原始数据(结构体) 步骤1:替换为固定宽度类型(int32_t等) 步骤2:统一转为大端(网络字节序) 步骤3:逐字节拷贝到缓冲区(避免对齐问题) 可移植字节流 关键注意事项 1. 不要用int/long 2. 不要直接memcpy结构体 3. 统一用大端传输 4. 协议头部加版本号 5. 加校验和防篡改 6. 用packed避免padding 7. 逐字段序列化 8. 测试所有目标平台

12.5 避坑指南

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

我曾经犯过的错:
  • 直接用memcpy拷贝结构体到网络缓冲区——不同平台的padding不同,数据全错位了
  • 在序列化时用了sizeof(struct)——不同平台结构体大小不一样,接收方解析会出问题
  • 忘了处理浮点数——IEEE 754标准虽然统一,但字节序还是要转的
  • 没有加协议版本号——后来协议升级,老设备解析新数据直接崩溃
我的建议: 写一个跨平台序列化库,把类型转换、字节序处理、打包解包都封装好。每个平台只调用接口,底层实现各自适配。这样维护起来轻松很多。

说白了,跨平台序列化没那么玄乎。记住三个要点:固定类型、统一字节序、逐字段处理。做到这三点,你的数据就能在ARM、x86、RISC-V之间自由穿梭了。


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