Skip to content

ROS2 消息序列化:从 .msg 到 CDR 字节流 ​

ROS2 的通信建立在 DDS 标准之上,一条消息从 publish() 到达对端,中间要经过代码生成、类型支持、CDR 编码三层机制。本文自顶向下拆解整条序列化管线:rosidl 如何把 .msg 变成 C++ 结构体,CDR 线格式如何对齐与编码,两种类型支持策略的差异在哪里,rmw_cyclonedds 适配器内部的自定义序列化如何实现,内省类型支持又如何在运行时支撑 GenericPublisher 的类型无关发布,以及零拷贝 loaned message 的生效条件。适合写过 ROS2 节点、想理解消息传输开销来源或做中间件适配的读者。

rosidl 代码生成管线 ​

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

text
.msg 文件 → rosidl_parser (解析) → rosidl_adapter → .idl (OMG IDL 4.2)
                                                        ↓
                                            ┌──────────┼──────────┐
                                            ↓          ↓          ↓
                                    generator_cpp  typesupport  introspection
                                    (C++ 结构体)   (类型支持)    (内省元数据)

以 std_msgs/msg/String 为例,rosidl_generator_cpp 生成三类头文件(较新的发行版中后两类位于 msg/detail/ 子目录):

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

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

ROS 类型到 DDS IDL 类型按固定映射转换:

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] 固定

Service 侧同理,一个 .srv 文件生成两个 .idl:

text
MyService.srv → MyService_Request.idl + MyService_Response.idl

CDR 线格式 ​

封装头 ​

每条 CDR 消息开头 4 字节,是 16 位 representation identifier(大端)加 16 位 options(通常为 0):

text
字节 0-1: 编码格式标识 (representation id, 大端)
  0x00 = PLAIN_CDR Big Endian (XCDR1)
  0x01 = PLAIN_CDR Little Endian (XCDR1)    ← ROS2 默认
  0x02 = PL_CDR Big Endian (参数列表,发现数据用)
  0x03 = PL_CDR Little Endian
  0x08/0x09 = PLAIN_CDR2 Big/Little Endian (XCDR2, final 类型)
  0x0a/0x0b = DELIMITED_CDR2 Big/Little Endian (XCDR2, appendable 类型)
  0x10/0x11 = PL_CDR2 Big/Little Endian (XCDR2, mutable 类型)

字节 2-3: options 字段(通常为 0)

抓包时看到 ROS2 序列化消息开头多出 4 字节,就是封装头。

对齐规则 ​

每个基本类型按自然边界对齐,不足处补 padding:

cpp
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 与 XCDR2 的关键差异在对齐上限:XCDR1 中 double、int64 等 64 位类型按 8 字节对齐,XCDR2 把所有基本类型的对齐上限收紧到 4 字节。这也是含 8 字节成员的 FINAL 类型在两种编码间不互通的原因——即使类型结构完全相同,padding 布局也不同。

复合类型编码 ​

String:[4 字节长度 N (uint32 LE)][N 字节 UTF-8 数据 + NUL 终止符]。长度包含结尾的 \0:"hello" 编码为 06 00 00 00 68 65 6C 6C 6F 00(6 = 5 个字符加 1 个终止符)。

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

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

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

两种类型支持策略 ​

ROS2 的 RMW 实现有两种获得序列化能力的方式,直接影响性能特征:

静态类型支持 ​

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

内省类型支持 ​

  • 编译时生成消息的元数据描述(成员名、类型、偏移量)
  • 运行时通过遍历元数据,用通用函数处理任意消息类型
  • rmw_cyclonedds_cpp 使用此方案;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_;
};

对比 ​

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

内省类型支持的运行时消费者:GenericPublisher ​

内省类型支持不仅服务于此处讨论的序列化,还有一类直接消费者:rclcpp 的 GenericPublisher / GenericSubscription。它们在编译期不知道消息类型的情况下实现类型无关的发布订阅——ros2 topic echo、rosbag2 的 Player/Recorder 都建立在这组类之上。

运行时加载类型支持 ​

GenericPublisher 不是模板类,直接继承 PublisherBase,消息类型在运行时从类型字符串(如 "std_msgs/msg/String")动态解析,分三步(typesupport_helpers.cpp):

  1. extract_type_identifier() 把类型字符串拆成 package_name / middle_module / type_name 三部分
  2. get_typesupport_library_path() 通过 ament 索引定位类型支持库文件(如 libstd_msgs__rosidl_typesupport_cpp.so),get_typesupport_library() 用 rcpputils::SharedLibrary 动态加载
  3. get_message_typesupport_handle() 在库内按符号名查找并取得类型支持句柄,符号命名规则为 {typesupport_identifier}__get_message_type_support_handle__{package}__{module}__{type}

加载成功的库由成员 ts_lib_(std::shared_ptr<rcpputils::SharedLibrary>)持有,保证发布器存活期间类型支持库不被卸载。

直接发布序列化消息 ​

类型未知意味着拿不到结构体,GenericPublisher 的 publish() 直接发布序列化消息:

cpp
rcl_publish_serialized_message(
    get_publisher_handle().get(),
    &message.get_rcl_serialized_message(), NULL);

此外提供 publish_as_loaned_msg() 零拷贝路径:从中间件借出消息内存(borrow_loaned_message())、把序列化数据反序列化进去(deserialize_message())、直接发布借出的消息(publish_loaned_message()),避免中间拷贝。

