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

Differences from standard ROS 2

Coming from rclcpp, rclrs, or rclc? This page calls out where nano-ros looks the same, where it diverges, and the reason behind each choice. It is an orientation page — the per-language API references (Rust / C / C++) cover the surface itself.

The short version: nano-ros keeps ROS 2’s vocabulary (Node, Publisher, Subscription, Service, Client, ActionServer, ActionClient, Timer, Executor, QoS, message codegen) so existing nodes port cleanly. The trade-offs that come from running on a Cortex-M3 with 64 KB of heap shape every place where the surface diverges.

Same vocabulary, same wire format

  • ROS 2 entity model unchanged. A node owns publishers, subscriptions, services, clients, action servers, action clients, timers, and parameters.
  • Topic / service / action names follow ROS 2 conventions (/talker_node/chatter, /add_two_ints, /fibonacci).
  • Message types stay rosidl-shaped (std_msgs/msg/Int32, geometry_msgs/msg/Twist, …). CDR encoding on the wire.
  • Default backend (nros-rmw-zenoh) is bit-compatible with the upstream rmw_zenoh ROS 2 RMW. A nano-ros publisher and an rclcpp subscriber on the same zenohd router exchange messages without a bridge — see ROS 2 Interoperability.

Where it diverges, and why

1. The executor is the entry point

Standard ROS 2 starts with a global init (rclcpp::init / rclrs::Context::default_from_env / rclc_support_init) followed by node creation, then optionally an executor.

nano-ros inverts this: an Executor opens the RMW session and owns the runtime budget. Every node, publisher, subscription, service, client, action handle, and timer is allocated out of the executor’s arena.

Executor::open(&config) → Node → Publisher / Subscription / Service / Client / Timer

Why. The arena is fixed-size and known at compile time (NROS_EXECUTOR_ARENA_SIZE / NROS_EXECUTOR_MAX_CBS). On a 64 KB heap MCU we cannot afford the indirection of a global allocator behind every create_publisher call. The executor-as-arena pattern moves the size negotiation up to the application’s startup code, where it belongs.

2. Both manual-poll and callback paths are first-class

rclcpp is callback-only — every subscription needs a callback, the executor dispatches them. rclrs <0.7 was manual-poll only. rclc exposes both but treats manual-poll as the second-class path.

nano-ros treats them as equals. A subscription created via node.create_subscription(...) exposes take(). A subscription registered via executor.node_mut(nid).create_subscription::<M, _>("/topic", callback) runs the callback during spin_once. Pick whichever fits the control loop.

Why. Embedded apps often want the predictability of an explicit poll inside a real-time loop. Callback dispatch is great for event-driven services but adds indirection that real-time engineers have to bound by hand. Offering both means the application picks.

3. Async pub/sub/service/action

rclcpp has no real async/await. rclrs 0.7+ added async but it sits next to the synchronous executor, not in it. rclc has none.

nano-ros has it as a first-class path: Executor::spin_async() drives the executor from inside a Future (it does not park on RMW I/O: each cycle is a 1 ms spin_once and an immediate self-wake, so it keeps a core busy — see its rustdoc), Subscription::recv().await, Client::call().await, ActionClient::send_goal().await, etc. Runs on tokio (POSIX), Embassy (FreeRTOS / RTIC), or any external Future-driver.

Why. Robotic control loops are stitched together from many concurrent waits — sensor data, service replies, action feedback, parameter updates. Async lets you write them as one straight-line function instead of hand-rolling state machines on top of a poll-based executor.

4. Heap is optional

ROS 2 RMW implementations all assume std + a heap. Even rclrs’s no_std story is “soon”.

nano-ros runs in three modes that map onto target capability:

ModeCargo featuresWhat works
stdstd (default on POSIX)Everything. POSIX threading, full async runtime.
no_std + allocalloc + a #[global_allocator]Everything except features that need std::sync::Mutex. Used by FreeRTOS / NuttX / ThreadX / Zephyr / ESP32.
no_std cooperativeno std/alloc platform threading (bare-metal board crates, RTIC apps)Cooperative single-task — no threading at all. Used by bare-metal MPS2-AN385, single-core RTIC.

Why. Heap presence is not a binary “embedded yes/no” — it is a spectrum. Stm32-class boards have a heap; Cortex-M0+-class might not. The feature axis lets the same application code target both.

5. Backend selection at compile time, not runtime

