Skip to content

ROS2 消息序列化深度分析

背景

ROS2 的通信建立在 DDS 标准之上,消息在发送前必须序列化为 CDR (Common Data Representation) 二进制格式。理解整条序列化管线 -- 从 .msg 文件到网络字节流 -- 对 DDS Wrapper 的适配层设计和性能优化至关重要。

1. rosidl 代码生成管线

1.1 完整管线

从 Dashing 开始,ROS2 消息生成管线基于 .idl 中间表示:

.msg 文件 → rosidl_parser (解析) → rosidl_adapter → .idl (OMG IDL 4.2)

                                            ┌──────────┼──────────┐
                                            ↓          ↓          ↓
                                    generator_cpp  typesupport  introspection
                                    (C++ 结构体)   (类型支持)    (内省元数据)

1.2 生成的关键文件

std_msgs/msg/String 为例,rosidl_generator_cpp 生成:

文件 内容
string.hpp 顶层头文件,用户代码 include 这个
string__struct.hpp 消息 struct 定义,字段即成员变量
string__traits.hpp 编译时类型特性:is_messagehas_fixed_sizehas_bounded_size

生成的结构体是 plain struct,所有实现均在头文件中(header-only)。

1.3 ROS 到 DDS 类型映射

rosidl_dds 自动完成类型转换:

ROS 类型 DDS IDL 类型 Wire 大小
bool boolean 1 字节
byte / uint8 octet 1 字节
float32 float 4 字节
float64 double 8 字节
int32 long 4 字节
int64 long long 8 字节
string string 变长
T[] sequence<T> 变长
T[N] T[N] 固定

1.4 Service 的 IDL 生成

一个 .srv 文件生成两个 .idl(Request + Response):

MyService.srv → MyService_Request.idl + MyService_Response.idl

2. CDR 序列化格式

2.1 封装头 (Encapsulation Header)

每条 CDR 消息开头 4 字节:

字节 0: 编码格式标识
  0x00 = PLAIN_CDR Big Endian (XCDR1)
  0x01 = PLAIN_CDR Little Endian (XCDR1)    ← ROS2 默认
  0x02 = PL_CDR Big Endian
  0x03 = PL_CDR Little Endian
  0x10~0x13 = XCDR2 变体

字节 1-3: 选项字段(通常为 0)

ROS2 序列化消息开头多出的 4 字节就是这个 encapsulation header。

2.2 对齐规则

每个基本类型按自然边界对齐,通过 padding 补齐:

struct Example {
    uint8  a;     // offset 0, 1 字节
                  // 3 字节 padding
    uint32 b;     // offset 4, 4 字节
    uint16 c;     // offset 8, 2 字节
                  // 6 字节 padding
    double d;     // offset 16, 8 字节
};                // 总计 24 字节

XCDR1 vs XCDR2 的关键差异:XCDR1 中 double 按 8 字节对齐,XCDR2 最大只按 4 字节对齐。

2.3 复合类型编码

String[4 字节长度 N (uint32 LE)][N 字节 UTF-8 数据 + NUL 终止符]

