3、SOME/IP序列化与反序列化:PDU结构、TLV编码、数据类型映射

好,咱们今天聊聊SOME/IP里最实在的东西——序列化与反序列化。

说白了,就是把咱们程序里的结构体、数组、字符串,变成一串能在以太网上跑的二进制流。反过来,收到二进制流再还原成程序能认的数据结构。这个过程,就是SOME/IP通信的基石。

我刚开始接触SOME/IP时,觉得这不就是编解码嘛,有啥难的?后来踩了几个坑才明白,细节决定成败。尤其是PDU结构怎么设计、TLV怎么用、各种数据类型怎么映射,稍不留神就会出兼容性问题。

3.1 PDU结构:数据在线上怎么排

先看PDU。Protocol Data Unit,协议数据单元。SOME/IP的PDU结构其实挺清晰的,我画个简图你感受下:

+------------------+
| Message ID       | 4字节
+------------------+
| Length           | 4字节
+------------------+
| Request ID       | 4字节
+------------------+
| Protocol Version | 1字节
| Interface Version| 1字节
| Message Type     | 1字节
| Return Code      | 1字节
+------------------+
| Payload          | 变长
+------------------+

嗯,这里要注意:Length字段是从Request ID之后开始算的,不包括Message ID和Length本身。这个坑我见过好几次,有人把整个报文长度都填进去了,结果接收方解析出来全是错的。

Payload部分才是咱们真正要序列化的数据。它可以是固定长度的,也可以是TLV编码的。具体用哪种,取决于你的服务接口设计。

3.2 TLV编码:灵活但别滥用

TLV,Tag-Length-Value。说白了就是给每个数据字段加个标签和长度前缀。

为什么需要TLV?因为有些场景下,数据字段是可选的,或者长度不固定。比如诊断服务、配置更新这类场景,用TLV就特别灵活。

TLV的基本结构:

+--------+--------+--------+
| Tag    | Length | Value  |
| 2字节  | 2字节  | 变长   |
+--------+--------+--------+

Tag用来标识这是什么字段,Length告诉接收方Value有多长。接收方读到Tag不认识?直接跳过Length指定的字节数就行。这就是TLV最大的好处——向前兼容。

我曾经踩过的坑:TLV的Tag和Length字节数,不同协议栈可能不一样。有的用1字节Tag,有的用2字节。设计初期一定要定死,不然后面联调时全是泪。

我个人习惯,在车载项目里TLV的Tag用2字节,Length用2字节。这样Tag范围够大,Length也能覆盖大部分场景。如果数据特别大,可以考虑用4字节Length,但一般用不上。

3.3 数据类型映射:从代码到二进制

这块是序列化的核心。咱们程序里的各种数据类型,怎么变成线上一串0和1?

3.3.1 基础类型

C++类型SOME/IP类型字节数说明
uint8UINT81无符号8位
uint16UINT162无符号16位,大端序
uint32UINT324无符号32位,大端序
uint64UINT648无符号64位,大端序
sint8SINT81有符号8位
sint16SINT162有符号16位,大端序
float32FLOAT324IEEE 754单精度
float64FLOAT648IEEE 754双精度

注意:SOME/IP统一使用大端序(网络字节序)。你想想看,如果有的ECU用小端,有的用大端,那不乱套了?所以协议里直接定死了,大家都用大端。

我在项目中遇到过一个问题:某供应商的MCU默认是小端,他们忘了做字节序转换,结果传过来的uint16数值全反了。排查了一整天,最后发现是字节序的问题。

3.3.2 结构体

结构体的序列化,说白了就是把成员变量按顺序排好,一个接一个地写进buffer里。

// 定义结构体
struct VehicleSpeed {
    uint32 timestamp;    // 4字节
    float speed;         // 4字节
    uint8 quality;       // 1字节
};

// 序列化后的二进制布局
+----------+-------+---------+
|timestamp | speed | quality |
| 4字节    | 4字节 | 1字节   |
+----------+-------+---------+

这里有个细节:对齐问题。SOME/IP序列化时,结构体成员是紧密排列的,不做内存对齐。也就是说,上面那个结构体序列化后就是9字节,不是12字节。

