Message Binding Generation
nros uses generated Rust bindings for ROS 2 message types. nros build generates no_std compatible bindings from each package’s package.xml dependencies before it builds (nros configure does that step alone, and nros interface generate rust the codegen alone).
Overview
The binding generator is part of the nros CLI and provides:
- Pure Rust,
no_stdcompatible output usingheaplesstypes - Automatic dependency resolution through your ROS 2 install’s ament index
- the
[patch.crates-io]rows that point the nano-ros runtime crates at the SDK root, written into the image’s generated build settings
Prerequisites
-
package.xml in each node package - Declares ROS interface dependencies
<?xml version="1.0"?> <package format="3"> <name>my_package</name> <version>0.1.0</version> <description>My nros package</description> <maintainer email="dev@example.com">Developer</maintainer> <license>Apache-2.0</license> <depend>std_msgs</depend> <depend>geometry_msgs</depend> <export> <build_type>ament_cargo</build_type> </export> </package> -
nros tool built + on PATH
Install a release;
install.shputsnrosin~/.nros/bin:curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh export PATH="$HOME/.nros/bin:$PATH"See Installation for the full walkthrough.
-
A ROS 2 install, sourced for the first build
The bindings are generated from the message definitions of your ROS 2 install,
std_msgsincluded, so the generated layouts and type hashes match the ROS 2 nodes you talk to. Source it before the firstnros build,nros runornros configureof a project:source /opt/ros/humble/setup.bashThat configure records the prefix (
AMENT_PREFIX_PATH) in the project, and later configures reuse it, so later shells need not source ROS. With neither, configure refuses and names this step. A project whose packages depend on no message package at all needs no ROS 2.
Workflow
Step 1: Create package.xml
Declare your ROS interface dependencies in <depend> tags:
<depend>std_msgs</depend> <!-- For std_msgs::msg::Int32, String, etc. -->
<depend>example_interfaces</depend> <!-- For service types -->
Step 2: Build
cd my_ws
source /opt/ros/humble/setup.bash # the first build of the workspace only
nros build
Before it builds, this will:
- Parse
package.xmlto find dependencies - Resolve transitive dependencies through the ament index
- Filter to interface packages (those with msg/srv/action)
- Generate bindings into the workspace’s
generated/directory - Resolve the bringup’s launch file into the system model
- Write each image’s build settings under
build/, carrying the board’s triple and flags, the resolved pool budgets, and the[patch.crates-io]rows for the nano-ros runtime crates
Each step is redone only when its inputs changed. To run them without building
(before opening the workspace in an IDE, for instance), use nros configure.
nros interface generate rust is the
codegen-only step underneath — steps 1–4 and nothing else — and is useful when
you want bindings without a workspace.
Step 3: there is no step 3 — package.xml is the dependency list
A generated message crate is a path dependency on its own directory, and
nros build writes that row for you, before it plans: one
per <depend> / <build_depend> / <exec_depend> that names a message
package, tagged # nros-managed:
[dependencies]
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
example_interfaces = { path = "../../generated/example_interfaces", default-features = false } # nros-managed
So adding a message package is one edit — the <depend> — exactly as on the
C/C++ road, and deleting the <depend> deletes the row on the next build. A
row without the tag is yours: the build never edits or removes it, so deleting the
tag is how you take a row over. The nros row is not
managed — it carries a feature choice that is yours to make.
Never name a message crate by registry version (std_msgs = "*"): those
crates are generated per host from your ament install, they are not
published, and a bare name resolves against the public crates.io instead.
The nano-ros runtime crates are the other case — nros,
nros-board-<board>, the backends — and they are registry-named with
version = "*", because the build writes the [patch.crates-io] rows
that redirect them into the nano-ros SDK root.
Why msg crates are RMW-agnostic
Generated msg crates carry only the wire-format data type — no backend-specific code, no per-RMW Cargo feature. A user manifest is the plain pair:
[dependencies]
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
nros = { version = "*" }
Transport choice and message schema are orthogonal concerns and the
manifest reflects that. (Which backend you actually get is
[system] rmw in the bringup’s system.toml, not a feature you pick here — see
Switching RMW in Config.) This matches upstream rclcpp + rclrs, which
both ship msg packages RMW-agnostic and let the RMW pick which
descriptor representation it wants at runtime.
For DDS-based backends that need a per-type descriptor on the wire
(Cyclone DDS today), the nros-rmw-cyclonedds shim builds those
descriptors lazily on first pub/sub for a given message type,
walks the static field schema exposed by nros-serdes (the
Message trait with const TYPE_NAME + const FIELDS), and caches
the result in a bounded no_std registry. No per-msg-pkg backend
code is required.
The registry is bounded by the NROS_CYCLONEDDS_MAX_TYPES sizing knob.
Where the nano-ros sources come from
A project uses the SDK root the nros binary belongs to — a release’s
own share/nano-ros — so a plain nros build
works anywhere. To name a different SDK root, pass it:
nros build --nano-ros-path <sdk-root>
The build then writes the [patch.crates-io] rows into each image’s
generated settings, pointing each nros* requirement at a directory
in the SDK root. Nothing about the location is baked into a tracked file,
so moving the workspace, or installing another release, needs no edit: the
next build re-points it.
Use in code
#![allow(unused)]
fn main() {
use std_msgs::msg::Int32;
use example_interfaces::srv::{AddTwoInts, AddTwoIntsRequest, AddTwoIntsResponse};
let msg = Int32 { data: 42 };
}
Command Options
nros interface generate rust is the codegen step alone — it writes
bindings and nothing else:
nros interface generate rust [OPTIONS]
Options:
--manifest <PATH> Path to package.xml [default: package.xml]
-o, --output <DIR> Output directory [default: generated]
--ros-edition <EDITION> humble | iron | jazzy [default: humble]
--codegen-config <PATH> Explicit per-field capacity config (nros-codegen.toml)
--rename <OLD=NEW> Rename a generated package
--force Overwrite existing bindings
-v, --verbose Enable verbose output
It also still accepts --generate-config / --nano-ros-path /
--nano-ros-git, which write a .cargo/config.toml beside a package by hand.
Those are the old shape and are not the path to use: nros build writes the
patches into the generated settings instead, where they are regenerated
rather than committed.
Generated Output Structure
my_ws/
├── src/
│ ├── my_node/
│ │ ├── package.xml # Your dependency declarations
│ │ ├── Cargo.toml # Your package manifest (a library)
│ │ └── src/lib.rs # Your code using generated types
│ └── my_node_bringup/
│ ├── package.xml
│ └── system.toml # Board, RMW, components
├── generated/ # Generated bindings (do not edit)
│ ├── std_msgs/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # #![no_std]
│ │ └── msg/
│ │ ├── mod.rs
│ │ └── int32.rs
│ └── builtin_interfaces/ # Transitive dependency
│ └── ...
└── build/ # Build output (do not commit)
└── …/<image>_entry/ # each image's generated entry and its cargo settings, incl. the patches
Generated Code Features
no_std by default:
#![allow(unused)]
#![no_std]
fn main() {
pub mod msg;
}
std feature for optional std support:
[features]
default = []
std = ["nros-core/std", "nros-serdes/std"]
heapless types for embedded:
#![allow(unused)]
fn main() {
pub struct String {
pub data: heapless::String<256>,
}
pub struct Arrays {
pub data: heapless::Vec<i32, 64>,
}
}
Service types with Request/Response:
#![allow(unused)]
fn main() {
pub struct AddTwoInts;
pub struct AddTwoIntsRequest { pub a: i64, pub b: i64 }
pub struct AddTwoIntsResponse { pub sum: i64 }
impl RosService for AddTwoInts {
type Request = AddTwoIntsRequest;
type Reply = AddTwoIntsResponse;
}
}
A timestamp message gets SecNanosecMsg, so Time::to_ros_msg() can return
it:
#![allow(unused)]
fn main() {
impl nros_core::SecNanosecMsg for Time {
fn from_sec_nanosec(sec: i32, nanosec: u32) -> Self { Self { sec, nanosec } }
}
}
Emitted for every message whose field set is exactly { int32 sec, uint32 nanosec } — builtin_interfaces/msg/Time, its Duration twin, and any message
of yours with that shape. It is what makes the call a publisher writes work:
#![allow(unused)]
fn main() {
msg.header.stamp = node.now().to_ros_msg();
}
nros-core cannot name a message type — every generated crate depends on it, and
your message crates are generated from your packages — so the conversion is
implemented on the generated side and the trait is the seam. The C++ side spells
the same thing as a template (node.now().to_msg(msg.header.stamp)); Rust has no
structural bound, so it needs the named trait. There is no Result: nros’s
Time already is { sec, nanosec }, so nothing can fail. (rclrs returns
Result<_, TryFromIntError> because its Time is i64 nanoseconds; a ported
t.to_ros_msg()? will not compile here, and deleting the ? is the fix.)
Examples
Each example on GitHub is a workspace that carries its own bringup and resolves on its own, so it builds wherever it lives. Build each from its own root:
cd my_talker_ws && nros build
cd my_service_client_ws && nros build
Regenerating Bindings
To regenerate after ROS package updates or dependency changes:
nros interface generate rust --force
Where the definitions come from
nros build reads message definitions from the ROS 2 install you sourced
(or the one the project’s first configure recorded), through
AMENT_PREFIX_PATH. The release carries copies of a few standard packages,
but they only fill gaps under a ROS 2 install, never stand in for one: with
no prefix set and none recorded, configure refuses rather than build from
them, because they would silently fix a ROS 2 distro and the types would
stop matching a peer on another one. A message package in your own
workspace shadows the installed one of the same name.
Troubleshooting
“AMENT_PREFIX_PATH is unset and no configure of … has recorded one to reuse”
- This shell has not sourced ROS 2, and this project has never been
configured from one that had. Run
source /opt/ros/humble/setup.bash(or your overlay’sinstall/setup.bash), then build again.
“Package ‘X’ not found in ament index or bundled interfaces”
- Source the ROS 2 environment:
source /opt/ros/humble/setup.bash - Check package is installed:
ros2 pkg list | grep X - Install if missing:
sudo apt install ros-humble-X
Build errors with generated code
- Regenerate with
--forceflag - Check nros crate compatibility
Which CMake spelling (one table)
Five codegen entry points exist in the CMake surface. They are NOT interchangeable spellings of one thing — each serves one audience, and two are back-compat only. Pick by what you are doing:
| You are… | Use | Notes |
|---|---|---|
| Consuming an existing msg package from an app/node pkg | find_package(<pkg> REQUIRED) | The recommended default. The smart Find-stub resolves the package through the 3-layer search (workspace → ament → bundled) and generates bindings on the fly. Zero codegen calls in your file. |
| Defining msgs in a message package | rosidl_generate_interfaces(${PROJECT_NAME} msg/… DEPENDENCIES …) | Recommended — the stock ROS shape, intercepted by nano-ros. Your msg pkg carries zero nano-ros-specific lines and cross-builds under colcon unchanged. |
| Defining msgs and you want the nano-ros verb explicitly (or C bindings) | nano_ros_generate_interfaces(<name> <files…> [LANGUAGE C|CPP] [DEPENDENCIES …]) | The ament-shape alias. Defaults to LANGUAGE CPP like rosidl — pass LANGUAGE C for C. |
| Building a workspace root with several msg pkgs | nros_workspace_interfaces() after set(NROS_INTERFACE_SEARCH_PATH …/src) | Bulk-builds every workspace msg pkg in topological order; one line, no per-pkg add_subdirectory. |
Generating this pkg’s package.xml dependencies (no find_package flow) | nros_find_interfaces([LANGUAGE C|CPP] [SKIP_INSTALL]) | Consume-by-manifest: resolves the transitive interface closure of your own package.xml. Used by the workspace node pkgs. Defaults LANGUAGE CPP. |
| — | nros_generate_interfaces(<target> <files…>) | Deprecated for new code. The low-level generator everything above routes through; direct calls are back-compat only. |
C Code Generation (CMake)
nano_ros_generate_interfaces(… LANGUAGE C) generates C bindings for .msg, .srv,
and .action files (the function defaults to C++ — the LANGUAGE C
argument is what makes it the C generator). It uses a bundled codegen library — no external nros binary needed.
Prerequisites
nano_ros_generate_interfaces() becomes available automatically once
the consumer’s CMakeLists.txt invokes add_subdirectory(nano-ros).
The codegen tool ships inside the in-tree nros CLI binary
(packages/cli/target/release/nros; the old nros-codegen
submodule is retired); cmake auto-resolves it from
PATH / packages/cli/target/release/ / the transitional
${NROS_HOME:-~/.nros}/bin/. No separate build step.
Usage
For a complete example, see the features workspace,
examples/workspaces/features:
its custom_msgs package defines a message, and c_reading_talker_pkg and
c_reading_listener_pkg use the generated C bindings. The custom-message
feature demo is a node package there, with its own image row.
Design background
- RFC-0067: environment-invariant message dependency identity
- RFC-0048: CMake ament consumption
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. Feature demos as node packages of the features workspace.