First Node — Rust (Linux)
Build, run, and verify a single nano-ros publisher node on Linux in
about ten minutes. Uses the canonical Zenoh backend. The zenoh router comes from ROS 2
(ros2 run rmw_zenoh_cpp rmw_zenohd) — nano-ros no longer ships one.
Rather not run a router? Use --rmw cyclonedds instead: it needs no
router at all (see Choosing an RMW).
Either way the build generates its messages from your ROS 2 install, so
you need ROS 2 sourced for the first build.
Stuck? See Troubleshooting — First 10 Minutes for the common first-build errors.
Prereqs
The nros CLI on PATH (Installation — install.sh
puts it in ~/.nros/bin), then provision the Linux host (installs the
zenoh client stack into a shared store; the router itself comes from
your ROS 2 install):
nros setup linux --rmw zenoh
Scaffold the talker
nros new talker_ws --board linux --template talker --lang rust --rmw zenoh
cd talker_ws
That wrote a workspace with two packages:
talker_ws/
└── src/
├── talker_node/ # node package: the code
│ ├── package.xml # ROS manifest: <depend>std_msgs</depend>
│ ├── Cargo.toml # a library crate; names no board and no RMW
│ └── src/lib.rs # the Talker: a 1 s timer publishing /chatter
└── talker_node_bringup/ # bringup package: what runs where
├── package.xml
└── system.toml # the board, the RMW, the node it runs
The node package
talker_node is a library. It has no main(), and nothing in it names a
board, an RMW or a ROS edition. src/lib.rs declares what the node creates
(a publisher on /chatter, a 1 s timer) and what the timer callback does,
and its last line exports the node:
#![allow(unused)]
fn main() {
nros::node!(Talker);
}
The program’s main() is generated by the build, from the bringup. That is
what lets the same node package go into a Linux image today and a Cortex-M
image later without a second copy.
Growing your workspace walks
through lib.rs line by line.
Cargo.toml holds Rust facts only: the [lib] target, the class the
package registers, and its dependencies.
[lib]
path = "src/lib.rs"
[package.metadata.nros.node]
class = "talker_node::Talker"
name = "talker_node"
[dependencies]
nros = { version = "*", default-features = false, features = ["rmw-cffi", "macros"] }
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
nano-ros crates are declared registry-style (version = "*"), but
nothing of nano-ros is on crates.io: the build points them at the SDK the
nros on PATH carries. The settings that takes (the [patch.crates-io]
table, the target triple, linker flags) are generated under build/ from
the bringup, never written by hand and never committed. No path in the
package names where nano-ros lives, so the workspace copies anywhere.
Message packages are not listed by hand either: package.xml’s
<depend>std_msgs</depend> is the one statement, and the build writes the
matching # nros-managed row. To use another message package, add its
<depend>; the next nros build adds the row.
The bringup package
talker_node_bringup/system.toml is the whole deployment statement:
[system]
name = "talker_node"
rmw = "zenoh"
ros_edition = "humble"
domain_id = 0
[[component]]
pkg = "talker_node"
class = "talker_node::Talker"
name = "talker_node"
[image.linux]
board = "linux"
The [[component]] row says which node runs; [image.linux] says it runs
on this machine. The --rmw you passed to nros new lives here and nowhere
else, so switching RMW or ROS edition is editing rmw or ros_edition and
building again.
Native (host) images read the locator and domain from environment
variables (NROS_LOCATOR — the retired ZENOH_LOCATOR is refused at startup — and
ROS_DOMAIN_ID). Embedded images bake theirs from the same
[image.<id>] table (locator, and the address in net) — the shape
shows up under the Embedded Starters section.
This is the same project as
examples/linux/rust/talker/
on GitHub, the copy-out example for Rust on Linux: one node package and a
bringup with no launch file of its own, since the build generates it. Its
bringup goes further than the scaffold, with one image per RMW and ROS
edition.
Configure
Three runtime knobs, each an env override on a built-in default:
| Knob | Default | Env override |
|---|---|---|
| Zenoh locator | tcp/127.0.0.1:7447 | NROS_LOCATOR |
| ROS domain ID | 0 | ROS_DOMAIN_ID |
| Zenoh mode | client | NROS_SESSION_MODE |
On native these stay environment variables — [image.linux] declares
no locator. An embedded image bakes them from its own [image.<id>]
table instead; see the
Configuration Guide.
Build
The build generates std_msgs from your ROS 2 install, so source it first.
This first build records the ROS 2 prefix in the workspace; later builds and
runs of talker_ws need no sourced shell.
source /opt/ros/humble/setup.bash
nros build # builds the image `[image.linux]` declares
nros build prepares everything first: the message bindings
package.xml declares, the generated main() for [image.linux], and the
cargo settings derived from board = "linux", all under build/. Then it
runs cargo. There is no step to run before it; when nothing changed, the
preparation costs a comparison.
First build pulls dependencies (~3 minutes). Re-builds finish in
seconds. When it finishes, nros build prints where the binary landed
(under build/, never beside your sources) and how to run it.
Driving cargo yourself (an IDE, a CI step, --release) is
supported: nros build --dry-run prints the exact cargo line, against the
generated entry under build/. Before you open the workspace in an IDE,
run the preparation on its own so rust-analyzer finds the generated
crates:
nros configure
What it writes under
build/ is regenerated on every build; never edit it, and never add a
.cargo/config.toml of your own to carry board facts. Your own
Rust-toolchain preferences go where cargo already looks for them — a
parent directory or $CARGO_HOME.
Run
Two terminals (each command below blocks; keep them open):
# Terminal 1 — zenoh router (ROS 2's own). Blocks the shell until Ctrl-C.
source /opt/ros/humble/setup.bash
ros2 run rmw_zenoh_cpp rmw_zenohd
# Terminal 2 — the talker. The talker logs via `log::info!`, so set
# RUST_LOG=info — without it `env_logger` only shows errors and the
# `Publishing:` lines stay hidden.
cd talker_ws
RUST_LOG=info nros run talker_node
# Expected output (on stderr):
# [INFO talker_node] Publishing: 'Hello World: 1'
# [INFO talker_node] Publishing: 'Hello World: 2'
# …
nros run talker_node runs the bringup whose only node is talker_node,
rebuilding first if anything changed. It keeps running until Ctrl-C.
That’s the nano-ros side fully working. Optional step: verify interop with stock ROS 2.
# Terminal 3 — stock ROS 2 with rmw_zenoh_cpp. It talks to the SAME
# router already running in terminal 1 (default tcp/127.0.0.1:7447).
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
# Talker publishes best-effort; stock `ros2 topic echo` defaults to
# RELIABLE, so the QoS-mismatched echo silently delivers nothing.
# Force best-effort to receive:
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
If ros2 topic echo shows no output despite the talker printing
Publishing:, the routers aren’t peering — confirm both processes
point at the same port (default tcp/127.0.0.1:7447).
Readiness signal. Within ~6 seconds of the program starting
(session open + the first 1 s timer tick), the talker should print
Publishing: 'Hello World: 1' — the count starts at 1, matching the
official ROS 2 demo_nodes_cpp talker. If no Publishing: line in 30
seconds:
- Confirm
RUST_LOGis set. WithoutRUST_LOG=info(ordebug),env_loggerfilters out thePublishing:lines and the run looks silent even when it’s working. - Confirm the router is running (terminal 1). Without it, the talker
blocks on
Executor::openindefinitely. - Re-run with
RUST_LOG=debugand look for “RMW session open failed” — usually a wrong locator or wrong port. - See Troubleshooting — First 10 Minutes.
Retargeting
To run the same node on a board, add an image for it to
src/talker_node_bringup/system.toml, named by that board’s id
(Supported Boards), and provision it:
[image.baremetal]
board = "mps2-an385-baremetal"
locator = "serial/UART_0#baudrate=115200"
nros setup mps2-an385-baremetal --rmw zenoh
nros run talker_node_bringup:baremetal
Nothing in src/talker_node/ changes: the board’s descriptor supplies the
target triple, the board crate and the memory budgets. With two images the
short nros run talker_node no longer picks one, so name the image you
mean; nros run lists the spellings when it refuses. The platform chapters
scaffold with --board <board> from the start, which writes the image for
you.
Next
- Add a subscription:
nros new listener_node --template listener --to talker_node_bringupfrom the workspace root adds a second node package and its component row (Growing your workspace). - Generate bindings for custom
.msg/.srv/.actionfiles: Message Generation - Cross-compile for an RTOS: pick the right Embedded Starter from the next section (FreeRTOS / Zephyr / NuttX / ThreadX / ESP32 / Bare-metal Cortex-M3).
Design background
- RFC-0121: workspace-first project layout
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker kept for each platform and language, and the language workspaces that hold the paired examples.