4、SOME/IP序列化与反序列化:PDU结构、序列化规则、数据类型映射、对齐与填充

好,咱们今天聊点硬核的。序列化与反序列化,说白了就是把内存里的数据结构,变成能在网线上跑的字节流,然后再变回来。这事儿听起来简单,但做起来坑特别多。我在好几个项目里都栽过跟头,后来才慢慢摸清楚门道。

4.1 PDU结构:数据在线上到底长什么样?

先说说PDU,Protocol Data Unit。你可以把它想象成一个信封。SOME/IP的PDU结构,我习惯分成三块来看:

  • 头部(Header):固定16字节,包含Service ID、Method ID、Length、Client ID等。这部分是硬性规定,谁也别想改。
  • 载荷(Payload):真正要传的数据。这部分怎么组织,就是我们今天要聊的重点。
  • 尾部(Trailer):有些协议会加,但SOME/IP基本不用。

嗯,这里要注意:Length字段是从Payload开始算的,不包括Header自身。我见过有人把整个PDU长度填进去,结果对端解析直接崩了。

核心要点:SOME/IP的PDU头部固定16字节,Payload的序列化方式由AUTOSAR的序列化规则决定。

4.2 序列化规则:字节序与数据布局

序列化规则,说白了就是「怎么把数据排成一排」。AUTOSAR里规定得很细,我挑几个关键点说说。

4.2.1 字节序(Byte Order)

SOME/IP默认用大端序(Big-Endian)。为什么?因为网络协议大多用大端,方便调试。你想想看,用Wireshark抓包时,大端序一眼就能看出数值,小端序还得在脑子里反转一下。

不过,AUTOSAR也允许用小端序(Little-Endian),但必须在某个地方声明。我个人建议:统一用大端。我在一个项目里遇到过,ECU A用大端,ECU B用小端,两边都没声明,结果通信数据全是乱码。排查了整整两天才发现是字节序问题。

4.2.2 数据布局规则

AUTOSAR的序列化规则,我总结成三条:

  1. 基本数据类型:直接按字节序排列。比如uint32,就是4个字节,大端序高位在前。
  2. 复合数据类型:按成员顺序排列,每个成员独立序列化。
  3. 数组和字符串:先序列化长度(如果有),再序列化元素。

举个例子,一个结构体:

struct VehicleSpeed {
    uint8_t  status;    // 1字节
    uint16_t speed;     // 2字节
    uint32_t timestamp; // 4字节
};

序列化后的字节流(大端序):

| status (1B) | speed high (1B) | speed low (1B) | timestamp high (1B) | ... | timestamp low (1B) |

你看,status占1字节,speed占2字节,timestamp占4字节。就这么简单直接。

4.3 数据类型映射:C语言类型与SOME/IP类型的对应关系

这个映射表,我建议你打印出来贴在工位上。我在项目中经常需要查,后来干脆背下来了。

SOME/IP类型 C语言类型(AUTOSAR) 字节数 说明
UINT8 uint8 1 无符号8位整数
SINT8 sint8 1 有符号8位整数
UINT16 uint16 2 无符号16位整数
SINT16 sint16 2 有符号16位整数
UINT32 uint32 4 无符号32位整数
SINT32 sint32 4 有符号32位整数
FLOAT32 float32 4 IEEE 754单精度浮点数
FLOAT64 float64 8 IEEE 754双精度浮点数
BOOLEAN boolean 1 0x00为false,非0为true
STRING uint8[] 可变 UTF-8编码,前4字节为长度

个人经验:BOOLEAN类型在SOME/IP里只占1字节,但有些实现会把它当成4字节。我建议你在设计阶段就明确约定好,否则联调时会有惊喜。

4.4 对齐与填充:字节对齐的坑

对齐,是序列化里最容易出问题的地方。为什么要有对齐?因为CPU访问对齐的数据更快。但网络传输时,对齐反而会浪费带宽。

4.4.1 AUTOSAR的对齐规则

AUTOSAR规定:每个数据成员的起始偏移量,必须是其自身大小的整数倍。比如:

  • uint8:偏移量必须是1的倍数(其实就是任意位置)
  • uint16:偏移量必须是2的倍数
  • uint32:偏移量必须是4的倍数
  • uint64:偏移量必须是8的倍数

如果前一个成员结束位置不满足对齐要求,就需要填充(Padding)字节。

4.4.2 填充示例

看这个结构体:

struct Example {
    uint8_t  a;      // 偏移0,占1字节
    // 填充1字节(因为b需要2字节对齐)
    uint16_t b;      // 偏移2,占2字节
    uint32_t c;      // 偏移4,占4字节
};

序列化后的字节流:

| a (1B) | padding (1B) | b high (1B) | b low (1B) | c high (1B) | ... | c low (1B) |

你看,a和b之间多了一个填充字节。这个填充字节的值是0x00。

避坑指南:我曾经在一个项目里,结构体定义时没注意成员顺序,结果序列化后多了4个填充字节。对端解析时直接按顺序读,数据全错位了。后来我养成了一个习惯:把大的数据类型放在前面,小的放后面,这样可以减少填充。

4.4.3 结构体末尾的填充

AUTOSAR还规定:结构体末尾也要填充到最大成员大小的整数倍。比如上面的Example结构体,最大成员是uint32(4字节),总大小是1+1+2+4=8字节,已经是4的倍数,所以不需要末尾填充。

但如果结构体是:

struct BadExample {
    uint32_t x;      // 偏移0,占4字节
    uint8_t  y;      // 偏移4,占1字节
    // 填充3字节(因为结构体总大小需要是4的倍数)
};

序列化后总大小是8字节,其中最后3字节是填充。

4.5 实战建议:序列化与反序列化的实现

说了这么多理论,来点实际的。我一般这样实现序列化:

// 序列化函数示例
void serialize_VehicleSpeed(const VehicleSpeed* data, uint8_t* buffer) {
    uint32_t offset = 0;
    
    // 序列化status(uint8,无需对齐)
    buffer[offset++] = data->status;
    
    // 对齐到2字节边界
    if (offset % 2 != 0) {
        buffer[offset++] = 0x00; // 填充字节
    }
    
    // 序列化speed(uint16,大端序)
    buffer[offset++] = (data->speed >> 8) & 0xFF;
    buffer[offset++] = data->speed & 0xFF;
    
    // 对齐到4字节边界
    while (offset % 4 != 0) {
        buffer[offset++] = 0x00;
    }
    
    // 序列化timestamp(uint32,大端序)
    buffer[offset++] = (data->timestamp >> 24) & 0xFF;
    buffer[offset++] = (data->timestamp >> 16) & 0xFF;
    buffer[offset++] = (data->timestamp >> 8) & 0xFF;
    buffer[offset++] = data->timestamp & 0xFF;
}

我的习惯:写序列化代码时,我会在关键位置加断言(assert),检查偏移量是否正确。比如序列化完uint16后,断言offset % 2 == 0。这样一旦出错,调试阶段就能发现,不用等到实车测试。

反序列化就是反过来,从buffer里按同样的规则读出数据。注意:填充字节要跳过,不要读进来

嗯,序列化这块内容不少,但核心就是:理解PDU结构、遵守序列化规则、搞清数据类型映射、处理好对齐与填充。把这四点吃透了,SOME/IP的序列化你就掌握了八成。剩下的两成,是在实际项目中踩坑积累的经验。

下一章,咱们聊聊SOME/IP的服务发现(SD),那又是一个容易出幺蛾子的地方。