第1章:SOME/IP序列化与反序列化——序列化规则详解

各位同学,咱们今天聊聊SOME/IP里最基础也最容易踩坑的部分——序列化。说白了,就是把咱们程序里的结构体、数组这些数据,变成能在网线上传输的一串字节。反过来,从字节流还原成数据结构,就是反序列化。

我刚开始接触SOME/IP时,觉得这不就是memcpy嘛,有啥好讲的?结果第一次联调就翻车了——两端数据对不上,查了一整天,最后发现是字节对齐的问题。嗯,从那以后我再也不敢小看序列化规则了。

1.1 序列化的基本规则

SOME/IP序列化遵循一个核心原则:TLV(Type-Length-Value)?不对,SOME/IP其实用的是静态序列化。什么意思呢?就是发送方和接收方提前约定好数据结构,传输时只传Value,不传Type和Length。这样省带宽,效率高。

但代价是什么?代价就是两端的数据定义必须完全一致。我见过一个项目,A团队用uint32,B团队用int32,结果数值超过21亿时直接崩了。所以啊,接口定义文件(比如ARXML)一定要严格对齐。

核心规则总结:

  • 数据按顺序依次序列化
  • 每个基本类型占用固定字节数
  • 复合类型(结构体、数组)递归序列化
  • 遵循8字节对齐原则(后面细讲)

1.2 基础数据类型映射

咱们先看最常用的基础类型。SOME/IP定义了一套标准的数据类型,跟C/C++的类型有对应关系。我整理了一张表,大家存一下:

SOME/IP类型 C/C++类型 字节数 说明
UInt8 uint8_t 1 无符号8位整数
SInt8 int8_t 1 有符号8位整数
UInt16 uint16_t 2 无符号16位整数,大端序
SInt16 int16_t 2 有符号16位整数,大端序
UInt32 uint32_t 4 无符号32位整数,大端序
SInt32 int32_t 4 有符号32位整数,大端序
Float32 float 4 IEEE 754单精度浮点
UInt64 uint64_t 8 无符号64位整数,大端序
String char* / std::string 变长 前4字节为长度,后跟UTF-8数据

个人经验:我习惯在代码里显式使用里的类型,比如uint32_t而不是unsigned int。不同编译器对int的位数理解不一样,但uint32_t永远是4字节。你想想看,要是因为类型长度不一致导致序列化错位,查起来多痛苦。

1.3 结构体的序列化

结构体序列化,就是把成员变量按顺序一个个排好。但这里有个坑——对齐填充

举个例子,假设我们有这样一个结构体:

struct Example {
    uint8_t  a;    // 1字节
    uint32_t b;    // 4字节
    uint16_t c;    // 2字节
};

在C语言里,这个结构体的大小是12字节(因为编译器会填充)。但在SOME/IP序列化时,规则不一样。SOME/IP要求每个基本类型从8字节对齐的边界开始。什么意思呢?

咱们一步步看序列化过程:

  1. 先放a(1字节),当前偏移0
  2. 接下来放b,b是4字节类型,需要从8的倍数开始。当前偏移是1,不是8的倍数,所以填充7个字节到偏移8
  3. 放b,占4字节,偏移到12
  4. 放c,c是2字节类型,需要从8的倍数开始。偏移12是8的倍数吗?12 mod 8 = 4,不是0,所以填充4个字节到偏移16
  5. 放c,占2字节,偏移到18

最终这个结构体序列化后占用18字节。你看,比C语言里的12字节多了6个填充字节。

注意:我曾经在一个项目里,结构体里有个uint8_t后面跟着uint64_t,结果中间填充了7个字节。接收方按C结构体直接memcpy解析,数据全错位了。所以啊,SOME/IP序列化后的数据,绝对不能直接用memcpy映射到C结构体!必须按SOME/IP规则逐字段解析。

1.4 数组的序列化

数组序列化相对简单——就是重复序列化每个元素。但分两种情况:

固定长度数组:

// 定义:uint8_t data[4];
// 序列化结果:直接放4个字节,没有长度前缀

动态长度数组:

