4、SOME/IP序列化与反序列化:基于CMAP的序列化规则、基本数据类型与复杂数据类型的编码

序列化与反序列化,说白了就是解决一个问题:内存里的结构体,怎么变成能在以太网上传输的字节流?反过来,收到一串字节,怎么还原成程序能用的数据?

我刚开始接触SOME/IP时,觉得这步很简单——不就是memcpy嘛。后来踩了坑才发现,事情远没那么简单。字节序、对齐、长度字段、复杂嵌套……任何一个细节没处理好,ECU之间就“鸡同鸭讲”。

这一章,我们就来彻底搞懂SOME/IP的序列化规则。核心依据是AUTOSAR的CMAP(Common MARSHALler Protocol)规范。我会结合自己的项目经验,把那些容易出坑的地方都指出来。

4.1 序列化的核心原则:CMAP规则

CMAP是AUTOSAR定义的一套序列化/反序列化标准。SOME/IP直接沿用了这套规则。我个人习惯把它总结为三条铁律:

  • 铁律一:大端字节序(Big-Endian)。SOME/IP所有多字节数据,一律采用网络字节序(大端)。高位字节在低地址,低位字节在高地址。这一点和Linux系统调用htonl/htons是一致的。
  • 铁律二:数据对齐到8位边界。说白了,每个数据成员都从字节的起始位置开始,没有位域,没有跨字节的bit packing。这大大简化了序列化的复杂度。
  • 铁律三:动态长度字段前置。对于变长数据(如字符串、数组、结构体),长度信息必须放在数据之前。接收方先读长度,再读数据。

核心要点:CMAP序列化后的字节流,是自描述的。你不需要额外的元数据文件,仅凭字节流本身和类型定义,就能完整还原数据。

嗯,这里要注意:CMAP规则只规定了单个数据元素的序列化方式。多个元素的组合(比如结构体、数组)是递归应用这些规则。你想想看,这就像搭积木——基本块搭好了,复杂结构就是一层层往上堆。

4.2 基本数据类型的编码

基本数据类型是序列化的原子单元。SOME/IP支持的类型包括:

类型 C语言对应 字节长度 序列化方式
uint8 uint8_t 1 直接写入1字节
sint8 int8_t 1 直接写入1字节(补码)
uint16 uint16_t 2 大端写入2字节
sint16 int16_t 2 大端写入2字节(补码)
uint32 uint32_t 4 大端写入4字节
sint32 int32_t 4 大端写入4字节(补码)
uint64 uint64_t 8 大端写入8字节
sint64 int64_t 8 大端写入8字节(补码)
float32 float 4 大端写入4字节(IEEE 754)
float64 double 8 大端写入8字节(IEEE 754)
boolean uint8_t 1 0x00为false,0x01为true
string char* 变长 长度(4字节大端) + UTF-8数据

举个例子,序列化一个uint32数值0x12345678:

内存地址: [0] [1] [2] [3]
字节内容: 0x12 0x34 0x56 0x78

我在项目中遇到过一个问题:某供应商的ECU把uint16序列化成了小端,结果通信一直失败。排查了两天才发现是字节序问题。所以,序列化前一定要确认双方字节序一致。SOME/IP强制大端,没得商量。

避坑指南:我曾经在调试时发现,有些编译器对bool类型的处理不一致。有的编译器把bool当作1字节,有的当作4字节。SOME/IP明确规定boolean就是1字节,0x00/0x01。所以,不要直接用memcpy序列化bool变量,一定要显式转换为uint8_t。

4.3 复杂数据类型的编码

复杂数据类型是基本类型的组合。SOME/IP定义了三种:结构体、数组、联合体(Union)。

4.3.1 结构体(Struct)

结构体的序列化规则很简单:按成员定义顺序,依次序列化每个成员。没有对齐填充(padding),成员之间紧密排列。

举个例子,定义一个结构体:

struct VehicleSpeed {
    uint16_t id;        // 2字节
    float    speed;     // 4字节
    uint8_t  quality;   // 1字节
};

序列化后的字节流:

