4、SOME/IP序列化与反序列化:基于CMake的序列化实现、基本数据类型与复杂数据类型的序列化规则

好,咱们今天聊点硬核的——序列化与反序列化。

说实话,很多刚接触SOME/IP的朋友,一上来就被「序列化」这三个字唬住了。其实说白了,序列化就是把内存里的数据结构,变成一串能在网络上传输的字节流。反序列化就是反过来,把字节流还原成数据结构。

我在项目里见过不少同事,协议栈调通了,但数据一传就乱码。查到最后,十有八九是序列化规则没对齐。嗯,这块儿确实容易踩坑。

4.1 为什么序列化这么重要?

SOME/IP是面向服务的通信协议。服务嘛,就得传数据。你传一个整数、一个字符串、甚至一个嵌套的结构体,怎么保证发送方和接收方理解的是同一个东西?

答案就是:大家都遵守同一套序列化规则。

我个人习惯把序列化比作「打包快递」。你想想看,你把一个杯子放进纸箱,塞好泡沫,封箱,贴标签——这就是序列化。收件人拆箱、取出杯子——这就是反序列化。如果打包方式不统一,你塞进去的是杯子,对方拆出来可能是一堆碎玻璃。

核心要点:SOME/IP序列化规则定义在AUTOSAR标准中,所有符合规范的ECU都必须遵循同一套规则。这是跨平台、跨芯片通信的基础。

4.2 基本数据类型的序列化规则

先看最简单的。基本类型包括:uint8、uint16、uint32、uint64、sint8、sint16、sint32、sint64、float32、float64、boolean等。

规则其实就两条:

  • 小端字节序(Little-Endian):低字节在低地址。这是SOME/IP的默认规则。
  • 对齐填充(Alignment Padding):每个基本类型的数据,其起始地址必须是该类型大小的整数倍。

举个例子,uint32的值0x12345678,在内存里怎么放?

地址偏移: 0x00  0x01  0x02  0x03
字节内容: 0x78  0x56  0x34  0x12

看到了吗?低字节0x78在最前面。这就是小端。

那对齐呢?我遇到过一个问题:一个结构体里先放一个uint8,再放一个uint32。uint8占1字节,uint32需要4字节对齐。那uint32就不能紧跟在uint8后面,中间要填充3个空字节。

我的小技巧:写序列化代码时,先画一张内存布局图。把每个字段的起始偏移算清楚,对齐填充一目了然。别偷懒,画图真的省事儿。

4.3 复杂数据类型的序列化规则

基本类型搞定了,复杂类型其实也不难。无非是结构体、数组、字符串、枚举、Union这些。

4.3.1 结构体(Struct)

结构体的序列化规则很简单:

  • 成员按声明顺序依次序列化
  • 每个成员遵循自身的对齐规则
  • 结构体整体不强制对齐到4或8字节(但很多实现会做)

举个例子:

struct Example {
    uint8   a;      // 偏移0,占1字节
    // 填充3字节(对齐到4)
    uint32  b;      // 偏移4,占4字节
    uint16  c;      // 偏移8,占2字节
    // 无尾部填充
};

序列化后的字节流长度是10字节,不是1+4+2=7字节。那3个填充字节就是对齐的代价。

注意:不同编译器对结构体的内存对齐处理可能不同。我曾经在ARM和x86平台之间传数据,就因为编译器默认对齐策略不一样,导致结构体大小不一致。解决方案是:手动指定对齐方式,或者用序列化代码逐字段处理,不要直接memcpy整个结构体。

4.3.2 数组(Array)

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

  • 固定长度数组:直接按顺序序列化每个元素,元素之间没有填充。
  • 动态长度数组:先序列化长度字段(通常是uint32),再序列化元素本身。

我习惯把动态数组的序列化叫做「TLV」模式——Type-Length-Value。SOME/IP里虽然没有显式的Type,但Length和Value是标配。

4.3.3 字符串(String)

SOME/IP的字符串序列化规则:

  • 先序列化字符串长度(uint32,包含终止符吗?标准说包含)
  • 再序列化字符串内容(UTF-8编码)
  • 最后补一个空终止符'\0'

嗯,这里有个坑。我记得有一次,接收方解析字符串时总是多出几个乱码字符。查了半天,发现发送方序列化时长度字段没包含终止符,而接收方按标准解析时多读了一个字节。对不上。

4.3.4 枚举(Enum)

枚举在SOME/IP里就是整数。默认用uint32表示。你定义枚举值0、1、2,序列化出来就是0x00000000、0x00000001、0x00000002。

没什么花头,但要注意:枚举值必须唯一,且不能超出uint32范围。

4.3.5 Union(联合体)

