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.
No ROS 2 on this machine? Use --rmw cyclonedds instead: it needs no
router at all (see Choosing an RMW).
Stuck? See Troubleshooting — First 10 Minutes for the common first-build errors.
Prereqs
Pick one path from a fresh checkout — just is NOT a prereq.
A. Front door (bare machine OK — no Rust, no just):
./scripts/bootstrap.sh
Installs rustup if needed and builds the in-tree nros CLI from
source at packages/cli/target/release/nros, leaving it on PATH for
this shell (in a checkout, the tree’s own build is the binary this tree
accepts — RFC-0090 / phase-431).
B. Already have cargo (equivalent — same build, same binary):
git submodule update --init packages/cli/third-party/play_launch
cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros
export PATH="$PWD/packages/cli/target/release:$PATH"
Every subsequent shell sources the workspace env via one of:
direnv allow # if you use direnv
source ./activate.sh # bash / zsh
source ./activate.fish # fish
Then provision the native host (installs the zenoh client stack into a shared store; the router itself comes from your ROS 2 install):
nros setup linux --rmw zenoh
See Install + first build (Linux) for more.
Project layout
The talker is a standalone Cargo package that pulls nano-ros in via a path dependency. Four files matter:
examples/linux/rust/talker/
├── system.toml # WHAT this deploys to — the one file you edit
├── Cargo.toml # registry-style deps; no board facts
├── package.xml # ROS-style manifest (drives codegen tooling)
├── generated/ # auto-generated message bindings (gitignored)
├── build/ # everything `nros sync` generates (gitignored)
└── src/
├── main.rs # one line: nros::main!(spin = "forever")
└── lib.rs # the ~65-line talker node body
system.toml is the whole deployment statement — the same schema a
multi-node workspace’s bringup package uses (RFC-0098):
[system]
name = "native_talker"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "native_talker"
class = "native_rs_talker::Talker"
name = "talker"
[image.linux]
board = "linux"
You never hand-write a build setting. No target triple, no linker
flag, no [patch.crates-io] table, no .cargo/config.toml: nros sync
derives every one of those from board = "linux" and writes them to
build/linux/nros-cargo.toml, which nros build hands cargo with
--config.
Retargeting is therefore one line. This native talker names no board
crate: it depends on a generated <pkg>_nros_selection package that
nros sync writes for the image’s board (with that image’s RMW and ROS
edition), so the board = line is the whole edit
(issue 1305).
Everything else the board implies — the triple, the link group, the
QEMU runner, the cross compiler — follows the system.toml edit on its
own. A multi-node workspace has no such caveat: its entry is generated,
so there the board really is one line.
Native (host) images read the locator / 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.
The Cargo.toml is the contract that wires nano-ros into your
package, and it holds Rust facts only. Every example is its own
standalone Cargo root (an empty
[workspace] table stops cargo walking up the filesystem), and
nano-ros crates are declared registry-style (version = "*");
the [patch.crates-io] block that resolves them into a nano-ros
checkout is GENERATED by nros sync, into
build/<image>/nros-cargo.toml beside the package — nothing about the
build is committed, and nothing about it is hand-edited (RFC-0098 D1).
Verbatim from
examples/linux/rust/talker/Cargo.toml
(trimmed to the docs-relevant fields — the in-tree file also keeps a
zenoh-only link-tls feature):
[package]
name = "native-rs-talker"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false
[[bin]]
name = "talker"
path = "src/main.rs"
[dependencies]
nros = { version = "*", default-features = false,
features = ["std", "alloc", "env", "rmw-cffi", "macros"] }
# The image's board crate, RMW backend and ROS edition — GENERATED by
# `nros sync` / `nros build` from `system.toml`.
native_rs_talker_nros_selection = { path = "build/nros/selection" }
# The ONE non-registry dep: your own generated message crate, by path.
std_msgs = { path = "generated/std_msgs", default-features = false }
log = "0.4"
env_logger = "0.11"
[workspace]
Nothing in it names a board, an RMW or a ROS edition. Those are the
image’s (system.toml), and nros sync writes them into the generated
<pkg>_nros_selection package the manifest depends on (RFC-0098 D6,
RFC-0031). The talker declares one image per RMW and edition —
linux (zenoh, Humble, the default), linux_xrce,
linux_cyclonedds, linux_jazzy, … — so picking another backend is
a command, not an edit:
nros sync --image linux_xrce && nros build linux_xrce
Copying this out of the workspace? That is the intended workflow —
nano-ros crates are not published to crates.io, so the version = "*"
deps resolve through a generated [patch.crates-io] block, and
nros sync rewrites it (plus the generated message crates, and the
board settings the copied system.toml still names) for wherever the
directory lives now:
cp -r examples/linux/rust/talker ~/my-talker && cd ~/my-talker
nros sync
nros build
RUST_LOG=info ./build/linux/target/debug/talker
Nothing in the copied directory names a path into the checkout, so the
copy is a checkout-independent starter: nros finds the SDK it belongs
to (the checkout an in-tree nros was built in, or a release’s own
share/nano-ros), and --nano-ros-path <dir> names another on the
command line, never in a file.
Prefer vendoring instead? examples/templates/multi-package-workspace/
documents the path-dep workspace layout.
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
cd examples/linux/rust/talker
nros sync # writes generated/ message bindings and
# build/linux/nros-cargo.toml from system.toml
nros build # builds the image `[image.linux]` declares
First build pulls dependencies (~3 minutes). Re-builds finish in
seconds. The binary lands at build/linux/target/debug/talker —
under build/, never beside your sources.
Driving cargo yourself (an IDE, a CI step, --release) is
supported: run nros sync first, then point cargo at the generated
settings file. Run it from the directory above the package, so the
package’s own .cargo/ is not read a second time — phase-445 W6
deletes that directory, after which the working directory stops
mattering:
cd examples/linux/rust
cargo build --manifest-path talker/Cargo.toml \
--config talker/build/linux/nros-cargo.toml
That file is regenerated on every nros sync; 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
Three 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 # or: just 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 examples/linux/rust/talker
RUST_LOG=info ./build/linux/target/debug/talker
# Expected output (on stderr):
# [INFO talker] Publishing: 'Hello World: 1'
# [INFO talker] Publishing: 'Hello World: 2'
# …
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 starting the binary
(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.
GitHub source
Canonical, copy-out:
examples/linux/rust/talker/
Copy the directory, run nros sync inside it, rename the package, and your starter is ready to modify.
To aim it at a board, change [image.linux] board in system.toml to
that board’s name (nros board list prints them), swap the board crate
in [dependencies] to match (issue 1305 — or start from a bare-metal,
FreeRTOS, NuttX or ThreadX-Linux example, whose board crate is generated), and re-run nros sync.
Next
- Add a subscription:
examples/linux/rust/listener/ - 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).