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_message、has_fixed_size、has_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 使用此方案
核心数据结构:
// 描述整个消息
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 将大小计算与数据写入分离:
- SizeCursor:遍历消息成员元数据,计算 CDR 编码后的总大小(不写数据)
- DataCursor:分配好 buffer 后,遍历成员写入实际数据
避免动态 buffer 扩容。
5.2 Simple Type 快速路径
对于 CDR 兼容内存布局的类型(所有成员是 primitive 且无对齐问题),直接 memcpy 跳过逐成员遍历:
首次序列化 → 检测消息是否 simple type → 缓存结果
后续序列化 → 命中缓存 → memcpy 整个消息体 5.3 serdata 操作表
CycloneDDS 的自定义类型通过 ddsi_serdata_ops 注册:
static const struct ddsi_sertopic_ops serdata_ops = {
.get_size = ..., // 获取序列化大小
.from_sample = ..., // 从 ROS2 消息构造 CDR 数据
.to_sample = ..., // 从 CDR 数据恢复 ROS2 消息
.free = ..., // 释放资源
// ...
}; 6. rmw 序列化 API
// 序列化: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
// 发布端借入
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 的启示
Serializer<T>天然是静态类型支持 -- 编译时模板,无需运行时元数据查找,比 ROS2 内省方式有天然优势- 两阶段序列化 --
getSerializedSize()预计算 + 一次写入,已在 v3.0 中实现 - Simple type memcpy 快速路径 -- 可为 CDR 兼容布局的纯 primitive 类型实现
memcpy跳板 publishZeroCopy()路径 -- 可利用 CycloneDDS 的dds_loan_sample()实现真正零拷贝- Service wire 协议 -- 当前
[8B requestId LE][用户数据]的格式与 CDR 的long long编码一致,保持兼容