// 定义:uint8_t data[];
// 序列化结果:前4字节放数组长度,后面跟实际数据
// 例如:长度=3,数据=[0x01, 0x02, 0x03]
// 序列化后:00 00 00 03 01 02 03

这里有个细节——动态数组的长度字段是UInt32类型,占4字节,大端序。我见过有人用uint16_t存长度,结果数组超过65535个元素时直接溢出。虽然实际场景很少遇到这么大的数组,但规范就是规范,别自己改。

1.5 对齐与填充规则详解

好,这是本章的重点。SOME/IP的对齐规则,我总结成一句话:每个基本类型的数据,其起始偏移必须是8的倍数

为什么是8?因为SOME/IP设计时考虑了64位处理器的对齐要求。你想想看,如果数据从8的倍数地址开始,CPU读取时效率最高。

具体规则如下:

  • 1字节类型(uint8_t):无对齐要求,可以放在任何位置
  • 2字节类型(uint16_t):起始偏移必须是8的倍数?不对,是2的倍数?等等,这里容易混淆。

嗯,我澄清一下。SOME/IP的规则是:所有基本类型都从8字节对齐边界开始。不管你是1字节还是8字节,都按8对齐。这和C语言里按自身大小对齐不一样。

看个复杂点的例子:

struct Complex {
    uint8_t   a;    // 1字节
    uint8_t   b;    // 1字节
    uint32_t  c;    // 4字节
    uint16_t  d;    // 2字节
    uint8_t   e;    // 1字节
};

序列化过程:

  1. a在偏移0,占1字节
  2. b在偏移1,占1字节(1字节类型不需要对齐到8?不对,规则是所有类型都从8倍数开始。但这里b紧跟在a后面,因为a只占1字节,偏移1不是8的倍数?等等,我重新理一下)

实际上,SOME/IP的规则是:每个字段的起始偏移必须是8的倍数。所以:

  1. a在偏移0(8的倍数),占1字节,偏移变为1
  2. b需要从8的倍数开始,偏移1不是,填充7字节到偏移8,然后放b,占1字节,偏移变为9
  3. c需要从8的倍数开始,偏移9不是,填充7字节到偏移16,然后放c,占4字节,偏移变为20
  4. d需要从8的倍数开始,偏移20不是(20 mod 8 = 4),填充4字节到偏移24,然后放d,占2字节,偏移变为26
  5. e需要从8的倍数开始,偏移26不是,填充6字节到偏移32,然后放e,占1字节,偏移变为33

最终占用33字节。你看,大部分字节都是填充,真正有用的数据只有5字节。效率确实不高,但这就是规范。

优化建议:我个人的习惯是,在设计SOME/IP接口时,尽量把同类型的字段放在一起。比如把所有uint8_t放前面,uint16_t放中间,uint32_t放后面。这样可以减少填充字节。你想想看,如果结构体里全是uint64_t,那就不需要任何填充了。

1.6 实战中的注意事项

最后,分享几个我在项目中踩过的坑:

  • 字符串编码:SOME/IP字符串默认是UTF-8编码。我曾经遇到一个项目,发送方用GB2312编码,接收方按UTF-8解析,中文全乱码了。所以一定要统一编码。
  • 布尔类型:SOME/IP没有专门的布尔类型,通常用uint8_t表示,0为false,非0为true。但有些实现用uint32_t,注意对齐。
  • 枚举类型:枚举按底层整数类型序列化,通常是uint32_t。但如果你定义的是uint8_t的枚举,那就按1字节处理。
  • 浮点数:Float32和Float64都遵循IEEE 754标准。但不同平台的字节序可能不同,SOME/IP统一用大端序(网络字节序)。

避坑指南:我曾经在一个项目中,发送方和接收方用了不同的SOME/IP库。发送方用的库对结构体做了优化,把填充字节去掉了;接收方用的库严格按规范填充。结果两边数据长度对不上,握手都过不去。所以啊,一定要确认两端的序列化实现是否一致,最好用同一个库。

好了,第一章的内容就到这里。序列化规则是SOME/IP的基础,看似简单,但细节很多。下一章咱们讲反序列化,到时候会结合Wireshark抓包,看看实际报文里这些规则是怎么体现的。

记住一句话:序列化时多花点心思,联调时少熬几个夜