Your own message package
You write a ROS 2 msg package once. The same src/my_msgs/ directory
builds under both:
- a nano-ros build —
rosidl_generate_interfaces(...)is intercepted by nano-ros’s wrapper and routed through nano-ros codegen. No ROS 2 build tools run: even therclcpp-shaped consumer below compiles against nano-ros’s ownrclcppCMake package.nros buildstill reads message definitions from your ROS 2 install, so source it for the first build (below). colcon build— upstreamrosidl_default_generatorsproduces the upstream-ROS bindings (this half, and only this half, needs a ROS 2 install; it is the optional cross-build proof at the end).
Different build systems, identical source tree. No nano-ros-specific files in the msg package.
Where this fits. This chapter uses ROS 2 vocabulary (
ament_cmake,rosidl,rclcpp) because its point is that stock ROS shapes work unchanged. If you came straight from First Project and none of that means anything yet, you can skim to “The Rust side” below or skip ahead to First Node — Rust and return later. The standalone tree here is for clarity — in your scaffoldedmy_robotworkspace the same drop-in applies: putsrc/my_msgs/next to your node packages.
The msg package — stock ROS shape
Drop a verbatim ROS msg pkg under src/ of your workspace:
src/my_msgs/
├── package.xml
├── CMakeLists.txt
└── msg/
└── MyMsg.msg
src/my_msgs/package.xml:
<?xml version="1.0"?>
<package format="3">
<name>my_msgs</name>
<version>0.1.0</version>
<description>My ROS 2 msg package</description>
<maintainer email="you@example.org">you</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>std_msgs</depend>
<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
src/my_msgs/CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(my_msgs)
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(std_msgs REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
msg/MyMsg.msg
DEPENDENCIES std_msgs
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()
src/my_msgs/msg/MyMsg.msg:
std_msgs/Header header
string payload
int32 sequence
Zero nano-ros-specific lines. Run colcon build from this dir — upstream
ROS produces a working msg package. Drop the same dir into a nano-ros
build (below) — nano-ros codegen produces the equivalent bindings.
The consumer — stock ROS shape
// src/my_app/src/my_app.cpp
#include <chrono>
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include "my_msgs/msg/my_msg.hpp"
using namespace std::chrono_literals;
class MyNode : public rclcpp::Node {
public:
MyNode() : rclcpp::Node("my_node") {
publisher_ = this->create_publisher<my_msgs::msg::MyMsg>("topic", 10);
timer_ = this->create_wall_timer(500ms, [this]() {
my_msgs::msg::MyMsg m;
m.payload = "hello";
publisher_->publish(m);
});
}
private:
rclcpp::TimerBase::SharedPtr timer_;
rclcpp::Publisher<my_msgs::msg::MyMsg>::SharedPtr publisher_;
};
int main(int argc, char* argv[]) {
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<MyNode>());
rclcpp::shutdown();
return 0;
}
src/my_app/CMakeLists.txt:
cmake_minimum_required(VERSION 3.20)
project(my_app LANGUAGES CXX)
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(my_msgs REQUIRED)
find_package(std_msgs REQUIRED)
add_executable(my_app src/my_app.cpp)
target_link_libraries(my_app
rclcpp::rclcpp
my_msgs::my_msgs
std_msgs::std_msgs
)
ament_package()
Build it with nros build
There is no nano-ros-specific file to add: a workspace has no root build file. With no bringup, nros build builds it the way colcon build
does — every package it finds by its package.xml, in <depend> order, each
in its own build/<pkg>/. For my_app it generates the CMake root that used
to be hand-written (nano-ros, NROS_INTERFACE_SEARCH_PATH at ./src/, the
rclcpp / ament surface); my_msgs is not built on its own — its bindings are
generated into the package that uses it.
The first nros build of the workspace needs ROS 2 sourced: it generates
the bindings against your ROS 2 install’s message definitions, and refuses
without one. It records the prefix, so later builds need no sourced shell.
source /opt/ros/humble/setup.bash
nros build
./build/my_app/cmake/pkg/my_app/my_app
In a workspace that also has a bringup (what nros new <ws> --board <board>
writes), the same nros build builds my_msgs into whichever image’s packages depend on it.
Escape hatch — a hand-written umbrella. For a CMake tree that is not a
nano-ros workspace, one top-level CMakeLists.txt pulls nano-ros in as a
subdirectory, naming the SDK root on the configure line
(Build as a CMake subdirectory has the
mechanism):
cmake_minimum_required(VERSION 3.22)
project(my_workspace LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)
# Pull nano-ros — the installed SDK root, passed as -DNANO_ROS_SDK=….
set(NROS_PLATFORM posix)
set(NROS_RMW "zenoh" CACHE STRING "Active RMW.")
set(NROS_RMW "${NROS_RMW}")
add_subdirectory("${NANO_ROS_SDK}" nano_ros)
# Point the message-package resolver at this workspace (must precede the
# ament-surface include so the workspace Find<pkg>.cmake auto-emit picks it up).
set(NROS_INTERFACE_SEARCH_PATH "${CMAKE_SOURCE_DIR}/src")
# nano-ros's ament / rclcpp surface (find_package(rclcpp), ament_* verbs).
include("${NANO_ROS_SDK}/cmake/NanoRosAmentSurface.cmake")
# Bulk-build every workspace msg pkg in topo order. One line, no
# add_subdirectory(src/<pkg>) per pkg.
nros_workspace_interfaces()
# Build the consumer app.
add_subdirectory(src/my_app)
cmake -B build -S . -DNANO_ROS_SDK="$(nros locate nano-ros)"
cmake --build build -j
./build/src/my_app/my_app
The Rust side
The same msg package feeds a Rust node with no CMake involved: declare
it in your node package’s package.xml (<depend>my_msgs</depend>) and
run nros build from the workspace root — that is the
whole change. The package index finds src/my_msgs/ by its
package.xml, generates the generated/my_msgs crate, and writes the
manifest row itself (my_msgs = { path = "../../generated/my_msgs", default-features = false } # nros-managed). The shipped precedent is
examples/workspaces/features/src/custom_msgs with its
rust_reading_listener_pkg consumer. Full
detail: Message Generation.
use my_msgs::msg::MyMsg; // generated crate, same field names
Cross-build proof — same source under colcon (optional; needs ROS 2)
cd src && colcon build
src/my_msgs/ produces the upstream my_msgs bindings; src/my_app/
links against the upstream rclcpp + my_msgs::my_msgs. The
nano-ros build above produces the same source linked against
NanoRos::NanoRosCpp through the nano-ros codegen, with the smart
Find-stub forwarding find_package(my_msgs) → my_msgs::my_msgs.
The interface-package search path
find_package(<pkg>) walks three layers, highest priority first:
| Layer | Source | Notes |
|---|---|---|
| 1 | NROS_INTERFACE_SEARCH_PATH | Colon/semicolon-separated colcon-src/-style roots; immediate subdirs with package.xml are candidates. |
| 2 | AMENT_PREFIX_PATH | The standard ROS install-prefix layout (<prefix>/share/<pkg>/{msg,srv,action}/). |
| 3 | Bundled | <nano-ros>/packages/cli/interfaces/<pkg>/ (in-tree bundled definitions). |
Shadowing — a workspace my_msgs and an AMENT my_msgs resolve to the
workspace one, with a message(STATUS ...) line noting the shadow.
Shadowing contract
When two layers carry the same package name (e.g. a workspace
std_msgs and /opt/ros/<distro>/share/std_msgs/), the higher
layer wins — silently and deterministically. Concretely:
-
NROS_INTERFACE_SEARCH_PATH>AMENT_PREFIX_PATH> bundled. The message-package resolver (cmake/find/_NrosFindRosMsgPackage.cmake) walks the three layers in order; the first hit wins. Lower layers are skipped entirely. -
The configure pass emits a
message(STATUS ...)line noting the resolved path, e.g.-- nros: find_package(std_msgs) -> /path/to/workspace/src/std_msgsGrep your configure log for
nros: find_package(<pkg>)to confirm which layer supplied each pkg. -
Intra-workspace shadowing — when two roots under
NROS_INTERFACE_SEARCH_PATHboth ship the same pkg, thenros_workspace_interfaces()bulk orchestrator keeps the earlier-listed one and warns about the shadowed copy. -
No partial overrides. Shadowing is whole-package: a workspace pkg replaces ALL of an AMENT pkg’s interfaces, not a subset. If you only want to add
MyExtraMsg.msgtostd_msgs, ship a separate pkg (e.g.my_std_extra_msgs) — don’t shadow.
Compile-time fail-safe
Shadowing is observed at compile time: if your workspace std_msgs
declares a Marker.msg and your consumer includes
"std_msgs/msg/marker.hpp", the build only succeeds when the
workspace copy is the one linked. AMENT’s std_msgs ships no
Marker.msg, so a fall-through resolves to a missing header. The
build outcome is the strongest evidence — nm on the linked binary
is the symbol-level corroborator.
Reference fixture for shadowing
The canonical smoke proof for the Layer 1 > Layer 2 case ships at
examples/templates/workspace-shadowing/ —
a workspace std_msgs carrying a Marker.msg shadows the
AMENT-installed std_msgs. The fixture’s README.md walks through
the cmake + nm verification. A regression test
(packages/testing/nros-tests/tests/workspace_shadowing.rs)
re-runs the same proof in CI when AMENT is sourced.
Reference fixture
examples/templates/local-msg-package/
ships the exact pattern above end-to-end (two workspace msg pkgs with a
dep between them, plus a consumer node). Use it as a copy-out template.
Next
- Something broke on the way here? Troubleshooting — First 10 Minutes.
- Single-node deep dive: First Node — Rust.
- Grow the workspace: Add a node package.