例:"hello"06 00 00 00 68 65 6C 6C 6F 00(长度 6 = 5 字符 + \0

Sequence[4 字节元素个数][元素1][元素2]...

Array:连续存储,无长度前缀。

Nested struct:递归应用 CDR 规则,从第一个字段的对齐要求开始。

3. Type Support 机制

ROS2 有两种类型支持策略,直接影响序列化性能:

3.1 静态类型支持 (Static Type Support)

  • 编译时为每个消息类型生成专用序列化/反序列化函数
  • 每个 .msg 生成独立的函数实现,直接访问结构体成员
  • rmw_fastrtps_cpp 使用此方案
.msg → .idl → FastRTPS 代码生成器 → 专用的 serialize()/deserialize()

3.2 内省类型支持 (Introspection Type Support)

  • 编译时生成消息元数据描述(成员名、类型、偏移量)
  • 运行时通过遍历元数据,用通用函数处理任意消息类型
  • rmw_cyclonedds_cpp 使用此方案

核心数据结构:

cpp
// 描述整个消息
struct MessageMembers {
    const char* message_namespace_;
    const char* message_name_;
    uint32_t member_count_;
    const MessageMember* members_;    // 字段描述数组
    size_t size_of_;                  // struct 的 sizeof
};

// 描述单个字段
struct MessageMember {
    const char* name_;
    uint32_t type_id_;
    size_t offset_;                   // 在 struct 中的偏移量
    bool is_array_;
    uint32_t array_size_;
};

3.3 两种策略对比

方面 静态 (FastRTPS) 内省 (CycloneDDS)
序列化函数 每类型独立生成 通用函数 + 元数据遍历
运行时开销 无(直接访问成员) 有间接调用和遍历
编译时间 较长 较短
二进制体积 较大 较小
类型发现 不支持 支持运行时发现

4. 完整数据流

4.1 发布路径

4.2 接收路径

5. rmw_cyclonedds 的序列化实现细节

5.1 两阶段序列化

rmw_cyclonedds 将大小计算与数据写入分离:

  1. SizeCursor:遍历消息成员元数据,计算 CDR 编码后的总大小(不写数据)
  2. DataCursor:分配好 buffer 后,遍历成员写入实际数据

避免动态 buffer 扩容。

5.2 Simple Type 快速路径

对于 CDR 兼容内存布局的类型(所有成员是 primitive 且无对齐问题),直接 memcpy 跳过逐成员遍历:

首次序列化 → 检测消息是否 simple type → 缓存结果
后续序列化 → 命中缓存 → memcpy 整个消息体

5.3 serdata 操作表

CycloneDDS 的自定义类型通过 ddsi_serdata_ops 注册:

c
static const struct ddsi_sertopic_ops serdata_ops = {
    .get_size      = ...,   // 获取序列化大小
    .from_sample   = ...,   // 从 ROS2 消息构造 CDR 数据
    .to_sample     = ...,   // 从 CDR 数据恢复 ROS2 消息
    .free          = ...,   // 释放资源
    // ...
};

6. rmw 序列化 API

cpp
// 序列化:ROS 消息 → CDR 字节流
rmw_ret_t rmw_serialize(
    const void* ros_message,
    const rosidl_message_type_support_t* type_support,
    rmw_serialized_message_t* serialized_message);

// 反序列化:CDR 字节流 → ROS 消息
rmw_ret_t rmw_deserialize(
    const rmw_serialized_message_t* serialized_message,
    const rosidl_message_type_support_t* type_support,
    void* ros_message);

rmw_serialized_message_t 本质上是 CDR 编码的字节缓冲区。

7. 零拷贝 (Loaned Message)

7.1 传统路径 vs 零拷贝

传统路径用户消息 → memcpy → CDR 序列化 → DDS 发送(至少两次拷贝)

零拷贝路径DDS 预分配 loaned buffer → 用户直接写入 → 共享内存传输

7.2 关键 API

cpp
// 发布端借入
rmw_borrow_loaned_message(publisher, type_support, &loaned_msg);
// 写入数据后直接发布(发布后自动归还)
rmw_publish_loaned_message(publisher, loaned_msg, allocation);

// 订阅端借入
rmw_take_loaned_message(subscription, &loaned_msg, &taken, allocation);
// 用完释放
rmw_release_loaned_message(subscription, loaned_msg);

7.3 限制条件

  • 仅支持固定大小类型(不含 string/sequence 等变长字段)
  • CycloneDDS 基于 iceoryx 实现,需 XML 启用 SharedMemory
  • QoS 限制:RELIABLE + VOLATILE + KEEP_LAST
  • 环境变量 ROS_DISABLE_LOANED_MESSAGES=1 可全局禁用

7.4 固定大小检测

通过 is_type_self_contained() 判断:

  • 不含 string/wstring(变长类型)
  • 不含无界 sequence
  • 所有嵌套子消息也必须 self-contained

8. 性能数据参考

场景 延迟 说明
小消息 (<1KB) 普通序列化 ~1-10μs 逐成员 CDR 编码
小消息 memcpy 快速路径 ~50ns simple type 缓存命中
小消息 loaned + 共享内存 ~0.1-1μs 跳过序列化
大消息 (>1MB) 普通序列化 ~100μs-1ms 变长字段增加开销
大消息 共享内存 ~1-10μs 仅指针传递

序列化比纯内存拷贝慢约 100 倍,主要开销来自:逐成员遍历、对齐 padding 计算、变长字段内存分配。

9. 对 DDS Wrapper 的启示

  1. Serializer<T> 天然是静态类型支持 -- 编译时模板,无需运行时元数据查找,比 ROS2 内省方式有天然优势
  2. 两阶段序列化 -- getSerializedSize() 预计算 + 一次写入,已在 v3.0 中实现
  3. Simple type memcpy 快速路径 -- 可为 CDR 兼容布局的纯 primitive 类型实现 memcpy 跳板
  4. publishZeroCopy() 路径 -- 可利用 CycloneDDS 的 dds_loan_sample() 实现真正零拷贝
  5. Service wire 协议 -- 当前 [8B requestId LE][用户数据] 的格式与 CDR 的 long long 编码一致,保持兼容

基于 VitePress 构建