Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

KnobDefaultEnv override
Zenoh locatortcp/127.0.0.1:7447NROS_LOCATOR
ROS domain ID0ROS_DOMAIN_ID
Zenoh modeclientNROS_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:

  1. Confirm RUST_LOG is set. Without RUST_LOG=info (or debug), env_logger filters out the Publishing: lines and the run looks silent even when it’s working.
  2. Confirm the router is running (terminal 1). Without it, the talker blocks on Executor::open indefinitely.
  3. Re-run with RUST_LOG=debug and look for “RMW session open failed” — usually a wrong locator or wrong port.
  4. 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 / .action files: 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).