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

Porting a ROS 2 C++ node to nano-ros

Goal: take a normal ROS 2 C++ node (one that compiles + runs under colcon build against ros-humble-*) and run it under nano-ros — without rewriting the source. The rclcpp:: names are declared by nano-ros’s own C++ headers — <rclcpp/rclcpp.hpp> is nros-cpp’s own umbrella, not a layer over another API (RFC-0096 D4). The CMake side is part of nano-ros’s own package: cmake/NanoRosAmentSurface.cmake defines the ament_* verbs, cmake/find/ holds the Find<pkg>.cmake modules, and diagnostic_updater is packages/api/nros-diagnostic-updater. So the only delta is build-script glue.

The canonical proof lives at examples/templates/cpp-port-minimal-publisher/ — the ROS 2 tutorial’s minimal_publisher.cpp vendored unmodified, building against nano-ros via the three glue lines below.

The one compile definition, and why you do not type it

A ported file names rclcpp::Node, std::make_shared<MyNode>() and create_publisher<M>(...) returning a SharedPtr. Those signatures are spelled in std::shared_ptr and std::string, and nano-ros compiles that surface only when a build asks for it by defining NROS_CPP_STD. It is never inferred from what your compiler happens to have on its include path.

nano-ros’s ament surface sets it for you: every target created through ament_auto_add_* / rclcpp_components_register_node, and every target linking rclcpp::rclcpp, gets it. A project following the two layers of glue below needs nothing extra. You type it yourself only when you build a ported translation unit some other way:

target_compile_definitions(my_ported_node PRIVATE NROS_CPP_STD=1)

Two things worth knowing before you reach for it:

  • This is not a host-versus-embedded switch. Code written against rclcpp::Node does not want the flag on a native Linux build either; ported rclcpp code wants it on a Cortex-M3 build just as much. The split is whose code it is, not what it runs on. See C++ API — two surfaces.
  • Define it per target, not per file. It changes the layout of rclcpp::Node, so two translation units of one image disagreeing about it is an ODR break rather than a missing function.

If you forget it the failure is loud and at compile time — an unknown rclcpp::Node, or a create_publisher overload that does not match — never a silent behaviour change.

Two layers of glue

Per-package CMakeLists.txt — zero nano-ros lines

The ported pkg’s CMakeLists.txt carries only stock ROS 2 syntax. Same file builds under both colcon build AND a nano-ros build:

cmake_minimum_required(VERSION 3.20)
project(my_node LANGUAGES CXX)

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
# … any other msg packages the source #include's.

add_executable(my_node src/my_node.cpp)
ament_target_dependencies(my_node rclcpp std_msgs)

ament_package()

find_package(rclcpp) resolves through nano-ros’s Findrclcpp.cmake, which defines rclcpp::rclcpp over the nros-cpp runtime — and with it the include path on which <rclcpp/rclcpp.hpp> and <rclcpp_components/register_node_macro.hpp> are nano-ros’s own headers; find_package(std_msgs) resolves through the message-package resolver (it walks NROS_INTERFACE_SEARCH_PATH > AMENT_PREFIX_PATH > bundled); nano-ros’s ament_target_dependencies wires both link targets.

These are find MODULES rather than <pkg>Config.cmake files on purpose: find_package() tries module mode first, so nano-ros’s rclcpp wins over an installed ROS 2 even when /opt/ros/<distro>/setup.bash has been sourced.

Workspace umbrella CMakeLists.txt — one nano-ros include

The umbrella CMakeLists.txt (sits at the workspace root, next to src/) is the only nano-ros-aware file:

cmake_minimum_required(VERSION 3.22)
project(my_workspace LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)

# 1) Pull nano-ros in.
set(NROS_PLATFORM posix)
set(NROS_RMW "zenoh" CACHE STRING "Active RMW.")
set(NROS_RMW "${NROS_RMW}")
add_subdirectory("/path/to/nano-ros" nano_ros)

