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

How nano-ros differs from ROS 2 and micro-ROS

Explanation · Level 3: Production and tuning

A ported rclcpp node compiles against nano-ros with the same includes and the same names. One line it needs that upstream does not:

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

Upstream’s rclcpp::Node constructor throws when the node cannot be created. nano-ros is built with -fno-exceptions, so the constructor records the failure instead, and the node answers false to ok(). The vocabulary is the same; the reason underneath is not.

That is the pattern of this page. nano-ros keeps ROS 2’s vocabulary and wire format so existing nodes port and talk to ROS 2 peers. Where it differs, the reason is almost always the same: it runs on a microcontroller with little RAM to spare, no dynamic loader and often no threads. This page states each difference plainly, then compares nano-ros with micro-ROS, its closest peer. The tasks are Port a C++ node and Move a ROS 2 project over.

What stays the same

  • The entity model. A node owns publishers, subscriptions, services, clients, action servers, action clients, timers and parameters, with ROS 2’s names (see the ROS 2 concepts).
  • Names and types. Topic, service and action names follow ROS 2’s conventions, message types are rosidl-shaped (std_msgs/msg/Int32), and CDR is the encoding on the wire.
  • Packages. A workspace is a src/ of packages with package.xml manifests.
  • The wire. The zenoh backend is compatible with upstream rmw_zenoh: a nano-ros publisher and an rclcpp subscriber on the same router exchange messages with no bridge (Talk to ROS 2).
  • The C and C++ spellings. The C API uses rclc’s names, argument order and arity; the C++ API uses rclcpp’s.

What changes in the workflow

  • One release, no package registry. The nros CLI is a GitHub release, and the library ships as source inside it, compiled into your build. Nothing comes from crates.io or a ROS 2 apt repository.
  • Per-board provisioning, no rosdep. nros setup <board> fetches the toolchains and SDKs for exactly that board and RMW into the user’s store, with no system-wide install. The zenoh router is not provisioned: it comes from a ROS 2 install.
  • nros build, not colcon build. It reads the same package.xml files and drives cargo, CMake or west, with no install space (How a build becomes an image).
  • Message code lands in your build. There is no installed _msgs library. The message definitions still come from your ROS 2 install, through AMENT_PREFIX_PATH, so the layouts and type hashes match your ROS 2 peers (Message generation).
  • No ros2 launch on the device. The launch file is read at build time, and the generated entry is the launched system.
  • Configuration is build-time on embedded. On a host, ROS_DOMAIN_ID and the other environment variables work. An embedded image bakes the same values from system.toml.

What changes in the API

The executor is the entry point

ROS 2 starts with a global init (rclcpp::init, rclc_support_init), then nodes, then optionally an executor. nano-ros inverts it: an Executor opens the session, and every node, entity and timer is created from it, in storage sized at build time. On a target with tens of kilobytes of RAM, a global allocator behind every create_publisher is a cost the image cannot carry, so the sizing moves to the build. The client library explains the model.

Polling and callbacks are both first-class

rclcpp is callback-only: every subscription has a callback the executor dispatches. nano-ros also has polled entities, under a different name (create_polling_subscription), so you choose per entity. A real-time loop often wants the predictability of an explicit poll; an event-driven service wants a callback (The execution model).

No async runtime and no multi-threaded executor

rclrs has an async spin and rclcpp has a MultiThreadedExecutor. nano-ros has neither, on purpose. A future polled by tokio or Embassy runs when that scheduler decides, so nano-ros could not state when a callback runs or at what priority. Parallelism is a sched task instead: an executor on an RTOS thread at a priority the build derives.

The heap is optional

ROS 2 assumes std and a heap. The nano-ros core is no_std with fixed-size pools, and alloc is opt-in (Static memory and pools). The C transport libraries still allocate through the platform’s allocator.

The backend is chosen when you build

ROS 2 reads RMW_IMPLEMENTATION at process start and loads a shared library. nano-ros links the backend when it builds the image, from the bringup’s rmw. Most embedded targets have no dlopen; linking only the chosen backend keeps its C client out of an image that does not use it; and the build knows what to link. The cost is that changing backends means rebuilding. One binary can still link several backends and bridge between them (Bridge between backends).

No introspection on the device

ros2 topic list, ros2 node info and the rmw_get_* graph queries run on a host. A nano-ros image answers what its backend’s discovery exposes, and you introspect it from a ROS 2 host over the interop path. Every byte of introspection is a byte a microcontroller pays for, when a ROS 2 host is one router hop away.

QoS: the full profile, with support stated per backend

The QoS profile has the full DDS field set, and the standard profiles (_DEFAULT, _SENSOR_DATA, _SERVICES_DEFAULT, _PARAMETERS, _SYSTEM_DEFAULT) match upstream field for field. What differs is enforcement. Each backend advertises which policies it can enforce (Session::supported_qos_policies()), and creating an entity with a policy the backend cannot honour fails at once with IncompatibleQos. A requested policy is never dropped silently. Upstream reports a mismatch later, as a run-time event; most mismatches are configuration errors visible at start-up.

