第4章 SOME/IP序列化与反序列化

序列化,说白了就是把内存里的数据结构,变成一串能在网络上传输的字节流。反序列化就是反过来,把收到的字节流还原成数据结构。这事儿听起来简单,但做起来全是坑。我在项目里见过太多次因为序列化没对齐,导致ECU之间互相看不懂对方的数据。

嗯,咱们今天就把SOME/IP的序列化规则彻底讲透。

4.1 SOME/IP序列化规则

SOME/IP的序列化规则,核心就一句话:数据怎么放,字节怎么排,对齐怎么搞。我个人习惯把它拆成三个维度来看:

  • 字节序(Byte Order):大端还是小端?SOME/IP默认用大端(Big Endian),也就是网络字节序。但也可以协商成小端,不过我在项目中几乎没见过有人这么干。
  • 对齐(Alignment):每个数据类型都有它的自然对齐边界。比如uint32要4字节对齐,uint16要2字节对齐。不对齐?轻则性能下降,重则直接崩溃。
  • 填充(Padding):为了对齐,中间会塞一些无意义的字节。这些字节叫填充字节,值通常为0。

核心原则:SOME/IP序列化时,每个基本数据类型都从它的自然对齐边界开始存放。如果当前位置不对齐,就插入填充字节。

你想想看,如果发送方和接收方对齐规则不一致,那解析出来的数据全是错的。我曾经排查过一个CAN网关的bug,折腾了两天,最后发现就是对齐规则没统一。

4.2 基本数据类型序列化

SOME/IP支持的基本数据类型包括:uint8、uint16、uint32、uint64、sint8、sint16、sint32、sint64、float32、float64、boolean、string等。

咱们直接看一个uint32的例子。假设我要序列化数值0x12345678:

// 大端序(默认)
内存地址: [0x00] [0x01] [0x02] [0x03]
字节内容:  0x12   0x34   0x56   0x78

// 小端序(不常用)
内存地址: [0x00] [0x01] [0x02] [0x03]
字节内容:  0x78   0x56   0x34   0x12

对于boolean类型,SOME/IP规定用uint8表示,0为false,非0为true。我记得有个同事图省事,直接用bit来表示布尔值,结果跟其他ECU对接时全乱了。记住,SOME/IP里布尔就是1字节,别搞特殊。

string类型稍微特殊一点。它前面会带一个长度字段(uint32),表示字符串的字节数(不含终止符)。然后才是实际的字符串内容。字符串本身不强制以'\0'结尾,但我建议你加上,方便调试。

数据类型 大小(字节) 对齐边界 备注
uint8 / sint8 1 1字节 最基础的类型
uint16 / sint16 2 2字节 注意对齐
uint32 / sint32 / float32 4 4字节 最常用
uint64 / sint64 / float64 8 8字节 64位平台注意
boolean 1 1字节 0或非0
string 长度+内容 4字节(长度字段) 长度字段为uint32

我的习惯:在代码里显式指定字节序,不要依赖编译器默认行为。用htonl()、htons()这类函数,或者自己写宏。这样移植到不同平台时,不会出问题。

4.3 复杂数据类型序列化

复杂数据类型包括结构体(Struct)、数组(Array)、字符串(String)、联合体(Union)等。它们的序列化规则,是在基本类型规则上叠加的。

4.3.1 结构体序列化

结构体的序列化规则很简单:成员按声明顺序依次序列化,每个成员从自己的自然对齐边界开始。注意,结构体本身没有额外的对齐要求,它的对齐取决于内部成员。

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

struct Example {
    uint8   a;      // 1字节,对齐边界1
    uint32  b;      // 4字节,对齐边界4
    uint16  c;      // 2字节,对齐边界2
};

序列化后的内存布局是这样的:

偏移:  0   1   2   3   4   5   6   7   8   9
内容:  [a] [pad] [pad] [pad] [b0] [b1] [b2] [b3] [c0] [c1]
       ^--- a占1字节
            ^--- 填充3字节,让b从4字节对齐开始
                      ^--- b占4字节
                                     ^--- c占2字节,已经2字节对齐

看到了吗?a后面跟了3个填充字节,因为b需要4字节对齐。这就是对齐规则带来的开销。我在项目中优化过一个大结构体,通过调整成员顺序,把填充字节从12个降到了2个,带宽利用率提升了不少。

避坑指南:我曾经遇到过一个bug,发送方和接收方用的结构体成员顺序不一样。发送方是{a, b, c},接收方以为是{a, c, b}。结果解析出来的数据完全对不上。所以,结构体定义必须在双方完全一致,包括成员顺序、类型、对齐方式。

