Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Cross-backend bridges

Most nano-ros applications pick one RMW backend at compile time and never look back. A cross-backend bridge is the one case where a single binary needs two backends in parallel — one node receiving on backend A, another publishing on backend B, the executor draining both each tick.

Typical reasons:

  • Drone link. A flight controller speaks XRCE-DDS over a serial radio link; the ground-station LAN runs standard DDS. A bridge forwards telemetry verbatim between the two.
  • Field gateway. Sensor pods talk zenoh-pico over an unreliable Wi-Fi mesh; the ROS 2 datacenter side wants Cyclone DDS. A bridge republishes after a session-buffered drop check.
  • Safety carve-out. Mission-critical traffic stays on the deterministic backend; introspection / logging republishes to a best-effort backend so dashboards never starve the critical pipeline.

This chapter walks the model, the build knobs, and the three shipped examples.

The mental model — rclcpp::Node, twice

nano-ros mirrors rclcpp:

Executor exec;                   // owns the primary session
Node ingress = exec.node_builder("ingress").rmw("xrce").build();
Node egress  = exec.node_builder("egress").rmw("cyclonedds").build();
auto sub = ingress.create_subscription<Foo>(...);
auto pub = egress.create_publisher<Foo>(...);

The executor opens one primary session at open* time. Each extra node_builder(...).rmw(name) call:

  1. Looks up name in the registry of linked backends.
  2. If name == primary_rmw and the locator matches → the Node binds to the primary session (slot 0). No second session opened.
  3. Else → opens a fresh session through that backend’s open_with_rmw and stores it in extra_sessions[N-1]. The new Node’s session_idx = N.

spin_once() drains every session in turn. Handles created through a multi-Session Node route through the node’s resolved session record instead of the legacy support/session pointer kept for single-backend callers.

NodeRecord.session_idx is the dispatch key. Print it to verify which session each Node landed on:

#![allow(unused)]
fn main() {
exec.node(node_in).unwrap().session_idx   // 0 = primary
exec.node(node_out).unwrap().session_idx  // 1 = first extra
}

Build knobs — three audiences, three shapes

Rust binary

Add both backend crates to Cargo.toml and call each register() early in main:

[dependencies]
nros            = { ..., features = ["rmw-cffi"] }
nros-rmw-zenoh  = { ... }
nros-rmw-xrce-cffi = { ... }
fn main() {
    nros_rmw_zenoh::register().expect("register zenoh");
    nros_rmw_xrce_cffi::register().expect("register xrce");
    let mut exec = nros::Executor::open_with_rmw("zenoh",
        &nros::ExecutorConfig::from_env())?;
    let ingress = exec.node_builder("ingress").rmw("zenoh").build()?;
    let egress  = exec.node_builder("egress").rmw("xrce").build()?;
    // ...
}

Why explicit register()? Stable Rust drops un-referenced rlib CGUs from the final link even when the backend’s #[used] static RMW_INIT_ENTRIES exists. The register() call doubles as the symbol reference that drags the rlib in and the registration trigger. C / C++ builds dodge this because --whole-archive pulls every section unconditionally.

C binary

NANO_ROS_RMW=none switches off the root CMake’s RMW auto-pull. Pull each backend’s staticlib through corrosion_import_crate and wrap with --whole-archive so the registry walker finds both names:

set(NANO_ROS_RMW none)
add_subdirectory(${nano_ros_root} nano_ros)

corrosion_import_crate(
    MANIFEST_PATH ${nano_ros_root}/Cargo.toml
    CRATES nros-rmw-xrce-cffi
    NO_DEFAULT_FEATURES
    FEATURES std)
corrosion_import_crate(
    MANIFEST_PATH ${nano_ros_root}/Cargo.toml
    CRATES nros-rmw-zenoh-staticlib
    NO_DEFAULT_FEATURES
    FEATURES "platform-posix;ros-humble")

target_link_libraries(my_bridge PRIVATE
    NanoRos::NanoRos
    -Wl,--whole-archive
    nros_rmw_xrce_cffi-static
    nros_rmw_zenoh_staticlib-static
    -Wl,--no-whole-archive)

Then declare + call the register functions early in nros_app_main:

extern int8_t nros_rmw_xrce_register(void);
extern int8_t nros_rmw_zenoh_register(void);

int nros_app_main(int argc, char** argv) {
    if (nros_rmw_xrce_register() != 0)  return 1;
    if (nros_rmw_zenoh_register() != 0) return 1;
    // ... nros_support_init / nros_executor_init / nros_executor_node_init
}

nros_executor_node_init honours per-Node rmw_name + locator via the nros_node_options_t struct; the executor sets node.executor so subsequent nros_*_init calls (publisher, subscription, service, action) route to the right session.

C++ binary

Same CMake shape as C. The C++ surface (nros-cpp) is a thin header layer; the linker work is identical. Use the Executor::node_builder(name) chain:

auto exec = nros::Executor::open_with_rmw("zenoh", cfg);
auto ingress = exec.node_builder("ingress").rmw("zenoh").build();
auto egress  = exec.node_builder("egress").rmw("xrce").build();
auto pub = egress.create_publisher<std_msgs::String>("/chatter");
exec.register_subscription_on<std_msgs::String>(ingress, "/chatter",
    [&pub](const auto& msg) { pub->publish(msg); });

NROS_RMW environment variable

When a binary links multiple backends, set NROS_RMW to pin the primary before open():