# 2) Point the message-package resolver at this workspace's src/ (must
#    precede the ament-surface include so workspace-pkg Find<pkg>.cmake
#    auto-emit picks it up).
set(NROS_INTERFACE_SEARCH_PATH "${CMAKE_SOURCE_DIR}/src")

# 3) nano-ros's ament / rclcpp surface.
include("/path/to/nano-ros/cmake/NanoRosAmentSurface.cmake")

# 4) Bulk-build every workspace msg pkg in topo order (one line instead of
#    N add_subdirectory(src/<pkg>) lines).
nros_workspace_interfaces()

# 5) Build consumer apps.
add_subdirectory(src/my_node)

No nros_generate_interfaces(<pkg>) calls per consumer — the smart Find-stub does the codegen at find_package(<pkg>) time.

Reference fixture

examples/templates/local-msg-package/ ships the pattern end-to-end: two workspace msg pkgs (local_msgs, extra_msgs) with intra-workspace dep + a C++ consumer pulling msgs from BOTH the workspace AND AMENT (std_msgs, geometry_msgs, sensor_msgs) via one find_package shape. Cross-build proof: the same src/ builds under colcon build (CI-gated).

Legacy nros_generate_interfaces(<pkg>) shape

(The full current-vs-legacy spelling table lives in Message Generation.)

Per-package nros_generate_interfaces(std_msgs LANGUAGE CPP SKIP_INSTALL) calls still work (back-compat preserved) but are deprecated for new code — they bypass the ROS-convention smart Find-stub + workspace discovery. Existing in-tree examples will migrate as part of Phase 210.E.3.

Running the ported node on a microcontroller

The same source runs on an RTOS, main included. Build glue for each platform sits beside the source, and the source itself does not change. examples/templates/cpp-port-minimal-publisher/ carries two such directories, mps2-an385-freertos/ and zephyr/, each with a CMakeLists.txt built around one line:

find_package(nano_ros REQUIRED)
nano_ros_add_executable(minimal_publisher ../src/minimal_publisher.cpp ROS2_MAIN)

The board comes from the directory’s system.toml, as for any nano-ros image. Every nano_ros_add_executable reaches your main (phase-499): no C runtime calls int main(int argc, char** argv) on a board, so the build renames main in your sources only (never in the board’s own startup) and calls it from the platform’s entry, with argc == 0. ROS2_MAIN adds what a ported program needs on top of that:

  • It gives the ported code the C++ standard library. A ported node uses std::string, std::to_string and <chrono> literals.
    • On FreeRTOS the toolchain compiles C++ -ffreestanding, which makes <string> an #error, so the flag is removed for this package’s directory. nano-ros’s own code stays freestanding.
    • On Zephyr, select the full library in prj.conf: CONFIG_REQUIRES_FULL_LIBCPP=y and CONFIG_GLIBCXX_LIBCPP=y.
  • It asks for the std-shaped API (NROS_CPP_STD), the same one the ament verbs give a ported package on a host.

Where the node connects is decided when the image is built, because a board has no environment to read it from: the NROS_ENTRY_LOCATOR cache variable on FreeRTOS, the image’s locator in system.toml on Zephyr (rendered into CONFIG_NROS_ZENOH_LOCATOR). rclcpp::init(argc, argv) reads it in every source file that includes <rclcpp/rclcpp.hpp>.

A node with a hand-written spin loop also works. The other two templates, rclcpp-compat-smoke/ and topic-state-monitor-port/, have the same two directories, and their main is the common shape:

while (rclcpp::ok()) {
    node->tick();
    rclcpp::spin_some(node);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

On FreeRTOS the board provides what the C++ library needs for this: std::this_thread::sleep_for sleeps the calling task, and std::chrono::steady_clock counts from boot, because the board has no real-time clock. A package that uses another library, such as diagnostic_updater, names it the usual way, ament_target_dependencies(<name> diagnostic_updater). On Zephyr the dependency goes to app, where the sources are compiled.

Zephyr’s native_sim cannot run a ported node. There Zephyr links its own C library, and the host’s libstdc++ cannot run on top of it. Use a Cortex-M board such as mps2/an385 in QEMU, which is what the template targets.

What “just works” without source edits

nano-ros covers the patterns a typical ROS 2 C++ node uses:

rclcpp surfacenano-ros mappingNotes
class MyNode : public rclcpp::Noderclcpp::Node — ours, and the same name.Ctor takes (name), (name, options) or (name, ns, options).
std::make_shared<MyNode>()worksauto node = std::make_shared<MyNode>() and rclcpp::spin(std::make_shared<MyNode>()) are unchanged. rclcpp::Node::SharedPtr node = std::make_shared<…>(…) does not compile — write auto; see “Node::SharedPtr observes, it does not own” below. shared_from_this() works too, with the same caveat.
create_publisher<M>(topic, qos)shared_ptr-returning wrapperqos can be rclcpp::QoS(10) or an int.
create_subscription<M>(topic, qos, callback)registered on the executor arena; dispatched by any spin verbCapturing lambdas + std::function all work.
create_wall_timer(period, callback)registered on the executor arena; dispatched by any spin verbstd::chrono::duration arg, capturing-lambda callback. Returns rclcpp::TimerBase::SharedPtr, which is rclcpp::Timer::SharedPtr — one flat type, two names. See below.
rclcpp::create_timer(node, clock, period, cb)the clock-taking verb; a RCL_ROS_TIME clock follows /clockHumble’s only form. create_wall_timer stays on the steady clock.
rclcpp::init(argc, argv) / shutdown() / ok() / spin(n) / spin_some(n)wraps rclcpp::init_in/shutdown_in/ok/spin_once--ros-args -r from:=to is honoured, beneath any remap the launch file sets for the same name. -p [node:]name:=value and --params-file <file> are honoured in an image with a parameter store: the value replaces the default when the node declares the parameter (scalars only from a file; a value of the wrong type is logged and the default kept). Any other ROS argument (__node, log flags) aborts, naming it.
RCLCPP_INFO / WARN / ERROR / DEBUG / FATALdispatched through the NROS_LOG_* family into nros_logCarries the logger, so get_logger("x") selects a real per-logger level, and FATAL is a distinct severity. Reaches LOG_ERR/printk on embedded, where the legacy NROS_* sink is a no-op (issue 1019). _THROTTLE variants are REFUSE-LOUD.
get_logger().get_child("sub")a real child logger, <parent>.sub (RFC-0102)Its level, when unset, follows the nearest ancestor’s, so set_level on the node logger reaches it. A full name over 48 bytes or a full runtime-logger arena emits through the PARENT and set_level on it is refused — never a silent move of the parent’s threshold. Iron/Jazzy images publish children on /rosout; Humble ones, as upstream, do not.
executor.spin_once() / client->wait_for_service() / action_client->wait_for_action_server()the budget is requiredUpstream defaults all three to “block forever”, which nano-ros has no form of; the no-argument call is a compile error naming the alternative rather than a silently substituted 10 ms / 5 s budget. Write spin_once(10), wait_for_service(10000).
rclcpp::QoS / KeepLast(n) / SystemDefaultsQoS()one class with upstream’s implicit (depth) ctorChainable setters inherited.
diagnostic_updater::Updater + DiagnosticStatusWrapperpackages/api/nros-diagnostic-updaterPublishes /diagnostics.
rclcpp_action::Server<A> / Client<A>ours, under upstream’s names (rclcpp::ActionServer/Client<A> are aliases for them)Same entities, smaller shapes: no goal handles and no futures. See Actions below.
class MyNode : public rclcpp_lifecycle::LifecycleNodeours, under upstream’s name (<rclcpp_lifecycle/lifecycle_node.hpp>)It IS an rclcpp::Node. The on_configure(const State&) … on_shutdown overrides, CallbackReturn::SUCCESS, configure() / activate() / … and get_current_state() keep upstream’s signatures; the REP-2002 services are registered by the constructor. create_publisher<M>(topic, qos) returns a managed LifecyclePublisher<M>::SharedPtr that sends only while Active. One managed node per executor.
RCLCPP_COMPONENTS_REGISTER_NODE(class)no-op macro + cmake-side rclcpp_components_register_node() emits a thin int main() per registrationSingle-binary embedded.
find_package(ament_cmake_auto / rclcpp / rclcpp_components / diagnostic_updater / std_msgs / …)find modules at cmake/find/~28 of the most-cited ROS 2 packages; a message package not among them is found by the resolver from NROS_INTERFACE_SEARCH_PATH / AMENT_PREFIX_PATH.

Actions: no goal handles, no futures

The action types keep upstream’s names and concepts, and GoalResponse / CancelResponse keep upstream’s enumerators and values. The SHAPES around them are smaller, because there is no allocator to hand out a goal handle and no async runtime to complete a future (RFC-0021). Every difference below is a compile error in a ported file, so the list is what to rewrite, not what to debug:

  • Server: no ServerGoalHandle. The goal and accepted callbacks receive the 16-byte goal id. Feedback and termination are calls on the SERVER keyed by that id — server.publish_feedback(id, fb), server.succeed(id, result), abort, canceled — taking the message by const reference, not a shared_ptr.
  • Server: created in place. There is no rclcpp_action::create_server returning a SharedPtr; declare the Server<A> and initialise it with node.create_action_server(srv, name). It is not a Waitable (no execute / is_ready / set_on_ready_callback): the executor drives it directly.
  • Callbacks are stateless function pointers (empty-capture lambdas), or the _with_ctx setters with a void* context.
  • Client: send_goal blocks and fills an out-param goal id, returning Result, where upstream’s async_send_goal returns a shared_future of a goal handle. send_goal_future is the future-shaped spelling.
  • Client: callbacks bind to the CLIENT. Upstream passes SendGoalOptions to each async_send_goal; here client.set_callbacks(opts) installs them once for every goal the client sends. They are read when a reply is dispatched, so install them before the spin that delivers it.
  • wait_for_action_server takes milliseconds and returns Result — and the budget is required (see the table above).

Two things the compiler will not tell you

Everything else on this page either works or fails to compile. These two compile and behave slightly differently, so they are written down rather than left for a debugging session.

Node::ok() — nano-ros cannot throw, so YOU have to ask

Upstream’s rclcpp::Node constructor THROWS when the node cannot be created. nano-ros is built -fno-exceptions (RFC-0018), so the constructor cannot: it records the failure and the node answers false to ok().

rclcpp::Node node("talker");
if (!node.ok()) return 1;        // <- this line replaces upstream's try/catch

A generated entry already does this and halts naming the node, so a workspace component needs nothing. A hand-written main does not, and a node that failed to create will otherwise go on to create publishers that quietly do nothing. There is no diagnostic for it — the object exists either way, which is the whole point of not throwing — so checking ok() after constructing a node by hand is on you.

The same shape applies to init(), the two-phase form:

rclcpp::Node node;
if (!node.init("talker").ok()) return 1;

Result carries [[nodiscard]] on C++17 and later, so that one the compiler does warn about.

Node::SharedPtr observes, it does not own

rclcpp::Node::SharedPtr is rclcpp::Handle<rclcpp::Node> on every target, embedded included: a copyable reference that does not keep the node alive. A node is constructed by your main (or the generated entry) and outlives everything it is handed to, so storing and copying it works as upstream’s does. Two consequences:

  • shared_from_this() returns that handle. It is safe to pass to anything that holds it for less than the node’s lifetime (diagnostic_updater::Updater(shared_from_this(), 1.0) is the common case), and NOT safe to store somewhere that outlives the node. It never throws where upstream would raise bad_weak_ptr. nano-ros cannot derive from std::enable_shared_from_this<Node>: its std::weak_ptr member exists only where <memory> does, so two translation units of one firmware image could disagree about the object’s layout.
  • rclcpp::Node::SharedPtr node = std::make_shared<MyNode>(); is a compile error. The shared_ptr there is a temporary, and a handle to it would dangle at the end of the line; the compiler names the deleted conversion. Write auto node = std::make_shared<MyNode>(); instead. A shared_ptr you keep converts to Node::SharedPtr freely, and rclcpp::spin(…) accepts the temporary directly.

rclcpp::TimerBase is a NAME here, not a hierarchy

rclcpp::TimerBase::SharedPtr timer_; compiles unchanged, and you should keep writing it — it is the only spelling that works against real ROS 2 as well as against nano-ros, since upstream has no rclcpp::Timer.

What differs is behind the name. nano-ros has no timer hierarchy: TimerBase is an ALIAS for the one flat rclcpp::Timer, so is_same<TimerBase, Timer>::value is true and neither spelling carries a vtable. The executor dispatches through a raw function pointer, so a polymorphic base would be cost with no caller.

Consequences worth knowing:

  • Timer::SharedPtr is a small handle, not a std::shared_ptr. Its callback lives in the executor, so the handle does not own the timer. Dropping or overwriting timer_ leaves the timer firing; destroying the node stops it. timer_.reset() releases the timer for good. timer_->reset() restarts it, as upstream. A member declared as std::shared_ptr<rclcpp::TimerBase> does not compile: spell it rclcpp::TimerBase::SharedPtr.
  • The same holds for Subscription<M>::SharedPtr, Service<S>::SharedPtr and Client<S>::SharedPtr: dropping the handle leaves the entity running, and sub_.reset() / srv_.reset() / cli_.reset() releases it for every copy at once. A one-shot timer or subscription that resets ITSELF from its own callback works: the release takes effect when the callback returns.
  • rclcpp::WallTimer<...> and rclcpp::GenericTimer<...> do not exist. The clock axis is the second verb rclcpp::create_timer(node, clock, period, cb) plus a runtime field, not a type parameter.
  • DERIVING from rclcpp::TimerBase gets you a concrete handle with a non-virtual destructor. Upstream code almost never does this; if yours does, it needs rework. There is no timer hierarchy here: the executor dispatches through a raw function pointer, so a polymorphic base would be a vtable nothing calls, and TimerBase is a name that promises WallTimer and GenericTimer siblings we deliberately do not have. The clock axis is a runtime field plus the second verb rclcpp::create_timer, not a type parameter.

What’s documented as “needs adapt” (codegen-side, not surface-side)

These are cosmetic codegen differences nano-ros’s per-package codegen and the upstream rosidl_default_runtime codegen don’t share; both are tracked as ROS-convention codegen work.

  • Message string fields. nano-ros codegen emits rclcpp::FixedString<N>, upstream emits std::string. Assigning a std::string needs a one-token adapter: message.data = s.c_str(). The reverse (std::string{}.c_str()) is what RCLCPP_INFO already takes.
  • Generated message header path. CLOSED (alias headers): nano-ros codegen emits BOTH the per-message form <std_msgs/msg/string.hpp> (upstream-shape) AND the umbrella <std_msgs/std_msgs.hpp>. Use whichever the original source picks.

What’s out of scope (will need code adapt or a follow-up phase)

  • Yaml-loaded parameters. declare_parameter<T>("name", default) reads from a launch yaml in stock ROS 2. nano-ros has no runtime yaml loader — parameter initials are compile-baked from the launch XML’s <param name="…" value="…"/> entries (RFC-0004 §10), then live in a volatile store the standard parameter services can update until the next boot. Move yaml values into the launch file (or expose them as compile-time constants).
  • tf2, image_transport, pluginlib — out of nano-ros scope. Project- specific helpers (autoware universe_utils, PX4 uORB shims) are not nano-ros’s to ship; the porting user vendors them or replaces the call sites with raw nros-cpp ones.

When the port hits a gap

Known open follow-ups: yaml-loaded parameter baking, LifecycleNode compat, and the in-tree migration of legacy nros_generate_interfaces(<pkg>) call sites. If your port surfaces a new gap, file an issue — a fix lands either tree-side (in packages/api/nros-cpp/, cmake/NanoRosAmentSurface.cmake or cmake/find/) or as a codegen change.

In-tree regression fixtures:

Drop your reduced-case node under examples/templates/<your-port>/ and add it to CI once the gap closes.