小技巧:设计结构体时,尽量把相同大小的成员放一起。比如所有uint32放前面,uint8放后面。这样序列化代码写起来更规整,也方便调试。

3.3.3 数组

数组分两种:固定长度数组和动态长度数组。

固定长度数组:直接按顺序把每个元素序列化就行,不需要长度前缀。

// 固定长度数组
uint8 rgb[3] = {0xFF, 0x00, 0x00};

// 序列化后
+------+------+------+
| 0xFF | 0x00 | 0x00 |
+------+------+------+

动态长度数组:需要在数据前面加一个长度字段,告诉接收方有多少个元素。

// 动态长度数组
vector<uint16> values = {100, 200, 300};

// 序列化后
+--------+------+------+------+
| length | val1 | val2 | val3 |
| 4字节  | 2字节 | 2字节 | 2字节 |
+--------+------+------+------+
// length = 3

嗯,这里要注意:长度字段的单位是元素个数,不是字节数。有些协议栈用字节数,但SOME/IP规范里明确说是元素个数。我见过有人搞混,结果解析出来的数组完全不对。

3.3.4 字符串

字符串在SOME/IP里,本质上就是个动态长度的字节数组。

// 字符串
string name = "Hello";

// 序列化后
+--------+-----+-----+-----+-----+-----+
| length | 'H' | 'e' | 'l' | 'l' | 'o' |
| 4字节  | 1字节| 1字节| 1字节| 1字节| 1字节|
+--------+-----+-----+-----+-----+-----+
// length = 5

字符串的编码方式,SOME/IP默认使用UTF-8。我个人建议,所有字符串都用UTF-8。为什么?因为UTF-8兼容ASCII,而且没有字节序问题。如果你用UTF-16,还得操心大端小端,麻烦得很。

核心要点:

  • 基础类型:统一大端序,字节数固定
  • 结构体:成员紧密排列,不做对齐
  • 数组:固定长度直接排,动态长度加元素个数前缀
  • 字符串:UTF-8编码,长度字段是字符数

3.4 反序列化:逆向操作,但更考验细节

反序列化就是序列化的逆过程。但说实话,反序列化比序列化更容易出bug。

为什么?因为序列化是你自己写的,你知道数据长什么样。反序列化时,你面对的是别人发来的数据,可能格式不对、长度不对、甚至恶意数据。

我写反序列化代码时,一定会做这几件事:

  1. 长度检查:读取每个字段前,先确认buffer里还有足够的字节
  2. 范围检查:数值是否在合理范围内?比如uint8不能超过255
  3. 版本检查:如果TLV的Tag不认识,是忽略还是报错?

举个例子,反序列化一个动态数组:

// 伪代码
uint32_t element_count = read_uint32(buffer);
if (element_count > MAX_ARRAY_SIZE) {
    // 异常处理,防止内存溢出
    return ERROR;
}
for (uint32_t i = 0; i < element_count; i++) {
    uint16_t value = read_uint16(buffer);
    array.push_back(value);
}

你看,先检查元素个数是否合理。如果不检查,对方发个element_count = 0xFFFFFFFF,你的程序直接崩了。

我曾经踩过的坑:有一次反序列化字符串时,忘了检查长度字段。对方发了个长度=0的字符串,结果我直接memcpy了0字节,看起来没问题。但后续代码以为字符串有内容,访问了空指针。这种bug特别隐蔽,很难复现。

3.5 实战建议

说了这么多,最后给几个实战中的建议:

  • 序列化反序列化代码,一定要写单元测试。构造各种边界情况的数据,确保代码健壮。
  • 用工具生成代码。手动写序列化代码太容易出错了。ARXML定义好接口,用工具自动生成,省心省力。
  • 日志要详细。序列化失败时,把buffer内容以hex形式打印出来。调试时能省一半时间。
  • 版本兼容性。设计PDU时,预留一些保留字段。后续加功能时,不用改协议版本号。

好了,序列化与反序列化这块就聊到这儿。说白了就是:写的时候规规矩矩,读的时候小心翼翼。下一章咱们聊聊SOME/IP的服务发现,那才是真正有意思的东西。