Union的序列化稍微复杂一点。SOME/IP标准里,Union需要带一个类型标识符(Type ID),告诉接收方当前Union里存的是哪个成员。

序列化顺序:先序列化Type ID(uint32),再序列化对应的成员数据。

我的建议:尽量少用Union。能用结构体解决的问题,别用Union。Union的序列化逻辑容易出错,而且调试起来很痛苦。如果非用不可,一定要写单元测试覆盖所有分支。

4.4 基于CMake的序列化实现

理论说完了,咱们动手写代码。我习惯用CMake管理项目,干净利落。

先看项目结构:

someip_serializer/
├── CMakeLists.txt
├── include/
│   └── serializer.h
└── src/
    ├── serializer.c
    └── main.c

CMakeLists.txt长这样:

cmake_minimum_required(VERSION 3.10)
project(someip_serializer C)

set(CMAKE_C_STANDARD 11)

include_directories(include)

add_executable(serializer_demo
    src/main.c
    src/serializer.c
)

序列化器的核心接口,我设计成这样的:

// serializer.h
#ifndef SERIALIZER_H
#define SERIALIZER_H

#include <stdint.h>
#include <stddef.h>

// 序列化一个uint32(小端)
void serialize_uint32(uint8_t *buffer, size_t *offset, uint32_t value);

// 反序列化一个uint32
uint32_t deserialize_uint32(const uint8_t *buffer, size_t *offset);

// 序列化一个字符串(带长度前缀)
void serialize_string(uint8_t *buffer, size_t *offset, const char *str);

// 反序列化一个字符串(返回堆分配的内存,调用者负责释放)
char* deserialize_string(const uint8_t *buffer, size_t *offset);

#endif

实现部分,我挑一个典型的给你看:

// serializer.c
#include "serializer.h"
#include <string.h>
#include <stdlib.h>

void serialize_uint32(uint8_t *buffer, size_t *offset, uint32_t value) {
    // 小端序:低字节在前
    buffer[(*offset)++] = (uint8_t)(value & 0xFF);
    buffer[(*offset)++] = (uint8_t)((value >> 8) & 0xFF);
    buffer[(*offset)++] = (uint8_t)((value >> 16) & 0xFF);
    buffer[(*offset)++] = (uint8_t)((value >> 24) & 0xFF);
}

uint32_t deserialize_uint32(const uint8_t *buffer, size_t *offset) {
    uint32_t value = 0;
    value |= (uint32_t)buffer[(*offset)++];
    value |= (uint32_t)buffer[(*offset)++] << 8;
    value |= (uint32_t)buffer[(*offset)++] << 16;
    value |= (uint32_t)buffer[(*offset)++] << 24;
    return value;
}

void serialize_string(uint8_t *buffer, size_t *offset, const char *str) {
    size_t len = strlen(str) + 1;  // 包含终止符
    // 先序列化长度
    serialize_uint32(buffer, offset, (uint32_t)len);
    // 再序列化字符串内容
    memcpy(&buffer[*offset], str, len);
    *offset += len;
}

char* deserialize_string(const uint8_t *buffer, size_t *offset) {
    uint32_t len = deserialize_uint32(buffer, offset);
    char *str = (char*)malloc(len);
    if (str) {
        memcpy(str, &buffer[*offset], len);
        *offset += len;
    }
    return str;
}

你看,代码其实不复杂。核心就是逐字节操作,严格按照小端序来。

关键点:序列化时,*offset指针要同步更新。每写一个字节,偏移加1。反序列化同理。这个偏移量就是你的「光标」,指到哪儿读到哪儿。

4.5 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 对齐问题:不同平台、不同编译器的默认对齐策略不一样。解决方案是手动填充,或者用#pragma pack(1)强制1字节对齐。但我个人不推荐后者,因为会影响性能。
  • 字符串编码:SOME/IP标准要求UTF-8。但有些老项目还在用ASCII或GBK。跨平台通信时,编码不一致会导致乱码。我建议统一用UTF-8,省心。
  • 大小端混淆:SOME/IP默认小端,但有些嵌入式平台是大端(比如某些PowerPC)。如果你在大小端混合的环境里工作,序列化时一定要做字节序转换。
  • 缓冲区溢出:序列化时,确保目标缓冲区足够大。我见过一个bug,序列化一个很大的动态数组时,缓冲区写爆了,直接覆盖了相邻内存。调试了两天才找到原因。

嗯,序列化这块儿,说白了就是「细心活」。规则不复杂,但细节多。你只要把每个字节的位置算清楚,对齐填充搞明白,基本就不会出大问题。

下一章,咱们聊聊SOME/IP的通信流程和Service Discovery。到时候你会发现,序列化只是基本功,真正的挑战还在后面。