Manual liveliness is the sharpest case. assert_liveliness() exists in every language, but only Cyclone DDS puts it on the wire. zenoh-pico’s liveliness is a session keep-alive with no per-topic lease, so under a manual kind zenoh, XRCE-DDS and uORB answer UNSUPPORTED rather than an Ok that would mean nothing was sent. The run-time side is Use QoS, status events and discovery, and per-backend coverage is the Per-RMW feature matrix.

Parameters: one store, a smaller surface

nano-ros keeps the vocabulary (declare_parameter<T>, get_parameter<T>, set_parameter<T>, has_parameter) and the remote services, so ros2 param list and ros2 param set reach a node with parameter services on. Underneath there is one store, owned by the executor and keyed by node, with a slot count fixed at build time; it is also the store the services read, so what you declare is what ros2 param get returns. A node does not own its parameters, because a store per node is a fixed arena per node on a target that has one.

What is smaller than rclcpp, and why:

rclcppnano-rosWhy
ParameterDescriptor (ranges, read-only, description)not exposedmetadata is a host concern; the type is enforced at declare time
three set callbacks (pre_set, on_set, post_set)one validation callback per C servereach callback is an indirection slot per node; one covers range checks
set_parameter returns SetParametersResult with a reasona return codea reason string needs a heap or a fixed buffer
set_parameters_atomicallynot exposedneeds a transaction log
declare_parameters with a std::mapC++ on a hosted build only, returning Resultthe map needs an allocator
overrides from the command line, launch file or YAMLthe launch file at build time; ~/set_parameters over the wire; -p and --params-file on a hosted buildan embedded image has no command line

An array value is rclcpp::Seq<T, N>, an array parameter, with no heap: the store copies the elements into its own slot. When you need the full upstream surface, run it on a host ROS 2 node and keep the embedded node a leaf. The calls and /parameter_events are in the rclcpp compatibility reference.

Where C++ code behaves differently

Most of the rclcpp surface either works or fails to compile. Three things compile and behave differently, each for a reason:

  • Node::ok(). With no exceptions, a failed constructor cannot throw, so you ask. A generated entry checks it and halts naming the node; a hand-written main must check, or a failed node goes on to create publishers that do nothing.
  • Node::SharedPtr observes; it does not own. It is a copyable handle, not a std::shared_ptr, on every target, embedded included. A node is constructed by the entry and outlives everything it is handed to, so no ownership needs counting. shared_from_this() returns the handle and never throws bad_weak_ptr. nano-ros cannot derive from std::enable_shared_from_this, because its std::weak_ptr member exists only where <memory> does, and two translation units of one image could then disagree about the node’s layout. Assigning a temporary make_shared result to a Node::SharedPtr is a compile error, since the handle would dangle; write auto.
  • rclcpp::TimerBase is a name, not a hierarchy. It is an alias of the one flat rclcpp::Timer, with no vtable: the executor dispatches through a plain function pointer, so a polymorphic base would cost bytes with no caller. The clock is a run-time field and a second verb, rclcpp::create_timer, not a type parameter, so WallTimer and GenericTimer do not exist. A SharedPtr to a timer, subscription, service or client is a small handle too: dropping it leaves the entity running, and reset() on the handle releases it.

Compared with micro-ROS

micro-ROS is the closest peer: a ROS 2 client stack for microcontrollers. The two differ mostly in what they assume about the network and the language.

nano-rosmicro-ROS
Application APIsRust, C (rclc’s names), C++ (rclcpp’s names)C (rclc)
RMWzenoh, XRCE-DDS, Cyclone DDS, chosen per imageXRCE-DDS
Network modelthrough a zenoh router, brokerless with Cyclone DDS, or agent-based with XRCE-DDSagent-based
Process beside the devicea zenoh router, none, or an XRCE-DDS agent, by backendalways the Micro XRCE-DDS Agent
Coreno_std, fixed-size poolsC with a static memory configuration
Schedulingsched tasks and derived levels, EDF and sporadic classes, budgets (The execution model)the rclc executor
Several backends in one binaryyes, with bridgesno
End-to-end integrityCRC and sequence check (Safety)none built in
Formal checksKani harnesses and Verus proofs over serialization, scheduling and protocol logicnone published
Buildnros build driving cargo, CMake or west; or nano-ros inside your CMake, Zephyr or NuttX buildcolcon and per-RTOS firmware scripts
LicenseMIT or Apache-2.0Apache-2.0

The boards nano-ros supports, and at what level, are generated in Supported boards.

Choose nano-ros when you want Rust as a first-class API, a zenoh router or brokerless DDS instead of an agent, several backends in one image, or scheduling beyond priorities. Choose micro-ROS when you already have rclc code and an agent deployment, when your toolchain must stay C-only, or when you need its ecosystem’s commercial support.

Moving from micro-ROS is mostly a rename, and in C often not even that: the C API keeps rclc’s spellings, so rclc_publisher_init_default, rclc_node_init_default (in rclc’s argument order) and rclc_executor_t are nano-ros’s names too. An existing XRCE agent serves nano-ros’s XRCE-DDS backend unchanged. A custom transport is a platform-level transport rather than rmw_uros_set_custom_transport (Add a transport).

Design background

  • RFC-0113: why there is no async executor.
  • RFC-0089: adopting the ROS 2 API, and the compile-or-conform rule: a ported line either behaves as upstream’s or does not compile.