偏移0: id的高字节 (大端)
偏移1: id的低字节
偏移2: speed的字节0 (大端)
偏移3: speed的字节1
偏移4: speed的字节2
偏移5: speed的字节3
偏移6: quality

总长度 = 2 + 4 + 1 = 7字节。注意,没有填充字节。这和C语言结构体的内存布局完全不同。C语言里,由于对齐要求,speed可能从偏移4开始,中间会空出2个字节。但在SOME/IP序列化中,这些填充全部去掉。

我的建议:在定义SOME/IP接口时,尽量把相同长度的成员放在一起。比如把所有uint32放前面,uint16放中间,uint8放最后。这样序列化后的字节流更紧凑,也更容易调试。

4.3.2 数组(Array)

数组分两种:定长数组和变长数组。

  • 定长数组:直接按顺序序列化每个元素。没有长度字段,因为长度是编译期已知的。
  • 变长数组:先写入一个uint32的长度字段(元素个数),再依次序列化每个元素。

变长数组的序列化格式:

[长度(uint32, 大端)] [元素0] [元素1] ... [元素N-1]

我曾经在项目中遇到一个坑:变长数组的长度字段,单位是元素个数,不是字节数。比如一个uint32数组有5个元素,长度字段写5,不是20。接收方拿到长度后,需要根据元素类型自己计算字节数。

4.3.3 联合体(Union)

联合体在SOME/IP中比较特殊。它必须和一个选择器(Selector)配合使用。选择器是一个uint8或uint32的枚举值,用来指示联合体中当前激活的是哪个成员。

序列化格式:

[选择器(uint32, 大端)] [激活的成员数据]

举个例子:

union Data {
    uint32_t  speed;
    float     temperature;
    uint8_t   status;
};

// 如果选择器=1,表示当前是speed
// 序列化结果: [0x00000001] [speed的4字节大端]

// 如果选择器=2,表示当前是temperature
// 序列化结果: [0x00000002] [temperature的4字节大端]

注意:联合体的序列化长度是可变的,取决于当前激活的成员。接收方必须先读选择器,再根据选择器决定后续数据的解析方式。如果选择器和实际数据不匹配,反序列化就会出错。我曾经见过一个bug,发送方忘了更新选择器,导致接收方用错误的方式解析数据,结果算出来的车速是几万公里每小时……

4.4 序列化流程总览

为了让你更直观地理解整个序列化过程,我画了一张流程图:

SOME/IP序列化流程 SOME/IP序列化流程 开始序列化 判断数据类型 基本类型 大端写入字节 复杂类型 递归序列化 还有更多成员? 是 否 序列化完成 说明: 基本类型直接大端写入;结构体按成员顺序递归序列化;变长数组先写长度再写元素;联合体先写选择器再写激活成员。 所有序列化都遵循CMAP规则:大端字节序、8位对齐、动态长度前置。

这张图展示了序列化的核心逻辑:从根对象开始,递归遍历每个成员。基本类型直接写入,复杂类型继续分解。直到所有成员都处理完毕,序列化完成。

4.5 反序列化的对称性

反序列化就是序列化的逆过程。规则完全对称:

  • 读uint32:读4字节,大端转为主机字节序
  • 读结构体:按成员顺序,递归反序列化每个成员
  • 读变长数组:先读4字节长度,再循环读N个元素
  • 读联合体:先读选择器,再根据选择器读对应成员

我在项目中经常看到有人把序列化和反序列化写成两套独立的代码。这其实很危险——两边逻辑一旦不一致,就会出bug。我的习惯是:用同一个宏或模板生成序列化和反序列化代码。这样保证两边逻辑完全一致,减少出错概率。

实战技巧:写反序列化代码时,一定要做边界检查。比如读字符串时,长度字段不能超过缓冲区剩余大小。我曾经见过一个攻击报文,长度字段写了一个超大值,导致反序列化时直接内存越界。所以,永远不要信任网络上的数据。

4.6 本章小结

序列化与反序列化是SOME/IP通信的基石。记住三个核心点:

  1. 大端字节序——所有多字节数据都是大端,没得商量
  2. 8位对齐——没有填充,成员紧密排列
  3. 动态长度前置——变长数据的长度信息放在数据之前