CDR Bytes as Payload

Important

The ROS 2 integrations are currently prototypes and have not yet been validated in real ROS 2 workflows. They are recommended only for experimentation in development deployments.

Many standard ROS 2 message definitions contain bounded or dynamic fields. As discussed in Plain Struct as Payload, the native types generated for such definitions utilize the heap and cannot be placed in shared memory.

One way to propagate these types anyway is to keep them in their serialized form, which in ROS 2 is typically the Common Data Representation (CDR) used by DDS. This can be done by utilizing the Passthrough translator, which moves the serialized bytes across the boundary unmodified, in both directions.

With this approach, every message type can be propagated and the gateway performs no payload processing at all. The trade-off is that applications take on the (de)serialization responsibility and the overhead.

Independent of the message type, keeping the (de)serialization in the application can also be desirable, for example when it must be certified as part of the application, or isolated from the processes communicating over the network.

Topology

To demonstrate the approach, let’s build a minimal pipeline that carries a message containing a dynamic field through both domains. A shouter application in iceoryx2 that subscribes to the /chatter topic, uppercases the received text, and publishes it on /shouter.

Both topics use std_msgs/msg/String, which contains a dynamic data field, resulting in its generated type being unsuitable for shared memory.

Hint

Dynamic fields are not always obvious at a glance. Even mostly-numeric definitions like nav_msgs/msg/Odometry are heap-backed due to strings nested in their definitions.

        %%{init: {"flowchart": {"subGraphTitleMargin": {"top": 10, "bottom": 8}}} }%%
flowchart LR
    subgraph ros2a["ROS 2"]
        pub["ros2 topic pub"]:::external
    end

    gw1["Gateway"]:::gateway

    subgraph iox2["iceoryx2"]
        sht["Shouter"]
    end

    gw2["Gateway"]:::gateway

    subgraph ros2b["ROS 2"]
        echo["ros2 topic echo"]:::external
    end

    pub -- "/chatter<br/>DDS" --> gw1
    gw1 -- "CDR bytes<br/>SHM" --> sht
    sht -- "CDR bytes<br/>SHM" --> gw2
    gw2 -- "/shouter<br/>DDS" --> echo
    

The String message crossing the boundary between ROS 2 and iceoryx2

To keep things simple, the ros2cli is used to mock both ends, with ros2 topic pub feeding text and ros2 topic echo displaying the uppercased result.

Setting Up

Let’s build on the setup from Plain Struct as Payload. The String message is part of std_msgs, whose Rust types come from the same place as those of geometry_msgs there, so no additional message generation is needed. On an older installation that does not ship the Rust types, follow the message generation steps in the previous article.

We create a new cargo project for our shouter in the same working directory as the limiter:

~/iceoryx2_ros2/
├── shouter/         # cargo project of the iceoryx2 application
└── twist_limiter/   # cargo project from the previous article
cd ~/iceoryx2_ros2
cargo new shouter
cd shouter

There are two notable differences in the Cargo.toml compared to the previous article. The cdr crate is added as an additional dependency, and the serde feature is enabled on ros-env to make the generated types (de)serializable:

shouter/Cargo.toml
[package]
name = "shouter"
edition = "2024"
publish = false

[dependencies]
cdr = { version = "0.2" }
iceoryx2 = { version = "X.Y.Z" } # select the desired `iceoryx2` version
# iceoryx2 = { git = "https://github.com/eclipse-iceoryx/iceoryx2", branch = "main" } # if the gateway is built from `main`
ros-env = { version = "0.2", features = ["serde"] }
rosidl_runtime_rs = { version = "0.7" } # the version `ros-env` depends on

As before, the iceoryx2 version must match the one the gateway is built from.

The Shouter

Like the limiter, the shouter will consist of a module for the payload type and the application itself:

~/iceoryx2_ros2/shouter/
├── Cargo.toml
├── mapping.toml       # gateway configuration, see below
└── src/
    ├── main.rs        # the application
    └── string_byte.rs # the payload type

The Payload

For this approach, the payload is a slice of opaque CDR-serialized bytes. However, the gateway still needs to know which ROS 2 message the bytes hold, which it reads from the type name of the payload. A plain u8 always reports its own type name, so the bytes are wrapped in a new type that keeps the layout of a u8 through #[repr(transparent)] and reports the name of the ROS 2 message instead.

