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:
[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:
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:
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:
[[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 gateway against your own ROS 2 workspace.
Example applications propagated to ROS 2 by the gateway, one set per translator.
Understand how to extend iceoryx2 beyond its shared memory domain.