ROS2 and raw DDS: four differences that keep them from connecting

This post is also available in Korean.

ROS2 runs on DDS. That is true, and it is not enough: a program that links the same CycloneDDS and publishes into the same domain will not be seen by a ROS2 node. Here are the four places the two diverge, and what it costs to run both inside one process.

What "ROS2 uses DDS" leaves out

The transport under ROS2 is DDS. The documentation says so, and with rmw_cyclonedds_cpp it really is CycloneDDS moving the packets. So you form a reasonable expectation: publish over DDS and a ROS2 node will pick it up.

It will not.

Same library, same domain, same network, and ros2 topic list stays empty. ROS2 does not use DDS as it comes. It puts a thin layer of its own conventions on top, and a publisher that does not follow those conventions is invisible to it — no error, no warning, just two programs that never discover each other.

You end up dealing with both sides the moment the same data has to reach a ROS2 node and a program that has no ROS2 installed. One path publishes the way ROS2 expects. The other shares nothing but an IDL file and talks to DDS directly. I will call the second one raw DDS for the rest of this post.

Build both and the difference narrows to exactly four things. Past those four, it is the same DDS underneath.

The four differences

1. The type name is rewritten

Say you write this IDL:

module my_msgs {
  struct Location {
    string send_time;
    double lat;
    double lon;
  };
};

Over raw DDS the type is called my_msgs::Location — exactly what you wrote. When ROS2 sends the same message, it does not keep that name. What goes out on the wire is:

my_msgs::msg::dds_::Location_

An msg::dds_ segment is inserted and a trailing underscore is appended. For a service you get srv::dds_::Command_Request_ and Command_Response_. This mechanical rewriting is called name mangling.

DDS matches publishers to subscribers by type name. One character of difference and the two never pair up during discovery.

2. Topic names carry an rt/ prefix

The topic you see in ROS2 as /my/location travels on the wire as rt/my/location. The rt stands for ROS topic; services use rq/ for requests and rr/ for replies. The leading / that ROS2 shows you is not part of the DDS name at all.

This is easy to get wrong. Write the topic in a config file with a leading slash out of habit and the code builds "rt/" + "/my/location", which comes out as rt//my/location. A topic with a double slash matches nothing, and nothing in the log says so. Settle on one rule — config values never start with a slash — and the problem disappears.

location_topic = my/location     # correct: raw DDS uses it as is, ROS2 prepends rt/
location_topic = /my/location     # wrong: becomes rt//my/location under ROS2

3. The QoS defaults are not the same

Pass NULL where the CycloneDDS C API wants a QoS and you get the DDS defaults: BEST_EFFORT and VOLATILE. The ROS2 default profile is RELIABLE with KEEP_LAST(10).

DDS only connects a pair when what the subscriber requests is compatible with what the publisher offers. A subscriber asking for RELIABLE will not connect to a publisher offering BEST_EFFORT — it does not degrade, it simply does not match. When the type and the topic are right and the line is still silent, this is the next thing to check.

Choosing between them is not subtle. If the next sample supersedes the last one, BEST_EFFORT is fine; periodic position and status updates fall in that bucket. If losing a sample means a person has to do something twice, you want RELIABLE with KEEP_LAST. Commands fall in that bucket. There is no reason to give periodic data and commands the same QoS.

4. A service is not two topics

This is the part that diverges most.

Raw DDS has no notion of a request and a reply. There is publishing and there is reading. To get a command through, you set up one topic for requests and one for replies, and you match each reply to its request by some identifier. If the messages already carry a unique ID, use that; there is no need to invent a field whose only purpose is pairing the two halves.

ROS2 services do not have you build any of that. The rmw layer defines the scheme: rq/ and rr/ topics, with requests and replies correlated by GUID. Reimplementing it by hand is not advisable. Open a service server with rclcpp and the ROS2 command line tools work against it immediately.

ros2 service list                    # /my/cmd shows up here
ros2 service call /my/cmd my_msgs/srv/Command \
  "{command: 'arm', payload_json: '{\"cmd_id\":\"t1\"}'}"