限制 ​

  • 不支持进程内(intra-process)通信:类型在编译期未知,无法走进程内传递的模板化路径,官方头文件注释明确说明
  • 动态加载库与序列化/反序列化都有额外开销
  • 编译器无法检查类型匹配,类型错误只能在运行时发现

完整数据流 ​

发布路径 ​

接收路径 ​

rmw_cyclonedds 的序列化实现细节 ​

游标体系与两阶段序列化 ​

序列化基础设施位于 Serialization.cpp,核心是一组基于 CRTP 模板的游标类(静态分发,非虚函数继承):

  • CursorBase<Derived> / WriteCursorBase<Derived>:提供 align() 等通用操作的模板基类,各具体游标自行定义 offset()、advance()、put_bytes()
  • SizeCursor:只计算偏移量、不实际写入数据(put_bytes 仅前进),ignores_data() 返回 true;变体 CheckingSizeCursor 在累计大小超过 UINT32_MAX 时抛 std::overflow_error
  • SerializeCursor:直接写入给定内存位置(memcpy),ignores_data() 返回 false
  • ByteVectorCursor:向 std::vector<byte> 追加数据
  • DeserializeCursor:读方向的游标,带 origin/position/end 指针与越界检查

序列化按两阶段进行:

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

这样先算大小再一次性分配,避免了动态 buffer 扩容和多次内存分配。

Simple Type 快速路径 ​

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

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

serdata 操作表 ​

CycloneDDS 的自定义类型通过 ddsi_serdata_ops 操作表注册(序列化数据级的操作;类型级的操作表是另一个结构 ddsi_sertype_ops):

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

serdata_rmw:与 CycloneDDS 的集成层 ​

虽然序列化逻辑完全自定义,数据包装却严格遵循 CycloneDDS 的接口规范(serdata.hpp / serdata.cpp):

  • serdata_rmw 类继承自 ddsi_serdata(CycloneDDS 的标准数据接口),持有序列化数据缓冲区 m_data 与大小 m_size
  • serdata_rmw_serialize_into():将 ROS 消息样本序列化到 serdata 缓冲区;普通消息与服务请求的区分在 sertype 层(SampleOrRequest)完成
  • serdata_rmw_from_ser():从网络接收的 fragment chain 逐段拷贝重组,创建 serdata 对象并从序列化数据提取 key
  • serdata_rmw_from_ser_iov():从 iovec 数组创建 serdata 对象,支持零拷贝接收路径

为什么需要自定义序列化 ​

直接复用 CycloneDDS 自身的序列化满足不了 ROS 的需求:

  1. 线格式差异:ROS 消息的 CDR 封装(encapsulation header、服务请求头)与 CycloneDDS 内部序列化格式不同,产出必须符合 ROS 语义才能与其他 DDS 实现互通
  2. 内省类型支持的直接消费:CycloneDDS 没有每消息的代码生成包,rmw_cyclonedds 通过 rosidl 生成的内省元数据遍历字段直接读写 CDR,避免中间转换层
  3. 服务通信扩展:请求/响应关联头信息的注入
  4. 高效路径:iovec 接收、loaned message 等零拷贝优化都建立在这套自定义实现之上

rmw 序列化 API ​

手动序列化(调试、跨进程传递消息)直接用 rmw 接口:

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 编码的字节缓冲区。

零拷贝:Loaned Message ​

传统路径与零拷贝路径 ​

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

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

关键 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);

限制条件 ​

  • 仅支持固定大小类型(不含 string/sequence 等变长字段)
  • CycloneDDS 基于 iceoryx 实现,需 XML 启用 SharedMemory
  • QoS 限制(任一不满足则退回网络/拷贝路径):Reliability = RELIABLE、Durability = VOLATILE、History = KEEP_LAST(深度不超过配置的池容量)、Liveliness = AUTOMATIC、Deadline = INFINITY
  • 环境变量 ROS_DISABLE_LOANED_MESSAGES=1 可全局禁用

固定大小检测 ​

通过 is_type_self_contained() 判断:

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

要点 ​

  1. 序列化发生在 RMW 实现层,通过 TypeSupport 完成:静态方案每类型生成专用函数,内省方案运行时遍历元数据,二者在编译时间、二进制体积和运行时开销上互补
  2. 封装头解释了消息开头的 4 字节;XCDR1 与 XCDR2 的对齐上限差异(8 字节 vs 4 字节)意味着含 64 位成员的 FINAL 类型在两种编码间不互通
  3. rmw_cyclonedds 的序列化是自定义实现:CRTP 游标体系两阶段进行(SizeCursor 算大小、SerializeCursor 写数据),通过继承 ddsi_serdata 集成进 CycloneDDS,产出仍是标准 CDR
  4. 内省类型支持支撑了运行时的类型无关发布:GenericPublisher 动态加载类型支持库、直接发布序列化消息,代价是不支持进程内通信与额外的运行时开销
  5. 序列化的主要开销来自逐成员遍历、对齐 padding 计算和变长字段内存分配;纯 primitive 且布局兼容 CDR 的类型可走 memcpy 快速路径,真正跳过序列化只有零拷贝路径
  6. 零拷贝的条件是严格的:固定大小类型、iceoryx 共享内存、五项 QoS 全部满足;条件不满足时自动回退普通路径,功能不受影响

参考资料 ​

最近更新

基于 VitePress 构建