Standard ROS 2 uses an RMW_IMPLEMENTATION env var read at process start. The plugin loader pulls a shared library, dispatches calls through C function pointers.

nano-ros bakes the backend in at compile time. The consuming Cargo.toml adds the backend crate directly (nros-rmw-zenoh / nros-rmw-cyclonedds / nros-rmw-xrce-cffi) alongside nros with the rmw-cffi feature; CMake options (-DNROS_RMW=zenoh) decide it for C/C++ builds. The backend’s #[ctor] registers its vtable with the nros-rmw-cffi runtime registry before main.

Why.

  • Dead-code elimination. A 32 KB Flash budget cannot afford to link every backend’s C client and pick at runtime. Linking only the selected backend cuts the binary substantially (the three backends differ by an order of magnitude in linked C footprint — measure your own image with size; no benchmarked figure exists).
  • No plugin loader. Most embedded targets have no dlopen. The cost of the plugin abstraction is a permanent overhead with no payoff there.
  • Cross-compile sanity. RMW_IMPLEMENTATION baked into the binary means the build system already knows which backend’s C client to link — no separate “find shared library at runtime” step.

The trade-off is real: changing backends requires a rebuild. This is the right trade-off for the embedded use case; it would be the wrong trade-off for desktop ROS 2.

A binary can still link multiple backends and run them in parallel — one node on backend A, another on backend B, the executor draining both each tick. The Cross-backend Bridges chapter walks the build knobs (NROS_RMW=... primary selector, explicit per-backend register(), NROS_RMW= none cmake escape hatch) and the three shipped examples.

6. Message codegen lands inside your build, not a sibling library

Standard ROS 2 uses ament + rosidl to compile message packages (std_msgs, geometry_msgs, …) into separate shared libraries that your application links against.

nano-ros’s nros generate-rust (Rust) and nano_ros_generate_interfaces() (C / C++ via CMake — one of five entry points; see the spelling table) write message type definitions into your build tree. No _msgs library, no ament overlay, no colcon workspace required.

Why. Embedded cross-builds without a hosted ROS 2 install need to generate message types from package.xml + .msg files alone. The codegen tool ships its own bundled rosidl-flavoured .msg set inside the nros CLI (built in-tree from packages/cli/, Phase 218), so you don’t even need the upstream message packages on disk.

7. QoS profile is the full DDS field set; backends advertise per-policy support

Standard ROS 2 supports the full DDS QoS profile family (reliability, durability, history, depth, deadline, lifespan, liveliness, liveliness_lease_duration, avoid_ros_namespace_conventions) and performs profile matching between endpoints.

nano-ros’s rmw_qos_profile_t carries the same field set; standard profile constants (NROS_RMW_QOS_PROFILE_DEFAULT, _SENSOR_DATA, _SERVICES_DEFAULT, _PARAMETERS, _SYSTEM_DEFAULT) match upstream rmw_qos_profile_* field-for-field. ROS 2 apps porting across pull the equivalent constant unchanged.

Each backend advertises which policies it can enforce via Session::supported_qos_policies(). The runtime validates the requested QoS at entity-create time and returns IncompatibleQos synchronously when the backend can’t honour a requested policy:

#![allow(unused)]
fn main() {
if session.supported_qos_policies().contains(QoSPolicyMask::DEADLINE) {
    // backend honours deadline; safe to set deadline_ms
} else {
    // app handles deadline monitoring itself
}
}

No silent downgrade. The runtime never quietly drops a requested policy. Apps either get the QoS they asked for or a hard error.

Why upstream-shaped struct, not a smaller subset. ROS 2 QoS is the established vocabulary; mismatched APIs make porting painful. The field set is small (24 bytes); apps that don’t request a policy leave its field at zero (“off”). Per-backend implementation is a separate question — which policies actually fire — answered by the support mask.

Why synchronous error instead of runtime event. Upstream’s RMW_EVENT_REQUESTED_INCOMPATIBLE_QOS event surfaces mismatches at run time. Most QoS mismatches are configuration errors visible at startup; the runtime path doesn’t need to handle them. The few that aren’t (cross-process QoS-mismatched discovery) the wire protocol handles itself — DDS endpoints negotiate via DDS Discovery, zenoh endpoints communicate intent through the topic-key encoding.

Manual liveliness assertion. Publishers configured with MANUAL_BY_TOPIC / MANUAL_BY_NODE liveliness call assert_liveliness() explicitly to refresh the lease. Available on every language surface (Rust Publisher<M>::assert_liveliness(), C rcl_publisher_assert_liveliness(&pub), C++ pub.assert_liveliness()).

