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

C / C++ multi-node workspaces

The four previous chapters (project layout, node packages, bringup packages, images) describe the canonical node pkgs + bringup pkg + [image.*] shape against the Rust path (nros::node!(…) + nros::main!(launch = …)). This chapter shows the C and C++ path through the same shape, role-for-role.

The parity gap is closed. Same launch.xml, same package.xml, same system.toml, same workspace pkg-index — the only thing that changes language-side is the cmake-fn / macro surface.

TL;DR — side-by-side

RoleRustC / C++
Node pkglib.rs with nros::node!(MyNode), declared by a [[component]] row in the bringup’s system.tomlTalker.{hpp,cpp} with a configure(::rclcpp::Node&) component method (C++) / RCLC_C_COMPONENT (C); CMakeLists.txt calling nano_ros_auto_add_library + nros_components_register_node (RFC-0057)
Bringup pkgpackage.xml + system.toml + launch/*.launch.xml (no Cargo.toml)identical (language-agnostic)
Image[image.linux] board = "linux" in the bringup’s system.tomlidentical (language-agnostic)
The entryGENERATED into build/<coord>/linux_entry/ — a Cargo.toml and a nros::main!(launch = "demo_bringup")GENERATED into build/<coord>/CMakeLists.txt — a nano_ros_add_executable(linux_entry BOARD … BRINGUP … LAUNCH … LANG cpp TYPED DEPLOY linux) call, plus the main TU the verb emits
Workspace rootnone — the generated entry’s own Cargo.toml is the cargo rootnone — the generated build/<coord>/CMakeLists.txt is the cmake root, calling nano_ros_workspace(WORKSPACE_ROOT … BACKEND zenoh PLATFORM posix SYSTEM demo_bringup SUBDIRS …)
Buildnros sync then nros build linuxnros sync then nros build linux
Boot./build/posix-zenoh/linux_entry/target/debug/linux_entry./build/posix-zenoh-linux/cmake/linux_entry

The reference C++ workspace ships in-tree at examples/workspaces/cpp/. Copy the whole directory, rename the packages.

Workspace layout

Identical structure to the Rust template, swapping Cargo.toml → CMakeLists.txt inside each Node pkg — and, at the root, nothing at all:

my_ws/
├── .colcon_workspace             # tracked marker; there is no root build file
└── src/
    ├── talker_pkg/               # Node pkg (C++)
    │   ├── package.xml
    │   ├── CMakeLists.txt        # nano_ros_auto_add_library + nros_components_register_node
    │   └── src/{Talker.hpp,Talker.cpp}
    ├── listener_pkg/             # Node pkg (C++)
    │   ├── package.xml
    │   ├── CMakeLists.txt
    │   └── src/{Listener.hpp,Listener.cpp}
    └── demo_bringup/             # Bringup pkg (language-agnostic — copy/paste
        ├── package.xml           #          works between Rust and C++ workspaces)
        ├── system.toml           #          [system] + [[component]] + [image.*]
        └── launch/system.launch.xml

No entry package, and nothing at the root: a workspace is a directory of packages (RFC-0098 D9). nros build <image> writes a CMakeLists.txt and the entry’s nano_ros_add_executable(…) call into build/<coordinate>/, configures that into build/<coordinate>/cmake/, and builds — see Images. The exception is a Zephyr application, which keeps a package because west needs a real application directory.

The generated cmake root

You do not write this; nros build does, from the packages it discovered and the image it resolved. It is the cmake root — and it lives in build/<coordinate>/, not at the workspace root, which is why its first argument says where the workspace actually is. It is worth reading once, because everything below happens inside it:

cmake_minimum_required(VERSION 3.22)
project(my_ws LANGUAGES C CXX)
find_package(nano_ros REQUIRED COMPONENTS workspace)

nano_ros_workspace(
    WORKSPACE_ROOT "../.."               # this file lives in build/<coord>/
    BACKEND        zenoh                 # zenoh | xrce | cyclonedds
    PLATFORM       "posix"
    SYSTEM         demo_bringup
    ORDER_FROM_DEPENDS
    SUBDIRS        "../../src/talker_pkg"
                   "../../src/listener_pkg"
)

nano_ros_workspace() does the heavy lifting in one call:

  1. Sets NROS_PLATFORM=posix + NROS_RMW=zenoh.
  2. add_subdirectory(<nano-ros>) once at root scope (so per-pkg subdirs don’t collide on re-include).
  3. include(NanoRosNodeRegister.cmake) + include(NanoRosEntry.cmake) once.
  4. add_subdirectory(<each member>) for each SUBDIRS entry.

Subdir CMakeLists begin with the dual call:

nano_ros_workspace_pkg_guard()  # no-op inside a workspace; bootstraps standalone solo

— the cmake equivalent of cargo [workspace] discipline. Every member compiles standalone (with -DNROS_REPO_DIR=<path>) or as part of the workspace; the per-pkg CMakeLists doesn’t change between modes.

Node pkg

A typed component (RFC-0043) — no main(). The pkg ships a class with a configure(::rclcpp::Node&) method that creates real entities (a Publisher, a Timer) and binds member callbacks by identity (member-fn-pointer template param, no string callback name, no interpreter). The generated entry constructs the object and calls configure(node); the executor dispatches the callbacks.

# src/talker_pkg/CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(talker_pkg LANGUAGES C CXX)
nano_ros_workspace_pkg_guard()
nros_find_interfaces(LANGUAGE CPP SKIP_INSTALL)

# RFC-0057: the ament shape — the library owns the sources, the register
# owns the identity (keyword parity with rclcpp_components_register_node).
# Interface deps (std_msgs__nano_ros_cpp) are wired automatically.
nano_ros_auto_add_library(talker_lib STATIC src/Talker.cpp)

nros_components_register_node(talker_lib
    PLUGIN talker_pkg::Talker      # any qualified name — upstream namespaces port verbatim
    EXECUTABLE talker
    SHAPE configure)               # this walkthrough uses the configure(Node&) shape

Telling the build what the component creates

NROS_EXECUTOR_MAX_CBS, the subscriber slots and the arena are const sizes compiled into nros-node before a component TU is compiled, while an RFC-0043 component wires itself in its CONSTRUCTOR, at runtime. Nothing the macros emit can supply those sizes — it exists only after linking. So somebody has to state what the image creates, ahead of the build.

That statement is a contract sidecar beside the launch file, not something in this CMakeLists. <stem>.contract.yaml next to <stem>.launch.xml in the bringup package:

# src/demo_bringup/launch/system.contract.yaml
version: 1

nodes:
  talker:
    paths:
      on_timer:                                  # a path with a timer trigger
        trigger: { timer: { rate_hz: 1 } }       # IS the periodic callback
        output: [chatter]
    pub:
      chatter: {}
  listener:
    sub:
      chatter: {}

topics:
  /chatter:
    type: std_msgs/msg/Int32
    pub: [talker/chatter]
    sub: [listener/chatter]

nodes: keys are LOCAL endpoint names — an absolute path there is concatenated onto the node’s FQN and matches no topic. topics:, services: and actions: wire those endpoints to absolute names and carry the type. paths: is where timers live: the model records a path with no input as the periodic callback.

A single-package example writes the same file at <leaf>/system.contract.yaml, beside its system.toml. Its launch file is generated from the [[component]] rows, so there is no launch/ directory to put a sidecar in; nros sync carries this one to where the resolver looks. That is how a standalone leaf states its QoS — and stating it is worth real memory: one sub: { chatter: { qos: { history: keep_last, depth: 1 } } } row takes examples/linux/rust/listener from 273,802 to 172,298 bytes of static RAM, because the zenoh backend then sizes its payload pools for the one sample the image keeps instead of the four it might have.

The name you write must be the name the code registers, ABSOLUTELY. A contract states RESOLVED names; a call site writing "chatter" or a node the launch file remaps is one the build cannot match with certainty, so it refuses that endpoint by name and the image keeps its configured pools rather than being sized from a guess.

nros sync folds it into the resolved SystemModel, and the entry passes that model to the sizing automatically — nothing here takes an argument for it. Inspect the composed answer with:

$ nros ws entity-inventory --metadata <build>/nros-metadata.json       --model <ws>/build/nros/models/demo_bringup/system_model.yaml

The derivation is all-or-nothing per image: a component the contract does not describe makes that image derive nothing and keep its configured NROS_EXECUTOR_MAX_CBS. A count taken over only the components that answered is smaller than the image needs, and a short MAX_CBS fails entity creation at boot — so “some of them” is refused rather than guessed at.

Note that a publisher is inventoried but claims no callback slot, so the entity count and the derived MAX_CBS are legitimately different numbers.

Earlier versions stated this per component, as nano_ros_node_register(ENTITIES ...). That keyword is retired (phase-412) and now fails with a pointer here. It was a list beside the code with nothing comparing the two, and on the safety island one of them declared six subscriptions for a node that creates seven: every pool derived from it was short by one, and the eleventh subscription failed at boot with a transport error that named nothing.

The census: the contract checked against the code

Since the contract is still a statement beside the code, a CROSS image (any image whose board is not native) checks it before it configures: the native image generated from the same launch file is run once in census mode — it constructs every component against a recording backend, writes what they created, and exits without spinning or dialling a router — and the configure compares that census with the contract row by row. An endpoint the code creates and the contract omits refuses as missing-in-contract (not waivable: it is the short pool); one the contract declares and the code never creates refuses as phantom (waivable per row under [census.waive]).

The configure never builds the native image itself, so a cross build takes the census first:

$ nros ws entity-census take --image demo_bringup:zephyr
$ nros build zephyr

take does nothing when there is nothing to do — a model with no contract, or a census that still matches the code (freshness is by content: touching a file changes nothing, adding a subscription does). With no census, or a stale one, the configure REFUSES and prints that command. A bringup that wants the old behaviour writes [census] on_missing = "warn" / on_stale = "warn" in its system.toml, and gets a CMake WARNING instead.

// src/talker_pkg/include/talker_pkg/Talker.hpp
#pragma once
#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"

namespace talker_pkg {

class Talker {
    ::rclcpp::Publisher<std_msgs::msg::Int32> pub_;
    ::rclcpp::Timer timer_;
    int count_ = 0;

    void on_tick();  // real body; bound via &Talker::on_tick (no name)

  public:
    ::rclcpp::Result configure(::rclcpp::Node& node);
};

}  // namespace talker_pkg
// src/talker_pkg/src/Talker.cpp
#include "talker_pkg/Talker.hpp"

namespace talker_pkg {

void Talker::on_tick() {
    std_msgs::msg::Int32 m;
    m.data = count_++;
    (void)pub_.publish(m);
}

::rclcpp::Result Talker::configure(::rclcpp::Node& node) {
    ::rclcpp::Result r = node.create_publisher(pub_, "/chatter");
    if (!r.ok()) return r;
    // Member-fn-pointer-as-template-param → no-alloc trampoline; `this` is ctx.
    return node.create_wall_timer<Talker, &Talker::on_tick>(timer_, 1000, this);
}

}  // namespace talker_pkg

The generated entry constructs Talker in static storage and calls configure(node) on the real executor — the same component model the Rust nros::node!(Talker) + the C RCLC_C_COMPONENT paths use, so C++, C, and Rust Node pkgs interoperate in one launch graph.

Scaffold a C++ Node pkg with:

$ nros new --component my-talker --lang cpp --use-case talker
✓ Created nano-ros C++ Node pkg 'my-talker'
  Class     : my_talker::Talker
  Node      : talker
  Kind      : typed component (RFC-0043)

Bringup pkg

Language-agnostic — copy verbatim from the bringup chapter. package.xml + system.toml + launch/system.launch.xml. No Cargo.toml, no CMakeLists.txt. Stock ROS 2 launch.xml from nav2 / Autoware / turtlebot3 pastes in modulo unsupported tags.

<!-- src/demo_bringup/launch/system.launch.xml -->
<launch>
  <node pkg="talker_pkg" exec="talker" name="talker"/>
  <node pkg="listener_pkg" exec="listener" name="listener"/>
</launch>

The image, and the entry it generates

Declare the program you want in the bringup’s system.toml —

[image_defaults]
rmw = "zenoh"

[image.linux]
board = "linux"

— and nros build linux emits this into that generated root. BOARD and DEPLOY come from the resolved image, so a generated entry cannot disagree with the board it was generated for; there is no SOURCES, because the verb generates the translation unit carrying main:

nano_ros_add_executable(linux_entry
    BOARD   native
    BRINGUP "${CMAKE_CURRENT_SOURCE_DIR}/../../src/demo_bringup"
    LAUNCH  default
    LANG    cpp
    TYPED
    DEPLOY  native)

At configure time the cmake fn shells nros codegen entry --lang cpp --typed, emits ${CMAKE_BINARY_DIR}/linux_entry_nros_main_generated.cpp (the canonical int main() body that constructs each launch node’s component + calls configure(node) on the real executor via LinuxBoard::run_components), appends it to the target’s sources, and auto-links every <pkg>_<exec>_component static lib the launch XML named.

BRINGUP + LAUNCH — name your input, never the artifact

BRINGUP + LAUNCH name your input: the Bringup package and a launch selector. nros sync resolves that into a SystemModel build artifact (under <ws>/build/nros/models/<bringup>/ — never committed; gate check-no-tracked-models), and the entry bakes from it, so the same artifact drives the Linux runtime and every embedded image (contracts, tiers, QoS never drift). MODEL — naming the resolved artifact file directly — is a deprecated expert override; the two are mutually exclusive, and a committed model is banned outright.

nros sync resolves/refreshes the model whenever the bringup’s launch XML or system.toml is newer than it, and nros build runs it for you.

Writing the entry yourself

Two cases need a hand-written one, and both use nano_ros_entry(NAME … ) in a package’s own CMakeLists.txt:

  • A Zephyr application, which west requires as a real directory — scaffold it with nros new entry <name> --platform zephyr.
  • Startup the declarations cannot express — nros materialize <image> writes the generated entry out to src/<image>_entry/ and nros build then leaves it alone.

Either way the entry package needs no source file of its own: the cmake function generates the whole body (the main above), so an entry’s CMakeLists.txt names the bringup and launch file and nothing else.

Build + boot

nros sync
nros build linux
./build/posix-zenoh-linux/cmake/linux_entry

nros build resolves the SystemModel for you (via the pinned nros-launch-resolve helper, whenever the launch XML or system.toml is newer), writes build/<coordinate>/CMakeLists.txt, configures, and builds. It does not run nros sync: a workspace that has never been synced has no generated message bindings, and nros build refuses at preflight naming nros sync as the remedy. Sync once per workspace; after that only a .msg edit needs it again. The coordinate directory names what it contains: platform, RMW, board. Driving cmake yourself against that generated root — build/<coordinate>/CMakeLists.txt, never a file at the workspace root — still works.

The build produces:

  • src/talker_pkg/libtalker_pkg_talker_component.a — the Node pkg static lib (_component is the compatibility target suffix).
  • src/listener_pkg/liblistener_pkg_listener_component.a — ditto.
  • linux_entry — the image’s executable, with the generated int main() + register-call sequence + Board boot stub linked in.

cmake configure is incremental — pinned CMAKE_CONFIGURE_DEPENDS on every file nros codegen entry reads (depfile from the CLI), so any launch.xml / package.xml / Node pkg edit re-runs codegen.

What runs where

ConcernLives in
Node entities + real callbacksNode pkg configure(::rclcpp::Node&)
Topology + launch args + per-target deployBringup pkg system.toml + launch/*.launch.xml
int main() + executor init + spinGenerated TU emitted by the entry’s cmake fn
Board + RMW selectionThe image’s board and rmw, which become the generated BOARD / DEPLOY args

Same partition as the Rust track — the only thing that changes is the syntax the user types into the two package roles.

C / C++ scaffolding via nros new

CommandOutput
nros new --component <name> --lang cpp --use-case talkerC++ Node pkg; --component is the compatibility scaffold flag
nros new system <name>_bringup --components a,bBringup pkg (language-agnostic — works for both Rust and C++ workspaces)
nros new <name> --lang cpp --platform linuxA standalone single-package project — its own CMakeLists.txt and its own system.toml; the copy-out shape, not a workspace member
nros new entry <name> --platform zephyrA Zephyr application package + its [image.*] row. The verb accepts no other platform: everywhere else the entry is generated

The C-side compatibility scaffold (nros new --component … --lang c) is available for pure-C Node pkgs. Pure-C and mixed C/C++ workspace examples live under examples/workspaces/.

See also