4.3.2 数组序列化

数组的序列化规则:元素按顺序连续存放,每个元素从自己的自然对齐边界开始。如果是固定长度数组,直接序列化所有元素。如果是可变长度数组,前面会带一个长度字段(uint32)。

固定长度数组示例:

uint8 arr[4] = {0x01, 0x02, 0x03, 0x04};
// 序列化结果: 0x01 0x02 0x03 0x04
// 没有填充,因为uint8对齐边界是1

可变长度数组示例:

// 先写长度(uint32),再写元素
uint32 length = 3;
uint16 arr[3] = {0x1001, 0x1002, 0x1003};
// 序列化结果: [0x00 0x00 0x00 0x03] [0x10 0x01] [0x10 0x02] [0x10 0x03]
// 长度字段占4字节,元素从4字节对齐边界开始

嗯,这里要注意:可变长度数组的长度字段本身是uint32,它需要4字节对齐。所以如果数组前面有其他成员,可能需要填充。

4.3.3 联合体序列化

联合体(Union)在SOME/IP里用得不多,但偶尔会遇到。规则是:联合体的大小等于最大成员的大小,序列化时按实际使用的成员来写。但接收方怎么知道当前是哪个成员?通常需要配合一个类型标识字段(比如一个uint8的discriminant)来区分。

我个人建议,除非万不得已,别在SOME/IP里用联合体。它让序列化逻辑变得复杂,而且容易出错。用结构体加可选字段的方式,更清晰。

4.4 序列化对齐规则

对齐规则是SOME/IP序列化里最容易出错的地方。咱们把规则总结一下:

  1. 每个基本数据类型从它的自然对齐边界开始存放。自然对齐边界就是它自身的大小(uint32是4,uint16是2,等等)。
  2. 结构体成员按声明顺序存放,每个成员独立对齐。
  3. 数组元素连续存放,每个元素独立对齐。如果元素类型是uint8,没有填充;如果是uint32,每个元素之间可能有填充?不对,数组元素是连续存放的,不会在元素之间插入填充。但数组整体的起始地址需要对齐到元素的对齐边界。
  4. 字符串的长度字段(uint32)从4字节对齐边界开始,字符串内容紧接着长度字段存放,没有额外对齐要求。

对齐规则速记:每个数据块都从它自身大小的整数倍地址开始。如果当前位置不对,就塞填充字节,直到对齐为止。

为什么会这样设计?说白了,是为了CPU访问效率。很多CPU架构要求对齐访问,否则会触发异常或者性能骤降。SOME/IP在设计时就考虑到了这一点,让序列化后的数据可以直接被CPU高效处理。

我记得有一次,我们在一个ARM Cortex-M3的平台上跑SOME/IP,发现反序列化特别慢。排查了半天,发现是结构体里有个uint32成员没有4字节对齐,导致CPU每次访问都要做两次内存读取。调整对齐后,性能提升了30%。

最后,给一个完整的序列化示例,把今天讲的内容串起来:

// 定义一个复杂结构体
struct VehicleData {
    uint8   vehicleId;      // 1字节
    uint32  speed;          // 4字节
    uint16  rpm;            // 2字节
    uint8   gear;           // 1字节
    uint8   brakePedal;     // 1字节
    uint32  timestamp;      // 4字节
};

// 序列化后的内存布局(大端序)
// 偏移:  0   1   2   3   4   5   6   7   8   9   10  11  12  13  14  15
// 内容: [ID] [pad] [pad] [pad] [speed0] [speed1] [speed2] [speed3] [rpm0] [rpm1] [gear] [brake] [pad] [pad] [ts0] [ts1] [ts2] [ts3]
//        ^--- vehicleId
//             ^--- 填充3字节,让speed从4对齐
//                       ^--- speed占4字节
//                                        ^--- rpm占2字节
//                                                       ^--- gear和brake各1字节
//                                                                       ^--- 填充2字节,让timestamp从4对齐
//                                                                                     ^--- timestamp占4字节

这个例子一共18字节。如果不考虑对齐,实际数据只有1+4+2+1+1+4=13字节。对齐开销是5字节,占比接近28%。所以,设计结构体时,把大的成员往前放,小的往后放,可以有效减少填充字节。

好了,序列化与反序列化这块,核心就是这些。记住对齐规则,记住字节序,记住填充的处理方式。下一章咱们聊SOME/IP的通信模式,那才是真正让协议跑起来的关键。