Since the new type has the layout of a u8, a payload can be viewed as bytes without copying, both to read the CDR bytes it holds and to write them. The unsafe views rely on this layout, so assert it at compile time.

Create the module for the payload type:

cd ~/iceoryx2_ros2/shouter
touch src/string_byte.rs

And add the wrapper to it:

shouter/src/string_byte.rs
use iceoryx2::prelude::*;
use rosidl_runtime_rs::{Message, RmwMessage};

#[derive(Debug, Default, Clone, Copy)]
#[repr(transparent)]
pub struct StringByte(pub u8);

unsafe impl ZeroCopySend for StringByte {
    unsafe fn type_name() -> &'static str {
        <<ros_env::std_msgs::msg::String as Message>::RmwMsg as RmwMessage>::TYPE_NAME
    }
}

// Viewing a payload as bytes relies on StringByte having the layout of a u8.
const _: () = assert!(size_of::<StringByte>() == 1 && align_of::<StringByte>() == 1);

The Application

Then update the generated src/main.rs with the application. The payload type is declared as a slice of StringByte in both directions. For dynamic message types the size of the message is not known at compile time, so the publisher is configured with an initial size guess and an allocation strategy to grow the shared memory when required:

shouter/src/main.rs
mod string_byte;

use core::time::Duration;

use cdr::{CdrLe, Infinite};
use iceoryx2::prelude::*;
use string_byte::StringByte;

const CYCLE_TIME: Duration = Duration::from_millis(100);
const INITIAL_MAX_PAYLOAD_SIZE: usize = 64;

fn main() -> Result<(), Box<dyn core::error::Error>> {
    set_log_level_from_env_or(LogLevel::Info);

    let node = NodeBuilder::new().create::<ipc::Service>()?;

    let chatter = node
        .service_builder(&"Chatter".try_into()?)
        .publish_subscribe::<[StringByte]>()
        .open_or_create()?;
    let subscriber = chatter.subscriber_builder().create()?;

    let shouter = node
        .service_builder(&"Shouter".try_into()?)
        .publish_subscribe::<[StringByte]>()
        .open_or_create()?;
    let publisher = shouter
        .publisher_builder()
        .initial_max_slice_len(INITIAL_MAX_PAYLOAD_SIZE)
        .allocation_strategy(AllocationStrategy::PowerOfTwo)
        .create()?;

    while node.wait(CYCLE_TIME).is_ok() {
        while let Some(chatter_sample) = subscriber.receive()? {
            // The application deserializes the CDR bytes itself, directly from
            // shared memory
            let chatter_payload = chatter_sample.payload();

            // SAFETY: StringByte is #[repr(transparent)] over u8, so the bytes have
            // the same length, layout and valid values.
            let chatter_bytes = unsafe {
                core::slice::from_raw_parts(
                    chatter_payload.as_ptr().cast::<u8>(),
                    chatter_payload.len(),
                )
            };

            let mut message: ros_env::std_msgs::msg::String = cdr::deserialize(chatter_bytes)?;
            message.data.make_ascii_uppercase();

            // And then serializes the outgoing message back to CDR, directly into
            // a sample loaned for its exact size
            let size = cdr::calc_serialized_size(&message) as usize;
            let mut shouter_sample = publisher.loan_slice(size)?;
            let shouter_payload = shouter_sample.payload_mut();

            // SAFETY: StringByte is #[repr(transparent)] over u8, so the bytes have
            // the same length, layout and valid values.
            let shouter_bytes = unsafe {
                core::slice::from_raw_parts_mut(
                    shouter_payload.as_mut_ptr().cast::<u8>(),
                    shouter_payload.len(),
                )
            };

            cdr::serialize_into::<_, _, _, CdrLe>(shouter_bytes, &message, Infinite)?;
            shouter_sample.send()?;

            coutln!("shouted \"{}\" ({} bytes)", message.data, size);
        }
    }

    Ok(())
}

Each message is deserialized from and serialized into shared memory by the application itself, while the gateway only moves bytes.

The Gateway