Which backend actually asserts — this paragraph said the opposite until the phase-467 RMW gap-closure study. It read “the zenoh backend wires it … Backends without manual-assertion wiring (xrce, cyclonedds) treat the call as a no-op”, and both halves were wrong. cyclonedds implements it (dds_assert_liveliness on the writer: renews the lease and sends a Heartbeat). zenoh-pico does not — its liveliness is a session-scoped keepalive, it has no per-topic lease a call can renew, and what assert_liveliness() refreshes there is a LOCAL watchdog that fires this publisher’s own LivelinessLost on expiry. That local behaviour is real and is kept; what changed is the return value. Under a manual kind, zenoh-pico, XRCE-DDS and uORB now answer UNSUPPORTED rather than Ok, because an Ok from a call that put nothing on the wire is the one thing a caller uses this function to rule out. AUTOMATIC and NONE answer Ok everywhere: there was nothing to assert.

See Status events for the runtime-event side of liveliness, deadline, and message-lost.

Per-backend coverage is documented in RMW vs upstream § 7.

8. No runtime backend swap, no runtime introspection

Standard ROS 2 ships ros2 topic list, ros2 node info, dynamic endpoints discovery, rmw_get_* introspection.

nano-ros has none of that at runtime. The backend is fixed at compile time, the wire-protocol introspection is whatever the backend natively exposes (zenoh-pico’s z_query for SPDP, Cyclone DDS’s SPDP/SEDP discovery, …). Use the host-side ROS 2 tools for introspection and connect via the rmw_zenoh interop path.

Why. Every byte of “introspect what’s running” is overhead a microcontroller can’t justify when a host-side ROS 2 environment is one router-hop away.

9. Parameters: node-local server, no descriptors, no callbacks (yet)

Standard ROS 2 (rclcpp::Node) ships a rich parameter surface: declare_parameter<T> with ParameterDescriptor (description, ranges, read-only, dynamic typing), set_parameter returning a SetParametersResult, atomic multi-set, three callback hooks (pre_set / on_set / post_set), parameter overrides from the launch file or CLI, and a service-backed remote-introspection surface (/<node>/get_parameters, /<node>/set_parameters, …).

