How ROS 2 Binds a Message Type to Fast DDS
- Two classes named
TypeSupport - Static generated callbacks
- Publisher creation: binding the type once
- How a DDS endpoint retains the type
- The per-sample boundary: two different data structures
- Publishing one native ROS message
- Publishing an already serialized message
- Receiving one ROS message
- Where compile-time typing ends
- Final mental model
- Source references
A ROS 2 publisher is typed at the application boundary:
rclcpp::Publisher<MyMessage>
Fast DDS DataWriter and DataReader, however, are not C++ templates over MyMessage. They operate on opaque addresses. How, then, does Fast DDS know how to serialize a particular ROS message?
The answer is a chain of type erasure, registration, virtual dispatch, and generated callbacks:
Fast DDS DataWriter/DataReader
│ opaque pointer
▼
Fast DDS TopicDataType virtual interface
▲
│ implemented by
ROS rmw_fastrtps TypeSupport adapter
│ calls message-specific callbacks
▼
generated ROS Fast RTPS typesupport
│
▼
Fast CDR
This article follows ROS 2 Humble’s static/generated implementation, rmw_fastrtps_cpp. The dynamic introspection implementation, rmw_fastrtps_dynamic_cpp, follows a different path and is outside this article’s scope.
It complements Type Erasure IV — ROS 2 Messages and ROS 2 Generated Message Runtime Dependencies.
Two classes named TypeSupport
The first source of confusion is that Fast DDS and ROS’s Fast DDS bridge both define classes whose short name is TypeSupport.
Fast DDS defines the abstract, type-erased serialization interface TopicDataType. Its Humble-era operations are conceptually:
class TopicDataType
{
public:
virtual bool serialize(void * data, SerializedPayload_t * payload) = 0;
virtual bool deserialize(SerializedPayload_t * payload, void * data) = 0;
virtual void * createData() = 0;
virtual void deleteData(void * data) = 0;
};
The exact signatures vary across Fast DDS releases, but the design remains the same: Fast DDS sees an opaque address and delegates type-specific behavior through virtual functions.
Fast DDS also defines:
eprosima::fastdds::dds::TypeSupport
This is a shared-ownership and registration wrapper around a TopicDataType object. It is not the abstract serialization interface itself.
ROS’s Fast DDS bridge independently defines:
rmw_fastrtps_shared_cpp::TypeSupport
This class derives from eprosima::fastdds::dds::TopicDataType and adapts Fast DDS operations to ROS-message operations:
class TypeSupport : public eprosima::fastdds::dds::TopicDataType
{
protected:
virtual bool serializeROSmessage(
const void * ros_message,
fastcdr::Cdr & cdr,
const void * implementation) const = 0;
virtual bool deserializeROSmessage(
fastcdr::Cdr & cdr,
void * ros_message,
const void * implementation) const = 0;
};
The relationship is:
eprosima::fastdds::dds::TopicDataType
▲
│ inherits
rmw_fastrtps_shared_cpp::TypeSupport
▲
│ inherits
rmw_fastrtps_cpp::TypeSupport
▲
│ inherits
rmw_fastrtps_cpp::MessageTypeSupport
eprosima::fastdds::dds::TypeSupport
└── owns the resulting TopicDataType object
In short:
| Class | Role |
|---|---|
Fast DDS TopicDataType |
Type-erased virtual serialization interface |
Fast DDS TypeSupport |
Ownership and type-registration wrapper |
ROS rmw_fastrtps_shared_cpp::TypeSupport |
Adapter from Fast DDS operations to ROS-message operations |
ROS rmw_fastrtps_cpp::MessageTypeSupport |
Static adapter bound to generated message callbacks |
Static generated callbacks
For rmw_fastrtps_cpp, ROS generates Fast RTPS callbacks for every message type at build time. The callback table includes operations for:
- CDR serialization;
- CDR deserialization;
- serialized-size calculation;
- key handling;
- bounded and plain-type properties.
ROS does not generate a different C++ adapter class for every message. It uses the same uniform rmw_fastrtps_cpp::MessageTypeSupport class for all statically supported ROS messages. Each instance becomes message-specific by storing that message’s resolved callback table:
const message_type_support_callbacks_t * members_;
The original generic rosidl_message_type_support_t is therefore important during endpoint creation: RMW uses it to locate the concrete Fast RTPS handle and its callback table. Once resolved, the per-sample path does not restart at the generic ROSIDL dispatcher.
generic ROSIDL handle
↓ resolve during endpoint creation
uniform rmw_fastrtps_cpp::MessageTypeSupport class
+
generated message-specific callback table
↓ construct one message-bound adapter instance
rmw_fastrtps_cpp::MessageTypeSupport(callbacks)
↓ implements
Fast DDS TopicDataType
Publisher creation: binding the type once
Publisher creation performs two related bindings:
- ROS resolves the generic ROSIDL handle to generated Fast RTPS callbacks.
- Fast DDS binds the DDS endpoint to the registered
TopicDataTypeadapter.
The simplified flow is:
rclcpp::create_publisher<MessageT>()
↓
rcl publisher creation
↓
rmw_create_publisher(generic ROSIDL typesupport handle)
↓
rmw_fastrtps resolves concrete Fast RTPS typesupport
↓
obtain message_type_support_callbacks_t
↓
construct rmw_fastrtps_cpp::MessageTypeSupport(callbacks)
↓
the object is already a Fast DDS TopicDataType through inheritance
↓
register it through a Fast DDS TypeSupport ownership handle
↓
participant registry: DDS type name → Fast DDS TypeSupport
↓
create Topic(topic name, DDS type name)
↓
create DataWriter for that Topic
The Humble implementation saves the resolved callback pointer in the publisher’s CustomPublisherInfo and constructs the same MessageTypeSupport adapter class used for every ROS message type. The adapter instance differs only through its stored message_type_support_callbacks_t. Because the class derives from Fast DDS TopicDataType, the resulting object already implements the interface Fast DDS needs. A Fast DDS TypeSupport handle supplies shared ownership and registration; it does not add another message-specific adapter layer. RMW then registers the object under a DDS type name, creates or reuses the topic, and finally creates the writer.
How a DDS endpoint retains the type
The Fast DDS participant maintains a registry:
DDS type name → eprosima::fastdds::dds::TypeSupport
A topic records both its topic name and its type name:
Topic
├── topic name
└── DDS type name
A writer or reader is not created independently and associated with a topic later. It is created for an existing topic or topic description. Conceptually, the Fast DDS APIs are:
DataWriter * writer = publisher->create_datawriter(
topic,
writer_qos,
listener);
DataReader * reader = subscriber->create_datareader(
topic_description,
reader_qos,
listener);
The supplied Topic gives writer creation both pieces of identity it needs: the DDS topic name and the registered DDS type name. A reader is similarly created from a TopicDescription, normally the same Topic, and obtains the corresponding type information from it.
DomainParticipant
├── registered types: type name → Fast DDS TypeSupport
└── Topic(topic name, type name)
│
├── Publisher::create_datawriter(Topic, ...)
│ └── DataWriterImpl bound to that topic and type
│
└── Subscriber::create_datareader(TopicDescription, ...)
└── DataReaderImpl bound to that topic and type
During endpoint creation, Fast DDS reads the type name from the supplied topic, finds its registered type support, and retains that support in the endpoint implementation:
Topic or TopicDescription supplied to endpoint creation
↓ provides DDS type name
participant's registered-type lookup
↓
Fast DDS TypeSupport
↓ retained together with topic information
DataWriterImpl or DataReaderImpl
The lookup is not normally repeated for every sample. Each write or take uses virtual dispatch through the TopicDataType object already stored by the endpoint.
This distinction matters:
Endpoint creation:
identify and bind the type
Each sample:
invoke the already-bound type behavior
The per-sample boundary: two different data structures
The most important distinction in the per-sample path is between ROS’s SerializedData and Fast DDS’s SerializedPayload_t. Their names sound similar, but they sit on opposite sides of serialization and have different jobs.
Fast DDS’s Humble-era virtual interface is conceptually:
virtual bool serialize(
void * data,
SerializedPayload_t * payload) = 0;
virtual bool deserialize(
SerializedPayload_t * payload,
void * data) = 0;
For the registered ROS adapter, the arguments mean:
void * data
→ actually points to ROS SerializedData
→ describes this operation's source or destination
SerializedPayload_t * payload
→ Fast DDS serialized sample buffer
→ contains CDR bytes and wire-format metadata
The boundary can therefore be pictured as:
ROS/RMW side Fast DDS side
SerializedData SerializedPayload_t
├── representation mode ├── data: byte buffer
├── source/destination pointer ⇄ ├── length: valid bytes
└── implementation pointer ├── max_size: capacity
└── encapsulation: CDR format
SerializedData: an operation envelope
Despite its name, SerializedData does not necessarily contain serialized bytes. It is an RMW operation envelope:
SerializedData
├── is_cdr_buffer
│ ├── false: data points to a native ROS message
│ └── true: data points to an existing CDR buffer or stream
├── data
│ └── source or destination for this operation
└── impl
└── RMW-specific serialization context
For a normal native-message publication, Humble constructs:
SerializedData data;
data.is_cdr_buffer = false;
data.data = const_cast<void *>(ros_message);
data.impl = info->type_support_impl_;
info->data_writer_->write(&data);
DataWriter::write() receives &data as an opaque void *. Fast DDS does not know that it points to SerializedData. The meaning of the pointer belongs to the registered ROS TopicDataType adapter.
For an already serialized publication, RMW instead sets:
data.is_cdr_buffer = true;
data.data = &cdr_stream;
data.impl = nullptr; // not used in this mode
The envelope is necessary because the same type-erased Fast DDS operation must handle both native ROS objects and pre-serialized CDR data.
SerializedPayload_t: the Fast DDS sample
SerializedPayload_t is the actual serialized DDS sample. Fast DDS supplies it to TopicDataType::serialize() as the destination buffer and receives it from the transport/history path during deserialization.
It carries the information required below the language-object boundary:
- the CDR byte buffer;
- buffer capacity;
- actual serialized length;
- CDR encapsulation and endianness.
Fast DDS needs this representation to place samples in DDS history, fragment them into RTPS submessages, transmit them, retransmit them, and deliver them to readers. It exists independently of ROS.
For a native message, the ROS adapter creates a Fast CDR stream over payload->data. The generated callback writes message fields through that stream, after which the adapter records the resulting payload->length and payload->encapsulation.
For pre-serialized input, the adapter skips the generated callback and copies the existing CDR bytes into payload->data.
Who performs serialization?
The work crosses several ownership boundaries, so saying only “RMW serializes” or “Fast DDS serializes” is incomplete:
Fast DDS DataWriter
decides that a sample must be serialized
↓ calls its stored virtual interface
TopicDataType::serialize(void *, SerializedPayload_t *)
↓ virtual dispatch selects
rmw_fastrtps ROS adapter
unwraps SerializedData and chooses the representation path
↓ for a native message
generated ROS cdr_serialize callback
knows the concrete message fields and their order
↓ uses
Fast CDR
encodes fields into payload->data
↓
Fast DDS receives the populated SerializedPayload_t
Thus Fast DDS initiates and orchestrates serialization through its registered typesupport interface. The RMW adapter implements that interface for ROS representations, the generated callback supplies message-specific knowledge, and Fast CDR performs the byte encoding.
The adapter branches as follows:
is_cdr_buffer == false
↓
Fast DDS invokes stored TopicDataType::serialize()
↓ virtual dispatch to ROS adapter
adapter invokes generated callback
↓
Fast CDR encodes ROS fields into SerializedPayload_t
is_cdr_buffer == true
↓
Fast DDS invokes stored TopicDataType::serialize()
↓ virtual dispatch to ROS adapter
adapter copies existing CDR bytes into SerializedPayload_t
Why does the envelope also carry callbacks?
The endpoint retains a TopicDataType whose concrete object is an rmw_fastrtps_cpp::MessageTypeSupport. That object stores the static callback table in members_. Humble nevertheless also places the same callback pointer in SerializedData::impl.
The stored pointer and per-operation pointer are used differently:
MessageTypeSupport::members_
persistent type configuration
maximum size, bounded/plain status, empty-message handling, type size
SerializedData::impl
RMW-specific context passed to this serialize/deserialize operation
static path: message_type_support_callbacks_t
The shared adapter forwards impl to the concrete ROS operation:
serializeROSmessage(ros_message, cdr, serialized_data->impl);
The Humble static implementation then performs:
auto callbacks =
static_cast<const message_type_support_callbacks_t *>(impl);
return callbacks->cdr_serialize(ros_message, cdr);
Deserialization uses cdr_deserialize in the same way. Passing impl is part of the shared RMW adapter design; Fast DDS neither defines nor interprets it. In the static implementation the callback pointer is duplicated, and the code could theoretically use members_ for each call instead. The source shows the duplication but does not document a definitive motivation for that design choice.
Publishing one native ROS message
The complete native-message path is:
rclcpp::Publisher<MessageT>::publish(message)
↓
rcl_publish()
↓
rmw_publish()
↓ constructs
SerializedData
├── is_cdr_buffer = false
├── data = ROS message pointer
└── impl = generated callback table
↓ passed as opaque void *
Fast DDS DataWriter::write()
↓
DataWriterImpl requests serialization
↓ virtual dispatch through stored TopicDataType
rmw_fastrtps_shared_cpp::TypeSupport::serialize(
void *, SerializedPayload_t *)
↓ cast void * to SerializedData *
↓ unwrap envelope and build Fast CDR stream over payload buffer
generated cdr_serialize callback
↓
Fast CDR writes CDR bytes
↓
SerializedPayload_t receives bytes, length, and encapsulation
↓
Fast DDS history and RTPS transport
There are two distinct runtime dispatches:
- A C++ virtual call selects the registered ROS
TopicDataTypeadapter. - A callback-table function pointer selects the concrete message serializer.
Fast DDS drives the operation without knowing the C++ type MessageT.
Publishing an already serialized message
rmw_publish_serialized_message() uses the same Fast DDS entry point but avoids encoding the fields again:
rmw_serialized_message_t containing CDR bytes
↓ wrapped as a CDR stream
SerializedData
├── is_cdr_buffer = true
├── data = CDR stream pointer
└── impl = nullptr
↓
DataWriter::write(void *)
↓
stored TopicDataType::serialize()
↓
ROS adapter copies existing bytes
↓
Fast DDS SerializedPayload_t
The TopicDataType virtual call still occurs because Fast DDS always reaches the sample through its registered type interface. The generated cdr_serialize callback is simply unnecessary in this branch.
Receiving one ROS message
For rmw_take(), RMW prepares a native-message destination envelope:
SerializedData data;
data.is_cdr_buffer = false;
data.data = ros_message;
data.impl = info->type_support_impl_;
The direction across the boundary reverses:
RTPS/DDS bytes arrive
↓
Fast DDS SerializedPayload_t
↓
DataReaderImpl invokes stored TopicDataType::deserialize()
↓ virtual dispatch
rmw_fastrtps_shared_cpp::TypeSupport::deserialize(
SerializedPayload_t *, void *)
↓ cast void * to SerializedData *
↓ unwrap destination and create Fast CDR reader over payload bytes
generated cdr_deserialize callback
↓
fields are written into the destination ROS message
↓
rcl/rclcpp returns the populated message
For a serialized take, is_cdr_buffer selects the other branch and the adapter copies the payload bytes into the caller’s serialized destination instead of invoking the message-specific deserializer.
Where compile-time typing ends
The application-facing rclcpp API uses templates:
rclcpp::Publisher<MyMessage>
rclcpp::Subscription<MyMessage>
Those templates provide compile-time type safety and obtain the generated ROSIDL handle for MyMessage. Below the RMW boundary, the important interfaces are type-erased:
The typed-to-erased transition begins with a generic function-template declaration supplied by the ROS C++ runtime:
// rosidl_runtime_cpp/rosidl_typesupport_cpp/message_type_support.hpp
namespace rosidl_typesupport_cpp {
template<typename T>
const rosidl_message_type_support_t *
get_message_type_support_handle();
} // namespace rosidl_typesupport_cpp
That declaration is not generated separately for every message. The interface package generates an explicit specialization for each concrete message type and exports it from the package’s C++ typesupport library. Conceptually:
template<>
const rosidl_message_type_support_t *
get_message_type_support_handle<MyMessage>()
{
return /* MyMessage's generic ROSIDL handle */;
}
When rclcpp::Publisher<MyMessage> is instantiated, its template code calls this specialization. The C++ compiler checks MyMessage statically, while the returned value has the uniform erased type const rosidl_message_type_support_t * expected by rcl and RMW.
rclcpp template instantiated with MyMessage
↓ direct, type-checked C++ call
generated explicit specialization:
get_message_type_support_handle<MyMessage>()
↓ returns uniform erased representation
generic rosidl_message_type_support_t handle
↓
rcl and RMW
The generated specialization and its companion predictable extern "C" symbol are explained in detail in Type Erasure IV — C++ additional export: template specializations.
rclcpp template API
↓ calls generated explicit specialization
generic ROSIDL handle
↓
rcl and RMW C APIs
↓ passes SerializedData as an opaque pointer
Fast DDS DataWriter/DataReader
↓ invokes stored virtual TopicDataType
ROS adapter + generated callback + Fast CDR
↓ fills or reads SerializedPayload_t
↓ DDS history and RTPS transport
This is why Fast DDS writers and readers do not need to be templates over every ROS message type. The endpoint stores a polymorphic serialization object, while generated callbacks carry the concrete message behavior.
Final mental model
The complete mechanism has two phases.
During endpoint creation:
generic ROSIDL handle
↓ resolve once
generated Fast RTPS callbacks
↓
ROS TopicDataType adapter
↓ register by DDS type name
Fast DDS participant registry
↓ lookup while creating endpoint
DataWriter/DataReader retains Fast DDS TypeSupport
For each sample:
DataWriter/DataReader
↓ invokes stored TopicDataType
ROS adapter
├── unwraps SerializedData operation envelope
└── reads or fills Fast DDS SerializedPayload_t
↓ native-message branch
generated callback + Fast CDR
The core conclusions are:
- Fast DDS
DataWriterandDataReaderare not message-type templates. - Fast DDS
TopicDataTypeis the abstract type-erasure interface. - ROS’s
rmw_fastrtps_shared_cpp::TypeSupportadapts that interface to ROS messages. - Fast DDS’s separately named
TypeSupportowns and registers the adapter. - The generic ROSIDL handle is resolved during endpoint creation, not from scratch for every sample.
- The writer or reader retains the registered type support and uses virtual dispatch for each operation.
SerializedDatais a ROS operation envelope, not necessarily serialized bytes.SerializedPayload_tis the actual Fast DDS serialized sample used by DDS history and RTPS transport.- Fast DDS initiates serialization through
TopicDataType; the ROS adapter, generated callbacks, and Fast CDR implement it for ROS messages. - In Humble’s static implementation,
SerializedData::implcarries the callback pointer even though the adapter also stores it inmembers_.
In short:
ROS 2 binds a generated message to Fast DDS once while creating the endpoint, then serializes each sample through a stored type-erased adapter and generated callback table.
Source references
ROS 2 Humble:
rmw_fastrtpsHumble sourcermw_fastrtps_shared_cpp::TypeSupportandSerializedData- Shared adapter serialization and deserialization
- Static publisher creation and type registration
rmw_publish()and itsSerializedDatawrapperrmw_take()and its destination wrapper- Static
MessageTypeSupport - Static callback storage and per-call callback use
Fast DDS: