第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序列化里最容易出错的地方。咱们把规则总结一下:
- 每个基本数据类型从它的自然对齐边界开始存放。自然对齐边界就是它自身的大小(uint32是4,uint16是2,等等)。
- 结构体成员按声明顺序存放,每个成员独立对齐。
- 数组元素连续存放,每个元素独立对齐。如果元素类型是uint8,没有填充;如果是uint32,每个元素之间可能有填充?不对,数组元素是连续存放的,不会在元素之间插入填充。但数组整体的起始地址需要对齐到元素的对齐边界。
- 字符串的长度字段(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的通信模式,那才是真正让协议跑起来的关键。