nano-ros keeps the vocabulary (declare_parameter<T>, get_parameter<T>, set_parameter<T>, has_parameter) but trims the surface for embedded use. There is ONE store, and it is in Rust: the nros_params::ParameterServer the executor owns, which is also what the six rcl_interfaces/srv/* servers read. C++ reaches it through methods on rclcpp::Node; C through rclc’s own parameter-server API, <rclc_parameter/rclc_parameter.h> (rclc_parameter_server_t, one per node); Rust through Executor::declare_parameter and Node::parameter.

If you have older nano-ros code: rclcpp::ParameterServer<Cap> is gone (phase-426 W4), and so is the caller-storage store it wrapped as a thing to reach for. Both were node-LOCAL: the parameter services read a different object, so ros2 param list never saw what you declared. The C caller-storage family and the nros_executor_*_param* functions are gone too (phase-491): C uses rclc’s rclc_parameter_server_t, which declares into the same executor-owned store.

What we keep

  • Same five scalar types: bool, int64_t, double, string, plus the bool / int64_t / double / byte / string array variants on the C side (rclc_parameter_{declare,get,set}_*_array; string arrays not yet).
  • Same lifecycle: declare → get → set, with declare-once-then-typed-get semantics.
  • Optional service-backed exposure (~/get_parameters / ~/set_parameters / ~/list_parameters / …) when the param-services feature is enabled. This pulls in ROS 2 wire compat: declared parameters are visible to ros2 param list /<node> and ros2 param set. The services use the rcl_interfaces message bounds: a list_parameters reply carries at most 64 names and 64 prefixes, and when more match it is cut there and the node logs one warning (the reply message has no field to say so). A set_parameters request is answered per parameter, so a value or name past those bounds gets successful = false with a reason while the rest are applied.

What we drop, and why

Upstream featurenano-ros statusWhy dropped
ParameterDescriptor (description, ranges, read-only, dynamic_typing)not exposeddescriptor metadata is host-side concern; embedded server enforces type at declare-time, range checks belong in set callbacks (deferred — see below)
add_pre_set_parameters_callback / add_on_set_parameters_callback / add_post_set_parameters_callbackone rclc_parameter_callback_t per C server — rclc’s (old, new, context), fired before the write, false refusesthree callbacks → three indirection slots × N subscribers; one callback covers the safety-island validation use case (reject if out of range)
set_parameter returning SetParametersResult (successful: bool, reason: string)returns rcl_ret_tstring reason would force heap or fixed-buffer; ret code captures the binary outcome
set_parameters_atomicallynot exposedatomic multi-set requires transaction log; not justified by current embedded use
declare_parameters (multi-declare with namespace)C++ only, hosted only: rclcpp::Node::declare_parameters<T>(prefix, map)upstream’s argument is a std::map, so it needs an allocator; returns Result, not the std::vector<T> of values in effect (read back with get_parameter<T>). Rust and C declare one at a time
Parameter overrides from CLI / launch / yamlnot exposedembedded apps configure via Kconfig / Config struct; runtime overrides come over the wire via ~/set_parameters (when param-services is on)
Storage allocation policyone executor-owned table, NROS_MAX_PARAMETERS slots, keyed by nodeno per-node heap; capacity is a build knob, sized once like the executor arena rather than multiplied by the node count

Storage shape difference

rclcppnano-ros
Containerstd::map<string, ParameterValue> per node (heap)one fixed table on the executor, keyed by (node, name)
String valuestd::string (heap)fixed slot, copy semantics
Array paramsstd::vector<T> (heap)rclcpp::Seq<T, N> in, elements copied into the store’s own slot — nothing for the caller to keep alive
Total fixed costunboundedNROS_MAX_PARAMETERS slots, known at build time, shared by every node

Class shape difference

rclcpp::Node owns the parameter store; nano-ros’s node borrows one the EXECUTOR owns. The call shape is upstream’s:

// rclcpp
auto node = std::make_shared<rclcpp::Node>("ctrl");
node->declare_parameter<double>("ctrl_period", 0.15);
double v = node->get_parameter("ctrl_period").as_double();

// nano-ros
rclcpp::Node node;
RCLCPP_TRY(rclcpp::Node::create(node, "ctrl"));
double period = node.declare_parameter<double>("ctrl_period", 0.15);
double v = 0.0;
node.get_parameter<double>("ctrl_period", v);

// an array parameter, with no heap: `Seq<T, N>` is the value, the store
// owns the elements
rclcpp::Seq<double, 8> w = node.declare_parameter("mpc_weights",
                                                rclcpp::Seq<double, 8>{1.0, 2.0});

Why the executor owns it. Templating Node on a capacity would propagate through every create_publisher / create_subscription site, so the store cannot be a member; and a store per node is a store per node’s worth of fixed arena on a target that has one. One executor-owned table, keyed by node, is both — /talker’s rate is not /listener’s, and the arena is sized once (NROS_MAX_PARAMETERS). It is also the table the services answer from, which is the property the split shape could not have: the parameter you declare is the parameter ros2 param get returns.

Why no Box<dyn FnMut> callback yet. The same constraint that shapes the QoS event callbacks applies here: nano-ros’s #[no_std] core forbids alloc-style indirection. A future descriptor-+-validation-callback path will use a function pointer + void* user_context pair, registered at declare-time. Tracked under the upstream parity backlog.

Going further. When upstream’s full parameter surface matters — describe_parameter, ranges, three-stage validation callbacks, override files — fall back to running a host-side ROS 2 node that exposes them and uses the embedded node only as the leaf publisher / subscriber.

What this means in practice

If you are coming from rclcpp:

  • Open an Executor, then create the node from it.
  • Decide poll vs. callback per subscription, not globally.
  • If the platform has std, nros::init() looks identical; if it is RTOS / bare-metal, plan the executor arena up front.
  • Pick nros-rmw-zenoh for ROS 2 interop; everything else is a different trade-off.

If you are coming from rclrs:

  • The umbrella crate is nros, not split into rclrs_*. nros::prelude gives you everything.
  • Executor::open(&config) is the equivalent of Context::default_from_env() + Executor::new(...).
  • The async surface is on the executor + handles themselves (re-exported at the crate root). Compatible with tokio out of the box.

If you are coming from rclc:

  • The C entry points carry rclc’s own names (rclc_node_init_default, rclc_publisher_init_default, rclc_subscription_init_default), in rclc’s argument order and at rclc’s arity. Memory ownership rules are the same — the caller owns storage, the API initialises it.
  • See the C API reference for the full surface.

Going deeper