Note that the service does not appear in ros2 topic list. Check the topic list, see nothing, and conclude the service failed to start, and you will lose an hour to it. Services have their own listing.

Side by side

raw DDSROS2
Type namemy_msgs::Locationmy_msgs::msg::dds_::Location_
Topic namemy/locationrt/my/location
QoS defaultBEST_EFFORT / VOLATILERELIABLE / KEEP_LAST(10)
Request / replyTwo topics, matched by an identifierROS2 service
What you hand a peerOne .idl file (compiled with idlc)A colcon package
Talks to ROS2NoYes

Which one to use

Use ROS2 when the other end is a ROS2 node, when you want to watch traffic with ros2 topic echo while debugging, or when you want rviz and rosbag to work. Services, actions and parameters come with it rather than being written.

Use raw DDS when the other end cannot install ROS2, or does not want to. A C program, an embedded target, another language. What you hand over is a single .idl file and you are done. The ROS2 route asks your peer to build a colcon workspace, and how hard that is depends entirely on their machine. From apt on Linux it was uneventful. On macOS it only went through after pinning CMake below 4 and pointing the build at the system clang.

One symptom worth naming: if ros2 topic echo reports message type invalid, the subscribing machine is missing the message package or has not sourced it. Nothing is wrong on the publishing side.

The minimum code

Raw DDS

Write the IDL, generate C with idlc, publish through the C API.

idlc -l c my_msgs.idl        # produces my_msgs.c / my_msgs.h
participant = dds_create_participant(0, NULL, NULL);
topic  = dds_create_topic(participant, &my_msgs_Location_desc, "my/location", NULL, NULL);
writer = dds_create_writer(participant, topic, NULL, NULL);

my_msgs_Location s;
s.send_time = (char *)ts.c_str();     // dds_write copies the string internally
dds_write(writer, &s);

Reading is dds_take, and there is a trap in it. The samples you get back are not yours — they are memory the library has loaned you. Return them with dds_return_loan or you leak once per sample received.

int n = dds_take(reader, samples, infos, MAX, MAX);
for (int i = 0; i < n; i++) {
  if (!infos[i].valid_data) continue;
  handle((my_msgs_Command *)samples[i]);
}
if (n > 0) dds_return_loan(reader, samples, n);   // omit this and it leaks

A single dds_delete(participant) tears down everything created beneath it. Join the reader thread first, then delete.

ROS2

Build the message package with colcon and source it.

mkdir -p ~/ws/src && cp -r my_msgs ~/ws/src/
cd ~/ws && colcon build --packages-select my_msgs && source install/setup.bash

ros2 interface show my_msgs/srv/Command
ros2 topic echo /my/location

ROS2 requires field names in snake_case — that is a rosidl rule, not a style preference. If the JSON you already exchange is camelCase, those keys cannot go straight into fields.

That leaves two options. Rename everything to snake_case and spell each field out in the IDL, or carry a single string field holding JSON and keep the original keys inside it. The second option means the IDL stops changing every time a message type is added, and the price is that you give up type checking: what may appear in that string now lives in documentation rather than in the schema.

For commands, where there are dozens of them and each carries different parameters, the second option earns its keep. For periodic data with a fixed shape, spell the fields out. Mixing both in one system is fine.

Running both in one process

This was the painful part. Bringing up rmw and a raw DDS participant in the same domain, inside the same process, has a required order.

set the environment -> start the ROS2 side (rmw creates the domain) -> raw participant joins it

Get it backwards and it fails here:

rmw_create_node: failed to create domain, Precondition Not Met

The domain has to be created by rmw, and your own participant has to be the one joining a domain that already exists.

Shutdown, and what it breaks

rclcpp::init defaults to shutdown_on_signal = true. With that on, a thread rclcpp owns catches SIGINT and runs Context::shutdown() on its own, asynchronously. If your program already handles SIGINT, you now have two teardown paths, and they collide while the logging library is being brought down. The symptom is a bus error at Ctrl-C. The fix is to let the program own signals outright.

rclcpp::InitOptions opts;
opts.shutdown_on_signal = false;

