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::Nodedoes 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_stringand<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=yandCONFIG_GLIBCXX_LIBCPP=y.
- On FreeRTOS the toolchain compiles C++
- 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 surface | nano-ros mapping | Notes |
|---|---|---|
class MyNode : public rclcpp::Node | rclcpp::Node — ours, and the same name. | Ctor takes (name), (name, options) or (name, ns, options). |
std::make_shared<MyNode>() | works | auto 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 wrapper | qos can be rclcpp::QoS(10) or an int. |
create_subscription<M>(topic, qos, callback) | registered on the executor arena; dispatched by any spin verb | Capturing lambdas + std::function all work. |
create_wall_timer(period, callback) | registered on the executor arena; dispatched by any spin verb | std::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 /clock | Humble’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 / FATAL | dispatched through the NROS_LOG_* family into nros_log | Carries 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 required | Upstream 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) ctor | Chainable setters inherited. |
diagnostic_updater::Updater + DiagnosticStatusWrapper | packages/api/nros-diagnostic-updater | Publishes /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::LifecycleNode | ours, 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 registration | Single-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 ashared_ptr. - Server: created in place. There is no
rclcpp_action::create_serverreturning aSharedPtr; declare theServer<A>and initialise it withnode.create_action_server(srv, name). It is not aWaitable(noexecute/is_ready/set_on_ready_callback): the executor drives it directly. - Callbacks are stateless function pointers (empty-capture lambdas), or the
_with_ctxsetters with avoid*context. - Client:
send_goalblocks and fills an out-param goal id, returningResult, where upstream’sasync_send_goalreturns ashared_futureof a goal handle.send_goal_futureis the future-shaped spelling. - Client: callbacks bind to the CLIENT. Upstream passes
SendGoalOptionsto eachasync_send_goal; hereclient.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_servertakes milliseconds and returnsResult— 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 raisebad_weak_ptr. nano-ros cannot derive fromstd::enable_shared_from_this<Node>: itsstd::weak_ptrmember 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. Theshared_ptrthere is a temporary, and a handle to it would dangle at the end of the line; the compiler names the deleted conversion. Writeauto node = std::make_shared<MyNode>();instead. Ashared_ptryou keep converts toNode::SharedPtrfreely, andrclcpp::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::SharedPtris a small handle, not astd::shared_ptr. Its callback lives in the executor, so the handle does not own the timer. Dropping or overwritingtimer_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 asstd::shared_ptr<rclcpp::TimerBase>does not compile: spell itrclcpp::TimerBase::SharedPtr.- The same holds for
Subscription<M>::SharedPtr,Service<S>::SharedPtrandClient<S>::SharedPtr: dropping the handle leaves the entity running, andsub_.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<...>andrclcpp::GenericTimer<...>do not exist. The clock axis is the second verbrclcpp::create_timer(node, clock, period, cb)plus a runtime field, not a type parameter.- DERIVING from
rclcpp::TimerBasegets 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, andTimerBaseis a name that promisesWallTimerandGenericTimersiblings we deliberately do not have. The clock axis is a runtime field plus the second verbrclcpp::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 emitsstd::string. Assigning astd::stringneeds a one-token adapter:message.data = s.c_str(). The reverse(std::string{}.c_str())is whatRCLCPP_INFOalready 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 (autowareuniverse_utils, PX4 uORB shims) are not nano-ros’s to ship; the porting user vendors them or replaces the call sites with rawnros-cppones.
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:
local-msg-package— mixed workspace (workspace + AMENT msg sources) C++ + Rust consumers.cpp-port-minimal-publisher— ROS 2 tutorialminimal_publisher.cppverbatim.rclcpp-compat-smoke— minimal source-compat regression test.topic-state-monitor-port— multi-sub / wall-timer / diagnostic_updater exercise.
Drop your reduced-case node under examples/templates/<your-port>/ and
add it to CI once the gap closes.