The final component is the gateway itself, which connects the two topics to their iceoryx2 counterparts. Refer to Gateway Basics for an overview. Here we will only look at the configuration suitable for this pipeline.

Mapping

To associate the topics in ROS 2 with the services in iceoryx2, we will use static mapping, which can be defined with a configuration file.

Create the configuration file in the project directory:

cd ~/iceoryx2_ros2/shouter
touch mapping.toml

And add the mappings to it:

shouter/mapping.toml
[[mapping]]
iceoryx2.service_name = "Chatter"
iceoryx2.payload_type = "std_msgs/msg/String"
ros2.topic = "/chatter"
ros2.type = "std_msgs/msg/String"

[[mapping]]
iceoryx2.service_name = "Shouter"
iceoryx2.payload_type = "std_msgs/msg/String"
ros2.topic = "/shouter"
ros2.type = "std_msgs/msg/String"

The payload_type matches the type name reported by the StringByte type defined earlier.

Translator

For payloads crossing as serialized bytes, the Passthrough translator is the correct choice. It is the default, so specifying it explicitly is optional.

From the shouter directory, the gateway is launched with:

iox2 link gateway ros2 --static-mapping mapping.toml --translator Passthrough

Running

Now with all pieces implemented and configured, we can run the complete pipeline. Each application will run in a separate terminal and requires the ROS 2 distribution to be sourced.

First, launch the shouter:

source /opt/ros/<distro>/setup.bash
# source ~/iceoryx2_ros2/messages/install/setup.bash  # on older installations
cd ~/iceoryx2_ros2/shouter
cargo run

Next, launch the gateway with the configuration from the previous section:

source /opt/ros/<distro>/setup.bash
# source ~/iceoryx2_ros2/messages/install/setup.bash  # on older installations
cd ~/iceoryx2_ros2/shouter
iox2 link gateway ros2 --static-mapping mapping.toml --translator Passthrough

Note

For simplicity, the gateway runs in its default polling mode. See the Event-Driven Execution tutorial for a pipeline in which neither the gateway nor the applications poll.

Then start observing the output. The type is given explicitly because the topic is not yet published. Since shared memory communication guarantees delivery, the subscription requests reliable delivery to match:

source /opt/ros/<distro>/setup.bash
# source ~/iceoryx2_ros2/messages/install/setup.bash  # on older installations
ros2 topic echo --qos-reliability reliable /shouter std_msgs/msg/String

Finally, publish text at 1 Hz:

source /opt/ros/<distro>/setup.bash
# source ~/iceoryx2_ros2/messages/install/setup.bash  # on older installations
ros2 topic pub -r 1 /chatter std_msgs/msg/String "{data: hello}"

Every published message is forwarded by the gateway into shared memory as CDR bytes, where the shouter deserializes, transforms and serializes it. On the way out, the gateway forwards the bytes into ROS 2 unmodified:

$ cargo run
shouted "HELLO" (14 bytes)
shouted "HELLO" (14 bytes)
shouted "HELLO" (14 bytes)
$ ros2 topic echo --qos-reliability reliable /shouter std_msgs/msg/String
data: HELLO
---
data: HELLO
---

Conclusion

The approach described in this article fits every message type, including those with bounded or dynamic fields, and keeps the gateway out of payload translation entirely. In exchange, payloads are opaque while in transit and every application (de)serializes them itself.

The application-side (de)serialization can also be a benefit for safety. When the (de)serialization code itself must be certified, it is certified as part of each application, while the gateway never touches payload contents and stays out of the certified path.

For self-contained message definitions, Plain Struct as Payload moves the (de)serialization into the gateway, letting applications read plain structs straight from shared memory. For a shared-memory-compatible representation of dynamic data, FlatBuffer as Payload is an alternative.

Further Reading

Build the ROS 2 gateway

Build the gateway against your own ROS 2 workspace.

Build the ROS 2 gateway
ROS 2 Gateway Examples

Example applications propagated to ROS 2 by the gateway, one set per translator.

https://github.com/eclipse-iceoryx/iceoryx2/tree/main/integrations/ros2/examples
Links: Tunnels and Gateways

Understand how to extend iceoryx2 beyond its shared memory domain.

Links