NROS_RMW=zenoh ./my_bridge

The C-side nros_support_init reads it and routes Executor::open through open_with_rmw(name, ...). Without this, the linkme walker returns whichever backend’s ctor fired first, which is non-deterministic across link orderings. The bridge then opens another session against the same backend when the per-Node .rmw(name) matches the unintended primary — and most singleton-state backends (XRCE-DDS-Client’s uxrSession, zenoh-pico’s global g_session) refuse a second open.

The Rust path mirrors this: Executor::open consults $NROS_RMW first; the per-Node resolve_session_slot detects the primary-name match and returns slot 0 instead of opening a duplicate.

Memory + WCET budget

Each extra session adds:

  • One ConcreteSession (RMW-specific; see RMW Backends for sizing).
  • One register_wake_signal_on_extra wake-callback slot (std only; bare-metal targets share the primary wake).
  • One round-trip through spin_once per backend per tick.

The bridge’s per-tick WCET is the sum across linked backends:

bridge_wcet = Σ poll_wcet_i + Σ dispatch_wcet_j

Read the per-backend numbers in the Real-time budget per backend table and add them up — there is no parallelism between backends inside a single executor.

For deadline-critical bridges, partition by SchedContext instead of running everything on the default Fifo slot:

#![allow(unused)]
fn main() {
let ingress = exec.node_builder("ingress")
    .rmw("xrce")
    .sched(critical_sc)   // RT priority
    .build()?;
let egress = exec.node_builder("egress")
    .rmw("cyclonedds")
    .sched(best_effort_sc)
    .build()?;
}

The PiCAS-style per-callback OS-priority dispatcher (gated behind the scheduler-os-priority feature) routes each Node’s callbacks to its own OS priority slot so the slow backend cannot block the fast one.

Shipped examples

examples/bridges/tt-zenoh-to-xrce/

Pure-Rust bridge. Zenoh ingress → XRCE-DDS egress under an ARINC-653-style time-triggered cyclic schedule. Read this first — it shows the multi-RMW Executor::open_with_rmw("zenoh", ...) plus node_builder.rmw("xrce") per-session pin, with raw byte forwarding and no codegen.

zenohd --listen tcp/127.0.0.1:7447 &
build/xrce-agent/MicroXRCEAgent udp4 -p 8888 &
NROS_XRCE_LOCATOR=udp/127.0.0.1:8888 \
    cargo run -p native-rs-bridge-tt-zenoh-to-xrce

examples/bridges/tt-zenoh-to-cyclonedds/

The stock-Cyclone-DDS sibling: same TT schedule, but the egress is .rmw("cyclonedds"), forwarding onto the DDS databus where a stock rmw_cyclonedds_cpp (e.g. an Autoware listener) or another nano-ros cyclonedds node receives the samples. One structural difference from the XRCE variant: Cyclone rejects a raw publisher whose topic type has no registered dds_topic_descriptor_t, so the egress type’s schema is staged before the raw publisher is created —

#![allow(unused)]
fn main() {
// The Cyclone backend installs the registrar during its register():
nros_rmw_cyclonedds_sys::register()?;
// Stage std_msgs/msg/String's schema (NUL-terminated key — it is handed
// straight to Cyclone's C descriptor table):
nros_rmw::register_type_descriptor(
    "std_msgs/msg/String\0",
    &[nros_serdes::schema::Field { name: "data\0", ty: FieldType::String, offset: 0 }],
)?;
let pub_out = node_out.create_publisher_raw("/chatter", "std_msgs/msg/String", hash)?;
}

XRCE registers lazily from name+hash; Cyclone needs the descriptor up front. The backend links the vendored CycloneDDS (no -DNANO_ROS_RMW needed for the Rust binary — the nros-rmw-cyclonedds-sys dep is the selection).

zenohd --listen tcp/127.0.0.1:7447 &
ROS_DOMAIN_ID=0 cargo run -p native-rs-bridge-tt-zenoh-to-cyclonedds
# subscribe on Cyclone DDS /chatter (stock ROS 2 or a nano-ros cyclone node on
# the same ROS_DOMAIN_ID) and publish on zenoh /chatter to see bridged samples.

Coverage matrix

Bridge examples live under examples/bridges/<name>/ (cross-platform, transport- spanning) or under their canonical examples/<plat>/<lang>/bridge/<name>/ cell when the bridge is platform-specific. The examples README coverage matrix lists which <plat> × <lang> combinations ship a bridge today (a bridge spans RMW backends by nature, so RMW is not a directory axis).

Troubleshooting

SymptomLikely cause
Transport(ConnectionFailed) on open_with_rmw("X", ...)Backend X’s rlib not pulled into the link line. Rust: add a register() call. C / C++: confirm --whole-archive wraps the staticlib.
Second node’s .rmw("zenoh") returns Transport(...)Both nodes try to open against the same singleton-state backend. Set NROS_RMW=zenoh so the primary lands on zenoh + the second Node hits the session-cache (slot 0) instead of double-opening.
nros_publisher_init -> -7 after nros_executor_node_initStale build. The C-side multi-Session dispatch in entity-init paths landed in commit 42001c37; rebuild nros-c after pulling main.
Bridge spinning marker never reaches piped test harnessAdd setvbuf(stdout, NULL, _IOLBF, 0) at the top of nros_app_main. glibc full-buffers piped stdout; line-buffering surfaces readiness markers before the long-lived spin_period loop.

See also