It also crashed on the way out after a clean teardown, this time during static destruction. The logging library ROS2 loads and the one statically linked into the program each carried the same symbols, and the duplicate definitions collided. The quick answer was to finish cleanup and then call std::_Exit(), skipping that destruction phase entirely. The real answer is to localize the symbols (--exclude-libs), and that one is still outstanding.

String lifetime, or the bug that hides

The hardest one to find.

An IDL string is a char * in C, so when filling a sample you keep a std::string alive somewhere and hand over its c_str(). I kept those strings in a std::vector<std::string>, and that is where it went wrong.

When a vector grows, it allocates a new buffer and moves its elements into it. A long string keeps its characters in a separate heap block, so the address you took stays valid. A short string keeps its characters inside the std::string object itself — small string optimization — so the moment the object is moved, the c_str() pointer you saved is pointing at where the object used to be.

That made the symptom strange: only the fields holding short values came out as garbage, while long values were fine. Worse, a corrupted string ran over the bytes that followed it, so the receiving side did not report a string problem at all. It reported deserialization failed. I spent the first stretch of debugging in the wrong place entirely.

Switch to a container that does not move its elements and it is over.

std::deque<std::string> pool;   // a vector relocates its elements when it grows

When another machine sees nothing

Type, topic and QoS can all be correct and a second machine still hears nothing. What decides it is how far multicast is allowed in the CycloneDDS configuration.

<AllowMulticast>spdp</AllowMulticast>   <!-- discovery only -->
<AllowMulticast>true</AllowMulticast>   <!-- data as well -->

With true, every machine on the same network and in the same domain receives the data with no further configuration. Loopback on the publishing host works too, which makes local tools useful for checking. In exchange, the switch has to pass multicast (IGMP) and the firewall has to allow multicast UDP.

On a network where multicast is blocked, leave it at spdp and list the peers explicitly.

<Peers><Peer address="10.0.0.11"/><Peer address="10.0.0.12"/></Peers>

Several copies of an IDL will drift apart

Supporting both sides multiplies the type definition: an IDL for raw DDS, an IDL describing what ROS2 puts on the wire, and the ROS2 message package. Field names have to agree across all of them, and so does field order. DDS lays fields into bytes in the order they are declared, so a definition that is off by one position does not fail — it quietly delivers each value into the wrong field. That is worse than a type mismatch. A mismatch refuses to connect; this connects and lies.

Two generation paths are their own hazard. If some code is generated fresh by every build while other code was generated once by a script and committed, then the day someone edits the IDL without rerunning the script, one side is still on the old type. The build succeeds, which is exactly why the drift survives.

Writing "three places to keep in sync" in a document and trusting people to honor it is not a solution. Pick one definition as the source and generate the rest from it.

What to check, in order

  1. Domain — the same number on both sides. ROS_DOMAIN_ID for ROS2, the argument to participant creation for DDS.
  2. RMW — is it rmw_cyclonedds_cpp? A peer on Fast DDS is a different implementation.
  3. Type name — on the ROS2 side, does it carry the msg::dds_::..._ form?
  4. Topic name — the rt/ prefix, and no double slash.
  5. QoS — RELIABLE requested against BEST_EFFORT offered.
  6. Services are not in the topic list. Use ros2 service list.
  7. Multicast — if only remote machines are silent, this is where to look.

If bytes are arriving but no data appears, suspect the layer above DDS — types and QoS — not the network below it. If the two ends never discover each other at all, suspect the layer below.

Takeaways

  • ROS2 adds a thin but firm set of conventions on top of DDS: a mangled type name, the rt/ topic prefix, different QoS defaults, and the rmw service scheme. Four things.
  • Know them and you can move between the two. Miss them and you spend days on "it is the same DDS, why will it not connect".
  • Running both in one project works. The price is initialization order, deciding who owns signal handling at shutdown, and keeping one definition of your types.
  • If every peer is on ROS2, there is no reason to build a raw DDS path at all.
  • If even one peer cannot install ROS2, handing over an .idl file beats debugging a colcon build on someone else's machine.

Comments