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

Introduction

nano-ros is a lightweight ROS 2 client library for embedded real-time systems. It runs on bare-metal microcontrollers, FreeRTOS, NuttX, ThreadX, and Zephyr, as well as Linux hosts. The entire core stack is no_std compatible.

flowchart TB
    App["<b>Application</b><br/>Rust / C / C++ node code"]
    Core["<b>nano-ros core</b><br/>Executor · Node · pub/sub · services · actions · CDR"]
    RMW["<b>RMW backend</b><br/>Zenoh · XRCE-DDS · Cyclone DDS · custom"]
    Plat["<b>Platform</b><br/>clock · heap · threads · sleep · sockets · libc"]
    Wire(["ROS 2 / DDS / Zenoh wire"])
    App --> Core --> RMW --> Plat
    RMW -. wire-compatible .-> Wire

Four layers, swappable independently: the same node code runs over any RMW backend on any platform. Here is a complete Linux publisher — register a backend, open the executor, publish std_msgs/String (Hello World: N) on /chatter once a second:

use core::fmt::Write as _;
use nros::prelude::*;
use std_msgs::msg::String as StringMsg;

fn main() {
    // Pick the backend at compile time; this one line registers it.
    nros_rmw_zenoh::register().unwrap();

    let config = ExecutorConfig::new("tcp/127.0.0.1:7447").node_name("talker");
    let mut executor: Executor = Executor::open(&config).unwrap();

    let mut node = executor.create_node("talker").unwrap();
    let publisher = node.create_publisher::<StringMsg>("/chatter").unwrap();

    let mut count = 0i32;
    executor
        .register_timer(nros::TimerDuration::from_millis(1000), move || {
            count += 1;
            let mut msg = StringMsg::default();
            let _ = write!(msg.data, "Hello World: {count}");
            publisher.publish(&msg).unwrap();
        })
        .unwrap();

    executor.spin(SpinOptions::default()).unwrap();
    // This is the imperative Layer-2 API; the starters use the
    // declarative `nros::node!` / `nros::main!` form — both are
    // first-class (see Concepts → Two-Layer API).
}

The same program in C and C++ is in the First Node guides: Rust · C · C++. When a project grows beyond one node, continue with Multi-Node Project Layout.

Key Features

  • Minimal stack — three software layers (application, nano-ros, transport). Lean dependency tree, fast compile times.
  • Pluggable middleware — choose Zenoh (agent-less, direct peer communication), XRCE-DDS (agent-based), or Cyclone DDS (RTPS wire-compatible with stock ROS 2) at compile time. Same application code regardless of backend.
  • The same source runs on every supported target — a node body written for one platform compiles unchanged on the others; what differs is one line of configuration, the board = in the image’s system.toml, not the code you write. Everything that choice implies — the triple, the linker flags, the backend, the pool sizes — is generated from it, so the build files stay free of target facts too. This is asserted, not asserted-about: the example_portability test normalizes every platform’s copy of each example and fails if any two differ, so the claim is checked on every run rather than maintained by hope. Two execution models are declared exceptions with written reasons — bare-metal deferred dispatch, and Zephyr’s component shape — and the test names them explicitly rather than hiding them.
  • Rust-first with C API — the core is written in Rust for memory safety and ergonomics, with a thin C FFI (Foreign Function Interface) layer following rclc conventions.
  • True no_std — runs on bare-metal Cortex-M3; the alloc and std features are opt-in. Whether the IMAGE needs an allocator is the backend’s call, not the core’s: XRCE allocates only at entity setup, zenoh-pico wants an allocator (a bump allocator suffices on bare-metal), and Cyclone DDS allocates per message. scripts/rmw-alloc-sites.py reports every site.
  • Standalone tooling — nros generate-rust produces message bindings without a ROS 2 installation (bundled interface definitions).
  • Formally verified — 148 Kani bounded model checking harnesses and 83 Verus deductive proofs cover CDR serialization, scheduling, and protocol correctness.
  • ROS 2 compatible — interoperates with standard ROS 2 nodes via rmw_zenoh_cpp, or directly over RTPS with rmw_cyclonedds_cpp (same wire protocol, no key rewriting). Topics, services, and actions work across the boundary.

Quick board check — does it work on the board I have today?

Vendor / form factorChipRTOS / no-RTOSLanguagesExample in repoROS 2 interop
ARM MPS2-AN385 (QEMU)Cortex-M3FreeRTOS / bareRust C C++ ¹examples/mps2-an385-{freertos,baremetal}/Verified
ST STM32F4-DiscoveryCortex-M4FbareRust ²out-of-tree — worked exampleUntested ⁴
Espressif ESP32-C3RISC-V (RV32)ESP-IDF—none — the shell was deletedRetired ⁵
Espressif ESP32-C3 (QEMU)RISC-VbareRustexamples/esp32-c3-baremetal/Verified
QEMU virt RISC-V64RV64GCThreadXRust C C++examples/rv-virt-threadx/Verified
Linux hostx86-64 / aarch64ThreadX simRust C C++examples/threadx-linux/Verified
QEMU virt / Cortex-A9Cortex-A7 / A9NuttX / ZephyrRust C C++examples/qemu-armv7a-nuttx/, examples/zephyr/Verified
Pixhawk 4 / 6XSTM32F7 / H7NuttX (PX4)C++integrations/px4/module-template/Ready ³
Generic Cortex-M0+/M4/M7≥ 64 KB SRAMRTOS of choiceRust C C++Use your board’s vendor BSP + integrations shellsPattern shown

Legend: Verified = booted + tested in CI. Ready = builds but no in-CI gate yet — where the row names an examples/<plat>/ directory there is an app to compile and try; where it names an integrations/ shell there is build glue but no worked reference app yet.

Footnotes — ¹ MPS2-AN385 bare-metal is Rust-only (nros-c / nros-cpp need an RTOS for libc / heap). ² STM32F4 is reached through the customization ladder, not an in-tree crate; a FreeRTOS variant sits on the shared nros-board-freertos glue. ³ PX4 path is via the external-module template in integrations/px4/ — C++ only because PX4’s uORB binding is C++-only. ⁴ Untested = the code path exists and the port is documented, but no lane in this repo boots it — the hardware is not in the test rack and QEMU models no STM32 MAC. phase-337 W7.a moved the two STM32F4 board crates out of the tree for exactly that reason; keeping a row that says “Verified” for a board nothing verifies is the failure mode the tier registry exists to prevent. ⁵ The ESP-IDF component path is gone (phase-468 W2) — integrations/nano-ros/ and nros-platform-esp-idf were deleted, and esp_idf is no longer an accepted NROS_PLATFORM value. This row said “Ready” and “nightly lane” until 2026-09-25, when it was measured: no workflow under .github/ ran its module, and examples/fixtures.toml had no row naming it. The claim sat two sentences after this table’s own warning that “keeping a row that says ‘Verified’ for a board nothing verifies is the failure mode the tier registry exists to prevent” — the same mistake, one status word over, and measuring it is what retired the path. The row stays as a Retired marker so the next reader does not re-add it from the old claim. The esp-hal row above it is the ESP32 path with a booting CI lane, and is unaffected: esp-idf and esp32 are different platforms. Detail: ESP32 (ESP-IDF component) — retired.

Supported platforms (by RTOS)

PlatformRTOSNetwork StackTargets
POSIXLinuxOS socketsx86-64, aarch64
Bare-metalNonesmoltcpCortex-M3, ESP32-C3
FreeRTOSFreeRTOSlwIPCortex-M3 (QEMU)
NuttXNuttXBSD socketsCortex-A7 (QEMU), RISC-V32 (QEMU rv-virt)
ThreadXThreadXNetX DuoRISC-V 64 (QEMU), Linux sim
ZephyrZephyrZephyr socketsVarious boards

posix there is the platform layer, which is genuinely POSIX-clean. The host board is named linux, and that is the narrower claim: only Linux is tested, and nros-board-linux::apply_tier_affinity calls sched_setaffinity with cpu_set_t, which libc does not define for macOS. macOS is not supported (AGENTS.md, phase-260).

RMW Backends

nano-ros supports several middleware backends, selected at compile time by naming one in system.toml:

  • Zenoh (nros-rmw-zenoh) — peer-to-peer via zenoh-pico. No agent process. Compatible with ROS 2 rmw_zenoh_cpp.
  • XRCE-DDS (nros-rmw-xrce-cffi) — agent-based via Micro-XRCE-DDS. Compatible with micro-ROS agent.
  • Cyclone DDS (nros-rmw-cyclonedds) — C++ shim; full RTPS wire-compat with stock rmw_cyclonedds_cpp.

Application code is identical regardless of backend — switching is editing [system] rmw and re-running nros sync. (Zephyr keeps Kconfig as its own front-end, as it does for every other build knob.)

Project Status

nano-ros is under active development. Core capabilities are functional and exercised in CI; see the platform chapters for per-target detail.

CapabilityStatus
Pub/SubComplete
ServicesComplete
ActionsComplete
ParametersComplete
ROS 2 interopComplete
Zenoh backendComplete
XRCE-DDS backendComplete
Cyclone DDS backendComplete (native + embedded; some embedded action paths in progress)
Zephyr supportComplete
QEMU bare-metalComplete
C APIComplete
C++ APIComplete
Message codegenComplete

How This Book Is Organized

  • Getting Started — install toolchains, build your first app, connect to ROS 2.
  • Concepts — understand the architecture, feature system, and backend model.
  • Guides — step-by-step walkthroughs for message generation, QEMU testing, and ESP32 development.
  • Platforms — per-RTOS setup and configuration.
  • Reference — API details, environment variables, build commands, and wire protocol.
  • Advanced — formal verification, real-time analysis, safety features, and contributing.

Choose Your Entry

Different readers want different paths through this book. Pick the shoe that fits and jump straight to the right page.

🧪 I’m taking a glance

You heard about nano-ros and want to know in 5 minutes whether it’s worth playing with.

🌱 I’ve never used ROS

New to ROS entirely? Two ideas carry the whole book: a node is a process-like unit that computes; nodes talk through named topics (publish/subscribe), plus request/reply services. ROS 2 is the ecosystem standardizing those; an RMW (“ROS MiddleWare”) backend is the transport that moves the bytes. nano-ros is a small implementation of that model for microcontrollers. That’s enough to start:

  1. Install — host toolchain, no ROS 2 required.
  2. First Project — scaffold a two-node workspace and watch one node hear the other.
  3. Anatomy explains what you built, in nano-ros’s own terms.

🔧 I want it running on the board in my hand tonight

Honest expectations first: the fastest real win is on your Linux host (~10 minutes, no ROS 2, no daemon — --rmw cyclonedds), and every embedded chapter has a QEMU flow that works without hardware. Real-hardware flashing is currently documented only for out-of-tree boards (STM32F4 worked example). The zenoh embedded flows need a ROS 2 install on the host for the router.

  1. Check your board’s row in Supported Boards.
  2. Take the host win: First Project.
  3. Then your platform’s chapter under Bring Your Own RTOS (FreeRTOS / Zephyr / NuttX / ThreadX / ESP32 / bare-metal).

🤖 I have rclcpp/rclpy nodes and a colcon workspace

Experienced ROS 2 developer porting existing nodes to an MCU:

  1. Setup Compared to Standard ROS 2 — what stays familiar (package.xml, launch XML, find_package) and what changes (no install prefix, compile-time RMW).
  2. Porting a ROS 2 C++ node — what builds unchanged, and its limits.
  3. C / C++ multi-node workspaces — the colcon-shaped workspace flow.
  4. ROS 2 Interoperability — wire your MCU node into your existing graph. Zenoh needs ros-jazzy-rmw-zenoh-cpp (Humble ships no apt package); Cyclone DDS interops with rmw_cyclonedds_cpp directly, no router.
  5. Migration Guide — concept-to-concept map.

🔌 I have a board (or a vendored SDK tree) on my desk

Already have hardware — an ESP32, an STM32Cube or MCUXpresso project, a Zephyr or NuttX workspace? Two-step path:

  1. Linux first — First Project on your host verifies the whole stack in ~10 minutes, with no daemon and no ROS 2 install: nros setup linux --rmw cyclonedds, one scaffold command, then nros sync and nros build.
  2. Then your target — start at How Integration Works: your RTOS keeps its own build tool (west, make, idf.py, your IDE) and nano-ros plugs into it. One chapter per host build system follows it.
  3. Company tree, own BSP, forked RTOS? Start at Integrating into a Vendored Tree (pinning, air-gapped CI, the patch set you carry), then the Porting Guide: Build as a CMake subdirectory, Custom Board Package, Vendor Overlay, and the STM32F4 out-of-tree worked example. Know up front: adding a platform (new RTOS) or an in-catalog board still means carrying a small patch set inside your vendored nano-ros checkout — plan for a fork-with-rebase workflow against the nros-v<X.Y.Z> tags.

🚀 I want to get started shipping something

You’ve decided to use nano-ros and want a working system on Linux first, then move it to your target.

  1. Install — three commands: ./scripts/bootstrap.sh, source ./activate.sh (every new shell), then nros setup linux --rmw cyclonedds. Those set up the machine; the per-project step comes next.
  2. First Project — one scaffolded workspace, then nros sync and nros build, publishing with nothing else running. Rust variant on the same page: same two commands, same system.toml, only the node bodies differ.
  3. Anatomy of What You Just Built — the three package roles and the one configuration file; every later addition is another instance of these.
  4. Growing: nodes, parameters, more deploy targets — the Multi-Node Projects group; other languages — Rust, C, and Mixed.
  5. Troubleshooting — First 10 Minutes if anything goes sideways.
  6. Talking to a ROS 2 system — zenoh (router-based) or Cyclone DDS (direct, routerless) both interop: Choosing an RMW.

🔬 I’m evaluating capabilities

You’re a senior engineer or tech lead assessing nano-ros for adoption. You want to see scope of coverage, performance bounds, verification status, and trade-offs before committing.

💼 I’m scoping nano-ros for a fleet / product line

You’re a PM, CTO, or technical buyer. You want license terms, supplier reach, deployment patterns, and risk signals before you write the memo.

Still not sure?

Read the Introduction for the one-page overview. Every section above branches from there.

Installation

The library is distributed as source, vendored into the consumer’s project tree (git submodule, west manifest, ESP-IDF component, etc.) and built in-tree via add_subdirectory(nano-ros). There is no binary tarball, no system-wide install step and no find_package(NanoRos).

The nros CLI is a separate question with a separate answer, and which one you want depends on who you are:

you are…you get nros by…
using nano-ros to build your own projectinstalling a release — scripts/install.sh, below
developing nano-ros itselfbuilding it from the checkout — ./scripts/bootstrap.sh

That is not a preference. Inside a nano-ros checkout the tree’s own build is the only correct binary: a released nros run against a checkout is refused outright, because it emits its own generated code and this tree’s runtime is the one that has to compile it (RFC-0090).

Host prerequisites

Everything toolchain-shaped — rustup, cross-compilers, emulators, the zenoh router — is provisioned by the steps below. The host itself only needs the basics: git, curl, a C toolchain, pkg-config, python3.

Debian / Ubuntu:

sudo apt-get update
sudo apt-get install -y git curl ca-certificates build-essential \
    cmake pkg-config python3 python3-dev zstd \
    python3-catkin-pkg python3-empy python3-lark python3-yaml

Fedora / RHEL:

sudo dnf install -y git curl ca-certificates gcc gcc-c++ make \
    cmake pkgconf-pkg-config python3 python3-devel python3-pip zstd
# Fedora ships no rosidl packages — install the codegen Python deps via pip:
pip install --user catkin_pkg 'empy==3.3.4' lark PyYAML

Arch:

sudo pacman -S --needed git curl base-devel cmake python python-pip zstd
# Arch ships no rosidl packages — install the codegen Python deps
# (use a venv or pipx if your Python is externally managed):
pip install --user catkin_pkg 'empy==3.3.4' lark PyYAML

(base-devel is a package group, and it covers pkg-config; ca-certificates ships with the base system.)

(cmake builds the C/C++ quick start and every workspace root; zstd unpacks the prebuilt SDK assets nros setup fetches; python3-dev is needed once, while bootstrap.sh builds the launch resolver — it embeds CPython to parse ROS launch files. Each tool’s absence produces an error naming it. The Python packages feed rosidl_adapter, the ROS message-to-IDL step the CycloneDDS backend uses — on Fedora/Arch install catkin_pkg, empy==3.3.4, lark and PyYAML via pip or your package manager; empy must stay 3.x.)

These few packages are the only ones you install by hand. Per-board OS dependencies later on are declared in the index and printed for your package manager — apt, dnf or pacman — by:

nros setup --system          # what is missing + the exact install command

That command needs the nros CLI, which is why the basics above come first: they are what it takes to clone the repo and build the CLI.

The whole flow, end to end

Four steps take a bare machine to a building project; each is detailed in a section below.

Why step 2 builds the CLI here. These four steps are the contributor flow — they start by cloning the repository, so step 2 is the checkout’s own build, which is the only binary that may be used against it.

Prebuilt binaries were shipped early and withdrawn, because a released binary emitted code that had drifted from the runtime — and drifted generated code compiles, so the failure was silent. Restoring the download needed one question to have a checkable answer: can this binary’s output work with this runtime? RFC-0090 answers it with a codegen version that every generated artifact carries and every runtime asserts, and every arm of it refuses at compile time.

No release has been cut yet. The machinery is in place (scripts/install.sh and a manual release workflow), and cutting one is a deliberate act rather than a consequence of a version bump — so until the first one exists, building from the checkout is how everyone gets nros. install.sh says so itself if you run it early, and points you back here.

# 1. Pull the source at a pinned version (or `main` for latest):
git clone --branch nros-v0.6.0 https://github.com/NEWSLabNTU/nano-ros.git
cd nano-ros

# 2. Build the `nros` CLI from source (installs rustup if needed):
./scripts/bootstrap.sh

# 3. Wire the workspace env into this (and every future) shell:
source ./activate.sh          # or: direnv allow / source ./activate.fish

# 4. Provision the toolchains + RMW daemon for your target:
nros setup <board> --rmw <zenoh|xrce|cyclonedds>    # e.g. `native`

Then scaffold a project and build it: nros new, then nros sync and nros build — First Project walks it end to end. Your project says what it deploys to with a board name in its system.toml; it does not name a path, a triple or a backend crate in any build file. Consuming nano-ros below has the shape, and Pinning a version how the tag relates to nros version.

activate.sh is the after-install step, NOT a prereq. Sourcing activate.sh (or activate.fish, or direnv allow) only wires the workspace env into a new shell — it presumes the nros binary already exists at packages/cli/target/release/nros. The bootstrap front door below is what produces that binary; the activate file is what makes it findable in every subsequent shell.

build-as-subdirectory.md is the lower-level CMake escape hatch, for a C/C++ tree that is not a nano-ros workspace. This page walks the surrounding workspace + per-target setup choices.

Do I need ROS 2 installed?

Building, no. Running two zenoh nodes, yes — or use Cyclone. nros sync generates the message bindings from the interface sources vendored in packages/cli/interfaces/, so nothing about setup, codegen or compilation needs ROS 2.

Running is where it splits, and the reason is the router. zenoh-pico connects in client mode, so any two-process example needs a zenoh router — and since RFC-0075 that router is rmw_zenoh_cpp/rmw_zenohd, which ships with ROS 2. nano-ros no longer vendors one. So on a host with no ROS 2:

  • --rmw cyclonedds works standalone — Cyclone is in-process, there is no daemon to start. This is the ROS-less path.
  • --rmw zenoh (the default) has no router. Either install ros-<distro>-rmw-zenoh-cpp, or point NROS_RMW_ZENOHD at a rmw_zenohd you obtained another way.

If you do have ROS 2, source its setup first — that is what makes the router findable:

source /opt/ros/<distro>/setup.bash

nano-ros reads AMENT_PREFIX_PATH from that, so a ROS installed anywhere works, including one you built from source or a colcon overlay — it is not assumed to be under /opt/ros.

Do not expect command -v rmw_zenohd to print anything: rmw_zenohd installs into <prefix>/lib/rmw_zenoh_cpp/, and setup.bash puts only bin/ on PATH. ROS’s own way to reach it is ros2 run rmw_zenoh_cpp rmw_zenohd. So a silent command -v is normal and not a broken install — the ros2 run rmw_zenoh_cpp rmw_zenohd spelling finds it regardless.

Only what you name is used. nano-ros needs the router that ships with your rmw_zenoh_cpp: the two share a zenoh build, and a mismatch shows up as “nothing was delivered” rather than as an error. So the search is NROS_RMW_ZENOHD, then the prefixes you sourced, then $ROS_DISTRO — and nothing else. In particular:

  • PATH is not searched. A zenohd there — installed from zenoh’s own instructions, or left behind by an older nano-ros — is usually a different and older zenoh. The machine this was written on had two, at v1.4.0 and 1.7.2, against ROS’s 1.8.0.
  • /opt/ros/* is not globbed. If you have humble and jazzy installed, guessing between them is not better than saying nothing. Source the one you want, or set ROS_DISTRO.

If you do want a specific binary, name it with NROS_RMW_ZENOHD; it is used as given, with a warning if it is not an rmw_zenoh_cpp router.

If you once used an older nano-ros, run nros doctor: it reports a retired SDK store entry that is still installed, zenohd included, with the command to remove it.

TaskNeeds a ROS 2 install?
nros setup, nros sync, message codegenNo — interface sources are vendored
Building any node (Rust / C / C++), any RMWNo
Embedded targets (Zephyr, FreeRTOS, NuttX, ThreadX)No
Cyclone DDS backend, build and runNo — idlc comes from the index dist, and there is no daemon
Running a multi-process zenoh exampleYes — the router is ROS’s rmw_zenohd (or set NROS_RMW_ZENOHD)
Verifying with ros2 topic echo / ros2 node listYes
Interop tests against rmw_zenoh_cpp / a real ROS 2 graphYes
Contributor lanes that bridge to ROS 2 (just test-all interop cells)Yes

When you source activate.sh without ROS 2 present it says so:

activate.sh: /opt/ros/humble/setup.bash not found — ROS-dependent recipes
  will fail (interop tests, `ros2` CLI verification). The setup, codegen
  and first-node flows do not need it.

That line is informational on a getting-started host. If you do want the ROS 2 side and your distro has no Humble packages (Arch, for example), a container is the usual answer — nano-ros itself stays on the host.

Consuming nano-ros

Pattern A: your project is a nano-ros workspace

This is the supported shape, in any language. A workspace is a directory of packages — node packages plus one bringup package — and it has no root build file at all, neither CMakeLists.txt nor Cargo.toml (RFC-0098 D9):

~/my_robot/
├── .colcon_workspace          # marks the root
└── src/
    ├── pkg_a/                 # your node package(s)
    ├── pkg_b/
    └── my_robot_bringup/      # system.toml + launch files, no code

The bringup’s system.toml names the board once per deploy target:

[system]
name = "my_robot"
rmw  = "cyclonedds"

[image.linux]
board = "linux"

nros sync resolves the launch files and generates what the board choice implies; nros build generates the entry and the build root for each [image.*] under build/, then hands off to CMake, cargo, west or idf.py:

nros sync
nros build                     # or: nros build linux

nros new my_robot --workspace scaffolds exactly that layout — see First Project. A POSIX C++ workspace that already uses colcon can still be built by colcon build; the packages keep their package.xml and their own CMakeLists.txt.

A complete working example lives at examples/templates/multi-package-workspace/.

Pattern B: nano-ros as a third-party subdirectory

For a C/C++ project that is not a nano-ros workspace — an existing CMake tree with its own layout, that wants nano-ros as a subdirectory and nothing else:

~/my_project/
├── CMakeLists.txt
├── src/main.c
└── third_party/
    └── nano-ros/             # git submodule

There the platform and backend are cache variables set before add_subdirectory(), because there is no system.toml for the build to read them from. That is the lower-level shape and it has its own page, with the full variable table: Build as a CMake subdirectory. Reach for it when Pattern A does not fit your tree, not before.

Provision your toolchain with nros setup

nros setup is the single command that prepares a machine to build nano-ros for a given board. The cross-compiler, emulator, RMW host daemon, and any SDK sources a board needs are resolved from a pinned index and placed in a shared store (~/.nros/sdk). You do not install cross-toolchains by hand, and you do not need ROS 2 on the machine.

Packages are prebuilt where the index has a binary for your host, and built from source otherwise. Source builds are normal, not an error — but they cost real time and disk, so nros setup announces them before it starts:

nros setup: 1 package(s) have no prebuilt for linux-x86_64 — BUILDING FROM SOURCE: <name>
  Expect minutes (tens of minutes for a large recipe) and hundreds of MB under …

Source recipes build with the workspace’s pinned Rust channel, so a recipe carrying its own pin does not make rustup fetch a second toolchain behind your back. nros setup <board> --dry-run prints the whole plan, prebuilt vs source, without fetching anything — on the native board it is two source packages (zenoh-pico and mbedtls, both vendored submodules) and no daemon at all.

1. Get the nros CLI onto PATH

If you only want to use nano-ros, install a release — no checkout, no cargo, no just:

curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh
export PATH="$HOME/.nros/bin:$PATH"   # install.sh prints this line too

It verifies the download’s sha256 (and refuses an asset it cannot verify), installs into ~/.nros/sdk/nros/<version>/ and points ~/.nros/bin/nros at the newest version installed — so however many versions accumulate, nros means one command. Put ~/.nros/bin on your PATH. Until the first release is cut it will tell you so and send you to the source build below.

If you are working on nano-ros itself, build the CLI from the checkout — that binary is the only one this tree accepts. One front door from a fresh checkout (just is NOT a prereq; rustup is installed on demand):

Sourcing activate.sh on a host without just prints one line about RTOS SDK path defaults (FREERTOS_DIR, NUTTX_DIR, THREADX_DIR, …) not being loaded. Harmless — nothing in the user flows reads them, and embedded builds go through nros sync + nros build, which drives cargo, cmake, west or idf.py for you. just is a contributor tool: only the in-tree just recipes (test lanes, fixture builds) need it (cargo install just).

git clone https://github.com/NEWSLabNTU/nano-ros.git
cd nano-ros
./scripts/bootstrap.sh

Builds the in-tree nros CLI from source at packages/cli/target/release/nros and leaves it on PATH for this shell.

Already have cargo? The equivalent by hand (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"

Both produce the per-checkout binary at packages/cli/target/release/nros. One checkout = one CLI version = one runtime ABI — no global install, no ~/.nros/bin PATH skew across worktrees.

2. Activate the workspace (every subsequent shell)

The bootstrap path you ran in §1 left nros on PATH for that shell only. Every new shell needs the workspace env wired in. Pick whichever fits your shell — all three wire the same env exports + PATH entries from the Phase 218.C SSoT activate.sh:

direnv allow                  # auto-activates on `cd nano-ros` (recommended)
source ./activate.sh          # bash / zsh, one-shot per shell
source ./activate.fish        # fish, one-shot per shell

The activate file also sources /opt/ros/humble/setup.bash if present (used by the rmw_zenoh interop tests, and it lets codegen resolve message packages from the ament index; building with any RMW — Cyclone included — does NOT require ROS 2, only the pip-installable rosidl_adapter Python packages from the prerequisites). It exports no checkout root: the in-tree nros knows the checkout it was built in.

3. Provision a board (+ RMW)

nros setup <board> --rmw <zenoh|xrce|cyclonedds>

nros setup resolves the board’s package set union the RMW’s host packages and fetches them all — prebuilt cross-toolchain + emulator + RMW daemon + board SDK sources. --rmw defaults to zenoh. For every board with a CMake build road (all of them except Zephyr, which builds with west) it also installs the pinned Corrosion, the CMake-to-cargo bridge a C/C++ configure needs: a configure never fetches it, and without it in the SDK store the configure stops and names this command.

CommandProvisions
nros setup linuxhost build; zenoh-pico + mbedtls sources (no router — see above)
nros setup linux --rmw xrcehost build; the Micro-XRCE-DDS agent
nros setup linux --rmw cycloneddshost build; Cyclone DDS runtime + idlc
nros setup mps2-an385-freertosarm-none-eabi-gcc, patched qemu-system-arm, FreeRTOS + lwIP sources
nros setup qemu-armv7a-nuttxarm-none-eabi-gcc, qemu, NuttX sources
nros setup rv-virt-threadxriscv64-*-gcc, qemu, ThreadX/NetX sources
nros setup threadx-linuxThreadX POSIX-sim sources
nros setup esp32-c3-baremetalthe ESP32-C3 QEMU + bare-metal (esp-hal) toolchain bits
nros setup zephyrthe Zephyr west workspace + Zephyr SDK bits
nros setup mps2-an385-baremetal / stm32f4bare-metal arm-none-eabi-gcc + qemu

Every board also needs a Rust toolchain, whatever language you write: the runtime your project links is compiled from source when you first configure. If the host has neither rustup nor rustc + cargo, nros setup installs rustup (a pinned, checksum-verified installer) into ~/.cargo with the stable toolchain as the default — where cmake finds it without any PATH change. A host that already has Rust is left as it is. For cargo in a shell afterwards: . "$HOME/.cargo/env".

Useful flags:

nros setup --list            # every package in the index + its version
nros setup <board> --dry-run # resolve + print the plan, fetch nothing
nros setup --licenses        # license-gated packages + how to install them
nros setup --tool qemu       # one tool by name
nros setup --source freertos-kernel   # one source package by name

Each board’s exact package set lives in the index; run nros setup <board> --dry-run to see precisely what a board pulls. See Supported Boards for the full board list and nros CLI for every subcommand.

Heads-up before your first example. Every nano-ros example (Linux talker, FreeRTOS talker, …) connects to its RMW host daemon at startup — a zenoh router for zenoh, the Micro-XRCE-DDS agent for xrce. Cyclone DDS is in-process — no separate daemon — so none of this applies if you ran nros setup … --rmw cyclonedds.

For xrce, nros setup … --rmw xrce installs the agent into the nros store (~/.nros/sdk/<tool>/<version>/bin/), and you run it in a dedicated terminal before launching any example.

For zenoh, the router is not provisioned by nros setup: it is ROS 2’s rmw_zenoh_cpp/rmw_zenohd, so that nano-ros is tested against the router a ROS 2 deployment actually runs (RFC-0075). Start it with your ROS environment sourced (leave it running for the whole session):

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd

It is not configured on the command line — rmw_zenohd ignores argv and reads ZENOH_CONFIG_OVERRIDE, as above. To pick a different endpoint, change the port in the listen/endpoints entry.

Without a router the talker blocks forever on Executor::open with no output. Default ports: tcp/127.0.0.1:7447 on POSIX, tcp/10.0.2.2:7447 on QEMU FreeRTOS (Slirp forwards to host). Mismatch = silent hang; see Troubleshooting — First 10 Minutes.

After provisioning, follow the per-platform starter page (FreeRTOS, Zephyr, NuttX, …) for the board’s build + run steps.

Rust-only consumers

nano-ros is source-only — nothing is published to crates.io (decision 2026-05-14). The full nros crate can’t be published because it depends transitively on C/C++ submodules (zenoh-pico, mbedtls); nros-core isn’t carved out for crates.io either, to avoid a hybrid distribution model with version drift between the crates.io snapshot and in-repo HEAD.

Rust packages consume nano-ros one of two ways:

  • Registry-style names + patch redirection (what every shipped example uses): declare nros = { version = "*" } (+ msg crates) and let nros sync resolve the names into the SDK root (the checkout the project sits in, else the nros binary’s own; --nano-ros-path names one). The [patch.crates-io] block it writes goes into the generated settings file under build/, not into your package — nros build hands that file to cargo with --config, and a cargo command you drive yourself passes the same path. Copy the project anywhere; re-run nros sync to re-point.
  • Explicit path dependency on the in-workspace checkout:
[dependencies]
nros = { path = "src/nano-ros/packages/api/nros",
         default-features = false,
         features = ["std", "rmw-cffi", "platform-posix", "ros-humble"] }

A Rust package carries no build settings of its own — no target triple, no linker flag, no patch table. It states its board in system.toml and nros sync derives the rest (RFC-0098); see examples/templates/multi-package-workspace/src/pkg_rust_publisher/ for a package in that shape, and Integrating into a Vendored Tree for the CI spelling.

Pinning a version

Consumers pin the source tree, not a package: clone (or submodule) at a nros-v<X.Y.Z> git tag —

git clone --branch nros-v0.6.0 https://github.com/NEWSLabNTU/nano-ros.git
# or, as a submodule in your project:
git submodule add -b main https://github.com/NEWSLabNTU/nano-ros third_party/nano-ros
git -C third_party/nano-ros checkout nros-v0.6.0

The tag names the bundle version: the nros CLI built from that tree prints the same number (nros version → nros 0.5.0; the scripts/check-version-lockstep.sh gate + bootstrap.sh shell-doctor enforce binary↔tree agreement), and the tree’s committed nros-sdk-index.toml pins the exact toolchain/SDK versions nros setup will provision for it. One tag therefore fixes the whole surface: runtime source, CLI behavior, and provisioned toolchains. Upgrading = checking out a newer tag and re-running ./scripts/bootstrap.sh (rebuilds the CLI to match) + nros setup (reconciles the SDK store against the new index; already-current packages are no-ops). See versioning for how the bundle number is chosen.

Contributor setup (working on nano-ros itself)

Contributors clone the repo and drive everything through just. The just setup recipes are thin wrappers over the same nros setup index — just setup <module> calls nros setup <board> under the hood, so the toolchains a contributor gets are identical to a user’s.

git clone https://github.com/NEWSLabNTU/nano-ros.git
cd nano-ros
./scripts/bootstrap.sh base   # path A from §1 above; gets rustup + just + nros
source ./activate.sh          # OR: direnv allow / source ./activate.fish
just setup all                # provision every supported board's SDK/toolchain

Diagnose missing tools (read-only):

just doctor tier=all

Provision one module:

just setup freertos           # → nros setup mps2-an385-freertos
just setup nuttx              # → nros setup qemu-armv7a-nuttx
just setup threadx_linux      # → nros setup threadx-linux

Docker environment (contributors)

For a containerized environment with QEMU 7.2+ (in-tree checkout):

just docker build
just docker shell      # Interactive shell with all tools
just docker test-qemu  # Run QEMU tests in container

Migrating from a pre-140 checkout

If you were on a nano-ros version that still had the (contributor) just install-local recipe, see migration-install-local-removal.md for the one-page rewrite.

Next Steps

First Project

One command scaffolds a working multi-node workspace; two more build and run it. C++ and CMake, publishing on your machine, with nothing else running — no router, no daemon, no ROS 2 installation.

This assumes the two-step install is done (nros on your PATH, nros setup linux --rmw cyclonedds run once).

Scaffold

nros new my_robot --workspace
cd my_robot

That wrote a complete workspace, not a hello-world stub:

my_robot/
├── .colcon_workspace           # marks the workspace root; there is no root build file
└── src/
    ├── talker_pkg/             # a C++ node: publishes std_msgs/Int32 on /chatter
    ├── listener_pkg/           # a C++ node: subscribes to /chatter
    └── demo_bringup/           # launch file + system.toml — no code

There is no entry package and no root CMakeLists.txt: the binary is generated from the bringup’s system.toml when you build. What each directory is for is the next chapter, Anatomy of What You Just Built. You don’t need it to run.

Build

nros sync
nros build

nros sync resolves the launch file into the system model; nros build generates the entry for each [image.*] in system.toml and drives CMake under build/. The first build compiles nano-ros’s runtime into the build tree (~3 minutes); rebuilds are seconds. Inside a nano-ros checkout that checkout is used; anywhere else nros uses its own SDK (a release’s share/nano-ros, or the checkout an in-tree nros was built in), and --nano-ros-path <dir> names one explicitly. Nothing is exported. nros sdk-root --explain prints the root it would use and where that came from.

Run

./build/posix-cyclonedds-linux/cmake/linux_entry
Published: 0
Received: 0
Published: 1
Received: 1
Published: 2
Received: 2

One process, two nodes: the talker publishes std_msgs/Int32 on /chatter every 500 ms and the listener prints each one it receives — the interleaved Received: lines are your proof of delivery — exactly as a ROS 2 composition container would run two composable nodes. Ctrl-C stops it.

Why nothing else needed to be running: the default RMW is CycloneDDS, which discovers peers directly — there is no router or daemon in the picture. The zenoh backend (for talking to a ROS 2 system) and XRCE (for the smallest targets) are one page away: Choosing an RMW, and switching is a one-word edit — Switching RMW in Config.

Same thing in Rust

nros new my_rust_robot --workspace --lang rust
cd my_rust_robot
nros sync
nros build
RUST_LOG=info ./build/posix-cyclonedds/linux_entry/target/debug/linux_entry
[INFO  talker_pkg] Publishing: 0
[INFO  listener_pkg] I heard: 0
[INFO  talker_pkg] Publishing: 1

(The Rust talker ticks at 1 s.)

Known issue (1295). On CycloneDDS — the scaffold’s default RMW — the generated Rust entry currently stops at startup with NodeError::Transport(PublisherCreationFailed). The C++ path above is not affected.

Same workspace shape, same system.toml, same launch file, same two commands — only the node implementations and the driver nros build picks (cargo instead of CMake) differ. Neither workspace has a root build file: the entry is generated per image under build/.

If it didn’t work

  • nros: RMW session open failed on a busy machine usually means the DDS port range on your ROS_DOMAIN_ID is contended — pick another: ROS_DOMAIN_ID=57 ./build/posix-cyclonedds-linux/cmake/linux_entry.
  • Everything else: Troubleshooting — First 10 Minutes.

Where to go next

Anatomy of What You Just Built

Four directories, three roles, one configuration file. Everything you will ever add to this project is another instance of one of these three roles — the shape never changes, it only gains packages.

If you come from ROS 2, each role is something you already know:

Your workspaceRoleIn rclcpp terms
src/talker_pkg/, src/listener_pkg/Node packagea composable node (rclcpp_components)
src/demo_bringup/Bringup package<pkg>_bringup — launch files + config, no code
(generated under build/)Entrythe composition container / main()

Node packages — the code

A node package is one node as a reusable library. The C++ shape is a class with a configure(rclcpp::Node&) method that binds real member callbacks:

src/talker_pkg/
├── CMakeLists.txt          # nano_ros component library
├── package.xml             # ROS package manifest — deps live here
├── include/talker_pkg/Talker.hpp
└── src/Talker.cpp          # create_wall_timer<C,&C::m> → publish on /chatter

package.xml is standard ROS: message dependencies (std_msgs) are declared there and code-generated during the build. You never hand-write message types.

The bringup package — the one configuration file

src/demo_bringup/ holds no code: a launch file and system.toml. This is where every configuration axis lives:

[system]
name = "demo"
rmw = "cyclonedds"                   # the RMW axis
domain_id = 0
default_launch = "system.launch.xml"

[[component]]                        # the topology: which nodes exist
pkg = "talker_pkg"
class = "talker_pkg::Talker"
name = "talker"

[image.linux]                       # one block per BUILDABLE image
board = "linux"

[host.native]                        # WHERE the nodes run; no `nodes` = all

The launch file is the ROS 2 launch XML schema, verbatim — <node>, <param>, <remap>, <group>, <include> with $(find) / $(var) substitutions. A launch file from nav2 or Autoware pastes in unchanged (Python .launch.py is not supported). The scaffold’s launch even carries a per-topic QoS override to show the plumbing:

<node pkg="talker_pkg" exec="talker" name="talker">
  <param name="qos_overrides./chatter.publisher.depth" value="5"/>
</node>

An override changes how the two ends MATCH as well as how one end behaves. Setting the publisher to reliability = best_effort here would look harmless and deliver nothing on CycloneDDS: DDS never pairs a best-effort writer with a reliable reader, and the listener’s subscription is reliable by default. (ros2 topic echo --qos-reliability best_effort is the same rule seen from the other side.)

When you later target hardware, you add an [image.<id>] block per board — the code and topology stay put, and no new directory appears. That is the growth rule: configuration is a new block, never a restructure.

The entry — the deliverable, and it is generated

There is no entry directory under src/. nros build reads each [image.<id>] in system.toml and generates its entry under build/ (for the scaffold’s [image.linux], the binary is build/posix-cyclonedds-linux/cmake/linux_entry). That entry resolves the bringup’s launch file and has a main() that constructs each declared component, applies launch parameters and QoS overrides, and runs them on one executor — the launch product is the binary. There is no separate ros2 launch step; running the entry is launching the system.

One binary per deployable image — and image is a row, not a directory. A robot with a perception image and a control image is one workspace, one bringup, and two [image.*] blocks in its system.toml; nros build derives each entry from (launch, args, board) and generates it under build/. Everything a hand-written entry used to carry — its board, its RMW locator — is a key in that block. See Images.

What was generated vs. what is yours

Everything under src/ is yours to edit. The build tree (build/) holds everything derived: generated message code, the resolved system model, the entry’s generated main(). Nothing derived is ever committed — if you can regenerate it, it does not belong in git.

The same anatomy in Rust

nros new --workspace --lang rust produces the identical shape: node packages are library crates with nros::node!(Talker), the entry is a one-line nros::main!(launch = "demo_bringup", spin = "forever"), and the bringup directory is byte-for-byte the same idea. One difference: Rust message codegen runs as a pre-build step (nros sync) instead of inside CMake.

Next

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 install needed — even the rclcpp-shaped consumer below compiles against nano-ros’s own rclcpp CMake package.
  • colcon build — upstream rosidl_default_generators produces 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 scaffolded my_robot workspace the same drop-in applies: put src/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()

The nano-ros umbrella — the only nano-ros-specific file

The two src/ packages are stock ROS. One umbrella CMakeLists.txt at the workspace root pulls nano-ros in:

cmake_minimum_required(VERSION 3.22)
project(my_workspace LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)

# Pull nano-ros.
set(NROS_PLATFORM posix)
set(NROS_RMW "zenoh" CACHE STRING "Active RMW.")
set(NROS_RMW "${NROS_RMW}")
add_subdirectory(/path/to/nano-ros 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(/path/to/nano-ros/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)

Build:

cmake -B build -S .
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 manifest (my_msgs = { path = "../../generated/my_msgs", version = "0.0.0", default-features = false } — workspace-level generated/, constant 0.0.0 version), then run nros sync from the workspace root — the package index finds src/my_msgs/ by its package.xml, generates the generated/my_msgs crate, and wires the cargo patch table. 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:

LayerSourceNotes
1NROS_INTERFACE_SEARCH_PATHColon/semicolon-separated colcon-src/-style roots; immediate subdirs with package.xml are candidates.
2AMENT_PREFIX_PATHThe standard ROS install-prefix layout (<prefix>/share/<pkg>/{msg,srv,action}/).
3Bundled<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:

  1. 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.

  2. The configure pass emits a message(STATUS ...) line noting the resolved path, e.g.

    -- nros: find_package(std_msgs) -> /path/to/workspace/src/std_msgs
    

    Grep your configure log for nros: find_package(<pkg>) to confirm which layer supplied each pkg.

  3. Intra-workspace shadowing — when two roots under NROS_INTERFACE_SEARCH_PATH both ship the same pkg, the nros_workspace_interfaces() bulk orchestrator keeps the earlier-listed one and warns about the shadowed copy.

  4. 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.msg to std_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

Troubleshooting — First 10 Minutes

The Quick Start runs on --rmw cyclonedds — no router daemon exists on that path, so “no output” there is never a router problem; skip section B’s router branches. The zenoh walkthroughs (First Node pages) additionally assume nros setup linux --rmw zenoh has run and the ROS 2 zenoh router (ros2 run rmw_zenoh_cpp rmw_zenohd) is reachable. When something goes wrong in the first ten minutes, the error you see usually points at one of the predictable misses below.

Each branch quotes the real stderr you can grep against — not a paraphrase. If your error text matches, the fix on the right is the one to try.

A. Build failures (cargo / cmake)

A1. You built before nros sync

… has not been synced — missing the resolved model under `build/nros/models/`,
the generated message crate `std_msgs` (generated/std_msgs).
  Run `nros sync` in … (RFC-0098 D2), then build again.

The example’s Cargo.toml declares nano-ros crates registry-style (nros = { version = "*" } — they are not published to crates.io); the [patch.crates-io] block that resolves them into a nano-ros checkout is GENERATED, into build/<image>/nros-cargo.toml, which nros build hands cargo with --config (RFC-0098 D1). This error means that file has not been written yet, or its relative paths no longer reach a checkout (typical right after copying the example somewhere else). Fix:

cd <the example dir>
nros sync        # or: nros sync --nano-ros-path /path/to/nano-ros

Driving cargo directly skips that refusal and gets cargo’s own version of it instead — no matching package named 'nros' found, or an unresolved import. Same cause, same fix: sync, then pass the generated file with --config. nros sync is what ties a copied-out example to an SDK (the nros binary’s own, or the one --nano-ros-path names), so re-run it after moving the directory.

This is not an nros setup issue — nros setup only fetches the SDK / source-package payload (zenoh-pico, mbedtls, cyclonedds, …); it does not synthesise missing Cargo dependencies.

A2. nros codegen tool not found

nros (codegen tool) not found on PATH or in packages/cli/target/release/
or ${NROS_HOME:-~/.nros}/bin. nano-ros assumes `nros` is provided
(the CLI lives in-tree at packages/cli/). Build it with:
  just setup-cli                 # or: just setup

(The message names the contributor recipe; ./scripts/bootstrap.sh runs the same build without just.)

Missing the nros binary on PATH and in the per-checkout location. It builds from the in-tree sub-workspace; build it, then activate the workspace:

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

If packages/cli/target/release/nros exists but PATH doesn’t see it — that’s the [PATH] doctor status (D2 below), not this branch (the activate file is what puts it on PATH).

The nros binary ships the codegen — there is no separate nros-codegen build step. CMake examples auto-resolve nros from PATH / packages/cli/target/release/ / ${NROS_HOME:-~/.nros}/bin/; -D_NANO_ROS_CODEGEN_TOOL=<path> is an override, not a requirement.

A3. Rust target not installed

error: could not compile … due to previous error
the target `thumbv7m-none-eabi` is not installed

Add it:

rustup target add thumbv7m-none-eabi

The triple is the board’s, not the example’s — you will not find it in any file you wrote. Read it back from the [build] target line of the generated build/<image-id>/nros-cargo.toml, which nros sync writes from the board your system.toml names.

A4. Cross linker not found

error: linker `arm-none-eabi-gcc` not found

The cross toolchain wasn’t provisioned. Run nros setup for your board (it ships a prebuilt arm-none-eabi-gcc):

nros setup mps2-an385-freertos       # or qemu-armv7a-nuttx / mps2-an385-baremetal / …

A5. Cyclone DDS runtime missing

ld: cannot find -lddsc
ld: cannot find -lcyclonedds-ddsc

The Cyclone DDS runtime wasn’t provisioned:

nros setup linux --rmw cyclonedds
undefined reference to `nros_rmw_cyclonedds_register`
undefined reference to `dds_create_participant`

rmw-cyclonedds cannot link from cargo alone — the Cyclone backend is C++ + CMake, registered via nros_rmw_cffi_register_named from a CMake-built target — wired through CMakeLists.txt + Corrosion. Use the cmake build path instead, and choose the backend where backends are chosen: [system] rmw in the leaf’s system.toml.

[system]
rmw = "cyclonedds"
cd examples/linux/c/talker        # (or cpp)
cmake -B build-cyclone -Dnano_ros_ROOT="$(nros sdk-root)"
cmake --build build-cyclone

A single-package C / C++ leaf needs no nros sync — its message bindings are a CMake-time output. There is no -DNROS_RMW= to pass: a backend selected on the command line disagrees with the one the rest of the build resolves from system.toml, which is the drift RFC-0098 removes.

A Rust example does not take --features rmw-cyclonedds at all: its RMW is its image’s (issue 1305). examples/linux/rust/talker declares linux_cyclonedds, and nros sync --image linux_cyclonedds && nros build linux_cyclonedds builds it — the board crate’s rmw-cyclonedds builds Cyclone through nros-rmw-cyclonedds-sys.

A7. The board name is a typo

…/system.toml: board `mps2-an358` is claimed by no board descriptor under /…/nano-ros

[image.<id>] board is where a leaf names its board, and a typo there is refused by name rather than guessed at. The legal spellings are the names = [...] arrays of the board descriptors, one per board crate at packages/boards/*/nros-board.toml; several are aliases for the same board (qemu-mps2-an385 and rtic-mps2-an385 are two entry shapes on one descriptor).

The neighbouring refusals, when the image is missing rather than misspelt:

this workspace declares no `[image.*]`. An image is the buildable unit — see RFC-0065 D6.
…/system.toml: names no board (RFC-0098 D3: `[image.<id>] board = "<board>"`)

The first means there is no system.toml beside the package at all — a leaf without one is not a leaf nros build can plan. The second means the file is there and its [image.<id>] table has no board key.

A8. direnv allow reminder

NROS_PLATFORM_CFFI_INCLUDE not set (direnv allow, or build via just)
FREERTOS_PORT not set

(The quoted message is emitted by the build script; “build via just” in it refers to the contributor recipes.) The common build sites autoresolve these from the in-tree checkout, so a fresh cargo build no longer panics on them in canonical examples. If your custom build site still does, run direnv allow once after clone, or set the env explicitly (contributors can build via the in-tree just <plat> recipe).

B. Binary runs but no output

B1. Rust: RMW session open failed — Transport(ConnectionFailed)

[ERROR nros] RMW session open failed — Transport(ConnectionFailed)

The zenoh router isn’t running, or isn’t reachable on the locator the talker is pointed at. The router comes from ROS 2 (nano-ros no longer ships one) — start it in another terminal:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

Default ports: tcp/127.0.0.1:7447 on POSIX, tcp/10.0.2.2:7451 on QEMU FreeRTOS (Slirp forwards to host), 7452 NuttX, 7453 ThreadX-RV, 7454 ESP32, 7455 ThreadX-Linux, 7447 Zephyr.

B2. C: RCLC_CHECK failed: rclc_support_init(...) -> -4

RCLC_CHECK failed at src/main.c:92: rclc_support_init(&app.support, argc, argv, &allocator) -> -4

Process exits 1 (the retval passed to RCLC_CHECK_RET). -4 = RCLC_RET_NOT_FOUND — the locator was unreachable (zenohd not running, or wrong port). Same fix as B1 above.

The C API entry point is rclc_support_init (rclc’s (&support, argc, argv, &allocator)) or, with an explicit locator, rclc_support_init_with_locator — not nros_init or nros::init, which don’t exist in the C API.

B3. C++: process exits 156 after a rclcpp::init_in failure

nros::init returned NROS_CPP_RET_TRANSPORT_ERROR (-100)

Same root cause as B1/B2 — zenohd not reachable. NROS_CPP_RET_TRANSPORT_ERROR = -100 is the C++ result code that RCLCPP_TRY_RET propagates from main(); on POSIX this becomes (unsigned char)-100 = 156 as the process exit code. Treat “exited 156 after starting” as the C++ equivalent of B1.

B4. Override the locator at runtime

When a native talker can’t reach the daemon, override the locator with the canonical env var:

NROS_LOCATOR=tcp/192.168.1.50:7447 ./build/c_talker
ROS_DOMAIN_ID=7 ./build/c_talker         # also overridable

The native Rust / C / C++ talkers all read NROS_LOCATOR, then fall back to the build-time default. The retired ZENOH_LOCATOR / ZENOH_MODE are REFUSED at startup with an error naming NROS_LOCATOR / NROS_SESSION_MODE — unset them. Embedded targets have no runtime env — their locator is compile-baked from system.toml ([image.<id>] locator, or [system] locator for an image that does not state one), so editing that file and rebuilding is the override.

B5. Binary exits immediately, no error printed

Buffering: setvbuf(stdout, NULL, _IOLBF, 0) if you piped the run. POSIX terminals flush on newline; piped stdout full-buffers and may eat short outputs. Add a RUST_LOG=info (Rust) or unbuffer the C / C++ output (stdbuf -oL).

C. ROS 2 side sees nothing

C1. RMW mismatch

# On the ROS 2 side, default rmw_fastrtps_cpp will NOT see nano-ros:
export RMW_IMPLEMENTATION=rmw_zenoh_cpp     # for Zenoh
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp # for Cyclone

C2. QoS mismatch — echo silent, list sees the topic

ros2 topic list           # /chatter shown
ros2 topic echo /chatter  # … nothing

nano-ros publishers default to BEST_EFFORT; stock ros2 topic echo defaults to RELIABLE. The QoS-mismatched subscriber is created but receives no data. Force best-effort on the echo:

ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

D. Doctor + last-resort

D1. Run the doctor first

nros doctor

It prints fixit hints for the toolchain you actually need. (A just-free platform-scoped doctor spelling does not exist today; contributors with an in-tree checkout can scope via the platform recipes.)

D2. [PATH] nros built but not on PATH

The doctor now reports this distinct from [MISSING] when the binary is built at packages/cli/target/release/nros (or the transitional ${NROS_HOME:-~/.nros}/bin/nros) but PATH doesn’t see it. Activate the workspace — it wires PATH:

source ./activate.sh        # bash / zsh
# OR
source ./activate.fish      # fish
# OR
direnv allow                # auto-activates on `cd nano-ros`

Don’t loop on the contributor recipe just workspace cargo-tools — that re-runs the build which short-circuits on the same PATH miss.

D3. Full sweep (slow)

nros doctor
nros setup --check

Only run this when you’re standing up every supported platform in one go. It walks the provisioning checks and can take a few minutes.

D4. File an issue

When all else fails, include:

  • the exact command you ran,
  • the full stderr,
  • rustc --version, cmake --version, qemu-system-arm --version,
  • nros --version.

What success looks like

A correctly-running Rust Linux talker (examples/linux/rust/talker) prints something like this on stderr (with RUST_LOG=info):

[INFO  talker] Publishing: 'Hello World: 1'
[INFO  talker] Publishing: 'Hello World: 2'
[INFO  talker] Publishing: 'Hello World: 3'

(That is the full expected output — the talker logs one line per publish, nothing else.)

A correctly-running C talker (examples/linux/c/talker) prints on stdout:

Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
Publishing: 'Hello World: 3'

A correctly-running C++ talker prints the same Publishing: 'Hello World: N' line once per second.

The ROS 2 side (ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort with RMW_IMPLEMENTATION=rmw_zenoh_cpp) should see:

data: 'Hello World: 1'
---
data: 'Hello World: 2'
---
data: 'Hello World: 3'
---

If you see all three of these — talker logging, ROS 2 echo output, and matching counter values — interop is verified end-to-end.

See also

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).

First Node — C (Linux)

Build, run, and verify a single nano-ros publisher node on Linux from C. Uses CMake and the Zenoh backend, with one find_package(nano_ros).

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 — ros2 run rmw_zenoh_cpp rmw_zenohd):

nros setup linux --rmw zenoh

See Install + first build (Linux) for more.

Project layout

The talker is a standalone CMake project. Four files matter:

examples/linux/c/talker/
├── system.toml         # WHAT this deploys to — the one file you edit
├── CMakeLists.txt      # find_package + targets; no board facts
├── package.xml         # ROS-style manifest (drives codegen tooling)
└── src/
    └── main.c          # ~100-line talker

system.toml is the whole deployment statement — the same schema a Rust leaf and a multi-node workspace bringup use (RFC-0098):

[system]
name      = "native_c_talker"
rmw       = "zenoh"
domain_id = 0

[[component]]
pkg   = "native_c_talker"
class = "native_c_talker::Talker"
name  = "talker"

[image.linux]
board = "linux"

find_package(nano_ros) reads it, derives the platform from the board, and configures the build accordingly. Nothing in CMakeLists.txt names a platform, a board or an RMW — and the package.xml <nano_ros deploy=… board=… rmw=…/> export that used to carry that tuple is retired and refused. Switching this example to a board is editing [image.linux] board and re-running cmake; a C or C++ leaf names no board crate, so that is the only edit to the project — a cross build still passes its -DCMAKE_TOOLCHAIN_FILE on the command line.

nano_ros_add_executable still accepts DEPLOY and BOARD keywords, for a project with no system.toml. Beside one they are redundant at best: a keyword that names a different deploy or board than the leaf’s [image.<id>] stops the configure and names both files, rather than quietly building the CMakeLists.txt one.

On native, locator + domain come from env vars (NROS_LOCATOR, ROS_DOMAIN_ID) with built-in defaults; an embedded image declares them in its own [image.<id>] table (locator, and its address as net = { eth0 = { network = "qemu", host = N } }) and they are baked at build time (see Configuration).

The CMakeLists.txt is, verbatim from examples/linux/c/talker/CMakeLists.txt:

cmake_minimum_required(VERSION 3.22)
project(c_talker LANGUAGES C CXX)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)

nano_ros_add_executable(c_talker src/main.c)
ament_target_dependencies(c_talker std_msgs)

install(TARGETS c_talker DESTINATION lib/${PROJECT_NAME})
ament_package()

nano_ros_add_executable does the wiring the old five-set() preamble did by hand: it generates the C bindings the package declares, links NanoRos::NanoRos and the platform, and pulls in the active RMW’s strong nros_app_register_backends() stub (calling nros_rmw_zenoh_register() for the zenoh image above). Which RMW that is comes from [system] rmw, not from a -D on the command line.

The C entry point is int main(int argc, char **argv), as in any rclc program. On a board the C runtime does not call main, so the build compiles yours under another name and reaches it from the platform’s own entry (Zephyr’s main(void), an RTOS startup task); on Linux it is the process entry. You write main either way.

#include <rcl/rcl.h>
#include <rclc/executor.h>
#include <rclc/rclc.h>
#include <std_msgs/msg/int32.h>

int main(int argc, char** argv) {
    // 1. rclc_support_init() — opens the zenoh session
    // 2. rclc_executor_init() / rclc_node_init_default()
    // 3. rclc_publisher_init_default(&pub, &node,
    //        ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "/chatter")
    // 4. rclc_timer_init_with_context() with a 1 Hz period; the callback calls
    //        rcl_publish(&pub, &msg, NULL)
    // 5. rclc_executor_spin() until SIGINT
}

Generated messages take rosidl’s C names and member shape (std_msgs__msg__String in <std_msgs/msg/string.h>, msg.data.data / .size / .capacity). Their strings and sequences point at memory you own: wire them with the generated <Msg>__init_in(&msg, &storage) over a <Msg>__Storage, or point data at your own buffer as micro-ROS code does. Nothing on this path allocates, so rosidl’s allocating <Msg>__init / __fini are not declared.

Configure

Three runtime knobs:

KnobDefaultEnv override
Zenoh locatortcp/127.0.0.1:7447NROS_LOCATOR
ROS domain ID0ROS_DOMAIN_ID
Node nametalkerhard-coded in source

On an embedded image the same knobs are compile-baked from that image’s [image.<id>] table in system.toml (see Configuration).

Build

cd examples/linux/c/talker
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)"
cmake --build build

No nros sync here, and that is a property of the language, not an oversight: a C or C++ leaf’s message bindings are generated inside CMake, at configure time, from the package’s own declared dependencies. A Rust leaf needs sync because its message crates and its build settings must exist before cargo parses anything. Measured: a copy of this directory outside the checkout configures and builds with no nros sync at all. See Workflow by Platform and Language.

nros build works here too: in a single-package C or C++ leaf it runs the same two cmake lines, into build/<image>/cmake, with the toolchain file the board names (issue 1296). In a multi-node workspace it is the only build verb.

A raw cmake must be told where nano-ros is, as above — -Dnano_ros_ROOT=/path/to/nano-ros, or "$(nros sdk-root)", which prints the SDK root the nros on PATH resolves. nros build (and the CMakePresets nros init writes) passes it for you; activate.sh does not export it.

The first configure pulls and builds nano-ros’s Rust staticlibs (~3 minutes). Re-builds finish in seconds.

Run

Three terminals.

# 1. Start the zenoh router (ROS 2's own):
source /opt/ros/humble/setup.bash
ros2 run rmw_zenoh_cpp rmw_zenohd    # or: just zenohd

# 2. Run the talker:
cd examples/linux/c/talker
./build/c_talker
# Expected output:
#   Publishing: 'Hello World: 1'
#   Publishing: 'Hello World: 2'
#   Publishing: 'Hello World: 3'
#   …

# 3. Verify from stock ROS 2:
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

Readiness signal. Within ~6 seconds of ./build/c_talker (session open + the first 1 s timer tick), the binary should print Publishing: 'Hello World: 1' on stdout — Rust + C + C++ all start the count at 1, matching the official ROS 2 demo talker. If no Publishing: line in 30 seconds:

  1. Confirm the router is running (terminal 1). Without it, rclc_support_init returns immediately with -4 (RCLC_RET_NOT_FOUND — connection refused).
  2. Wrong locator / unreachable host → same -4 signature in stderr. Reachable host but mismatched port → talker hangs on session-open handshake rather than returning a code.
  3. See Troubleshooting — First 10 Minutes.

GitHub source

Canonical, copy-out: examples/linux/c/talker/

Next

First Node — C++ (Linux)

Build, run, and verify a single nano-ros publisher node on Linux from C++14. Uses CMake, the Zenoh backend, and add_subdirectory consumption.

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 — ros2 run rmw_zenoh_cpp rmw_zenohd):

nros setup linux --rmw zenoh

See Install + first build (Linux) for more.

Project layout

The talker is a standalone CMake project. Three files matter:

examples/linux/cpp/talker/
├── system.toml         # WHAT this deploys to — the one file you edit
├── CMakeLists.txt      # find_package + targets; no board facts
├── package.xml         # ROS-style manifest
└── src/
    └── main.cpp        # ~70-line talker

system.toml is the whole deployment statement — [system] for the RMW and domain, [[component]] for what runs, and one [image.<id>] per target naming its board (RFC-0098). find_package(nano_ros) reads it and derives the platform from the board, so no line of CMakeLists.txt names a platform, a board or an RMW. Switching this example to a board is editing [image.linux] board and re-running cmake; see First Node — C for the file in full.

Native (host) talkers read the locator + domain from arguments passed to rclcpp::init_in(...); no other config is needed. An embedded image declares its locator and its address (net = { eth0 = { network = "qemu", host = N } }) in its own [image.<id>] table and bakes them at build time. The package.xml <nano_ros deploy=… board=… rmw=…/> export that used to carry that tuple is retired and refused.

The CMakeLists is the ament shape you already know — this is the complete canonical file at examples/linux/cpp/talker/CMakeLists.txt, verbatim (RFC-0048: nano_rosConfig.cmake at the checkout root makes find_package(nano_ros) resolve source-side, and the message-package Find-stubs make find_package(std_msgs) generate bindings on the fly):

cmake_minimum_required(VERSION 3.22)
project(cpp_talker LANGUAGES C CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)

nano_ros_add_executable(cpp_talker src/main.cpp)
ament_target_dependencies(cpp_talker std_msgs)

install(TARGETS cpp_talker DESTINATION lib/${PROJECT_NAME})
ament_package()

LANGUAGES C CXX (not CXX alone): the per-target register stub is a C translation unit, so C must be enabled in this directory scope or the link fails. Backend selection is [system] rmw in system.toml, not CMake code and not a configure-line cache variable.

The C++ entry point is int main(int argc, char** argv), as in any rclcpp program; the build reaches it from the platform’s entry, as for C. Write that exact signature: the build declares the renamed function with it, so another form fails at link naming nros_ported_main(int, char**).

The body uses typed rclcpp::Publisher<M> / rclcpp::Subscription<M> wrappers over the C ABI:

#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"

#define RCLCPP_TRY_LOG(file, line, expr, ret) \
    std::fprintf(stderr, "[nros] %s:%d %s -> %d\n", file, line, expr, (int)ret)

int main(int argc, char** argv) {
    RCLCPP_TRY_RET(rclcpp::init_in(), 1);

    rclcpp::Node node;
    RCLCPP_TRY_RET(rclcpp::create_node(node, "talker"), 1);

    rclcpp::Publisher<std_msgs::msg::String> pub;
    RCLCPP_TRY_RET(node.create_publisher(pub, "/chatter"), 1);

    // ... register a timer + spin
}

RCLCPP_TRY_RET short-circuits on any non-OK return code and logs the expression that failed. Define RCLCPP_TRY_LOG once (any sink — here std::fprintf) and reuse it across every call site.

If you write the rclcpp::Node constructor instead

rclcpp::create_node(node, "talker") above returns a Result, and RCLCPP_TRY_RET checks it. The rclcpp-shaped constructor has no return value to check — and on a -fno-exceptions target, which every nano-ros build is (RFC-0018), it cannot throw the way upstream’s does either. It records the failure and reports it through ok():

rclcpp::Node node("talker");
if (!node.ok()) return 1;   // this line replaces upstream's try/catch

Skip that line and main runs on with a node that never came up. The object exists either way, node.create_publisher(...) on it still compiles and still returns, and nothing ever reaches the wire. There is no diagnostic and no crash — not throwing is the point, so asking is your job.

A generated entry already asks, and halts naming the node that failed, so a workspace component needs none of this. It is only on you in a hand-written main like the one above.

The two-phase form carries the same obligation:

rclcpp::Node node;
if (!node.init("talker").ok()) return 1;

with one difference in your favour: init() returns a Result, which is [[nodiscard]], so dropping it on the floor is a compiler warning. ok() on a constructed node is a question nothing can force you to ask.

Full compatibility notes, including what shared_from_this() and rclcpp::TimerBase do differently here: Porting a ROS 2 C++ node.

Configure

Three runtime knobs:

KnobDefaultOverride
Zenoh locatortcp/127.0.0.1:7447First arg to rclcpp::init_in
ROS domain ID0Second arg to rclcpp::init_in
Node nametalkerFirst arg to rclcpp::create_node

With no arguments, rclcpp::init_in() (and rclcpp::init(argc, argv)) takes the locator and domain from the build, then the NROS_LOCATOR and ROS_DOMAIN_ID environment variables on a hosted target, so an example needs neither argument.

Build

cd examples/linux/cpp/talker
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)"
                        # raw cmake names the root; `nros build` (or the
                        # CMakePresets `nros init` writes) passes it for you
cmake --build build

No nros sync here: a C++ leaf’s message bindings are generated inside CMake, at configure time. (A Rust leaf needs sync, because its message crates and its build settings have to exist before cargo parses anything.) nros build in the leaf runs these same two cmake lines, into build/<image>/cmake, with the board’s toolchain file (issue 1296); in a multi-node workspace it is the only build verb.

First configure builds nano-ros’s Rust staticlibs (~3 minutes). Re-builds finish in seconds.

Run

Three terminals.

# 1. zenoh router (ROS 2's own):
source /opt/ros/humble/setup.bash
ros2 run rmw_zenoh_cpp rmw_zenohd    # or: just zenohd

# 2. Run the talker:
cd examples/linux/cpp/talker
./build/cpp_talker
# Expected:
#   nros C++ Talker
#   ===================
#   Node created: talker
#
#   Publishing messages (Ctrl+C to exit)...
#
#   Publishing: 'Hello World: 1'
#   Publishing: 'Hello World: 2'
#   …

# 3. Verify from stock ROS 2:
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

Readiness signal. Within ~6 seconds of ./build/cpp_talker (session open + the first 1 s timer tick), the binary should print Publishing: 'Hello World: 1' — Rust + C + C++ all start the count at 1, matching the official ROS 2 demo talker. If no Publishing: line in 30 seconds:

  1. Confirm the router is running (terminal 1). Without it, rclcpp::init_in returns -100 (TransportError) — the RCLCPP_TRY_RET macro logs the failed call to stderr.
  2. Check stderr for [nros] …/main.cpp:LINE rclcpp::init_in(...) -> -N diagnostics. -3 / -100 both indicate transport open failed.
  3. See Troubleshooting — First 10 Minutes.

GitHub source

Canonical, copy-out: examples/linux/cpp/talker/

Next

Choosing an RMW

Every nano-ros binary picks its RMW (ROS Middleware) backend at build time. There are three: Cyclone DDS, Zenoh (zenoh-pico), and XRCE-DDS. This page helps you pick one; the mechanics of changing your pick live in Switching RMW Backends, and the per-backend deep dive (wire behavior, per-platform network configuration) is in RMW Backends.

Start with Cyclone DDS

For getting started, use Cyclone DDS. It is the one backend that needs no daemon, router, or agent: a nano-ros node publishes with nothing else running, and a stock ROS 2 node using rmw_cyclonedds_cpp sees it directly — same RTPS wire protocol, same discovery, no translation layer in between.

The reason the other two are not the starting point: nano-ros ships no bridge process of its own. The zenoh router is ROS 2’s own rmw_zenohd (see RFC-0075), so the zenoh backend requires a ROS 2 installation just to start the router; the XRCE backend requires a running Micro-XRCE-DDS Agent. Cyclone DDS requires neither.

Current maturity: pub/sub and services interoperate with stock rmw_cyclonedds_cpp; status events (liveliness, deadline-miss) are not wired to Cyclone listeners yet. The full list is in docs/reference/cyclonedds-known-limitations.md.

Zenoh — ROS 2 interop and small embedded targets

Choose the Zenoh backend (built on zenoh-pico) when:

  • You are integrating with a ROS 2 fleet running rmw_zenoh_cpp.
  • Your target is a small MCU: ~16 KB of heap and ~100 KB of flash are enough, versus ~32 KB+ RAM for Cyclone DDS.
  • Your network cannot do UDP multicast (RTPS discovery needs IGMP; zenoh-pico in client mode runs over plain TCP).

The zenoh backend needs a router, and the router comes from your ROS 2 installation — it is the same rmw_zenohd that ROS 2 zenoh nodes use:

ros2 run rmw_zenoh_cpp rmw_zenohd

That invocation works on any host with rmw_zenoh_cpp installed, regardless of where ROS 2 lives — never hard-code an install path to the router binary. See ROS 2 Interop for the full three-terminal walkthrough.

XRCE-DDS — smallest footprint, serial transport

Choose the XRCE-DDS backend when:

  • RAM is the constraint: ~3 KB of client RAM, and the backend allocates only when a session or entity is created — never per message. (It is not heap-free: each allocation goes through nros_platform_alloc, and their number follows the entities you declare.)
  • You need a serial (UART) transport — the only backend that works on an MCU with no networking hardware. See Serial Transport.
  • You are joining an existing micro-ROS / DDS deployment: the agent bridges to any DDS-based RMW.

The MCU cannot participate without the Micro-XRCE-DDS Agent (MicroXRCEAgent) running on a host; if the agent stops, the device loses all connectivity.

Capability matrix

AspectCyclone DDS (cyclonedds)Zenoh (zenoh)XRCE-DDS (xrce)
Client RAM~32 KB+ (heap required)~16 KB+ (heap required)~3 KB (fully static)
Client Flash~150 KB+~100 KB+~75 KB
Bridge processNone — RTPS multicast directlyrmw_zenohd router (from your ROS 2 install)Agent (protocol translator, mandatory)
Peer-to-peerYes (RTPS native)Not as shipped — zenoh-pico’s multicast/scouting is compiled outNo (agent always required)
DiscoverySPDP / SEDP on UDP multicast or static peer listClient participates (liveliness tokens)Agent handles on behalf
Entity creationClient creates directlyClient creates directlyClient requests, agent creates
TransportsUDP unicast + multicast (RTPS)TCP, UDP, TLSUDP, serial (HDLC framing)
Heap allocationRequired (Cyclone uses malloc)Required (C-level)None
ImplementationC++ wrapper over upstream Cyclone DDS CRust + zenoh-pico CRust + Micro-XRCE-DDS-Client C
ROS 2 interopDirect against rmw_cyclonedds_cpp (same upstream version)Via rmw_zenoh_cpp + its routerVia agent + any DDS RMW
Failure modePeer goes offline = its samples stop arrivingRouter crash = lose routingAgent crash = lose connectivity

Notes on two rows: zenoh’s router-free peer mode exists upstream but nano-ros compiles zenoh-pico without the multicast transport and scouting, so every deployment reaches its peers through the router; and the upstream Micro-XRCE-DDS client defines a CAN FD transport, but nano-ros builds do not enable it.

Decision summary

  • Default / getting started / DDS fleet → Cyclone DDS. Nothing to run beside your node; direct rmw_cyclonedds_cpp interop. Needs ~32 KB RAM and a network stack with IGMP.
  • rmw_zenoh_cpp fleet, small MCU, or TCP-only network → Zenoh. Needs the router from your ROS 2 install (ros2 run rmw_zenoh_cpp rmw_zenohd).
  • Tiny MCU, serial link, or micro-ROS deployment → XRCE-DDS. Smallest footprint, zero heap, but the agent is mandatory.

You do not have to pick exactly one per system: a single binary can link multiple backends and bridge between them — see Cross-backend Bridges.

Once you have picked, Switching RMW Backends shows the exact edits per build system.

Switching RMW Backends

nano-ros selects its RMW backend at build time — there is no RMW_IMPLEMENTATION environment variable to flip at runtime the way desktop ROS 2 does. Which edit you make depends on how your project is built. This page lists the exact change per builder; for help picking a backend in the first place, see Choosing an RMW.

Backend names are the same everywhere: zenoh, xrce, cyclonedds. When nothing selects a backend, the default is zenoh.

Workspace (C, C++, mixed or Rust)

One edit, in the bringup’s system.toml:

[system]
rmw = "cyclonedds"          # zenoh | xrce | cyclonedds

or, for one image only, rmw = "…" inside its [image.<id>] block. Then

nros sync
nros build

A workspace has no root build file to edit (RFC-0098 D9): nros build writes the CMake root under build/ with BACKEND taken from the image’s rmw, and for a Rust image nros sync writes the selection facade that gives the board crate the backend feature. Each image builds into its own directory, so no object files from another backend linger.

Known issue (1295). A Rust image on cyclonedds also needs the nros crate’s rmw-cyclonedds feature — the type-descriptor gate: Cyclone DDS resolves topic types through a runtime descriptor registry, and this feature registers a message’s descriptor before its topic is created. The generated facade does not name it yet, so such an entry builds and then fails PublisherCreationFailed at startup. Zenoh and XRCE need no such feature, and C/C++ images are not affected.

Single C/C++ package (no workspace function)

A standalone package that consumes nano-ros in source form — either find_package(nano_ros REQUIRED) (the ament-shape entry point, nano_rosConfig.cmake at the checkout root, located via -Dnano_ros_ROOT, which nros build passes; RFC-0048) or a raw add_subdirectory — for example a copied-out leaf like examples/linux/c/talker/ — picks its backend on the configure line:

cmake -S . -B build -Dnano_ros_ROOT="$(nros sdk-root)" \
    -DNROS_RMW=cyclonedds   # zenoh | xrce | cyclonedds
cmake --build build

Use one build directory per backend (build-zenoh/, build-cyclonedds/, …) if you switch back and forth.

Single Rust package

A single Rust package with its own system.toml (no bringup) is the same one edit — rmw in that system.toml — followed by nros sync and a rebuild. The Rust workspace case is covered by the section above: a workspace entry is generated, so there is no entry Cargo.toml to edit.

The full-system declaration model — how the one value in system.toml is lowered to cargo features and CMake cache variables — is specified in RFC-0031.

Embedded targets

On embedded platforms the backend is fixed at compile time like everything else — there is nothing to switch on the device. Each platform surfaces the same choice through its own configuration mechanism; on Zephyr, for instance, the image’s rmw in system.toml is rendered into a Kconfig choice (CONFIG_NROS_RMW_ZENOH / CONFIG_NROS_RMW_XRCE / CONFIG_NROS_RMW_CYCLONEDDS, exactly one), never written in a conf file (phase-481).

See the platform guides for the concrete per-platform workflow:

and Workflow by Platform and Language for the overview of which builder applies where.

RMW Backends: Zenoh, XRCE-DDS, Cyclone DDS

Capability truth (services / events / QoS per backend) is the generated Per-RMW Feature Matrix; version pins live on Support Status. This page is the architectural narrative.

nano-ros supports three RMW (ROS Middleware) backends for connecting embedded devices to a ROS 2 network. Each backend targets different deployment scenarios and resource constraints. Each Node picks its backend at build time; one binary can link multiple backends and bridge between them — see Cross-backend Bridges for the multi-RMW pattern.

Zenoh (rmw-zenoh)

The Zenoh backend uses zenoh-pico, a lightweight C client for the Zenoh protocol. The MCU participates directly in the Zenoh network – there is no protocol translation layer.

How it works:

  1. The MCU runs zenoh-pico in client mode, connecting to a zenoh router (rmw_zenohd, ROS 2’s own) over TCP, UDP, or TLS.
  2. zenoh-pico creates publishers and subscribers directly on the Zenoh network.
  3. ROS 2 nodes running rmw_zenoh_cpp connect to the same router, enabling transparent interop.
  4. zenoh itself defines a peer mode in which two zenoh-pico devices talk directly with no router. nano-ros does not ship it. The shim is compiled without zenoh-pico’s multicast transport and scouting (nros_zpico_build::MULTICAST_TRANSPORT = false) because those are three more code paths in a library whose job here is fitting on an MCU, and every nano-ros deployment reaches its peers through a router or an agent. NROS_SESSION_MODE=peer is therefore refused at session open with a message saying so — see issue 0682. Enabling it means flipping that constant, rebuilding the shim, and accepting the footprint.

Key characteristics:

  • Client/router topology; zenoh’s router-free peer mode is compiled out (see above)
  • The router forwards messages without interpreting them – it is not a protocol translator
  • If the router crashes, peers in client mode lose routing but the MCU continues running
  • Full ROS 2 graph discovery via liveliness tokens
  • Transport options: TCP, UDP, TLS (via zpico-smoltcp or platform sockets)

XRCE-DDS (rmw-xrce)

The XRCE-DDS backend uses Micro-XRCE-DDS-Client, the same client library used by micro-ROS. It follows an agent-based model where a lightweight client on the MCU delegates entity creation to an agent process.

How it works:

  1. The MCU runs the XRCE-DDS client, connecting to an Agent process over UDP or serial (UART).
  2. The client sends requests to the Agent: “create a publisher on topic X with type Y.”
  3. The Agent creates full DDS entities on behalf of the client and bridges data between the XRCE protocol and the DDS data space.
  4. ROS 2 nodes using any DDS-based RMW (FastDDS, Cyclone DDS) communicate through the Agent.

Key characteristics:

  • Agent is mandatory – the MCU cannot participate in the network without it
  • If the Agent crashes, the MCU loses all connectivity
  • Small, bounded heap use on the MCU: the backend allocates when a session or entity is created, never per message (each is a nros_xrce_calloc in an open/create path, through nros_platform_alloc; each XRCE_MAX_SUBSCRIBERS slot is heap, so size it to what you declare)
  • Client-side discovery is not supported; the Agent handles it
  • Transport options: UDP, serial (HDLC framing)

Cyclone DDS (rmw-cyclonedds)

Maturity status. Cyclone DDS supports pub/sub + service round-trips on the native (host) build (full service implementation in src/service.cpp); status events (liveliness, deadline-miss, etc.) are NOT wired to Cyclone listeners yet, and the service-availability probe is deferred (service_server_available returns NROS_RMW_RET_UNSUPPORTED — poll-with-retry instead of waiting on it). Wire-level interop with stock rmw_cyclonedds_cpp (Humble) works for topics and services. Full known-limitations list: docs/reference/cyclonedds-known-limitations.md.

The Cyclone DDS backend uses Eclipse Cyclone DDS, the same DDS implementation that ROS 2 ships with via rmw_cyclonedds_cpp. Built as a standalone C++ library at packages/rmw/cyclonedds/nros-rmw-cyclonedds/ that registers itself with the runtime through the C ABI vtable in nros-rmw-cffi.

How it works:

  1. The application (or platform) calls nros_rmw_cyclonedds_register() once before nros::init() — this is automatic when the consumer’s CMake build sets -DNROS_RMW=cyclonedds.
  2. The runtime stores the vtable pointer; subsequent nros::init() calls dispatch through it for session, publisher, subscriber, and service operations.
  3. dds_create_domain / dds_create_participant / dds_create_topic / dds_create_writer / dds_create_reader are invoked under the hood; Cyclone owns its own RX threads.
  4. ROS 2 nodes using stock rmw_cyclonedds_cpp interoperate directly — same wire protocol, same discovery, no key rewriting (unlike rmw_zenoh’s <domain>/<topic>/<type>/... scheme).

Key characteristics:

  • Pure-C++ backend (not a Cargo crate) — Autoware contributors can read and extend the wrapper using the same patterns as actuation_module/include/common/dds/.
  • ROS 2 Tier-1 wire compat — pinned to Cyclone DDS tag 0.10.5 to match ros-humble-cyclonedds 0.10.5 + ros-humble-rmw-cyclonedds-cpp 1.3.4.
  • Static ddsi_config via dds_create_domain_with_rawconfig skips the XML parser; embedded-friendly.
  • Discovery via SPDP multicast or unicast peer list (mirrors Cyclone’s standard config knobs).
  • Heap required (Cyclone uses malloc); BUILD_SHARED_LIBS=ON produces libddsc.so for POSIX, static link for embedded.
  • Services wired (create/recv/reply — service.cpp); the availability probe (service_server_available) and actions are still unsupported.
  • No status events yet — subscription_event_init / publisher_event_init / publisher_assert_liveliness slots are not wired to Cyclone listeners yet.

Build:

Contributors: the in-tree Cyclone DDS build/test lanes are in Per-Platform Contributor Lanes.

Each example picks its RMW via -DNROS_RMW=cyclonedds at configure time; the root CMakeLists.txt add_subdirectory’s packages/rmw/cyclonedds/nros-rmw-cyclonedds/ and links the resulting target into NanoRos::NanoRos. No build/install/ prefix, no find_package(NrosRmwCyclonedds) deleted both.

Availability follows from config — the build provisions Cyclone. Selecting cyclonedds (-DNROS_RMW=cyclonedds / [image.<id>] rmw = "cyclonedds") is your whole responsibility; nros_provide_cyclonedds() resolves the library: a prebuilt install you point at (-DCMAKE_PREFIX_PATH=<install> / -DCycloneDDS_DIR=), else self-provision from source — by default the pinned third-party/dds/cyclonedds submodule, or your own checkout via -DCYCLONEDDS_SOURCE_DIR=<path>. So a bare cmake/cargo build needs no just cyclonedds pre-step (that is a contributor-only recipe); freertos / threadx-rv64 / native examples build Cyclone on demand (sccache-accelerated), gated on the relevant cross toolchain. On a tier without the toolchain, the embedded-Cyclone tests are filtered out of test-all (skipped, not failed). idlc is a host tool, found on PATH (e.g. a ROS 2 install) or via -DIDLC_EXECUTABLE=. See docs/development/sdk-tiers.md § “CycloneDDS — self-provisioned in CMake”.

Known limitations. Cyclone DDS currently has a 2× CDR roundtrip per message, deferred status-event wiring, pending service request-id correlation, and incomplete Cortex-A/R Zephyr board support. See docs/reference/cyclonedds-known-limitations.md for the full list. ARMv8-R toolchain prep (Cortex-A 64-bit FVP, Cortex-R52 hardware) is in docs/reference/zephyr-armv8r-setup.md.

Comparison

AspectZenoh (rmw-zenoh)XRCE-DDS (rmw-xrce)Cyclone DDS (rmw-cyclonedds)
Client RAM ¹~16 KB+ (heap required)~3 KB (fully static)~32 KB+ (heap required)
Client Flash ¹~100 KB+~75 KB~150 KB+ (libddsc.so ~1.4 MB on POSIX, sized down on embedded link)
Bridge processrmw_zenohd (ROS 2’s zenoh router)Agent (protocol translator)None — RTPS multicast directly
Peer-to-peerNot as shipped — multicast/scouting compiled out (issue 0682)No (agent always required)Yes (RTPS native)
DiscoveryClient participatesAgent handles on behalfSPDP / SEDP on UDP multicast or static peer list
Entity creationClient creates directlyClient requests, agent createsClient creates directly
Transport optionsTCP, UDP, TLSUDP, serial (HDLC)UDP unicast + multicast (RTPS)
Heap allocationRequired (C-level)NoneRequired (Cyclone uses malloc)
ImplementationRust + zenoh-pico CRust + Micro-XRCE-DDS-Client CC++ wrapper over upstream Cyclone DDS C
ROS 2 interopVia rmw_zenoh_cpp + routerVia Agent + any DDS RMWDirect against rmw_cyclonedds_cpp (same upstream version)
Failure modeRouter crash = lose routingAgent crash = lose connectivityPeer goes offline = its samples stop arriving
C source files~100+28Upstream Cyclone (~600+ files, vendored unchanged via submodule)

¹ Footprint figures are order-of-magnitude planning numbers, not measurements — they vary with features, message set, and opt level. Size your own image with arm-none-eabi-size on your build; the RAM knobs are enumerated in the Static Pool Inventory.

Multi-backend binaries (bridges)

A single nano-ros binary can link more than one RMW backend and forward traffic between them. The bridge pattern is useful for:

  • Translating between protocols — a gateway node running zenoh ingress + DDS egress lets MCU fleets on zenoh-pico talk to an Autoware stack on Cyclone DDS without a separate translator.
  • Hard-real-time + best-effort split — a high-priority Node on Cyclone DDS for control loops, a low-priority Node on Zenoh for telemetry, both in one process.
  • Bringing up an XRCE Agent-free fleet — bridge XRCE devices to a Zenoh network so they look like first-class participants to stock ROS 2.

The pattern uses Executor::open_with_rmw("<name>", ...) to pin the primary session and node_builder("name").rmw("<other>").build() to open additional sessions on other backends. Both backends must be in the binary’s link line (Cargo manifest deps + register() call each).

Two worked examples declare the bridge in system.toml instead of writing it: examples/workspaces/bridge-cyclonedds/ (zenoh → Cyclone DDS) and examples/workspaces/bridge-xrce/ (zenoh → XRCE Agent). The hand-written form above has no example of its own; the walkthrough points at the e2e gateway fixtures that use it.

Full walkthrough: Cross-backend Bridges.

RMW Selection

RMW selection is a declared, language-agnostic, per-deploy value — you set it once in system.toml ([system].rmw, optionally overridden per image by [image.<id>].rmw) or via a CLI/build flag, and the toolchain lowers that declaration to each language’s native build mechanism: a Rust cargo rmw-<x> feature, or a CMake -DNROS_RMW cache var. The cargo feature and the CMake var below are the lowering targets the build uses — not the user-facing knob. A binary links the common rmw-cffi runtime plus exactly one backend; the linked backend self-registers through the nros_rmw_vtable_t C ABI, and the registry walker resolves it at Executor::open. See RFC-0031 for the full selection-and-lowering model and precedence rules.

Cargo.toml (Rust)

You write none of it. The board crate’s rmw-<x> feature is the lowered form of the declared RMW (RFC-0031’s phase-248 C5b amendment: the board crate is the selection point, and brings the backend and the platform), and the CLI writes it:

  • in a WORKSPACE, [image.<id>].rmw in the bringup’s system.toml is the declaration, and the Entry pkg’s generated manifest carries the lowered form;
  • in a single-package example, the same key in the example’s own system.toml, lowered into its generated <pkg>_nros_selection package (build/nros/selection, issue 1305). An example that runs on several backends declares one image per RMW and builds the one it names:
# system.toml
[system]
name = "native_talker"
rmw = "zenoh"
default_images = ["linux"]

[image_defaults]
board = "linux"

[image.linux]

[image.linux_cyclonedds]
rmw = "cyclonedds"
nros sync --image linux_cyclonedds && nros build linux_cyclonedds

The example’s Cargo.toml names only <pkg>_nros_selection = { path = "build/nros/selection" } — never rmw-<x> / ros-<edition> features. (The nros umbrella’s rmw-cyclonedds is a MARKER the generated package adds for a Cyclone image: the generic path then registers type descriptors.) No example calls <backend>::register() either: nros::main! boots through the board crate, whose rmw-<x> feature links the backend and registers it.

For C++ consumers, the declared RMW lowers to the CMake cache var:

cmake -S . -B build -Dnano_ros_ROOT="$(nros sdk-root)" \
    -DNROS_RMW=cyclonedds  # zenoh / xrce / cyclonedds

Zephyr

The image’s rmw in system.toml, like every other platform. The nano-ros module renders it into the Kconfig choice (CONFIG_NROS_RMW_ZENOH / _XRCE / _CYCLONEDDS, exactly one), so it is never written in prj.conf (phase-481). One image per backend, picked with -DNROS_IMAGE:

[image.zephyr_zenoh]
board = "zephyr"
rmw = "zenoh"
conf = ["prj-zenoh.conf"]       # Zephyr-native settings that backend needs

[image.zephyr_cyclonedds]       # Cyclone: Cortex-A/R and native_sim targets
board = "zephyr"
rmw = "cyclonedds"
conf = ["prj-cyclonedds.conf"]

See Configuring an image.

Cyclone DDS — per-platform configuration profile

The Cyclone DDS backend speaks raw RTPS over UDP multicast and unicast. The host networking stack on every RTOS needs IGMP enabled, adequate net- buffer pool sizing for the SPDP / SEDP discovery burst, and a way to join a multicast group. Concrete deltas per platform:

Zephyr (platform-zephyr)

# IGMP for SPDP multicast discovery (239.255.0.1:7400+).
CONFIG_NET_IPV4_IGMP=y

# Multicast group slots — RTPS uses up to 4 builtin groups per
# participant (SPDP + SEDP pubs / subs / topics).
CONFIG_NET_IF_MCAST_IPV4_ADDR_COUNT=4

# Net-buffer pools — defaults of 14 / 36 are too small for the SEDP
# burst when more than two service / action entities exist on each
# participant. Symptom of undersizing: runtime starvation as the
# discovery exchange backs up. Cortex_a9 has 512 MB SRAM; the cost
# is trivial.
CONFIG_NET_PKT_RX_COUNT=256
CONFIG_NET_PKT_TX_COUNT=128
CONFIG_NET_BUF_RX_COUNT=512
CONFIG_NET_BUF_TX_COUNT=256
CONFIG_NET_BUF_DATA_SIZE=512

# Heap — Cyclone DDS (malloc) + heapless mailboxes.
CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE=4194304
CONFIG_HEAP_MEM_POOL_SIZE=524288

On qemu_cortex_a9, additionally use a board overlay to bump the GEM driver’s RX / TX descriptor ring (default 32) so a brief drainer pause doesn’t immediately spill:

&gem0 {
    promiscuous-mode;
    rx-buffer-descriptors = <128>;
    tx-buffer-descriptors = <64>;
};

native_sim is not yet supported — Zephyr’s NSOS driver doesn’t forward IP_ADD_MEMBERSHIP to the host kernel (no IPPROTO_IP case in nsos_adapt_setsockopt). Use qemu_cortex_a9 for DDS-on-Zephyr testing instead.

FreeRTOS + lwIP (platform-freertos)

// FreeRTOSConfig.h / lwipopts.h
#define LWIP_IGMP            1   // SPDP multicast
#define LWIP_SO_RCVTIMEO     1   // Cyclone DDS recv timeouts
#define LWIP_BROADCAST       1
#define IP_REASSEMBLY        1   // RTPS DATA_FRAG fragments
#define MEMP_NUM_NETBUF      32  // discovery burst headroom

NuttX (platform-nuttx)

CONFIG_NET_IGMP=y
CONFIG_NET_BROADCAST=y
CONFIG_NET_UDP_NRECVS=4
CONFIG_NET_RECV_TIMEO=y

ThreadX + NetX Duo (platform-threadx)

tx_user.h / nx_user.h:

#define NX_ENABLE_IGMPV2          // IGMP v2 for RTPS multicast
// NetX BSD layer init must call `bsd_initialize` early — required
// for `setsockopt(SO_RCVTIMEO)` support.

Bare-metal smoltcp (platform-mps2-an385, platform-stm32f4,

platform-esp32-qemu)

#![allow(unused)]
fn main() {
// Bridge config in the board crate.
let mut config = smoltcp::iface::Config::new(...);
config.multicast_groups = vec![Ipv4Address::new(239, 255, 0, 1)];
// MulticastConfig::Strict in smoltcp 0.x; the bridge must expose
// a `join_multicast_group` API.
}

POSIX (platform-posix)

No configuration needed — the kernel does IGMP and setsockopt support natively. Just ensure the loopback interface is up (default).

When to Use Which

Choose Zenoh when:

  • You need ROS 2 interop with rmw_zenoh_cpp (the recommended ROS 2 middleware for Jazzy+)
  • You want peer-to-peer communication without any bridge process
  • Your MCU has at least ~16 KB of heap and ~100 KB of flash
  • You are using TCP or UDP networking
  • You want simpler deployment (the router is a single process started with ros2 run rmw_zenoh_cpp rmw_zenohd)

Choose XRCE-DDS when:

  • Your MCU has very limited RAM (under 8 KB available for middleware)
  • You need serial (UART) transport – useful for MCUs without networking hardware
  • You are integrating with an existing micro-ROS or DDS deployment
  • You want zero heap allocation on the MCU side

Choose Cyclone DDS when:

  • You want direct, brokerless ROS 2 interop with no router and no Agent
  • Your MCU has at least ~32 KB RAM (heap for Cyclone + RTPS state) and a network stack with IGMP
  • You’re integrating with a DDS-based ROS 2 deployment running stock rmw_cyclonedds_cpp and want full RTPS wire-compat without protocol translation
  • Your tolerance for failure is “samples from peer X stop arriving” rather than “the whole router goes down”
  • Note: Cyclone DDS is pub/sub-only today (no services / actions yet)

Any of the three works well for:

  • Standard pub/sub and service patterns
  • Integration with ROS 2 desktop nodes
  • QEMU-based development and testing

Multi-Node Project Layout

You built the single-file talker in examples/linux/rust/talker/: one main.rs, one Cargo.toml, one package — open a terminal, run cargo run, done. That shape covers a lot of ground: demos, driver tests, single-purpose microcontroller apps.

At some point it stops being enough.

When you outgrow one app

Three common triggers:

  1. Two or more nodes. You want a talker and a listener, or a sensor driver alongside a control loop. Splitting them into one entry binary per node file works until you need to tune their wiring or deploy them to different boards.

  2. A shared launch topology. You want to describe once how nodes are named, remapped, and parameterised — and reuse that description across a dev laptop, a hardware bring-up board, and a sim target.

  3. Multiple deploy targets. The same talker logic goes on a native Linux host for integration testing and on an STM32F4 for production. The node logic is identical; only the boot and board differ.

  4. Mixed implementation languages. You want to keep a C driver or legacy C node alongside C++ or Rust ones. The Node-pkg register ABI is language-neutral, so a C Node pkg links into the same binary as C++ or Rust Node pkgs.

That’s when you split your project into a multi-node workspace.

Canonical layout

Start with the whole project before diving into the parts:

my_robot_ws/
├── .colcon_workspace           # the tracked marker: this directory IS a workspace root
└── src/
    ├── talker_pkg/             # Node pkg: reusable node logic, no main()
    ├── listener_pkg/           # Node pkg: another reusable node
    └── robot_bringup/          # Bringup pkg: launch XML + system.toml + [image.*]

Two kinds of package, and that is the whole tree. A workspace has no root build file at all — no Cargo.toml, no CMakeLists.txt — the way a colcon workspace is just a directory of packages (RFC-0098 D9). There is no binary package to write either: from the [image.*] table in the Bringup pkg, nros build generates the entry and the build root that compiles it, both under build/. The cargo root is the generated entry itself, build/<coordinate>/<entry>/Cargo.toml; the cmake root is build/<coordinate>/CMakeLists.txt. Nothing generated is ever written beside your source.

RoleOwnsDoes not own
Node pkgPublishers, subscriptions, timers, services, actions, callback bodiesBoard choice, launch topology, main()
Bringup pkgWhich nodes run, names, remaps, parameters — and the [image.*] rows saying which programs get built out of themCompiled code

A typical product has many Node pkgs, one Bringup pkg per logical system, and one [image.*] row per board or deploy target. The same talker_pkg and listener_pkg are linked into a native host image for integration testing and a Cortex-M image for hardware, with nothing duplicated between them.

Reading order

This group starts broad and then drills into each part:

  1. Project layout — this page: when to split and how the roles fit.
  2. Node packages — reusable node libraries with nros::node!.
  3. Bringup packages — launch XML, system.toml, remaps, parameters.
  4. Images — the [image.*] row that becomes a program, and the one platform that still needs a package of its own.
  5. C / C++ multi-node workspaces — the same structure through CMake.
  6. Mixed-language workspaces — C Node pkgs beside C++ and Rust ones.
  7. Role reference — metadata fields and macro forms in reference style.

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:

nros setup linux --rmw zenoh

The three roles in practice

Node pkg — a lib crate that contains one node’s logic. It declares nros::node!(T); its Cargo.toml carries Rust-toolchain facts only, and the node’s identity — which package, which class, what it is called — is a [[component]] row in the Bringup pkg’s system.toml. It has no fn main() — no package you write has one. One Node pkg per node. Think of it as a composable building block: the same talker_pkg lib can be assembled into a native binary and an embedded binary without any source change.

Bringup pkg — a purely declarative directory that owns the launch topology and the images built from it. It contains a package.xml, a system.toml (which nodes run, how they’re wired, and an [image.<id>] row per program), and a launch/ directory with ROS 2 launch XML. No Cargo.toml, no compiled code. Naming convention <system>_bringup, matching nav2 / Autoware / turtlebot3.

Image — not a package. [image.linux] board = "linux" is a row in that system.toml, and nros build linux turns it into a program: it generates, under build/, the entry that links the Node pkgs and the board crate and hands control to the nano-ros runtime. A second board is a second row, not a second directory.

Two commands, in this order, and they are the same two everywhere in this group:

nros sync      # generated message bindings + the generated build settings
nros build     # every [image.*]; or name one: nros build linux

nros sync is yours to run once per workspace, and again whenever you edit a .msg or move the checkout. A workspace that has never been synced makes nros build stop at preflight with one line naming it.

The app-node shape you already know (examples/linux/rust/talker/) is a single package that is both the logic and the boot point — no bringup and no entry, but the same system.toml beside its Cargo.toml, stating its board in an [image.*] row (RFC-0098 D3). nros::main!() reads it. That fusion is fine — and encouraged — for single-node work. Only split when you actually need the flexibility.

ROS 2 ↔ nano-ros command map

If you’re coming from ROS 2, here’s the mapping of the commands you already know:

ROS 2nano-rosNotes
ros2 pkg createnros new node <name>scaffolds a Node pkg and adds its [[component]] row
colcon buildnros sync once, then nros build <image>resolves the image, generates the entry and its build root under build/, hands off to cargo / cmake / west / idf.py
ros2 launch <pkg> <file>run the image’s binarythe image IS the launch product; the old launch wrapper was removed in nros 0.5.0
(plan/validate)nros plan → nros checkresolve + statically check the topology
ros2 run <pkg> <exe>run the image’s binaryone [image.*] row per board

nros build hands off rather than taking over: it generates what the build system needs and then runs your platform’s own tool (cargo, cmake, west, idf.py), so compiler errors stay the compiler’s, unchanged. Running the tool yourself keeps working. One image = one binary = one process; multi-process orchestration (the equivalent of several ros2 launch nodes in separate processes) is several images plus a shell script or tmux session, not a CLI verb.

The app-node shape stays valid

There is no obligation to restructure. If your project is one node on one board, the app-node shape (src/main.rs + one package = both logic and boot) is perfectly idiomatic and has no runtime penalty. The three-role split is a tool for when you need it, not a gatekeeping requirement.

Where to go next

Walk through the multi-node project model step by step:

  1. Node packages — scaffold and implement Node pkgs with nros::node!.
  2. Bringup packages — declare your topology in a Bringup pkg.
  3. Images — declare the [image.*] row that becomes a runnable program.
  4. C / C++ multi-node workspaces — use the same project shape with CMake.

For C Node pkgs alongside C++ and Rust ones, see Mixed-language workspace.

For the full API reference covering all three roles, see Role reference.

Node packages

This page is part of the Multi-Node Projects group. Previous: Project layout — Next: Bringup packages

A Node pkg is the unit of reusable behaviour in a multi-node workspace. It is a Rust library — a [lib] crate — that implements one node and registers it with nros::node!(T). An image is what boots the binary; the Node pkg is what runs inside it.


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:

nros setup linux --rmw zenoh

Scaffolding a Node pkg

Use nros new to create a skeleton:

nros new node talker_pkg

nros new node writes the package under src/ and adds its [[component]] row to the bringup. There is no member list to maintain and no workspace root to maintain it in: a workspace is a directory of packages (RFC-0098 D9), and nros build discovers them under src/, generating the entry that names them as path dependencies under build/.

(nros new <name> --platform linux --lang rust is the other verb, and a different output — a standalone runnable project that states its own board in its own system.toml. Use it for a copy-out example, not for a workspace member.)


Node pkg anatomy

A Node pkg has three files:

src/talker_pkg/
├── package.xml          # ROS 2 manifest — <exec_depend> per message package
├── Cargo.toml           # [lib] + dependencies — Rust-toolchain facts only
└── src/lib.rs           # impl Component + ExecutableNode; ends with nros::node!(Talker);

No fn main() here — a Node pkg is a library linked into the entry that nros build generates for an image. That entry’s macro-generated runtime owns nros::init, executor open, RMW registration, and the spin/yield loop.


Declaring the node — the [[component]] row

A Node pkg’s Cargo.toml is an ordinary Rust manifest: a [lib] target and its dependencies, and nothing that describes a deployment. Who this node is — which package provides it, which type implements it, what it is called — is one [[component]] row in the Bringup pkg’s system.toml, beside the rows for every other node in the system (RFC-0098 D5). From examples/workspaces/rust/src/demo_bringup/system.toml:

[[component]]
pkg = "talker_pkg"
class = "talker_pkg::Talker"
name = "talker"
FieldPurpose
pkgThe package that provides the node (matches <name> in its package.xml)
classFully-qualified path to the type that impls Node + ExecutableNode
nameDefault ROS 2 node name (remappable at launch)
dispatchOptional — the dispatch shape, when it is not the default

The node package’s own manifest REGISTERS the class it exports, in a [package.metadata.nros.node] table. That is not a deployment and not a second copy of the row above: it is the Rust spelling of node registration, the counterpart of a C/C++ package’s nros_components_register_node(<target> PLUGIN pkg::Class …) in its CMakeLists.txt (itself ROS’s rclcpp_components_register_node). The metadata pipeline reads it — nros sync’s probe, nros check’s dispatch lint — and a package registers once however many bringups launch it, whereas a [[component]] row is one bringup’s choice to run it. It names no namespace: that is the launch file’s to give, as it is for a C/C++ node (issue 1289).

The manifest beside src/lib.rs is then just this:

[lib]
path = "src/lib.rs"

[package.metadata.nros.node]
class = "talker_pkg::Talker"
name = "talker"

[dependencies]
nros = { path = "../../../../../packages/api/nros", default-features = false,
         features = ["alloc", "rmw-cffi"] }

A Node pkg names no platform and no RMW. alloc is the universal baseline and rmw-cffi is the vtable seam; the concrete backend and the platform both flow in from the board crate the generated entry depends on, via cargo feature unification. That is what lets one talker_pkg deploy to a Linux host and a Cortex-M board without a fork — the difference between them is an [image.*] row, not a second copy of anything.


src/lib.rs — the node implementation

A Node pkg implements two traits: Component (declarative registration) and ExecutableNode (per-callback body), then calls nros::node! to export the trampolines the entry macro expects.

The node is the same type everywhere. ctx.create_node(...) returns an nros::Node, the type a standalone program gets from executor.create_node(name), with rclrs’s methods: create_publisher, create_subscription(topic, callback), timers, name(), logger(), the graph queries. A function written against &mut nros::Node serves both. What a component adds is the DeclarativeNode extension (bring it into scope with use nros::DeclarativeNode;): constructors that declare an entity by stable id and bind its callback by NAME, which is what lets the board dispatch callbacks from its own tasks and lets nros sync measure the component without running it.

Here is the essential shape, drawn from examples/workspaces/rust/src/talker_pkg/src/lib.rs (see that file for the full worked version):

#![allow(unused)]
fn main() {
use nros::{
    CallbackCtx, ExecutableNode, Component, NodeContext, DeclarativeNode, NodeOptions, NodeResult,
    TimerDuration,
};

pub struct Talker;

impl Component for Talker {
    const NAME: &'static str = "talker";

    // What this class's per-instance registries hold, in order: publishers,
    // service servers, service clients, action clients, action servers.
    const ENTITY_BOUNDS: nros::EntityBounds = nros::EntityBounds::exact(1, 0, 0, 0, 0);

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        let mut node = ctx.create_node(NodeOptions::new("talker"))?;
        let chatter = node.create_publisher_for_topic::<MyMsg>("/chatter")?;
        let _timer =
            node.create_timer_for_callback_name("on_tick", TimerDuration::from_millis(1000))?;
        node.callback_for_name("on_tick")
            .publishes_entity(&chatter)?;
        Ok(())
    }
}

impl ExecutableNode for Talker {
    type State = i32;

    fn init() -> Self::State { 0 }

    // See the full example for the callback body.
}

nros::node!(Talker);   // <-- exports the trampolines; this is the last line
}

Key points:

  • Component::register is declarative — it runs once at startup to declare publishers, subscriptions, timers, and callback edges. No message bytes flow here.
  • ExecutableNode::on_callback is the body — called by the image’s executor each time a callback fires. state is your per-node mutable storage.
  • nros::node!(Talker) must be the last public API call in the file. It generates the extern "C" trampolines the entry macro imports.
  • There is no fn main() in a Node pkg.
  • ENTITY_BOUNDS is static RAM you either pay or do not (issue 0857). nros::node! emits a static sized from it, and a publisher slot carries a loan arena — so the default (NROS_RUNTIME_MAX_CELL_ENTITIES, 8 per kind) measured 50,824 bytes of .bss for a one-service component against 568 bytes for the same component declaring what it creates. Count the five kinds in your own register(); a timer or a subscription needs no slot here (they claim an executor callback slot instead). Declaring FEWER than register() creates is a registration error at boot, never a silent drop, so the number is safe to make exact.

package.xml — the ROS 2 manifest

A Node pkg that uses generated message types lists them as <exec_depend> entries. Minimal example:

<?xml version="1.0"?>
<package format="3">
  <name>talker_pkg</name>
  <version>0.1.0</version>
  <description>Talker node</description>
  <maintainer email="dev@example.com">Developer</maintainer>
  <license>MIT OR Apache-2.0</license>

  <depend>std_msgs</depend>

  <export>
    <build_type>ament_cargo</build_type>
  </export>
</package>

If your node uses no external message packages, the <depend> line can be omitted.


Services & clients

Server (responds to requests)

Declare the service in Component::register and handle requests in the node body.

  • Rust — declare a service edge in register; requests dispatch into ExecutableNode::on_callback like any other callback (read the request and write the reply through the CallbackCtx).

  • C++/C (the configure(Node&) component shape) — bind a typed member with rclcpp::bind_service:

    // Response on_request(const Request&) — no hand-rolled CDR.
    AddTwoInts::Response on_add(const AddTwoInts::Request& req) {
        AddTwoInts::Response r; r.sum = req.a + req.b; return r;
    }
    ::rclcpp::Result configure(::rclcpp::Node& node) {
        return ::rclcpp::bind_service<AddTwoInts, MyServer, &MyServer::on_add>(
            node, "/add_two_ints", this);   // service-type name from AddTwoInts::TYPE_NAME
    }
    

    bind_service deserializes the request into the generated Svc::Request, calls your typed member, and serializes the returned Svc::Response — no manual byte offsets. (bind_service_raw<&method> is still available for the rare hand-rolled case.)

Client (issues requests) — call from tick, not on_callback

A node cannot make a blocking service/action client call from on_callback: that runs while the executor is mid-dispatch, so a blocking call would deadlock the spin. Client calls go on the separate per-spin hook ExecutableNode::tick(&mut TickCtx), which the executor runs once per spin_once between callback dispatch:

#![allow(unused)]
fn main() {
fn on_callback(state: &mut State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
    // ...decide a request is needed; just arm a flag / stash args:
    state.want_call = true;
}

fn tick(state: &mut State, ctx: &mut TickCtx<'_>) {
    if state.want_call {
        state.want_call = false;
        let reply = ctx.call_for_name("add_two_ints", &request);  // blocking, safe here
        // ...use reply
    }
}
}

The two-surface pattern (arm in on_callback, call in tick) is the supported way to drive a client from a declarative node. See examples/workspaces/rust/.../service_client_pkg for the reference.


Building

You never build a Node pkg on its own — you build an image, and it links the Node pkgs its launch file names:

# From examples/workspaces/rust/ (or your workspace root):
nros sync
nros build linux

nros sync writes the generated message crates and the build settings each image needs; run it once per workspace, and again after editing a .msg.

No per-Node-pkg invocation is needed, and no cross-compile flags either: the image’s board carries the rustc triple, so building for an embedded target is another row rather than another command line.

[image.freertos]
board = "mps2-an385-freertos"
nros build freertos

Next steps

  • Bringup packages — wire the Node pkgs together into a topology.
  • Images — the [image.*] row that becomes a binary, on real hardware or a host process.
  • Role reference — the full reference for the package roles, metadata fields, and the nros::main!() forms.

Bringup packages

A Bringup pkg is the declarative glue that ties your Node packages together into runnable programs. It owns the launch file, the wiring between nodes, and the [image.*] table saying which programs get built out of them — all without any compiled code of its own.

Pre-requisite: You’ve scaffolded your Node packages following the Node packages guide. This page adds the demo_bringup layer that turns them into something you can build and run.


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:

nros setup linux --rmw zenoh

What a Bringup pkg is

A Bringup pkg is pure declarative — no Cargo.toml, no CMakeLists.txt, no src/. Its job is to describe which nodes run, how they’re wired, and where they deploy. Naming convention: <system>_bringup (aliased <system>_launch), matching nav2 / Autoware / turtlebot3.

A multi-node workspace needs exactly one per logical system. It is where [image.*] lives, so it is the package that gives a workspace images to build — nros build reads that table to decide what programs exist. A workspace with no bringup still builds, colcon-style: every package in dependency order, each into its own build/<pkg>/, with no images. (The single-package copy-out shape under examples/<platform>/<lang>/ has no bringup package, but it writes the same system.toml beside its own Cargo.toml or CMakeLists.txt — one schema either way, RFC-0098 D3 — and boots itself with a bare nros::main!().)


Anatomy

src/demo_bringup/
├── package.xml          # ROS 2 manifest; <exec_depend> per node pkg
├── system.toml          # [system] + [[component]] + [image.<id>] + [host.<name>]
├── launch/
│   └── system.launch.xml   # ROS 2 launch schema, verbatim
└── config/                 # optional — params.yaml, per-target overrides

system.toml — node wiring + deploy targets

The system.toml is the machine-readable topology. It lists every node the system runs, its class path, and one or more deploy targets (which board, which RMW, which domain).

Below is a minimal two-node example adapted from the real fixture at packages/testing/nros-tests/fixtures/orchestration_e2e/demo_pkg_bringup/system.toml:

[system]
name = "demo"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "talker_pkg"
class = "talker_pkg::Talker"
name = "talker"

[[component]]
pkg = "listener_pkg"
class = "listener_pkg::Listener"
name = "listener"

[image.linux]
board = "linux"

[host.native]

Key fields:

FieldMeaning
[system] nameLogical system name; used by nros plan/check
[system] rmwDefault RMW for all components (zenoh, xrce, cyclonedds)
[system] domain_idROS 2 domain (compile-time on embedded, runtime env on host)
[[component]] pkgThe ROS package name (matches <name> in package.xml)
[[component]] classFully-qualified Rust type (crate::TypeName)
[[component]] nameNode name at runtime
[image.<id>]A buildable image; read by nros build, nros check and Entry codegen
[image.<id>] boardThe board this image is built for; its descriptor supplies the rustc triple
[host.<name>]A machine nodes run on. No nodes means every node
[board_config.<board>]Site facts for that board (SDK roots, netstack)

For multi-domain setups or cross-domain bridges add [[domain]] and [[bridge]] sections — see docs/design/0024-multi-node-workspace-layout.md §11 for the full schema.


launch/system.launch.xml — ROS 2 launch schema

The launch file uses the ROS 2 launch XML schema verbatim — nano-ros reads it with the same parser so existing nav2/Autoware/turtlebot3 XML pastes in and Just Works.

<launch>
  <node pkg="talker_pkg" exec="talker" name="talker"/>
  <node pkg="listener_pkg" exec="listener" name="listener"/>
</launch>

v1 tag set

TagPurpose
<launch>Root element
<arg name="…" default="…"/>Declare a launch argument
<node pkg="…" exec="…" name="…"/>Instantiate a node
<param name="…" value="…"/>Set one parameter inline (nested inside <node>)
<param from="…param.yaml"/>Load a ROS parameter FILE (nested inside <node>)
<remap from="…" to="…"/>Topic/service remapping (nested inside <node>)
<group ns="…">Namespace a group of nodes
<include file="…"/>Nest another launch file

Substitutions

  • $(find <pkg>) — resolves to the package’s install/source path
  • $(var <arg>) — expands a launch argument
  • $(env <name>) — reads an environment variable

A richer example using args and remapping (taken from the real fixture):

<launch>
  <arg name="talker_name" default="talker" />

  <node pkg="talker_pkg" exec="talker" name="$(var talker_name)" output="screen">
    <param name="rate_hz" value="25" />
    <remap from="chatter" to="/chatter" />
  </node>

  <node pkg="listener_pkg" exec="listener" name="listener"/>
</launch>

Parameter files

<param from="…"/> takes a standard ROS parameter file. Relative paths resolve against the launch file’s own directory.

# config/talker.param.yaml
/**:                      # applies to every node
  ros__parameters:
    use_sim_time: false
talker:                   # applies to the node named `talker` only
  ros__parameters:
    rate_hz: 25
    limits:
      max_accel: 1.5      # reaches the node as `limits.max_accel`
<node pkg="talker_pkg" exec="talker" name="talker">
  <param from="config/talker.param.yaml"/>
  <param name="rate_hz" value="50"/>   <!-- wins over the file -->
</node>

Precedence matches ROS: the /** wildcard block first, then the node-specific block, then inline <param name= value=> last. A node block may be keyed by fully-qualified name (/ns/talker), bare name (talker), or /**/talker.

These values are resolved and baked into the generated entry at build time — embedded targets do no runtime file loading, the same approach the domain ID uses. Editing the YAML re-runs codegen. A from= naming a file that doesn’t exist is a build error, not a silent skip.

Note: the baked values are seeded into the node’s parameter store. Two capability axes in [system] features decide what else the image gets:

declaredstore (declare_parameter, launch seeds, --ros-args -p)ros2 param services
paramsyesno — nothing is served, and the image claims no queryable for it
param_servicesyessix per node (get, set, list, describe, …)

Pick params on a small target that only needs its nodes to read their configuration: the six services cost a queryable each per node, plus their buffers. What it gives up: ros2 param get|set|list|describe|dump finds nothing for the image’s nodes, and the use_sim_time runtime switch has nothing to set it. Which parameters exist is a contract fact; whether they are served is this bringup fact.

Note: Python .launch.py files ARE resolved by nros sync (the RFC-0060 resolver executes them with its bundled CPython; $(find-pkg-share) against ROS packages needs a sourced AMENT_PREFIX_PATH). The XML schema above is the native authoring format.


package.xml

A standard ROS 2 manifest. List each Node package as an <exec_depend>:

<?xml version="1.0"?>
<package format="3">
  <name>demo_bringup</name>
  <version>0.1.0</version>
  <description>Bringup package for the demo system</description>
  <maintainer email="you@example.com">Your Name</maintainer>
  <license>Apache-2.0</license>

  <exec_depend>talker_pkg</exec_depend>
  <exec_depend>listener_pkg</exec_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

No <build_depend> entries — there is nothing to compile.


Workflow: sync → check → run

Once your Bringup pkg is written, nros sync prepares what the build needs, nros check validates the declarations, and nros build turns one of its images into a program:

# 1. Sync. Once per workspace, and again after editing a .msg: it writes the
#    generated message crates and each image's generated build settings.
nros sync

# 2. Lint the bringup pkg (pure-declarative check — no Cargo.toml, stray files, etc.)
nros check --bringup src/demo_bringup

# 3. Lint the whole workspace (pkg/class rows, duplicate system.toml, etc.)
nros check --workspace .

# 4. Build an image. With no name, `nros build` lists what this workspace declares.
nros build linux

# 5. Run it. Zenoh needs a router first, in another shell:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd &

Sync comes first because everything after it reads what it wrote. A workspace that has never been synced makes nros build stop at preflight with one line naming it:

Error: missing prerequisites for this build:
  - generated message bindings (this workspace has never been synced)
      run: nros sync

The binary boots every node the launch file names, composed into one process. It lands under build/, never beside your source: build/<coordinate>/cmake/ on the cmake driver, build/<coordinate>/<entry>/target/ on the cargo one.

Both nros check forms pass for the canonical template at examples/workspaces/rust/.

Caveat — nros plan with this template

  • nros plan demo_bringup resolves a topology into plan.json for static type/QoS checks, but it currently requires pre-collected source-metadata sidecars (record.json + per-pkg _metadata/*.json). The automatic metadata-build path (nros metadata --build) is not yet wired for lib-only Node pkgs, so nros plan does not produce a plan straight from this template. See packages/testing/nros-tests/fixtures/orchestration_e2e/ for the pre-collected-sidecar pipeline.

The canonical template README at examples/workspaces/rust/README.md is the source of truth for the current CLI state.


Runnable copy-out

examples/workspaces/rust/ is the canonical Rust workspace that pairs with this guide. Copy the whole directory out and rename the packages, then:

nros setup linux
nros sync            # once per workspace — message bindings + build settings
nros build linux

nros build walks the packages, resolves the image, checks the toolchain, generates the entry and the build root that compiles it — both under build/, neither beside your source — and hands off to cargo. nros check still works and is worth running; it is just no longer something you have to type.

The workspace README at examples/workspaces/rust/README.md documents the exact CLI commands that are verified green today.


Naming a launch file from an entry

The nros::main! macro’s launch = argument names the bringup package and, optionally, a launch file within it — the SAME inputs you author, and the same pair an [image.*] row names. You will read this in a generated entry, and write it yourself only in a single-package project or a materialized one. The build resolves the pair to a SystemModel under <workspace>/build/nros/models/ (run nros sync, or let the build system drive it); you never reference the model file, though you can inspect it there:

#![allow(unused)]
fn main() {
// Multi-node: the bringup's default launch (system.launch.xml)
nros::main!(launch = "demo_bringup");

// A named launch file in the bringup pkg
nros::main!(launch = "demo_bringup:sim.launch.xml");

// A launch-argument binding, declared as [[model]] in system.toml
nros::main!(launch = "demo_bringup:multihost.launch.xml", args = [("host", "robot1")]);
}

The Images page covers the macro forms and the generated entry in full.


Where to go next

  • Images — the [image.*] row that becomes a program
  • Role reference — full reference for the package roles and the image table
  • Project layout — start here if you haven’t read it yet

Images

An image is one buildable program: a topology, a board, and a backend. You declare it as a row in your Bringup pkg’s system.toml —

[image.linux]
board = "linux"

— and build it by name:

nros sync
nros build linux

There is no package to write. nros build discovers your Node pkgs, reads the [image.*] table, and generates the entry that boots the topology, under build/. An image is a row, not a directory.

That is the whole model. The rest of this page is what goes in the row, where the generated pieces land, and the one case that still needs a package of its own.

Prereqs

Pick one path from a fresh checkout:

A. Front door (bare machine OK — no Rust):

./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 (the canonical first image target; for Zephyr / FreeRTOS / ESP32 swap in the matching nros setup board):

nros setup linux --rmw zenoh
nros sync                     # generated message bindings, once per workspace

What a workspace looks like

The four reference workspaces in examples/workspaces/ all have the same shape. Node pkgs, one Bringup pkg, and nothing else:

examples/workspaces/c/
├── .colcon_workspace         # the tracked marker: this directory IS a workspace root
├── .gitignore
├── README.md
└── src/
    ├── talker_pkg/           # Node pkg
    ├── listener_pkg/         # Node pkg
    ├── service_server_pkg/   # Node pkg
    ├── … three more …
    ├── demo_bringup/         # Bringup pkg: package.xml + system.toml + launch/
    └── zephyr_entry/         # the one exception — see below

No CMakeLists.txt, no Cargo.toml at the root — and none is generated there either. A workspace is a directory of packages, exactly as a colcon workspace is, and everything the build produces lives under build/, dist/ and log/ (RFC-0098 D9). The tracked marker that this directory is a workspace root is .colcon_workspace.

And no entry packages, except the Zephyr one. Fourteen images ship out of that directory. nros build with no argument lists them:

$ cd examples/workspaces/c
$ nros build
Error: this workspace declares 14 images and no default.

  demo_bringup:freertos
  demo_bringup:freertos_posix
  demo_bringup:linux
  demo_bringup:linux_action_client
  demo_bringup:linux_action_server
  demo_bringup:linux_cyclonedds
  demo_bringup:linux_robot1
  demo_bringup:linux_robot2
  demo_bringup:linux_service_client
  demo_bringup:linux_service_server
  demo_bringup:linux_xrce
  demo_bringup:nuttx
  demo_bringup:threadx
  demo_bringup:zephyr

  build one:   nros build freertos
  build all:   nros build --all
  or declare:  [system] default_images = ["freertos"]

Fourteen programs, one launch tree, six node packages, one entry directory. Under the old shape that was fourteen directories.

Declaring an image

Images live in the Bringup pkg beside the topology they boot — examples/workspaces/c/src/demo_bringup/system.toml:

[image_defaults]
rmw = "zenoh"

[image.linux]
board = "linux"

[image.freertos]
board = "mps2-an385-freertos"

[image.freertos_posix]
board = "freertos-posix"
# NOT the `[image_defaults]` zenoh. zenoh-pico's FreeRTOS backend is lwIP-only
# and this board has no lwIP.
rmw = "cyclonedds"

[image.linux_service_server]
board = "linux"
launch = "service_server.launch.xml"

[image.linux_robot1]
board = "linux"
launch = "multihost.launch.xml"
args = { host = "robot1" }

[image.zephyr]
board = "zephyr"
conf = ["prj-zenoh.conf"]

Read it as a table of variants over one topology. linux and linux_cyclonedds differ in a backend; linux and linux_service_server differ in a launch file; linux_robot1 and linux_robot2 differ in one launch argument. Nothing is duplicated to express any of that.

KeyMeaning
boardThe nano-ros board id — resolved through packages/boards/board-support.toml, which supplies the rustc triple, the platform, and the framework’s own board string
launchThe launch file this image bakes, relative to the Bringup pkg. Absent ⇒ [system] default_launch
argsLaunch arguments bound at resolve time ({ host = "robot1" }) — how an image selects one machine out of a multi-host launch tree
rmwBackend for this image. Absent ⇒ [system] rmw
panicRFC-0077 panic policy (platform | halt | own), forwarded to the generated entry
profileA cargo / CMake build profile name
confExtra framework config fragments, in order (Zephyr prj-*.conf)
featuresCapability axes for this image, over [system]’s list
entryThe application package — normally omitted; see the exception

Switching a workspace image to another board is that one board line and nros sync — nothing in any package you wrote mentions a board crate, because the entry that depends on it is generated (RFC-0098 D6). A single-package leaf is its own entry and does not yet get that: see Role reference.

[image_defaults] is the base every block folds over (RFC-0065 D5.1): scalars are replaced by the specific block, args merges, conf and features concatenate. Without it an eight-image workspace repeats its RMW eight times, and eight copies of one fact is how they start disagreeing.

[system] default_images = ["linux"] is what a bare nros build then builds.

What nros build generates

Everything it generates lands under build/ — build output, gitignored, none of it yours to edit:

driverwhere the entry landswhat roots the build
cargoa real package at <ws>/build/<coordinate>/<image_id>_entry/, path-depending on your Node pkgsits own Cargo.toml. That IS the cargo root; the binary lands at …/<image_id>_entry/target/[<triple>/]<profile>/<image_id>_entry
cmakea nano_ros_add_executable(…) call in <ws>/build/<coordinate>/CMakeLists.txt, one per image sharing the coordinatethat generated CMakeLists.txt; the binary lands in <ws>/build/<coordinate>/cmake/

An earlier shape kept the cargo root at <ws>/Cargo.toml, on the grounds that cargo resolves a package’s workspace by walking up and refuses a member above its root. That constraint binds only while a workspace exists: with no root manifest anywhere there is nothing to walk up to, and a path dependency need not sit below anything. So the generated entry is the root, and the workspace keeps no build file of its own (RFC-0098 D9, superseding RFC-0065 D3).

Beside the generated entry sits nros-cargo.toml, the settings file nros sync writes per image (RFC-0098 D1): the board’s rustc triple and link flags, that image’s target-dir, the resolved [env], and the in-repo patch rows. Cargo reads it through --config, which is why each image compiles with its own settings and nothing generated ever appears beside a package — no leaf .cargo/config.toml, no board projection to commit.

The coordinate is the platform and the RMW, plus — for cmake, which pins one compiler per configure — the board. So [image.linux] in examples/workspaces/c configures build/posix-zenoh-linux/ into build/posix-zenoh-linux/cmake/ and leaves its binary at build/posix-zenoh-linux/cmake/linux_entry. On the cargo driver nros build names the entry package it wrote as it goes:

nros build:   entry → …/build/posix-zenoh/linux_entry

The generated cmake root lists every image on that coordinate, not just the one being built: what a coordinate contains is a property of the workspace, and making it depend on which image you asked for would reconfigure on every switch.

An image with its own configuration gets its own configure

One cmake configure builds the nano-ros runtime (nros-c / nros-cpp and the RMW) once for every image on it, so a knob only one image states — a [image.<id>.knobs] row such as [knobs.log] max_level = "warn" — would have no build to land in. So an image whose resolved configuration differs gets its own configure, keyed by a hash of that configuration (RFC-0098 D12, phase-481 W4):

[image.plain]            # states nothing -> build/posix-zenoh-linux/
board = "linux"

[image.warn]             # -> build/posix-zenoh-linux-cfga50aa93c83/
board = "linux"

[image.warn.knobs.log]
max_level = "warn"

[image.warn.knobs.executor]
max_cbs = 13

(packages/testing/nros-tests/fixtures/image_config_ws; each binary prints what its runtime compiled in.) Images whose configuration is the SAME share one configure, -cfg suffix and all, and a workspace whose images state nothing keeps today’s build/<coordinate>/ paths exactly. The cost is one more configure and one more runtime build per distinct configuration — the reason to put a knob that every image on a board shares in the board’s [knobs] instead, where it changes no image’s hash. nros image-facts prints each image’s cmake_build_dir; never derive it by hand.

On Zephyr the same env rows are rendered into the image’s Kconfig fragment instead (see Configuring an image).

The generated entry is the same few lines you would have written. RFC-0065 D4 measured them before deciding to derive them: every Rust entry in the tree was ≤ 6 non-comment lines, every embedded C/C++ entry had zero source files. Three parts, all derivable —

partderived from
the no_std / no_main shellthe board’s entry_kind
board boilerplate (use panic_semihosting as _;, esp_app_desc!())the board descriptor’s entry.crate_root_extra
nros::main!(launch = …, args = …)the image

— which is why adding a board adds a descriptor rather than a branch in the emitter, and why the generated entry calls nros::main! rather than its own expansion: the macro reads the launch XML at expansion time, so adding a node to the launch file is picked up by the next compile with nothing regenerated.

The exception: when a framework owns the application

An entry package survives in exactly one situation — an external build system that demands a real application directory. That is Zephyr.

A Zephyr app is the app target that find_package(Zephyr) creates, and it carries authored Kconfig that nothing can derive: prj.conf, the per-RMW prj-<rmw>.conf fragments, boards/*.overlay. RFC-0065 D5 draws the line there — “west and ESP-IDF apps keep their own files because those are Kconfig overlays — user intent, not derivable.”

The Kconfig is the exception; the application is not (issue 1288). nros build writes the west application too — build/<coord>/<image>_entry/ CMakeLists.txt, with find_package(Zephyr), project(), one add_subdirectory per node package your launch file names, and the single nano_ros_add_executable(...) (or rust_cargo_application() for a Rust image) — and then runs west build against it. What you author is the Kconfig, and it lives with the bringup:

src/demo_bringup/boards/native_sim_native_64/
├── prj.conf                            # APPLICATION_CONFIG_DIR
├── prj-zenoh.conf                      # named by [image.<id>] conf
└── boards/
    └── native_sim_native_64.conf       # Zephyr's own per-board discovery

That directory is what [image.<id>] board selects, and switching boards selects a different one rather than editing a package. Every Zephyr image in examples/workspaces/{rust,c,cpp,mixed,derived-tiers-cpp,realtime-c,realtime-cpp} is built this way. realtime-c shows the two-bringup case: demo_bringup declares [image.zephyr] (native_sim) and smp_bringup declares [image.zephyr_smp] (qemu-cortex-a53), so each image names its own bringup and gets its own generated application and its own boards/<board>/ directory.

A board PACKAGE can carry Zephyr files of its own beside its descriptor — a base prj.conf, boards/<board>.conf and boards/<board>.overlay. nros build hands those to the generated application as the first EXTRA_CONF_FILE / EXTRA_DTC_OVERLAY_FILE entries, by the same rule nano_ros_use_board() applies to a hand-written one. They merge AFTER the bringup’s prj.conf (Zephyr’s CONF_FILE) and BEFORE the image’s conf fragments, so a value that must beat the board goes in a conf fragment. realtime-cpp’s [image.fvp] (fvp-aemv8r-smp) is the in-tree case: it lists its own prj.conf in conf again so that its CONFIG_RUST=n lands after the board’s CONFIG_RUST=y.

For a Rust workspace whose image needs a hand-written application after all — Kconfig nros build cannot express, a nano_ros_use_board() preamble — the scaffold is still there:

nros new entry zephyr_entry --platform zephyr

That writes Cargo.toml, CMakeLists.txt and prj.conf, and the [image.*] row — the two halves are one declaration, and every Zephyr build failure worth having is the two disagreeing. A package written this way SUPPRESSES generation for the image that would carry its name, which is what makes the migration in either direction a file operation rather than a flag. Zephyr is the only platform the verb accepts, which is the rule stated by the tool itself:

`nros new entry` currently scaffolds Zephyr entries only (got --platform linux).
Every other platform builds through cargo or cmake, where the entry is
GENERATED from the image and needs no package of its own (RFC-0065 D3).

Running on Zephyr

The Zephyr image names its application and its RMW overlay; nothing else in the row is Zephyr-specific:

[image.zephyr]
board = "zephyr"
conf  = ["prj-zenoh.conf"]
nros sync                              # generated message bindings, once
nros build demo_bringup:zephyr         # resolves the app + overlays, runs west build
nros build demo_bringup:zephyr -- -t run

Plain west keeps working and stays the primary flow — nros build is the convenience that applies an image’s overlays for you, never a required layer between you and west:

west build -b native_sim/native/64 build/zephyr-zenoh/zephyr_entry \
    -- -DAPPLICATION_CONFIG_DIR=src/demo_bringup/boards/native_sim_native_64
west build -t run                      # native_sim; `west flash` for hardware

(nros build demo_bringup:zephyr --dry-run prints that command for your image, which is the reliable way to get it: the application path carries the coordinate, and --dry-run reads it off the image rather than asking you to spell it.)

One workspace covers every Zephyr board. Zephyr owns its board abstraction, so the board is chosen by the image’s board key and never baked into a package. That key is a nano-ros board id (zephyr), never Zephyr’s own spelling: its board descriptor states the Zephyr id ([board.zephyr] west_board = "native_sim/native/64"), and that is what west build -b receives. Writing board = "native_sim/native/64" on the image is refused, naming the id to use. To target another Zephyr board, name a descriptor that states its west_board — nrf52840dk/nrf52840, say, from a board package in your workspace — and swap prj-zenoh.conf for prj-xrce.conf: what changes is which boards/<board>/ directory is read, and nothing in src/ at all.

The entry key exists for the case where several packages could answer to one image. An application package no longer declares the board it serves — that direction is reversed: the image claims the entry, by naming it (entry = "zephyr_entry") or, when it names none, by being the image whose generated entry would carry that name (RFC-0098 D5, phase-445 W5). So the application reads its deployment — board, RMW, locator, domain — off the image that claims it, and a [package.metadata.nros.entry] deploy left in a Cargo.toml is now an error naming the file to write instead.

Two images claiming one entry is refused rather than resolved by first match — which image’s locator the entry bakes is not a coin toss, so nros build names the candidates instead of picking one. That arm is reachable for C/C++ entries, which are matched by their DEPLOY token; a Rust entry names its image in its own system.toml, so it is never a candidate for a sibling image.

The other reason to set entry is that NO package claims the board — then nros build falls back to the Bringup directory and you see a conf fragment “not found” in a path that is not your application’s. A hand-written application that takes its board from nano_ros_use_board(...) in its own CMakeLists.txt is that case, because the DEPLOY scan does not read the call (issue 1517). Everywhere else, leave entry out and let the name match.

The full Zephyr path — west workspaces, freestanding applications, west nros, sysbuild — is Zephyr (west module).

If you already have entry packages

nros build keeps a hand-written entry: if src/<image_id>_entry/ carries a Cargo.toml or a CMakeLists.txt, it is used as-is and nothing is generated over it. Migration is therefore a deletion, one image at a time — remove the directory, and the next build generates its replacement.

Move these out first, since deleting the package deletes them too:

in the old entrygoes to
board, launch file, launch args, RMW, panic policy, profilethe [image.*] row
RTOS config overlays — prj.conf, boards/*.overlay, sdkconfig.defaultsthe Bringup pkg, under boards/<board>/
genuinely hand-written startup you cannot express as a declarationnros materialize, which writes the package back out for you to keep

nros::main!() — what the generated entry contains

You will read this in a generated entry, and write it by hand only in a single-package project or a materialized entry.

#![allow(unused)]
fn main() {
// 1. Single-node self-bringup: reads the board from the `[image.*]` row in
//    the `system.toml` beside this package's Cargo.toml, and boots the Node
//    pkg that is the only member of this package.
nros::main!();

// 2. RETIRED (phase-484 W8c): `nros::main!(board = LinuxBoard)` is a
//    compile error. The board belongs in `system.toml`; an out-of-tree board
//    crate states its own type in
//    `[package.metadata.nros.board] type = "::my_board::MyBoard"`, which
//    form 1 reads.

// 3. Multi-node (CANONICAL): name your INPUT — the Bringup pkg, and
//    optionally a launch file inside it; the default is its default launch.
nros::main!(launch = "demo_bringup");
nros::main!(launch = "demo_bringup:variant_b.launch.xml");

// 4. Multi-host slice: the launch file gates nodes on a `host` launch
//    argument (`if=` conditions), and the model is resolved with
//    `host:=robot1`, so it already contains only this host's nodes.
nros::main!(launch = "demo_bringup:multihost.launch.xml", args = [("host", "robot1")]);

// 5. DEPRECATED (expert override): name the resolved model ARTIFACT
//    directly instead of the input. Skips the sync-freshness contract;
//    warns at bake time (phase-330 W7).
nros::main!(model = "demo_bringup");
}

launch and model are mutually exclusive. launch is the canonical spelling: it names your input — the bringup package and launch file — and the entry consumes the SystemModel that nros sync resolves from it. The same resolved artifact drives the Linux runtime (play_launch) and every embedded image, so contract budgets, tiers, and QoS never drift between runtimes.

$ nros sync    # resolves models into <ws>/build/nros/models/<bringup>/

nros sync re-resolves a model whenever a bringup’s launch XML or system.toml is newer than it, and nros build runs it for you. SystemModels are build artifacts — never commit one (the check-no-tracked-models gate enforces this). Under the hood nros sync runs the pinned nros-launch-resolve helper (RFC-0060 layer 2) by absolute path, never by a bare name on $PATH, because an unrelated ROS 2 play_launch on PATH used to win that race.

The macro reads the image’s board at compile time to select the board crate and executor backend, and the same model resolves its tier table for whichever RTOS that board targets. On Embassy / RTIC targets it emits the framework-specific #[embassy_executor::main] or #[rtic::app] body, so the crate root stays a single line — as in examples/mps2-an385-baremetal/rust/talker-rtic/src/main.rs:

#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
use panic_semihosting as _;

nros::main!();
}

Escape hatch

If you need more control than the macro provides — custom startup ordering, hardware init before the runtime, or a fully manual executor loop — take ownership of the generated entry:

$ nros build linux          # generate it first — materialize copies, it does not re-emit
$ nros materialize linux
wrote …/src/linux_entry

This entry is YOURS now — `nros build` will not regenerate it.

It lands at src/<image_id>_entry/, carries a stamp recording the shape it was cut for, and nros build warns if that shape later moves. Its derivation stays live — nros::main! still reads the launch file at compile time, so adding a node needs no change here; what is frozen is the shell: the panic policy, the board boilerplate, the crate type. Inside it you can bypass nros::main!() entirely:

#![allow(unused)]
fn main() {
// Option A: delegate init to the board crate, supply your own closure.
<LinuxBoard as BoardEntry>::run(|runtime| {
    let node = runtime.create_node("talker", "/", &Default::default())?;
    // ...
    Ok(())
});

// Option B: fully manual — no board crate.
let executor = nros::Executor::open(&ExecutorConfig::default())?;
// wire nodes, spin manually ...
}

Option A is the right choice when you need to run something before the first spin (e.g. DMA setup, flash unlock). Option B is there for board-bringup authors adding a new platform. Reach for either only after the declarative escapes — an [image.*] key, a conf fragment, a support package — have actually run out.

Running a native image

Build it, start a router if your backend needs one, run the binary:

cd examples/workspaces/c
nros sync
nros build linux

# in another shell, for the zenoh backend:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd &

./build/posix-zenoh-linux/cmake/linux_entry

The binary opens the executor, registers talker + listener composed into a single process, and runs the topology. The launch product is the binary — there is no separate launch step.

examples/workspaces/{c,cpp,rust,mixed}/ are the four canonical workspaces and each carries a README with the commands verified green today.

C / C++ images

Nothing above is Rust-specific: the driver is chosen by the board, not by the language mix, and cmake wins whenever the package graph crosses languages (corrosion makes cargo consumable from cmake; nothing makes cmake consumable from cargo). examples/workspaces/c and examples/workspaces/cpp declare their images exactly as shown here. See C / C++ multi-node workspaces.

Where to go next

C / C++ multi-node workspaces

The four previous chapters (project layout, node packages, bringup packages, images) describe the canonical node pkgs + bringup pkg + [image.*] shape against the Rust path (nros::node!(…) + nros::main!(launch = …)). This chapter shows the C and C++ path through the same shape, role-for-role.

The parity gap is closed. Same launch.xml, same package.xml, same system.toml, same workspace pkg-index — the only thing that changes language-side is the cmake-fn / macro surface.

TL;DR — side-by-side

RoleRustC / C++
Node pkglib.rs with nros::node!(MyNode), declared by a [[component]] row in the bringup’s system.tomlTalker.{hpp,cpp} with a configure(::rclcpp::Node&) component method (C++) / RCLC_C_COMPONENT (C); CMakeLists.txt calling nano_ros_auto_add_library + nros_components_register_node (RFC-0057)
Bringup pkgpackage.xml + system.toml + launch/*.launch.xml (no Cargo.toml)identical (language-agnostic)
Image[image.linux] board = "linux" in the bringup’s system.tomlidentical (language-agnostic)
The entryGENERATED into build/<coord>/linux_entry/ — a Cargo.toml and a nros::main!(launch = "demo_bringup")GENERATED into build/<coord>/CMakeLists.txt — a nano_ros_add_executable(linux_entry BOARD … BRINGUP … LAUNCH … LANG cpp TYPED DEPLOY linux) call, plus the main TU the verb emits
Workspace rootnone — the generated entry’s own Cargo.toml is the cargo rootnone — the generated build/<coord>/CMakeLists.txt is the cmake root, calling nano_ros_workspace(WORKSPACE_ROOT … BACKEND zenoh PLATFORM posix SYSTEM demo_bringup SUBDIRS …)
Buildnros sync then nros build linuxnros sync then nros build linux
Boot./build/posix-zenoh/linux_entry/target/debug/linux_entry./build/posix-zenoh-linux/cmake/linux_entry

The reference C++ workspace ships in-tree at examples/workspaces/cpp/. Copy the whole directory, rename the packages.

Workspace layout

Identical structure to the Rust template, swapping Cargo.toml → CMakeLists.txt inside each Node pkg — and, at the root, nothing at all:

my_ws/
├── .colcon_workspace             # tracked marker; there is no root build file
└── src/
    ├── talker_pkg/               # Node pkg (C++)
    │   ├── package.xml
    │   ├── CMakeLists.txt        # nano_ros_auto_add_library + nros_components_register_node
    │   └── src/{Talker.hpp,Talker.cpp}
    ├── listener_pkg/             # Node pkg (C++)
    │   ├── package.xml
    │   ├── CMakeLists.txt
    │   └── src/{Listener.hpp,Listener.cpp}
    └── demo_bringup/             # Bringup pkg (language-agnostic — copy/paste
        ├── package.xml           #          works between Rust and C++ workspaces)
        ├── system.toml           #          [system] + [[component]] + [image.*]
        └── launch/system.launch.xml

No entry package, and nothing at the root: a workspace is a directory of packages (RFC-0098 D9). nros build <image> writes a CMakeLists.txt and the entry’s nano_ros_add_executable(…) call into build/<coordinate>/, configures that into build/<coordinate>/cmake/, and builds — see Images. The exception is a Zephyr application, which keeps a package because west needs a real application directory.

The generated cmake root

You do not write this; nros build does, from the packages it discovered and the image it resolved. It is the cmake root — and it lives in build/<coordinate>/, not at the workspace root, which is why its first argument says where the workspace actually is. It is worth reading once, because everything below happens inside it:

cmake_minimum_required(VERSION 3.22)
project(my_ws LANGUAGES C CXX)
find_package(nano_ros REQUIRED COMPONENTS workspace)

nano_ros_workspace(
    WORKSPACE_ROOT "../.."               # this file lives in build/<coord>/
    BACKEND        zenoh                 # zenoh | xrce | cyclonedds
    PLATFORM       "posix"
    SYSTEM         demo_bringup
    ORDER_FROM_DEPENDS
    SUBDIRS        "../../src/talker_pkg"
                   "../../src/listener_pkg"
)

nano_ros_workspace() does the heavy lifting in one call:

  1. Sets NROS_PLATFORM=posix + NROS_RMW=zenoh.
  2. add_subdirectory(<nano-ros>) once at root scope (so per-pkg subdirs don’t collide on re-include).
  3. include(NanoRosNodeRegister.cmake) + include(NanoRosEntry.cmake) once.
  4. add_subdirectory(<each member>) for each SUBDIRS entry.

Subdir CMakeLists begin with the dual call:

nano_ros_workspace_pkg_guard()  # no-op inside a workspace; bootstraps standalone solo

— the cmake equivalent of cargo [workspace] discipline. Every member compiles standalone (with -DNROS_REPO_DIR=<path>) or as part of the workspace; the per-pkg CMakeLists doesn’t change between modes.

Node pkg

A typed component (RFC-0043) — no main(). The pkg ships a class with a configure(::rclcpp::Node&) method that creates real entities (a Publisher, a Timer) and binds member callbacks by identity (member-fn-pointer template param, no string callback name, no interpreter). The generated entry constructs the object and calls configure(node); the executor dispatches the callbacks.

# src/talker_pkg/CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
project(talker_pkg LANGUAGES C CXX)
nano_ros_workspace_pkg_guard()
nros_find_interfaces(LANGUAGE CPP SKIP_INSTALL)

# RFC-0057: the ament shape — the library owns the sources, the register
# owns the identity (keyword parity with rclcpp_components_register_node).
# Interface deps (std_msgs__nano_ros_cpp) are wired automatically.
nano_ros_auto_add_library(talker_lib STATIC src/Talker.cpp)

nros_components_register_node(talker_lib
    PLUGIN talker_pkg::Talker      # any qualified name — upstream namespaces port verbatim
    EXECUTABLE talker
    SHAPE configure)               # this walkthrough uses the configure(Node&) shape

Telling the build what the component creates

NROS_EXECUTOR_MAX_CBS, the subscriber slots and the arena are const sizes compiled into nros-node before a component TU is compiled, while an RFC-0043 component wires itself in its CONSTRUCTOR, at runtime. Nothing the macros emit can supply those sizes — it exists only after linking. So somebody has to state what the image creates, ahead of the build.

That statement is a contract sidecar beside the launch file, not something in this CMakeLists. <stem>.contract.yaml next to <stem>.launch.xml in the bringup package:

# src/demo_bringup/launch/system.contract.yaml
version: 1

nodes:
  talker:
    paths:
      on_timer:                                  # a path with a timer trigger
        trigger: { timer: { rate_hz: 1 } }       # IS the periodic callback
        output: [chatter]
    pub:
      chatter: {}
  listener:
    sub:
      chatter: {}

topics:
  /chatter:
    type: std_msgs/msg/Int32
    pub: [talker/chatter]
    sub: [listener/chatter]

nodes: keys are LOCAL endpoint names — an absolute path there is concatenated onto the node’s FQN and matches no topic. topics:, services: and actions: wire those endpoints to absolute names and carry the type. paths: is where timers live: the model records a path with no input as the periodic callback.

A single-package example writes the same file at <leaf>/system.contract.yaml, beside its system.toml. Its launch file is generated from the [[component]] rows, so there is no launch/ directory to put a sidecar in; nros sync carries this one to where the resolver looks. That is how a standalone leaf states its QoS — and stating it is worth real memory: one sub: { chatter: { qos: { history: keep_last, depth: 1 } } } row takes examples/linux/rust/listener from 273,802 to 172,298 bytes of static RAM, because the zenoh backend then sizes its payload pools for the one sample the image keeps instead of the four it might have.

The name you write must be the name the code registers, ABSOLUTELY. A contract states RESOLVED names; a call site writing "chatter" or a node the launch file remaps is one the build cannot match with certainty, so it refuses that endpoint by name and the image keeps its configured pools rather than being sized from a guess.

nros sync folds it into the resolved SystemModel, and the entry passes that model to the sizing automatically — nothing here takes an argument for it. Inspect the composed answer with:

$ nros ws entity-inventory --metadata <build>/nros-metadata.json       --model <ws>/build/nros/models/demo_bringup/system_model.yaml

The derivation is all-or-nothing per image: a component the contract does not describe makes that image derive nothing and keep its configured NROS_EXECUTOR_MAX_CBS. A count taken over only the components that answered is smaller than the image needs, and a short MAX_CBS fails entity creation at boot — so “some of them” is refused rather than guessed at.

Note that a publisher is inventoried but claims no callback slot, so the entity count and the derived MAX_CBS are legitimately different numbers.

Earlier versions stated this per component, as nano_ros_node_register(ENTITIES ...). That keyword is retired (phase-412) and now fails with a pointer here. It was a list beside the code with nothing comparing the two, and on the safety island one of them declared six subscriptions for a node that creates seven: every pool derived from it was short by one, and the eleventh subscription failed at boot with a transport error that named nothing.

The census: the contract checked against the code

Since the contract is still a statement beside the code, a CROSS image (any image whose board is not native) checks it before it configures: the native image generated from the same launch file is run once in census mode — it constructs every component against a recording backend, writes what they created, and exits without spinning or dialling a router — and the configure compares that census with the contract row by row. An endpoint the code creates and the contract omits refuses as missing-in-contract (not waivable: it is the short pool); one the contract declares and the code never creates refuses as phantom (waivable per row under [census.waive]).

The configure never builds the native image itself, so a cross build takes the census first:

$ nros ws entity-census take --image demo_bringup:zephyr
$ nros build zephyr

take does nothing when there is nothing to do — a model with no contract, or a census that still matches the code (freshness is by content: touching a file changes nothing, adding a subscription does). With no census, or a stale one, the configure REFUSES and prints that command. A bringup that wants the old behaviour writes [census] on_missing = "warn" / on_stale = "warn" in its system.toml, and gets a CMake WARNING instead.

// src/talker_pkg/include/talker_pkg/Talker.hpp
#pragma once
#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"

namespace talker_pkg {

class Talker {
    ::rclcpp::Publisher<std_msgs::msg::Int32> pub_;
    ::rclcpp::Timer timer_;
    int count_ = 0;

    void on_tick();  // real body; bound via &Talker::on_tick (no name)

  public:
    ::rclcpp::Result configure(::rclcpp::Node& node);
};

}  // namespace talker_pkg
// src/talker_pkg/src/Talker.cpp
#include "talker_pkg/Talker.hpp"

namespace talker_pkg {

void Talker::on_tick() {
    std_msgs::msg::Int32 m;
    m.data = count_++;
    (void)pub_.publish(m);
}

::rclcpp::Result Talker::configure(::rclcpp::Node& node) {
    ::rclcpp::Result r = node.create_publisher(pub_, "/chatter");
    if (!r.ok()) return r;
    // Member-fn-pointer-as-template-param → no-alloc trampoline; `this` is ctx.
    return node.create_wall_timer<Talker, &Talker::on_tick>(timer_, 1000, this);
}

}  // namespace talker_pkg

The generated entry constructs Talker in static storage and calls configure(node) on the real executor — the same component model the Rust nros::node!(Talker) + the C RCLC_C_COMPONENT paths use, so C++, C, and Rust Node pkgs interoperate in one launch graph.

Scaffold a C++ Node pkg with:

$ nros new --component my-talker --lang cpp --use-case talker
✓ Created nano-ros C++ Node pkg 'my-talker'
  Class     : my_talker::Talker
  Node      : talker
  Kind      : typed component (RFC-0043)

Bringup pkg

Language-agnostic — copy verbatim from the bringup chapter. package.xml + system.toml + launch/system.launch.xml. No Cargo.toml, no CMakeLists.txt. Stock ROS 2 launch.xml from nav2 / Autoware / turtlebot3 pastes in modulo unsupported tags.

<!-- src/demo_bringup/launch/system.launch.xml -->
<launch>
  <node pkg="talker_pkg" exec="talker" name="talker"/>
  <node pkg="listener_pkg" exec="listener" name="listener"/>
</launch>

The image, and the entry it generates

Declare the program you want in the bringup’s system.toml —

[image_defaults]
rmw = "zenoh"

[image.linux]
board = "linux"

— and nros build linux emits this into that generated root. BOARD and DEPLOY come from the resolved image, so a generated entry cannot disagree with the board it was generated for; there is no SOURCES, because the verb generates the translation unit carrying main:

nano_ros_add_executable(linux_entry
    BOARD   native
    BRINGUP "${CMAKE_CURRENT_SOURCE_DIR}/../../src/demo_bringup"
    LAUNCH  default
    LANG    cpp
    TYPED
    DEPLOY  native)

At configure time the cmake fn shells nros codegen entry --lang cpp --typed, emits ${CMAKE_BINARY_DIR}/linux_entry_nros_main_generated.cpp (the canonical int main() body that constructs each launch node’s component + calls configure(node) on the real executor via LinuxBoard::run_components), appends it to the target’s sources, and auto-links every <pkg>_<exec>_component static lib the launch XML named.

BRINGUP + LAUNCH — name your input, never the artifact

BRINGUP + LAUNCH name your input: the Bringup package and a launch selector. nros sync resolves that into a SystemModel build artifact (under <ws>/build/nros/models/<bringup>/ — never committed; gate check-no-tracked-models), and the entry bakes from it, so the same artifact drives the Linux runtime and every embedded image (contracts, tiers, QoS never drift). MODEL — naming the resolved artifact file directly — is a deprecated expert override; the two are mutually exclusive, and a committed model is banned outright.

nros sync resolves/refreshes the model whenever the bringup’s launch XML or system.toml is newer than it, and nros build runs it for you.

Writing the entry yourself

Two cases need a hand-written one, and both use nano_ros_entry(NAME … ) in a package’s own CMakeLists.txt:

  • A Zephyr application, which west requires as a real directory — scaffold it with nros new entry <name> --platform zephyr.
  • Startup the declarations cannot express — nros materialize <image> writes the generated entry out to src/<image>_entry/ and nros build then leaves it alone.

Either way the entry package needs no source file of its own: the cmake function generates the whole body (the main above), so an entry’s CMakeLists.txt names the bringup and launch file and nothing else.

Build + boot

nros sync
nros build linux
./build/posix-zenoh-linux/cmake/linux_entry

nros build resolves the SystemModel for you (via the pinned nros-launch-resolve helper, whenever the launch XML or system.toml is newer), writes build/<coordinate>/CMakeLists.txt, configures, and builds. It does not run nros sync: a workspace that has never been synced has no generated message bindings, and nros build refuses at preflight naming nros sync as the remedy. Sync once per workspace; after that only a .msg edit needs it again. The coordinate directory names what it contains: platform, RMW, board. Driving cmake yourself against that generated root — build/<coordinate>/CMakeLists.txt, never a file at the workspace root — still works.

The build produces:

  • src/talker_pkg/libtalker_pkg_talker_component.a — the Node pkg static lib (_component is the compatibility target suffix).
  • src/listener_pkg/liblistener_pkg_listener_component.a — ditto.
  • linux_entry — the image’s executable, with the generated int main() + register-call sequence + Board boot stub linked in.

cmake configure is incremental — pinned CMAKE_CONFIGURE_DEPENDS on every file nros codegen entry reads (depfile from the CLI), so any launch.xml / package.xml / Node pkg edit re-runs codegen.

What runs where

ConcernLives in
Node entities + real callbacksNode pkg configure(::rclcpp::Node&)
Topology + launch args + per-target deployBringup pkg system.toml + launch/*.launch.xml
int main() + executor init + spinGenerated TU emitted by the entry’s cmake fn
Board + RMW selectionThe image’s board and rmw, which become the generated BOARD / DEPLOY args

Same partition as the Rust track — the only thing that changes is the syntax the user types into the two package roles.

C / C++ scaffolding via nros new

CommandOutput
nros new --component <name> --lang cpp --use-case talkerC++ Node pkg; --component is the compatibility scaffold flag
nros new system <name>_bringup --components a,bBringup pkg (language-agnostic — works for both Rust and C++ workspaces)
nros new <name> --lang cpp --platform linuxA standalone single-package project — its own CMakeLists.txt and its own system.toml; the copy-out shape, not a workspace member
nros new entry <name> --platform zephyrA Zephyr application package + its [image.*] row. The verb accepts no other platform: everywhere else the entry is generated

The C-side compatibility scaffold (nros new --component … --lang c) is available for pure-C Node pkgs. Pure-C and mixed C/C++ workspace examples live under examples/workspaces/.

See also

Mixed-language workspace

nano-ros Node pkgs are linked through a C ABI register trampoline, so one image can compose Node pkgs written in different languages. The native reference shape is:

  • C Node pkgs for C code you want to keep in C.
  • C++ and Rust Node pkgs beside them, in the same workspace.
  • One Bringup pkg with the normal ROS 2 launch XML and the [image.*] rows.

The mixed workspace is in examples/workspaces/mixed/. For a pure-C workspace, use examples/workspaces/c/.

Layout

my_ws/
├── .colcon_workspace             # tracked marker; there is no root build file
└── src/
    ├── c_talker_pkg/             # C Node pkg
    ├── cpp_listener_pkg/         # C++ Node pkg
    ├── rust_heartbeat_pkg/       # Rust Node pkg
    └── demo_bringup/             # launch XML + system.toml (+ [image.*])

Mixed is not a special case. The driver is chosen by the board, and cmake wins whenever the package graph crosses languages — corrosion makes cargo consumable from cmake, and nothing makes cmake consumable from cargo (RFC-0024 §6.3). So a mixed workspace builds exactly like the C++ one:

$ cd examples/workspaces/mixed
$ nros sync
$ nros build linux

nros build writes a CMakeLists.txt — a nano_ros_workspace(…) call listing the discovered packages, plus one nano_ros_add_executable(…) per image on that coordinate — into build/<coordinate>/, configures it into build/<coordinate>/cmake/, and builds. That generated file is the cmake root; the workspace itself has no build file at all (RFC-0098 D9), and neither the root nor the entry is yours to write.

C Node pkg

A C Node pkg is a typed component (RFC-0043): no main(). It defines a state struct + a configure(node, executor, self) function and exports the C-ABI factory/configure seam via RCLC_C_COMPONENT(StateT, configure_fn) — the typed Entry creates the node and runs configure on the real executor. The component is written against the C API: configure receives an rcl_node_t * and an rclc_executor_t * over the entry’s own node and executor, so a C and a C++ component in one image share both.

# std_msgs is declared in package.xml (`<depend>std_msgs</depend>`), which
# generates its C bindings for this package.
find_package(std_msgs REQUIRED)
nano_ros_auto_add_library(talker_lib STATIC src/Talker.c)

nros_components_register_node(talker_lib
    PLUGIN c_talker_pkg::Talker
    EXECUTABLE talker
    SHAPE configure
    TYPED
    DEPLOY linux)
#include <stdint.h>
#include <rclc/rclc.h>
#include <std_msgs/msg/int32.h>

typedef struct {
    rcl_publisher_t pub;
    rcl_timer_t timer;
    int32_t count;
} c_talker_pkg_t;

static void on_tick(rcl_timer_t* timer, void* ctx) {
    (void)timer;
    c_talker_pkg_t* self = (c_talker_pkg_t*)ctx;
    std_msgs__msg__Int32 msg = {.data = self->count};
    (void)rcl_publish(&self->pub, &msg, NULL);
    self->count++;
}

static rcl_ret_t talker_configure(rcl_node_t* node, rclc_executor_t* executor,
                                  c_talker_pkg_t* self) {
    self->count = 0;
    self->pub = rcl_get_zero_initialized_publisher();
    rcl_ret_t rc = rclc_publisher_init_default(
        &self->pub, node, ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "/chatter");
    if (rc != RCL_RET_OK) return rc;
    self->timer = rcl_get_zero_initialized_timer();
    rc = rclc_timer_init_with_context(&self->timer, 1000000000ULL, on_tick, self);
    if (rc != RCL_RET_OK) return rc;
    return rclc_executor_add_timer(executor, &self->timer);
}

RCLC_C_COMPONENT(c_talker_pkg_t, talker_configure)

nano_ros_auto_add_library injects NROS_PKG_NAME (language inferred from the sources), so RCLC_C_COMPONENT exports the __nros_c_component_<pkg>_{create,configure} seam the typed Entry calls — interoperable with C++ configure(Node&) and Rust nros::node! components in one launch graph.

The image

Nothing about the language mix reaches the image declaration. One row per program, in src/demo_bringup/system.toml:

[image_defaults]
rmw = "zenoh"

[image.linux]
board = "linux"

The launch file names the Node pkgs, whatever they are written in:

<launch>
  <node pkg="c_talker_pkg" exec="talker" name="talker"/>
  <node pkg="cpp_listener_pkg" exec="listener" name="listener"/>
</launch>

From that pair, nros build emits this into the generated root. LANG follows the workspace’s own sources; BOARD and DEPLOY come from the resolved image, so a generated entry cannot disagree with the board it was generated for. There is no SOURCES, because the verb generates the translation unit carrying main:

nano_ros_add_executable(linux_entry
    BOARD   native
    BRINGUP "${CMAKE_CURRENT_SOURCE_DIR}/../../src/demo_bringup"
    LAUNCH  default
    LANG    cpp
    TYPED
    DEPLOY  native)

The generated entry translation unit calls each package’s __nros_component_<pkg>_register symbol and the CMake sidecar links the matching static libraries. The symbol keeps the legacy component spelling for ABI compatibility; the user-facing package role is still Node pkg.

Note there is no MODEL here. The SystemModel is a build artifact resolved from the launch file by nros sync; naming it in a build file is the deprecated expert override, and a committed one is banned outright (gate check-no-tracked-models).

Scaffolding

# Current compatibility scaffold for Node pkgs:
nros new --component c_talker_pkg --lang c --use-case talker
nros new --component cpp_listener_pkg --lang cpp --use-case listener
nros new system demo_bringup --components c_talker_pkg,cpp_listener_pkg

For a complete working tree, copy the template instead of creating each package separately.

Rust, C, and Mixed-Language Workspaces

The anatomy does not change with the language: node packages hold code, the bringup holds configuration, and nros build generates one entry per image under build/. This page is what changes — per language — inside that fixed shape.

Two commands build any of them, in this order:

nros sync
nros build

Rust

Scaffold the whole workspace in Rust, or add Rust node packages to an existing one:

nros new my_robot --workspace --lang rust      # whole workspace
nros new my_node --component --lang rust       # one node pkg, added under src/

A Rust node package is a library crate carrying nros::node!(YourNode). You write no entry: the one nros build generates for each image collapses to one line, and this is what it holds —

#![allow(unused)]
fn main() {
nros::main!(launch = "demo_bringup", spin = "forever");
}

Two Rust-specific facts:

  • nros sync before the first build, and again after editing .msg files or moving the checkout. It generates the message crates, and — per image — the settings file cargo is handed with --config: the board’s rustc triple, the link flags, the [env], the patch rows (RFC-0098 D1). Nothing of that is written beside your packages, and cargo cannot resolve the build without it. A C or C++ workspace runs nros sync too; what it does not get from it is the message codegen, which runs inside CMake.
  • The RMW is spelled once — [system] rmw = "zenoh" in the bringup’s system.toml, or rmw = on a single [image.*] row when one image differs. The board crate’s feature and the nros facade feature are generated from it, so switching backend is a one-word edit plus nros sync: Switching RMW in Config.

C

C node packages join a workspace the same way:

nros new my_c_node --component --lang c

A C node package is the declarative component shape — a configure function binding callbacks through rclc/rclc.h — built by the same per-package CMake as C++. There is no C workspace scaffold; start from the C++ workspace (nros new --workspace) and add C packages, which is also what the in-tree reference does.

Mixed — and why it just works

Languages mix per package, not per file, and the bringup does not care: a [[component]] row names a package and a class/entry point, whatever implements it. The in-tree reference workspace (examples/workspaces/mixed/) runs a C talker, a C++ listener, and a Rust heartbeat, composed into one binary:

mixed/src/
├── c_talker_pkg/          # C — publishes std_msgs/Int32 on /chatter
├── cpp_listener_pkg/      # C++ — subscribes
├── rust_heartbeat_pkg/    # Rust — a timer callback
├── …                      # more C and C++ node pkgs: service + action pairs
├── demo_bringup/          # one system.toml for all of them
└── zephyr_entry/          # the one hand-written application: west needs a directory

There is no entry package for the native images and no root build file: the entry for each [image.*] is generated under build/, and the driver is cmake because the package graph crosses languages. Copy the workspace out as a starting point if your project is mixed from day one.

Next

Role Reference: Node pkgs, Bringup pkgs, and Images

A nano-ros workspace has two kinds of package you write, and a third thing that is not a package at all:

  • Node pkg — a reusable, board-agnostic node library. Defines what a node does (publishers, subscribers, timers, services, actions) and registers itself with the nros::node!(T) macro. No main(), no board pick, no deploy config. (Previously called a component package; renamed to Node pkg to match ROS 2 composable-node naming.)
  • Bringup pkg — pure declarative: owns the launch topology and the images built from it. Contains a package.xml, system.toml, launch/*.launch.xml, and optional config/ + boards/. No Cargo.toml, no compiled code. Named <system>_bringup.
  • Image — a row, not a directory. [image.<id>] in that system.toml names a board, a launch file, and a backend; nros build <id> generates the entry package from it, under build/, and hands off to cargo / cmake / west / idf.py. The workspace itself has no root build file (RFC-0098 D9).

The split exists because a node’s logic is portable across boards, but boot + transport + deploy config is not. One Node pkg is reused across native POSIX, FreeRTOS, and Zephyr by adding an [image.*] row per target — never a package per target.

Single-package projects (the copy-out examples under examples/<platform>/<lang>/) skip both packages but keep the declaration: one package carries the node and nros::main!(), and states its board in a system.toml beside its own Cargo.toml — the same file a bringup writes. See Single-package convenience below.

Node pkg

A Node pkg is a normal Rust library (or C++ static library) with a few nano-ros-specific knobs:

src/talker_pkg/
├── Cargo.toml          # [lib] crate-type = ["rlib", "staticlib"] — Rust-toolchain
│                       # facts only; the node is declared by a [[component]] row
│                       # in the bringup's system.toml
├── package.xml         # ROS 2 package manifest (<exec_depend> etc.)
├── src/
│   └── lib.rs          # impl Component for Talker { … }
│                       # nros::node!(Talker);
└── launch/             # OPTIONAL — per-node launch fragment
    └── talker.launch.xml

src/lib.rs declares the user class, implements Node + ExecutableNode (init / on_callback / optional tick), and ends with nros::node!(Talker); to emit the register trampoline. Codegen owns the spin loop — your code only describes what the node has and what its callbacks do.

Key rules:

  • No fn main(). A Node pkg builds as rlib + staticlib and is linked into the entry the build generates. Codegen synthesises the spin driver; you never hand-write one.
  • class must be a namespace-qualified C++ name. The old 212.L.4 prefix rule (class starting with the pkg dir name) is retired by RFC-0057 — pkg on the [[component]] row is the identity authority, so upstream namespaces (autoware::x::Node) port verbatim. nros check still rejects an unqualified class.
  • C++ / C analogue: nano_ros_auto_add_library(<lib> STATIC <srcs>) + nros_components_register_node(<lib> PLUGIN … EXECUTABLE …) (RFC-0057, rclcpp_components keyword parity) + a typed component in the source — C++ a configure(::rclcpp::Node&) method, C a RCLC_C_COMPONENT(StateT, configure_fn) seam (RFC-0043). Same conceptual shape, no Cargo.toml.
  • package.xml is mandatory. Even pure-Rust Node pkgs ship one — <exec_depend> lines drive ROS 2 launch discovery when the system runs through ros2 launch outside the nano-ros toolchain.

Bringup pkg

A Bringup pkg is pure declarative — it owns the launch topology and the image table, and contains no compiled code:

src/demo_bringup/
├── package.xml          # <name>demo_bringup</name>, <exec_depend> per node
├── system.toml          # [system] + [[component]] + [image.<id>] + [host.<name>] (+ [[domain]]/[[bridge]])
├── launch/
│   └── system.launch.xml   # ROS 2 launch schema, verbatim
├── boards/                 # optional — per-board RTOS overlays (prj.conf, *.overlay, sdkconfig.defaults)
└── config/                 # optional — params.yaml, etc.

No Cargo.toml, no CMakeLists.txt, no src/. Naming convention <system>_bringup (alias <system>_launch), matching nav2 / Autoware / turtlebot3. A multi-node workspace needs one: [image.*] lives here, so it is the package that gives a workspace images to build. (A workspace with no bringup at all still builds — every package in dependency order, each into its own build/<pkg>/, which is colcon’s default; there are simply no images. A single-package project has no bringup package either, but keeps the system.toml — see below.)

launch/*.launch.xml is the ROS 2 launch schema verbatim — <launch>, <arg>, <node>, <param>, <remap>, <group>, <include>, with $(find <pkg>) / $(var) / $(env) substitutions. Stock nav2/Autoware XML pastes in and Just Works; Python .launch.py files are resolved by nros sync too (the RFC-0060 resolver executes them — see the bringup tutorial). See the workspace bringup tutorial.

Image

An image is one buildable program: a topology, a board, and a backend. It is a table row in the Bringup pkg’s system.toml:

[image_defaults]           # base every block folds over (RFC-0065 D5.1)
rmw = "zenoh"

[image.linux]
board = "linux"

[image.freertos]
board = "mps2-an385-freertos"
panic = "own"

[image.linux_robot1]
board  = "linux"
launch = "multihost.launch.xml"
args   = { host = "robot1" }
KeyMeaning
boardnano-ros board id, resolved through packages/boards/board-support.toml (which supplies the rustc triple, the platform, and the framework’s own board string)
launchLaunch file relative to the Bringup pkg. Absent ⇒ [system] default_launch
argsLaunch arguments bound at resolve time — how an image selects one machine out of a multi-host launch tree
rmwBackend for this image. Absent ⇒ [system] rmw
panicRFC-0077 panic policy: platform | halt | own
profilecargo / CMake build profile name
confExtra framework config fragments, in order (Zephyr prj-*.conf)
variantFramework build-variant name (Zephyr prj_<buildtype>.conf)
featuresCapability axes for this image, over [system]’s list
serdes, ros_editionSerialization provider and ROS edition. Absent ⇒ the [system] value
entryThe application package — only for the framework-owned case below

Folding is per-key: scalars are replaced by the specific block, args merges, conf and features concatenate base-then-specific. [system] default_images = ["native"] is what a bare nros build builds; without it, nros build lists the declared images and stops.

Build one, all, or ask what would run:

$ nros build linux
$ nros build --all
$ nros build linux --dry-run     # print the handoff, run nothing

[image.<id>] says what is BUILT; [host.<name>] says where nodes RUN — which machine a node runs on. An image needs no host and a host needs no image (RFC-0065 D6). The old [deploy.<id>] table conflated the two and is refused by name, pointing at both (phase-486 W8).

The generated entry

The entry package is derived from (launch, args, board) and generated — RFC-0065 D4, “A workspace contains no entry packages.” On the cargo driver it lands at <ws>/build/<coordinate>/<image_id>_entry/, and that package’s own Cargo.toml is the cargo root: with no workspace root to walk up to, a generated entry is simply a package with path dependencies (RFC-0098 D9). On the cmake driver the generated <ws>/build/<coordinate>/CMakeLists.txt is the root, and emits a nano_ros_add_executable(…) call per image on that coordinate. Beside a cargo entry sits nros-cargo.toml, the per-image settings file nros sync writes — triple, link flags, [env], patch rows — which cargo reads through --config.

All of it is build output under build/, and none of it is written beside your source: no root build file, no leaf .cargo/config.toml. The tracked marker that a directory is a workspace root is .colcon_workspace.

Three parts, all derivable, which is why adding a board adds a descriptor rather than a branch in the emitter:

partderived from
the no_std / no_main shellthe board’s entry_kind
board boilerplate (use panic_semihosting as _;, esp_app_desc!())the board descriptor’s entry.crate_root_extra
nros::main!(launch = …, args = …)the image

The generated entry calls nros::main! rather than an expanded form on purpose: the macro reads the launch XML at expansion time, so adding a node to the launch file is picked up by the next compile with nothing regenerated.

nros::main!() forms

nros::main!();                                          // single-package: reads [image.*] board from system.toml
// RETIRED (phase-484 W8c): `nros::main!(board = X)` is a compile error — an
// out-of-tree board states `[package.metadata.nros.board] type` instead.
nros::main!(launch = "demo_bringup");                   // multi-node (CANONICAL): the bringup's default launch
nros::main!(launch = "demo_bringup:sim.launch.xml");    // multi-node, a named launch file
nros::main!(                                            // multi-host: per-host slice via launch args
    launch = "demo_bringup:multihost.launch.xml",
    args = [("host", "robot1")],
);
// DEPRECATED (expert override): name the resolved model ARTIFACT directly
// instead of the input — skips the sync-freshness contract; bake-time warning.
nros::main!(model = "demo_bringup");

The proc-macro reads the launch file at compile time, walks the workspace pkg-index for each <node pkg=…> entry, and expands to a fn main() that delegates to <Board as BoardEntry>::run(...), dispatching one <pkg>::register(runtime)? call per launch row.

The launch forms take <bringup>[:<file>] against launch/<file>.launch.xml + system.toml [system] default_launch; nros sync resolves that input into a SystemModel under <ws>/build/nros/models/<bringup>/ (a build artifact — never committed; gate check-no-tracked-models), and the macro bakes from it. launch and model are mutually exclusive.

Escape hatch: nros materialize <image> copies the generated entry to src/<image_id>_entry/ and stamps it; nros build then leaves it alone and warns if the shape it was cut for moves. Inside it you can skip the macro and call <LinuxBoard as BoardEntry>::run(|runtime| { ... }), or go fully manual with nros::Executor::open(&ExecutorConfig::default()). Reach for it only after the declarative escapes — an [image.*] key, a conf fragment, a support package — have run out.

C++ / C analogue: the generated cmake root emits one nano_ros_add_executable(<name> BOARD … BRINGUP … LAUNCH … LANG … TYPED DEPLOY …) per image — no SOURCES, because the verb generates the translation unit that carries main. nano_ros_entry(NAME … LANG … BRINGUP … LAUNCH …) is the hand-written spelling, and you write it yourself only in a Zephyr application (below). MODEL on either is the deprecated artifact-naming override, mirroring the macro. Metadata flows through ${BUILD}/nros-metadata.json rather than a sidecar TOML.

The one entry package that survives

An entry package is still required where an external build system demands a real application directory. That is Zephyr: a Zephyr app is the app target find_package(Zephyr) creates, and it carries authored Kconfig (prj.conf, prj-<rmw>.conf, boards/*.overlay) that nothing derives — RFC-0065 D5, “west and ESP-IDF apps keep their own files because those are Kconfig overlays — user intent, not derivable.”

src/zephyr_entry/
├── CMakeLists.txt      # find_package(Zephyr) + nano_ros_add_executable(… DEPLOY zephyr)
├── package.xml
├── prj.conf
└── prj-zenoh.conf      # no src/ — the entry TU is generated into `app`

Scaffold it with nros new entry <name> --platform zephyr, which writes the package and its [image.*] row together. The verb accepts no other platform, and says why:

`nros new entry` currently scaffolds Zephyr entries only (got --platform linux).
Every other platform builds through cargo or cmake, where the entry is
GENERATED from the image and needs no package of its own (RFC-0065 D3).

One Zephyr entry covers every Zephyr board — Zephyr owns its board abstraction, so the board comes from the image’s board key — a nano-ros board id, which the descriptor it resolves to turns into the string west build -b receives.

Set the image’s entry key when the derivation cannot answer. Either several packages claim one board (a coin flip, so nros build refuses and names the candidates; reachable for C/C++ entries, matched by their DEPLOY token) or none does, which falls back to the Bringup directory and surfaces as a conf fragment “not found” in the wrong path. A hand-written application that takes its board from nano_ros_use_board(...) in its own CMakeLists.txt is the second case, because the DEPLOY scan does not read the call (issue 1517).

Migrating off a hand-written entry is a deletion. nros build keeps src/<image_id>_entry/ when it carries a Cargo.toml or CMakeLists.txt; remove the directory and the next build generates its replacement. Move the board / launch / RMW / panic facts to the [image.*] row and the RTOS overlays to the Bringup pkg’s boards/<board>/ first.

Workspace shape

my_ws/
├── .colcon_workspace   # tracked marker; there is no root Cargo.toml / CMakeLists.txt
└── src/
    ├── talker_pkg/         # Node pkg (lib, nros::node!)
    ├── listener_pkg/       # Node pkg
    └── demo_bringup/       # Bringup pkg (declarative; system.toml carries [image.*])

nros sync writes the generated message crates and the per-image build settings; nros build <image> then generates the entry and its build root under build/ and runs the platform’s own tool. nros plan takes the sole bringup when there is one, or you name it: nros plan demo_bringup.

examples/workspaces/{c,cpp,rust,mixed}/ are the four canonical workspaces — each is exactly this shape, plus one zephyr_entry for the Zephyr images.

Single-package convenience

For tiny fixtures, host-side dev loops, and the copy-out examples under examples/<platform>/<lang>/, one package is both the node and the boot point. There is no bringup package — but the declaration is the same file, written beside the manifest (RFC-0098 D3), and src/main.rs collapses to one line:

# system.toml, beside Cargo.toml
[system]
name = "native_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "native_talker"
class = "native_rs_talker::Talker"
name = "talker"

[image.linux]
board = "linux"
// src/main.rs
nros::main!();

The macro reads the board from that [image.*] row, maps it to the right board crate, and emits fn main() + <this_pkg>::register(runtime)?; — the latter resolves through the companion src/lib.rs cargo auto-wires alongside the binary target. No build.rs, no launch file (one is synthesised in-memory), no hand-written boot glue.

Switching board here is the same one-line edit it is in a workspace, provided the package depends on its generated selection package instead of a board crate: <pkg>_nros_selection = { path = "build/nros/selection" }, which nros sync / nros build write for the image’s board (RFC-0098 D6) and nros::main! reaches the board through. The in-tree bare-metal, FreeRTOS, NuttX, ThreadX-Linux, RTIC and native examples do (a native example declares one image per RMW and ROS edition). A package that still names its board crate in [dependencies] has to move that line with [image.*] board; nros sync refuses the mismatch, naming both crates. See issue 1305.

Build it the same two ways a workspace is built:

nros sync            # generated/ message crates + build/<image>/nros-cargo.toml
nros build           # or: nros build <image-id>

The binary lands at build/<image-id>/target/[<triple>/]<profile>/<bin>. To drive cargo yourself, sync first and pass the generated settings file — and run cargo from the directory above the package, because until phase-445 W6 deletes the leaf’s own .cargo/ directory cargo would read it a second time and join the two sets of link flags:

cd <leaf-parent>
cargo build --manifest-path <leaf>/Cargo.toml \
            --config <leaf>/build/<image-id>/nros-cargo.toml

Once W6 lands, the working directory stops mattering. cargo run through the same file works for a QEMU board — the board’s runner is in the generated file.

A single-package C or C++ leaf is simpler still: its own CMake is unchanged, and it needs no sync, because its message bindings are a CMake-time output.

cd <leaf>
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)"
cmake --build build

What changed for it is where the facts come from: the board, RMW, domain and network identity are in system.toml, not in a package.xml <nano_ros …/> tuple and not on the cmake command line. nros build in the leaf runs the same pair into build/<image>/cmake, with the board’s toolchain file (issue 1296).

This is a project shape, not a workspace shortcut: one system.toml, one package, and that is what makes a copy-out example copy out (RFC-0026). Two or more nodes means a workspace with a Bringup pkg.

Quick reference

You want…Use
Reusable node logic, board-independentNode pkg (nros::node!())
A runnable program: N nodes on one boardAn [image.<id>] row + nros build <id>
A single-node project with no workspaceOne package with its own system.toml — [image.linux] board = "linux"
Same nodes on multiple boardsOne Node pkg set + one [image.*] row per board
Launch topology + the image tableBringup pkg (declarative — the one package a workspace must have)
A Zephyr application directorynros new entry <name> --platform zephyr
Startup the declarations cannot expressnros materialize <image>, then edit src/<image>_entry/
Board hardware bringupBoard trait family (see porting chapter)

How integration works

You already have an RTOS project that builds and runs — an STM32Cube or NXP MCUXpresso tree, a Pico SDK app, an ESP-IDF project, a vendored Zephyr or NuttX workspace. This section is about adding nano-ros to that project, without changing who is in charge.

The principle

nano-ros is a library your project imports. Your RTOS keeps its own build tool. You keep building with west, make, idf.py, cmake, or your IDE, exactly as before. nano-ros never compiles your kernel, never owns your linker script, and never replaces your build system.

What nano-ros contributes to your build is small and fixed:

  • a Rust static library (the client runtime, behind a stable C ABI),
  • generated code for your messages and system configuration,
  • a thin C shim that your build compiles, so it sees your config headers (FreeRTOSConfig.h, lwipopts.h, sdkconfig, …).

The last point is why the shim is source, not a prebuilt: config-header mismatches between your kernel and a prebuilt binary are silent ABI breaks. Your compiler, your headers, your flags.

One shape per host build

Each supported host build system gets a small “shell” — build glue plus the host’s own package manifest — that makes nano-ros look native to it. Pick your row:

Your buildHow nano-ros plugs inShell in the repoStart here
Zephyr (west)west modulezephyr/ (module.yml, Kconfig, CMakeLists.txt)Zephyr (west module)
NuttX (make + Kconfig)apps/external/ appintegrations/nuttx/ (Make.defs, Kconfig, Makefile)NuttX (apps/external)
ESP-IDF (idf.py)retired — integrations/nano-ros/ is gonenone ships todayESP32 (ESP-IDF component) — retired; for an ESP32-C3 use ESP32 (esp-hal)
FreeRTOS, CMake projectadd_subdirectory(nano-ros)cmake/platform/nano-ros-freertos.cmakeBuild as a CMake subdirectory, FreeRTOS (QEMU)
ThreadX, CMake projectadd_subdirectory(nano-ros)cmake/platform/nano-ros-threadx.cmakeBuild as a CMake subdirectory, ThreadX
PX4 (make px4_…)EXTERNAL_MODULES_LOCATION copy-outintegrations/px4/module-template/PX4 (integration shell)
PlatformIO (pio run)library + pre-build codegen scriptlibrary.json (repo root) + integrations/platformio/integrations/platformio/README.md in the repo
IDE project (no CMake)emitted drop-in sourcesplanned — no shell ships todaysee note below

Notes on the rows that need them:

  • Vendored FreeRTOS — STM32Cube, MCUXpresso, Pico SDK, and upstream FreeRTOS-Kernel all count. Your SDK builds the kernel and network stack; your CMake project does add_subdirectory(nano-ros) and links NanoRos::NanoRos. See the honest caveat in FreeRTOS: who builds the kernel?
  • PX4 is a hookless host: its configure step cannot run nano-ros codegen from inside, so codegen runs ahead of the PX4 build, emitting module directories PX4 then builds natively. PlatformIO works the same way, via a pre-build script.
  • IDE hosts (STM32CubeIDE, MCUXpresso IDE, IAR, Keil) have no build hook nano-ros can join. A drop-in emitter (prebuilt static library + shim sources + an integration README) is designed but not shipped yet. Today, the practical route is your IDE’s external-CMake or makefile support driving the CMake subdirectory path.

In every row the division of labor is the same: your build owns compile and link; nano-ros owns the adapter shell and the host-time code generation. Generated configuration is baked to C at build time on your host — system.toml and launch files never reach the device.

Two homes for configuration

When you integrate against your own board, you author facts in exactly two places. The split is by what kind of fact it is:

1. A board package — facts intrinsic to the board. A directory in your workspace with a package.xml and an nros-board.toml: the board’s name, RTOS platform, target triple, architecture, entry shape. These are true of every copy of that board on every desk, so they are reusable and shareable. Discovery: the CLI’s board catalog scans <nano-ros>/packages/boards/ plus every root named by NROS_EXTRA_BOARD_PATH (PATH-style list of directories shaped like packages/boards/), so an out-of-tree board dir is seen without copying it into the checkout.

2. An [image.<id>] block, and a [board_config.<board>] beside it — facts about this checkout, this machine, this application. It lives in your bringup package’s system.toml, next to your nodes and launch files:

[image.my-board]
board = "my-board"         # joins to the board package by name
                           # the rustc triple comes from that board's descriptor

[board_config."my-board"]  # site facts: where YOUR SDK lives, which config
sdk.vendor = "{env:VENDOR_SDK_DIR}"     # headers are yours — keyed by BOARD

SDK paths, config-header locations, serial ports: anything another developer’s checkout would spell differently belongs here, keyed per deploy target, and never in the board package.

If a fact would survive being published with the board, it is a board fact. If it names a path on your disk, it is a deploy fact.

FreeRTOS: who builds the kernel?

One honest exception to the principle. On the QEMU reference board, nano-ros can build the FreeRTOS kernel and lwIP for you — point FREERTOS_DIR at a kernel checkout and cmake/platform/nano-ros-freertos.cmake compiles it into the image. That is a convenience for the out-of-the-box demo, and it is the one place nano-ros compiles an RTOS.

With a vendored kernel — STM32Cube’s Middlewares/Third_Party/ FreeRTOS, MCUXpresso’s or the Pico SDK’s bundled kernel — do not use that path. Your SDK’s build owns the kernel, its port selection, and FreeRTOSConfig.h; nano-ros compiles against your include directories and links in as a library, the same as every other row in the table.

Where to go next

FreeRTOS (QEMU MPS2-AN385)

Single-node starter on FreeRTOS + lwIP, cross-compiled for Cortex-M3 and booted in QEMU MPS2-AN385. Slirp networking; no host TAP / bridge / sudo. Rust, C, and C++ talkers all live in-tree.

Prereqs. nros setup mps2-an385-freertos is the single command that prepares your machine for this board. It fetches a prebuilt toolchain set into the shared store at ~/.nros/sdk — the arm-none-eabi-gcc cross-compiler, the patched qemu-system-arm emulator, and the FreeRTOS kernel + lwIP sources. You do not hand-install a cross-toolchain. The zenoh path DOES need a ROS 2 install for the router (ros2 run rmw_zenoh_cpp rmw_zenohd); the xrce path needs only the Micro-XRCE-DDS agent, which nros setup installs.

Setup

Build the in-tree nros CLI (Phase 218):

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

Then provision the board (--rmw defaults to zenoh; pick xrce or cyclonedds to match the example you intend to run):

nros setup mps2-an385-freertos --rmw zenoh

This fetches the cross-compiler, the patched qemu-system-arm, and the FreeRTOS + lwIP sources into ${NROS_HOME:-~/.nros}/sdk (plus the Micro-XRCE-DDS agent when you pick --rmw xrce). The zenoh router is NOT installed — it comes from your ROS 2 install (RFC-0075).

Project layout

Each language uses the standard nano-ros canonical example shape — standalone Cargo (Rust) or CMake (C / C++) project under examples/mps2-an385-freertos/<lang>/<example>/.

examples/mps2-an385-freertos/
├── rust/talker/             # Cargo package
│   ├── system.toml                 # WHAT this deploys to — the one file you edit
│   ├── Cargo.toml                  # Rust deps only; nothing here names a board
│   ├── package.xml
│   ├── generated/                  # generated message bindings (gitignored)
│   ├── build/                      # everything `nros sync` generates (gitignored)
│   └── src/lib.rs                  # the component class; nros::main! generates the entry
├── c/talker/                 # CMake project, add_subdirectory consumption
│   ├── system.toml                 # the same file, same schema
│   ├── CMakeLists.txt              # targets only
│   ├── package.xml
│   └── src/Talker.c
└── cpp/talker/               # CMake C++14 project
    ├── system.toml
    ├── CMakeLists.txt
    ├── package.xml
    └── src/Talker.cpp

All three languages state their deployment in the same system.toml, and nothing else does. The Rust leaf has no .cargo/ to edit — the thumbv7m-none-eabi triple, the -Tmps2_an385.ld --nmagic --gc-sections link group, the QEMU runner and the [patch.crates-io] rows all come from the board and are written by nros sync into build/mps2-an385-freertos/nros-cargo.toml (RFC-0098). The C / C++ leaves have no <nano_ros deploy=… board=… rmw=…/> tuple in their package.xml: find_package(nano_ros) reads system.toml instead, and derives the platform from the board.

The Rust Cargo.toml pulls the FreeRTOS board crate (nros-board-mps2-an385-freertos), which wraps the kernel + lwIP + LAN9118 driver build. The C / C++ CMakeLists.txt follows the canonical add_subdirectory(<repo-root>) + nano_ros_link_rmw(<target> RMW zenoh) pattern.

Configure

Board, RMW, domain and network identity are declared in system.toml and baked at compile time — there is no config file on the device. Verbatim from the in-tree examples/mps2-an385-freertos/rust/talker/system.toml:

[system]
name = "freertos_rs_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "freertos_rs_talker"
class = "freertos_rs_talker::Talker"
name = "talker"

[image.mps2-an385-freertos]
board = "freertos"
locator = "tcp/10.0.2.2:7800"
net = { eth0 = { network = "qemu", host = 15 } }

The C and C++ leaves carry the same file, in the same schema — only the names and the locator differ. Nothing about the deployment lives in Cargo.toml, in CMakeLists.txt, or in the package.xml <export> block any more.

For a C or C++ leaf, switching board really is editing [image.<id>] board (a cross build still passes its own -DCMAKE_TOOLCHAIN_FILE). The Rust leaf switches on the same one line: it names no board crate, only freertos_rs_talker_nros_selection = { path = "build/nros/selection" }, a package nros sync and nros build generate for the image’s board (with nros-board-mps2-an385-freertos, its RMW and nros-platform’s FreeRTOS feature). Its image also states panic = "platform", as its main.rs does, so that package does not add the board’s own panic handler. See issue 1305.

Task stacks / priorities come from the board crate’s defaults (Cargo features), not a config file — see the Configuration Guide.

The 10.0.2.0/24 subnet is QEMU Slirp’s default; 10.0.2.2 is the Slirp gateway that forwards to host loopback. No TAP, no sudo.

Ports: read your image’s own locator. The shipped Rust talker dials host port 7800; the C and C++ talkers declare a [system] locator of their own.

Contributors: the prebuilt test fixtures bake different ports — see Per-Platform Contributor Lanes.

Build

# Rust — `nros sync` first, once per checkout location. It writes the
# generated message bindings AND build/<image>/nros-cargo.toml, which
# carries this board's triple, link group and [patch.crates-io] table
# (RFC-0098 D1). Without it cargo builds for the host, or fails while
# PARSING the manifest with an error that never names sync (see below).
cd examples/mps2-an385-freertos/rust/talker
nros sync
nros build          # or: cargo build --config build/<image>/nros-cargo.toml

# C / C++ — the leaf's own CMake, with the cross-toolchain file (the `nros`
# CLI on PATH auto-resolves the codegen tool — no `-D_NANO_ROS_CODEGEN_TOOL=`
# needed, and no `-DNROS_BOARD=` / `-DNROS_RMW=`: both come from
# system.toml). The toolchain file path is relative to the nano-ros SDK root,
# which `nros sdk-root` prints:
toolchain="$(nros sdk-root)/cmake/toolchain/arm-freertos-armcm3.cmake"
cd examples/mps2-an385-freertos/c/talker
cmake -B build -DCMAKE_TOOLCHAIN_FILE="$toolchain" \
              -Dnano_ros_ROOT="$(nros sdk-root)" \
              -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel

Contributors: the in-tree fixture build lanes for this platform are in Per-Platform Contributor Lanes.

First Rust build pulls + cross-compiles deps (~5 min); the ELF lands at build/mps2-an385-freertos/target/thumbv7m-none-eabi/debug/talker. The C / C++ build also compiles FreeRTOS kernel + lwIP — first run ~3 min.

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/mps2-an385-freertos/rust
cargo build --manifest-path talker/Cargo.toml \
            --config talker/build/mps2-an385-freertos/nros-cargo.toml

If you skipped nros sync, the Rust build stops at its preflight with one line naming what is missing and telling you to run sync in that directory — not with a cargo error four frames down. The single-package C / C++ builds above do not need sync at all: their message bindings are a CMake-time output. See Workflow by Platform and Language for why the requirement is per language rather than per platform.

Run

# 1. Start the router (ROS's `rmw_zenohd`) on the host, on port 7800 —
#    the locator the Rust example bakes from its system.toml
#    ([image.mps2-an385-freertos] locator, shown above).
#    Slirp forwards guest 10.0.2.2:<p> → host:<p>.
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7800"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# 2. Boot the talker in QEMU. Invoke qemu-system-arm directly, with the
#    LAN9118 + Slirp wiring the example expects — the board's own cargo
#    runner is bare `-kernel`, so booting through it gives you QEMU
#    without networking:
cd examples/mps2-an385-freertos/rust/talker
qemu-system-arm -cpu cortex-m3 -machine mps2-an385 -nographic \
    -icount shift=auto \
    -semihosting-config enable=on,target=native \
    -kernel build/mps2-an385-freertos/target/thumbv7m-none-eabi/debug/talker \
    -nic user,model=lan9118

# 3. Verify from stock ROS 2:
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

QEMU exits via Ctrl-A x.

Contributors: the in-tree fixture run/test lanes for this platform are in Per-Platform Contributor Lanes.

Readiness signal. Within ~20 seconds of QEMU boot, the talker should print Publishing: 'Hello World: 1' on its semihosting stdout — the count starts at 1, matching the official ROS 2 demo talker. QEMU cold-boot through FreeRTOS init + lwIP DHCP + zenoh session open typically takes 10–15 s. If no Publishing: line in 30 seconds:

  1. Confirm the router is running on the host on the port the image dials — the locator in its system.toml (7800 for the Rust talker; Slirp forwards 10.0.2.2:7800 → host:7800). Without it the talker retries the zenoh handshake until QEMU is killed.
  2. Check the talker’s early log for lwIP DHCP timeout or Failed to open session.
  3. Bridge tip: ros2 topic echo /chatter from a stock ROS 2 install (with RMW_IMPLEMENTATION=rmw_zenoh_cpp) confirms end-to-end interop.
  4. See Troubleshooting — First 10 Minutes.

GitHub source

Canonical, copy-out:

Next

  • Subscriber: peer listener/ directory next to each talker.
  • Services + actions: peer service-*/ and action-*/ directories.
  • Real hardware: same code runs on STM32F4-Discovery / NXP-LPC55S69 / TI-MSP432 with a different board crate + linker script; see the Bare-metal Cortex-M3 page for the no-RTOS variant.
  • Your own board / RTOS: the Board Integration matrix maps each user profile (Cargo-first, vendor-IDE, Zephyr, NuttX, ESP32-C3, niche fork) to the shortest bring-up path.
  • RTOS-specific debugging: FreeRTOS LAN9118 Debugging.

Zephyr (west module)

Single-node starter on Zephyr via the in-tree zephyr/ west module. nano-ros ships as a Zephyr module — west discovers it from your workspace’s west.yml, drops in a prj.conf Kconfig surface, and the standard west build / west flash flow takes care of the rest.

Contributor path? Building nano-ros’s own Zephyr examples straight from this repository (no west-managed workspace) is covered at Zephyr (contributor). The page below is the canonical user entry.

Just want a working starter? Clone the nano-ros-zephyr-example repo (west init -m …) — a manifest + zenoh talker app pinned to a tested Zephyr, with the same quickstart as below baked in. The steps here explain what it does so you can adapt it to your own workspace.

Prereqs. Run nros setup zephyr --rmw <rmw> once (see Prerequisites below) — it provisions the Zephyr west workspace + Zephyr SDK bits, the emulator, and your RMW’s host daemon into the shared store. No hand-installed Zephyr SDK, west, or cross-toolchain, and no ROS 2 install required. Python: 3.10+ on Zephyr 3.7 LTS, but ≥ 3.12 on Zephyr 4.x (4.x’s find_package(Python3) requires 3.12 — see the version matrix below). nano-ros’s imported west fragment zephyr/west.yml is a manifest-only file — it does NOT pull Zephyr itself; that has to be in your parent manifest (zephyrproject-rtos/zephyr).

Which Zephyr? — 3.7 LTS and 4.x both supported

nano-ros consumes as a module on both the 3.7 LTS line (supported to Jan 2027; the safety-island default) and the current 4.x rolling line. You build against whatever Zephyr your workspace already pins — nano-ros adapts. The two lines differ only in how you select an RMW and apply nano-ros’s Zephyr patches:

CapabilityZephyr 3.7 LTSZephyr 4.x
Min Python3.103.12 (find_package(Python3))
RMW selectionthe image’s rmw in system.tomlthe image’s rmw in system.toml (an app with no system.toml: the -S nros-<rmw> snippet)
nano-ros patchesapplied during nros setup zephyr provisioningapplied during nros setup zephyr provisioning
Examples as samples / Twister—samples: + Twister (sample.nano-ros.*)
zenoh (native_sim)✅ build + e2e✅ build + e2e
cyclonedds (native_sim)✅ build + e2e✅ build · publish · receive · multicast-join (stable 2-node run pending a tracked k_mutex fix)
xrce✅build path WIP

native_sim networking uses NSOS (host loopback) on both lines — no TAP/bridge/root. The copy-out, snippet, patch-apply, and dual-line build flows are exercised in CI (contributor lanes just zephyr ci-both, just zephyr check-copy-out).

Project layout

A Zephyr workspace using nano-ros looks like any other Zephyr project — the nano-ros module sits beside Zephyr, your application sits beside both:

my_zephyr_ws/
├── .west/
├── zephyr/                            # cloned by `west init`
├── modules/
│   └── nano-ros/                      # imported via west.yml
└── apps/
    └── my_app/                        # your application
        ├── CMakeLists.txt
        ├── system.toml                # nano-ros config: RMW, endpoint, knobs
        ├── prj.conf                   # Zephyr's own Kconfig (+ CONFIG_NROS=y)
        ├── west.yml                   # (optional) per-app manifest
        └── src/
            └── main.c                 # nros user code

The application CMakeLists.txt is a stock Zephyr app — find_package(Zephyr)

  • target_sources. No add_subdirectory(<nano-ros>) is needed; the module shell handles it once CONFIG_NROS=y flips on.

A minimal apps/my_app/ looks like this — the shipped examples/zephyr/c/talker/ quoted directly (only the project/class names are trimmed to my_app), so this page can’t drift from the real API surface. It is a stateful C component (a struct + a configure function, RFC-0043 / phase-244.C2) — not a hand-written main() — because the Zephyr typed carrier generates the entry point and calls into the component by identity:

# apps/my_app/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app)

set(NROS_PLATFORM zephyr)
include("${NROS_REPO_DIR}/cmake/NanoRosNodeRegister.cmake")
include("${NROS_REPO_DIR}/cmake/NanoRosVerbs.cmake")

nano_ros_auto_add_library(talker_lib STATIC src/Talker.c)

nros_components_register_node(talker_lib
    PLUGIN my_app::Talker
    EXECUTABLE talker
    SHAPE configure
    TYPED
    DEPLOY zephyr)
// apps/my_app/src/Talker.c
// `talker_configure` creates a publisher on `/chatter` + a timer that
// publishes an Int32 counter each tick, with the C API. `RCLC_C_COMPONENT`
// emits the C-ABI factory/configure the Zephyr typed Entry carrier calls.
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#include <rclc/rclc.h>
#include <std_msgs/msg/int32.h>

typedef struct {
    rcl_publisher_t pub;
    rcl_timer_t timer;
    int32_t count;
} talker_t;

static void on_tick(rcl_timer_t* timer, void* ctx) {
    (void)timer;
    talker_t* self = (talker_t*)ctx;
    std_msgs__msg__Int32 msg = {.data = self->count};
    if (rcl_publish(&self->pub, &msg, NULL) == RCL_RET_OK) {
        printf("Published: %d\n", (int)self->count);
    }
    self->count++;
}

static rcl_ret_t talker_configure(rcl_node_t* node, rclc_executor_t* executor, talker_t* self) {
    self->count = 0;
    self->pub = rcl_get_zero_initialized_publisher();
    rcl_ret_t rc = rclc_publisher_init_default(
        &self->pub, node, ROSIDL_GET_MSG_TYPE_SUPPORT(std_msgs, msg, Int32), "/chatter");
    if (rc != RCL_RET_OK) {
        return rc;
    }
    self->timer = rcl_get_zero_initialized_timer();
    rc = rclc_timer_init_with_context(&self->timer, 500000000ULL, on_tick, self);
    if (rc != RCL_RET_OK) {
        return rc;
    }
    return rclc_executor_add_timer(executor, &self->timer);
}

RCLC_C_COMPONENT(talker_t, talker_configure)

See examples/templates/zephyr-byo/app/src/Talker.c for the up-to-date source (this is that BYO starter’s talker, only stripped of its file header comment; the canonical in-tree examples/zephyr/c/talker/ publishes std_msgs/String the same way). The component uses the C API only: configure is handed an rcl_node_t * and an rclc_executor_t * over the carrier’s own node and executor, so it shares both with a C++ component.

Its configuration is in Configure below. You do not choose the language API: the module renders it from the app, and the typed Zephyr carrier that drives this component’s configure is C++, so the nros-cpp header surface is on the app include path even though Talker.c itself is C.

Prerequisites

nros setup provisions the parts nano-ros owns — the RMW host daemon (zenohd / Micro-XRCE-DDS agent) and the RMW transport submodules (zenoh-pico + mbedtls for zenoh, the cyclonedds fork) — from a pinned index into ${NROS_HOME:-~/.nros}/sdk / the nano-ros checkout. It does not replace Zephyr’s own SDK, and interface codegen still needs the ROS message definitions.

  1. Build the in-tree nros CLI (Phase 218, from the nano-ros checkout):
    ./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
    source ./activate.sh        # OR: direnv allow / source ./activate.fish
    
  2. Provision the RMW (daemon + transports) from the nano-ros checkout:
    ( cd modules/nano-ros && nros setup zephyr --rmw zenoh )  # zenohd + zenoh-pico + mbedtls
    ( cd modules/nano-ros && nros setup --source px4-rs )     # workspace cargo-load dep
    
  3. Install the Zephyr SDK the standard Zephyr way (nros setup does not provide it) and expose it — export ZEPHYR_SDK_INSTALL_DIR=/path/to/zephyr-sdk-<ver> (or register it via the SDK’s setup.sh -c).
  4. Message definitions for codegen. The interface codegen resolves a package’s msg/*.msg from NROS_<PKG>_DIR (e.g. export NROS_STD_MSGS_DIR=/opt/ros/humble/share/std_msgs) — point it at a ROS install or any dir holding the .msg files.

The RMW host daemon must be running before an example connects (for zenoh: ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd; the Micro-XRCE-DDS agent for xrce).

Configure

Add nano-ros (and a Zephyr pin — zephyr/west.yml is manifest-only; it does not pull Zephyr itself) to your workspace west.yml:

manifest:
  remotes:
    - name: nano-ros
      url-base: https://github.com/NEWSLabNTU
    - name: zephyr
      url-base: https://github.com/zephyrproject-rtos
  projects:
    - name: zephyr
      remote: zephyr
      revision: v3.7.0          # or your chosen 3.7 LTS / 4.x SHA
      path: zephyr
      import: true               # pulls Zephyr's own modules
    - name: nano-ros
      remote: nano-ros
      revision: nros-v0.6.0      # pin a release tag (west needs SOME
                                 # revision — its default `master` does
                                 # not exist here). `main` works for
                                 # tracking tip, but a tag keeps your
                                 # build reproducible.
      path: modules/nano-ros
      import:
        file: zephyr/west.yml    # pulls nano-ros's transport deps

Then, per application, nano-ros’s half of the configuration goes in a system.toml beside the CMakeLists.txt (RFC-0098 D10, phase-481), and Zephyr’s own half in prj.conf:

# system.toml
[system]
name = "my_app"
rmw = "zenoh"
domain_id = 0

[image.zephyr]
board = "zephyr"            # the concrete board is west's `-b`
rmw = "zenoh"               # zenoh | xrce | cyclonedds
# locator = "tcp/192.0.2.10:7447"
# [image.zephyr.knobs.zenoh]
# max_publishers = 4
# prj.conf — Zephyr settings only; never a CONFIG_NROS_* knob here
CONFIG_NROS=y               # enables the module; everything else is the image's
CONFIG_NETWORKING=y
CONFIG_NET_IPV4=y
CONFIG_NET_TCP=y

The module renders the image into a Kconfig fragment merged after prj.conf: the RMW from rmw, the language API from your app, the endpoint from locator, the domain from domain_id, and every env knob (any symbol in the Kconfig reference, spelled without CONFIG_). A CONFIG_NROS_* line in prj.conf would be overridden by it, so it is at best dead. Several images in one file — one per RMW — are picked with west build … -- -DNROS_IMAGE=<id>; see Configuring an image. An app with NO system.toml gets no rendering and is configured by Kconfig defaults and the RMW snippets alone.

CONFIG_NROS=y activates the shell, which maps Kconfig values to NROS_* CMake cache vars and add_subdirectory()s the root nano-ros CMake. NanoRos::NanoRos is linked into your app library transparently.

Build

If your workspace is a fresh manifest-only dir (no .west/), initialise it first so west knows which west.yml is the manifest:

cd my_zephyr_ws
west init -l .                           # one-time; points west at the
                                         # local west.yml in cwd
west update                              # clones nano-ros + Zephyr into the workspace

(If you started from west init -m <remote>, both calls above are already done — go straight to west build below.)

nros build on Zephyr: it hands off, it does not take over

The other platforms let nros build own the build: it generates a cargo or CMake root from your [image.*] declarations and runs the tool. Zephyr is not like that, on purpose.

You own the west workspace and the application. west init / west update populate the workspace, your app carries its own CMakeLists.txt and prj.conf, and west only runs from inside that workspace. None of that is ours to generate — an app’s Kconfig is authored, not derived (RFC-0065 D5).

So for a Zephyr image nros build:

  1. resolves the application — the entry package for that image’s board, not the bringup;
  2. resolves your Zephyr — $ZEPHYR_BASE, else $NROS_ZEPHYR_WORKSPACE, else an in-tree zephyr-workspace/, else a sibling ../nano-ros-workspace. Note it is a Zephyr that is needed, not a .west/ directory: with ZEPHYR_BASE set, west build runs from anywhere, which is what makes a freestanding application — one outside the west workspace — work;
  3. runs west build from there with the image’s overlays (EXTRA_CONF_FILE, APPLICATION_CONFIG_DIR) already applied.

If it cannot find a workspace it does not guess. It prints the command and where to run it:

$ nros build demo_bringup:zephyr
Error: no west workspace found, so `west build` cannot be run for `zephyr`.

Zephyr differs from the other drivers: YOU own the west workspace and the
application, and `west` only runs inside a workspace …

Run this from your west workspace:

    west build -b native_sim/native/64 …/src/zephyr_entry

Point nros at your workspace:

    NROS_ZEPHYR_WORKSPACE=<dir>   # the dir containing zephyr/

That is deliberate and it is the same boundary nros setup --system draws when it composes an install command and prints it rather than running sudo for you: where the tool is yours, nano-ros hands you the command instead of assuming it may act.

The plain west build below keeps working and is still the primary flow. nros build is the convenience that applies an image’s overlays for you; it never becomes a required layer between you and west.

Starting from nothing: nros new node and nros new entry

A node package is board- and RMW-agnostic — it names no platform, which is what lets the same package be deployed to Linux and to a Cortex-M without an edit:

nros new node talker_pkg

It writes the package and adds the [[component]] row to your bringup. (Use nros new <name> --platform linux only for a standalone runnable project — it pins a board crate, so an entry on another platform cannot link it.)

The entry: nros new entry

You do not have to write the entry package by hand:

nros new entry zephyr_entry --platform zephyr
nros new entry: scaffolded ./src/zephyr_entry (7 file(s))
  declared [image.zephyr_entry] in ./src/demo_bringup/system.toml

Next:
  nros sync
  nros build demo_bringup:zephyr_entry --zephyr-workspace <dir>

It writes the package — Cargo.toml, CMakeLists.txt, build.rs, src/lib.rs, prj.conf, prj-zenoh.conf, boards/<board>.conf — and three things outside it that are easy to miss and invisible until they fail:

  • the [image.*] block, with board, entry and the conf overlay the entry’s own CMakeLists.txt requires;
  • an exclude entry in every enclosing cargo workspace, because a west-built staticlib must not be an ordinary cargo member;
  • a path dependency per node package the bringup declares, since the entry links them.

--board, --rmw and --bringup override the defaults (native_sim/native/64, zenoh, and the workspace’s single bringup).

The whole flow, end to end

Five commands, run from your workspace. This is the examples/workspaces/rust transcript, verbatim:

# 0. tell nros where your west workspace is — the directory containing
#    `zephyr/`. On the command line:
#
#      nros build demo_bringup:zephyr --zephyr-workspace /path/to/zephyr-workspace
#
#    or once for the shell, if you would rather not repeat it:
export NROS_ZEPHYR_WORKSPACE=/path/to/zephyr-workspace

# 1. generate the message bindings this workspace's packages depend on.
#    `nros build` refuses with "this workspace has never been synced" until
#    you do — it will not silently build against absent bindings.
nros sync

# 2. build. This resolves the application, applies the image's overlays and
#    runs `west build` for you.
nros build demo_bringup:zephyr

# 3. start the router ROS itself uses — the one `rmw_zenoh_cpp` ships, so it
#    cannot drift from the RMW your ROS 2 nodes use. Separate terminal; it
#    keeps running. Listens on tcp/127.0.0.1:7447.
ros2 run rmw_zenoh_cpp rmw_zenohd

# 4. run the image (its own build dir: build/<coordinate>/<bringup>__<image>_west).
./build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.exe
*** Booting Zephyr OS build v3.7.0 ***
<inf> nros_net_wait: Network ready (NSOS — host kernel sockets)
<inf> rust: rustapp: nros: zephyr workspace entry up (2 nodes)
<inf> rust: talker_pkg: talker publishing chatter seq=0
<inf> rust: talker_pkg: talker publishing chatter seq=1

and from a ROS 2 shell on the same router, the nodes are ordinary ROS nodes:

$ export RMW_IMPLEMENTATION=rmw_zenoh_cpp
$ ros2 topic echo /chatter --once
data: 6
---

Step 3 is not optional and its ORDER does not matter, but the locator does: zenoh-pico is a client, so with no router the image gets

<err> rust: nros: zephyr entry — executor open failed: Transport(ConnectionFailed)

which names no port. Both sides default to tcp/127.0.0.1:7447 — the port rmw_zenohd listens on and a rmw_zenoh_cpp node connects to. Override with the image’s locator = "tcp/…" in system.toml and ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:<port>"]' on the router, and change both.

What an image declares

The Zephyr images in examples/workspaces/* are the worked examples:

[image.zephyr]
board = "zephyr"
entry = "zephyr_entry"          # only when several entries match, see below
conf  = ["prj-zenoh.conf"]      # the RMW overlay this app requires
  • conf names the app’s own Kconfig fragments, in order. They are looked for beside the board config dir, then beside the application, then beside the bringup — the app rung matters because a Zephyr app keeps its prj-<rmw>.conf next to the CMakeLists.txt west builds. These entries FATAL_ERROR without an RMW overlay, so the image has to say which one.
  • board is a nano-ros board id, which nano-ros resolves to the Zephyr board target. See below.
  • entry names the application package. Normally leave it out: an entry declares the deploy target it serves — its own system.toml in Rust, nano_ros_add_executable(... DEPLOY zephyr) in CMakeLists.txt — and one package usually claims a given board. Set it when the derivation cannot answer, which happens two ways and says which:
    • several packages claim the board — deriving would be a coin flip, so nros build lists the candidates rather than picking one. Reachable for C/C++ entries, which are matched by their DEPLOY token;
    • none does — nros build falls back to the Bringup directory, and you see a conf fragment “not found” in a path that is not your application’s. A hand-written application that picks its board with nano_ros_use_board(...) inside its own CMakeLists.txt is this case: the DEPLOY scan does not read that call (issue 1517). Usually the better fix is to delete the application — nros build generates one and hands it the board package’s own Kconfig and devicetree overlay, which is all nano_ros_use_board added (issue 1288).

If you live in west: west nros

Everything above assumes you stand in your nano-ros workspace and name the Zephyr one. If you work the other way round — inside the west workspace — west already knows where Zephyr is, so it can tell nros for you:

cd ~/zephyr-workspace
west nros build --workspace ~/my_robot demo_bringup:zephyr

No ZEPHYR_BASE, no NROS_ZEPHYR_WORKSPACE, no --zephyr-workspace. Every argument after nros is passed straight through, so anything nros accepts works here unchanged.

It picks up automatically in any workspace whose manifest lists nano-ros. west nros is now the only nano-ros west extension: west fvp shared that route until RFC-0064 revision 5 retired it, since its body was environment wiring in front of the stock west build -t run. Two things worth knowing:

  • It only exists inside a west workspace. West loads extension commands from the manifest, so from an unrelated directory you get west: unknown command "nros". That is why this is an addition rather than a replacement: it swaps which of the two trees you have to name, trading the Zephyr path (which differs per machine) for your own workspace path (which you chose).
  • It never overwrites a ZEPHYR_BASE you set yourself — an exported one is a deliberate choice, and this only fills in a missing answer.

Which boards you can name

board names a board in nano-ros’s catalog, and the board’s descriptor states the Zephyr id west build -b receives:

# packages/boards/zephyr/nros-board.toml
names = ["zephyr"]                        # what your images say

[board.zephyr]
west_board = "native_sim/native/64"       # what west is given

So an image writes board = "zephyr", never board = "native_sim/native/64": the Zephyr spelling is the descriptor’s to state, and an image that authors it is refused with the id to write instead (issue 1519).

Nor is it free-form. A board the catalog does not know is refused with the list of ones it does, because the descriptor supplies more than the -b string — the platform, the toolchain, the entry kind, the declared capabilities. There is no safe default for those.

A board is a package — put it in your workspace. Drop an nros-board.toml beside a package’s package.xml and it joins the catalog like any other package in src/:

my_robot/src/
├── talker_pkg/
└── my_board/
    ├── package.xml
    └── nros-board.toml
# src/my_board/nros-board.toml
[[board]]
names = ["my-board"]              # what your images say
west_board = "qemu_cortex_m3"     # what west is given
platform = "zephyr"
toolchain = "stable"
platform_feature = "platform-zephyr"
link_kind = "none"
entry_kind = "zephyr-staticlib"
# src/demo_bringup/system.toml
[image.zephyr]
board = "my-board"

No environment variable, nothing outside the tree, nothing copied into the nano-ros checkout. west_board is what lets the friendly name be the one your workspace uses, which is also what the shipped Zephyr descriptors do (under [board.zephyr]). Leave it out only when your board name already is the west board target and you have no other name for it.

For a board shared by several workspaces there is no single workspace to put it in, and $NROS_EXTRA_BOARD_PATH still covers that: PATH-style, naming directories shaped like packages/boards/.

nros new board <name> --for-platform zephyr scaffolds the descriptor. Note this is the nano-ros descriptor; an out-of-tree Zephyr board definition (its devicetree and board.yml) is contributed the Zephyr way, through your module’s board_root.

Adding your own drivers, modules and boards

Everything Zephyr lets you extend, you still extend the Zephyr way. The application CMakeLists.txt is yours (RFC-0065 D5) and nros build runs west build against it, so the extension points are untouched:

you wantyou donros build involvement
an out-of-tree modulelist(APPEND ZEPHYR_EXTRA_MODULES <dir>) before find_package(Zephyr)none
a driver in that modulezephyr_library() + zephyr_library_sources(), gated on your own Kconfignone
its devicetree bindingdts/bindings/ + settings: {dts_root: .} in the module’s zephyr/module.ymlnone
an out-of-tree board / SoC / arch / snippetthe matching *_root in the same module.yml (board_root, soc_root, arch_root, snippet_root, module_ext_root)none
turning your Kconfig on for one imagea conf fragment on [image.*]passed as EXTRA_CONF_FILE
a devicetree overlay for one imagean .overlay in the same conf listpassed as EXTRA_DTC_OVERLAY_FILE

Only the last two rows are ours, and they are how an IMAGE differs from its siblings — the module itself is a property of the application, not of one image, so it belongs in the app’s CMakeLists.txt where you would have put it anyway.

nros build passes exactly four things to west of its own — APPLICATION_CONFIG_DIR, EXTRA_CONF_FILE, EXTRA_DTC_OVERLAY_FILE, FILE_SUFFIX, all Zephyr’s own variables — and there is deliberately no image key for arbitrary -D defines. Something your application always needs belongs in its CMakeLists.txt, which keeps one place to look.

Customizing the west command itself

Anything after -- goes to west. west build has two argument zones, and nros build routes each token to the right one using west’s own flag list:

nros build demo_bringup:zephyr -- --pristine        # a west option
nros build demo_bringup:zephyr -- -t run            # build, then run it
nros build demo_bringup:zephyr -- -DMY_OPT=1        # a cmake option
nros build demo_bringup:zephyr -- -p always -DX=1   # both, in one go

which becomes, respectively, the west option zone and the cmake zone:

west build -b <board> <app> -p always -- -DEXTRA_CONF_FILE=… -DX=1

nros build --dry-run prints that line without running it, so you can always see exactly what west is being asked to do — and copy it if you would rather drive west yourself.

Two flags are refused rather than routed, because the image already decides them and a build must not be able to disagree with its own declaration:

$ nros build demo_bringup:zephyr -- --sysbuild
Error: `--sysbuild` is decided by the image, not by the command line.
It comes from the presence of a `sysbuild.conf` beside the application.

Change it there, so one build cannot disagree with the declaration it was
resolved from.

The other is -b/--board, which comes from the image’s board.

Verified by building one. An out-of-tree module with its own Kconfig, a driver source gated on it, a devicetree overlay declaring a node and a binding shipped from the module’s dts_root, added to examples/workspaces/rust’s entry and enabled through the image:

[image.zephyr]
conf = ["prj-zenoh.conf", "prj-user-extra.conf", "user-widget.overlay"]
*** Booting Zephyr OS build v3.7.0 ***
user_extra: out-of-tree driver init, widget-id=42
<inf> rust: rustapp: nros: zephyr workspace entry up (2 nodes)
<inf> rust: talker_pkg: talker publishing chatter seq=0

The driver’s BUILD_ASSERT(DT_NODE_EXISTS(DT_NODELABEL(user_widget))) is what makes that a test rather than a screenshot: without the overlay reaching the build, it fails to compile instead of quietly doing nothing.

A mistake in your module surfaces as Zephyr’s own error, unmediated — a wrong kconfig: path in module.yml reports

ERROR: "kconfig" key in …/extra_module/zephyr/module.yml has value
"../Kconfig" which does not point to a valid Kconfig file.

which is the message you would get from a plain west build, because it is a plain west build.

The transports + px4-rs come from the prerequisites step (west update clones nano-ros but not its submodules). With the Zephyr SDK + NROS_STD_MSGS_DIR exported (also prerequisites), build your app — nros on PATH is auto-resolved as the codegen tool:

# native_sim (POSIX, no QEMU). The 3.7 line needs the NSOS line overlay; apply
# the NSOS patches first (see "apply nano-ros's patches" below).
overlay="$PWD/modules/nano-ros/cmake/zephyr/native-sim-line-3.7.conf"
west build -b native_sim/native/64 apps/my_app -- -DCONF_FILE="prj.conf;$overlay"

# A real board, e.g. Cortex-A9 (no native_sim overlay):
west build -b qemu_cortex_a9 apps/my_app

(Verified end-to-end on a fresh BYO west workspace: this builds to zephyr.exe and runs to Published: 0 against a router started with ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd. On the Zephyr 4.4 line, find_package(Python3) requires ≥ 3.12 and you select the RMW with -S nros-zenoh instead of the overlay.)

For a quick sanity check that the module is wired correctly:

west build -t menuconfig                 # confirm CONFIG_NROS=y is visible

Rust applications

Two things differ for a Rust app (C/C++ apps skip this section):

  1. The Rust crate’s [lib] must be named rustapp (crate-type = ["staticlib"]) — a zephyr-lang-rust contract: its rust_cargo_application() links librustapp.a. The Cargo package name is free.

  2. Generate the interface crates and the dependency wiring for YOUR layout. From your app dir, run (after the Prerequisites nros setup, which provides the codegen toolchain + message sources):

    nros sync --nano-ros-path <path-to-your-nano-ros-module>
    

    This writes generated/<pkg>/ (the message crates) and a .cargo/config.toml whose [patch.crates-io] points the nros-* crates at your modules/nano-ros/packages/core/* and the generated interfaces at generated/*. Adjust --nano-ros-path to your workspace’s modules/nano-ros/packages/core (the dir holding nros-core, nros-node, …). In-tree examples commit no .cargo/config.toml at all (phase-445 W6): their equivalent is generated into build/<image>/nros-cargo.toml and is likewise repo-relative, so it is no more copyable than the old file was.

Run

# 1. Start the router (ROS's `rmw_zenohd` — nano-ros ships no router
#    of its own) on port 7447, the port your app dials by default
#    (an image with no `locator` gets "tcp/127.0.0.1:7447"; change
#    either side to match):
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# 2. Boot the app from your BYO west workspace:
# QEMU Cortex-A9:
west build -t run
# native_sim:
./build/zephyr/zephyr.exe

# 3. Verify from stock ROS 2 in another terminal. Match the message
# type to the app you booted: the canonical in-tree talker publishes
# std_msgs/String; the raw BYO Talker.c shown above publishes
# std_msgs/Int32.
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   # in-tree talker
# ros2 topic echo /chatter std_msgs/msg/Int32 --qos-reliability best_effort  # BYO my_app

Contributors: nano-ros’s own zephyr talker has a matching in-tree recipe for the canonical native_sim build path — see Per-Platform Contributor Lanes.

The Zephyr boot banner runs first, then the talker fires: the canonical in-tree talker prints Publishing: 'Hello World: 1', Publishing: 'Hello World: 2', … (Rust + C + C++ all count from 1, matching the official ROS 2 demo talker); the raw BYO Talker.c above prints Published: 0, Published: 1, …

Readiness signal. On native_sim, expect the first publish line (Publishing: 'Hello World: 1' for the in-tree talker; Published: 0 for the BYO app) within 5 seconds of boot (or ./build/zephyr/zephyr.exe); on qemu_cortex_a9 expect it within ~15 seconds (QEMU cold boot + Zephyr init). If no publish line in 30 seconds:

  1. Confirm CONFIG_NROS=y lit up via west build -t menuconfig; without it the module shell never add_subdirectory’s nano-ros.
  2. Check CONFIG_NETWORKING=y, CONFIG_NET_IPV4=y, CONFIG_NET_TCP=y in prj.conf — Zephyr networking is opt-in.
  3. Confirm zenohd reachable from the simulated network (Slirp needs 10.0.2.2:7447 on QEMU; native_sim uses host loopback).
  4. See Troubleshooting — First 10 Minutes.

Zephyr 4.x build gotchas.

  • Could NOT find Python3 ... required is at least "3.12" — 4.x needs Python ≥ 3.12. Provision one without sudo (e.g. uv venv --python 3.12 .venv312 && uv pip install west -r zephyr/scripts/requirements.txt) and run west through it (.venv312/bin/python -m west build ...), so the ROS descriptor-codegen subprocess still uses the system ROS Python.
  • attempt to assign the value ... to the undefined symbol ETH_NATIVE_POSIX — that symbol was renamed ETH_NATIVE_TAP in 4.x; the version-aware NSOS overlay handles it (contributors: the in-tree just zephyr build-one recipe applies it automatically).

Zephyr 4.x: select the RMW with a snippet

On 4.x, nano-ros ships west snippets so you pick the RMW on the build line instead of hand-writing the overlay:

west build -b native_sim/native/64 -S nros-cyclonedds apps/my_app
#                                   ^^^^^^^^^^^^^^^^^^^  nros-zenoh | nros-cyclonedds | nros-xrce

The snippet (shipped via the module’s snippet_root) carries the RMW-common Kconfig — equivalent to merging prj-<rmw>.conf. It is for an app with NO system.toml: with one, the image’s rmw decides and merges after the snippet, so a snippet that agrees is redundant and one that names a different RMW is refused at configure time (phase-481). The prj-<rmw>.conf / -DCONF_FILE path still works (and is the only option on 3.7).

nano-ros patches into your workspace

nano-ros needs a few patches to Zephyr’s native_sim NSOS driver (recvmsg, IPv4-multicast) so the host-loopback-based examples work. Both supported lines (3.7 LTS and 4.x) take the same path — nros setup zephyr reads zephyr/patches.yml and applies each patch against the workspace’s Zephyr tree, sha256-checked. No extra step on your side beyond the provisioning command (already run during Prerequisites).

To re-apply (e.g. after a west update reset the tree):

nros setup zephyr --rmw zenoh

(Earlier nano-ros revisions documented a west patch apply flow on 4.x — that required a workspace-side west extension that doesn’t ship with stock Zephyr. Phase 208.D.7 / E.9 unified both lines on the provisioner. Cyclone-DDS-on-Zephyr patches stay baked into the pinned cyclonedds submodule, not delivered through patches.yml.)

Copy out an example as your starting point

The examples/zephyr/<lang>/<role>/ dirs are copy-out clean — copy one into your own app tree and it builds against the nano-ros module with no reference back into the nano-ros repo:

cp -r modules/nano-ros/examples/zephyr/c/talker apps/my_app
# cyclonedds examples need the host idlc + the ROS message dirs:
export NROS_STD_MSGS_DIR=/opt/ros/humble/share/std_msgs   # PKG_DIR contract
west build -b native_sim/native/64 -S nros-zenoh apps/my_app

Cyclone idlc and the descriptor-gen scripts are located via the module’s exported cache vars (NROS_CYCLONE_IDLC, NROS_CYCLONE_SCRIPTS_DIR, NROS_CYCLONE_CMAKE_DIR); message-package dirs come from NROS_<PKG>_DIR env (defaulting to /opt/ros/humble/share/<pkg>). No /opt/ros or repo-relative paths are baked into the example.

See the zephyr/ module dir + its Kconfig for the canonical in-repo surface.

GitHub source

Next

  • Pick a real board (Nordic, NXP, STM32, …): swap -b <board> and add a board-specific overlay to your prj.conf.
  • Cyclone DDS on Cortex-A/R: see the DDS section of Choosing an RMW Backend for the required Kconfig deltas.
  • Build nano-ros’s own Zephyr examples without west: Zephyr (contributor).

Zephyr (contributor / in-tree workflow)

Looking for the user-facing path? This page covers building nano-ros’s own Zephyr examples from this repository. If you’re consuming nano-ros as a Zephyr module in YOUR Zephyr workspace, see Integration: Zephyr (west module) instead.

Complete setup procedure for Zephyr native_sim testing. Networking uses NSOS (Native Sim Offloaded Sockets) — each socket call is forwarded to the host kernel, so tests run on 127.0.0.1 without TAP devices, bridges, or sudo.

Overview

nros uses an in-tree Zephyr workspace at zephyr-workspace/ (gitignored). Set $NROS_ZEPHYR_WORKSPACE to install elsewhere.

nros/
├── scripts/zephyr/
│   ├── setup.sh                      # Initialize workspace
│   ├── migrate-workspace.sh          # Move legacy sibling install in-tree
│   ├── downloads/                    # SDK tarball cache (gitignored)
│   └── sdk/                          # Installed Zephyr SDK (gitignored)
├── zephyr/                           # Zephyr module definition
│   ├── Kconfig                       # RMW backend, API selection, tuning
│   ├── CMakeLists.txt                # Transport C sources + nros-c build
│   └── cmake/                        # nros_cargo_build(), nros_generate_interfaces()
├── examples/zephyr/
│   ├── rust/                   # Rust + zenoh (talker, listener, ...)
│   ├── rust/xrce/                    # Rust + XRCE-DDS (talker, listener)
│   ├── c/                      # C + zenoh (talker, listener)
│   └── c/xrce/                       # C + XRCE-DDS (talker, listener)
├── west.yml                          # West manifest
└── zephyr-workspace/                 # Created by setup.sh (gitignored)
    ├── nros -> ../                   # Symlink back to repo root
    ├── zephyr/                       # Zephyr RTOS v3.7.0
    └── modules/                      # HALs, zephyr-lang-rust

Migrating from the legacy sibling layout

Legacy setups put the workspace at ../nano-ros-workspace/ with an in-tree symlink. Both layouts work (contributors: the in-tree just zephyr recipes auto-detect either), but to consolidate run:

./scripts/zephyr/migrate-workspace.sh --dry-run     # preview
./scripts/zephyr/migrate-workspace.sh               # execute

Prerequisites

Build the in-tree nros CLI (Phase 218), then let it provision the toolchain:

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

nros setup provides most components prebuilt per platform per RMW — the cross-compiler, emulator, RMW host daemon, and SDK sources (including the Zephyr west workspace + Zephyr SDK bits) are fetched from a pinned index into a shared store at ${NROS_HOME:-~/.nros}/sdk. You do not hand-install a cross-toolchain, and you do not need ROS 2 installed.

Packages are prebuilt where the index has a binary for your host and built from source otherwise — zenohd is the notable source build, and zenoh is the default --rmw. --dry-run prints the plan for your host. See Installation for the full explanation.

Step 1: Initialize Workspace (One-Time)

nros setup zephyr --rmw zenoh      # --rmw defaults to zenoh; xrce | cyclonedds also valid
source ./activate.sh

This provisions:

  • The Zephyr west workspace + Zephyr SDK bits
  • The emulator
  • The RMW host daemon (zenohd for zenoh, the Micro-XRCE-DDS agent for xrce)
  • The in-tree workspace at zephyr-workspace/ (gitignored; auto-detects legacy ../nano-ros-workspace/), with nros symlinked in
  • Rust embedded targets

Contributors: the in-tree just setup zephyr recipe still works and now delegates to nros setup zephyr under the hood.

The RMW host daemon must be running before any example: zenohd for zenoh, the Micro-XRCE-DDS agent for xrce. nros setup zephyr --rmw <rmw> installs it.

Step 2: Networking

No network setup is required. native_sim uses the NSOS offloaded-sockets driver, enabled by boards/native_sim_native_64.conf in each example:

CONFIG_ETH_NATIVE_POSIX=n
CONFIG_NET_SOCKETS_OFFLOAD=y
CONFIG_NET_NATIVE_OFFLOADED_SOCKETS=y

With NSOS, Zephyr’s socket API goes straight to host syscalls. Bind to 127.0.0.1 and reach zenohd / the XRCE Agent on the host loopback just like any other native test. Multiple native_sim processes can coexist without bridge configuration.

Step 3: Build and Run Zephyr Examples

# Source environment
source zephyr-workspace/env.sh

cd zephyr-workspace

# Rust — `nros sync` first, once per checkout location. It writes the
# generated message bindings and the [patch.crates-io] table the leaf's
# (GITIGNORED, never committed — phase-445 W6) .cargo/config.toml
# includes. Zephyr is the one lane that still needs that file rather than
# build/<image>/nros-cargo.toml: west drives cargo through
# zephyr-lang-rust's rust_cargo_application, which passes no --config
# (issue 1288). This applies to cargo under WEST too: west
# drives the same leaf, so without sync the build fails while PARSING the
# manifest, with an error that never names sync (issue 0694).
#
# `--no-metadata` because the source-metadata probe builds a host binary that
# path-deps this leaf, dragging in the `zephyr` crate — whose build.rs needs a
# DOTCONFIG from a Zephyr cmake configure that has not run yet, and cannot,
# since this sync is its prerequisite (issue 0318). Without the flag the sync
# still succeeds but prints a "no producer" error the reader cannot act on.
# Codegen and the patch table — the reason to run it — are unaffected.
nros sync --no-metadata ../examples/zephyr/rust/talker

# Build Zephyr talker (Rust + zenoh, default backend).
#
# `../examples/...` rather than `<name>/examples/...`: the workspace's link
# back to the checkout is named after YOUR checkout directory
# (`scripts/zephyr/setup.sh` uses `basename "$NROS_REPO_DIR"`), so it reads
# `nano-ros/` for a plain `git clone` and `nros/` only if you renamed the
# directory. For the in-tree workspace this page sets up, `..` IS the
# checkout and needs no name at all.
west build -b native_sim/native/64 ../examples/zephyr/rust/talker

# Run (no sudo needed)
./build/zephyr/zephyr.exe

Configuring an image: system.toml, not prj.conf

An application’s nano-ros configuration lives in ONE place, its system.toml (RFC-0098 D10, phase-481). The nano-ros Zephyr module renders the selected image into a Kconfig fragment and merges it last, after every conf file. So the prj*.conf files hold Zephyr’s own settings only (networking, POSIX, stacks, heaps) and never a CONFIG_NROS_* line: one there is either dead (the image overrides it) or a second source that can disagree with the first. A gate (check-leaf-conf-nros-knobs) refuses one in the tree.

What the module renders, and from where:

You write in system.tomlThe build gets
[image.<id>] rmw = "xrce"CONFIG_NROS_RMW_XRCE=y
(nothing — the package’s language)CONFIG_NROS_{C,CPP,RUST}_API=y
locator = "tcp/…" (zenoh)CONFIG_NROS_ZENOH_LOCATOR
locator = "udp/<host>:<port>" (XRCE)CONFIG_NROS_XRCE_AGENT_ADDR + _PORT
domain_id = 7 (image, [defaults] or [system])CONFIG_NROS_DOMAIN_ID
[image.<id>.knobs.zenoh] max_queryables = 12CONFIG_NROS_MAX_QUERYABLES=12 — a registry knob, spelled as a board’s [knobs] is (see Configuration)
[image.<id>.knobs.xrce] max_subscribers = 0CONFIG_NROS_XRCE_MAX_SUBSCRIBERS=0 — likewise
env = { NROS_ZEPHYR_TASK_STACK_SIZE = "16384" }CONFIG_NROS_ZEPHYR_TASK_STACK_SIZE=16384 — any other option in the reference below, spelled without CONFIG_
conf = ["prj-xrce.conf"]that Zephyr-native fragment, added ahead of your own EXTRA_CONF_FILE

An env row with no Kconfig symbol is not lost: it reaches the image’s cargo builds as an [env] row instead (issue 1712). A row whose type does not fit its symbol, or that only exists under another RMW, is refused at configure time, naming the system.toml line.

One image per RMW

An application built for several backends states one image per RMW, and picks one at build time. From examples/zephyr/c/talker/system.toml:

[system]
name = "nros_zephyr_talker_c"
rmw = "zenoh"
domain_id = 0
default_images = ["zephyr_zenoh"]

[image.zephyr_zenoh]
board = "zephyr"
rmw = "zenoh"
conf = ["prj-zenoh.conf"]

[image.zephyr_xrce]
board = "zephyr"
rmw = "xrce"
conf = ["prj-xrce.conf"]
locator = "udp/127.0.0.1:2018"

[image.zephyr_xrce.knobs.xrce]
max_subscribers = 0
max_service_servers = 0
max_service_clients = 0

[image.zephyr_cyclonedds]
board = "zephyr"
rmw = "cyclonedds"
conf = ["prj-cyclonedds.conf"]
west build -b native_sim/native/64 ../examples/zephyr/c/talker                       # default_images: zenoh
west build -b native_sim/native/64 ../examples/zephyr/c/talker -- -DNROS_IMAGE=zephyr_xrce

-DNROS_IMAGE is a CMake cache variable like any -D: re-running west build on the same build directory WITHOUT it keeps the image it last built, exactly as -b keeps the board. Pass it every time, or use -p when switching. A name the system.toml does not declare is refused, listing the ones it does.

Precedence, last wins: the conf files, then a 4.x -S nros-<rmw> snippet, then the image’s rendering, then any -DCONFIG_… on the command line (how a test harness passes a per-run port). A snippet naming a DIFFERENT RMW than the image is refused rather than silently overridden; one that agrees is redundant. An application with no system.toml gets no rendering, so for it the snippets and Kconfig defaults still choose.

What each RMW’s Zephyr-native fragment needs

The image’s conf file carries what the backend needs from Zephyr itself:

  • Zenoh (the Kconfig default): TCP and POSIX threads for zenoh-pico — CONFIG_NET_TCP=y, CONFIG_POSIX_API=y, and pthread pools big enough for it (examples/zephyr/rust/talker/prj-zenoh.conf uses CONFIG_MAX_PTHREAD_MUTEX_COUNT=64, CONFIG_MAX_PTHREAD_COND_COUNT=16).
  • XRCE-DDS: CONFIG_NET_TCP=y, on top of the BSD sockets (CONFIG_NET_SOCKETS=y) every image’s shared prj.conf enables. The agent endpoint is the image’s locator.
  • Cyclone DDS: brokerless RTPS, wire-compatible with stock ROS 2 (rmw_cyclonedds_cpp). Its source is C++, so CONFIG_CPP=y even for Rust callers; it needs a large stack (CONFIG_MAIN_STACK_SIZE=524288), pthread pools, and CONFIG_NET_IPV4_IGMP=y for SPDP multicast. The bool prerequisites (thread-local storage, dynamic threads, NET_TCP, …) are selected by CONFIG_NROS_RMW_CYCLONEDDS. Its ddsrt_malloc draws on the nano-ros platform heap, 1 MiB by default under Cyclone — raise it with [image.<id>.knobs.zephyr] heap_size = … on the image, not in the conf.

On native_sim, add the NSOS host-socket offload (CONFIG_NET_SOCKETS_OFFLOAD=y + CONFIG_NET_NATIVE_OFFLOADED_SOCKETS=y) so discovery uses host BSD sockets instead of zeth/TAP. See examples/zephyr/rust/talker/prj-cyclonedds.conf for the full fragment.

API Selection

The language API comes from the package itself — the module renders CONFIG_NROS_RUST_API, CONFIG_NROS_C_API or CONFIG_NROS_CPP_API from the package’s entry language, so you never set it. What differs is the CMakeLists.txt.

Rust

prj.conf needs Zephyr’s own Rust support (CONFIG_RUST=y, CONFIG_RUST_ALLOC=y), and CMakeLists.txt uses rust_cargo_application():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
rust_cargo_application()

C

CMakeLists.txt uses nros_generate_interfaces():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
nros_generate_interfaces(std_msgs "msg/Int32.msg")
target_sources(app PRIVATE src/main.c)

Kconfig Reference

All options are under menuconfig NROS in zephyr/Kconfig. They are what the module RENDERS from system.toml, never from a conf file: a registry knob as [image.<id>.knobs.<group>] <name> ([image.zephyr.knobs.zenoh] max_publishers = 4), any other option as an image env row with the CONFIG_ dropped (env = { NROS_BOOT_REPORT = "1" }). An env row naming a registry knob is refused, naming its knobs spelling. The RMW, API, endpoint and domain rows have their own system.toml keys (table above).

Common Options

OptionTypeDefaultDescription
CONFIG_NROSboolnEnable nros module
CONFIG_NROS_RUST_APIboolyUse Rust API
CONFIG_NROS_C_APIboolnUse C API
CONFIG_NROS_DOMAIN_IDint0ROS 2 domain ID
CONFIG_NROS_INIT_DELAY_MSint2000Network init wait (ms)

Zenoh Options (visible when CONFIG_NROS_RMW_ZENOH=y)

OptionTypeDefaultDescription
CONFIG_NROS_ZENOH_LOCATORstring"tcp/127.0.0.1:7447"Router address
CONFIG_NROS_ZENOH_MULTI_THREADboolyZenoh-pico multithreading
CONFIG_NROS_ZENOH_PUBLICATIONboolyPublication support
CONFIG_NROS_ZENOH_SUBSCRIPTIONboolySubscription support
CONFIG_NROS_ZENOH_QUERYboolyService client support
CONFIG_NROS_ZENOH_QUERYABLEboolyService server support
CONFIG_NROS_ZENOH_LINK_TCPboolyTCP transport link
CONFIG_NROS_MAX_PUBLISHERSint8Max concurrent publishers
CONFIG_NROS_MAX_SUBSCRIBERSint8Max concurrent subscribers
CONFIG_NROS_MAX_QUERYABLESint-1 (derive)Max concurrent queryables (service servers). the param_services capability costs 6 per node, [lifecycle] 5 once — a value below that floor fails the build, naming this knob (phase-426 W3)
CONFIG_NROS_FRAG_MAX_SIZEint2048Max reassembled message size
CONFIG_NROS_BATCH_UNICAST_SIZEint1024Max unicast batch size
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZEint-1 (derive)Per-subscriber buffer, small payload class
CONFIG_NROS_MAX_LARGE_SUBSCRIBERSint-1 (derive)Blocks in the large payload class
CONFIG_NROS_SUBSCRIBER_LARGE_SIZEint-1 (derive)Per-sample capacity of the large class
CONFIG_NROS_SERVICE_BUFFER_SIZEint1024Per-service buffer

-1 means “derive it from the message bounds”

Three of the rows above, plus CONFIG_NROS_SUBSCRIPTION_BUFFER_SIZE, default to -1. That is not a size: it is how an image says nothing here chose a number, work it out (phase-403 W8, issue 0940).

Codegen derives an exact serialized-size bound for every message type it generates and writes it beside the code as nros_message_bounds.cmake. The build composes those, and the configure prints what it concluded:

-- nros: message-bound sizing DERIVED from 84 types in 11 interface packages (all bounded)
-- nros:   largest type std_msgs/msg/Float64MultiArray at 1496 B -> NROS_SUBSCRIPTION_BUFFER_SIZE
-- nros:   0 types over the 2048 B ceiling -> NROS_MAX_LARGE_SUBSCRIBERS

Three things to know before you rely on it:

  • Anything you state wins. A value in your image’s env, a board .conf, or an environment override outranks the derivation, silently and in both directions. The derived number is a default, never an override.
  • It is an UPPER BOUND. The inventory covers every type in the interface closure you LINK, not the ones you subscribe to, so the number is the largest message the image could receive. That is the safe direction, and it can still be far above what you need: the line above names std_msgs/Float64MultiArray, which that image never receives. When the type it names is not one of yours, either state your own value or stop linking the package.
  • One unbounded type refuses the lot. If any type in the closure has no derived bound, nothing is derived, every knob keeps its configured value, and the configure names the types and the members that cost them their bound. Deriving over the rest would publish a maximum a real sample can exceed, and that drop is silent on the C++ arena path. The remedy is a bound in the .msg or an inline cap in nros-codegen.toml; one cap on a declaring type ("std_msgs/Header.frame_id") reaches every message that nests it.

The answer, and why it came out that way, is written to <build>/nros/message_bound_knobs.cmake.

XRCE Options (visible when CONFIG_NROS_RMW_XRCE=y)

OptionTypeDefaultDescription
CONFIG_NROS_XRCE_AGENT_ADDRstring"127.0.0.1"Agent IP address
CONFIG_NROS_XRCE_AGENT_PORTint2018Agent UDP port
CONFIG_NROS_XRCE_TRANSPORT_MTUint512Transport MTU
CONFIG_NROS_XRCE_MAX_SUBSCRIBERSint-1 (derive)Max concurrent subscribers
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERSint-1 (derive)Max service servers
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTSint4Max service clients
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTHint32Queued samples per subscriber
CONFIG_NROS_XRCE_BUFFER_SIZEint1024Per-slot buffer size; the default of the three below
CONFIG_NROS_XRCE_SUBSCRIBER_BUFFER_SIZEint-1 (derive)Subscriber ring entry size
CONFIG_NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZEint-1 (= BUFFER_SIZE)Service-server request entry size
CONFIG_NROS_XRCE_SERVICE_REPLY_BUFFER_SIZEint-1 (= BUFFER_SIZE)Service-client reply slot size
CONFIG_NROS_XRCE_STREAM_HISTORYint16Reliable stream depth (2-16)

Every option in this table except the agent address and port is a knob-registry row: a project with a system.toml states it as [image.<id>.knobs.xrce] <name> — the option without CONFIG_NROS_XRCE_, lowercased (transport_mtu = 512, subscriber_ring_depth = 1) — not in prj.conf, where the symbol has no prompt.

C API Options (visible when CONFIG_NROS_C_API=y)

OptionTypeDefaultDescription
CONFIG_NROS_C_MAX_HANDLESint16Max executor handles
CONFIG_NROS_C_MAX_SUBSCRIPTIONSint8Max subscriptions
CONFIG_NROS_C_MAX_TIMERSint8Max timers
CONFIG_NROS_C_MAX_SERVICESint4Max services

E2E Testing

Contributors: the in-tree fixture/test lanes for this platform are in Per-Platform Contributor Lanes.

Troubleshooting

IssueSolution
west: command not foundRun pip3 install --user west and add ~/.local/bin to PATH
Connection refusedStart zenohd / MicroXRCEAgent on the host loopback (e.g. tcp/127.0.0.1:7447)
Build failsSource environment: source zephyr-workspace/env.sh
XRCE Agent not foundProvision the xrce daemon: nros setup zephyr --rmw xrce
Zenoh mutex exhaustionIncrease CONFIG_MAX_PTHREAD_MUTEX_COUNT (default 5 is too low)

Network Architecture

With NSOS, Zephyr sockets are forwarded to host syscalls — there is no emulated L2/L3 stack to configure, no static IP, and no bridge.

┌─────────────────────────────────────────────────────────────┐
│                      Host (Linux)                            │
│                                                              │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zephyr.exe talker  │       │ zephyr.exe listener    │   │
│   │ (native_sim+NSOS)  │       │ (native_sim+NSOS)      │   │
│   └─────────┬──────────┘       └──────────┬─────────────┘   │
│             │ host socket() via NSOS       │                │
│             ▼                              ▼                │
│                 127.0.0.1 (loopback)                        │
│             │                              │                │
│             ▼                              ▼                │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zenohd             │       │ MicroXRCEAgent         │   │
│   │ tcp/127.0.0.1:7447 │       │ udp/127.0.0.1:2018     │   │
│   └────────────────────┘       └────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Updating the Workspace

To update Zephyr and modules to latest versions specified in west.yml:

cd zephyr-workspace
west update

To completely recreate the workspace:

Contributors: the in-tree workspace-recreate lane is in Per-Platform Contributor Lanes.

NuttX (apps/external)

Single-node starter on NuttX. nano-ros plugs into the standard NuttX app discovery as an external app under apps/external/nano-ros/, exposing Kconfig knobs under Application Configuration → External Modules → nano-ros. Use this entry when your NuttX board ships its own kernel build and you want to add ROS 2 communication.

Contributor path? Building nano-ros’s own NuttX QEMU examples straight from this repository (no NuttX-managed workspace) is covered at NuttX (contributor). The page below is the canonical user entry.

Prereqs. Install the nros CLI once per machine, then run nros setup qemu-armv7a-nuttx --rmw <zenoh|xrce|cyclonedds> (--rmw defaults to zenoh). This fetches a mostly-prebuilt toolchain set into ${NROS_HOME:-~/.nros}/sdk — the NuttX cross-compiler, the emulator, the NuttX sources, and the RMW host daemon — so you do not hand-install a cross-toolchain and do not need a ROS 2 install:

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros (Phase 218)
source ./activate.sh        # OR: direnv allow / source ./activate.fish
nros setup qemu-armv7a-nuttx --rmw zenoh

You still need a NuttX ≥ nuttx-12 checkout with an apps/ sibling and Python 3.10+ for the NuttX configure scripts.

Project layout

NuttX’s “external apps” pattern places the app shim under $NUTTX_APPS_DIR/external/<name>/:

$NUTTX_DIR/                              # NuttX kernel checkout
$NUTTX_APPS_DIR/                             # sibling: apps tree
└── external/
    └── nano-ros/                        # symlink or submodule of
        ├── Make.defs                    #   integrations/nuttx/
        ├── Makefile
        ├── CMakeLists.txt               #   (cmake-driven NuttX builds)
        └── Kconfig
my_app/                                  # your application
├── system.toml                          # WHAT this deploys to — the one file you edit
├── package.xml
├── Cargo.toml | CMakeLists.txt          # language-toolchain facts only
├── generated/                           # generated message bindings (gitignored)
└── src/main.{rs,c,cpp}

Wire the shell into your NuttX apps tree. Two commands, both run once:

nros setup qemu-armv7a-nuttx --rmw zenoh    # toolchain + NuttX sources + SDK
# from the nano-ros checkout:
scripts/nuttx/stage-external-apps.sh "$NUTTX_APPS_DIR"

The staging script writes $NUTTX_APPS_DIR/external/Make.defs + Kconfig and symlinks the integration shell in as external/nano-ros. Menuconfig surfaces it under Application Configuration → External Modules.

Contributors (in-tree checkout): just setup nuttx runs both of the above in one step against this repository’s own NuttX checkout.

The script stages the integration shell only. A NuttX workspace image (a bringup with several components) is not built through the apps tree: add an [image.<id>] with board = "qemu-armv7a-nuttx" to the bringup’s system.toml and run nros build — see examples/workspaces/rust [image.nuttx]. The script’s old --bringup <dir> / --image <id> flags staged a per-bringup app that could not build an image, and now refuse (issue 1883).

If you’d rather wire it yourself (e.g. into a vendored apps tree):

ln -s /path/to/nano-ros/integrations/nuttx \
      $NUTTX_APPS_DIR/external/nano-ros
# then copy integrations/nuttx/external-Make.defs.in →
# $NUTTX_APPS_DIR/external/Make.defs and add a matching
# $NUTTX_APPS_DIR/external/Kconfig that `source`s the shell.

Configure

NuttX uses Kconfig as its single source of truth. After the symlink above:

cd $NUTTX_DIR
make menuconfig
# Navigate to:
#   Application Configuration → External Modules → nano-ros
#   [*] nano-ros ROS 2 client
#       RMW backend  →  zenoh           # zenoh | xrce | cyclonedds
#       ROS 2 edition →  humble

Networking Kconfig requirements live under Networking Support — enable CONFIG_NET, CONFIG_NET_TCP, CONFIG_NET_IPv4. For QEMU nsh_smp configurations the defaults already include these.

Those Kconfig knobs govern what the integration shell compiles into libnros_c.a — NuttX’s build owns that, the way Zephyr’s Kconfig owns its lane. They are not where your application states its deployment, and the two must name the same RMW.

Your application’s deployment — board, RMW, domain id, and an optional locator — is declared in system.toml beside the package and baked at compile time. Verbatim from the in-tree examples/qemu-armv7a-nuttx/rust/talker/system.toml:

[system]
name = "nuttx_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "nuttx_talker"
class = "nuttx_rs_talker::Talker"
name = "talker"

[image.qemu-armv7a-nuttx]
board = "nuttx"
locator = "tcp/10.0.2.2:8200"

The C / C++ variants carry the same file, in the same schema — their package.xml has no <nano_ros …/> tuple and their CMakeLists.txt names no board. Nor does anything here name a target triple: armv7a-nuttx-eabihf is a fact about the board, and it lives in the board descriptor. Run nros sync in the application directory after editing this file; it writes the generated message bindings and the build settings the board choice implies.

The guest network shape (eth0 10.0.2.15, Slirp gateway 10.0.2.2 — QEMU’s own network, which the board’s [board.net] network = "qemu" names) comes from the board; add a locator = "tcp/10.0.2.2:<port>" field to [image.<id>] to dial a non-default router port. The prebuilt test fixtures bake distinct per-language allocator ports so parallel suites don’t collide on one router. Start the router (ROS’s rmw_zenohd) on the port your app dials — it must listen on 0.0.0.0, not loopback, because the guest reaches it through QEMU’s Slirp gateway:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:8200"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd

Build

NuttX owns the build verb here — make, from the configured kernel — the way Zephyr owns west build. nros sync still runs first, in the application directory, because the generated message bindings must exist before anything compiles against them:

cd my_app
nros sync                           # bindings + settings from system.toml

cd $NUTTX_DIR
make                                # full kernel + apps build

The Cargo build of nano-ros’s Rust staticlibs runs as a sub-step of the NuttX app build; libnros_c.a is linked at the final app link stage.

For CMake-driven NuttX builds:

cmake -B build -DBOARD=qemu-armv7a \
              -DCONFIG=nsh_smp
cmake --build build

Run

# 1. Start the router on the host (Slirp forwards 10.0.2.2:<port> →
#    host). It must listen on 0.0.0.0 for the Slirp gateway; 8200 is
#    the port the in-tree lanes use — match whatever `locator` your
#    app bakes:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:8200"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# 2. QEMU NuttX (ARM). For a NuttX-managed workspace where you've
#    staged the integration shell + your own app, boot QEMU directly:
qemu-system-arm -M virt -cpu cortex-a7 -nographic \
                -icount shift=auto \
                -kernel $NUTTX_DIR/nuttx \
                -netdev user,id=net0 \
                -device virtio-net-device,netdev=net0
# `$NUTTX_DIR/nuttx` is the linked NuttX ELF produced by `make`
# at the NuttX source root — adjust if your workspace puts it
# elsewhere (e.g. an out-of-tree build dir).
# At the NSH prompt, run the example's PROGNAME — the
# `make`-driven build registers every nano-ros example as a
# built-in command via Application.mk's `-Dmain=<PROGNAME>_main`
# rename. Real PROGNAMEs (from
# `packages/testing/nros-tests/tests/nuttx_make_e2e.rs::EXPECTED_PROGNAMES`):
nsh> nuttx_c_talker            # C talker
# nsh> nuttx_cpp_talker        # C++ talker
# nsh> nuttx_c_listener        # ...and listener / service / action variants

# Real hardware: standard NuttX flash flow (openocd / J-Link / etc.)

# 3. Verify from stock ROS 2 in another terminal:
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

Contributors: the in-tree fixture/test lanes for nano-ros’s own QEMU examples are in Per-Platform Contributor Lanes.

Readiness signal. After typing the app’s NSH command (e.g. nuttx_c_talker), expect Publishing: 'Hello World: 1' on the NSH console within ~6 seconds — Rust + C + C++ talkers all start the count at 1, matching the official ROS 2 demo talker. If no Publishing: line:

  1. Confirm the app actually ran — ps should show your task.
  2. Confirm networking — ifconfig shows a configured interface. With the virtio-net + Slirp wiring above, eth0 comes up at 10.0.2.15 (the board’s default on QEMU’s network; an image’s net address overrides it).
  3. Confirm zenohd reachable; the image’s locator (or the rclc_support_init arguments) must match the router’s listen port.
  4. See Troubleshooting — First 10 Minutes.

Auto-configure glue (NSH built-in registration)

The make-driven build above relies on a host-side glue layer. In-tree that is scripts/nuttx/stage-external-apps.sh plus kconfig-tweak. If you wire the integration shell into your own NuttX tree by hand, reproduce the same three steps:

  1. Bring the required symbols into your config. Stock NuttX qemu-armv7a/nsh ships without CONFIG_NET=y, virtio-net, or TLS_NELEM. If you have no config of your own, copy the board defconfig packages/boards/nros-board-nuttx-qemu/nuttx-config/arm/defconfig to $NUTTX_DIR/.config and run make olddefconfig. If you already maintain a vendor defconfig, do NOT clobber it — merge the nano-ros requirements into it instead (CONFIG_NET=y, the virtio-net / netdev options, CONFIG_TLS_NELEM=8, CONFIG_LIBCXXTOOLCHAIN=y, CONFIG_ALLSYMS=n; diff the board defconfig against nsh for the authoritative list) and re-run make olddefconfig.
  2. Stage the integration shell. Run scripts/nuttx/stage-external-apps.sh "$NUTTX_APPS_DIR" to symlink integrations/nuttx/ into $NUTTX_APPS_DIR/external/. Remove $NUTTX_APPS_DIR/Kconfig so NuttX’s mkkconfig.sh rediscovers the new apps/external/Kconfig.
  3. Flip the nano-ros Kconfig knobs via kconfig-tweak. The recipe enables NROS, NROS_C_API, NROS_CPP_API, every NROS_EXAMPLE_<EX>_<LANG>, sets TLS_NELEM=8, disables LIBCXXNONE + enables LIBCXXTOOLCHAIN, and disables ALLSYMS for the bootstrap link. Re-run make olddefconfig so the newly-visible dependencies settle, then make.

kconfig-tweak ships in the kconfig-frontends package on most distros. Without it the recipe skips ("NuttX skip: kconfig-tweak not on PATH"); install it before retrying.

GitHub source

Next

  • Multiple apps: each app declares its own progname in Application Configuration → External Modules; they share the one libnros_c.a build via the external-app shell.
  • DDS on NuttX: bump the netbuffer Kconfig knobs (similar to the Zephyr DDS profile under Choosing an RMW Backend).
  • Build nano-ros’s own NuttX QEMU tests without a NuttX-managed workspace: NuttX (contributor).

NuttX (contributor / in-tree workflow)

Looking for the user-facing path? This page covers building nano-ros’s own NuttX examples on QEMU from this repository. If you’re integrating nano-ros into YOUR NuttX app at apps/external/nano-ros/, see Integration: NuttX external app instead.

nano-ros runs on NuttX, targeting QEMU ARM virt (Cortex-A7 + virtio-net). NuttX provides POSIX-compatible BSD sockets, which makes it the most straightforward RTOS port – zenoh-pico uses the same socket API as on Linux.

Overview

The NuttX platform uses:

  • NuttX RTOS – POSIX-compliant real-time OS with BSD socket support
  • BSD sockets – standard socket API provided by NuttX’s network stack
  • zenoh-pico – Zenoh transport over NuttX sockets (same code path as POSIX)
  • virtio-net – NuttX built-in Ethernet driver (no custom driver needed)

Board crate: nros-board-nuttx-qemu (in packages/boards/) — ONE crate serves both QEMU NuttX boards, arm virt and rv-virt. Everything that differs between them is data (a defconfig, a toolchain file, a target triple); nothing in the Rust or C sources is arch-conditional.

Why NuttX Is Simpler Than FreeRTOS

NuttX is the simplest RTOS platform to port because of its strong POSIX compliance (POSIX.1-2008: pthreads, BSD sockets, select(), clock_gettime()):

AspectNuttXFreeRTOS
zenoh-pico layerReuses unix/ platformDedicated freertos/ platform
NetworkingBuilt-in BSD socketsExternal lwIP
Ethernet driverNuttX virtio-net (built-in)Custom LAN9118 lwIP netif
Rust targetarmv7a-nuttx-eabi with stdthumbv7m-none-eabi (no_std)
Build integrationNuttX build system + cargocc crate compiles FreeRTOS + lwIP
QEMU machinevirt (Cortex-A7)mps2-an385 (Cortex-M3)

Because NuttX supports Rust std, examples use standard fn main() entry points and println! – no semihosting or custom panic handlers needed.

Setup

nros setup qemu-armv7a-nuttx provisions everything this board needs — the NuttX cross-compiler, qemu-system-arm, the NuttX source tree, and the RMW host daemon — into the shared store at ~/.nros/sdk. No hand-installed cross-toolchain and no ROS 2 install required.

Build the in-tree nros CLI (Phase 218), then provision the board (--rmw defaults to zenoh):

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish
nros setup qemu-armv7a-nuttx --rmw zenoh

As a contributor, just setup nuttx remains available and now delegates to nros setup qemu-armv7a-nuttx for the toolchain/SDK provisioning while also staging the external apps.

The NuttX sources land in third-party/nuttx/nuttx/ and third-party/nuttx/nuttx-apps/. Override the paths with environment variables if your sources are elsewhere:

VariableDefaultDescription
NUTTX_DIRthird-party/nuttx/nuttxNuttX RTOS source
NUTTX_APPS_DIRthird-party/nuttx/nuttx-appsNuttX apps source

Prerequisites

The nros setup qemu-armv7a-nuttx step above provisions the qemu-system-arm emulator and the arm-none-eabi-gcc cross-compiler used for NuttX kernel compilation. The one host-side tool you still supply yourself:

  • Rust nightly toolchain (NuttX targets are Tier 3, require -Z build-std)

Building

Contributors: the in-tree build lanes for this platform are in Per-Platform Contributor Lanes.

The examples link against NuttX’s POSIX layer, which provides sockets, pthreads, and standard I/O.

Available Examples

All examples are in examples/qemu-armv7a-nuttx/rust/:

ExampleDescription
talkerPublishes std_msgs/String on /chatter
listenerSubscribes to std_msgs/String on /chatter
service-serverServes AddTwoInts on /add_two_ints
service-clientCalls AddTwoInts on /add_two_ints
action-serverServes Fibonacci action on /fibonacci
action-clientSends Fibonacci goal on /fibonacci

RISC-V (rv-virt, riscv32)

The same NuttX path also runs on QEMU’s RISC-V rv-virt board (rv32imac, riscv32imac-unknown-nuttx-elf), proving the provisioning path is arch-agnostic. Provision and build with:

nros setup rv-virt-nuttx --rmw zenoh

Contributors: the in-tree RISC-V fixture build lanes are in Per-Platform Contributor Lanes.

Board crate: nros-board-nuttx-qemu (in packages/boards/) — the same crate as the arm board above, selected by deploy = "nuttx-riscv", which picks the rv-virt [[board]] entry of its nros-board.toml (riscv toolchain, riscv defconfig, riscv32imac-unknown-nuttx-elf). The C pub/sub pair is exercised end-to-end under qemu-system-riscv32, and Rust / C / C++ realtime-tiers workspace runtime lanes cover the multi-tier scheduling path on this board.

Testing

Contributors: the in-tree test lanes for this platform are in Per-Platform Contributor Lanes.

Tests run under qemu-system-arm -M virt with TAP networking. Each QEMU instance connects to the host bridge (br-qemu) via TAP devices for zenohd communication. The test infrastructure builds a NuttX kernel image with the example app embedded, boots it in QEMU, and verifies message exchange.

Network Configuration

NuttX QEMU instances use the same IP scheme as other QEMU board crates:

RoleIP AddressTAP Device
Talker/Publisher192.0.3.10tap-qemu0
Listener/Sub192.0.3.11tap-qemu1
Service Server192.0.3.12tap-qemu0
Service Client192.0.3.13tap-qemu1
zenohd (host)192.0.3.1br-qemu

Architecture

Board Crate

The nros-board-nuttx-qemu board crate follows the standard Config / run() pattern documented in the Custom Board Package guide. It provides network and node configuration presets (talker(), listener(), server(), client()).

Unlike bare-metal and FreeRTOS board crates, there is no custom hardware initialization, no network stack setup, and no task creation. NuttX’s kernel boots the hardware, initializes virtio-net, and starts the application before main() runs. Because NuttX supports Rust std, examples use standard fn main() entry points.

NuttX Defconfig

The QEMU board configuration lives in packages/boards/nros-board-nuttx-qemu/nuttx-config/arm/defconfig (the rv-virt one sits beside it under riscv/) and enables:

  • CONFIG_NET – networking subsystem
  • CONFIG_NET_TCP / CONFIG_NET_UDP – TCP/UDP protocols
  • CONFIG_DRIVERS_VIRTIO + CONFIG_DRIVERS_VIRTIO_NET – virtio Ethernet
  • CONFIG_PTHREAD_MUTEX_TYPES – POSIX mutex types (for zenoh-pico)
  • CONFIG_DEV_URANDOM – /dev/urandom for session ID generation
  • CONFIG_DEFAULT_TASK_STACKSIZE=8192 – adequate stack for nros apps
  • CONFIG_BUILD_FLAT – flat memory model (no MMU protection)

Status

NuttX platform support is complete: feature flags, build integration, six Rust examples, E2E network tests, and documentation.

ThreadX (Linux sim / RISC-V64 QEMU)

Single-node starter on Microsoft Azure RTOS ThreadX + NetX Duo (BSD socket layer). Two flavours ship in-tree:

  • threadx-linux — ThreadX user-space simulator on Linux. Fast build, host network stack, ideal for development.
  • threadx-riscv64 — QEMU virt machine with the RISC-V64 GCC toolchain. Full kernel + NetX Duo TCP/IP stack.

Rust, C, and C++ are supported on both flavours.

Contributors: the in-tree fixture/test lanes for this platform are in Per-Platform Contributor Lanes.

See the coverage matrix for the per-RMW cell status.

Prereqs. Install the nros CLI once, then run nros setup <board> --rmw <rmw> for the flavour you need (see Setup). It provisions the cross-compiler, emulator, and ThreadX/NetX sources — no hand-installed riscv64 cross toolchain or qemu-system-riscv64. The zenoh path additionally needs a ROS 2 install for the router (ros2 run rmw_zenoh_cpp rmw_zenohd); xrce needs only the Micro-XRCE-DDS agent, which nros setup installs.

Setup

nros setup is the single canonical command to prepare a machine to build nano-ros for a board. Most components are prebuilt per platform per RMW — the cross-compiler, emulator, and SDK sources (the ThreadX/NetX sources, and for threadx-linux the POSIX-sim sources) are fetched from a pinned index into a shared store at ${NROS_HOME:-~/.nros}/sdk. The zenoh router is NOT among them — it comes from a ROS 2 install (RFC-0075); only the xrce agent is provisioned by nros setup.

Packages are prebuilt where the index has a binary for your host and built from source otherwise. --dry-run prints the plan for your host. See Installation for the full explanation.

Build the in-tree nros CLI (Phase 218):

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

Provision the ThreadX flavour you need (+ the RMW):

nros setup threadx-linux --rmw zenoh          # POSIX-sim flavour; --rmw defaults to zenoh
nros setup rv-virt-threadx --rmw zenoh   # only if you need the RISC-V64 QEMU flow
source ./activate.sh

The RMW host daemon must be running before any example: for zenoh the ROS 2 router (ros2 run rmw_zenoh_cpp rmw_zenohd), for xrce the Micro-XRCE-DDS agent (installed by nros setup … --rmw xrce).

Project layout

Each example is a standalone Cargo or CMake project under examples/threadx-linux/ and examples/rv-virt-threadx/ (<lang>/<example>/ under each):

examples/threadx-linux/
├── rust/talker/                 # Cargo package
│   ├── system.toml               # WHAT this deploys to — the one file you edit
│   ├── Cargo.toml                # Rust deps only; nothing here names a board
│   ├── package.xml
│   ├── generated/                # generated message bindings (gitignored)
│   ├── build/                    # everything `nros sync` generates (gitignored)
│   └── src/lib.rs                # the component class; nros::main! generates the entry
└── c/talker/                    # CMake, add_subdirectory
    ├── system.toml               # the same file, same schema
    ├── CMakeLists.txt            # targets only
    ├── package.xml
    └── src/Talker.c

examples/rv-virt-threadx/
├── rust/talker/                 # identical shape; `board = "rv-virt-threadx"`
│   └── ...
└── c/talker/
    └── ...

The two flavours differ in the board = line of their system.toml — plus, on the Rust side, how the board crate is reached. A threadx-linux Rust leaf names no board crate: it depends on a generated <pkg>_nros_selection package that nros sync writes for the image’s board (issue 1305), so its board switch is the one line. The rv-virt-threadx Rust leaves are static libraries a CMake image links, and still name nros-board-threadx-qemu-riscv64 by hand. The C leaves have no board crate and really do switch on the one line.

Everything else follows the board. threadx-linux builds for the host triple (its Rust entry is still #![no_std], issue 1759), so its board contributes no target triple; rv-virt-threadx cross-compiles, and its triple, link group and emulator all come from the board descriptor — never from a leaf. nros sync resolves the choice into build/<image-id>/nros-cargo.toml, which is what the build reads (RFC-0098).

ThreadX-linux runs as a regular host process — no QEMU. NetX Duo uses the nx_bsd_* BSD socket shim layered on the host TCP stack (threadx-linux variant) or on its own NetX Duo TCP/IP stack (riscv64 variant).

Why there are two board crates and one arch port

The two flavours look like one board with two targets, and they are not. nano-ros ships three ThreadX packages, in the three layers RFC-0064 describes:

PackageLayerWhat it owns
nros-board-threadxfamily driverthe kernel/NetX build and the generic run_entry boot path both boards call
nros-board-threadx-port-riscv64arch porta fork of upstream’s ports/risc-v64/gnu: tx_port.h + five context-switch .S files
nros-board-threadx-{linux,qemu-riscv64}board overlaydefaults, console/exit/panic, and the network bring-up

The arch port is the reason the two boards did not merge. Upstream’s RISC-V64 port types ULONG as unsigned long — 8 bytes on rv64 — but NetX Duo’s packet code does ULONG * arithmetic assuming 4-byte words, so nano-ros retypes it to unsigned int, matching upstream’s own Linux and AArch64 ports. That retype shifts every field offset inside TX_THREAD, and the port’s context-switch assembly loads those fields at hard-coded offsets — so the header change forces the assembly to be forked with it. threadx-linux needs none of this, because upstream’s Linux port already types ULONG as 4 bytes.

That is architecture code, not board code: a second RISC-V64 ThreadX board would need every line of it unchanged, and folding it into a crate that also serves Linux would mean cfg-gating RISC-V assembly there. If you are porting to another RISC-V64 ThreadX board, depend on the arch port and write only the overlay — see Custom boards.

One rule matters if you build ThreadX outside the shipped recipes: the arch port’s inc/ must be searched before $THREADX_DIR/ports/risc-v64/gnu/inc. Get that wrong and the build succeeds against upstream’s 8-byte ULONG, and the symptom is corrupted packets at runtime rather than a compiler error. A _Static_assert(sizeof(ULONG) == 4, …) in packages/boards/nros-board-common/c/threadx_hooks.c turns that mistake into a build failure with a name on it.

Configure

Deployment is declared per flavour in system.toml and baked at compile time. Both shipped shapes, verbatim:

threadx-linux — examples/threadx-linux/rust/talker/system.toml:

[system]
name = "threadx_linux_rs_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "threadx_linux_rs_talker"
class = "threadx_linux_rs_talker::Talker"
name = "talker"

[image.threadx-linux]
board = "threadx-linux"
locator = "tcp/127.0.0.1:9000"

threadx-riscv64 — examples/rv-virt-threadx/rust/talker/system.toml adds the guest’s network identity, because it boots behind QEMU Slirp:

[image.rv-virt-threadx]
board = "rv-virt-threadx"
locator = "tcp/10.0.2.2:9400"
net = { eth0 = { network = "qemu", host = 15 } }

The C leaves carry the same file with the same keys; their CMakeLists.txt declares targets and nothing about the deployment, and their package.xml carries no <nano_ros …/> tuple. Anything the image does not state — the rest of the network shape — comes from the board crate’s defaults; see the Configuration Guide.

ThreadX-Linux normally uses a veth pair (tap-tx0) for an isolated host link, but nros setup threadx-linux does not create the interface — the test fixtures fall back to a loopback path when tap-tx0 is absent, which is fine for the happy-path tutorial. Bring up tap-tx0 by hand (ip link add … type veth …) only when you need real-network bridging. The QEMU-RISC-V64 fixture uses Slirp’s default 10.0.2.2 gateway just like the FreeRTOS QEMU flow.

Build

# Single example — `nros sync` writes the generated message bindings and
# build/threadx-linux/nros-cargo.toml from system.toml; `nros build` builds
# the image that file declares.
cd examples/threadx-linux/rust/talker
nros sync
nros build

Contributors: the in-tree fixture build lanes for both flavours are in Per-Platform Contributor Lanes.

First setup builds ThreadX + NetX Duo (~3 min). Subsequent example builds finish in seconds. The binary lands under the image’s own directory — build/threadx-linux/target/debug/talker for the hosted flavour, with the cross triple as an extra path segment for rv-virt-threadx.

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/threadx-linux/rust
cargo build --manifest-path talker/Cargo.toml \
            --config talker/build/threadx-linux/nros-cargo.toml

Skipping nros sync is not a mysterious failure: nros build stops at its preflight with one line naming what is missing and telling you to run sync in that directory. See Workflow by Platform and Language.

Run

# threadx-linux (no QEMU). Step 1 brings up the router (ROS's
# `rmw_zenohd`) on port 9000 — the `locator` the talker bakes from
# `[image.threadx-linux]`. Step 2 builds + runs the talker:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:9000"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &
cd examples/threadx-linux/rust/talker && nros sync && nros build
./build/threadx-linux/target/debug/talker
# Expected (per src/lib.rs structured logs):
#   Publishing: 'Hello World: 1'
#   Publishing: 'Hello World: 2'
#   ...

# threadx-riscv64 (QEMU virt). Same shape — the router on 9400 first
# (the riscv64 talker dials tcp/10.0.2.2:9400 through QEMU Slirp):
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:9400"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# Build it the same way (`system.toml` names `board = "rv-virt-threadx"`,
# so the riscv64 triple and link group come from the board):
cd examples/rv-virt-threadx/rust/talker && nros sync && nros build

# Then boot the built image in QEMU (UART on stdio, Slirp networking):
qemu-system-riscv64 -M virt -m 256M -bios none -nographic \
    -global virtio-mmio.force-legacy=false \
    -kernel build/rv-virt-threadx/target/riscv64gc-unknown-none-elf/debug/talker \
    -netdev user,id=net0 \
    -device virtio-net-device,netdev=net0,bus=virtio-mmio-bus.0
# **Contributors (in-tree checkout):** `just threadx_riscv64 talker`
# builds and boots this exact shape in one step.

# Verify from stock ROS 2:
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

Contributors: the in-tree fixture run/test lanes are in Per-Platform Contributor Lanes.

Readiness signal. threadx-linux: Publishing: 'Hello World: 1' within a few seconds of starting the binary on a warm cache; a cold first nros build compiles the Rust example (~80 s on a fresh checkout) before the first publish lands. threadx-riscv64 (QEMU): within ~15 seconds of QEMU boot. If no Publishing: line:

  1. Confirm the router is reachable on the image’s locator (threadx-linux uses 127.0.0.1; riscv64 QEMU uses 10.0.2.2).
  2. threadx-linux: if you brought up tap-tx0 by hand, confirm it is up; without it the fixtures use the loopback fallback, which is fine for this tutorial (nros setup threadx-linux does not create the interface).
  3. See Troubleshooting — First 10 Minutes.

Multi-tier scheduling (phase-297)

ThreadX supports the multi-tier workspace scheduling model (RFC-0053): declare per-tier ThreadX priorities in the workspace manifest with [tiers.<name>.threadx] priority tables, and nros::main! routes the app onto run_tiers — one executor per tier, all sharing one session, with per-tier stacks carved from the ThreadX byte pool. ThreadX priorities follow the native convention: lower number = higher priority. Tiers are sorted descending by raw priority number, so tiers[0] (the numerically-largest, i.e. lowest-priority tier) boots first and then adopts its declared priority. See Scheduling Models for the mechanics.

GitHub source

Next

  • Subscriber + service + action peers in the same example tree.
  • DDS on ThreadX: Cyclone DDS is the surviving DDS backend (nros-rmw-cyclonedds) — select it with [system] rmw = "cyclonedds" in system.toml and re-run nros sync; see Choosing an RMW Backend.
  • Real hardware: same code runs against ThreadX vendor BSPs (Renesas Synergy, MIMXRT, etc.); replace the QEMU board crate with a vendor board crate.

ESP32 (esp-hal, bare-metal Rust) — dormant

Dormant (issue 1525). ESP32 support is parked: the code below is kept in the tree for future use, but nothing builds, tests or provisions it — the esp32 just module is not mounted, nros setup no longer carries the ESP32 board or tools, the CI job and fixture rows are gone, and nros new --platform does not offer esp32. The rest of this page records how the path worked, for whoever revives it.

Single-node starter on ESP32-C3 using the bare-metal esp-hal Rust path — no ESP-IDF — running under the Espressif QEMU fork (OpenETH ethernet). This is the only ESP32 path nano-ros ships: the ESP-IDF component shell was retired in phase-468 W2.

Prereqs. nros setup esp32-c3-baremetal prepares the build: the riscv cross-gcc and espflash from a pinned index into the shared store at ~/.nros/sdk — you do not hand-install cross-compilers. Running under QEMU additionally needs Espressif’s QEMU fork (nros setup --tool esp32-qemu, source-built) and — for zenoh — a ROS 2 install to provide the router.

Time budget. This chapter is the longest embedded setup in the book (~a dozen steps, two source builds, a nightly toolchain): plan an afternoon, not ten minutes. If you have not had a first win yet, take the First Project host flow first — it is the ten-minute one — and come back.

Setup

Build the in-tree nros CLI (Phase 218):

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

Provision the board (and RMW):

nros setup esp32-c3-baremetal --rmw zenoh     # --rmw defaults to zenoh; xrce | cyclonedds also valid

This pulls the SDK sources nano-ros owns (zenoh-pico + mbedtls submodules for zenoh; analogous for xrce / cyclonedds) plus the riscv cross-gcc and espflash into ${NROS_HOME:-~/.nros}/sdk. It does NOT provide a zenoh router — that is ROS 2’s rmw_zenohd, so the zenoh Run step below needs a ROS install (--rmw cyclonedds needs no daemon at all). esp-hal itself is a Cargo dependency the example pulls in at build time, not a separately-installed toolchain; the only cross-toolchain you may need to add by hand is the rustup target — once per host:

rustup target add riscv32imc-unknown-none-elf      # ESP32-C3

Project layout

Each example is a standalone Cargo package targeting riscv32imc-unknown-none-elf (ESP32-C3). The board crate (nros-board-esp32-qemu) wraps the OpenETH / esp-hal init.

ESP32-S3 (Xtensa) is NOT supported today. The tutorial targets the RISC-V ESP32-C3 only. Xtensa targets do not ship via rustup (they require the espup toolchain installer) and the in-tree board crate is RISC-V only; this gap is tracked separately.

examples/esp32-c3-baremetal/rust/talker/
├── system.toml                # WHAT this deploys to — the one file you edit
├── Cargo.toml                 # Rust deps only; nothing here names a board
├── package.xml
├── generated/                 # generated message bindings (gitignored)
├── build/                     # everything `nros sync` generates (gitignored)
└── src/                       # lib.rs component class + main.rs entry

There is no .cargo/ to edit. The riscv32imc-unknown-none-elf triple, the -Tlinkall.x link group, the [unstable] build-std that compiles core and alloc from source, ESP_LOG, the DRAM budgets and the [patch.crates-io] rows all come from the board, and nros sync writes them into build/esp32-c3-baremetal/nros-cargo.toml — a generated file the build reads with --config (RFC-0098).

Configure

The whole deployment statement is system.toml, beside the manifest, and it is baked at compile time; the board’s default Config supplies the remaining smoltcp knobs like the MAC. The QEMU ESP32 board uses OpenETH ethernet via nros-board-esp32-qemu. Verbatim from examples/esp32-c3-baremetal/rust/talker/system.toml:

[system]
name = "esp32_qemu_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "esp32_qemu_talker"
class = "esp32_qemu_talker::Talker"
name = "talker"
entities = ["publisher:std_msgs/msg/String:/chatter", "timer"]

[image.esp32-c3-baremetal]
board = "esp32-c3-baremetal"
net = { eth0 = { network = "qemu", host = 50 } }
locator = "tcp/10.0.2.2:9800"

The entities line is this board’s one extra obligation. Pool sizes are normally probed from a host build of the component, and this leaf has no host build — a foreign triple, build-std, and a board crate that only exists for the target. So the image states what the node creates, and nros sync sizes the pools from that (RFC-0098 D8). On .bss-tight ESP32-C3 that matters: the stack is whatever the linker leaves after .bss, so a pool sized for an entity this node never creates is stack it cannot use.

Build

Contributors: the in-tree fixture/test lanes for this platform are in Per-Platform Contributor Lanes.

Copy the example out, generate bindings, build with the pinned nightly, and pack the flash image:

# once per checkout location — message bindings + the generated
# build/esp32-c3-baremetal/nros-cargo.toml:
# (`nros` finds the SDK it belongs to; `--nano-ros-path <dir>` names another)
nros sync

# nros build hands cargo the generated settings file. That file asks for
# `[unstable] build-std`, so a nightly carrying `rust-src` must be the ACTIVE
# toolchain — nros build picks no channel for you. The pinned channel is
# tools/rust-toolchain.toml's; any recent nightly works:
#   rustup toolchain install nightly && rustup component add rust-src --toolchain nightly
#   rustup target add riscv32imc-unknown-none-elf --toolchain nightly
#   rustup override set nightly          # in this directory
# Release, because ESP32-C3 DRAM is tight: anything after `--` goes to cargo
# verbatim.
nros build -- --release

# pack the ELF into the flash image QEMU boots (espflash comes from
# `nros setup esp32-c3-baremetal`, on PATH via activate):
espflash save-image --chip esp32c3 --flash-size 4mb --merge \
    build/esp32-c3-baremetal/target/riscv32imc-unknown-none-elf/release/esp32_qemu_talker \
    talker.bin

Driving cargo yourself — for an IDE or a CI step — run nros sync first, then point cargo at the same generated 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/esp32-c3-baremetal/rust
cargo +nightly build --release \
      --manifest-path talker/Cargo.toml \
      --config talker/build/esp32-c3-baremetal/nros-cargo.toml

First build cross-compiles core/alloc + every dep (~5 min); rebuilds are seconds. If you build a C-flavored variant, the riscv cross-gcc the zpico shim needs is also provisioned by the same nros setup.

Run

The esp32c3 machine exists only in Espressif’s QEMU fork — stock qemu-system-riscv32 knows only virt. The fork is source-built by:

nros setup --tool esp32-qemu    # clones + builds espressif/qemu (needs
                                # libglib2-dev libpixman-dev libgcrypt-dev;
                                # the command names them if absent)
# 1. Bring up the router (ROS's `rmw_zenohd`) on the port the example
#    dials (9800 — the image's `locator` above):
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:9800"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# 2. Boot the flash image (the file packed in Build above):
qemu-system-riscv32 -M esp32c3 -icount 3 -nographic \
    -drive file=talker.bin,if=mtd,format=raw \
    -nic user,model=open_eth
# Serial output:
#   Publishing: 'Hello World: 1'
#   Publishing: 'Hello World: 2'

Contributors: the in-tree run lane that boots the talker in QEMU is in Per-Platform Contributor Lanes.

# Verify from stock ROS 2 on the same network:
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

Readiness signal. QEMU ESP32: ~15 seconds after boot.

If no Publishing: line:

  1. Wrong locator → talker logs zenoh open failed and retries. Confirm the router is reachable on the host IP (10.0.2.2:9800).
  2. Confirm the image really is the C3 one — [image.<id>] board should read esp32-c3-baremetal, and the [build] target in the generated build/esp32-c3-baremetal/nros-cargo.toml should read riscv32imc-unknown-none-elf. The tutorial does not support ESP32-S3 (Xtensa) yet.
  3. See Troubleshooting — First 10 Minutes.

GitHub source

Next

  • Subscriber + service + action peer directories under the same examples/esp32-c3-baremetal/rust/.
  • C / C++ apps on an ESP32 have no in-tree path today — the ESP-IDF component shell is retired, and so is the idf.py build driver. nros build says so rather than failing later (issue 1525).
  • ESP32-S3 (Xtensa) — not supported today. The Xtensa toolchain does not ship via rustup (it requires espup), and there is no in-tree Xtensa board crate. Stick with ESP32-C3 (RISC-V) for now.

ESP32 (ESP-IDF component) — retired

The ESP-IDF integration has been removed (phase-468 W2). This page is kept so the links that point at it still resolve; the instructions it used to carry have been deleted rather than left to be followed.

What was here, and why it went

integrations/nano-ros/ was an ESP-IDF component shim, driven by a just-module of its own. Measured on 2026-09-25, before removing it:

  • no workflow under .github/ built it;
  • examples/fixtures.toml had no row naming it;
  • it was the only one of six pure-C platform ports with neither an nros-platform.toml descriptor nor a package.xml;
  • it had never been published to the Espressif Component Registry — the release doc records the publication step as not performed.

The book had nonetheless advertised it as Ready in two support tables, on the strength of a nightly lane that did not exist. That claim was the reason to look, and looking is what retired it.

What still works: ESP32 bare-metal

The ESP32 support that has a booting CI lane is unaffected, and it is a different thing one hyphen away. esp32 is the ESP32-C3 QEMU bare-metal path over esp-hal, with its own board crate, fixtures and nightly lane:

If you are targeting an ESP32-C3, that is the page you want.

The idf.py build driver went too (2026-09-28)

W2 left one piece standing: nros build still had an idf.py driver, chosen for an ESP32 board whose package graph crossed languages, on the reasoning that it would hand off to a project that was now necessarily yours. RFC-0065 D3’s 2026-09-28 amendment deleted it, because it could not have worked — with the component shell gone there was nothing for your project to depend on, nothing emitted an ESP-IDF project for idf.py to find, no mechanism carried a resolved nano-ros setting into that build, and nros setup could not install ESP-IDF in the first place.

That combination now refuses, naming the four reasons, the ESP32 road that does work, and issue 1525, instead of running a tool that had no project to build.

If you need ESP-IDF

Nothing here forbids an out-of-tree ESP-IDF integration — the C API and the platform ABI are unchanged, and packages/platform/nros-platform-api/ still describes what a port must provide. What is gone is an in-tree component that nothing built and nobody could tell was unbuilt.

Issue 1525 lists what an in-tree road would have to bring back: a component, a knob carrier for that build, an nros setup entry, and a lane that builds it. That is the same bar library.json already sets for re-advertising the espidf PlatformIO framework — “re-add only alongside a real integration”.

Bare-metal Cortex-M3 (QEMU)

Single-node starter on bare-metal Cortex-M3 (QEMU MPS2-AN385) — no RTOS, no kernel scheduler. Pure cooperative spin via zpico_spin_once. Rust only. nros-c / nros-cpp are not supported on bare-metal targets (they assume a hosted RTOS for startup / heap / libc); see the examples coverage matrix for the policy.

When to use this path: ultra-constrained Cortex-M0+ / M3 / M4 targets with no OS scheduler, no pthread. If you have FreeRTOS or any RTOS, use the FreeRTOS starter instead — it’s more ergonomic and produces smaller code overall.

Prereqs. Install the nros CLI once per machine, then provision this board. nros setup fetches a prebuilt bare-metal toolchain (arm-none-eabi-gcc, qemu-system-arm, the zenoh router) plus the Rust thumbv7m-none-eabi target into a shared store — no manual cross-compiler install, no ROS 2 needed.

# Build the in-tree nros CLI (Phase 218):
./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

# Provision the bare-metal Cortex-M3 board (zenoh RMW is the default):
nros setup mps2-an385-baremetal --rmw zenoh

One key, not two. qemu-arm-baremetal and mps2-an385 were the same entry under two spellings until phase-437 W5 collapsed them (RFC-0093: a board name states the reach of its build, and this build is true for one silicon part). Nothing extra is provisioned for physical hardware — no probe/flash tooling. For a real STM32F4 see the out-of-tree worked example.

Project layout

examples/mps2-an385-baremetal/rust/talker/
├── system.toml                # WHAT this deploys to — the one file you edit
├── Cargo.toml                 # Rust deps only; nothing here names a board
├── package.xml                # ROS-style manifest (drives codegen tooling)
├── generated/                 # generated message bindings (gitignored)
├── build/                     # everything `nros sync` generates (gitignored)
└── src/                       # lib.rs component class + main.rs entry

There is no .cargo/ to edit. The target triple, the linker script, the --gc-sections flag, the QEMU runner and the [patch.crates-io] rows that resolve nano-ros’s crates all come from the board, and nros sync writes them into build/mps2-an385-baremetal/nros-cargo.toml — a generated file nros build hands cargo with --config (RFC-0098).

The board crate is nros-board-mps2-an385 (note: no -freertos suffix — this is the bare-metal variant) which provides:

  • Cortex-M3 startup + linker script
  • LAN9118 driver for smoltcp
  • BoardIdle::wfi() for cooperative wait

Direct-exec or RTIC — one crate, two entry shapes

The same crate serves both bare-metal entry models. Both are names of the same board descriptor, so you pick the entry shape in system.toml and the matching Cargo feature — never a different board crate:

Entry shape[image.<id>] board =board dep
direct-exec (inline spin loop)"qemu-mps2-an385"nros-board-mps2-an385 = { version = "*", features = ["board-entry"] }
RTIC (framework-owned #[rtic::app], deferred dispatch)"rtic-mps2-an385"nros-board-mps2-an385 = { version = "*", features = ["rtic"] }

The in-tree RTIC peers (listener-rtic/, service-client-rtic/, action-server-rtic/, …) differ from their direct-exec siblings in exactly those two lines. (This talker no longer writes the row at all: its generated selection package, described below, derives board-entry or rtic from the board name.)

The RTIC surface used to be a separate nros-board-rtic-mps2-an385 crate; phase-337 W6.a folded it in, because it depended on this crate and re-declared its Config — the two copies had drifted to different default IPs, which nothing catches until a node fails to reach the router.

Which network the defaults describe is stated by the board: its nros-board.toml says [board.net] network = "qemu", so Config::default() sits on QEMU’s own user-mode network (10.0.2.15/24, gateway and router 10.0.2.2) — the network every QEMU board shares. Config::qemu_slirp() is the same network at the RTIC entry’s own address (10.0.2.10, router port 7450). Either way, an image that sets its locator and its address (net = { eth0 = { network = "qemu", host = N } }) in its [image.<id>] table overrides the default — which every in-tree example does, and which you should too.

Configure

The whole deployment statement is system.toml, beside the manifest — the same schema a multi-node workspace’s bringup package uses. It is baked at compile time: nros::main!() folds the image’s network identity into a DeployOverlay the board’s boot Config applies. Verbatim from the in-tree examples/mps2-an385-baremetal/rust/talker/system.toml:

[system]
name = "qemu_bsp_talker"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "qemu_bsp_talker"
class = "qemu_bsp_talker::Talker"
name = "talker"
dispatch = "deferred"

[image.mps2-an385-baremetal]
board = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net = { eth0 = { network = "qemu", host = 10 } }

board picks the silicon and everything that follows from it; rmw picks the backend. Everything the board implies — triple, linker script, runner, cross compiler, [patch.crates-io] — follows the board = line on the next nros sync, so no linker flag and no triple ever moves by hand.

That includes the board crate. This leaf’s Cargo.toml names no board: its image-only dependencies (the board crate with the features its entry needs, the RMW backend, the runtime and the panic handler) come from one generated path dependency,

[target.'cfg(target_os = "none")'.dependencies]
qemu_bsp_talker_nros_selection = { path = "build/nros/selection" }

which nros sync and nros build write for the image’s board, and nros::main! reaches the board through it. So changing board = to "freertos" and re-running nros sync && nros build builds the FreeRTOS image with no other edit. The other bare-metal, FreeRTOS, NuttX, ThreadX-Linux and RTIC Rust examples take the same dependency, and so do the native ones, the copy-out templates’ Rust packages and the C/C++ apps on this board’s cargo link root. A package that names a board crate beside it is refused at nros sync, naming the crate (issue 1305). In a workspace the entry is generated, so there the board line is the only edit.

QEMU Slirp networking — no host TAP / bridge / sudo. The zenoh default port is 7447; this example dials 10500 (the locator above), so start the router (ROS’s rmw_zenohd) on that port:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:10500"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd

or edit the locator above to the port you prefer.

Build

cd examples/mps2-an385-baremetal/rust/talker
nros sync            # message bindings + build/mps2-an385-baremetal/nros-cargo.toml
nros build           # builds the image `[image.mps2-an385-baremetal]` declares

First build (~5 min) cross-compiles all of nano-ros’s Rust deps for thumbv7m-none-eabi. Re-builds finish in seconds. The ELF lands at build/mps2-an385-baremetal/target/thumbv7m-none-eabi/debug/qemu-bsp-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/mps2-an385-baremetal/rust
cargo build --manifest-path talker/Cargo.toml \
            --config talker/build/mps2-an385-baremetal/nros-cargo.toml

Skipping nros sync is not a mysterious failure: nros build stops at its preflight with one line naming the file it could not find and telling you to run sync in this directory.

That settings 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. See Workflow by Platform and Language.

Run

# 1. Bring up the router on the host (Slirp forwards 10.0.2.2:10500 →
#    host 127.0.0.1:10500). This example dials 10500, NOT zenoh's
#    default 7447 — edit `[image.mps2-an385-baremetal] locator` in
#    system.toml if you want 7447:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:10500"];scouting/multicast/enabled=false' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

# 2. Boot the talker in QEMU. Invoke qemu-system-arm directly with the
#    LAN9118 networking wiring the example expects — the board's own
#    runner is bare `-kernel`, so it boots QEMU without networking. The
#    patched qemu-system-arm is provisioned by
#    `nros setup mps2-an385-baremetal` and reaches PATH via activate.sh:
qemu-system-arm -cpu cortex-m3 -machine mps2-an385 -nographic \
    -icount shift=auto \
    -semihosting-config enable=on,target=native \
    -kernel build/mps2-an385-baremetal/target/thumbv7m-none-eabi/debug/qemu-bsp-talker \
    -nic user,model=lan9118
# Expected serial-over-semihosting output (per src/lib.rs):
#   Publishing: 'Hello World: 1'
#   Publishing: 'Hello World: 2'
#   ...

# 3. Verify from stock ROS 2:
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

QEMU exits via Ctrl-A x.

Readiness signal. Within ~15 seconds of QEMU boot (no RTOS init delay, but smoltcp + zenoh handshake still takes a few seconds), expect Publishing: 'Hello World: 1' on semihosting stdout — the count starts at 1, matching the official ROS 2 demo talker. If no Publishing: line:

  1. Router not running — talker spins on smoltcp poll until killed.
  2. Wrong LAN9118 emulation flag — qemu-system-arm needs -nic user,model=lan9118 (or equivalent). The board’s runner (in the generated build/mps2-an385-baremetal/nros-cargo.toml) is bare -kernel, so booting through it gives you QEMU without networking; the direct qemu-system-arm invocation shown above carries the LAN9118 wiring and is the working invocation for this tutorial. If you copy the runner out, mirror those flags.
  3. Cooperative spin starvation — if you added a long-running callback, the entire executor stalls; bare-metal has no preemption.
  4. See Troubleshooting — First 10 Minutes.

GitHub source

Constraints to be aware of

  • No alloc by default. Pure no_std + heapless for bounded collections. If you need alloc, opt in via the alloc feature on your board crate and supply a #[global_allocator].
  • No wake primitive. Cooperative single-thread spin only; the executor’s nros_platform_wake_* slots return Unsupported.
  • No preemption. A long-running user callback blocks every other dispatchable handle until it returns.
  • nros-c / nros-cpp NOT supported. These wrappers assume hosted-RTOS libc + heap. Pure-Rust API only on this target.

For Cortex-M3 with an RTOS, switch to the FreeRTOS starter.

Next

  • Subscriber / service / action peers under the same examples/mps2-an385-baremetal/rust/ tree.
  • Wake-callback opt-in: the wake-callback (latency-probe) bench under packages/testing/nros-bench/wake-latency-cortex-m3/ shows how to feed a backend’s transport-notify into the cooperative spin loop on bare-metal.
  • Real hardware: the same code runs on an STM32F4-Discovery with a board crate of your own and a different linker script — see Worked Example — STM32F4 Out of Tree, which takes that board through the customization ladder end to end.

PX4 Autopilot (external module)

Single-node starter on PX4 Autopilot via the external-module copy-out template. PX4’s EXTERNAL_MODULES_LOCATION pattern lets downstream firmware drop in nano-ros without forking PX4 itself. C++ only — PX4’s uORB binding is C++-only (Rust + C not in the coverage matrix).

Prereqs. A PX4-Autopilot ≥ v1.16 clone, the matching cross toolchain (e.g. gcc-arm-none-eabi for Pixhawk targets), and python3 with the PX4 development requirements installed (bash ./Tools/setup/ubuntu.sh once).

Project layout

PX4 external modules live outside the PX4 source tree and are hooked in at configure time. The template at integrations/px4/module-template/ has the PX4-required src/modules/<name>/ shape:

my_drone_firmware/
├── PX4-Autopilot/                          # PX4 source tree (submodule)
└── px4-modules/                            # passed via EXTERNAL_MODULES_LOCATION
    └── nano-ros/                           # copy-out from integrations/px4/module-template/
        └── src/
            ├── CMakeLists.txt              # populates config_module_list_external
            └── modules/
                └── nano_ros_app/
                    ├── CMakeLists.txt      # px4_add_module(... MAIN nano_ros_app)
                    └── nano_ros_app.cpp    # the user-editable app

Prereq. PX4 is a full-tier dependency. Provision the source trees first to populate third-party/px4/PX4-Autopilot and third-party/px4/px4-rs, then initialize PX4’s own submodules.

nros setup --source px4-autopilot --source px4-rs
git -C third-party/px4/PX4-Autopilot submodule update --init \
    --depth 1 --recommend-shallow --recursive

PX4’s own Python dependencies come from its Tools/setup scripts (e.g. PX4-Autopilot/Tools/setup/ubuntu.sh) — run those per PX4’s documentation if the firmware build complains about missing Python modules.

Contributors (in-tree checkout): just px4 doctor reports the provisioning gap on a fresh clone.

Vendor the template into your firmware repo, then point PX4 at its parent directory. The template finds nano-ros itself: an explicit -DNROS_REPO_DIR=<path> cache variable wins, otherwise it asks the nros on PATH (nros sdk-root), which answers with the SDK that binary belongs to. No environment variable is read (RFC-0103 D6):

cmake -B build -S PX4-Autopilot \
      -DCONFIG=px4_fmu-v5_default \
      -DEXTERNAL_MODULES_LOCATION=$PWD/px4-modules/nano-ros \
      -DNROS_REPO_DIR=$PWD/../nano-ros           # optional: name your clone

(EXTERNAL_MODULES_LOCATION must point at the dir containing the template’s src/ — that’s px4-modules/nano-ros, not its parent. PX4’s root CMakeLists.txt does add_subdirectory("${EXTERNAL_MODULES_LOCATION}/src" …), so the path resolves to px4-modules/nano-ros/src per the layout above.)

Inside the module, the canonical pattern bridges uORB → nano-ros. The module is a PX4Module subclass that runs in its own work queue, opens an nros executor, and forwards uORB messages onto a zenoh / DDS topic (or vice versa). Edit nano_ros_app.cpp to add your topic bindings.

Configure

The template does not ship a Kconfig overlay (no Kconfig.projbuild files). Module enablement is implicit once EXTERNAL_MODULES_LOCATION points at the template’s parent. Pass RMW + ROS-edition selection via CMake cache vars rather than menuconfig:

cmake -B build -S PX4-Autopilot \
      -DCONFIG=px4_fmu-v5_default \
      -DEXTERNAL_MODULES_LOCATION=$PWD/px4-modules/nano-ros \
      -DNROS_RMW=zenoh

(Adding a Kconfig overlay so the module appears under menuconfig → External modules is a follow-up task; for now the template is always enabled.)

Build

cd PX4-Autopilot
make px4_fmu-v5_default
# Or for the SITL simulator (POSIX target — easier to develop against):
make px4_sitl_default gazebo

The first build cross-compiles nano-ros’s Rust staticlibs alongside PX4’s NuttX kernel + apps (~10 min on a fresh checkout).

Run

# SITL: PX4 boots Gazebo + the autopilot binary
cd PX4-Autopilot
make px4_sitl_default gazebo
# In the PX4 console:
pxh> nano_ros_app start

# Real hardware (Pixhawk): flash via QGroundControl or
#     `make px4_fmu-v5_default upload` over the bootloader USB

# Verify from stock ROS 2 on the same network (only after you have
# added uORB → nano-ros forwarders to nano_ros_app.cpp — the shipped
# template forwards nothing on its own):
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /vehicle_local_position px4_msgs/msg/VehicleLocalPosition

Readiness signal. After nano_ros_app start in the PX4 console, the shipped template logs nano-ros uORB backend registered and returns immediately — it doesn’t start a publisher loop or print a “bridge started” line on its own. You’re expected to edit nano_ros_app.cpp to wire your uORB → nano-ros forwards. If nano_ros_app reports register failed, check:

  1. Module didn’t register — the template logs nros_rmw_uorb_register() -> <rc> on startup (search the PX4 boot log for nros_rmw_uorb_register). A non-zero <rc> usually means a NuttX kernel-config / feature-gate mismatch.
  2. Once you’ve added forwarders, zenohd must be reachable from the autopilot’s network (Pixhawk: configured via QGroundControl or param set).
  3. uORB topic not advertised yet — start the upstream PX4 module that publishes it (commander start etc.) first.
  4. See Troubleshooting — First 10 Minutes.

GitHub source

Constraints to be aware of

  • uORB binding is C++-only. The PX4 example collapses uORB registration to a C++ port; Rust / C variants exist for non-PX4 RTOSes but not for PX4.
  • PX4’s NuttX kernel. Underneath, PX4 runs NuttX; if you need to debug at the kernel layer, the NuttX starter page applies too.
  • uORB throughput vs zenoh hops. uORB is in-process pub/sub at ~µs latency; zenoh adds network-RTT. Plan accordingly when bridging high-rate streams.

Next

  • Add your own uORB topics to the bridge: see the nano_ros_app.cpp template’s topic-table section.
  • Multi-vehicle: PX4-XRCE-Agent → nano-ros XRCE backend gives you the standard PX4-ROS bridge with nano-ros on the autopilot side.
  • For pure-NuttX (no PX4) firmware: see the NuttX starter.

PX4 (integration shell)

Contributor docs? Building nano-ros’s PX4 example from this repository is covered at PX4 (contributor).

nano-ros lifts the PX4 EXTERNAL_MODULES_LOCATION pattern into a generic copy-out template under integrations/px4/module-template/. Downstream PX4 apps vendor the template, customise it, and point EXTERNAL_MODULES_LOCATION at their copy.

Prereqs

  • PX4-Autopilot checkout (git clone https://github.com/PX4/PX4-Autopilot)
  • The full PX4 SITL build toolchain (make px4_sitl_default works standalone)

Contributors (in-tree checkout): for nano-ros’s in-repo PX4 checks:

just setup px4              # `_setup-common` + the px4 module recipe
just px4 doctor

One-liner vendor

cp -r /path/to/nano-ros/integrations/px4/module-template \
      ./my-px4-modules

Layout (PX4 enforces this exactly)

my-px4-modules/
└── src/
    ├── CMakeLists.txt                       (lists module names)
    └── modules/
        └── nano_ros_app/
            ├── CMakeLists.txt               (px4_add_module)
            └── nano_ros_app.cpp             (module body)

Rename nano_ros_app consistently in all three places when adapting for your own module.

Build

export EXTERNAL_MODULES_LOCATION=$PWD/my-px4-modules
export NANO_ROS_DIR=/path/to/nano-ros
make -C $PX4_AUTOPILOT_DIR px4_sitl_default

PX4’s build picks up the module via <location>/src/CMakeLists.txt. The module CMakeLists pulls in nano-ros C++ headers via $NANO_ROS_DIR.

Reference

The validated end-to-end SITL pattern (with the Rust-side nros-rmw-cffi_register weak-stub trick) lives at packages/testing/nros-px4-register-check/. Use that as the source of truth for the register-check shape; this template is the generic shell for downstreams writing their own modules.

Registry note

PX4 has no central module registry. Downstreams vendor the template via git submodule or cp -r. See Registry Publishing for the cross-ecosystem comparison.

ARM FVP (FVP_BaseR_AEMv8R)

Run nano-ros on Arm’s Base_RevC AEMv8-R Fast Models — the Cortex-A SMP profile under Zephyr 3.7 (board id fvp_baser_aemv8r/fvp_aemv8r_aarch64/smp). The FVP is the canonical local proxy for the safety-island reference platforms that follow the same hwv2 Zephyr shape; pair this chapter with the Zephyr (west module) starter for the build half.

Status. The run half — invoking FVP_BaseR_AEMv8R and piping UART 0–3 to stdout — is landed.

The two standalone talker lanes were retired (issue 0537): their runners had been deleted, so they built images nothing booted. What remains is the part with consumers — the board-crate import surface and the workspace Entry. FVP support is intended to grow back from those. See docs/roadmap/archived/phase-217-arm-fvp-local-runtime.md.

When to use

  • You need to exercise a Cortex-A SMP Zephyr image on a developer laptop without dedicated silicon.
  • You’re bringing up a new hwv2 safety-island target — the FVP is the closest in-tree reference for board.cmake + SMP boot + Cyclone DDS shape.
  • You’re validating stock-Cyclone-DDS RMW interop locally before promoting to a hardware bench.

The FVP is not a replacement for QEMU on Cortex-M (mps2-an385, mps3-an547); those targets are covered by FreeRTOS (QEMU) and the Zephyr starter. The FVP is also not the same surface as Corellium’s AVH cloud FVP — AVH packages firmware as .coreimg and provisions via a remote API; local FVP loads a raw ELF via -a cluster0.cpu*=<elf>. AVH is out of scope for this chapter.

Prereqs

  • A working Zephyr 3.7 workspace — run nros setup zephyr once if you haven’t (see the Zephyr starter).

  • The Arm Base_RevC AEMv8R Fast Models binary. nano-ros fetches it:

    nros setup --tool arm-fvp
    

    nros setup board fvp-aemv8r-smp does it as one of its steps, so most people never run the line above directly. It is a pinned Arm CDN permalink with a checked SHA256 ([tool.arm-fvp] in nros-sdk-index.toml), installed to ~/.nros/sdk/arm-fvp/<version>.

    x86_64 Linux only — Arm publishes no other host build of this model. On another architecture you can still build the image; you cannot run it here.

    Until 2026-09-06 this page said the model was license-gated and that fetching it was your responsibility. It is not: the permalink answers 200 with no authentication, which is how autoware-safety-island had been downloading it in CI the whole time. The mistaken policy cost every reader of this page a manual install.

After installing, export one of the discovery env vars:

# Preferred — Zephyr's canonical name; takes highest priority.
export ARMFVP_BIN_PATH=/opt/Arm/FastModels/Base_RevC_AEMv8R/models/Linux64_GCC-9.3

# Alternative — directory layout from the gated installer; the
# resolver scans `models/Linux64_GCC-*/` underneath it.
export ARM_FVP_DIR=/opt/Arm/FastModels/Base_RevC_AEMv8R

If neither is set, FVP_BaseR_AEMv8R is discovered via PATH as a last-ditch fallback.

Installer surface

After extracting the Arm FVP tarball, run the discovery script:

ARM_FVP_DIR=/path/to/extracted/fvp \
    scripts/installers/arm-fvp-installer.sh

The installer locates FVP_BaseR_AEMv8R under $ARM_FVP_DIR, symlinks the containing directory to ~/.nros/sdks/arm-fvp/current/ (atomic via ln -sfn), and prints the export ARMFVP_BIN_PATH=… line for your shell rc. Run scripts/installers/arm-fvp-installer.sh --print-env later to re-emit the export. It never downloads anything — gated-tool policy.

Doctor check

nano-ros checks that the model resolves via ARMFVP_BIN_PATH, ARM_FVP_DIR, PATH, or the SDK store (nros sdk-path arm-fvp), and warns — never hard-fails — when it can’t. A warning rather than an error because a contributor who is not working on the FVP should not be blocked by its absence; nros setup --tool arm-fvp is the fix when they are.

Contributors: the in-tree doctor cross-check and FVP run recipes are in Per-Platform Contributor Lanes.

Build

Contributors: the in-tree FVP build lanes (and the zephyr.elf paths they produce) are in Per-Platform Contributor Lanes.

Two standalone talker lanes (build-fvp-aemv8r-cyclonedds{,-rust}, over examples/zephyr/{cpp,rust}/talker-aemv8r) used to live here. Their tests were deleted and the lanes later retired, so the chapter no longer documents a lane whose output nobody consumes.

Run

Once the build artifacts and ARM_FVP_DIR / ARMFVP_BIN_PATH are in place:

Contributors: the in-tree FVP run lanes (and what they do under the hood) are in Per-Platform Contributor Lanes.

Expected output

The Zephyr 3.7 boot banner appears on UART0 first, followed by the talker. The exact line counts depend on CONFIG_BOOT_BANNER and your locator config, but the markers to look for are:

*** Booting Zephyr OS build v3.7.0 ***
[00:00:00.xxx,000] <inf> nros: session up (domain 0)
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
...

For the cpp/cyclonedds recipe, the same banner is followed by Cyclone DDS reader-match logs and std_msgs/String publish lines. Verify ROS 2 interop by running a sibling listener in another terminal:

# stock ROS 2 — reads the FVP's Cyclone DDS publisher
ros2 topic echo /chatter std_msgs/msg/String

The same std_msgs/String payload (Hello World: N) + byte-equal CDR framing must appear on both sides — that’s the stock-RMW interop contract (byte-compatible with rmw_cyclonedds_cpp).

Cross-references

Native (Host) Build

Use the host (linux) board for first experiments, CI smoke tests, and ROS 2 interoperability checks on Linux. It uses OS sockets, the host process environment, and the same source checkout layout as the embedded targets.

The host board is named linux ([image.<id>] board = "linux") because that is all it supports: the platform layer (nros-platform-posix) is POSIX, but the host board crate’s apply_tier_affinity calls sched_setaffinity with cpu_set_t, which libc does not define for macOS, Windows or FreeBSD. Only Linux is supported (AGENTS.md, phase-260, phase-500). Neither native (every general-purpose host) nor posix (the platform) names a board: board = "…", nros setup, nros new --platform and the just lane/scope words refuse both with a hint naming linux.

When to Use It

  • You want the shortest path to a working nano-ros publisher or subscriber.
  • You are validating message generation, CMake integration, or ROS 2 interop before moving to an RTOS.
  • Your deployment target is Linux or an embedded Linux system.

Setup

Build the in-tree nros CLI (Phase 218), then provision the native host. For a host build there is no cross-toolchain to fetch — nros setup linux installs the client stack (and for xrce the Micro-XRCE-DDS agent) into a shared store. The zenoh router is NOT installed: it comes from a ROS 2 install (ros2 run rmw_zenoh_cpp rmw_zenohd, RFC-0075). No ROS 2 on this machine? Use --rmw cyclonedds — it needs no daemon.

# Build the in-tree nros CLI:
./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

# Provision the native host (zenoh RMW is the default):
nros setup linux --rmw zenoh        # or: --rmw xrce / --rmw cyclonedds

native and linux are the two names of this board (native the role, linux the reach); posix names the platform layer underneath it, not the board.

For a colcon consumer workspace that already has nano-ros under src/:

colcon build && source install/setup.bash

Package Layout

For a host application package:

my_native_node/
├── package.xml
├── system.toml         # board, rmw, domain, components
├── Cargo.toml          # Rust path
├── CMakeLists.txt      # C / C++ path
└── src/
    └── main.rs         # or main.c / main.cpp

Examples are copy-out ready, and nothing in a copied one names a path back into the checkout. For Rust, cp -r the example, then nros sync and nros build (nros finds its own SDK; --nano-ros-path names another). For C/C++, cp -r and run nros build, or configure a raw cmake with -Dnano_ros_ROOT=<nano-ros checkout> — a single-package C/C++ leaf needs no nros sync, because its message bindings are a CMake-time output.

Code Example

Rust publisher skeleton:

use core::fmt::Write as _;
use nros::prelude::*;
use std_msgs::msg::String as StringMsg;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = ExecutorConfig::from_env().node_name("talker");
    let mut executor = Executor::open(&config)?;
    let mut node = executor.create_node("talker")?;
    let publisher = node.create_publisher::<StringMsg>("/chatter")?;

    let mut count = 0i32;
    loop {
        count += 1;
        let mut msg = StringMsg::default();
        let _ = write!(msg.data, "Hello World: {count}");
        publisher.publish(&msg)?;
        executor.spin_once(100);
    }
}

Use the full First Node — Rust walkthrough for generated messages and runnable commands.

Build and Run

Start the RMW host daemon (installed by nros setup linux). For zenoh:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

Run the node directly — nros build then ./build/<image-id>/target/debug/<binary> (Rust), or cmake --build build && ./build/<binary> (C/C++) — or via colcon build && source install/setup.bash && ros2 run … if the package lives in a colcon consumer workspace.

Configuration

Native examples can read runtime settings from the shell:

export ROS_DOMAIN_ID=0
export NROS_LOCATOR=tcp/127.0.0.1:7447

The same fields are compiled into embedded targets through their platform-specific configuration files. See Configuration for the full layering.

Deployment

Host deployment is normal process deployment: install the workspace, source install/setup.bash, set environment, and run the binary. For ROS 2 interop, run ROS 2 Interoperability and set the ROS side to rmw_zenoh_cpp.

Workflow by Platform and Language

You have a target and a language. This page is the sequence of commands that pair implies, and the one step whose absence is the most common first failure.

The rest of the book is organized by where you are going — a starter page per platform. This page is organized by what you type, because the commands vary along a different axis than the pages do: the builder follows from your language, and the toolchain follows from your platform. Neither table below is a summary of the other.

Where the target comes from

Every leaf, in every language, states what it deploys to in one file: system.toml, beside its Cargo.toml or CMakeLists.txt (RFC-0098). [system] carries the RMW and the domain, [[component]] carries what runs, and one [image.<id>] per target carries its board and, on an embedded image, its network identity:

[system]
rmw       = "zenoh"
domain_id = 0

[image.mps2]
board   = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

Everything that choice implies — the target triple, the linker flags, the QEMU runner, the cross compiler for the cc crate, the pool sizes derived from the components, the [patch.crates-io] table that resolves nano-ros’s registry-style names into your checkout — is generated, never written by hand. Retargeting is editing the board line: in a workspace that is the whole edit, and in a single-package Rust leaf too once it depends on its generated <pkg>_nros_selection package instead of a board crate, as every in-tree single-package example on a supported board does (issue 1305).

The step that is easy to miss

Rust leaves need nros sync before their first build. C and C++ leaves do not.

That split is a property of the two languages, not an inconsistency. CMake generates a C/C++ leaf’s message bindings during configure, so there is nothing to prepare first. Cargo has no such stage: the message crates a Rust leaf path-depends on, and the settings file that carries its board facts, both have to exist before cargo parses the manifest. nros sync produces them:

<leaf>/generated/<pkg>/                  # message crates
<leaf>/build/<image>/nros-cargo.toml     # every cargo setting the board implies

Skipping it fails in three ways, and they read very differently. nros build says so in one line and names the remedy:

Error: <leaf> has not been synced — missing the resolved model under
`build/nros/models/`, the generated message crate `std_msgs`
(generated/std_msgs).
  Run `nros sync` in <leaf> (RFC-0098 D2), then build again.

A plain cargo build gets as far as the manifest and stops on the missing message crate, without mentioning nano-ros at all:

error: failed to load manifest for dependency `std_msgs`
Caused by:
  failed to read `<leaf>/generated/std_msgs/Cargo.toml`
Caused by:
  No such file or directory (os error 2)

And a cargo build --config <leaf>/build/<image>/nros-cargo.toml whose settings file does not exist yet produces the one that does not look like a missing file at all — cargo falls back to reading the argument as an inline setting:

error: failed to parse value from --config argument
`<leaf>/build/<image>/nros-cargo.toml` as a dotted key expression
Caused by:
  TOML parse error at line 1, column 39
  key with no value, expected `=`

If you see any of the three, run nros sync.

Contributors (in-tree checkout): the just recipes — just <module> build-fixtures and friends — run nros sync for you, so they work from a fresh clone. It is the hand-run build in a leaf that needs you to run it yourself.

You need it once per checkout location, not once per build. Re-run it after editing a .msg, .srv, or .action file, after changing system.toml, and after moving the checkout.

Which builder your cell uses

Each cell is the builder that nano-ros’s own CI uses for that pair, read from examples/fixtures.toml — the manifest the fixture builds and the staleness probe both consume. A dash means the pair has no in-tree coverage today, not that it is forbidden.

The row names are the manifest’s, which are shorter than the ones you type: freertos here is the platform whose board is mps2-an385-freertos and whose examples live in examples/mps2-an385-freertos/. The per-platform table further down maps all three spellings.

platformrustccppmixed
linuxcargocmakecmakecmake
freertoscargocmakecmakecmake
nuttxcargocmakecmake—
nuttx-riscvcargocmakecmake—
threadx-linuxcargocmakecmakecmake
threadx-riscv64cargo, cmakecmakecmake—
zephyrcargo, westcmake, westcmake, westcargo
zephyr-cortex-mwestwestwest—
esp32cargo———
baremetalcargo———

Where a row lists two builders, both are real and they build different things: on Zephyr, west builds the single-node examples under examples/zephyr/<lang>/, and cargo/cmake build the workspace examples under examples/workspaces/.

The three command shapes

Every cell above is one of these three, plus platform-specific flags that live on the platform’s own starter page.

cargo — Rust, every platform

./scripts/bootstrap.sh          # builds the in-tree nros CLI
source ./activate.sh            # OR: direnv allow / source ./activate.fish
nros setup <board> --rmw zenoh  # toolchain + SDK for the target

cd <leaf>
nros sync                       # ← the step above; once per checkout
nros build                      # every [image.*]; or: nros build <image-id>

The artifact lands under build/, keyed on the image: <leaf>/build/<image-id>/target/[<triple>/]<profile>/<bin>. No --target on the command line, on any platform — the board’s triple is in the generated settings file, which is also where its linker flags and its QEMU runner live.

If you would rather drive cargo yourself — an IDE, a CI step, --release — run nros sync and then point cargo at that 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, and then the working directory stops mattering:

cd <leaf-parent>
cargo build --manifest-path <leaf>/Cargo.toml \
            --config <leaf>/build/<image-id>/nros-cargo.toml

cargo run through the same file works for a QEMU board — the runner is in it.

cmake — C and C++

./scripts/bootstrap.sh
source ./activate.sh
nros setup <board> --rmw zenoh

cd <leaf>
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)" \
    -DCMAKE_TOOLCHAIN_FILE=<toolchain> -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel

No nros sync, and no -DNROS_BOARD / -DNROS_RMW either: find_package(nano_ros) reads the leaf’s system.toml and derives the platform from the board it names. The toolchain file is still per platform — see the starter page. -D_NANO_ROS_CODEGEN_TOOL= is not needed when nros is on PATH; CMake resolves it.

nros build drives exactly this cmake pair for you, in a single-package leaf (into build/<image>/cmake, with the board’s toolchain file — issue 1296) as in a C/C++ workspace.

west — Zephyr single-node examples

Zephyr owns the build. See Zephyr (west module) for the module wiring; the Rust leaves under examples/zephyr/rust/ still need nros sync first, because west drives cargo and cargo needs the generated message crates either way.

In the checkout, or copied out?

The commands above are written as cd <leaf> inside the nano-ros checkout, which is the fastest way to see something run. For anything beyond that, copy the example directory out — examples are standalone copy-out projects with no workspace walk-up, so a copied one builds on its own.

Either way the build output goes to the same place — build/<image>/, under the leaf, because the generated settings file names that target-dir — so an in-tree build leaves no target/ beside the sources for the repo’s own gate to reject (check-example-leaf-target-dirs). A copied-out example carries no path into the checkout at all: nros ties it to the SDK the binary belongs to, and --nano-ros-path names another on the command line rather than in a file.

So: exploring in the checkout, cd <leaf> && nros sync && nros build. Building your own thing, copy the example out and run the same two commands there.

Per platform

platformgrid rownros setup <board>examples underrecipes (contributors)starter page
Linux hostlinuxlinuxexamples/linux/just linux …Native host build
FreeRTOS (QEMU MPS2-AN385)freertosmps2-an385-freertosexamples/mps2-an385-freertos/just freertos …FreeRTOS
NuttX (Arm)nuttxqemu-armv7a-nuttxexamples/qemu-armv7a-nuttx/just nuttx …NuttX
NuttX (RISC-V)nuttx-riscvrv-virt-nuttxexamples/rv-virt-nuttx/just nuttx …NuttX
ThreadX (Linux sim)threadx-linuxthreadx-linuxexamples/threadx-linux/just threadx_linux …ThreadX
ThreadX (QEMU RISC-V 64)threadx-riscv64rv-virt-threadxexamples/rv-virt-threadx/just threadx_riscv64 …ThreadX
Zephyrzephyr, zephyr-cortex-mzephyrexamples/zephyr/just zephyr …Zephyr
ESP32 (dormant, issue 1525)esp32esp32-c3-baremetalexamples/esp32-c3-baremetal/— (module not mounted)ESP32
Bare-metal Cortex-M3baremetalmps2-an385-baremetalexamples/mps2-an385-baremetal/just qemu …Bare-metal
Arm FVP (Cortex-A SMP)—zephyr + the Arm FVP (nros setup --tool arm-fvp, x86_64 only)——ARM FVP

Multi-node workspace examples do not follow that directory rule: they all live under examples/workspaces/, selected by fixture row rather than by directory. So ESP32’s single-node and workspace examples share one grid row, esp32: a grid row names a platform FAMILY, never a board (RFC-0093 R6), and esp32 carries both. Zephyr is the case that does split — zephyr and zephyr-cortex-m build the SAME examples for different boards.

Contributors (in-tree checkout): each module’s recipes are discoverable rather than memorized:

just --list freertos          # every FreeRTOS recipe, grouped
just --list zephyr

More than one node

The sequence above builds one leaf. A project with several nodes adds a Bringup package carrying the same system.toml — and nothing else: no root build file, and no entry package to write, because the entry is generated per [image.*]. The commands stay nros sync and nros build, which then drive one of the three shapes above for you. See Project layout.

If it does not work

Application Workflow

nano-ros users usually want one path: prepare package, write node, build it, then deploy to target. Use Concepts only when workflow raises a technical question.

1. Prepare Workspace and Package

nano-ros is shipped source-only — vendor it next to (or inside) your workspace, then provision the board’s toolchain with nros setup:

# Build the in-tree nros CLI (Phase 218), then provision your board (+ RMW):
./scripts/bootstrap.sh base
source ./activate.sh        # OR: direnv allow / source ./activate.fish
nros setup linux --rmw zenoh        # or mps2-an385-freertos, zephyr, …

nros setup provides most components prebuilt per platform per RMW; a few without a seeded asset (the vendored submodules — zenoh-pico, mbedtls, RTOS sources) are built from source. nros setup <board> --rmw <rmw> --dry-run shows which is which on your host — see Installation.

It does not provide a zenoh router: that is ROS’s rmw_zenohd, so a multi-process zenoh example needs a ROS 2 install (or NROS_RMW_ZENOHD). --rmw cyclonedds needs no daemon at all.

For multi-package workspaces (Pattern A — recommended for POSIX + mixed C / C++ / Rust deployments), put nano-ros and your packages side-by-side under a shared src/:

~/ros2_ws/
├── src/
│   ├── nano-ros/                  # this repo
│   ├── my_robot_node/
│   │   ├── package.xml
│   │   ├── Cargo.toml             # Rust — dependencies; nothing about the board
│   │   ├── CMakeLists.txt         # C/C++ — targets; nothing about the board
│   │   └── src/
│   └── my_robot_bringup/
│       ├── system.toml            # board, RMW, components — the one place
│       └── launch/
└── build/ log/                    # generated; the workspace has no root build file

A package’s Cargo.toml and CMakeLists.txt carry language-toolchain facts only. Which board the program deploys to, which RMW it speaks, and which components it runs are stated once in system.toml — in the bringup package for a workspace, beside the package for a single-package project — and everything that choice implies is generated by nros sync (RFC-0098).

For third-party C/C++ projects without colcon (Pattern B), pull nano-ros in as a third_party/nano-ros/ git submodule and consume it via add_subdirectory(third_party/nano-ros nano_ros).

See Installation for both patterns. The per-language starter pages document the canonical package shape in their “Project layout” sections: Rust, C, C++. For two or more nodes, use the Multi-Node Projects group: start from the full project layout, then drill into Node, Bringup, and Entry packages.

2. Write Node Code

Choose API language first:

  • Rust — use nros, generated message crates, and Executor.
  • C — include rclc/rclc.h and generate interfaces with nros_find_interfaces(LANGUAGE C) in CMake (one of five codegen entry points — which to use).
  • C++ — include rclcpp/rclcpp.hpp and use typed wrappers.

Start with one of the Linux starters above, then adapt to your target via the Embedded Starters section.

3. Generate Messages

If you use custom .msg, .srv, or .action files, generate bindings inside the workspace/build tree. See Message Binding Generation.

4. Configure Target

The board and the RMW backend are a build-time choice — there is no RMW_IMPLEMENTATION runtime switch on embedded targets — and you state them in system.toml, the same way in every language:

[system]
name      = "my_robot"
rmw       = "zenoh"          # zenoh | cyclonedds | xrce
domain_id = 0

[[component]]
pkg   = "my_robot_node"
class = "my_robot_node::Talker"
name  = "talker"

[image.linux]               # one per program you want to build
board = "linux"

[image.mps2]                 # an embedded image carries its network identity too
board   = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

Switching board is editing [image.<id>] board and re-running nros sync. The platform, the rustc triple, the linker flags, the cross compiler and the pool budgets all follow from that one word — none of them is yours to write. The board crate follows too in a workspace, where the entry is generated, and in a single-package Rust project that depends on its generated <pkg>_nros_selection package (every in-tree single-package example on a supported board does); one that still names its board crate in [dependencies] must move the two together (issue 1305).

Supported pairs: posix / freertos / nuttx / threadx / zephyr / esp32 / baremetal × zenoh / xrce / cyclonedds. Not every cell is implemented — see the Coverage Matrix.

Runtime configuration (ROS_DOMAIN_ID, NROS_LOCATOR, …) works on POSIX; embedded targets bake the [image.*] values into the firmware, plus Kconfig on Zephyr. See Configuration.

5. Build, Test, Deploy

Two commands, everywhere:

nros sync          # generate what the board choice implies
nros build         # build every declared image; or: nros build <image-id>

nros sync writes the generated message crates, resolves the launch files into the system model, and writes each image’s build settings under build/. nros build generates that image’s entry, then hands off to the native tool — cargo, CMake or west — so compiler diagnostics are exactly what you would see running it yourself. nros build is where a ROS 2 user’s colcon build goes: same discovery of src/, same dependency order, same build/ and log/ beside the workspace.

Sync is a separate step, not an implicit one. Re-run it after editing system.toml or a .msg file; if you forget, nros build stops at its preflight and says so in one line — generated message bindings (this workspace has never been synced) / run: nros sync — rather than failing somewhere inside cargo.

A single Rust package works the same way:

cd examples/linux/rust/talker
nros sync
nros build

A single C/C++ package keeps its own CMake, and needs no sync — its message bindings are a CMake-time output:

cd examples/linux/c/talker
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)"   # raw cmake names the SDK root
cmake --build build

(nros build in the leaf runs the same pair into build/<image>/cmake, with the board’s toolchain file — issue 1296, resolved.)

Zephyr is the carve-out: west build is still its build verb, with nros sync run first — see Deployment Workflow.

# Contributors: per-platform multi-example fixture build, in-tree
# checkout only (these run `nros sync` for you):
just freertos build-fixtures
just zephyr  build-fixtures
just nuttx   build-fixtures

# Contributors: discover full-matrix commands for a platform:
just --group full-matrix --list zephyr

Which shape your target uses is Workflow by Platform and Language.

nros sync resolves the bringup’s system.toml (plus its launch files) into a SystemModel under the workspace build tree. SystemModels are build artifacts — never committed, and never named by an entry package; an entry names its input (nros::main!(launch = "bringup"), nano_ros_entry(BRINGUP … LAUNCH …)) and the build locates the artifact. nros model-path prints where a resolved one landed.

nros metadata / nros plan / nros check are the inspection path, not the build path: they produce and validate an nros-plan.json you can read with nros explain. You do not need them to build.

For target-specific deployment, go to the matching platform guide. Each guide covers toolchain setup, package layout, code example, build command, run/flash command, and deployment notes.

See Deployment Workflow.

Build as a CMake subdirectory

This is the escape hatch, not the path. If your project is a nano-ros package or workspace, you state the board once in system.toml and let nros sync + nros build drive CMake — you never set a cache variable by hand, because the build root it generates sets them from that one board name (RFC-0098). Start at First Project and Project layout instead.

This page is for the C or C++ project that is not a nano-ros workspace: an existing CMake tree with its own layout and its own conventions, that wants nano-ros as a subdirectory and nothing else. There the cache variables below are how you say what you are building for, because there is no system.toml to read it from. The mechanism is unchanged by RFC-0098 — nros build sets exactly these variables in the root it generates.

The repo’s top-level CMakeLists.txt exposes everything via add_subdirectory(...). The higher-level, ament-shaped alternative is find_package(nano_ros REQUIRED) — nano_rosConfig.cmake at the checkout root, located via -Dnano_ros_ROOT=<root> (RFC-0048), which nros build and the CMakePresets nros init writes pass for you (a raw cmake names it; no shell exports it) — which every in-tree example reaches through the workspace helpers. Either way there is NO install step: phase-140 removed the install prefix and the old find_package(NanoRos) (capital) config pipeline. Vendoring the repo into a company tree (pinning, offline CI, upgrade workflow) has its own page: Integrating into a Vendored Tree.

Layout

my_app/
├── CMakeLists.txt
├── main.c
└── third_party/
    └── nano-ros/        # git clone / git submodule of this repo

User project CMakeLists.txt

cmake_minimum_required(VERSION 3.22)
project(my_app C)

# Pick platform + RMW BEFORE add_subdirectory.
set(NROS_PLATFORM posix)   # posix | freertos | nuttx | threadx | zephyr | baremetal
set(NROS_RMW     zenoh)    # zenoh | xrce | cyclonedds

add_subdirectory(third_party/nano-ros nano_ros)

add_executable(my_app main.c)
target_link_libraries(my_app PRIVATE NanoRos::NanoRos)
if(COMMAND nros_platform_link_app)   # defined by zephyr/threadx platforms only
    nros_platform_link_app(my_app)
endif()

# Optional — generate C bindings for ROS 2 .msg / .srv / .action files.
# (LANGUAGE defaults to CPP; full spelling table → user-guide
# "Message Generation".)
nano_ros_generate_interfaces(std_msgs LANGUAGE C
    DEPENDENCIES builtin_interfaces)
target_link_libraries(my_app PRIVATE std_msgs__nano_ros_c)

That’s the whole story for the host-POSIX / zenoh case. CMake’s transitive target propagation pulls in libnros_c.a, libnros_rmw_zenoh_staticlib.a, the POSIX platform shim, system libraries (pthread, dl, m), and the per-build nros_config_generated.h header automatically.

Cache variables

VariableDefaultValues
NROS_PLATFORMposixposix, freertos, nuttx, threadx (legacy spellings: freertos_armcm3, nuttx_armv7a, threadx_linux, threadx_riscv64). esp_idf was accepted until phase-468 W2 and is now REJECTED. Zephyr and bare-metal are not add_subdirectory platforms — they enter via the Zephyr module / cargo directly
NROS_BOARD(unset)required for threadx (threadx-linux or rv-virt-threadx)
NROS_SKIP_BOOTSTRAPOFFON skips the configure-time bootstrap (git submodule updates). Required for air-gapped/mirrored CI — pre-seed submodules. Corrosion is never fetched: a configure without it in the SDK store stops and names nros setup --tool corrosion
NROS_FEATURES(empty)extra cargo features forwarded to the Rust build
NROS_RMWzenohzenoh, dds, xrce, cyclonedds
NROS_ROS_EDITIONhumblehumble, iron, jazzy
NROS_BUILD_CODEGENONON / OFF

Variables MUST be set(...) BEFORE add_subdirectory(...) — the sub-project consumes them at include time.

In a nano-ros package or workspace none of these are yours to set. The CMake root nros build generates writes NROS_PLATFORM and NROS_BOARD itself, both derived from the one [image.<id>] board you named in system.toml, and passes the backend from [system] rmw through the workspace helper. Setting them by hand there means two answers to one question, which is the arrangement RFC-0098 exists to end.

What about installing?

Phase-140 deleted every nano-ros-side install(...) rule. nano-ros is consumed in source form — never out of an installed prefix. If you need a shippable artefact, your user project owns the install layout; ship your binary, not nano-ros itself.

For RTOS users who want a more idiomatic surface than raw add_subdirectory, see the integration shells under integrations/<rtos>/ — they translate west / esp-idf / NuttX / PX4 manifests into the same root CMake. Each shell is a ~20-line wrapper around add_subdirectory(<repo>).

What the in-tree examples do instead

Worth reading as the contrast, because it is the shape most projects want. examples/linux/c/talker/CMakeLists.txt is fourteen lines and sets no cache variable at all:

cmake_minimum_required(VERSION 3.22)
project(c_talker LANGUAGES C CXX)

find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)

nano_ros_add_executable(c_talker src/main.c)
ament_target_dependencies(c_talker std_msgs)

The board, the RMW, the domain and the network identity are not in that file — they are in the system.toml beside it, and the build reads them from there. A single-package C/C++ leaf like this one configures and builds with plain CMake, no sync step required, because its message bindings are a CMake-time output. A raw cmake names the SDK root (nros sdk-root prints it; nothing exports it):

cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)"
cmake --build build

(nros build in the leaf runs these same two commands for you, into build/<image>/cmake, and passes the board’s toolchain file (issue 1296, resolved). A C/C++ workspace builds with nros sync + nros build as usual.)

All in-tree C/C++ examples follow that shape. The add_subdirectory mechanism on this page sits underneath it.

Integrating nano-ros into a Vendored Tree

This page is for the engineer whose company has its own source tree — a BSP, possibly a forked RTOS, an existing build system — and needs nano-ros to live inside it: pinned, reproducible, buildable offline, upgradable on their schedule. It collects the contracts the other chapters mention in passing into one place.

nano-ros is source-only: nothing is on crates.io, there is no install prefix, and no binary distribution. Vendoring the repository is not a workaround — it is the supported consumption model.

Pinning the checkout

Vendor the repo as a git submodule (or subtree) and pin a release tag:

git submodule add https://github.com/NEWSLabNTU/nano-ros.git third_party/nano-ros
git -C third_party/nano-ros checkout nros-v0.6.0
git add third_party/nano-ros

Rules that keep the pin trustworthy:

  • Pin tags (nros-v<X.Y.Z>), not main. A moving branch unships the pairing you tested.
  • Move the pin forward only. A rewind silently drops whatever the skipped commits fixed, and two hex strings in a diff cannot be ordered by eye.
  • nano-ros’s own submodules are decisions, not lags. The Cyclone DDS fork tracks the Cyclone that ROS ships; zenoh-pico is pinned at a wire-stable version. Never “update to latest” inside the vendored checkout — take what the tag pinned.

Initialize nano-ros’s submodules non-recursively — the launch toolchain’s third-level runtime submodules are never built by nano-ros:

git -C third_party/nano-ros submodule update --init packages/cli/third-party/play_launch

For build-related submodules you normally do nothing: the configure step populates the ones your (platform, RMW) pair needs (see Bootstrap below), or you pre-seed them explicitly for offline builds.

What a fresh vendored checkout needs, in order

Four things, and the order matters:

  1. The nros CLI. ./scripts/bootstrap.sh inside the checkout builds packages/cli/target/release/nros. Nothing else works without it.
  2. An activated environment for interactive use: source third_party/nano-ros/activate.sh wires nros and the SDK-store tools onto PATH. It exports no root: nros knows the checkout it was built in. CI can skip this and use an absolute CLI path (a raw cmake passes -Dnano_ros_ROOT=…).
  3. Provisioned toolchains. nros setup <board> --rmw <rmw> fetches the cross-compiler, emulator, and SDK sources for exactly that pair into the shared store at ${NROS_HOME:-~/.nros}/sdk. --dry-run prints the plan first. (The zenoh router is deliberately not provisioned — it comes from a ROS 2 install, ros2 run rmw_zenoh_cpp rmw_zenohd.)
  4. nros sync. nros sync from your workspace root (the vendored nros answers with its own checkout; --nano-ros-path <checkout> names it explicitly). It produces the message bindings and the build settings each [image.*] implies — see the next section for why this is not optional.

The Rust side: nros sync is mandatory, and nothing it writes is in your source tree

Two things a Rust package needs are generated, not authored: the generated/ message crates, and every build setting the target implies — the triple, the runner, the link flags, the resolved [env], and the [patch.crates-io] table that resolves registry-style names (nros = { version = "*" }) to the vendored sources. You state the target once, as a board name in the package’s system.toml, and nros sync derives the rest (RFC-0098):

[system]
name = "my_app"
rmw  = "cyclonedds"

[image.linux]
board = "linux"

nros sync writes those settings to build/<image-id>/nros-cargo.toml — one file per [image.*] row, under build/, never beside your package — and nros build hands it to cargo:

nros sync
nros build            # or: nros build <image-id> for one image

A CI job that drives cargo itself passes the same file:

cargo build --manifest-path <pkg>/Cargo.toml \
            --config <pkg>/build/<image-id>/nros-cargo.toml

(If the package still carries a .cargo/ directory of its own — the in-tree examples do, until that directory is retired — run cargo from the directory above it, or cargo reads both and joins two sets of link flags. A package of yours has no such directory, so the working directory does not matter.)

This is what makes a vendored tree cheap to move: there is no leaf .cargo/config.toml to copy from an in-repo example, no include line whose relative path cannot resolve from your directory, and no table of absolute paths under version control to invalidate. After relocating either the workspace or the vendored checkout, re-run nros sync; it rewrites what is under build/ and touches nothing you track. Switching a package to another board is editing its one board = line and syncing again.

A build that runs before sync fails at the first preflight with a line naming nros sync, rather than deep inside cargo.

Message dependencies stay path deps pinned 0.0.0 (std_msgs = { path = "generated/std_msgs" } after sync); never registry-name a message crate — a bare std_msgs = "*" can resolve against the public crates.io.

The CMake side

Two entry points, both source-backed:

  • find_package(nano_ros REQUIRED) — the ament-shaped entry (nano_rosConfig.cmake at the checkout root). Located via -Dnano_ros_ROOT=<checkout>, which nros build and the CMakePresets nros init writes pass; a raw cmake names it (no shell exports it).
  • add_subdirectory(third_party/nano-ros) — the lower-level shape. See Build as a CMake subdirectory for the cache-variable table.

Either way, at configure time nano-ros’s bootstrap.cmake runs tools/setup.sh to populate the source submodules your (NROS_PLATFORM, NROS_RMW) pair needs. That is correct on a developer machine and wrong in hermetic CI — see the next section. Corrosion is never fetched at configure time: without it in the SDK store the configure stops and names nros setup --tool corrosion. nros setup <board> installs it too, for every board with a CMake road.

Air-gapped / mirrored CI

The offline contract, all of it:

  1. -DNROS_SKIP_BOOTSTRAP=ON. Disables the configure-time submodule fetch. You now own pre-seeding.
  2. Pre-seed the submodules your platform/RMW pair needs in your mirror (e.g. zpico-sys/zenoh-pico, the FreeRTOS kernel + lwIP, the Cyclone fork — tools/setup.sh derives the per-target list from nros-sdk-index.toml, which is the single manifest of what comes from where).
  3. Pre-install Corrosion: nros setup --tool corrosion (or the nros setup <board> you already run) stages it into the SDK store, where the configure finds it without the network. Verify the configure line prints nano-ros: Corrosion <ver> via <origin> — never infer the version from having run an installer; a stale store entry can shadow a fresh one.
  4. Cache the SDK store. Everything nros setup fetches lands under ${NROS_HOME:-~/.nros}/sdk; point NROS_HOME at a shared or cached location and the store is reusable across jobs and machines of the same host triple.
  5. Mirror the asset sources. nros-sdk-index.toml names every asset’s upstream ([tool.*], [source.*] sections). For a full mirror, rehost those URLs and patch the index in your vendored checkout — that patch is part of the carried diff below, and the index is versioned with the tree so the patch rebases cleanly.

The patch set you carry

Be clear-eyed: some extensions live inside the vendored checkout. There is no out-of-tree escape hatch for them today, so a vendored tree carries a small local diff and rebases it across tag bumps:

You add…Files inside the checkout
A platform (your RTOS)packages/platform/nros-platform/Cargo.toml (feature + optional dep), nros-platform/src/resolve.rs (type alias), cmake/platform/nano-ros-<name>.cmake
A board the tooling can seenone needed since NROS_EXTRA_BOARD_PATH: point it (env, or CMake cache var for nano_ros_use_board()) at a directory of board crates in YOUR tree — same layout as packages/boards/. Board keys stay global (a name under two roots errors). Copying into packages/boards/ remains the fallback
A mirrored SDK indexnros-sdk-index.toml URL edits

An application, a message package, a board overlay crate, and a custom transport all live entirely in your tree — no diff.

Workflow for the diff: keep a branch in your nano-ros fork (company/nros-v0.6.0-patches), and on upgrade rebase it onto the new tag rather than merging. The registration edits are a few lines each; conflicts are rare and loud.

Upgrading the pin

git -C third_party/nano-ros fetch origin --tags
git -C third_party/nano-ros rebase --onto nros-v0.7.0 nros-v0.6.0 company/patches
# build + test, then:
git add third_party/nano-ros

After every pin move, in order: rebuild the nros CLI (./scripts/bootstrap.sh), re-run nros sync in each consuming workspace (the sync output is keyed to the CLI), then rebuild. Stale sync output after an upgrade produces build errors that look like missing packages, not like staleness.

Checklist

  • Submodule pinned to an nros-v tag, forward-only
  • play_launch submodule initialized non-recursively
  • CI drives nros build (or passes -Dnano_ros_ROOT= to a raw cmake) — no reliance on an activated shell
  • nros sync runs in CI before any Rust build — before nros build, and before any cargo line that passes --config build/<image-id>/nros-cargo.toml; no build setting hand-written into a package
  • Air-gapped: NROS_SKIP_BOOTSTRAP=ON, submodules pre-seeded, Corrosion staged, NROS_HOME cached
  • Local patch set (platform/board/index edits) lives on a fork branch, rebased per tag
  • Router comes from the ROS 2 install on the host that runs it — nothing in the vendored tree provides one

Message Binding Generation

nros uses generated Rust bindings for ROS 2 message types. The nros generate-rust command generates no_std compatible bindings from package.xml dependencies.

Overview

The binding generator lives in the in-tree CLI sub-workspace at packages/cli/ and provides:

  • nros standalone binary
  • Pure Rust, no_std compatible output using heapless types
  • Automatic dependency resolution via ament index or bundled interfaces
  • the [patch.crates-io] rows that point the nano-ros runtime crates at your checkout, written into the image’s generated build settings

Prerequisites

  1. package.xml in project root - 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>
    
  2. nros tool built + on PATH

    The nros CLI is built from the in-tree sub-workspace at packages/cli/ and put on PATH by the activate file:

    # From the nano-ros repository root
    ./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
    source ./activate.sh        # OR: direnv allow / source ./activate.fish
    

    See Installation for the full walkthrough.

  3. ROS 2 environment (optional for standard types)

    Standard interfaces (std_msgs, builtin_interfaces) are bundled with nros and work without ROS 2. For additional packages (e.g., geometry_msgs, sensor_msgs), source a ROS 2 environment:

    source /opt/ros/humble/setup.bash
    

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: Generate bindings

cd my_project
nros sync

This will:

  1. Parse package.xml to find dependencies
  2. Resolve transitive dependencies (ament index + bundled interfaces)
  3. Filter to interface packages (those with msg/srv/action)
  4. Generate bindings to generated/ directory
  5. Resolve the launch files into the system model
  6. Write this image’s build settings — build/<image-id>/nros-cargo.toml, carrying the board’s triple and flags, the resolved pool budgets, and the [patch.crates-io] rows for the nano-ros runtime crates

nros sync is the user’s verb. nros generate-rust is the codegen-only primitive underneath it — steps 1–4 and nothing else — and is useful when you want bindings without a build.

Step 3: Add dependencies to Cargo.toml

A generated message crate is a path dependency on its own directory:

[dependencies]
std_msgs = { path = "generated/std_msgs", default-features = false }
example_interfaces = { path = "generated/example_interfaces", default-features = false }

Never name one 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 (RFC-0067).

The nano-ros runtime crates are the other case — nros, nros-board-<board>, the backends — and they are registry-named with version = "*", because nros sync writes the [patch.crates-io] rows that redirect them into your nano-ros checkout.

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     = { version = "*", features = ["rmw-cyclonedds"] }

Transport choice and message schema are orthogonal concerns and the manifest reflects that. (Which backend you actually get is [system] rmw in 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.

Tracking + sizing knob (NROS_CYCLONEDDS_MAX_TYPES): see docs/roadmap/archived/phase-212-ux-cargo-native-and-file-consolidation.md section 212.K.7.

Outside the nano-ros checkout

A project that lives somewhere else uses the SDK the nros binary belongs to — a release’s own share/nano-ros, or the checkout an in-tree nros was built in — so a plain nros sync works. To name a different tree, pass it:

nros sync --nano-ros-path /path/to/nano-ros

nros sync then writes the [patch.crates-io] rows into that image’s generated settings file, pointing each nros* requirement at a directory in your checkout. Nothing about the location is baked into a tracked file, so moving the checkout is one re-sync, not an edit.

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 generate-rust is the codegen primitive — it writes bindings and nothing else:

nros 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 leaf .cargo/config.toml by hand. Those are the pre-RFC-0098 shape and are not the path to use: nros sync writes the patches into the generated settings file instead, where they are regenerated rather than committed.

Generated Output Structure

my_project/
├── package.xml              # Your dependency declarations
├── Cargo.toml               # Your package manifest
├── system.toml              # Board, RMW, components
├── src/
│   └── main.rs              # Your code using generated types
├── 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-id>/
        └── nros-cargo.toml  # This image's 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.)

Standalone Package Mode

Examples are standalone copy-out projects: each carries its own system.toml and resolves on its own, so you can copy one out of the checkout and build it where it lands. Build each from its own directory:

cd examples/linux/rust/talker && nros sync && nros build
cd examples/linux/rust/service-client && nros sync && nros build

Regenerating Bindings

To regenerate after ROS package updates or dependency changes:

nros generate-rust --force

Bundled Interfaces

nros ships standard .msg files for common packages so codegen works without a ROS 2 environment:

  • std_msgs (Bool, Int32, String, Header, etc.)
  • builtin_interfaces (Time, Duration)

These are located at packages/cli/interfaces/. When a ROS 2 environment is sourced, the ament index takes precedence over bundled files.

Troubleshooting

“Package ‘X’ not found in ament index or bundled interfaces”

  • For standard types (std_msgs, builtin_interfaces): should work without ROS 2
  • For other packages: source 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 --force flag
  • 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…UseNotes
Consuming an existing msg package from an app/node pkgfind_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 packagerosidl_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 (RFC-0048 §5). Defaults to LANGUAGE CPP like rosidl — pass LANGUAGE C for C.
Building a workspace root with several msg pkgsnros_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 (Phase 210.E.4). 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

See examples/linux/c/custom-msg/CMakeLists.txt for a complete example.

Configuration Guide

nano-ros config is language-agnostic and has one home per concern — no setting lives in two places, and nothing is merged across files. The authored surfaces are a universal system.toml, package.xml, and launch XML — plus Kconfig for the embedded build (Zephyr). Cargo.toml and CMakeLists.txt carry language-toolchain facts and nothing else. This guide covers what each file owns, the network identity an embedded image bakes at build time, and the standalone config.toml for hand-written no_std apps.

Retired surfaces you may find in old material: the nros.toml per-package/workspace file (rejected by the CLI), the old config.toml [network]/[zenoh]/[scheduling] schema, every [package.metadata.nros.{node,entry,component,deploy.*}] table, and the package.xml <nano_ros deploy=… board=… rmw=…/> tuple. The last two moved into system.toml under RFC-0098; readers refuse them rather than reading them. Design of record: RFC-0004.

One home per concern

FileOwnsPer
system.tomlEverything about the deployment — [system] (RMW, domain, capability axes like features = ["param_services"] and the [safety] block, tiers), [[component]] (what runs, and its entities), and one [image.<id>] per target (board, locator, and the image’s address on a network, net = { eth0 = { network = "qemu", host = 10 } }; a project’s own networks are [network.<name>]). One schema for Rust, C and C++, and the same file whether the project is one package or a workspace.package, or bringup pkg
Cargo.tomlRust toolchain facts only: crate, language deps, the RMW feature menu (rmw-zenoh/rmw-cyclonedds/rmw-xrce) the generated entry selects from. No board, no target triple, no deploy block.Rust project
CMakeLists.txtC/C++ toolchain facts only: targets and language deps, via find_package(nano_ros) + nano_ros_add_executable / nano_ros_auto_add_library + nros_components_register_node (RFC-0057; nano_ros_node_register remains as the compat spelling) / nano_ros_entry. No platform, board or RMW set().C/C++ project
build/<image>/nros-cargo.tomlGENERATED, never edited — every cargo setting the board choice implies: [build] target, [target.*] runner and rustflags, the cross compiler, the derived pool sizes as [env], the nano-ros profiles, and the [patch.crates-io] rows that resolve nano-ros’s registry-style names into your checkout. Written by nros sync, passed to cargo with --config.image
package.xmlROS package identity + msg <depend>s (codegen input for nros generate)all
config.toml (standalone)Hand-written no_std direct-mode apps only — [node] / [[transport]] / [node.rt], compile-baked via Config::from_toml(include_str!(…)). Apps that use nros::main!() / codegen do not have one — they use [image.<id>] above.embedded single-node app (no codegen)

Boundary rule. If a knob changes what is compiled/linked as a language choice — a C++ standard, an RMW feature the entry may select — it lives in the build file. Everything else about the deployment (components, board, domain, RMW, and the physical link + router address a target boots with) lives in system.toml. Nothing a board implies is authored anywhere: it is derived from the board descriptor into the generated settings file.

Config home by language × scale

Since RFC-0098 this table has one answer in all four cells, which is the point of it:

Single-nodeWorkspace
Rustsystem.toml beside Cargo.tomlsystem.toml in the bringup pkg
C / C++system.toml beside CMakeLists.txtsystem.toml in the bringup pkg

A single-package leaf and a workspace bringup use the same schema, so the resolver has one input shape and you have one thing to learn. Resolution is a fixed precedence ladder, not a merge: explicit CLI/build flag (--rmw / -DNROS_*) > system.toml ([image.<id>] > [system]) > the board descriptor’s [knobs] > built-in default. nros config show prints the resolved effective config with per-value provenance; nros check flags values still sourced from legacy files.

Full design rationale: RFC-0004 (configuration & transports); RMW backend selection & lowering is RFC-0031. For the multi-RMW runtime topic-forwarding bridge (a separate file/feature), see nros-bridge.toml.

Embedded image config (codegen apps — the common case)

An embedded app built through nros::main!() (Rust) or the CMake entry codegen (C/C++) declares its board and its network + router config per image, in system.toml. The toolchain bakes it at compile time — there is no config file on the device and nothing is parsed at runtime. The file is the same in both languages:

# system.toml (e.g. examples/mps2-an385-baremetal/rust/talker)
[system]
name      = "qemu_bsp_talker"
rmw       = "zenoh"
domain_id = 0

[[component]]
pkg      = "qemu_bsp_talker"
class    = "qemu_bsp_talker::Talker"
name     = "talker"
dispatch = "deferred"          # optional: callbacks hand off to a ring

[image.mps2-an385-baremetal]
board   = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

The [image.<id>] key is the image’s name — what you pass to nros build <image> — and board is the only thing that has to change to retarget it. nros::main!() bakes the image’s network fields into a DeployOverlay that BoardEntry::run_with_deploy applies onto the board’s boot Config; the C/C++ entry codegen bakes the same values into its generated glue. Nothing is written in Cargo.toml, in package.xml or on a cmake command line.

Networks and addresses

A network fact has three owners (RFC-0103 D10): the board states its interfaces, the project states its networks, and the image states its address on one. The image’s half is net, one entry per interface:

[image.talker]
board = "qemu-mps2-an385"
net   = { eth0 = { network = "qemu", host = 10 } }   # 10.0.2.10 on QEMU's network

qemu is built in: QEMU’s own user-mode (SLIRP) network, 10.0.2.0/24 with gateway and router host 10.0.2.2. Every -nic user makes a private copy of it, so two images on qemu never meet. Any other network is the project’s, stated once and named by every image on it:

[network.lab]                  # e.g. a switch or a Linux bridge with boards on it
subnet  = "192.168.50.0/24"
gateway = "192.168.50.1"
router  = "tcp/192.168.50.1:7447"    # the locator its images dial by default

[image.sensor]
board = "qemu-mps2-an385"
net   = { eth0 = { network = "lab", address = "192.168.50.20" } }

Per interface:

  • network defaults to the board’s (qemu on every QEMU board), so net = { eth0 = { host = 10 } } is enough there.
  • host is the last octet within the network’s subnet; address is the full IPv4, or "dhcp" on a network with dhcp = true (and a board whose interface runs a DHCP client). An interface on qemu that states no address takes 10.0.2.15, the address a plain -nic user guest picks.
  • mac is optional. Left out, it is derived from the board interface’s mac_prefix and the system, image, network and interface names, so two images never share one by accident.
  • The gateway and netmask come from the network, so an image never states them.
  • An image dials its own locator, else its network’s router, else [system] locator. On a network other than qemu one of the three must be stated.

A board may have several interfaces. An image addresses each on its own network, net = { eth0 = { … }, eth1 = { … } }.

These fail the build, naming the image (and, for a duplicate, both images):

  • an address outside its subnet, equal to the gateway, or on a network that does not exist;
  • two images with the same static address, or the same MAC, on one network other than qemu;
  • net on a board whose network belongs to the host operating system (linux, freertos-posix, threadx-linux), or on a board that declares no interfaces (Zephyr boards take their address from Kconfig);
  • the old flat ip / gateway / netmask keys on [image.<id>].

The board’s half is [board.net] in its descriptor: network (its default network) and interfaces, each a kind (eth0 = "ethernet") or a table (eth0 = { kind = "ethernet", mac_prefix = "52:54:00", dhcp = true }). A board states no address: [board.net] ip / mac are refused.

The resolved answer for an image is written to build/nros/net/<image>.toml (beside the workspace or leaf) and reaches the firmware as NROS_IMAGE_NET.

An emulated image joins its network through the network’s attach: "slirp" (QEMU’s own user-mode network, what qemu is), { bridge = "<dev>" } or { tap = "<dev>" } on the host. A network with no attach is physical only, and an emulated image on it is refused at launch. nros ws qemu-nic <leaf> --model <nic-model> prints the -nic value for a leaf’s image, for a script that starts QEMU itself:

[network.br]
subnet = "192.0.3.0/24"
gateway = "192.0.3.1"
router = "tcp/192.0.3.1:7447"
attach = { bridge = "qemu-br" }   # QEMU: -nic bridge,br=qemu-br,…

Per-image build knobs

A build knob that one image needs, and that its board does not state, goes in [image.<id>.knobs.<group>]. The table has the same shape as a board descriptor’s [knobs.<group>]:

[image.zephyr_xrce.knobs.zephyr]
heap_size = 98304            # NROS_ZEPHYR_HEAP_SIZE

[image.zephyr_xrce.knobs.xrce]
max_subscribers = 1          # NROS_XRCE_MAX_SUBSCRIBERS (-1, the default, derives it)

[image.linux.knobs.log]
max_level = "warn"           # NROS_LOG_MAX_LEVEL (a level name or 0..6)

[image.linux.knobs.executor]
max_cbs = 16                 # NROS_EXECUTOR_MAX_CBS

Each key is a row of the knob registry (packages/tooling/nros-zephyr-build/knobs.toml). The key is the row’s board key where it has one (executor.max_cbs, zenoh.tx.batch); otherwise it is the env name without NROS_, split at the first _ (NROS_ZEPHYR_HEAP_SIZE becomes zephyr.heap_size). An unknown key, a value of the wrong type or a value outside the knob’s range stops nros sync / nros build with an error that names the key. The value reaches the build exactly as the knob’s NROS_* variable would: through the generated cargo config on the cargo road, the image env file on the cmake road, and the Kconfig defaults on west. It sits on the image rung (above the board, below an exported variable).

[image.<id>] env = { NAME = "value" } still carries an application’s own build variable, a name the registry does not hold. An env row that names a registry knob is an error that names its knobs spelling (release 0.6.0 accepted it with a warning).

RT/stack/priority for a single-node embedded app comes from board-crate Cargo features + Kconfig (prj*.conf on Zephyr), not a config file. Multi-node RT is declared in system.toml ([tiers.<name>.<rtos>] + [[node_overrides]]) — see RFC-0015.

Periodic timers after a stall

If a tier is preempted past several periods of a periodic timer, the backlog is coalesced, not replayed: the timer fires ONCE and the missed periods are counted. Phase is preserved, so activations stay on the declared cadence grid rather than drifting by each stall. This is the default (TimerOverrunPolicy::Skip) and matches rclcpp and Zephyr’s k_timer.

Replay is available — Executor::set_timer_overrun_policy(.., CatchUp) — and is the right choice for a counter or accumulator, where every tick carries state that must not be lost. It is the wrong choice for a control loop: the replayed activations compute with stale inputs and emit a burst of commands inside a fraction of a period.

Overruns are counted per timer (Executor::timer_overruns) and reported as the timer-overrun-runtime violation. Watch that rather than a publish-rate check: under CatchUp a replayed burst makes the achieved rate look nominal — measured 100.03 Hz on a declared 100 Hz loop while the tier was stalling for up to 611 ms at a time — so a rate monitor reports health during exactly the fault it exists to catch. Rationale and the measurements: RFC-0002 § 4.4a.

Timer periods are microseconds throughout, including across the C ABI.

Standalone config.toml — hand-written no_std apps (no codegen)

A hand-written single-node app that writes its own main() and bypasses nros::main!() keeps its net config in a file, not hardcoded in Rust: a sibling config.toml carrying [node] / [[transport]] / [node.rt], compile-baked with include_str! and parsed by the board crate’s Config::from_toml.

# config.toml

[node]
domain_id = 0              # ROS 2 domain ID (0–232)
# namespace = "/"
# rmw = "zenoh"            # ACTIVE backend; must match a LINKED backend
                           # (the build file picks what is linked)

# One transport per session. A single ethernet/wifi/serial entry is the
# common case. In-process topic forwarding is the separate [[bridge]] path.
[[transport]]
kind    = "ethernet"       # ethernet | wifi | serial | can
ip      = "10.0.2.10/24"   # CIDR — the prefix rides the address
mac     = "02:00:00:00:00:00"
gateway = "10.0.2.2"
locator = "tcp/10.0.2.2:7447"

[node.rt]                  # scheduling / real-time (RTOS); omit for defaults
app_priority         = 12
app_stack_bytes      = 65536
zenoh_read_priority  = 16
zenoh_lease_priority = 16
poll_priority        = 16
poll_interval_ms     = 5

Per-kind transport fields:

kindfields
ethernetip (CIDR), mac, gateway, interface
wifissid, password, optional static ip/gateway
serial / candevice, baudrate
allid, rmw, locator

ip is CIDR (10.0.2.10/24) — the board derives the prefix or netmask from it. A serial locator carrying # (serial/UART_0#baudrate=115200) is fine — quote it.

Consumption (compile-baked; no filesystem on the target):

use nros_board_mps2_an385::{Config, run};

fn main() -> ! {
    run(Config::from_toml(include_str!("../config.toml")), |config| {
        let exec = ExecutorConfig::new(config.zenoh_locator)
            .domain_id(config.domain_id);
        // ...
    })
}

Pick by whether the app uses codegen: nros::main!() app → system.toml [image.<id>] (above); hand-written main() → config.toml. Only the old config.toml schema ([network]/[zenoh]/[scheduling]) is retired.

Retired files

  • nros.toml — retired in full. A workspace-root nros.toml is rejected by the CLI (NrosTomlNotSupported; migrate with the nros-v0.5.0 tag’s nros migrate workspace — the one-shot verb is retired on newer trees, #186); the legacy per-package overlay is a deprecated fallback that nros check flags; the embedded-runtime role never shipped (no example ever declared one). If a doc tells you to write nros.toml, it predates the migration — the content belongs in system.toml.
  • Old config.toml schema ([network]/[zenoh]/[scheduling]) — retired; superseded by [image.<id>] (net) + board features / Kconfig (RT). The direct-mode config.toml above is the kept, supported shape.

Build-time environment variables

Read during cargo build (by build.rs) or by justfile recipes. Set in .env or export in your shell.

SDK paths

You normally set none of these. nros setup puts each vendored source tree in the shared store, and every build finds it through one ladder (RFC-0103 D3): an environment override, then your project’s [sources] in system.toml, then a checkout submodule you are editing, then the store, then the checkout’s own copy. Ask where a tree came from with nros locate <name> --why.

Set one of these variables only to build against a tree of your own:

VariableTree
FREERTOS_DIR / LWIP_DIRFreeRTOS kernel / lwIP
THREADX_DIR / NETX_DIRThreadX / NetX Duo
NUTTX_DIR / NUTTX_APPS_DIRNuttX RTOS / apps
CYCLONEDDS_SOURCE_DIRCyclone DDS source

For a tree that belongs to one project, prefer [sources] <name> = "<path>" in its system.toml (project-relative) over an exported variable, which every build in that shell inherits.

FREERTOS_PORT, FREERTOS_CONFIG_DIR, THREADX_CONFIG_DIR and NETX_CONFIG_DIR are not yours to set: they are facts about the board, stated in its nros-board.toml and carried to the build from there.

Buffer-tuning vars (ZPICO_*, XRCE_*, NROS_*) are optional — see the Environment Variables Reference.

Binary-size knobs (embedded)

On a constrained MCU, two build-time env vars — carried in the image’s generated build/<image>/nros-cargo.toml [env], like the other NROS_* tuning, and set there from the board descriptor’s [knobs] or from your own environment — shed the parts a brokered client doesn’t need:

VariableDefaultEffect
NROS_LINK_IPonNROS_LINK_IP=0 on a serial-only node drops the IP link layer — zenoh-pico’s TCP/UDP link C (Z_FEATURE_LINK_TCP/UDP=0) and (with --gc-sections) the smoltcp platform impl. Serial link stays.
NROS_SMOLTCP_MAX_SOCKETS / NROS_SMOLTCP_MAX_UDP_SOCKETS4 / 2Sized for DDS RTPS (3 UDP/participant). A zenoh/XRCE client multiplexes everything over one session → set both to 1 to drop the spare socket buffers (≈8 KB each).
NROS_HEAP_SIZEper-platform-crate (64 KB nros-platform-mps2-an385, 32 KB nros-platform-stm32f4)Decimal bytes for the bare-metal static heap. The defaults are generous; size to the RMW’s working set (table below). E.g. NROS_HEAP_SIZE = "24576" on a zenoh-pico node cut the mps2-an385 .data 66 → 25 KB (−41 KB).

Static-heap sizing by backend (bare-metal FreeListHeap, set via NROS_HEAP_SIZE):

BackendPeak working setRecommended heapNotes
zenoh-pico (TCP)~16 KB24–32 KB (≈2× for fragmentation)peer middleware; alloc-based session/buffers
zenoh-pico (serial)lighter than TCP16–24 KBno TCP link buffers; verified running at 16 KB
XRCE (Micro-XRCE-DDS)~3 KB (micro-ROS figure)~8 KBstatic pools, discovery offloaded to the agent — the RAM-minimal backend; a measured bare-metal XRCE figure is pending an example (no bare-metal XRCE example ships yet — XRCE bare-metal needs a custom-transport injection)

Measured footprint

Honest, reproducible numbers per (platform, transport, backend, profile) — built with the in-tree examples, the release profile is cargo’s default (opt-3), the size profile is the scaffolded [profile.size] (opt-s + lto

  • strip, see the size-tuned release profile notes). RAM = data + bss. All cells are after --gc-sections + the size knobs above are applied where noted; the serial cell ships with the recipe below.
platformtransportbackendprofiletext (flash code)databssRAM total
mps2-an385-baremetal (cortex-m3)ethernet (smoltcp)zenoh-picorelease177.4 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalethernetzenoh-picosize158.3 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalserial (no IP stack)zenoh-picorelease128.6 KB25.2 KB75.8 KB101.0 KB
mps2-an385-baremetalserialzenoh-picosize + recipe116.1 KB25.2 KB75.8 KB101.0 KB
stm32f4 (thumbv7em-eabihf, cortex-m4) ¹ethernetzenoh-picorelease186.9 KB13.7 KB123.0 KB136.7 KB
stm32f4 ¹ethernetzenoh-picosize138.1 KB13.7 KB123.0 KB136.7 KB
mps2-an385-freertos (cortex-m3 + lwIP, RTOS-reused stack)ethernet (lwIP)zenoh-picorelease240.6 KB10.7 KB3.3 MB3.3 MB
mps2-an385-baremetal (serial)serial (custom XRCE transport)XRCEsize, heap 24 KB, tight XRCE pools60.3 KB25.2 KB (heap 24 KB)8.8 KB~34 KB
micro-ROS reference (XRCE)serialXRCE-DDS Client-Os< 75 KB—~3 KB~3 KB peak

¹ The two stm32f4 rows are historical: the board crates left the tree in a later cleanup, so nothing in-tree reproduces them today. They stay because a Cortex-M4F-with-its-own-stack data point is still the closest published figure for that class, and deleting a measurement is worse than dating it. The chip crate the numbers were built against (nros-platform-stm32f4) is still here.

The XRCE row uses tight per-session XRCE pools — delivered through the image’s generated [env] and read by nros-rmw-xrce-cffi’s build.rs: NROS_XRCE_STREAM_HISTORY=4, NROS_XRCE_CUSTOM_TRANSPORT_MTU=512, NROS_XRCE_MAX_SUBSCRIBERS=1, NROS_XRCE_MAX_SERVICE_SERVERS=1, NROS_XRCE_MAX_SERVICE_CLIENTS=1, NROS_XRCE_SUBSCRIBER_RING_DEPTH=1, NROS_XRCE_BUFFER_SIZE=256. Vendor defaults grow xrce_session_state_t to ~390 KB (which wouldn’t fit a 24 KB heap); these knobs drop it to ~12 KB. Defaults are unchanged for hosted / non-tight-RAM consumers — the env vars are pure opt-in.

How to read this:

  • The size profile (opt-s) shrinks .text by ~10–26 % with .bss/.data unchanged (opt-level doesn’t touch static buffers — those are the env knobs above). -Oz is not used — on smoltcp examples it grows .bss +24 KB by defeating opt-3’s per-socket dead-buffer DCE (see the size-tuning notes above).
  • Switching ethernet → serial sheds ~50 KB text + ~42 KB .data (no smoltcp stack, no IP link C, tuned heap) — the structural lever.
  • FreeRTOS + lwIP cells .bss is dominated by lwIP’s heap + FreeRTOS task stacks (3 MB is the configured headroom, not nano-ros overhead).
  • The micro-ROS / XRCE row is a reference, not a nano-ros measurement — no bare-metal XRCE example ships yet (needs a custom-transport injection); the path to parity is XRCE + serial + static pools.

Size-minimal recipe

Smallest measured nano-ros configuration today (mps2-an385-baremetal serial talker, 116 KB text / 101 KB RAM):

# Cargo.toml
[profile.size]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true

The gc-sections and link-script rustflags are not yours to write — they are the board’s, and they arrive in the generated build/<image>/nros-cargo.toml [target.<triple>] block:

# build/<image>/nros-cargo.toml — GENERATED, shown for orientation
[target.thumbv7m-none-eabi]
rustflags = [
    "-C", "link-arg=-Tlink.x",
    "-C", "link-arg=--gc-sections",   # 204.8 — strip unreferenced fns/data
]

The knobs that are yours are ordinary environment variables. The generated [env] never uses force, so a value you set in the calling environment wins:

NROS_LINK_IP=0 \
ZPICO_NO_SMOLTCP=1 \
NROS_HEAP_SIZE=131072 \
NROS_SMOLTCP_MAX_SOCKETS=1 \
NROS_SMOLTCP_MAX_UDP_SOCKETS=1 \
    nros build <image>

The profile is not one of them: name it on the image, as [image.<id>] profile = "size", and nros build passes it to whichever tool it drives.

NROS_LINK_IP=0 drops zenoh-pico’s TCP/UDP link C (204.7) and ZPICO_NO_SMOLTCP=1 skips the smoltcp glue on bare-metal. On the heap floor: the per-entry executor backing is a single ~75 KB allocation, so a nros::main! image needs ≥128 KB (the #176 board default) — the pre-271 24 KB “zenoh-pico working set” figure OOMs at boot. HEAP is .bss (no flash cost); shrink below the default only on a non-nros::main! direct-mode image with a measured smaller peak. A knob you always want for a board belongs in that board’s descriptor [knobs], not on every command line.

nros new --platform baremetal already scaffolds the [profile.size] shape (Phase 204.7/204.8).

The deeper RAM win waits on XRCE on bare-metal (the ~3 KB-class client + static pools, with discovery offloaded to the agent) — tracked separately; zenoh-pico’s SUBSCRIBER_BUFFERS + alloc-based session are what keep this row’s .bss ~76 KB.

Cargo features (which RMW/platform is linked)

Features select the linked RMW backend, platform, and ROS edition. The system.toml rmw ([image.<id>], else [system]) picks which linked backend is active — the two are different layers (link vs run). Matrix + mutual-exclusion rules: Platform Model.

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi",            # generic C-vtable runtime registry
    "platform-bare-metal", # or platform-{freertos,nuttx,threadx,zephyr,posix}
    "ros-humble",          # or ros-iron
    "std", "alloc",        # optional, target-dependent
] }
# Exactly one RMW backend crate; its registration runs before main:
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal", "link-tcp", "ros-humble"] }
# …or nros-rmw-xrce-cffi / the cyclonedds CMake backend

Runtime environment (native host only)

On the native host build, ExecutorConfig::from_env() reads at process start (embedded targets bake their config at build time instead):

VariableDescriptionDefault
ROS_DOMAIN_IDROS 2 domain ID0
NROS_LOCATORRouter addresstcp/127.0.0.1:7447
NROS_SESSION_MODEclient / peerclient
ZENOH_TLS_ROOT_CA_CERTIFICATE*TLS CA cert (path / base64)(none)

By deployment scenario

ScenarioConfig sourceCargo featuresNotes
Desktop (POSIX)env (ExecutorConfig::from_env())rmw-cffi, platform-posix, std + zenoh deprun the router locally (ros2 run rmw_zenoh_cpp rmw_zenohd, ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false')
QEMU bare-metalsystem.toml [image.<id>] ip/mac/gateway/locatorrmw-cffi, platform-bare-metal, ros-humble + zenohTAP/slirp bridge
FreeRTOS hardware[image.<id>] + board features (RT)…, platform-freertos, …FREERTOS_DIR/LWIP_DIR
ESP32 WiFi[image.<id>] (ssid/password via build env)…, platform-bare-metal, …SSID/PASSWORD build env
Zephyr moduleKconfig overlay (prj-<rmw>.conf)(Kconfig → features)
Hand-written no_std (no codegen)standalone config.toml → Config::from_tomlper-boardnet config in a file, not in code
Minimal RAM (XRCE serial)config.toml [[transport]] kind="serial" or [image.<id>]… + xrce depXRCE_* buffer tuning

.env

cp .env.example .env   # uncomment + adjust; gitignored; auto-loaded by just + direnv

Logging

nano-ros ships a ROS 2 style leveled logging facade (nros-log) with the same Logger + severity surface as rclcpp::Logger / rcutils_logging. Records flow through a single per-platform sink (PlatformSink) that delegates to whichever nros-platform-<rtos> is linked into the binary.

Severity ladder

REP-2012 style: rcutils’s five levels in rcutils’s order, plus Trace. The numbers are NOT rcutils_log_severity_t’s (UNSET=0, DEBUG=10 … FATAL=50) and there is no Unset level — in Rust a logger’s “no level of its own” state is Logger::unset_level(), and Logger::set_default_level takes a Severity (pass Info where rclrs passes Unset). The C surface’s rcl_log_severity_t does use rcutils’s numbers, UNSET included.

Severityu8When to use
Trace0Per-instruction granularity; off by default.
Debug1Diagnostic information useful while developing.
Info2Normal operation events worth surfacing once.
Warn3Unexpected but recoverable conditions.
Error4Errors the caller should surface; system continues.
Fatal5Unrecoverable; system is about to abort.

The numeric representation is stable and used by the nros_platform_log_write ABI.

Quick start

Rust

use nros_log::{Logger, Severity};
use nros_log::{log_info, log_warn};

static LOGGER: Logger = Logger::new("my_node");

fn main() {
    nros_log::register_logger(&LOGGER);
    nros_log::init(nros_log::sinks::default());

    log_info!(&LOGGER, "started; domain = {}", 42);
    log_warn!(&LOGGER, "queue depth {} exceeds soft limit", 5);
}

Inside a node, prefer the Node::logger() accessor — it resolves to the same intern’d entry when the name matches:

#![allow(unused)]
fn main() {
let mut node = executor.create_node("my_node")?;
log_info!(node.logger(), "subscribed to {}", topic);
}

C

#include <rclc/rclc.h> /* also brings the RCLC_LOG_* macros */

int main(void) {
    rclc_support_t support = rclc_support_get_zero_initialized();
    rcl_allocator_t allocator = rcl_get_default_allocator();
    rclc_support_init(&support, 0, NULL, &allocator); /* locator: build, then NROS_LOCATOR */

    rcl_node_t node = rcl_get_zero_initialized_node();
    rclc_node_init_default(&node, "my_node", "/", &support);

    rclc_logger_t logger = rclc_node_get_logger(&node);
    RCLC_LOG_INFO(logger, "started; domain=%u", 42);
    RCLC_LOG_WARN(logger, "queue depth %u exceeds soft limit", 5);

    rcl_node_fini(&node);
    rclc_support_fini(&support);
    return 0;
}

C++

#include <rclcpp/rclcpp.hpp>

int main() {
    rclcpp::init_in("tcp/127.0.0.1:7447", 0);

    rclcpp::Node node;
    rclcpp::create_node(node, "my_node");

    auto logger = node.get_logger();
    RCLC_LOG_INFO(logger, "started; domain=%u", 42);
    RCLC_LOG_WARN(logger, "queue depth %u exceeds soft limit", 5);

    rclcpp::shutdown_in();
    return 0;
}

The first NROS_LOG_* emit auto-installs PlatformSink so C/C++ call sites work without an explicit init step.

Per-platform delivery

TargetBackend
POSIXfprintf(stderr, "[<LEVEL>] <name>: <msg>\n")
ZephyrLOG_INF / LOG_WRN / etc. (or printk if CONFIG_LOG=n); module nros
NuttXsyslog(priority, "%s", buf)
FreeRTOSboard-registered UART writer fn-ptr
ThreadXboard-registered UART writer fn-ptr
Bare-metal (mps2-an385)QEMU semihosting via cortex_m_semihosting::hio::hstderr
Bare-metal (stm32f4)defmt::info!("{=str}", msg)
Bare-metal (esp32 / esp32-qemu)board-registered fn-ptr (esp-println / RTT / serial-jtag)

Boards on platforms with no native logger (FreeRTOS / ThreadX / bare-metal Rust ESP32) supply their writer fn-ptr at run() time:

#![allow(unused)]
fn main() {
// from nros-board-mps2-an385-freertos/src/lib.rs
fn register_log_writer() {
    unsafe extern "C" fn writer(severity: u8, name_ptr: *const u8, name_len: usize,
                                 msg_ptr: *const u8, msg_len: usize) { … }
    unsafe { nros_platform_register_log_writer(Some(writer), None); }
}
}

Filtering

Compile-time ceiling

The NROS_LOG_MAX_LEVEL knob: trace (the default), debug, info, warn, error, fatal or off — or 0–6, which is how Zephyr’s CONFIG_NROS_LOG_MAX_LEVEL states it, as Zephyr’s own LOG_MAX_LEVEL does. State it per image or per board, like every other knob:

# system.toml — this image only
[image.linux.knobs.log]
max_level = "info"

# nros-board.toml — every image on the board
[knobs.log]
max_level = "info"

Environment / [image.<id>.knobs] beats Kconfig beats the board; nros config explain prints which rung decided it. A Rust nros_*! call below the ceiling folds to a constant-false branch and its format call is dead-code-eliminated. C and C++ records — formatted on the C side — are refused by the same check in the facade (rclc_logger_is_enabled answers false below the ceiling).

The ceiling is a BOUND, not the runtime level: the per-logger threshold below still moves freely above it, and a threshold below it is unreachable. rcutils has the same pair (RCUTILS_LOG_MIN_SEVERITY_* at compile time, rcutils_logging_set_logger_level at run time).

The max-level-* Cargo features this replaced are deprecated for one release (issue 1037): honoured with a build warning when no knob is stated, a build error when they disagree with one.

Runtime per-logger threshold

#![allow(unused)]
fn main() {
let logger = nros_log::get_logger("my_node");
logger.set_level(nros_log::Severity::Warn);   // silences Trace/Debug/Info
}

The process default, and “unset”

A logger built with Logger::new(name) has no level of its own. It filters on the process-wide default, which is Severity::Info until something moves it:

#![allow(unused)]
fn main() {
nros_log::Logger::set_default_level(nros_log::Severity::Debug);  // every
                                     // logger that has not been given a level
logger.set_level(nros_log::Severity::Warn);   // this one now states its own
logger.unset_level();                         // ...and follows the default again
}

set_default_level is retroactive — a static LOGGER: Logger = Logger::new("my_node") compiled into the image follows it — and it never overrides a logger that states its own level, so Logger::with_level(name, Severity::Info) keeps Info whatever the default becomes.

From C, the same three verbs are rclc_log_set_default_level, rclc_log_get_default_level, and rclc_logger_set_level(logger, RCLC_LOG_SEVERITY_UNSET) — which UNSETS the level rather than selecting a value, exactly as rcutils_logging_set_logger_level(name, RCUTILS_LOG_SEVERITY_UNSET) does. C++ spells the last one logger.set_level(rclcpp::Logger::Level::Unset).

Child loggers, and what an unset level inherits

As upstream, an unset logger takes the level of its nearest dotted ancestor (x.y.z → x.y → x) before the process default (RFC-0102). Make children with the upstream spelling:

#![allow(unused)]
fn main() {
let node_log = node.logger();                       // "my_node"
let planner = node_log.create_child("planner")?;    // "my_node.planner"
node_log.set_level(nros_log::Severity::Debug);      // reaches `planner` too
nros_log::nros_debug!(planner, "replanning");       // emitted
}
auto planner = node->get_logger().get_child("planner");   // C++
rclc_logger_t planner = rclc_logger_get_child(node_log, "planner");   /* C */

A child with its own level keeps it; unset_level() hands it back to the parent. The same name always answers the same logger. The catch-all’s children are top-level names: planner, not nros.planner.

Bounds, if you are porting. Upstream keys a level by NAME, so an ancestor can hold one without being a logger; here an ancestor holds one by existing, and setting a level by name creates it, so in practice the behaviour matches. Logger names are capped at 48 bytes, and runtime-created loggers come from a bounded arena. When a child cannot be made:

  • Rust’s create_child returns Err(ChildError::{NameTooLong, EmptyName, ArenaFull}), as rclrs’s is fallible too;
  • C’s rclc_logger_get_child returns NULL and warns once;
  • C++’s get_child, whose upstream shape cannot fail, emits through the parent (records carry the parent’s name and level) and refuses set_level, so it can never move the parent’s threshold. get_name() still answers the name you asked for.

Sizes

nros-log formats each record into a stack-resident heapless::String<N>. Overflow truncates and appends …; the macro never panics on a long format string. That N, and the two static rings, are knobs on the same ladder as the ceiling ([knobs.log] <key>, [image.<id>.knobs.log] <key>, Kconfig CONFIG_<knob>):

KnobKeyDefaultWhat it sizes
NROS_LOG_BUFFER_SIZEbuffer_size256 (128–4096)the per-call formatting buffer, and the message in each early//rosout slot
NROS_LOG_EARLY_RECORDSearly_records4 (0 = drop, counted)records held before init installs the sinks
NROS_LOG_ROSOUT_RECORDSrosout_records16the /rosout queue (below)
NROS_LOG_DYNAMIC_LOGGERSdynamic_loggers16, Linux hosts 32 (0 = lookup only)loggers created at run time

The C/C++ NROS_LOG_* macros format into their own 256-byte frame in the calling translation unit, so a C record is bounded by the smaller of that and NROS_LOG_BUFFER_SIZE. The buffer-size-<N>, early-records-<N>, rosout-records-<N> and dynamic-loggers-<N> features are deprecated, like max-level-*.

Interop with the log crate

Enable nros-log/log-compat:

#![allow(unused)]
fn main() {
nros_log::log_compat::install_log_crate_bridge()
    .expect("log crate already initialized");
// Now `log::info!(...)` calls from ecosystem crates flow through
// nros-log's dispatcher.

// Or fan out the other direction — re-emit nros records via `log`:
static SINKS: &[&dyn nros_log::LogSink] = &[
    &nros_log::sinks::PlatformSink,
    &nros_log::log_compat::LogCrateSink,
];
nros_log::init(SINKS);
}

Severity maps Trace↔Trace, Debug↔Debug, Info↔Info, Warn↔Warn, Error↔Error round-trip. Fatal folds into Error one-way (log has no Fatal).

Working examples

  • Rust: examples/linux/rust/logging/
  • C: examples/linux/c/logging/
  • C++: examples/linux/cpp/logging/

Each demonstrates per-severity macros + the runtime threshold filter.

Reference

  • packages/core/nros-log/ — facade crate.
  • packages/platform/nros-platform-cffi/include/nros/platform.h — nros_platform_log_write / nros_platform_log_flush / nros_platform_register_log_writer ABI.
  • packages/api/nros-c/include/rclc/log.h — C API surface (NROS_LOG_* macros + rclc_log_emit_fmt).
  • packages/api/nros-cpp/include/rclcpp/logging.hpp — C++ macros (the legacy NROS_INFO / etc. file:line printf surface stays alongside the new NROS_LOG_* macros).

/rosout

It exists now (phase-467 Q4; this section read “explicitly out of scope today” until 2026-09-29). Records can be republished as rcl_interfaces/msg/Log on /rosout, so ros2 topic echo /rosout, rqt_console and launch-side log aggregators see a nano-ros node.

Declare the rosout capability — [system].features = ["rosout"] in system.toml, or set(NROS_FEATURES "rosout") before find_package(nano_ros) in a bare CMake project (a Rust leaf can also name the nros/rosout feature) — and wire three lines. Rust:

#![allow(unused)]
fn main() {
use nros_rcl_interfaces::msg::Log;

let rosout = executor
    .create_node("my_node")?
    .create_publisher_with_qos::<Log>(nros::rosout::TOPIC, nros::rosout::qos_bounded())?;
nros::rosout::enable();                  // records start queueing
loop {
    executor.spin_once(budget);
    let _ = nros::rosout::pump(&rosout); // and reach the wire here
}
}

C (<rclc/rosout.h>):

rcl_publisher_t rosout = rcl_get_zero_initialized_publisher();
rclc_rosout_publisher_init(&rosout, &node, NULL);   /* NULL = bounded QoS */
rclc_rosout_enable();
for (;;) {
    rclc_executor_spin_some(&executor, 10000000);
    rclc_rosout_pump(&rosout, NULL);
}

C++ (<rclcpp/rosout.hpp>):

rclcpp::rosout::Publisher rosout;
RCLCPP_TRY(rosout.create(node));          // or create(node, rclcpp::RosoutQoS())
RCLCPP_TRY(rclcpp::rosout::enable());
for (;;) {
    rclcpp::spin_once(10);
    rosout.pump();
}

All three share one encoder, so the records are identical. Without the capability the C and C++ entry points still link: rcl_logging_rosout_enabled() / nros::rosout::enabled() answer false and the rest answer UNSUPPORTED. rclcpp::NodeOptions::enable_rosout(true) still refuses to compile; “It is not automatic” below says why the publisher is yours to create.

Five things to know before you rely on it.

Which loggers reach it depends on the ROS release (RFC-0102 D4), because upstream’s does. On Humble, rcl publishes only a NODE’s logger: a child or a free get_logger("x") never reaches /rosout. Iron and Jazzy also publish a node’s get_child descendants. nros::rosout::enable() scopes the queue to the release the image is built for (ros-humble / ros-iron / ros-jazzy; an image naming none gets Humble’s rule). So log through node.logger() or its children, not a free logger, if you want the record on the wire. One bound: upstream stops publishing a child when its last copy is destroyed; here it stays published, because runtime loggers are never freed.

It is not automatic. Upstream republishes from every rcl node with no user action. Here you create the publisher yourself, because a publisher is an ENTITY: it reaches the derived pool counts, the zenoh session’s tables and the sizing descriptor. An entity the runtime conjured below your declaration is how a queryable table fills up at boot with nothing in your launch file to explain it.

The default QoS is not upstream’s. nros::rosout::qos() IS rcl_qos_profile_rosout_default — KEEP_LAST(1000), RELIABLE, TRANSIENT_LOCAL, 10 s lifespan — and on an embedded target it is expensive: a transient-local publisher takes a retention slot AND a cache queryable out of a NROS_ZENOH_MAX_QUERYABLES that defaults to 8 and already has eleven claimants if you run parameter and lifecycle services, and KEEP_LAST(1000) over a ~1 KB message is a megabyte of history. qos_bounded() is VOLATILE at the queue depth and costs neither. What you give up is that a subscriber which attaches LATE sees no boot story. A stock ros2 topic echo still matches — it requests VOLATILE and downgrades across a mixed publisher set.

The stamp is the platform monotonic clock, not wall time, because an RTOS image has none to offer: rqt_console will display an uptime as a time of day. Without the platform clock it is a constant zero — the clock is compiled in when the image’s platform declares [capabilities] clock = true (every in-tree port does) or nros-log’s platform-clock feature pulls a port in.

It costs .bss. 5 760 bytes on the defaults (16 queued records x 360 B), down to 1 856 B with NROS_LOG_ROSOUT_RECORDS=8 + NROS_LOG_BUFFER_SIZE=128 and up to 72 192 B with =64 + =1024. An image that does not enable the feature pays nothing — the module is compiled out whole.

The sink does not publish, and that is the point

nros_log’s /rosout sink copies each record into a bounded static ring and returns. It never enters the transport. A logging sink that publishes, on a path that can itself log, is unbounded recursion — and on Zephyr native_sim that shape kills the image by stack exhaustion with no message at all. The publish happens in pump(), on your spin thread, with a flag set that makes the queue refuse anything logged underneath it; those refusals are counted (nros::rosout::suppressed()) alongside the ones lost to a full ring (nros::rosout::dropped()), and pump reports both on /rosout itself so an operator learns about a gap on the channel the gap is in.

C and C++ reach none of this yet — see issue 1589.

Simulated Time

A node in a simulation or replaying a bag should run on the simulator’s clock, not the wall. nano-ros implements ROS 2’s answer to that: rosgraph_msgs/msg/Clock on /clock, the use_sim_time parameter, and timers that can be told which clock advances them.

The distinction the names carry

rclcpp draws it in the method name, and so do we:

you writeit advances withaffected by /clock
create_wall_timer(period, cb)the platform’s monotonic clockno
create_timer(clock, period, cb)whatever clock isyes, if it is a ROS-time clock

A wall timer is the right choice for a watchdog, a transport keep-alive, or anything whose job is to notice that real time has passed — a paused simulator must not pause those. Everything that consumes simulated data wants the other one.

If you are porting from rclcpp, the spelling you already write is the spelling that works. If you are reading older nano-ros code, note that create_timer used to mean the wall timer here; it does not any more, and there is no alias.

Reading the time

A node’s clock is ROS time, as in rclcpp:

rclcpp::Time now = node.get_clock()->now();

With no /clock source installed, a ROS-time clock reads system time. That is the same fallback rclcpp::Clock has, and it is what makes a node written for simulation still run standalone.

Two predicates report which situation you are in:

node.get_clock()->ros_time_is_active();  // is a /clock source driving this?
node.get_clock()->started();             // has a first sample arrived?

Turning it on

With the parameter, as in ROS 2

Declare use_sim_time and the runtime attaches the source itself — from a launch file, a params YAML, or ros2 param set:

talker:
  ros__parameters:
    use_sim_time: true

use_sim_time is reserved: nothing reads its value, the runtime acts on it. Set it false and /clock samples stop being installed.

You do not have to declare it first. An image built with parameter services declares use_sim_time = false on your behalf, as rclcpp does on every node, so ros2 param list shows it and

$ ros2 param set /talker use_sim_time true

reaches a node whose author never wrote a line about simulated time. Declaring it yourself still wins — your default, and your descriptor, replace the auto-declared one.

The Rust crate needs the sim-time feature, because the subscription costs an entity slot and an RX buffer:

nros-node = { version = "0.5", features = ["sim-time"] }

Explicitly

#![allow(unused)]
fn main() {
node.install_ros_time_source()?;          // subscribes /clock
node.install_ros_time_source_on("/sim/clock")?;  // a remapped clock
}

QoS is ClockQoS — best effort, keep-last 1, volatile. A late subscriber wants the next sample, not a replay of the simulation’s history.

Writing a timer that follows it

C++

rclcpp::Timer t;
RCLCPP_TRY(node.create_timer(t, *node.get_clock(), 100, on_tick));

Rust

#![allow(unused)]
fn main() {
executor.register_timer_on_clock(
    TimerDuration::from_millis(100),
    TimerClockSource::Ros,
    || { /* … */ },
)?;
}

TimerClockSource::Steady is the default everywhere and stays free: it consumes the spin delta the executor has already measured. Ros and System read their clock on every poll of the timer, which on a target is a platform call — that is why the choice is a separate entry point rather than a defaulted argument.

What happens at the edges

Paused. /clock stops advancing, so the delta is zero and a ROS-time timer does not fire. Wall timers on the same executor keep their cadence.

Replayed at 0.5x or 2x. The timer tracks the rate, because it advances by the clock’s own delta rather than by elapsed real time.

A jump backwards — a bag looping, a simulator reset — restarts the period. The alternative would be a timer that stays silent for the length of the jump.

A jump forwards is not special-cased. It lands as a backlog, and TimerOverrunPolicy decides: Skip (the default) coalesces it into one activation and counts the rest, CatchUp replays every missed period.

Unsubscribing does not reset the clock. A node that stops listening keeps the last simulated time rather than snapping back to the wall, which every ROS-time timer would otherwise absorb as a jump backwards. Clearing is deliberate: Clock::clear_ros_time_override().

Limits worth knowing before you rely on this

One simulated clock per image. The override is process-global, which is the model nros_core::Clock has always documented. Two nodes in one image cannot be on different simulated times.

A never-connected /clock looks exactly like a paused one, so the image says so once. Both leave every ROS-time timer sitting still. Five seconds after the source attaches with no sample received, the image logs one warning at WARN, naming use_sim_time and /clock and the fact that ROS-time timers are running on system time meanwhile:

use_sim_time is true but no /clock sample has arrived in 5s: ROS-time timers are
running on SYSTEM time meanwhile. Publish rosgraph_msgs/msg/Clock (ros2 bag play
--clock), or set use_sim_time false.

Once, not per spin, and never again once a sample has arrived — a simulator that publishes and then pauses is the case the line exists to be distinguished from. Turning use_sim_time off and on again arms it afresh. The five seconds are measured on the executor’s own per-spin elapsed time, the same one your wall timers accumulate, so an image whose wall clock is itself simulated gets the warning at a point it can relate to its own timers. Executor::sim_time_silence_warnings() answers the same question in code.

No jump callbacks. rclcpp::Clock::create_jump_callback and the rcl_jump_threshold_t machinery have no counterpart. The behaviour a jump produces is fixed (above); you cannot subscribe to the event.

No wait_until_started / sleep_until. RFC-0021 forbids a blocking helper that does not drive the executor. Poll started() from your spin loop.

Timers only. A ROS-time clock changes what now() returns and what a ROS-time timer does. It does not retime message delivery, QoS deadlines, or transport lease timers — those remain on the monotonic clock, which is what you want for a link that has to stay up while the simulation is paused.

The age monitor is on a different clock from your stamps. RFC-0052’s max-age-runtime rule compares a message’s header.stamp against an EPOCH clock (µs since the UNIX epoch, installed by the board via Executor::set_epoch_clock). If you stamp with node.get_clock()->now() while a simulator is driving ROS time, the two disagree and the reported ages are meaningless — usually enormous. Either stamp from the epoch clock, or leave the age rule off for that topic while running under simulation.

Build Profiles

nano-ros builds both C/C++ and Rust code on your behalf. You choose the optimization level once, and nano-ros propagates it to every crate and library it builds for you.

The image is where you say it, beside the board it is built for:

[image_defaults]
profile = "nros-relwithdebinfo"   # the base every image folds over

[image.linux]
board = "linux"

[image.mps2]
board   = "qemu-mps2-an385"
profile = "nros-minsizerel"       # this one is going on a board

nros build carries that through to whichever tool builds the image — the cargo profile flag on the cargo driver, CMAKE_BUILD_TYPE on the cmake one. You do not repeat it on the command line, and you do not define the nros-* profiles anywhere: nros sync writes them into the image’s generated cargo settings file under build/ (build/linux/nros-cargo.toml for a single-package project), along with everything else the board choice implies.

From a CMake build type

When you drive CMake yourself — a single-package C or C++ project builds with its own cmake -B build (naming the SDK root with -Dnano_ros_ROOT="$(nros sdk-root)", which nros build passes for you), and needs no sync — set CMAKE_BUILD_TYPE as you would in any CMake project. nano-ros derives the cargo profile for the Rust it builds underneath from it:

CMAKE_BUILD_TYPEcargo profilewhat it means
Debugdevdebuggable, minimal optimization
RelWithDebInfonros-relwithdebinfoopt-level 2, debug info, no LTO — the default
MinSizeRelnros-minsizerelopt-level "s", fat LTO — smallest images
Releasereleaseopt-level 3, fat LTO — fastest code
(unset)nros-relwithdebinfothe development default
cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)" -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build build

Your C/C++ sources get -Os from CMake as usual, and the Rust nano-ros builds for you gets --profile nros-minsizerel. You do not need to add anything to any Cargo.toml — nano-ros supplies the definition of its own nros-* profiles.

An unrecognized build type is an error rather than a guess, so a custom type never silently produces an optimization level you did not ask for.

Choosing the Rust profile separately

NROS_CARGO_PROFILE overrides the mapping when you want the two halves to differ — for example a small C/C++ image with debuggable Rust:

cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)" \
    -DCMAKE_BUILD_TYPE=MinSizeRel -DNROS_CARGO_PROFILE=nros-relwithdebinfo

Using your own profile

Name any profile you like. When the name is not one of nano-ros’s nros-* profiles, you own the definition — nano-ros passes the name through and injects nothing, so your settings are authoritative:

[profile.prod]
inherits = "release"
opt-level = 3
lto = "fat"

Put that where cargo already looks for a profile: the package’s own Cargo.toml for a single-package project, or a .cargo/config.toml in a directory above your project. There is no workspace-root Cargo.toml to put it in — a nano-ros workspace has no root build file, and the cargo root is the generated entry under build/, which is regenerated on every build.

A config file is a first-class home for a profile, not a workaround: it is exactly how the nros-* presets reach cargo. An undefined name still fails the way it always did — error: profile 'prod' is not defined — while a name the generated settings file defines resolves and builds.

cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)" \
    -DCMAKE_BUILD_TYPE=Release -DNROS_CARGO_PROFILE=prod

Which profile is active?

nros profile resolve --build-type MinSizeRel   # -> nros-minsizerel
nros profile dir     nros-minsizerel           # -> the target/ subdirectory

A CMake configure also prints it:

-- nano-ros: cargo profile `nros-minsizerel` (CMAKE_BUILD_TYPE=MinSizeRel) → target/nros-minsizerel

Notes for embedded targets

  • Images grow at the default. nros-relwithdebinfo trades size for build speed. For flashing to a constrained board, build MinSizeRel.
  • Two platforms pin their own profile regardless of what you choose, and will say so: NuttX Rust images (a codegen miscompile at lto = "off") and FreeRTOS QEMU images (the emulated Cortex-M3 misses zenoh-pico’s handshake window at low optimization).

What nros build Produces

nros build ends at an artifact and stops. It discovers your packages, resolves the image, checks the toolchain, generates the root build file and the entry, and then hands off to the native tool — cargo, cmake, west or idf.py. The last stage is an exec, so the artifact is exactly the one that tool would have produced had you run it yourself.

This page answers the question the build does not: what are you holding, and where is it?

There is no nros run and no nros flash

That is a decision, not a gap. How an image is flashed or started is a property of the board and of your bench — a probe, a bootloader, a QEMU command line, a CI fixture — and nano-ros cannot know it without inventing a device inventory nobody asked for. So nano-ros delivers binaries, and the board’s own tooling takes them from there.

The commands quoted below are that tooling’s, reproduced here as a reference. They are not nano-ros wrappers and nano-ros does not run them for you.

There is also no install step: nros build never runs cmake --install, writes no install/ prefix and stages nothing. Whatever the native tool wrote in its own build tree is the deliverable.

Where it lands

Which tool builds an image is decided by its platform and by whether the workspace crosses languages:

platform / shapedriverthe artifact
Rust single package, any non-Zephyr board (ESP32 included — see below)cargo<leaf>/build/<image-id>/target/[<triple>/]<profile>/<bin>
Rust-only workspace, the same boardscargo<ws>/build/<coordinate>/<image>_entry/target/[<triple>/]<profile>/<image>_entry
any workspace containing C or C++cmake<ws>/build/<coordinate>/cmake/<image>_entry
a zephyr boardwest<ws>/build/<coordinate>/<bringup>__<image>_west/zephyr/zephyr.{elf,bin,exe}
an esp32 board, image crossing languagesidf.pyESP-IDF’s own build/ in the project directory — an OUT-OF-TREE project; nano-ros ships no ESP-IDF component

The coordinate is the platform and the RMW — posix-zenoh, freertos-cyclonedds — plus, for CMake, the board, because CMake pins one compiler per configure and two boards on one platform would otherwise share a cache: posix-zenoh-linux, freertos-zenoh-mps2-an385-freertos.

Everything above is under build/, and that is the rule rather than a coincidence: a workspace has no root Cargo.toml and no root CMakeLists.txt (RFC-0098 D9). It is a directory of packages, like a colcon workspace, and every generated thing — the entry, the root build file, the cargo settings, the compiled output — lives in build/.

One path the build does print is easy to mistake for the binary:

nros build:   entry → …/build/posix-zenoh/linux_entry

That is the generated entry package — source that nano-ros wrote for you, under build/ because it is build output. The compiled program is elsewhere; see below.

cargo

Every image gets its own cargo settings file, build/<image-id>/nros-cargo.toml for a single package and build/<coordinate>/<entry>/nros-cargo.toml for a workspace, and nros build points cargo at it:

cargo build --manifest-path talker/Cargo.toml --config talker/build/linux/nros-cargo.toml

That file states the image’s target-dir, so each image compiles into its own tree rather than sharing one target/. It also carries the board’s triple, linker flags and cross compiler, the resolved pool budgets as [env], the in-repo [patch.crates-io] rows, and the nros-* build profiles. It is generated on every sync and build — read it, never edit it.

For a single package the artifact is that package’s own binary:

<leaf>/build/<image-id>/target/[<triple>/]<profile-dir>/<bin>

examples/linux/rust/talker lands at build/linux/target/debug/talker; its bare-metal sibling examples/mps2-an385-baremetal/rust/talker at build/mps2-an385-baremetal/target/thumbv7m-none-eabi/debug/qemu-bsp-talker. The triple level appears whenever the board pins one — which it does in the generated [build] target, not on your command line.

For a workspace the binary is the generated entry, inside that entry’s own target dir — the Rust scaffold’s linux image is build/posix-cyclonedds/linux_entry/target/debug/linux_entry.

<profile-dir> is debug unless the image declares [image.<id>] profile = …, in which case it is that profile’s directory (nros profile dir <name> prints it).

Next step: on a host board, run it. It is an ordinary executable.

./build/linux/target/debug/talker

For a QEMU board, the generated settings file also carries that board’s runner, so cargo run through the same --config boots the image — see Build Profiles for driving cargo yourself.

A multi-process zenoh example also needs a router; that is ROS’s rmw_zenohd, not something nano-ros ships — see Deployment Workflow and Install.

C, C++ and mixed workspaces (cmake)

CMake is chosen whenever the package graph contains anything that is not Rust, on the host and when cross-compiling alike. The generated root is <ws>/build/<coordinate>/CMakeLists.txt, it is configured into <ws>/build/<coordinate>/cmake/, and each image’s executable lands at the top of that directory:

<ws>/build/<coordinate>/cmake/<image>_entry

For example, examples/workspaces/c builds [image.linux] to build/posix-zenoh-linux/cmake/linux_entry and its FreeRTOS image to build/freertos-zenoh-mps2-an385-freertos/cmake/freertos_entry. (Node packages also build their own executables, under cmake/pkg/<pkg>/<pkg>; the image is the one at the top level.)

The file has no extension in either case. On the host it is a host executable; for a cross board it is a statically linked ELF for that architecture — the FreeRTOS one above is ELF 32-bit LSB executable, ARM, EABI5, statically linked.

Next step:

  • host — run it directly.
  • QEMU — the ELF is what -kernel takes, with the machine and CPU your board’s guide names.
  • hardware — hand the ELF to your flasher (probe-rs, OpenOCD, a vendor tool). nano-ros has no opinion about which.

Zephyr (west)

nros build resolves your application and its overlays and runs west build from the workspace root with each image’s OWN build directory, <ws>/build/<coordinate>/<bringup>__<image>_west (issue 1669 — west’s default, build/ under the cwd, put every image of a workspace in one tree and refused the second). Pass -- -d <dir> to choose another. Zephyr writes its image under zephyr/ inside it; for demo_bringup:zephyr on zenoh:

<ws>/build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.elf   # always
<ws>/build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.exe   # native_sim — a host executable
<ws>/build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.bin   # cross targets — the raw image
<ws>/build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.map   # the link map

Which of .exe and .bin appears is Zephyr’s choice, not nano-ros’s: a native_sim build produces zephyr.exe, a Cortex-M build produces zephyr.bin.

Next step — Zephyr’s own verbs:

./build/zephyr/zephyr.exe     # native_sim: just run it
west build -t run             # emulated boards: Zephyr's runner
west flash                    # hardware: the runner declared by the board

west flash picks its runner from the board’s own board.cmake; nano-ros adds nothing to it. Note that these are run against the west build directory, so they work from wherever you would normally run west.

ESP32

Two different shapes, because there are two ways to build for an ESP32:

ESP-IDF (idf.py driver) — reached only when an esp32 image crosses languages. nros build runs idf.py build in the project directory and the artifact is whatever ESP-IDF writes under that project’s build/; the next step is Espressif’s:

idf.py -p /dev/ttyUSB0 flash monitor

The driver survives, but the project it drives must be your own: the in-tree ESP-IDF component was retired in phase-468 W2. A Rust board-run esp32 image is a cargo image, not an idf.py one — deciding that by platform alone is what made nros build esp32 exec a tool with no project to build.

Rust bare-metal (esp-hal) — a single-package image on the cargo driver like any other, so the ELF is under that image’s own target dir: build/<image-id>/target/<triple>/<profile-dir>/<name>. espflash turns it into a flash image or writes it to a board:

espflash flash --monitor \
    build/esp32-c3-baremetal/target/riscv32imc-unknown-none-elf/release/talker
espflash save-image --chip esp32c3 --flash-size 4mb --merge \
    build/esp32-c3-baremetal/target/riscv32imc-unknown-none-elf/release/talker talker.bin

See ESP32 (esp-hal).

Finding it yourself

When in doubt, ask the build what it is about to do. --dry-run prints the stages and the exact command, and performs no I/O:

nros build <image> --dry-run

The tool named in that command owns the output location, and its own documentation is the authority — which is the whole point of handing off rather than wrapping.

For the generation side of this — which root file is written, why the entry is derived rather than hand-written, and what the coordinate directory holds — see Images.

Profiling Your Build

When a build feels slow, nros-build-profile tells you where the time went — codegen vs compile vs link vs flash, which unit dominated, and whether a shared crate was rebuilt redundantly.

It is a passive, read-only tool. Your build runs exactly as today on its native toolchain (west, cmake, idf.py, cargo); the profiler only reads the timing artifacts that build already produced. It never compiles or flashes anything, and it adds no nros build/test command — nros stays setup + codegen.

How it works

Every backend already emits timing data:

BackendArtifactOpt-in
west, cmake, idf.pybuild*/.ninja_lognone — ninja always writes it
cargo (native, esp32, cross)target*/cargo-timings/cargo build --timings

The profiler discovers and parses these, then prints a stage table (always) plus an optional per-unit drill-down and diagnostic hints.

Flow 1 — Zephyr / cmake / esp32-idf (no opt-in)

$ west build -b qemu_cortex_a53 examples/zephyr/rust/talker   # build/.ninja_log written
$ cargo build -p nros-build-profile      # once; builds the profiler bin
$ ./target/debug/nros-build-profile examples/zephyr/rust/talker

Backend: ninja (west)        Total: 41.2s

Stage      Duration    %
codegen        1.1s   3%
compile       33.8s  82%
link           6.0s  15%

hints:
  - 1 unit = 61% of compile (libzenoh_pico.a, 20.6s)

Flow 2 — cargo (one flag for per-crate detail)

The coarse table works with any build; the per-crate drill-down needs --timings:

$ cd examples/linux/rust/talker && cargo build --timings   # target/cargo-timings/ written
$ ./target/debug/nros-build-profile examples/linux/rust/talker --deep

Backend: cargo               Total: 30.3s

Stage      Duration    %
codegen        1.2s   4%
compile       24.8s  82%
link           3.9s  13%

slowest units:
  zenoh-pico-sys     18.1s ########
  nros-node           2.4s #
  std_msgs            1.1s #
  <80 more>           3.2s

hints:
  - nros-c compiled 3× — examples use isolated target/; pool target_dir to reuse the build

If you build cargo without --timings, you still get the coarse table plus a note telling you how to enable the drill-down — never an error.

Flow 3 — machine-readable (CI regression diffing)

$ ./target/debug/nros-build-profile examples/zephyr/rust/talker --json
wrote examples/zephyr/rust/talker/nros-build-profile.json

A CI step can diff nros-build-profile.json across commits to catch build-time regressions (a stage’s seconds or a unit’s share jumping).

External (copy-out) projects

For copy-out example projects, run the installed bin the same way:

$ nros-build-profile <project-dir> --deep

Diagnostics

On top of the numbers, the profiler flags known slow patterns:

  • a large native (C/C++/-sys) unit with no incremental → suggest a compiler cache;
  • the same crate compiled more than once → suggest pooling target_dir;
  • the configured job count vs available RAM (the fixture-build OOM budget).

Silence them with --no-hints.

Deployment Workflow

Deployment means different things per target, but the order is stable: prepare toolchain, build package, move binary/firmware to target, then verify ROS 2 communication.

Native (host)

Two commands, whatever the workspace shape:

# A single Rust package:
cd examples/linux/rust/talker
nros sync
nros build
./build/linux/target/debug/talker

# A workspace — same two commands, one entry generated per [image.*]:
cd my_robot
nros sync
nros build
./build/posix-cyclonedds-linux/cmake/linux_entry

nros metadata / nros plan / nros check are the inspection path, not the build path — they produce and validate a plan you can read with nros explain. You do not need them to build.

For interop with stock ROS 2 over Zenoh, run the router ROS ships and point ROS 2 at it:

ros2 run rmw_zenoh_cpp rmw_zenohd
export RMW_IMPLEMENTATION=rmw_zenoh_cpp

nano-ros no longer bundles a router (phase-362 / RFC-0075). rmw_zenohd links the same libzenohc.so that rmw_zenoh_cpp does, so it cannot drift from the RMW you are talking to — and it is what a ROS 2 deployment actually runs.

It takes no command-line configuration. To move it off the default tcp/[::]:7447:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"]' \
    ros2 run rmw_zenoh_cpp rmw_zenohd

See Native host build.

RTOS and Bare-Metal

RTOS targets usually produce firmware images or simulator binaries. The commands are the same as on the host — the board in system.toml is what makes them cross-compile:

# Rust leaf or workspace:
nros sync
nros build                     # or: nros build <image-id>

# C / C++ single package — no sync needed; `nros build` drives its CMake:
nros build
# (or raw CMake, naming the SDK root: cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)")

Contributors: the in-tree fixture build/test lanes are in Per-Platform Contributor Lanes.

For real hardware, deployment step becomes flash/load/monitor. For QEMU, deployment is launching simulator with correct network setup.

There is no nros run, no nros flash and no nros deploy verb, and none is planned: nros build ends at an artifact, and how that artifact is flashed or started is a property of the board and of your bench. Deployment runs on the vendor’s native tools. See What nros build Produces for where each target family’s artifact lands and which of the vendor’s commands takes it from there.

(nros build itself does exist — RFC-0065 introduced it after Phase 222 had removed an earlier set of wrappers. It generates the root build file and the entry and hands off; it does not wrap running or flashing.)

The embedded deploy contract is a documented three-step sequence (per RFC-0003 §4):

  1. Sync — nros sync reads system.toml + [image.<id>] + launch/*.xml and emits, per image, the generated message crates, the resolved system model and the image’s build settings under build/<image-id>/. Keyed on the image, not on the board: several images can name one board and still differ in what they contain.
  2. Build — nros build generates that image’s entry and hands off to the vendor tool — cargo / cmake / west / idf.py — which you can equally drive yourself against the generated settings.
  3. Flash + monitor — the vendor tool again: probe-rs run / west flash / idf.py flash monitor, or the platform’s QEMU runner.

Platform guides should show:

  • package layout,
  • setup command,
  • toolchain requirements,
  • build command,
  • run/flash command,
  • ROS 2 interop or smoke-test command.

Zephyr

Zephyr deployment uses west:

nros setup zephyr --rmw zenoh
# **Contributors (in-tree checkout):** `just setup zephyr` creates
# zephyr-workspace/ (west init + SDK) — then:
source zephyr-workspace/env.sh      # in-tree workspace layout
nros sync
west build -b native_sim/native/64 nros/examples/zephyr/rust/talker
./build/zephyr/zephyr.exe

Zephyr is the one target that still names its board on the command line. Everywhere else the board comes from system.toml and nros build hands it to the tool; here the west application around the entry is not yet generated (issue 1288), so west’s own -b is what selects the board. nros sync still runs first, for the message crates and the resolved model.

Bringing your own west workspace instead? Follow Zephyr Integration — the zephyr-workspace/env.sh line above is specific to the in-tree workspace layout.

ESP32

ESP32 deployment uses the Espressif toolchain and flash tool.

Contributors: the in-tree ESP32 build/run lanes are in Per-Platform Contributor Lanes.

For physical boards: espflash flash --monitor <elf> (the QEMU chapter’s espflash save-image packs an emulator image instead — real-hardware bring-up is not yet a documented end-to-end flow).

Verify

After deployment, verify from ROS 2 side:

ros2 topic list
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

If discovery works but samples do not arrive, check domain ID, router mode, QoS reliability, and platform network setup.

Cross-backend bridges

Most nano-ros applications pick one RMW backend at compile time and never look back. A cross-backend bridge is the one case where a single binary needs two backends in parallel — one node receiving on backend A, another publishing on backend B, the executor draining both each tick.

Typical reasons:

  • Drone link. A flight controller speaks XRCE-DDS over a serial radio link; the ground-station LAN runs standard DDS. A bridge forwards telemetry verbatim between the two.
  • Field gateway. Sensor pods talk zenoh-pico over an unreliable Wi-Fi mesh; the ROS 2 datacenter side wants Cyclone DDS. A bridge republishes after a session-buffered drop check.
  • Safety carve-out. Mission-critical traffic stays on the deterministic backend; introspection / logging republishes to a best-effort backend so dashboards never starve the critical pipeline.

This chapter walks the model, the build knobs, and what in the tree demonstrates each.

The mental model — rclcpp::Node, twice

nano-ros mirrors rclcpp:

Executor exec;                   // owns the primary session
Node ingress = exec.node_builder("ingress").rmw("xrce").build();
Node egress  = exec.node_builder("egress").rmw("cyclonedds").build();
auto sub = ingress.create_subscription<Foo>(...);
auto pub = egress.create_publisher<Foo>(...);

The executor opens one primary session at open* time. Each extra node_builder(...).rmw(name) call:

  1. Looks up name in the registry of linked backends.
  2. If name == primary_rmw and the locator matches → the Node binds to the primary session (slot 0). No second session opened.
  3. Else → opens a fresh session through that backend’s open_with_rmw and stores it in extra_sessions[N-1]. The new Node’s session_idx = N.

spin_once() drains every session in turn. Handles created through a multi-Session Node route through the node’s resolved session record instead of the legacy support/session pointer kept for single-backend callers.

NodeRecord.session_idx is the dispatch key. Print it to verify which session each Node landed on:

#![allow(unused)]
fn main() {
exec.node(node_in).unwrap().session_idx   // 0 = primary
exec.node(node_out).unwrap().session_idx  // 1 = first extra
}

Build knobs — three audiences, three shapes

Rust binary

Add both backend crates to Cargo.toml and call each register() early in main:

[dependencies]
nros            = { ..., features = ["rmw-cffi"] }
nros-rmw-zenoh  = { ... }
nros-rmw-xrce-cffi = { ... }
fn main() {
    nros_rmw_zenoh::register().expect("register zenoh");
    nros_rmw_xrce_cffi::register().expect("register xrce");
    let mut exec = nros::Executor::open_with_rmw("zenoh",
        &nros::ExecutorConfig::from_env())?;
    let ingress = exec.node_builder("ingress").rmw("zenoh").build()?;
    let egress  = exec.node_builder("egress").rmw("xrce").build()?;
    // ...
}

Why explicit register()? Stable Rust drops un-referenced rlib CGUs from the final link even when the backend’s #[used] static RMW_INIT_ENTRIES exists. The register() call doubles as the symbol reference that drags the rlib in and the registration trigger. C / C++ builds dodge this because --whole-archive pulls every section unconditionally.

C binary

NROS_RMW=none switches off the root CMake’s RMW auto-pull. Pull each backend’s staticlib through corrosion_import_crate and wrap with --whole-archive so the registry walker finds both names:

set(NROS_RMW none)
add_subdirectory(${nano_ros_root} nano_ros)

corrosion_import_crate(
    MANIFEST_PATH ${nano_ros_root}/Cargo.toml
    CRATES nros-rmw-xrce-cffi
    NO_DEFAULT_FEATURES
    FEATURES std)
corrosion_import_crate(
    MANIFEST_PATH ${nano_ros_root}/Cargo.toml
    CRATES nros-rmw-zenoh-staticlib
    NO_DEFAULT_FEATURES
    FEATURES "platform-posix;ros-humble")

target_link_libraries(my_bridge PRIVATE
    NanoRos::NanoRos
    -Wl,--whole-archive
    nros_rmw_xrce_cffi-static
    nros_rmw_zenoh_staticlib-static
    -Wl,--no-whole-archive)

Then declare + call the register functions early in main:

extern int8_t nros_rmw_xrce_register(void);
extern int8_t nros_rmw_zenoh_register(void);

int main(int argc, char** argv) {
    if (nros_rmw_xrce_register() != 0)  return 1;
    if (nros_rmw_zenoh_register() != 0) return 1;
    // ... rclc_support_init / rclc_executor_init / rclc_executor_node_init
}

rclc_executor_node_init honours per-Node rmw_name + locator via the rcl_node_options_t struct; the executor sets node.executor so subsequent nros_*_init calls (publisher, subscription, service, action) route to the right session.

C++ binary

Same CMake shape as C. The C++ surface (nros-cpp) is a thin header layer; the linker work is identical. Use the Executor::node_builder(name) chain:

auto exec = rclcpp::Executor::open_with_rmw("zenoh", cfg);
auto ingress = exec.node_builder("ingress").rmw("zenoh").build();
auto egress  = exec.node_builder("egress").rmw("xrce").build();
auto pub = egress.create_publisher<std_msgs::String>("/chatter");
exec.register_subscription_on<std_msgs::String>(ingress, "/chatter",
    [&pub](const auto& msg) { pub->publish(msg); });

NROS_RMW environment variable

When a binary links multiple backends, set NROS_RMW to pin the primary before open():

NROS_RMW=zenoh ./my_bridge

The C-side rclc_support_init reads it and routes Executor::open through open_with_rmw(name, ...). Without this, the linkme walker returns whichever backend’s ctor fired first, which is non-deterministic across link orderings. The bridge then opens another session against the same backend when the per-Node .rmw(name) matches the unintended primary — and most singleton-state backends (XRCE-DDS-Client’s uxrSession, zenoh-pico’s global g_session) refuse a second open.

The Rust path mirrors this: Executor::open consults $NROS_RMW first; the per-Node resolve_session_slot detects the primary-name match and returns slot 0 instead of opening a duplicate.

Memory + WCET budget

Each extra session adds:

  • One ConcreteSession (RMW-specific; see RMW Backends for sizing).
  • One register_wake_signal_on_extra wake-callback slot (std only; bare-metal targets share the primary wake).
  • One round-trip through spin_once per backend per tick.

The bridge’s per-tick WCET is the sum across linked backends:

bridge_wcet = Σ poll_wcet_i + Σ dispatch_wcet_j

Read the per-backend numbers in the Real-time budget per backend table and add them up — there is no parallelism between backends inside a single executor.

For deadline-critical bridges, partition by SchedContext instead of running everything on the default Fifo slot:

#![allow(unused)]
fn main() {
let ingress = exec.node_builder("ingress")
    .rmw("xrce")
    .sched(critical_sc)   // RT priority
    .build()?;
let egress = exec.node_builder("egress")
    .rmw("cyclonedds")
    .sched(best_effort_sc)
    .build()?;
}

The PiCAS-style per-callback OS-priority dispatcher (gated behind the scheduler-os-priority feature) routes each Node’s callbacks to its own OS priority slot so the slow backend cannot block the fast one.

Shipped examples

Declarative: examples/workspaces/bridge-cyclonedds/ and bridge-xrce/

Start here. A bridge is a [[bridge]] row in the workspace’s system.toml, between two [[domain]] rows naming the backends — no build.rs and no user bridge code. nros sync resolves each topic name to its ROS type and the generated entry runs the forwarder (open_multi over both sessions, one PubSubBridge per row):

[[domain]]
name = "zen"; rmw = "zenoh"; id = 0
[[domain]]
name = "dds"; rmw = "cyclonedds"; id = 5
[[bridge]]
name = "gw"; from = "zenoh:zen"; to = "cyclonedds:dds"
  • bridge-cyclonedds/ forwards zenoh /chatter onto Cyclone DDS, where a stock rmw_cyclonedds_cpp peer receives it.
  • bridge-xrce/ is the XRCE variant: the egress [[domain]] carries the Micro-XRCE-DDS Agent’s locator, and XRCE registers types lazily, so nothing is staged.

Each workspace’s README has the build and run lines. Both are fixture rows and are exercised end to end by the declarative_bridge_zenoh_to_{cyclonedds,xrce} tests.

Imperative: the e2e gateway fixtures

The hand-written form above — open_with_rmw for the primary session plus node_builder(..).rmw(..) for the second — has no example of its own. The nearest worked code is the two gateway binaries the mixed-RMW e2e tests run, bridge-zenoh-to-xrce-fwd and bridge-zenoh-to-cyclonedds-fwd. They are test fixtures, not copy-out projects, but each is one short main.rs: register both backends, open two sessions, forward raw bytes from a zenoh subscription to a raw publisher on the other backend.

The Cyclone one shows the one structural difference between the two egresses. Cyclone rejects a raw publisher whose topic type has no registered dds_topic_descriptor_t, so the egress type’s schema is staged before the raw publisher is created:

#![allow(unused)]
fn main() {
// The Cyclone backend installs the registrar during its register():
nros_rmw_cyclonedds_sys::register()?;
// Stage std_msgs/msg/String's schema (NUL-terminated key — it is handed
// straight to Cyclone's C descriptor table):
nros_rmw::register_type_descriptor(
    "std_msgs/msg/String\0",
    &[nros_serdes::schema::Field { name: "data\0", ty: FieldType::String, offset: 0 }],
)?;
// The raw publisher itself names the DDS-mangled type:
let pub_out = node_out.create_publisher_raw("/chatter", "std_msgs::msg::dds_::String_", hash)?;
}

XRCE registers lazily from name+hash; Cyclone needs the descriptor up front. The backend links the vendored CycloneDDS (no -DNROS_RMW needed for a Rust binary — the nros-rmw-cyclonedds-sys dep is the selection).

Not demonstrated: a time-triggered bridge

Two examples used to run a bridge under an ARINC-653-style time-triggered cyclic schedule (Phase 110.G: ingress and egress in non-overlapping windows of a 10 ms major frame, via Executor::apply_time_triggered_schedule). No lane ever built them, and phase-477 deleted them. No example in the tree combines a bridge with time-triggered windows today, and none demonstrates TT windows at all; the window gate is covered only by three nros-node executor unit tests (test_tt_window_gate_suppresses_outside_window and the two test_time_triggered_* cases in executor/tests.rs). The API is unchanged: Executor::apply_time_triggered_schedule (validates the windows, sets the major frame and returns one SchedContextId per window to bind handles to), or register_time_triggered_dispatcher plus a SchedContext’s tt_window_offset_us / tt_window_duration_us by hand — the rustdoc on those two methods is the reference.

Coverage matrix

Bridges are workspaces: a [[bridge]] row in a workspace’s system.toml, listed under workspaces/ in the examples README. There is no separate bridge category and no per-RMW directory — a bridge spans RMW backends by nature, so RMW is not a directory axis.

Troubleshooting

SymptomLikely cause
Transport(ConnectionFailed) on open_with_rmw("X", ...)Backend X’s rlib not pulled into the link line. Rust: add a register() call. C / C++: confirm --whole-archive wraps the staticlib.
Second node’s .rmw("zenoh") returns Transport(...)Both nodes try to open against the same singleton-state backend. Set NROS_RMW=zenoh so the primary lands on zenoh + the second Node hits the session-cache (slot 0) instead of double-opening.
rclc_publisher_init_default -> -7 after rclc_executor_node_initStale build — the C-side multi-Session dispatch predates nros-v0.5.0; rebuild nros-c after updating.
Bridge spinning marker never reaches piped test harnessAdd setvbuf(stdout, NULL, _IOLBF, 0) at the top of main. glibc full-buffers piped stdout; line-buffering surfaces readiness markers before the long-lived spin_period loop.

See also

QoS, Status Events, and Discovery

This page covers runtime event behavior that application authors need after choosing an RMW backend: liveliness, deadline misses, lost messages, and the places where nano-ros deliberately differs from standard ROS 2 waitset-style event handling.

Transport-level status events let an application observe conditions the underlying middleware detects: a remote node going silent, a publisher missing its rate, a subscriber dropping samples. nano-ros exposes a Tier-1 subset useful for embedded RTOS deployments — liveliness changes, deadline misses, and message loss.

This page describes the event surface, the dispatch model, and the patterns that motivate having events at all on RTOS targets.

The Tier-1 events

Five event kinds. Three on subscribers, two on publishers.

Subscriber events

KindFires whenUse
LivelinessChangedA tracked publisher’s liveliness state changes (publisher started asserting / stopped)Safety-island fail-over: trigger MRM when remote control node goes silent
RequestedDeadlineMissedAn expected sample didn’t arrive within the configured deadlinePeriodic-sensor pattern: 100 Hz topic with 15 ms deadline; fire alarm when late
MessageLostThe backend dropped one or more samples (typically: ring-buffer overflow, slow consumer)Diagnostic + adaptive: log, drop more aggressively, coalesce

Publisher events

KindFires whenUse
LivelinessLostThis publisher missed its own liveliness assertion deadlineSelf-monitoring: detect own task starvation
OfferedDeadlineMissedThis publisher promised X Hz, fell behindSelf-monitoring: detect overrunning compute

Registering callbacks

Same shape as message callbacks. Register at construction time; callback fires from inside spin_once when the backend detects the event.

Rust

#![allow(unused)]
fn main() {
use core::time::Duration;

let mut sub = node.create_polling_subscription::<SensorReading>("/sensor")?;

// Liveliness — fires when the publisher comes / goes
sub.on_liveliness_changed(|status| {
    if status.alive_count == 0 {
        log::warn!("sensor publisher went silent");
        trigger_failover();
    }
})?;

// Deadline — fires when no sample within 15 ms
sub.on_requested_deadline_missed(Duration::from_millis(15), |status| {
    metric_inc(&LATE_SAMPLE_COUNT, status.total_count_change);
})?;

// Message lost — fires when backend drops a sample
sub.on_message_lost(|status| {
    log::warn!("dropped {} samples", status.total_count_change);
})?;
}

Async equivalents (spin_async / Embassy / tokio):

#![allow(unused)]
fn main() {
let status = sub.next_liveliness_change().await?;
let status = sub.next_deadline_miss().await?;
}

C

static void on_liveliness_changed(
        rcl_subscription_t *sub,
        rclc_liveliness_changed_status_t status,
        void *user_context) {
    if (status.alive_count == 0) {
        trigger_failover();
    }
}

rclc_subscription_set_liveliness_changed_callback(
    sub, on_liveliness_changed, NULL);

Same shape on the four other event kinds. Each returns NROS_RMW_RET_UNSUPPORTED if the active backend doesn’t generate that event.

C++

sub.on_liveliness_changed([&](rclcpp::LivelinessChangedStatus status) {
    if (status.alive_count == 0) trigger_failover();
});

sub.on_requested_deadline_missed(
    std::chrono::milliseconds(15),
    [&](rclcpp::DeadlineMissedStatus status) {
        late_count_ += status.total_count_change;
    });

std::function overloads available with NROS_CPP_STD; freestanding mode uses C function pointers + user-context.

How dispatch works

Events fire from inside drive_io on the executor thread, the same way message callbacks do:

spin_once(timeout)
 └─ session.drive_io(...)
       ├─ backend RX worker detects message → fires message callback
       ├─ backend RX worker detects liveliness change → fires liveliness callback
       ├─ backend timer expires (deadline) → fires deadline callback
       └─ return

Same execution context, same priority, same constraints as message callbacks. The max_callbacks_per_spin knob (see RTOS Cooperation) covers events too — an event callback counts as one against the cap.

This means events get the same scheduling treatment as messages. max_callbacks_per_spin = 1 will fire either one message OR one event per spin_once, whichever the backend has ready first.

Why a callback model, not a waitset

Upstream rmw.h exposes events via a waitset — register an rmw_event_t handle in a waitset, call rmw_wait, then rmw_take_event to pull payload. nano-ros doesn’t have a waitset (see RMW vs upstream Section 4).

Reusing the existing message-callback path avoids introducing the waitset. Trade-off: applications can’t bulk-poll for events. For the Tier-1 set this isn’t load-bearing — events are rare, callbacks are cheap.

Backend support is uneven

Not every backend can generate every event. Apps must handle Unsupported errors when registering:

BackendLivelinessChanged / LostDeadlineMissedMessageLost
Cyclone DDS✅ Polled dds_get_liveliness_changed_status / _liveliness_lost_status✅ Polled dds_get_{requested,offered}_deadline_missed_status (deadline comes from the QoS profile, not the on_* argument)✅ Polled dds_get_sample_lost_status
XRCE-DDS✗ Not exposed (xrce-dds-client API limitation)✅ Shim-side clock check (sub: has_data / take_serialized; pub: publish_raw)✗ Not exposed
zenoh-pico✅ Poll-based via zenoh tokens (alive_count ∈ {0,1})✅ Shim-side clock check (<P as PlatformClock>::clock_ms)✅ Seq-gap detection from RMW attachment
uORB✗ No wire-level liveliness✗ No rate concept✅ Native (host mock + real PX4 via RustSubscriptionCallback publish-counter)

✓ = wired and tested. 🟡 = surface API works (returns Err while pending), wiring planned. ✗ = not feasible at this layer.

Cyclone DDS is the one backend that carries the whole Tier-1 set, and it does it by POLLING rather than by calling you back: the backend fills the ABI’s *_take_event slots instead of *_event_init, and the runtime drains them from the entity’s ordinary data path (has_data / take_serialized on a subscription; publish / assert_liveliness on a publisher). That is invisible from the application API — you still register with on_* and are still called back — but it has one consequence worth knowing: a publisher that stops publishing stops polling, so a publisher-side event is observed on the next publish. Apps should still call Subscriber::supports_event(kind) first or design for graceful fallback.

The Subscriber::supports_event(kind) query lets applications check support before registering:

#![allow(unused)]
fn main() {
if sub.supports_event(EventKind::RequestedDeadlineMissed) {
    sub.on_requested_deadline_missed(...)?;
} else {
    // fallback: app-side timeout monitoring
}
}

Apps that need cross-backend portability code defensively. Apps pinned to one backend can call register-and-unwrap.

What’s deliberately skipped

Three upstream event types are intentionally absent from the API:

*_MATCHED (publication / subscription matched)

Fires when a remote endpoint appears or disappears. Useful for discovery-tracking dashboards and dynamic-topology apps. Static- topology embedded apps don’t benefit; if a use case appears, the event kind is additive.

*_QOS_INCOMPATIBLE

Fires when publisher and subscriber QoS profiles can’t be reconciled. nano-ros surfaces this synchronously at create_publisher / create_subscription time as NROS_RMW_RET_INCOMPATIBLE_QOS. No runtime event needed; the failure is visible at startup.

*_INCOMPATIBLE_TYPE

Type-hash mismatch. Same: surfaced synchronously as NROS_RMW_RET_TOPIC_NAME_INVALID (or a future INCOMPATIBLE_TYPE code, unprefixed here because rmw_ret.h does not define one yet — issue 1126) at creation. No runtime event.

Patterns

Drone-bridge fail-over on liveliness loss

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<VehicleAttitude>("/vehicle_attitude")?;
sub.on_liveliness_changed(|status| {
    if status.alive_count == 0 {
        // PX4 commander.cpp went silent — trigger MRM
        request_minimum_risk_manoeuvre();
    } else if status.alive_count_change > 0 {
        // commander came back online; clear MRM if appropriate
        clear_failover();
    }
})?;
}

This pairs with the cross-backend bridge example pattern.

100 Hz sensor with deadline alarm

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<SensorReading>("/imu")?;
sub.on_requested_deadline_missed(
    Duration::from_millis(15),    // expected 100 Hz, allow 15 ms deadline
    |status| {
        if status.total_count_change > 0 {
            log::error!("IMU late: {} missed deadlines", status.total_count_change);
            // Optional: enter degraded-mode controller
        }
    },
)?;
}

Slow-consumer logging

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<Pointcloud>("/lidar")?;
sub.on_message_lost(|status| {
    log::warn!("dropped {} pointcloud frames (total: {})",
               status.total_count_change, status.total_count);
    // Tighten downstream filter, reduce work, coalesce, etc.
})?;
}

See also

TX Throughput & Latency Tuning

(zenoh-pico RMW — Zephyr, FreeRTOS, NuttX, ThreadX, POSIX; phases 279/282, issue #145)

Every nano-ros language front-end (Rust, C, C++) publishes through the same shared transport path (the zpico shim → zenoh-pico), so the knobs on this page apply uniformly to all of them. They matter most on Zephyr, where the kernel’s socket layer serializes send/recv per file descriptor: the zenoh-pico read task holds the session socket for a full receive window (CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS, default 100 ms), so without tuning, total image tx throughput is capped at roughly one socket send per window — about 9 msg/s at the default, regardless of how many publishers the image runs or how fast they publish.

Platform defaults (RFC-0049)

Per-platform defaults for these knobs live in each platform package’s nros-platform.toml ([knobs.zenoh.tx]), resolved through the fixed ladder builtin < platform < board < env/Kconfig — an explicit build-time setting (including an explicit 0 / n) always wins.

platformbatchsplit_lockflush_msrationale
Zephyronon50the per-fd tx ceiling above; measured 15–20× streaming. Requires the issue-0213 fork fix (declarations bypass the batch). zephyr/Kconfig’s NROS_ZENOH_TX_* defaults mirror this (drift-tested) — flip them per-app in prj.conf to opt out
everything elseoffoff50no per-fd ceiling (POSIX), no flush thread (ThreadX/bare-metal), or simply unmeasured (FreeRTOS/NuttX — flip is one line in their platform toml after a bench run)

With batching on, non-express topics pay up to flush_ms of publish latency — declare low-rate latency-critical publishers tx_express (below) to bypass the batch. Timer-paced low-rate systems that want the old behavior image-wide: set CONFIG_NROS_ZENOH_TX_BATCH=n (Zephyr) / NROS_ZENOH_TX_BATCH=0 (cargo/cmake lanes).

The decision tree

Is total publish rate comfortably under ~1 msg per socket window?
├── yes → no tuning needed (the defaults are fine)
└── no
    ├── Lots of small/medium messages, latency budget ≥ one flush period?
    │   └── TX batching: CONFIG_NROS_ZENOH_TX_BATCH=y  (or NROS_ZENOH_TX_BATCH=1)
    │       └── still tx-bound under a tight-loop / bursty publisher?
    │           └── add CONFIG_NROS_ZENOH_TX_SPLIT_LOCK=y (NROS_ZENOH_TX_SPLIT_LOCK=1)
    ├── A specific topic needs per-sample latency < the flush period?
    │   └── declare THAT publisher express (tx_express QoS) — it bypasses
    │       the batch; keep the rest batched
    └── Everything is latency-critical at high rate?
        └── lower CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS (e.g. 5 ms) — more
            send windows per second at the cost of read-task wakeups; batching
            still composes on top

Measured effect (Zephyr native_sim, 64 B messages, 100 ms socket timeout)

Provenance. Single-run measurements on one development host, on native_sim — a host-scheduled simulator, so the absolute msg/s figures move with host load; the ratios between rows are the durable result. Re-measure on freshly rebuilt fixtures on a quiet machine before citing any absolute number.

Streaming (tight-loop publisher, 5000 messages, deep-ring listener):

configurationtalker completes?throughputvs baseline
defaultsno (~33 s window)~9 msg/s1×
TX_BATCHno~136 msg/s~15×
TX_BATCH + TX_SPLIT_LOCKyes (27.7 s)~181 msg/s~20×

Timer-paced tiers (100 Hz control + 10 Hz telemetry, one session, harness): defaults 8.6 msg/s total → batch + flush thread 34.1 → + split lock 43.2, with the 10 Hz tier at its ideal rate. Delivery integrity is validated in every configuration.

Express-vs-batched (same batching image, 200 messages at 5 ms pacing, native): the batched topic arrives in flush-cadence bursts (21 inter-arrival gaps of ~50 ms), the express topic arrives continuously (zero gaps > 25 ms, max gap 5 ms). On Zephyr an express publisher sends each sample immediately and is therefore paced by the socket window instead — use express for low-rate, latency-sensitive topics (control setpoints, e-stop), never for streams.

The knobs

knobZephyr (Kconfig)cargo lanes (env at build)C/C++ cmake lanesdefault
socket recv windowCONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS— (posix doesn’t serialize per-fd)—100 ms
tx batchingCONFIG_NROS_ZENOH_TX_BATCH=yNROS_ZENOH_TX_BATCH=1-DZPICO_TX_BATCH=1 via NROS_CMAKE_EXTRA_DEFSoff
flush cadenceCONFIG_NROS_ZENOH_TX_BATCH_FLUSH_MSNROS_ZENOH_TX_BATCH_FLUSH_MS-DZPICO_TX_BATCH_FLUSH_MS=<ms>50 ms
split tx lockingCONFIG_NROS_ZENOH_TX_SPLIT_LOCK=yNROS_ZENOH_TX_SPLIT_LOCK=1-DZ_FEATURE_TX_SPLIT_LOCK=1 (shared config)off
per-publisher expresstx_express in the QoS profile (all languages, below)samesameoff

On Zephyr the Kconfig column names the symbol the build ends up with; you state it in system.toml, never in prj.conf (phase-481): a registry knob as [image.<id>.knobs.zenoh.tx] batch = true or [image.<id>.knobs.zenoh] socket_timeout_ms = 5, and any other option as an image env row without the CONFIG_ prefix.

All of these are compile-time knobs (embedded images have no runtime config), and all default off — an untuned build is byte-identical to pre-279 behavior.

What each one does

TX batching (TX_BATCH): publishes append to the transport write buffer and ship as one socket send per flush instead of one send per put. Throughput then scales with messages-per-flush. A dedicated flush thread (spawned automatically on multi-threaded platforms; ThreadX stays spin-driven because it runs no background tasks) flushes every TX_BATCH_FLUSH_MS. Batching adds up to one flush period of publish latency; service requests/replies always bypass the batch. Do not enable for timer-paced low-rate systems — with ≤1 message per flush period there is nothing to coalesce and the flush overhead measurably hurts (a measured negative result).

Flush cadence (TX_BATCH_FLUSH_MS): bounds the extra latency batching adds. Lower = fresher samples, more (smaller) sends. 50 ms is a good default for telemetry; drop toward 10–20 ms if your latency budget demands it.

Split tx locking (TX_SPLIT_LOCK, requires TX_BATCH): the flush steals the pending batch (buffer swap) and performs the socket write under a separate link mutex, so publishers keep appending while the send is in flight; a batch-overflow parks the full buffer instead of blocking the publishing thread. Wire order still equals SN order. This is what lets a tight-loop publisher actually saturate the path (~181 msg/s above). Fork-local Z_FEATURE_TX_SPLIT_LOCK; it gates transport-struct fields, so it must be set in the shared generated zenoh config (the build front-ends above do this for you — never define it for a single translation unit).

Flush-thread attributes: platforms that need explicit task priorities / stack sizes can call zpico_set_flush_task_config(priority, stack_bytes) before session open (mirrors zpico_set_task_config for the read/lease tasks; FreeRTOS applies name/priority/stack, POSIX-like platforms stack size only).

Per-publisher express (tx_express)

Marks one publisher’s samples “express”: they carry the zenoh EXPRESS wire flag and bypass tx batching — sent immediately even in a batching image. A transport hint, not a DDS policy: no RxO matching, ignored on subscriptions and by non-batching backends.

Rust:

#![allow(unused)]
fn main() {
// builder
let pub_ = node.publisher("/cmd_vel").typed::<Twist>().tx_express(true).build()?;
// or via the QoS profile
let qos = nros::QoSProfile::RELIABLE.tx_express(true);
let pub_ = node.create_publisher_with_qos::<Twist>("/cmd_vel", qos)?;
}

C:

rclc_qos_t qos = RCLC_QOS_DEFAULT;
qos.tx_express = 1;
rclc_publisher_init_with_qos(&pub, &node, "/cmd_vel", ts, &qos);

C++:

auto pub = node.create_publisher<Twist>("/cmd_vel", rclcpp::QoS().tx_express(true));

Pitfalls

  • Benchmark listeners need a deep rx ring. A batched publisher delivers a whole wire batch as one callback burst; the per-subscriber SPSC ring depth is compile-time (NROS_ZENOH_SUBSCRIBER_RING_DEPTH, default 4, drop-newest) — build measurement sinks with e.g. NROS_ZENOH_SUBSCRIBER_RING_DEPTH=1024 or the numbers undercount by >10×.
  • Express ≠ fast on Zephyr. Express bypasses the batch, so each sample pays the socket-window wait itself. Reserve it for topics whose rate is low but whose latency matters.
  • Don’t flush from your own timers/executor threads. The dedicated flush thread exists because flushing from tier threads stalls the very timers that generate the puts (measured slower than no batching).

Benchmarks and procedures: packages/testing/nros-bench/stress-zenoh-zephyr/ (streaming) and packages/testing/nros-tests/tests/w1_zephyr_tx_throughput_measure.rs (timer tiers). Background: docs/roadmap/archived/phase-279-*.md, docs/roadmap/phase-282-*.md, and platform implementation notes.

Serial Transport

This guide covers using serial (UART) transport with nros on embedded targets. Serial transport connects an MCU directly to a zenoh router over UART, without needing an IP stack.

Overview

nros supports two transport mechanisms for connecting embedded devices to a zenoh network:

TransportCrateUse Case
TCP/UDP (Ethernet/WiFi)zpico-smoltcpMCUs with Ethernet MAC or WiFi radio
Serial (UART)zpico-serialMCUs with only UART, or point-to-point links

Serial transport uses zenoh-pico’s built-in COBS framing protocol over UART. No IP stack is required — the MCU sends and receives zenoh frames directly over a serial link to a host running the zenoh router (rmw_zenohd).

When to Use Serial

  • Small MCUs — Cortex-M0/M0+ with no Ethernet MAC and insufficient RAM for smoltcp
  • Point-to-point — Direct UART connection to a host, no network infrastructure needed
  • Debugging — Serial output is visible in any terminal, easy to inspect
  • Mixed topology — Some nodes on Ethernet, others on serial, all bridged through the router

Architecture

┌──────────┐     UART      ┌──────────────┐     TCP      ┌──────────┐
│   MCU    │───────────────│  rmw_zenohd  │─────────────│  ROS 2   │
│  (nros)  │  COBS frames  │  (router)    │  zenoh net   │  node    │
└──────────┘               └──────────────┘              └──────────┘

The MCU connects to the zenoh router using a serial/... locator. The router bridges serial-connected nodes to the rest of the zenoh network (including ROS 2 nodes using rmw_zenoh).

Platform Support

Serial transport support varies by platform:

PlatformSerial ImplementationExtra Crate Needed
Bare-metal (MPS2-AN385)zpico-serial + UART driverYes
ESP32-QEMU (esp-hal, no IDF)Not wired — see issue 1499—
Zephyrzenoh-pico built-in (uart_poll_in/out)No
FreeRTOS / NuttXzenoh-pico built-in (POSIX /dev/ttyXXX)No
ThreadXzenoh-pico built-in (HAL DMA)No

On non-bare-metal platforms, serial just works by using a serial/... locator — no extra crates needed. zpico-serial only fills the gap for bare-metal targets where custom FFI symbols replace zenoh-pico’s system layer.

Board Crate Feature Selection

Each board crate uses Cargo features to select the transport:

# Board crates are registry-style names resolved by the `nros sync`
# patch table (see examples/README.md), not path deps:

# Use serial transport (disable default ethernet/wifi)
nros-board-mps2-an385 = { version = "*", default-features = false, features = ["serial"] }

# Use ethernet transport (default)
nros-board-mps2-an385 = { version = "*" }

# Both transports (runtime selection via locator string)
nros-board-mps2-an385 = { version = "*", features = ["serial"] }

Available Features by Board

Board CrateDefaultAlternativeBoth
nros-board-mps2-an385ethernetserialethernet,serial
nros-board-esp32-qemuethernetserialethernet,serial

When both features are enabled, the transport is selected at runtime by the [image.<id>] locator in system.toml:

  • "tcp/192.0.3.1:7448" → Ethernet/WiFi
  • "serial/UART_0#baudrate=115200" → Serial

(The manifest above is a single-package leaf’s. In a workspace the entry — and with it the board-crate dependency — is generated from the image, so there is no manifest of yours to put the feature in.)

Quick Start: QEMU Serial Example

This mirrors what the in-tree serial e2e test (packages/testing/nros-tests/tests/emulator.rs) actually does: a socat PTY pair links QEMU’s UART to the router, so both ends exist before either side starts.

1. Build the Serial Talker

cd examples/mps2-an385-baremetal/rust/serial-talker
nros sync                       # generated/ + this image's build settings
nros build

The serial locator is part of the image’s identity, so it is stated in the leaf’s system.toml rather than passed to the build:

[image.mps2-an385-baremetal]
board   = "qemu-mps2-an385"
locator = "serial/UART_0#baudrate=115200"

2. Create the PTY pair and start the router

# Two linked PTYs; QEMU gets one end, the router the other:
socat -d -d pty,raw,echo=0,link=/tmp/nros-serial-qemu \
            pty,raw,echo=0,link=/tmp/nros-serial-router &

# Router LISTENS on its end of the pair:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["serial//tmp/nros-serial-router#baudrate=115200"]' \
    ros2 run rmw_zenoh_cpp rmw_zenohd &

The router is ROS 2’s rmw_zenohd (phase-362 retired the vendored zenohd). It takes no command-line configuration — argv is not parsed, so a --connect flag is not rejected, it is simply unread. Configuration travels in ZENOH_CONFIG_OVERRIDE, and the router listens on the serial endpoint; the MCU side dials it.

3. Boot QEMU with UART0 on the other end

The board’s cargo run runner uses semihosting only (no serial device) — boot QEMU explicitly, wiring UART0 to the pair:

qemu-system-arm -cpu cortex-m3 -machine mps2-an385 \
    -display none -monitor none \
    -icount shift=auto -semihosting-config enable=on,target=native \
    -chardev serial,id=ser0,path=/tmp/nros-serial-qemu \
    -serial chardev:ser0 \
    -kernel build/mps2-an385-baremetal/target/thumbv7m-none-eabi/release/qemu-serial-talker

(-display none -monitor none, not -nographic — -nographic implies -serial mon:stdio, which hijacks UART0 for the monitor.)

4. Subscribe from Host

# From a ROS 2 node (the talker publishes best-effort; force the QoS
# or a RELIABLE echo silently receives nothing):
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Configuration

Serial Config

Board crates provide a serial_default() constructor:

#![allow(unused)]
fn main() {
use nros_board_mps2_an385::{Config, run};

let config = Config::serial_default();
// Defaults: baudrate=115200, locator="serial/UART_0#baudrate=115200"

run(config, |config| {
    let exec_config = ExecutorConfig::new(config.zenoh_locator)
        .domain_id(config.domain_id);
    let mut executor = Executor::open(&exec_config)?;
    // ...
    Ok(())
})
}

Custom Baud Rate

#![allow(unused)]
fn main() {
let config = Config::serial_default()
    .with_baudrate(921600)
    .with_locator("serial/UART_0#baudrate=921600");
}

Locator Format

Serial locators follow zenoh-pico convention:

FormatExampleUse Case
serial/<dev>#baudrate=<baud>serial/UART_0#baudrate=115200Device name (Zephyr, bare-metal)
serial/<tx>.<rx>#baudrate=<baud>serial/0.1#baudrate=115200Pin numbers (Arduino)

QEMU PTY Testing

How It Works

QEMU redirects the emulated UART to a host pseudo-terminal. The in-tree flow uses a socat PTY pair (rather than QEMU’s -serial pty) so both endpoints exist before either process starts — the router listens on one end, QEMU’s UART0 is wired to the other, enabling full end-to-end testing without physical hardware.

┌──────────────────────────────────────────────────────────┐
│                       Host                               │
│  ┌─────────┐    ┌───────────┐    ┌────────────────────┐  │
│  │rmw_zenohd◄──►│ socat PTY │◄──►│ QEMU MPS2-AN385   │  │
│  │ (listen)│    │   pair    │    │ -serial chardev:…   │  │
│  └────┬────┘    └───────────┘    │ UART0 ──► firmware  │  │
│       │                         └────────────────────┘  │
│       │ zenoh network                                    │
│  ┌────▼────┐                                             │
│  │  ROS 2  │                                             │
│  │  echo   │                                             │
│  └─────────┘                                             │
└──────────────────────────────────────────────────────────┘

QEMU Flags

The runner is the board’s, not the example’s. nros sync writes it into the image’s generated settings file, build/mps2-an385-baremetal/nros-cargo.toml, where it reads:

[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine mps2-an385 -nographic -semihosting-config enable=on,target=native -kernel"

Key flags:

  • -nographic — No display window
  • -semihosting-config enable=on,target=native — Debug output via semihosting (separate from UART)
  • No -netdev / -net — Serial transport doesn’t need Ethernet

That runner wires no serial device: it is the board’s general-purpose one, good for cargo run on any image for this board, and it is generated — editing it in place is overwritten by the next sync. The serial-specific flags (-chardev / -serial chardev:…) go on the explicit QEMU command line above, which is why step 3 boots QEMU by hand rather than through cargo run.

-icount shift=auto

For reliable serial communication, add -icount shift=auto to synchronize QEMU’s virtual clock with wall-clock time. Without this, QEMU runs the CPU at full speed, which can cause timing-sensitive serial handshakes to fail. The explicit command line in step 3 already carries it.

Automated Testing

The integration test test_qemu_serial_pubsub_e2e in packages/testing/nros-tests/tests/emulator.rs automates the full workflow:

  1. Build the serial-talker example (prebuilt fixture)
  2. Create socat PTY pairs (both ends exist before either process)
  3. Start the router with listen/endpoints=["serial/<pty>#baudrate=115200"]
  4. Boot QEMU via start_mps2_an385_with_serial (UART0 → the pair; -display none -monitor none)
  5. Subscribe and verify message delivery

Contributors: the in-tree lane that runs this test is in Per-Platform Contributor Lanes.

Baud Rate Tuning

Baud RateUse Case
115200Default, safe for all hardware
460800Higher throughput, most USB-serial adapters
921600Maximum for many MCU UARTs

Higher baud rates increase throughput but may cause framing errors on noisy or long cables. QEMU ignores the baud rate (infinite speed), so rate tuning only matters on physical hardware.

Buffer Sizing

zenoh-pico serial uses a 1500-byte MTU with COBS framing. The maximum wire frame is 1516 bytes. zpico-serial uses a 2048-byte RX ring buffer per port, which accommodates one full frame plus overhead.

For high-throughput scenarios, ensure the MCU’s UART FIFO is drained frequently by calling executor.spin_once(0) in a tight loop. The budget is required (phase-417 stage 3): 0 drains what is ready and returns, which is what a tight loop wants; a millisecond value sleeps until work arrives or the budget expires.

Troubleshooting

“Session open failed” or Handshake Timeout

The zenoh serial handshake (Init → Ack) must complete within zenoh-pico’s timeout. Common causes:

  • Wrong PTY path — Check the router’s listen endpoint names the same PTY link your QEMU -chardev uses
  • Baud rate mismatch — MCU and router must use the same baud rate
  • QEMU timing — Add -icount shift=auto to slow down QEMU’s CPU clock

No Messages Received

  • Locator mismatch — Ensure the MCU’s zenoh_locator matches what zenohd expects
  • Domain ID — Both sides must use the same ROS 2 domain ID
  • zenohd not bridging — Verify zenohd is connected to the serial port and also listening on TCP for subscribers

UART Pin Conflicts

On physical hardware, ensure the UART TX/RX pins aren’t shared with the debug console. Some boards use UART0 for debug output — use a different UART for zenoh transport, or disable debug prints.

ESP32 Serial — not wired

ESP32 serial does not work today, and this page used to say it did. The serial feature on nros-board-esp32-qemu exists and declares a locator, but the configuration does not compile:

$ cargo build --no-default-features --features serial --target riscv32imc-unknown-none-elf
error[E0609]: no field `mac_addr` on type `&config::Config`
error[E0609]: no field `ip` on type `&config::Config`

and even with that repaired it has no serial backend to link: the feature is serial = [], pulling neither zpico-serial nor a UART driver, and ESP32-QEMU is a bare-metal target, which is precisely the case zpico-serial says zenoh-pico has no built-in serial backend for. Nothing in the tree builds this combination, which is why it went unnoticed.

Use the ethernet transport on ESP32-QEMU. Tracking, with the measurement and the two ways out: issue 1499.

RTIC Integration

RTIC (Real-Time Interrupt-driven Concurrency) is a concurrency framework for ARM Cortex-M that compiles tasks directly to hardware interrupt handlers — no RTOS kernel, no task control blocks, no software scheduler. nano-ros runs on top of RTIC by letting the framework own fn main, the scheduler, and the dispatchers, while nano-ros contributes one spin task and (optionally) one callback-dispatch task.

This chapter is the user-facing tutorial for that integration. For the underlying design — why per-Node dispatch strategies, why tags instead of closures, why no AsyncNode trait yet — see the sibling internals page Dispatch Strategy.

When to reach for nros::main!() instead of Pattern A

The Pattern A escape hatch is hand-written #[rtic::app], manual Executor::open inside #[init], manual spin_once(0) polling task. It looks like this:

#![allow(unused)]
fn main() {
// Pattern A, written by hand
#[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])]
mod app {
    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        let syst = nros_board_mps2_an385::init_hardware(&config, cx.device, cx.core);
        nros_rmw_zenoh::register().expect("Failed to register RMW backend");
        let mut executor = Executor::open(&exec_config).unwrap();
        let mut node = executor.create_node("talker").unwrap();
        let publisher = node.create_publisher::<Int32>("/chatter").unwrap();
        net_poll::spawn().unwrap();
        publish::spawn().unwrap();
        (Shared {}, Local { executor, publisher })
    }

    #[task(local = [executor], priority = 1)]
    async fn net_poll(cx: net_poll::Context) {
        loop {
            cx.local.executor.spin_once(core::time::Duration::from_millis(0));
            Mono::delay(10.millis()).await;
        }
    }
}
}

That pattern works, but it’s ~90 lines of glue per binary, and it puts the burden of getting RTIC + nros + the dispatcher list right on every example author. The macro collapses it to one line:

#![allow(unused)]
fn main() {
// File: examples/mps2-an385-baremetal/rust/talker-rtic/src/main.rs
#![no_std]
#![no_main]

use panic_semihosting as _;

nros::main!();
}

The nros::main!() proc-macro reads the image’s board out of system.toml — [image.<id>] board = "rtic-mps2-an385" — resolves that board to the rtic framework, and emits a full #[rtic::app] module, including #[init], __nros_spin, and (if any deployed Node declares DispatchStrategy::Deferred) __nros_dispatch.

The framework is a property of the board, never something you spell in a manifest: rtic-mps2-an385 resolves to rtic, zephyr to Zephyr, esp32-qemu and esp32-c3-baremetal to the ESP32 shape, and every other board — native, FreeRTOS, NuttX, ThreadX, non-RTIC bare-metal — to the default owned-spin fn main. An out-of-tree board crate that is not in that table declares its own framework in [package.metadata.nros.board]. So moving a program between RTIC and a plain spin loop is a board change, not a code change — no main.rs, no node source, and no build command moves with it.

One caveat for a single package: it is its own entry, so the board crate, its rtic feature, the RMW backend and the panic handler come from a generated <pkg>_nros_selection package the in-tree RTIC examples depend on (nros sync writes it for the image’s board; issue 1305). rtic and the PAC stay in the package’s own [dependencies], because the #[rtic::app] expansion names rtic:: and device = mps2_an385_pac from that crate; they belong to the RTIC program, not to the board, and are the only image-shaped rows such a package keeps. A workspace’s generated entry has no such seam.

Pick nros::main!() whenever:

  • You want a one-line main.rs and don’t need custom RTIC tasks.
  • Your Node logic is portable across boards (the Node pkg is framework-agnostic; only the image’s board picks RTIC).
  • You’re happy with the default dispatcher list from the board crate.

Keep Pattern A when:

  • You need fine-grained control of dispatcher priorities, monotonic setup, or hand-tuned #[shared] state.
  • You’re shipping a one-off bring-up binary and don’t want a system.toml and a Node-pkg split at all.

Both paths stay supported. The escape hatch is the “I want full control” path; nros::main!() is the ergonomic path on top.

Where the pieces live

Two shapes, and the RTIC choice is made in the same place in both: one [image.<id>] board in system.toml naming an RTIC board.

A single package is what every in-tree RTIC example is — see examples/mps2-an385-baremetal/rust/talker-rtic:

talker-rtic/
├── package.xml
├── Cargo.toml       # dependencies, incl. the board crate's `rtic` feature
├── system.toml      # the board, the RMW, the components
└── src/
    ├── lib.rs       # impl Component for Talker + nros::node!(Talker)
    └── main.rs      # nros::main!();

A workspace is node packages plus a bringup (the 3-pkg-role taxonomy, per docs/design/0024-multi-node-workspace-layout.md §11). There is no root build file and no Entry package to write — the entry is generated per [image.*] under build/:

my_rtic_robot/
├── .colcon_workspace                  # marks the root; no root build file
└── src/
    ├── talker_pkg/                    # Node pkg — board-agnostic
    │   ├── package.xml
    │   ├── Cargo.toml
    │   └── src/lib.rs                 # impl Component for Talker + nros::node!(Talker)
    └── demo_bringup/                  # Bringup pkg — no code
        ├── system.toml
        └── launch/
  • Node pkg — declares what the node does (publishers, subscriptions, services, actions). No main, no #[rtic::app], no board choice. Builds as rlib + staticlib and gets linked into the generated entry.
  • Bringup pkg — holds system.toml and the launch files. This is where the board, and therefore RTIC, is chosen.
  • Entry — generated, not authored. It is the #[rtic::app] shell, derived from the image; you never see it unless you go looking under build/.

The split exists so the same talker_pkg/ can be deployed under RTIC on bare-metal Cortex-M, under FreeRTOS on QEMU, and under POSIX on a Linux host without any per-target Node-pkg fork.

The system.toml that makes it RTIC

[system]
name      = "my_rtic_robot"
rmw       = "zenoh"
domain_id = 0

# `dispatch` — callbacks hand off to a ring rather than running inline;
# read by `nros check`'s framework × dispatch lint.
[[component]]
pkg      = "talker_pkg"
class    = "talker_pkg::Talker"
name     = "talker"
dispatch = "inline"

[image.rtic]
board   = "rtic-mps2-an385"
locator = "tcp/192.168.1.10:7447"

board is the only line that says RTIC. Point it at mps2-an385-freertos and the same components build as a FreeRTOS image — in a workspace, with nothing else to change; in a single package too, whose board comes from its generated selection package (issue 1305 above).

A minimal Node pkg — pub-only Talker

A pub-only Node declares DispatchStrategy::Inline (the default) — it publishes from its own RTIC task and never enters on_callback.

#![allow(unused)]
fn main() {
// File: src/talker_pkg/src/lib.rs
#![no_std]

use core::time::Duration;
use nros::prelude::*;
use std_msgs::msg::Int32;

pub struct Talker;

pub struct TalkerState {
    publisher: Publisher<Int32>,
    counter: i32,
}

impl Component for Talker {
    const NAME: &'static str = "talker";
    // Inline is the default; spelled out here for clarity.
    const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        ctx.create_publisher::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Talker {
    type State = TalkerState;

    fn init() -> Self::State {
        TalkerState { publisher: Publisher::placeholder(), counter: 0 }
    }

    fn tick(state: &mut Self::State, _ctx: &mut TickCtx<'_>) {
        state.counter = state.counter.wrapping_add(1);
        let _ = state.publisher.publish(&Int32 { data: state.counter });
    }

    fn tick_period(_state: &Self::State) -> Option<Duration> {
        Some(Duration::from_millis(1000))
    }
}

nros::node!(Talker);
}
# File: src/talker_pkg/Cargo.toml
[package]
name    = "talker_pkg"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["rlib", "staticlib"]

[dependencies]
nros     = { version = "*", default-features = false }
std_msgs = { path = "generated/std_msgs", default-features = false }

Dependencies and nothing else. The class name, the node name and the dispatch strategy are the [[component]] row in system.toml, and there is no root manifest to inherit workspace = true from.

The entry

In a workspace there is nothing to write — the entry is generated from the image. In a single-package example it is one file:

#![allow(unused)]
fn main() {
// File: src/main.rs
#![no_std]
#![no_main]

use panic_semihosting as _;

nros::main!();
}

Either way the proc-macro reads the board from system.toml, resolves it to the rtic framework, and expands into a #[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])] module with auto-generated #[init], __nros_spin, and per-Node state slots. A single-package leaf gets the board crate, with its rtic feature, from its generated selection package; it names only the PAC and rtic itself, because the #[rtic::app] expansion refers to both from the leaf’s own crate. A workspace’s generated entry pulls all of it in for you.

DispatchStrategy::Inline vs Deferred

A Node pkg declares DispatchStrategy via the Node::DISPATCH const. Two variants matter today; FromIsr is reserved as a design slot (see DispatchStrategy::FromIsr).

Inline — pub-only or tick-only Nodes

#![allow(unused)]
fn main() {
impl Component for Talker {
    const NAME: &'static str = "talker";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;
    // ...
}
}

Inline means: callbacks (if any) fire from the executor’s spin loop — the same RTIC task that polls the network transport. On RTIC that’s __nros_spin running at priority = 1.

Pick Inline when:

  • The Node has no subscriptions, no service handlers, no action handlers. Its only output is Publisher::publish calls from tick or from a custom RTIC task you spawn yourself.
  • The Node has subscriptions, but the per-message work is so cheap (microseconds, no locks, no shared-state touches) that running it inline with the spin loop is acceptable.

The Talker above is the canonical Inline shape: it publishes from tick at 1 Hz, never receives anything, and never blocks the spin loop.

Deferred — callback-driven Nodes

#![allow(unused)]
fn main() {
impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
    // ...
}
}

Deferred means: when a callback arrives, the spin loop enqueues a callback token plus context into a heapless::spsc::Queue and returns immediately. A separate __nros_dispatch RTIC task (typically priority = 2, one above spin) drains the queue and calls ExecutableNode::on_callback from its own task context.

Pick Deferred when:

  • The Node’s on_callback handler must hold an RTIC lock on a #[shared] resource — running it from the spin task would force every other dispatcher at the spin’s priority to wait on the lock.
  • The handler does non-trivial work (parses, integrates, posts to a hardware peripheral) and you want it scheduled independently from network polling.
  • The Node sits at a different priority tier than the spin task.

The tag-based registration API for Deferred Nodes

Deferred Nodes can’t use the closure form ctx.create_subscription("/chatter", |msg| { ... }) — the closure captures state by value, and a no-alloc framework runtime has no place to store an unknown closure type. Instead, you register with _static and dispatch via tag match in on_callback:

#![allow(unused)]
fn main() {
// File: src/listener_pkg/src/lib.rs
#![no_std]

use nros::prelude::*;
use std_msgs::msg::Int32;

pub struct Listener;

pub struct ListenerState {
    sub_chatter: SubscriptionTag,
}

impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Listener {
    type State = ListenerState;

    fn init() -> Self::State {
        // The macro-emitted glue overwrites the placeholder with the
        // real tag at register time.
        ListenerState { sub_chatter: SubscriptionTag::placeholder() }
    }

    fn on_callback(
        state: &mut Self::State,
        cb: Callback<'_>,
        ctx: &mut CallbackCtx<'_>,
    ) {
        if state.sub_chatter == cb {
            let msg: Int32 = ctx.downcast().unwrap();
            defmt::info!("Received: {}", msg.data);
        }
    }
}

nros::node!(Listener);
}

The same shape applies to services (ServiceTag / create_service_static) and actions (ActionTag / create_action_static). The macro lint (216.A.6) rejects mixing closure registration into a Deferred Node — the error spans the offending call and suggests switching to the _static form.

The custom-task escape — nros::main!(custom_tasks = ...)

This form is implemented — nros::main! parses custom_tasks alongside the RTIC routing branch.

Real RTIC applications don’t only have nano-ros tasks. You want a dedicated my_adc task to poll an ADC into a #[shared] buffer, or a my_ui task to drive an OLED. The custom_tasks = [...] form of nros::main!() folds your extra task fns into the generated #[rtic::app] module:

#![allow(unused)]
fn main() {
// File: src/main.rs (with custom tasks)
#![no_std]
#![no_main]

use panic_semihosting as _;

nros::main!(custom_tasks = [my_adc, my_ui]);

#[rtic_task(priority = 3, shared = [adc_data])]
async fn my_adc(mut ctx: my_adc::Context) {
    loop {
        let sample = read_adc();
        ctx.shared.adc_data.lock(|d| *d = sample);
        Mono::delay(10.millis()).await;
    }
}

#[rtic_task(priority = 2)]
async fn my_ui(_ctx: my_ui::Context) {
    loop {
        draw_screen();
        Mono::delay(33.millis()).await;
    }
}
}

The proc-macro extracts the user task tokens verbatim, splices them into the generated mod __nros_app, and adds their dispatchers to the RTIC dispatchers = [...] list. Signatures + attributes + priorities are preserved.

Custom tasks can interact with nano-ros via the #[shared] resources the macro exposes — typically executor (the spin executor) for raw publisher access, or a Node’s state slot for direct handler calls. Specifics will be locked when 216.B.4 lands.

DispatchStrategy::FromIsr — not yet

The third variant of DispatchStrategy is reserved for callbacks that fire directly from an ISR handler (e.g. a timer pulse triggering a publish without a scheduler hop). This is a design slot only; the implementation is deferred.

Landing it requires:

  • A reentrancy audit of the dispatch path.
  • A lock-free SPSC variant tolerant of ISR-priority producers.
  • A per-Node #[isr_safe] proof contract.

Until that work lands, nros check rejects DispatchStrategy::FromIsr deployments with a clear diagnostic.

See also

Embassy Integration

Status: NO in-tree Embassy board — read before relying on this chapter. nros-board-embassy-stm32f4 was the only one, and phase-337 W7.a deleted it: it was registry-labelled scaffold (issue 0248 — entry methods todo!(), a Deferred image signalling into a channel nothing drained), and RFC-0064’s rule is that a board with no Runtime cell is not a support promise this repo can make. What survives is the seam, which is the part an integrator needs: the EmbassyBoardEntry trait in nros-platform and the nros::main!() Embassy emit branch, plus nros ws check’s Deferred-dispatch lint, which routes on the board crate’s framework = "embassy" metadata.

One consequence for this chapter: every path below names a board YOU write — see Worked Example — STM32F4 Out of Tree for the board-crate shape. Reaching the Embassy emit from out of tree does work (issue 0415, resolved by phase-346): the board crate declares [package.metadata.nros.board] framework = "embassy", and a one-line build.rs calling nros_build::emit_board_framework() hands that to macro expansion. The in-tree board table covers in-tree boards only, and maps none of them to Embassy. (The RTIC twin of this integration is complete and runtime-tested on mps2-an385 — see RTIC Integration.)

Embassy is an async/await framework for embedded Rust, built around a cooperative executor that polls futures from a single context (per priority tier). nano-ros runs on top of Embassy by letting the framework own fn main, the spawner, and the async executor, while nano-ros contributes one __nros_spin_task async fn and (optionally) one __nros_dispatch_task async fn.

This chapter is the user-facing tutorial for that integration. For the underlying design — why per-Node dispatch strategies, why tags instead of closures, why on_callback stays sync even on async runtimes — see the sibling internals page Dispatch Strategy.

What Embassy buys you and where nano-ros fits

Embassy is an alternative to RTOS-style preemptive tasks: every “task” is an async fn driven by embassy_executor::Spawner::spawn. Tasks yield at .await points; there’s no scheduler tick, no context switch overhead beyond a future poll. For nano-ros that means:

  • A natural place for I/O-driven background work (SPI bus servicing, GPIO debounce, sensor frame parsing) that yields at every .await on a embassy_time::Timer or embassy_stm32::spi::Spi::transfer.
  • A natural place to spawn downstream work from inside a nano-ros callback — the “spawn-from-sync escape” below.
  • Cooperative scheduling means a Node on_callback runs to completion before the executor polls anything else at the same priority. Keep handlers short, hand off long work to a spawned task.

Pattern A is hand-written #[embassy_executor::main], hand-written zenoh_poll_task, hand-written publisher_task. It works, but it is ~150 lines and each author re-derives the spawn topology. The 216.C.4 board-entry path collapses it to:

#![allow(unused)]
fn main() {
// File: src/main.rs
#![no_std]
#![no_main]

use defmt_rtt as _;
use panic_halt as _;

nros::main!();
}

The proc-macro reads the image’s board out of system.toml — [image.<id>] board = "<your-embassy-board>" — finds that board declaring framework = "embassy", and expands into a full #[embassy_executor::main] async fn main(spawner: Spawner) including the spin task spawn, the dispatch task spawn, and the run_plan registration call.

The framework is a property of the board, not something you spell in a manifest. For a board in the in-tree table the name alone decides it; for your own board crate the declaration travels through the build.rs line in the banner above.

Why sync on_callback even on Embassy

A natural-feeling Embassy API would make ExecutableNode::on_callback an async fn. We don’t — for two reasons:

  1. Keeping the dispatch path heap-free. Async fns desugar to anonymous future types; storing them generically in the runtime requires either boxing (Box<dyn Future> → alloc dependency) or const-generic GAT plumbing through every trait. Both add cost without buying anything Spawner::spawn doesn’t already give us. What is CHECKED, and what is not: heap-free-poc-mps2 — a bare-metal Cortex-M3 image built with nros minus its alloc feature, which opens an executor over static backing, installs a component, spins, and uses the ParameterServer — links with no allocation symbol at all, and the L3 CI lane fails if it ever does (scripts/check-no-alloc-image.py --tier heap-free). No Embassy image is built, and every shipped RMW backend allocates in C, so this is a property of the executor and dispatch path — not of an Embassy image with a transport linked in.
  2. Framework-task routing. The runtime already dispatches callbacks from a framework-owned task (__nros_dispatch_task on Embassy, __nros_dispatch on RTIC). The Node author can spawn their own async task from inside the sync on_callback; that task runs under the same executor with no extra plumbing.

So on_callback keeps the same callback-token signature as RTIC, POSIX, and every other backend. The escape for “I need to await something” is the spawn-from-sync pattern below.

AsyncNode (an async-on-callback trait via RPITIT) is reserved as a design slot — see When to wait for AsyncNode at the end of this chapter.

Where the pieces live

The shape is identical to RTIC (the 3-pkg-role taxonomy, per docs/design/0024-multi-node-workspace-layout.md §11): node packages plus a bringup, no root build file, and no Entry package to write — the entry is generated per [image.*] under build/.

my_embassy_robot/
├── .colcon_workspace                    # marks the root; no root build file
└── src/
    ├── listener_pkg/                    # Node pkg — board-agnostic
    │   ├── package.xml
    │   ├── Cargo.toml
    │   └── src/lib.rs                   # impl Component for Listener + nros::node!(Listener)
    └── demo_bringup/                    # Bringup pkg — no code
        ├── system.toml
        └── launch/

The Node pkg stays board-agnostic — listener_pkg/ from the RTIC chapter could be deployed under Embassy by changing one word in system.toml. That’s the point of the split.

The system.toml that makes it Embassy

[system]
name      = "my_embassy_robot"
rmw       = "zenoh"
domain_id = 0

[[component]]
pkg      = "listener_pkg"
class    = "listener_pkg::Listener"
name     = "listener"
dispatch = "deferred"

[image.embassy]
board   = "my-embassy-board"
locator = "tcp/192.168.1.10:7447"

board is the only line that says Embassy. The RMW, the domain and the network identity live beside it, and nothing about any of them appears in a Cargo.toml.

A single-package Embassy project — one crate holding system.toml, src/lib.rs and src/main.rs — works too; it is the shape every in-tree RTIC example takes. Such a package is its own entry, so give it the generated selection dependency instead of a board crate — <pkg>_nros_selection = { path = "build/nros/selection" }, written by nros sync / nros build for the image’s board — and changing board is again the only edit. A package that names its board crate by hand must move it with board, or the build fails in your own main.rs with cannot find <board crate> in the crate root. See issue 1305.

DispatchStrategy::Deferred is the common case

Every callback-driven Embassy Node should declare DispatchStrategy::Deferred:

#![allow(unused)]
fn main() {
impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
    // ...
}
}

Deferred means: the spin task pushes signaled callbacks into an embassy_sync::channel::Channel<NoopRawMutex, _, CHANNEL_CAPACITY> (default capacity 32). A separate __nros_dispatch_task awaits the channel receive end and routes each callback to the right Node’s on_callback. Both tasks run cooperatively on the same Embassy executor; you can spawn your own tasks alongside them.

Inline is allowed but unusual on Embassy:

  • The nros check lint (Phase 216.D.1) emits a warning for framework = "embassy" with DispatchStrategy::Inline and suggests switching to Deferred — running callbacks inline on Embassy ties them to the spin task’s poll point, which makes the scheduling cost-vs-benefit confusing.
  • If your Node is genuinely pub-only (no subscriptions, no services, no actions), Inline is still fine — the Inline path simply never enters on_callback. The warning above does not apply for pure-publisher Nodes.

The spawn-from-sync escape

Real Embassy Nodes need to do async work downstream from a callback: write to an SPI bus, send a UART frame, poll a sensor with timeout. The pattern is:

  1. Hold an embassy_executor::Spawner on Self::State.
  2. From inside the sync on_callback, call state.spawner.spawn(handle_downstream(msg)).unwrap().
  3. The downstream #[embassy_executor::task] async fn handles the await-heavy work.

Worked example — Listener that writes received data to SPI

#![allow(unused)]
fn main() {
// File: src/listener_pkg/src/lib.rs
#![no_std]

use embassy_executor::Spawner;
use nros::prelude::*;
use std_msgs::msg::Int32;

pub struct Listener;

pub struct ListenerState {
    sub_chatter: SubscriptionTag,
    spawner: Spawner,
}

impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Listener {
    type State = ListenerState;

    fn init() -> Self::State {
        ListenerState {
            sub_chatter: SubscriptionTag::placeholder(),
            // The macro-emitted glue populates the Spawner from the
            // EmbassyBoardEntry init hook; until then, hold a sentinel
            // that's overwritten before the first dispatch.
            spawner: Spawner::for_current_executor(),
        }
    }

    fn on_callback(
        state: &mut Self::State,
        cb: Callback<'_>,
        ctx: &mut CallbackCtx<'_>,
    ) {
        if state.sub_chatter == cb {
            let msg: Int32 = ctx.downcast().unwrap();
            // Spawn the async work and return immediately. The
            // dispatch task stays unblocked.
            state.spawner.spawn(handle_msg(msg)).unwrap();
        }
    }
}

#[embassy_executor::task(pool_size = 4)]
async fn handle_msg(msg: Int32) {
    // Pretend we have an SPI handle stashed somewhere accessible.
    // The real wiring depends on your peripheral-access pattern;
    // the point is `.await` is free here, even though on_callback
    // is sync.
    defmt::info!("handling /chatter sample: {}", msg.data);
    embassy_time::Timer::after_millis(5).await;
    // spi.write(&msg.data.to_le_bytes()).await.unwrap();
}

nros::node!(Listener);
}

Two things to note:

  • Spawner is Copy. Holding a Spawner on Self::State adds no runtime cost beyond an integer copy. You can clone it freely across multiple on_callback calls.
  • pool_size matters. #[embassy_executor::task(pool_size = N)] allocates N static slots. If you spawn faster than the spawned tasks complete, spawn(...) returns Err(SpawnError::Busy) — handle it (drop the message, log a warning, etc.). The default is pool_size = 1.

When the spawn pool fills up

Two patterns for backpressure:

  1. Drop on full. Treat the spawned task as best-effort. If the spawn returns an error, log it and continue.
  2. Channel-based queue. Pre-spawn one long-lived #[embassy_executor::task] async fn that holds an embassy_sync::channel::Channel receive end. The on_callback pushes into the channel’s send end (drop on full or block — your choice). Lets you control queue depth independently of pool_size.

The right pick depends on whether dropped messages are tolerable for your workload; nano-ros doesn’t impose either.

When to wait for AsyncNode

The spawn-from-sync escape covers every case we know of today. But if you find yourself writing a lot of one-off pool-of-one spawn dances — each callback spawning a task whose body is a single .await — that’s a smell, and we’d want to know.

The design slot for direct async callbacks is AsyncNode (Phase 216.E.2):

#![allow(unused)]
fn main() {
// Design sketch — NOT shipping today.
pub trait AsyncNode: 'static {
    const NAME: &'static str;
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()>;
    async fn on_callback(
        &mut self,
        callback: AsyncCallbackToken,
        ctx: CallbackCtx,
    );
}
}

It would compile only on Embassy targets (RTIC has no async runtime to drive RPITIT futures into; POSIX + RTOS don’t either), and the nros::node!() macro would emit a separate __nros_node_<pkg>_on_callback_async ABI symbol the Embassy dispatch task picks up. 216.E.2 lands only if real usage shows spawn-from-sync is consistently painful. If your application is hitting that case, file an issue with the call pattern that’s prompting it.

Until then: spawn from sync. It’s two lines per callback and stays heap-free on the dispatch path (the spawned task comes from the framework’s static task pool).

See also

Troubleshooting

Common issues and solutions when working with nros.

zenoh-pico Multiple Client Issues

Symptoms

When running multiple zenoh-pico clients (e.g., a talker and listener) simultaneously connecting to the same router, you may see errors like:

<err> nros_bsp: Failed to open zenoh session: -3
<err> rust: rustapp: BSP init failed: InitFailed(-5)

Or publisher/subscriber declarations fail with:

  • Error code -128 (_Z_ERR_GENERIC)
  • Error code -78 (_Z_ERR_SYSTEM_OUT_OF_MEMORY)

The first client typically succeeds while subsequent clients fail during z_declare_publisher() or z_declare_subscriber().

Root Cause

This is caused by the Z_FEATURE_INTEREST feature in zenoh-pico. The interest protocol implements write filtering to optimize network traffic by only sending data to interested subscribers. However, this feature has issues when multiple clients connect to the same router:

  1. The router tracks “interest” for each client
  2. When creating a publisher, zenoh-pico creates a write filter context
  3. The filter creation calls _z_session_rc_clone_as_weak() which can fail
  4. This manifests as _Z_ERR_SYSTEM_OUT_OF_MEMORY (-78) even when memory is available

The failure occurs in zenoh-pico/src/net/filtering.c:

ctx->zn = _z_session_rc_clone_as_weak(zn);
if (_Z_RC_IS_NULL(&ctx->zn)) {
    _z_write_filter_ctx_clear(ctx);
    z_free(ctx);
    _Z_ERROR_RETURN(_Z_ERR_SYSTEM_OUT_OF_MEMORY);
}

Solution

nros disables Z_FEATURE_INTEREST by default for all builds:

Native builds (build.rs):

#![allow(unused)]
fn main() {
let dst = cmake::Config::new(&zenoh_pico_build)
    // ...
    .define("Z_FEATURE_INTEREST", "0")
    .build();
}

Zephyr builds: edit modules/lib/zenoh-pico/include/zenoh-pico/config.h as shown below. (Contributors working in the nano-ros checkout: just setup zephyr applies the same patch to the in-tree workspace automatically.)

Manually edit modules/lib/zenoh-pico/include/zenoh-pico/config.h:

// Change from:
#define Z_FEATURE_INTEREST 1
#define Z_FEATURE_MATCHING 1

// To:
#define Z_FEATURE_INTEREST 0
#define Z_FEATURE_MATCHING 0

Then rebuild with --pristine:

west build -b native_sim/native/64 nros/examples/zephyr/rust/talker -d build-talker --pristine

Note: Z_FEATURE_MATCHING depends on Z_FEATURE_INTEREST, so both must be disabled.

References

  • rmw_zenoh_pico disables this feature by default
  • zenoh-pico filtering code: src/net/filtering.c
  • zenoh-pico interest protocol: src/session/interest.c

zenoh-pico Version Compatibility

Symptoms

Publisher works but z_publisher_put fails immediately:

zenoh_shim: z_publisher_put failed: -100
<err> rust: rustapp: Publish failed: PublishFailed(-1)
zenoh_shim: z_publisher_put failed: -73

Error codes:

  • -100: _Z_ERR_TRANSPORT_TX_FAILED - Transport transmission failed
  • -73: _Z_ERR_SESSION_CLOSED - Session closed after first failure

Root Cause

Version mismatch between zenoh-pico and zenohd:

  • zenoh-pico: 1.5.1 (in west.yml)
  • zenohd: 1.7.x (installed via cargo)

The zenoh protocol may have changed, causing transport-level incompatibilities.

Solution

Compare the two zenoh versions across the seam — nano-ros does not pin the router, because the router is the one ROS ships (phase-362 / RFC-0075):

grep 'define ZENOH_C ' /opt/ros/$ROS_DISTRO/opt/zenoh_cpp_vendor/include/zenoh_configure.h
cat packages/rmw/zenoh/zpico-sys/zenoh-pico/version.txt

Read the HEADER, not dpkg -l: the package version (ros-humble-rmw-zenoh-cpp 0.1.9) is a wrapper version and says nothing about the zenoh inside it.

Under zenoh’s 1.x wire guarantee the two need not match, so a mismatch is evidence rather than a diagnosis. The interop tests print both numbers on a delivery failure for exactly this comparison.


Multiple Zephyr Instance Issues

Ephemeral Port Conflict

Symptom: When running multiple Zephyr native_sim instances (e.g., talker and listener), both sessions fail to establish or one gets -100 (_Z_ERR_TRANSPORT_TX_FAILED) errors.

Root Cause: Zephyr native_sim uses a test entropy source that produces identical random number sequences unless seeded differently. This causes both instances to select the same ephemeral TCP port when connecting to the router, causing a port conflict.

Solution: Pass different --seed values to each native_sim instance:

# Start listener with one seed
./build-listener/zephyr/zephyr.exe --seed=12345

# Start talker with a different seed
./build-talker/zephyr/zephyr.exe --seed=67890

The --seed parameter initializes the test entropy source with a different value, producing different random numbers and thus different ephemeral ports.

For automated tests: The test framework (packages/testing/nros-tests) automatically assigns unique seeds to each process.

Cyclone DDS: Two native_sim Nodes Never Discover Each Other

Symptom: Two native_sim nodes with the Cyclone DDS RMW (e.g. talker + listener) each start, the publisher reports Publishing: 'Hello World: N', but the subscriber never prints an I heard: line — no abort, no error.

Root Cause: Same fixed-entropy issue as above. Cyclone derives each participant’s DDSI GUID prefix from the entropy source; with identical entropy, both processes get the same GUID. DDSI requires per-participant unique GUIDs — a node treats SPDP from a peer that shares its own GUID as a self-announcement and drops it, so the two never form a proxy-participant pair and discovery never closes.

Solution: Pass different --seed values per instance (exactly as above):

./build-listener/zephyr/zephyr.exe --seed=11
./build-talker/zephyr/zephyr.exe   --seed=22

With distinct seeds the GUID prefixes differ and SPDP discovery completes. (native_sim Cyclone discovery is unicast-only — the embedded config points <Peers> at 127.0.0.1; native_sim’s NSOS multicast RX fd does not survive Cyclone’s select()-based socket waitset. Real hardware has real entropy and full multicast, so neither workaround is needed off native_sim.)


Network Configuration Issues

TAP Interface Setup (QEMU bare-metal only)

Symptom: QEMU cannot connect to the network.

Solution: Run the network setup script:

sudo ./scripts/qemu/setup-network.sh

This creates and configures the tap0 interface at 192.0.3.1. QEMU bare-metal guests live on 192.0.3.10+.

Zephyr native_sim networking

Zephyr native_sim uses NSOS (Native Sim Offloaded Sockets) — socket calls are forwarded to host syscalls, so there is no emulated L2 stack and no TAP bridge. Point each example at a host-loopback zenohd / XRCE Agent:

ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd  # or any host-accessible address

Multiple native_sim instances can coexist without bridge configuration.


Message Too Large / Truncated Messages

Symptoms

  • TransportError::MessageTooLarge when receiving service requests or subscription data
  • Messages arrive but with corrupted or incomplete payloads
  • Large messages (images, point clouds) silently disappear

Root Cause

nros uses static buffers at multiple layers, each with configurable size limits. A message must fit through every layer in the path to be delivered intact.

Zenoh backend layers:

LayerPosix DefaultEmbedded DefaultEnv Var
Defragmentation (Z_FRAG_MAX_SIZE)655362048NROS_ZENOH_FRAG_MAX_SIZE
Batch size (Z_BATCH_UNICAST_SIZE)655361024NROS_ZENOH_BATCH_UNICAST_SIZE
Per-entity shim buffer10241024– (named constant in code)
User receive buffer (RX_BUF)10241024– (const generic)

XRCE-DDS backend layers:

LayerPosix DefaultEmbedded DefaultEnv Var
Transport MTU4096512XRCE_TRANSPORT_MTU
Per-entity buffer10241024– (named constant in code)

Solutions

Increase transport-level limits (set before cargo build):

# Zenoh: allow 128 KB reassembled messages
NROS_ZENOH_FRAG_MAX_SIZE=131072 cargo build

# XRCE: increase MTU to 8 KB
XRCE_TRANSPORT_MTU=8192 cargo build

Increase per-entity buffer sizes (in code):

#![allow(unused)]
fn main() {
// Zenoh subscriber with 4 KB receive buffer
let sub = node.create_subscriber_sized::<MyMsg, 4096>(SubscriberOptions::new("/topic"))?;

// Zenoh publisher with 4 KB transmit buffer
let pub_ = node.create_publisher_sized::<MyMsg, 4096>(PublisherOptions::new("/topic"))?;
}

Clean rebuild after changing env vars (CMake caches old values):

cargo clean -p zpico-sys   # or: cargo clean -p xrce-sys
cargo build

Build Issues

zenoh-pico Submodule Not Found

Symptom:

zenoh-pico submodule not found at /path/to/zenoh-pico. Run: git submodule update --init

Solution: init that one submodule, scoped — not --recursive. The transitive closure is large and mostly irrelevant to any one task, and the launch toolchain’s third-level runtime submodules are never built by nano-ros.

git submodule update --init packages/rmw/zenoh/zpico-sys/zenoh-pico

Better still, let provisioning do it — this is what nros setup is for, and it fetches shallow rather than deepening to reach the pin:

nros setup <board> --rmw zenoh      # or: nros setup --source zenoh-pico

CMake Cache Stale

Symptom: Changes to CMake defines (like Z_FEATURE_INTEREST) don’t take effect.

Solution: Clean the build cache:

# Clean the zenoh-pico sys crate build cache
cargo clean -p zpico-sys
touch packages/rmw/zenoh/zpico-sys/build.rs
cargo build

For Zephyr builds, clean the west build directory:

rm -rf build/
west build -b native_sim/native/64 <app>

Test Failures

Tests Hang Forever

Symptom: Integration tests hang indefinitely.

Possible causes:

  1. No zenohd router running: Many tests require a zenohd router
  2. Port already in use: Another process is using port 7447
  3. Network misconfigured: TAP or bridge interfaces not set up

Solutions:

# Check if zenohd is running
pgrep zenohd

# Check if port 7447 is in use
ss -tlnp | grep 7447

# Verify QEMU bare-metal TAP (only needed for QEMU bare-metal platforms)
ip addr show tap0

Zephyr Tests Skip

Symptom: All Zephyr tests are skipped.

Cause: The test framework couldn’t find the Zephyr workspace or required components.

Solution: Ensure the Zephyr workspace is properly configured:

# Default location: $repo/zephyr-workspace/ (gitignored, in-tree).
# Legacy setups: $repo/../nano-ros-workspace/ (auto-detected).
ls -la zephyr-workspace
nros doctor

# Or set explicitly
export NROS_ZEPHYR_WORKSPACE=/path/to/zephyr-workspace

# Verify west is available
west --version

Rust/C FFI Issues

Subscriber Callback Crashes

Symptom: When a Zephyr listener receives a message, the application crashes with a segfault or produces garbage values when accessing struct fields. Debug output may show valid pointers but invalid field values:

bsp_zephyr: sub->callback=0x60, sub->user_data=0x4  # These should be valid addresses!

Root Cause: Your C glue stores a pointer to the rcl_subscription_t struct when you hand it to a registration call. If the Rust code moves the struct after that (e.g., when returning it from a function), the C side’s pointer becomes dangling.

In Rust, values are moved by default:

#![allow(unused)]
fn main() {
// WRONG - struct will move when returned
pub fn create_subscriber(...) -> Result<BspSubscriber<M>, Error> {
    let mut sub = NanoRosSubscriber { ... };
    nros_bsp_create_subscriber(&mut sub, ...);  // C stores pointer to `sub`
    Ok(BspSubscriber { sub, ... })  // `sub` MOVES here - old address is invalid!
}
}

Solution: Use static storage or ensure the struct has a stable address before passing it to C:

use core::mem::MaybeUninit;
use core::ptr::addr_of_mut;

// Wrapper to make static storage Sync
struct StaticSubscriber(MaybeUninit<NanoRosSubscriber>);
unsafe impl Sync for StaticSubscriber {}

static mut SUBSCRIBER_STORAGE: StaticSubscriber =
    StaticSubscriber(MaybeUninit::uninit());

fn main() {
    let sub_ptr = unsafe {
        let storage_ptr = addr_of_mut!(SUBSCRIBER_STORAGE);
        let sub = (*storage_ptr).0.as_mut_ptr();
        // Initialize fields...
        sub
    };

    // Now the pointer is stable
    nros_bsp_create_subscriber(sub_ptr, ...);
}

Alternative approaches:

  • Use Box::new() (requires alloc) for heap allocation with stable address
  • Use Pin to prevent moves
  • Have the C code use indices instead of pointers

Why this happens: Unlike C where variables have fixed addresses, Rust moves values during assignment and return. When the C code stores a pointer during initialization, it doesn’t know the Rust struct will be moved later.

Function Pointer ABI Mismatch

Symptom: Callbacks passed between Rust and C crash or receive garbage arguments.

Solution: Ensure function pointers use extern "C":

#![allow(unused)]
fn main() {
// CORRECT - uses C calling convention
extern "C" fn my_callback(data: *const u8, len: usize, ctx: *mut c_void) {
    // ...
}

// WRONG - uses Rust calling convention (incompatible with C)
fn my_callback(data: *const u8, len: usize, ctx: *mut c_void) {
    // ...
}
}

zenoh-pico Error Codes

CodeNameDescription
-3_Z_ERR_TRANSPORT_OPEN_FAILEDCould not connect to router
-78_Z_ERR_SYSTEM_OUT_OF_MEMORYMemory allocation failed (or write filter issue)
-128_Z_ERR_GENERICGeneric error

For the complete list, see zenoh-pico/include/zenoh-pico/utils/result.h.

Setup Compared to Standard ROS 2

This page is for ROS 2 users who already know the normal desktop flow: install a ROS distro, create a workspace, run rosdep, build with colcon, and select an RMW at runtime with RMW_IMPLEMENTATION.

nano-ros keeps the workspace and package vocabulary, but changes the setup boundary because it targets embedded and RTOS builds.

Standard ROS 2 Flow

A typical ROS 2 application starts from a distro install:

source /opt/ros/humble/setup.bash
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws
rosdep install --from-paths src --ignore-src -y
colcon build
source install/setup.bash

The middleware implementation is selected at process startup:

export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
ros2 run my_pkg my_node

That model assumes shared libraries, a hosted OS, and runtime plugin loading.

nano-ros Flow

Where standard ROS 2 installs a distro and resolves system packages with rosdep, nano-ros provisions a per-board toolchain with one command. nros setup replaces the distro install + rosdep: it ships prebuilt toolchains per platform per RMW — the cross-compiler, emulator, RMW host daemon, and SDK sources for a board are fetched from a pinned index into a shared store (${NROS_HOME:-~/.nros}/sdk). You do not install cross-toolchains by hand, and you do not need a ROS distro on the machine.

# 1. Build the in-tree nros CLI (analogous to installing a ROS distro, Phase 218):
./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish

# 2. Provision a board + RMW (analogous to `rosdep install`):
nros setup linux --rmw zenoh

# 3. Build and run something (analogous to `colcon build`):
#    `nros sync` generates the message bindings and the build settings
#    the target implies; both live under gitignored directories, so a
#    fresh clone cannot build without this step.
cd examples/linux/rust/talker
nros sync
nros build
./build/linux/target/debug/talker

Step 3 is the one that maps most directly onto ROS 2, and it maps better than it used to. nros sync + nros build plays the part colcon build plays: you declare the system once, in a file, and one command works out what to compile and drives the language toolchain. What you declare is system.toml — the board, the RMW, the domain, the components — and nothing in Cargo.toml or CMakeLists.txt mentions a target (RFC-0098). Changing target is editing the board = line and re-running nros sync.

For embedded targets, name the board instead of native; nros setup fetches the matching prebuilt cross-toolchain + emulator + SDK:

nros setup mps2-an385-freertos --rmw zenoh     # arm-none-eabi-gcc, qemu, FreeRTOS+lwIP
nros setup zephyr            --rmw zenoh     # Zephyr west workspace + SDK bits
nros setup qemu-armv7a-nuttx    --rmw zenoh     # arm-none-eabi-gcc, qemu, NuttX

Useful flags: nros setup --list (every package + version), nros setup <board> --dry-run (resolve + print the plan, fetch nothing), nros setup --licenses (license-gated packages). See Supported Boards for the board list and nros CLI for every subcommand.

Contributors working on nano-ros itself drive the same index through just — just <module> setup calls nros setup <board> under the hood, so the provisioned toolchains are identical.

Choosing platform + RMW

Unlike standard ROS 2, RMW and platform are compile-time choices — there is no runtime RMW_IMPLEMENTATION switch on embedded targets (no dlopen). What replaces RMW_IMPLEMENTATION is two lines in system.toml, and they are the only place the choice appears:

[system]
name = "my_app"
rmw  = "zenoh"                 # zenoh | cyclonedds | xrce

[image.board]
board = "mps2-an385-freertos"  # the platform follows from the board

Then nros sync && nros build. There is no cache variable to set and no Cargo feature to name: the board descriptor already knows the triple, the linker flags and the platform module, and nros build writes them into the build root it generates. A second [image.*] row is a second target from the same sources — nros build <image-id> picks one.

The reason this looks like colcon rather than like a cross-compile recipe is deliberate: the decision a ROS 2 user makes at runtime, nano-ros makes in one declarative file, and everything downstream is derived rather than restated (RFC-0098).

Multi-RMW bridges (one binary, two or more backends) use Executor::open_with_rmw("<name>", ...) + node_builder.rmw("<name>") — see Cross-backend Bridges.

What Stays Familiar

  • Workspace layout: one source checkout next to your packages.
  • Package metadata: downstream packages still use package.xml.
  • ROS vocabulary: nodes, publishers, subscriptions, services, actions, QoS profiles, parameters, and message packages keep ROS-shaped names.
  • colcon build still works as a consumer-side build for POSIX C++ workspaces that already use it; nros build is the equivalent that also reaches embedded targets, driving cmake, cargo, west or idf.py for you.
  • Interop: POSIX nano-ros nodes can communicate with standard ROS 2 nodes through compatible RMW backends (Zenoh, Cyclone DDS, XRCE).

What Changes

  • Source-only. No binary SDK tarball, no crates.io umbrella crate, no Arduino zip / ESP-IDF binary component / GitHub Releases artifact. The locked policy is git clone --branch=v<X.Y.Z> + in-tree build.
  • Per-board provisioning, no rosdep. nros setup <board> --rmw <rmw> is the single setup command. It fetches toolchains (cross-gcc, emulator) and SDK sources for exactly that board+RMW into ~/.nros/sdk — no system-wide package install. Most are prebuilt; a few without a seeded asset are built from source — --dry-run says which on your host. The zenoh router is deliberately NOT provisioned: it comes from a ROS 2 install (ros2 run rmw_zenoh_cpp rmw_zenohd, RFC-0075), so the zenoh path needs ROS 2 on the router host; xrce and cyclonedds do not. (The just <module> setup recipes call the same command for contributors.)
  • Compile-time RMW + platform. Embedded targets can’t dlopen, so the combination is locked in at build time — stated once as [system] rmw and [image.<id>] board in system.toml, and derived from there into the build.
  • No install prefix. Phase 140 removed just install-local; there is no cmake --install step for nano-ros itself — consumers pull it into their build via add_subdirectory(<repo-root>) or find_package(nano_ros) from the checkout. (Example packages still carry their own install(TARGETS …) for colcon compatibility.) The integration shells under integrations/<rtos>/ re-export the same root CMake under each RTOS’s native package manager.
  • The store only grows, so there are verbs to inspect and shrink it. Provisioning never overwrites: a new version lands beside the old one, which is what makes going back to a previous toolchain free. nros store list prints every entry with its size and when it was last read; nros store gc --older-than 90d proposes what to reclaim and removes nothing unless you add --delete. It never removes an entry a pin names — it reads nros-sdk-index.toml / nros-sdk.lock from the directory you run it in and its ancestors, and says which files it consulted. nros toolchain uninstall <version> removes one nano-ros toolchain under the same rule, and refuses when it cannot find a pin file to check against, rather than assuming nothing needs it.
  • Generated bindings in-tree. Message codegen lands under <your-package>/generated/ (or OUT_DIR for Cargo builds), not in an installed ROS message library.
  • Configuration is build-time on embedded. Runtime env vars (ROS_DOMAIN_ID, NROS_LOCATOR, …) work on POSIX; embedded targets bake the same values from their [image.<id>] row in system.toml (locator, and the address in net) and [system] (rmw, domain_id) — one file, and the same one in every language — plus Kconfig on Zephyr.

Next Step

Continue with Installation, then run the ROS 2 Interoperability example before moving to a platform-specific guide.

Porting a ROS 2 C++ node to nano-ros

Goal: take a normal ROS 2 C++ node (one that compiles + runs under colcon build against ros-humble-*) and run it under nano-ros — without rewriting the source. The rclcpp:: names are declared by nano-ros’s own C++ headers — <rclcpp/rclcpp.hpp> is nros-cpp’s own umbrella, not a layer over another API (RFC-0096 D4). The CMake side is part of nano-ros’s own package: cmake/NanoRosAmentSurface.cmake defines the ament_* verbs, cmake/find/ holds the Find<pkg>.cmake modules, and diagnostic_updater is packages/api/nros-diagnostic-updater. So the only delta is build-script glue.

The canonical proof lives at examples/templates/cpp-port-minimal-publisher/ — the ROS 2 tutorial’s minimal_publisher.cpp vendored unmodified, building against nano-ros via the three glue lines below.

The one compile definition, and why you do not type it

A ported file names rclcpp::Node, std::make_shared<MyNode>() and create_publisher<M>(...) returning a SharedPtr. Those signatures are spelled in std::shared_ptr and std::string, and nano-ros compiles that surface only when a build asks for it by defining NROS_CPP_STD. It is never inferred from what your compiler happens to have on its include path.

nano-ros’s ament surface sets it for you: every target created through ament_auto_add_* / rclcpp_components_register_node, and every target linking rclcpp::rclcpp, gets it. A project following the two layers of glue below needs nothing extra. You type it yourself only when you build a ported translation unit some other way:

target_compile_definitions(my_ported_node PRIVATE NROS_CPP_STD=1)

Two things worth knowing before you reach for it:

  • This is not a host-versus-embedded switch. Code written against rclcpp::Node does not want the flag on a native Linux build either; ported rclcpp code wants it on a Cortex-M3 build just as much. The split is whose code it is, not what it runs on. See C++ API — two surfaces.
  • Define it per target, not per file. It changes the layout of rclcpp::Node, so two translation units of one image disagreeing about it is an ODR break rather than a missing function.

If you forget it the failure is loud and at compile time — an unknown rclcpp::Node, or a create_publisher overload that does not match — never a silent behaviour change.

Two layers of glue

Per-package CMakeLists.txt — zero nano-ros lines

The ported pkg’s CMakeLists.txt carries only stock ROS 2 syntax. Same file builds under both colcon build AND a nano-ros build:

cmake_minimum_required(VERSION 3.20)
project(my_node LANGUAGES CXX)

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)
# … any other msg packages the source #include's.

add_executable(my_node src/my_node.cpp)
ament_target_dependencies(my_node rclcpp std_msgs)

ament_package()

find_package(rclcpp) resolves through nano-ros’s Findrclcpp.cmake, which defines rclcpp::rclcpp over the nros-cpp runtime — and with it the include path on which <rclcpp/rclcpp.hpp> and <rclcpp_components/register_node_macro.hpp> are nano-ros’s own headers; find_package(std_msgs) resolves through the message-package resolver (it walks NROS_INTERFACE_SEARCH_PATH > AMENT_PREFIX_PATH > bundled); nano-ros’s ament_target_dependencies wires both link targets.

These are find MODULES rather than <pkg>Config.cmake files on purpose: find_package() tries module mode first, so nano-ros’s rclcpp wins over an installed ROS 2 even when /opt/ros/<distro>/setup.bash has been sourced.

Workspace umbrella CMakeLists.txt — one nano-ros include

The umbrella CMakeLists.txt (sits at the workspace root, next to src/) is the only nano-ros-aware file:

cmake_minimum_required(VERSION 3.22)
project(my_workspace LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)

# 1) Pull nano-ros in.
set(NROS_PLATFORM posix)
set(NROS_RMW "zenoh" CACHE STRING "Active RMW.")
set(NROS_RMW "${NROS_RMW}")
add_subdirectory("/path/to/nano-ros" nano_ros)

# 2) Point the message-package resolver at this workspace's src/ (must
#    precede the ament-surface include so workspace-pkg Find<pkg>.cmake
#    auto-emit picks it up).
set(NROS_INTERFACE_SEARCH_PATH "${CMAKE_SOURCE_DIR}/src")

# 3) nano-ros's ament / rclcpp surface.
include("/path/to/nano-ros/cmake/NanoRosAmentSurface.cmake")

# 4) Bulk-build every workspace msg pkg in topo order (one line instead of
#    N add_subdirectory(src/<pkg>) lines).
nros_workspace_interfaces()

# 5) Build consumer apps.
add_subdirectory(src/my_node)

No nros_generate_interfaces(<pkg>) calls per consumer — the smart Find-stub does the codegen at find_package(<pkg>) time.

Reference fixture

examples/templates/local-msg-package/ ships the pattern end-to-end: two workspace msg pkgs (local_msgs, extra_msgs) with intra-workspace dep + a C++ consumer pulling msgs from BOTH the workspace AND AMENT (std_msgs, geometry_msgs, sensor_msgs) via one find_package shape. Cross-build proof: the same src/ builds under colcon build (CI-gated).

Legacy nros_generate_interfaces(<pkg>) shape

(The full current-vs-legacy spelling table lives in Message Generation.)

Per-package nros_generate_interfaces(std_msgs LANGUAGE CPP SKIP_INSTALL) calls still work (back-compat preserved) but are deprecated for new code — they bypass the ROS-convention smart Find-stub + workspace discovery. Existing in-tree examples will migrate as part of Phase 210.E.3.

Running the ported node on a microcontroller

The same source runs on an RTOS, main included. Build glue for each platform sits beside the source, and the source itself does not change. examples/templates/cpp-port-minimal-publisher/ carries two such directories, mps2-an385-freertos/ and zephyr/, each with a CMakeLists.txt built around one line:

find_package(nano_ros REQUIRED)
nano_ros_add_executable(minimal_publisher ../src/minimal_publisher.cpp ROS2_MAIN)

The board comes from the directory’s system.toml, as for any nano-ros image. Every nano_ros_add_executable reaches your main (phase-499): no C runtime calls int main(int argc, char** argv) on a board, so the build renames main in your sources only (never in the board’s own startup) and calls it from the platform’s entry, with argc == 0. ROS2_MAIN adds what a ported program needs on top of that:

  • It gives the ported code the C++ standard library. A ported node uses std::string, std::to_string and <chrono> literals.
    • On FreeRTOS the toolchain compiles C++ -ffreestanding, which makes <string> an #error, so the flag is removed for this package’s directory. nano-ros’s own code stays freestanding.
    • On Zephyr, select the full library in prj.conf: CONFIG_REQUIRES_FULL_LIBCPP=y and CONFIG_GLIBCXX_LIBCPP=y.
  • It asks for the std-shaped API (NROS_CPP_STD), the same one the ament verbs give a ported package on a host.

Where the node connects is decided when the image is built, because a board has no environment to read it from: the NROS_ENTRY_LOCATOR cache variable on FreeRTOS, the image’s locator in system.toml on Zephyr (rendered into CONFIG_NROS_ZENOH_LOCATOR). rclcpp::init(argc, argv) reads it in every source file that includes <rclcpp/rclcpp.hpp>.

A node with a hand-written spin loop also works. The other two templates, rclcpp-compat-smoke/ and topic-state-monitor-port/, have the same two directories, and their main is the common shape:

while (rclcpp::ok()) {
    node->tick();
    rclcpp::spin_some(node);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
}

On FreeRTOS the board provides what the C++ library needs for this: std::this_thread::sleep_for sleeps the calling task, and std::chrono::steady_clock counts from boot, because the board has no real-time clock. A package that uses another library, such as diagnostic_updater, names it the usual way, ament_target_dependencies(<name> diagnostic_updater). On Zephyr the dependency goes to app, where the sources are compiled.

Zephyr’s native_sim cannot run a ported node. There Zephyr links its own C library, and the host’s libstdc++ cannot run on top of it. Use a Cortex-M board such as mps2/an385 in QEMU, which is what the template targets.

What “just works” without source edits

nano-ros covers the patterns a typical ROS 2 C++ node uses:

rclcpp surfacenano-ros mappingNotes
class MyNode : public rclcpp::Noderclcpp::Node — ours, and the same name.Ctor takes (name), (name, options) or (name, ns, options).
std::make_shared<MyNode>()worksauto node = std::make_shared<MyNode>() and rclcpp::spin(std::make_shared<MyNode>()) are unchanged. rclcpp::Node::SharedPtr node = std::make_shared<…>(…) does not compile — write auto; see “Node::SharedPtr observes, it does not own” below. shared_from_this() works too, with the same caveat.
create_publisher<M>(topic, qos)shared_ptr-returning wrapperqos can be rclcpp::QoS(10) or an int.
create_subscription<M>(topic, qos, callback)registered on the executor arena; dispatched by any spin verbCapturing lambdas + std::function all work.
create_wall_timer(period, callback)registered on the executor arena; dispatched by any spin verbstd::chrono::duration arg, capturing-lambda callback. Returns rclcpp::TimerBase::SharedPtr, which is rclcpp::Timer::SharedPtr — one flat type, two names. See below.
rclcpp::create_timer(node, clock, period, cb)the clock-taking verb; a RCL_ROS_TIME clock follows /clockHumble’s only form. create_wall_timer stays on the steady clock.
rclcpp::init(argc, argv) / shutdown() / ok() / spin(n) / spin_some(n)wraps rclcpp::init_in/shutdown_in/ok/spin_once--ros-args -r from:=to is honoured, beneath any remap the launch file sets for the same name. -p [node:]name:=value and --params-file <file> are honoured in an image with a parameter store: the value replaces the default when the node declares the parameter (scalars only from a file; a value of the wrong type is logged and the default kept). Any other ROS argument (__node, log flags) aborts, naming it.
RCLCPP_INFO / WARN / ERROR / DEBUG / FATALdispatched through the NROS_LOG_* family into nros_logCarries the logger, so get_logger("x") selects a real per-logger level, and FATAL is a distinct severity. Reaches LOG_ERR/printk on embedded, where the legacy NROS_* sink is a no-op (issue 1019). _THROTTLE variants are REFUSE-LOUD.
get_logger().get_child("sub")a real child logger, <parent>.sub (RFC-0102)Its level, when unset, follows the nearest ancestor’s, so set_level on the node logger reaches it. A full name over 48 bytes or a full runtime-logger arena emits through the PARENT and set_level on it is refused — never a silent move of the parent’s threshold. Iron/Jazzy images publish children on /rosout; Humble ones, as upstream, do not.
executor.spin_once() / client->wait_for_service() / action_client->wait_for_action_server()the budget is requiredUpstream defaults all three to “block forever”, which nano-ros has no form of; the no-argument call is a compile error naming the alternative rather than a silently substituted 10 ms / 5 s budget. Write spin_once(10), wait_for_service(10000).
rclcpp::QoS / KeepLast(n) / SystemDefaultsQoS()one class with upstream’s implicit (depth) ctorChainable setters inherited.
diagnostic_updater::Updater + DiagnosticStatusWrapperpackages/api/nros-diagnostic-updaterPublishes /diagnostics.
rclcpp_action::Server<A> / Client<A>ours, under upstream’s names (rclcpp::ActionServer/Client<A> are aliases for them)Same entities, smaller shapes: no goal handles and no futures. See Actions below.
class MyNode : public rclcpp_lifecycle::LifecycleNodeours, under upstream’s name (<rclcpp_lifecycle/lifecycle_node.hpp>)It IS an rclcpp::Node. The on_configure(const State&) … on_shutdown overrides, CallbackReturn::SUCCESS, configure() / activate() / … and get_current_state() keep upstream’s signatures; the REP-2002 services are registered by the constructor. create_publisher<M>(topic, qos) returns a managed LifecyclePublisher<M>::SharedPtr that sends only while Active. One managed node per executor.
RCLCPP_COMPONENTS_REGISTER_NODE(class)no-op macro + cmake-side rclcpp_components_register_node() emits a thin int main() per registrationSingle-binary embedded.
find_package(ament_cmake_auto / rclcpp / rclcpp_components / diagnostic_updater / std_msgs / …)find modules at cmake/find/~28 of the most-cited ROS 2 packages; a message package not among them is found by the resolver from NROS_INTERFACE_SEARCH_PATH / AMENT_PREFIX_PATH.

Actions: no goal handles, no futures

The action types keep upstream’s names and concepts, and GoalResponse / CancelResponse keep upstream’s enumerators and values. The SHAPES around them are smaller, because there is no allocator to hand out a goal handle and no async runtime to complete a future (RFC-0021). Every difference below is a compile error in a ported file, so the list is what to rewrite, not what to debug:

  • Server: no ServerGoalHandle. The goal and accepted callbacks receive the 16-byte goal id. Feedback and termination are calls on the SERVER keyed by that id — server.publish_feedback(id, fb), server.succeed(id, result), abort, canceled — taking the message by const reference, not a shared_ptr.
  • Server: created in place. There is no rclcpp_action::create_server returning a SharedPtr; declare the Server<A> and initialise it with node.create_action_server(srv, name). It is not a Waitable (no execute / is_ready / set_on_ready_callback): the executor drives it directly.
  • Callbacks are stateless function pointers (empty-capture lambdas), or the _with_ctx setters with a void* context.
  • Client: send_goal blocks and fills an out-param goal id, returning Result, where upstream’s async_send_goal returns a shared_future of a goal handle. send_goal_future is the future-shaped spelling.
  • Client: callbacks bind to the CLIENT. Upstream passes SendGoalOptions to each async_send_goal; here client.set_callbacks(opts) installs them once for every goal the client sends. They are read when a reply is dispatched, so install them before the spin that delivers it.
  • wait_for_action_server takes milliseconds and returns Result — and the budget is required (see the table above).

Two things the compiler will not tell you

Everything else on this page either works or fails to compile. These two compile and behave slightly differently, so they are written down rather than left for a debugging session.

Node::ok() — nano-ros cannot throw, so YOU have to ask

Upstream’s rclcpp::Node constructor THROWS when the node cannot be created. nano-ros is built -fno-exceptions (RFC-0018), so the constructor cannot: it records the failure and the node answers false to ok().

rclcpp::Node node("talker");
if (!node.ok()) return 1;        // <- this line replaces upstream's try/catch

A generated entry already does this and halts naming the node, so a workspace component needs nothing. A hand-written main does not, and a node that failed to create will otherwise go on to create publishers that quietly do nothing. There is no diagnostic for it — the object exists either way, which is the whole point of not throwing — so checking ok() after constructing a node by hand is on you.

The same shape applies to init(), the two-phase form:

rclcpp::Node node;
if (!node.init("talker").ok()) return 1;

Result carries [[nodiscard]] on C++17 and later, so that one the compiler does warn about.

Node::SharedPtr observes, it does not own

rclcpp::Node::SharedPtr is rclcpp::Handle<rclcpp::Node> on every target, embedded included: a copyable reference that does not keep the node alive. A node is constructed by your main (or the generated entry) and outlives everything it is handed to, so storing and copying it works as upstream’s does. Two consequences:

  • shared_from_this() returns that handle. It is safe to pass to anything that holds it for less than the node’s lifetime (diagnostic_updater::Updater(shared_from_this(), 1.0) is the common case), and NOT safe to store somewhere that outlives the node. It never throws where upstream would raise bad_weak_ptr. nano-ros cannot derive from std::enable_shared_from_this<Node>: its std::weak_ptr member exists only where <memory> does, so two translation units of one firmware image could disagree about the object’s layout.
  • rclcpp::Node::SharedPtr node = std::make_shared<MyNode>(); is a compile error. The shared_ptr there is a temporary, and a handle to it would dangle at the end of the line; the compiler names the deleted conversion. Write auto node = std::make_shared<MyNode>(); instead. A shared_ptr you keep converts to Node::SharedPtr freely, and rclcpp::spin(…) accepts the temporary directly.

rclcpp::TimerBase is a NAME here, not a hierarchy

rclcpp::TimerBase::SharedPtr timer_; compiles unchanged, and you should keep writing it — it is the only spelling that works against real ROS 2 as well as against nano-ros, since upstream has no rclcpp::Timer.

What differs is behind the name. nano-ros has no timer hierarchy: TimerBase is an ALIAS for the one flat rclcpp::Timer, so is_same<TimerBase, Timer>::value is true and neither spelling carries a vtable. The executor dispatches through a raw function pointer, so a polymorphic base would be cost with no caller.

Consequences worth knowing:

  • Timer::SharedPtr is a small handle, not a std::shared_ptr. Its callback lives in the executor, so the handle does not own the timer. Dropping or overwriting timer_ leaves the timer firing; destroying the node stops it. timer_.reset() releases the timer for good. timer_->reset() restarts it, as upstream. A member declared as std::shared_ptr<rclcpp::TimerBase> does not compile: spell it rclcpp::TimerBase::SharedPtr.
  • The same holds for Subscription<M>::SharedPtr, Service<S>::SharedPtr and Client<S>::SharedPtr: dropping the handle leaves the entity running, and sub_.reset() / srv_.reset() / cli_.reset() releases it for every copy at once. A one-shot timer or subscription that resets ITSELF from its own callback works: the release takes effect when the callback returns.
  • rclcpp::WallTimer<...> and rclcpp::GenericTimer<...> do not exist. The clock axis is the second verb rclcpp::create_timer(node, clock, period, cb) plus a runtime field, not a type parameter.
  • DERIVING from rclcpp::TimerBase gets you a concrete handle with a non-virtual destructor. Upstream code almost never does this; if yours does, it needs rework. There is no timer hierarchy here: the executor dispatches through a raw function pointer, so a polymorphic base would be a vtable nothing calls, and TimerBase is a name that promises WallTimer and GenericTimer siblings we deliberately do not have. The clock axis is a runtime field plus the second verb rclcpp::create_timer, not a type parameter.

What’s documented as “needs adapt” (codegen-side, not surface-side)

These are cosmetic codegen differences nano-ros’s per-package codegen and the upstream rosidl_default_runtime codegen don’t share; both are tracked as ROS-convention codegen work.

  • Message string fields. nano-ros codegen emits rclcpp::FixedString<N>, upstream emits std::string. Assigning a std::string needs a one-token adapter: message.data = s.c_str(). The reverse (std::string{}.c_str()) is what RCLCPP_INFO already takes.
  • Generated message header path. CLOSED (alias headers): nano-ros codegen emits BOTH the per-message form <std_msgs/msg/string.hpp> (upstream-shape) AND the umbrella <std_msgs/std_msgs.hpp>. Use whichever the original source picks.

What’s out of scope (will need code adapt or a follow-up phase)

  • Yaml-loaded parameters. declare_parameter<T>("name", default) reads from a launch yaml in stock ROS 2. nano-ros has no runtime yaml loader — parameter initials are compile-baked from the launch XML’s <param name="…" value="…"/> entries (RFC-0004 §10), then live in a volatile store the standard parameter services can update until the next boot. Move yaml values into the launch file (or expose them as compile-time constants).
  • tf2, image_transport, pluginlib — out of nano-ros scope. Project- specific helpers (autoware universe_utils, PX4 uORB shims) are not nano-ros’s to ship; the porting user vendors them or replaces the call sites with raw nros-cpp ones.

When the port hits a gap

Known open follow-ups: yaml-loaded parameter baking, LifecycleNode compat, and the in-tree migration of legacy nros_generate_interfaces(<pkg>) call sites. If your port surfaces a new gap, file an issue — a fix lands either tree-side (in packages/api/nros-cpp/, cmake/NanoRosAmentSurface.cmake or cmake/find/) or as a codegen change.

In-tree regression fixtures:

Drop your reduced-case node under examples/templates/<your-port>/ and add it to CI once the gap closes.

ROS 2 Interop

nano-ros communicates with standard ROS 2 nodes using the rmw_zenoh_cpp middleware. Both sides connect to the same zenohd router and exchange CDR-encoded messages with compatible key expressions.

Prerequisites

  • ROS 2 installed. For the zenoh path use ROS 2 Jazzy: sudo apt install ros-jazzy-rmw-zenoh-cpp. Humble and Iron ship no rmw_zenoh_cpp apt package — on those, either build rmw_zenoh from source as a colcon overlay, or use the Cyclone DDS backend instead (direct rmw_cyclonedds_cpp interop, no router at all — see Choosing an RMW)
  • The zenoh router running: ros2 run rmw_zenoh_cpp rmw_zenohd

Quick Start

Open three terminals:

# Terminal 1: Start the zenoh router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd
# Terminal 2: Run the nano-ros talker
cd examples/linux/rust/talker
nros sync                            # generated/ bindings + build settings (once)
nros build
RUST_LOG=info ./build/linux/target/debug/talker
# Terminal 3: Run a ROS 2 listener
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

You should see the ROS 2 listener printing messages published by nano-ros:

data: 'Hello World: 1'
---
data: 'Hello World: 2'
---

The Other Direction

ROS 2 publishers also work with nano-ros subscribers:

# Terminal 2: Run the nano-ros listener instead
cd examples/linux/rust/listener
nros sync && nros build
RUST_LOG=info ./build/linux/target/debug/listener

# Terminal 3: ROS 2 talker
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'
ros2 topic pub /chatter std_msgs/msg/String "{data: 'Hello from ROS 2'}" --qos-reliability best_effort

Discovery

nano-ros publishes ROS 2-compatible liveliness tokens, so standard ROS 2 tools work:

source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'

ros2 topic list        # Shows /chatter
ros2 topic info /chatter   # Shows publisher/subscriber count
ros2 node list         # Shows nano-ros nodes

Configuration

Domain ID

Both sides must use the same ROS domain ID. On the native (host) build nano-ros reads ROS_DOMAIN_ID from the environment (default: 0). On embedded targets the domain ID is fixed at compile time — system.toml [system] domain_id (or an image’s own — on Zephyr the module renders it into CONFIG_NROS_DOMAIN_ID), or a hand-written config.toml on a direct-mode app (see Workflow):

ROS_DOMAIN_ID=42 ./build/linux/target/debug/talker   # nano-ros side
ROS_DOMAIN_ID=42 ros2 topic echo /chatter ...  # ROS 2 side

QoS

nano-ros defaults to BEST_EFFORT reliability and VOLATILE durability. When using ROS 2 subscribers, match the QoS:

ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Without --qos-reliability best_effort, ROS 2 defaults to RELIABLE, which won’t match the nano-ros publisher’s BEST_EFFORT QoS.

rmw_zenoh Client Mode

By default, rmw_zenoh_cpp uses peer mode and won’t connect to a zenohd router. Force client mode with:

export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'

Set this before any ros2 command.

Protocol Details

nano-ros uses the same wire format as rmw_zenoh_cpp:

  • Data key expression: <domain_id>/<topic>/<type>/TypeHashNotSupported (Humble). Example: 0/chatter/std_msgs::msg::dds_::String_/TypeHashNotSupported
  • Liveliness tokens: @ros2_lv/<domain>/<zid>/0/... for discovery
  • Message encoding: CDR little-endian with 4-byte encapsulation header [0x00, 0x01, 0x00, 0x00]
  • RMW attachment: 33-byte metadata (sequence number, timestamp, GID) appended via Zenoh attachment

ROS 2 Editions

Three editions are supported (RFC-0056): humble (default; TypeHashNotSupported key expressions, XCDR1), iron, and jazzy (both RIHS01 type hashes). Pick the edition matching the ROS 2 install you interop with — a Humble-edition image against a Jazzy bus (or vice versa) discovers nothing, because the key expressions differ.

The edition is declared like the RMW, per image, in system.toml: [image.<id>] ros_edition = "jazzy" (over [image_defaults], then [system] ros_edition). nros sync --image <id> generates the messages for that edition and nros build <id> builds the image; the ros-<edition> cargo features are what the build lowers it to, not something you pass. The native examples declare linux_jazzy, linux_xrce_jazzy, … for this:

cd examples/linux/rust/talker
nros sync --image linux_jazzy && nros build linux_jazzy

Troubleshooting

Topic not visible in ros2 topic list: Ensure both nano-ros and ROS 2 use the same domain ID and that rmw_zenoh_cpp is in client mode pointing to the same router.

Discovery works but no messages received: Check that the QoS matches. Use --qos-reliability best_effort on the ROS 2 side.

rmw_zenoh not connecting: Verify ZENOH_CONFIG_OVERRIDE is set. Without it, rmw_zenoh uses peer mode and won’t find the router.

Next Steps

Differences from standard ROS 2

Coming from rclcpp, rclrs, or rclc? This page calls out where nano-ros looks the same, where it diverges, and the reason behind each choice. It is an orientation page — the per-language API references (Rust / C / C++) cover the surface itself.

The short version: nano-ros keeps ROS 2’s vocabulary (Node, Publisher, Subscription, Service, Client, ActionServer, ActionClient, Timer, Executor, QoS, message codegen) so existing nodes port cleanly. The trade-offs that come from running on a Cortex-M3 with 64 KB of heap shape every place where the surface diverges.

Same vocabulary, same wire format

  • ROS 2 entity model unchanged. A node owns publishers, subscriptions, services, clients, action servers, action clients, timers, and parameters.
  • Topic / service / action names follow ROS 2 conventions (/talker_node/chatter, /add_two_ints, /fibonacci).
  • Message types stay rosidl-shaped (std_msgs/msg/Int32, geometry_msgs/msg/Twist, …). CDR encoding on the wire.
  • Default backend (nros-rmw-zenoh) is bit-compatible with the upstream rmw_zenoh ROS 2 RMW. A nano-ros publisher and an rclcpp subscriber on the same zenohd router exchange messages without a bridge — see ROS 2 Interoperability.

Where it diverges, and why

1. The executor is the entry point

Standard ROS 2 starts with a global init (rclcpp::init / rclrs::Context::default_from_env / rclc_support_init) followed by node creation, then optionally an executor.

nano-ros inverts this: an Executor opens the RMW session and owns the runtime budget. Every node, publisher, subscription, service, client, action handle, and timer is allocated out of the executor’s arena.

Executor::open(&config) → Node → Publisher / Subscription / Service / Client / Timer

Why. The arena is fixed-size and known at compile time (NROS_EXECUTOR_ARENA_SIZE / NROS_EXECUTOR_MAX_CBS). On a 64 KB heap MCU we cannot afford the indirection of a global allocator behind every create_publisher call. The executor-as-arena pattern moves the size negotiation up to the application’s startup code, where it belongs.

2. Both manual-poll and callback paths are first-class

rclcpp is callback-only — every subscription needs a callback, the executor dispatches them. rclrs <0.7 was manual-poll only. rclc exposes both but treats manual-poll as the second-class path.

nano-ros treats them as equals. A subscription created via node.create_subscription(...) exposes take(). A subscription registered via executor.node_mut(nid).create_subscription::<M, _>("/topic", callback) runs the callback during spin_once. Pick whichever fits the control loop.

Why. Embedded apps often want the predictability of an explicit poll inside a real-time loop. Callback dispatch is great for event-driven services but adds indirection that real-time engineers have to bound by hand. Offering both means the application picks.

3. Async pub/sub/service/action

rclcpp has no real async/await. rclrs 0.7+ added async but it sits next to the synchronous executor, not in it. rclc has none.

nano-ros has it as a first-class path: Executor::spin_async() drives the executor from inside a Future (it does not park on RMW I/O: each cycle is a 1 ms spin_once and an immediate self-wake, so it keeps a core busy — see its rustdoc), Subscription::recv().await, Client::call().await, ActionClient::send_goal().await, etc. Runs on tokio (POSIX), Embassy (FreeRTOS / RTIC), or any external Future-driver.

Why. Robotic control loops are stitched together from many concurrent waits — sensor data, service replies, action feedback, parameter updates. Async lets you write them as one straight-line function instead of hand-rolling state machines on top of a poll-based executor.

4. Heap is optional

ROS 2 RMW implementations all assume std + a heap. Even rclrs’s no_std story is “soon”.

nano-ros runs in three modes that map onto target capability:

ModeCargo featuresWhat works
stdstd (default on POSIX)Everything. POSIX threading, full async runtime.
no_std + allocalloc + a #[global_allocator]Everything except features that need std::sync::Mutex. Used by FreeRTOS / NuttX / ThreadX / Zephyr / ESP32.
no_std cooperativeno std/alloc platform threading (bare-metal board crates, RTIC apps)Cooperative single-task — no threading at all. Used by bare-metal MPS2-AN385, single-core RTIC.

Why. Heap presence is not a binary “embedded yes/no” — it is a spectrum. Stm32-class boards have a heap; Cortex-M0+-class might not. The feature axis lets the same application code target both.

5. Backend selection at compile time, not runtime

Standard ROS 2 uses an RMW_IMPLEMENTATION env var read at process start. The plugin loader pulls a shared library, dispatches calls through C function pointers.

nano-ros bakes the backend in at compile time. The consuming Cargo.toml adds the backend crate directly (nros-rmw-zenoh / nros-rmw-cyclonedds / nros-rmw-xrce-cffi) alongside nros with the rmw-cffi feature; CMake options (-DNROS_RMW=zenoh) decide it for C/C++ builds. The backend’s #[ctor] registers its vtable with the nros-rmw-cffi runtime registry before main.

Why.

  • Dead-code elimination. A 32 KB Flash budget cannot afford to link every backend’s C client and pick at runtime. Linking only the selected backend cuts the binary substantially (the three backends differ by an order of magnitude in linked C footprint — measure your own image with size; no benchmarked figure exists).
  • No plugin loader. Most embedded targets have no dlopen. The cost of the plugin abstraction is a permanent overhead with no payoff there.
  • Cross-compile sanity. RMW_IMPLEMENTATION baked into the binary means the build system already knows which backend’s C client to link — no separate “find shared library at runtime” step.

The trade-off is real: changing backends requires a rebuild. This is the right trade-off for the embedded use case; it would be the wrong trade-off for desktop ROS 2.

A binary can still link multiple backends and run them in parallel — one node on backend A, another on backend B, the executor draining both each tick. The Cross-backend Bridges chapter walks the build knobs (NROS_RMW=... primary selector, explicit per-backend register(), NROS_RMW= none cmake escape hatch) and the three shipped examples.

6. Message codegen lands inside your build, not a sibling library

Standard ROS 2 uses ament + rosidl to compile message packages (std_msgs, geometry_msgs, …) into separate shared libraries that your application links against.

nano-ros’s nros generate-rust (Rust) and nano_ros_generate_interfaces() (C / C++ via CMake — one of five entry points; see the spelling table) write message type definitions into your build tree. No _msgs library, no ament overlay, no colcon workspace required.

Why. Embedded cross-builds without a hosted ROS 2 install need to generate message types from package.xml + .msg files alone. The codegen tool ships its own bundled rosidl-flavoured .msg set inside the nros CLI (built in-tree from packages/cli/, Phase 218), so you don’t even need the upstream message packages on disk.

7. QoS profile is the full DDS field set; backends advertise per-policy support

Standard ROS 2 supports the full DDS QoS profile family (reliability, durability, history, depth, deadline, lifespan, liveliness, liveliness_lease_duration, avoid_ros_namespace_conventions) and performs profile matching between endpoints.

nano-ros’s rmw_qos_profile_t carries the same field set; standard profile constants (NROS_RMW_QOS_PROFILE_DEFAULT, _SENSOR_DATA, _SERVICES_DEFAULT, _PARAMETERS, _SYSTEM_DEFAULT) match upstream rmw_qos_profile_* field-for-field. ROS 2 apps porting across pull the equivalent constant unchanged.

Each backend advertises which policies it can enforce via Session::supported_qos_policies(). The runtime validates the requested QoS at entity-create time and returns IncompatibleQos synchronously when the backend can’t honour a requested policy:

#![allow(unused)]
fn main() {
if session.supported_qos_policies().contains(QoSPolicyMask::DEADLINE) {
    // backend honours deadline; safe to set deadline_ms
} else {
    // app handles deadline monitoring itself
}
}

No silent downgrade. The runtime never quietly drops a requested policy. Apps either get the QoS they asked for or a hard error.

Why upstream-shaped struct, not a smaller subset. ROS 2 QoS is the established vocabulary; mismatched APIs make porting painful. The field set is small (24 bytes); apps that don’t request a policy leave its field at zero (“off”). Per-backend implementation is a separate question — which policies actually fire — answered by the support mask.

Why synchronous error instead of runtime event. Upstream’s RMW_EVENT_REQUESTED_INCOMPATIBLE_QOS event surfaces mismatches at run time. Most QoS mismatches are configuration errors visible at startup; the runtime path doesn’t need to handle them. The few that aren’t (cross-process QoS-mismatched discovery) the wire protocol handles itself — DDS endpoints negotiate via DDS Discovery, zenoh endpoints communicate intent through the topic-key encoding.

Manual liveliness assertion. Publishers configured with MANUAL_BY_TOPIC / MANUAL_BY_NODE liveliness call assert_liveliness() explicitly to refresh the lease. Available on every language surface (Rust Publisher<M>::assert_liveliness(), C rcl_publisher_assert_liveliness(&pub), C++ pub.assert_liveliness()).

Which backend actually asserts — this paragraph said the opposite until the phase-467 RMW gap-closure study. It read “the zenoh backend wires it … Backends without manual-assertion wiring (xrce, cyclonedds) treat the call as a no-op”, and both halves were wrong. cyclonedds implements it (dds_assert_liveliness on the writer: renews the lease and sends a Heartbeat). zenoh-pico does not — its liveliness is a session-scoped keepalive, it has no per-topic lease a call can renew, and what assert_liveliness() refreshes there is a LOCAL watchdog that fires this publisher’s own LivelinessLost on expiry. That local behaviour is real and is kept; what changed is the return value. Under a manual kind, zenoh-pico, XRCE-DDS and uORB now answer UNSUPPORTED rather than Ok, because an Ok from a call that put nothing on the wire is the one thing a caller uses this function to rule out. AUTOMATIC and NONE answer Ok everywhere: there was nothing to assert.

See Status events for the runtime-event side of liveliness, deadline, and message-lost.

Per-backend coverage is documented in RMW vs upstream § 7.

8. No runtime backend swap, no runtime introspection

Standard ROS 2 ships ros2 topic list, ros2 node info, dynamic endpoints discovery, rmw_get_* introspection.

nano-ros has none of that at runtime. The backend is fixed at compile time, the wire-protocol introspection is whatever the backend natively exposes (zenoh-pico’s z_query for SPDP, Cyclone DDS’s SPDP/SEDP discovery, …). Use the host-side ROS 2 tools for introspection and connect via the rmw_zenoh interop path.

Why. Every byte of “introspect what’s running” is overhead a microcontroller can’t justify when a host-side ROS 2 environment is one router-hop away.

9. Parameters: node-local server, no descriptors, no callbacks (yet)

Standard ROS 2 (rclcpp::Node) ships a rich parameter surface: declare_parameter<T> with ParameterDescriptor (description, ranges, read-only, dynamic typing), set_parameter returning a SetParametersResult, atomic multi-set, three callback hooks (pre_set / on_set / post_set), parameter overrides from the launch file or CLI, and a service-backed remote-introspection surface (/<node>/get_parameters, /<node>/set_parameters, …).

nano-ros keeps the vocabulary (declare_parameter<T>, get_parameter<T>, set_parameter<T>, has_parameter) but trims the surface for embedded use. There is ONE store, and it is in Rust: the nros_params::ParameterServer the executor owns, which is also what the six rcl_interfaces/srv/* servers read. C++ reaches it through methods on rclcpp::Node; C through rclc’s own parameter-server API, <rclc_parameter/rclc_parameter.h> (rclc_parameter_server_t, one per node); Rust through Executor::declare_parameter and Node::parameter.

If you have older nano-ros code: rclcpp::ParameterServer<Cap> is gone (phase-426 W4), and so is the caller-storage store it wrapped as a thing to reach for. Both were node-LOCAL: the parameter services read a different object, so ros2 param list never saw what you declared. The C caller-storage family and the nros_executor_*_param* functions are gone too (phase-491): C uses rclc’s rclc_parameter_server_t, which declares into the same executor-owned store.

What we keep

  • Same five scalar types: bool, int64_t, double, string, plus the bool / int64_t / double / byte / string array variants on the C side (rclc_parameter_{declare,get,set}_*_array; string arrays not yet).
  • Same lifecycle: declare → get → set, with declare-once-then-typed-get semantics.
  • Optional service-backed exposure (~/get_parameters / ~/set_parameters / ~/list_parameters / …) when the param-services feature is enabled. This pulls in ROS 2 wire compat: declared parameters are visible to ros2 param list /<node> and ros2 param set. The services use the rcl_interfaces message bounds: a list_parameters reply carries at most 64 names and 64 prefixes, and when more match it is cut there and the node logs one warning (the reply message has no field to say so). A set_parameters request is answered per parameter, so a value or name past those bounds gets successful = false with a reason while the rest are applied.

What we drop, and why

Upstream featurenano-ros statusWhy dropped
ParameterDescriptor (description, ranges, read-only, dynamic_typing)not exposeddescriptor metadata is host-side concern; embedded server enforces type at declare-time, range checks belong in set callbacks (deferred — see below)
add_pre_set_parameters_callback / add_on_set_parameters_callback / add_post_set_parameters_callbackone rclc_parameter_callback_t per C server — rclc’s (old, new, context), fired before the write, false refusesthree callbacks → three indirection slots × N subscribers; one callback covers the safety-island validation use case (reject if out of range)
set_parameter returning SetParametersResult (successful: bool, reason: string)returns rcl_ret_tstring reason would force heap or fixed-buffer; ret code captures the binary outcome
set_parameters_atomicallynot exposedatomic multi-set requires transaction log; not justified by current embedded use
declare_parameters (multi-declare with namespace)C++ only, hosted only: rclcpp::Node::declare_parameters<T>(prefix, map)upstream’s argument is a std::map, so it needs an allocator; returns Result, not the std::vector<T> of values in effect (read back with get_parameter<T>). Rust and C declare one at a time
Parameter overrides from CLI / launch / yamlnot exposedembedded apps configure via Kconfig / Config struct; runtime overrides come over the wire via ~/set_parameters (when param-services is on)
Storage allocation policyone executor-owned table, NROS_MAX_PARAMETERS slots, keyed by nodeno per-node heap; capacity is a build knob, sized once like the executor arena rather than multiplied by the node count

Storage shape difference

rclcppnano-ros
Containerstd::map<string, ParameterValue> per node (heap)one fixed table on the executor, keyed by (node, name)
String valuestd::string (heap)fixed slot, copy semantics
Array paramsstd::vector<T> (heap)rclcpp::Seq<T, N> in, elements copied into the store’s own slot — nothing for the caller to keep alive
Total fixed costunboundedNROS_MAX_PARAMETERS slots, known at build time, shared by every node

Class shape difference

rclcpp::Node owns the parameter store; nano-ros’s node borrows one the EXECUTOR owns. The call shape is upstream’s:

// rclcpp
auto node = std::make_shared<rclcpp::Node>("ctrl");
node->declare_parameter<double>("ctrl_period", 0.15);
double v = node->get_parameter("ctrl_period").as_double();

// nano-ros
rclcpp::Node node;
RCLCPP_TRY(rclcpp::Node::create(node, "ctrl"));
double period = node.declare_parameter<double>("ctrl_period", 0.15);
double v = 0.0;
node.get_parameter<double>("ctrl_period", v);

// an array parameter, with no heap: `Seq<T, N>` is the value, the store
// owns the elements
rclcpp::Seq<double, 8> w = node.declare_parameter("mpc_weights",
                                                rclcpp::Seq<double, 8>{1.0, 2.0});

Why the executor owns it. Templating Node on a capacity would propagate through every create_publisher / create_subscription site, so the store cannot be a member; and a store per node is a store per node’s worth of fixed arena on a target that has one. One executor-owned table, keyed by node, is both — /talker’s rate is not /listener’s, and the arena is sized once (NROS_MAX_PARAMETERS). It is also the table the services answer from, which is the property the split shape could not have: the parameter you declare is the parameter ros2 param get returns.

Why no Box<dyn FnMut> callback yet. The same constraint that shapes the QoS event callbacks applies here: nano-ros’s #[no_std] core forbids alloc-style indirection. A future descriptor-+-validation-callback path will use a function pointer + void* user_context pair, registered at declare-time. Tracked under the upstream parity backlog.

Going further. When upstream’s full parameter surface matters — describe_parameter, ranges, three-stage validation callbacks, override files — fall back to running a host-side ROS 2 node that exposes them and uses the embedded node only as the leaf publisher / subscriber.

What this means in practice

If you are coming from rclcpp:

  • Open an Executor, then create the node from it.
  • Decide poll vs. callback per subscription, not globally.
  • If the platform has std, nros::init() looks identical; if it is RTOS / bare-metal, plan the executor arena up front.
  • Pick nros-rmw-zenoh for ROS 2 interop; everything else is a different trade-off.

If you are coming from rclrs:

  • The umbrella crate is nros, not split into rclrs_*. nros::prelude gives you everything.
  • Executor::open(&config) is the equivalent of Context::default_from_env() + Executor::new(...).
  • The async surface is on the executor + handles themselves (re-exported at the crate root). Compatible with tokio out of the box.

If you are coming from rclc:

  • The C entry points carry rclc’s own names (rclc_node_init_default, rclc_publisher_init_default, rclc_subscription_init_default), in rclc’s argument order and at rclc’s arity. Memory ownership rules are the same — the caller owns storage, the API initialises it.
  • See the C API reference for the full surface.

Going deeper

nano-ros vs micro-ROS

The closest peer project to nano-ros is micro-ROS. Both ship a full embedded ROS 2 framework — client library + RMW + tooling targeted at MCU-class hardware. This page is an apples-to-apples comparison.

Why not compare to rmw_zenoh or ros2_rust? Scope mismatch. rmw_zenoh is a single RMW backend; nano-ros is a full client stack. ros2_rust (rclrs) targets hosted Linux only — it can’t run on the bare-metal or RTOS targets nano-ros and micro-ROS both address. Comparing nano-ros to either would be misleading.

Side-by-side

Axisnano-rosmicro-ROS
Project homeNEWSLab NTUROS 2 ecosystem (community + Bosch + eProsima)
First release20242019
Primary languageRust (with C + C++ bindings)C (rclc)
User-facing APIsRust + C + C++C only (rclc); experimental C++ in rclcpp_lite
RMW backend choiceZenoh, XRCE-DDS, Cyclone DDS — pick at compile timeXRCE-DDS only
Network modelPeer-to-peer (Zenoh / Cyclone DDS) or agent-based (XRCE)Agent-based only
Bridge process required?No for Zenoh / Cyclone DDS; yes for XRCEYes (Micro-XRCE-DDS Agent)
Supported RTOSesFreeRTOS, NuttX, ThreadX, Zephyr, PX4 (NuttX), POSIX, bare-metal (incl. ESP32-C3 over esp-hal). No ESP-IDF — that port was retired in phase-468 W2FreeRTOS, NuttX, Zephyr, ESP-IDF, POSIX; PX4 is the canonical deployment
no_std coreYes — entire client stack compiles no_std + heaplessNo — rclc requires libc + a heap
Heap usageOptional on bare-metal (XRCE backend is fully static); required for Zenoh / Cyclone DDSRequired (malloc-based DDS-XRCE client)
RT scheduling storySchedContext classes (Fifo/Edf/Sporadic live in dispatch; TT deprecated-cooperating) + per-platform kernel capabilities — the generated Scheduling Wiring Matrix is the per-cell truthrclc executor with priority callbacks; no SchedContext / EDF / TT story
Multi-executor preemptionExecutor::open_threaded per-RTOS via PlatformScheduler traitSingle executor per process
Multi-backend bridge in one binaryYes — Executor::open_with_rmw + multi-NodeNo (single XRCE session per process)
DiscoveryZenoh liveliness, RTPS SPDP, XRCE-via-AgentXRCE-via-Agent
QoS supportBackend-dependent — per-policy table in RMW vs upstream §7Subset of XRCE QoS
Formal verification148 Kani harnesses + 83 Verus proofs (CDR, scheduling, RMW glue)None published
E2E safety protocolCRC-32/ISO-HDLC + sequence tracking, EN 50159-mapped (safety-e2e feature)None
ROS 2 distro coverageHumble, Iron, Jazzy ([image.<id>] ros_edition, lowered to ros-<edition> features, RFC-0056)Humble, Iron, Jazzy
Build systemCargo + CMake + platform tools (west, idf.py, probe-rs) plus just recipes; C/C++ consume via add_subdirectory(<repo>)colcon + CMake; per-RTOS meta-build (create/configure/build/flash_firmware.sh)
Deploy/config modelEntry packages select board/RMW/deploy shape; Bringup packages own launch topology; platform tools build and flashcolcon.meta (hand-tuned static sizing) + configure_firmware.sh -t <transport> flags + hand-coded rclc app
Host-side brokernone (Zenoh P2P / Cyclone DDS brokerless); Agent only for XRCEMicro-XRCE-DDS Agent always required
Release modelSource-only (no crates.io, no precompiled binaries)Source-only + per-distro Debian packages
Code-size (Cortex-M XRCE talker)~75 KB flash (XRCE), ~100 KB+ (Zenoh) — order-of-magnitude, not benchmarked~30–50 KB (XRCE + rclc, as commonly quoted upstream — not measured here)
LicenseMIT OR Apache-2.0 (dual)Apache-2.0
GovernanceSingle-academic-lab maintainership todayROS 2 community + corporate stewards
Commercial supportNone as of writingBosch + eProsima offer services

Pick nano-ros when…

  • You want Rust as a first-class API, not bolted on. Memory safety + ownership semantics extend to your application code.
  • You want multi-backend flexibility — same binary can speak Zenoh + DDS, or you want to pick Zenoh’s peer-to-peer model over XRCE’s agent dependency.
  • You need scheduling primitives beyond priority callbacks — EDF / Sporadic / ARINC-653 TT classes, per-callback runtime accounting, formal SchedContext API.
  • You’re targeting safety-aware deployments that benefit from the E2E CRC + sequence tracking and the Kani / Verus harness coverage.
  • You want a lean source-only consumption model: git clone + add_subdirectory(<repo>). No crates.io drift, no pre-built per-distro binaries.

Pick micro-ROS when…

  • You’re already in the micro-ROS ecosystem (existing rclc code, established Agent deployment, ROS 2 Jazzy / Iron support).
  • Your toolchain is C-only and you don’t want to introduce Rust into the build pipeline.
  • Your target’s flash budget is tight (~30 KB ceiling) — the rclc + XRCE client is smaller than nano-ros + Zenoh.
  • You need commercial support contracts today (Bosch, eProsima, PIWeb).

Migration sketch

If you’re porting from micro-ROS to nano-ros:

Several rows below are now identity on the C side: phase-417 took rcl/rclc’s spellings, so rclc_publisher_init_default and rclc_executor_t are what nano-ros calls them too. The migration is a rename only where the shape differs.

  • rcl_node_init → Executor::create_node (Rust) / rclc_node_init_default (C — same name as micro-ROS, but the ARGUMENTS are rclc’s order (node, name, namespace, support)).
  • rclc_executor_t → nros::Executor (Rust) / rclcpp::Executor (C++); unchanged in C.
  • rclc_publisher_init_default → Node::create_publisher::<M> (Rust) or the identically-spelled rclc_publisher_init_default (C).
  • micro-ROS’s rmw_uros_set_custom_transport → nano-ros’s custom transport pattern via nros_platform_* C ABI (see Custom Transport).
  • XRCE Agent deployment stays the same — point nano-ros’s XRCE backend at the same Agent.

See also

Migration Guide for ROS 2 Users

This page maps standard ROS 2 concepts to their nano-ros equivalents. It is not an API reference; use it as a checklist when moving an existing rclcpp, rclc, or rclrs node toward nano-ros.

Concept map

You have (ROS 2)You get (nano-ros)Where
colcon workspace (src/*)nano-ros workspace: Node pkgs + one declarative Bringup pkg, whose system.toml carries one [image.<id>] row per deploy target. The binary is generated, not writtenProject layout
package.xmlkept, verbatim. What it deploys to is not in it — that is the bringup’s system.tomlAnatomy
.launch.xml / .launch.pykept — nros sync resolves them (RFC-0060) into a SystemModel the image bakes inBringup packages
ros2 launch pkg filenros build <image>, then run its binary — the launch product is compiled inImages
colcon buildnros build <image> — it generates the root and hands off to cmake / cargo / west / idf.py. Running those yourself still works when you point them at the generated build root, and colcon build still works for POSIX C++ workspacesC/C++ workspaces
rosdep installnros setup <board> --rmw <rmw>Install
RMW_IMPLEMENTATION=… at runtimecompile-time backend, one line: [system] rmw = "…" in system.toml, then nros syncSwitching RMW
generated msg packages (ament index)nros sync writes generated/ crates; the cargo patch table goes in the generated settings under build/, not in your packageMessage Generation
find_package(rclcpp)find_package(nano_ros) — ament-shaped, source-backedPorting a C++ node
ament_target_dependencies(t std_msgs)kept, verbatim (nano-ros’s ament verbs + find modules)same page

Setup

Standard ROS 2 usually starts from a distro install and a runtime RMW choice. nano-ros starts from a source checkout + a compile-time target tuple:

git clone --branch=v<X.Y.Z> https://github.com/NEWSLabNTU/nano-ros.git
cd nano-ros
./scripts/bootstrap.sh              # build the nros CLI (no `just` needed)
source ./activate.sh
nros setup linux --rmw zenoh       # native/ROS/zenoh quick start

Read Setup Compared to Standard ROS 2 before changing package code.

Node Lifecycle

ROS 2 applications typically call rclcpp::init(), create nodes, then spin. nano-ros opens an executor first; the executor owns the session, arena, and runtime budget. Nodes and entities are created from it.

See Differences from Standard ROS 2 and Execution Model and Two-Layer API.

Publishers and Subscriptions

Topic names, message names, and CDR wire encoding stay ROS-shaped. The main porting decision is whether to use:

  • polling handles from Node::create_*, or
  • callback registration through Executor::register_*.

Use polling for RTIC, Embassy, or tight RT loops. Use callbacks for desktop-style event-driven nodes.

Services and Actions

Service and action names map cleanly, but nano-ros exposes request / reply and goal / feedback / result paths through explicit handles and promises. Manual-poll paths may require explicit result handling in RTOS loops.

Start with the native examples, then check platform-specific examples for FreeRTOS, Zephyr, or bare-metal timing constraints.

QoS and Events

nano-ros keeps DDS-shaped QoS profile fields, but each backend advertises the policies it can enforce. Unsupported QoS is reported at entity creation instead of being silently downgraded.

See QoS, Status Events, and Discovery and Choosing an RMW Backend.

Message Generation

Standard ROS 2 builds generated message libraries as sibling packages. nano-ros generates Rust, C, or C++ bindings into the workspace or build tree and can use a shared generation cache.

See Message Binding Generation.

Backend Selection

Replace runtime RMW_IMPLEMENTATION=... with a compile-time selection:

  • zenoh for ROS 2 interop through rmw_zenoh_cpp (default).
  • xrce for agent-based micro-ROS-style deployments.
  • cyclonedds for direct DDS/RTPS interop with rmw_cyclonedds_cpp — no router or agent process at all — where the platform supports the required networking and memory model.

Common Porting Traps

  • Assuming the backend can change without rebuilding.
  • Creating heap-heavy callbacks on no_std targets without alloc.
  • Expecting ROS 2 graph introspection APIs on constrained targets.
  • Forgetting to match ROS domain ID, QoS reliability, or zenoh router mode during interop tests.
  • Treating a platform guide as optional when cross-compiling for RTOS or bare-metal targets.

Architecture Overview

nano-ros is a ROS 2 client stack for embedded and RTOS targets. The important architectural idea is separation by responsibility: user code talks to a ROS-shaped API, the core runtime owns entities and serialization, the RMW layer moves bytes, and the platform layer supplies OS or hardware primitives.

Layers

Application
  Rust / C / C++ node code

Board package (optional)
  Hardware init, network drivers, config loading, entry point

Core runtime
  Executor, Node, pub/sub/service/action handles, parameters,
  message traits, CDR serialization

RMW backend
  Zenoh, XRCE-DDS, Cyclone DDS, or a custom backend

Platform
  Clock, allocation, threading, sleep, random, sockets, libc,
  and bare-metal network polling

POSIX applications usually depend directly on nros. Embedded applications often depend on a board package that initializes hardware, networking, and platform glue before running user code.

Core Runtime

The core runtime is middleware-agnostic. nros-node owns the Executor, node creation, entity handles, timers, and the two API styles:

  • Node::create_* returns handles that the caller polls or awaits.
  • Executor::register_* installs callbacks dispatched by spin_once.

Message and service types are ordinary generated Rust, C, or C++ types with CDR serialization. The RMW backend receives serialized bytes; it does not own rosidl typesupport.

For the API split, see Execution Model and Two-Layer API.

RMW Layer

The RMW layer is the transport boundary. It creates sessions, publishers, subscribers, services, clients, and action channels, then moves serialized samples over the selected wire protocol.

Each Node picks its backend at build time. That compile-time selection replaces standard ROS 2’s runtime RMW_IMPLEMENTATION plugin loader, which is not available on many embedded targets. A single binary can link multiple backends and bridge between them — see Cross-backend Bridges.

For user-facing backend selection, see Choosing an RMW Backend. For design rationale, see RMW API Design.

Platform Layer

The platform layer supplies the primitives that desktop ROS 2 normally gets from the operating system: monotonic time, memory allocation, threading, sleep, random IDs, TCP/UDP sockets, multicast, and libc helpers. Bare-metal ports may also expose a network_poll() hook so the runtime can advance smoltcp while waiting.

The platform is selected at compile time together with the RMW backend and ROS edition. See Platform Model for the feature axes and Custom Platform for porting.

Board Packages

A board package combines a platform implementation with hardware setup and drivers. It typically provides:

  • a Config type loaded from config.toml or target-specific build settings,
  • network and clock initialization,
  • driver setup for Ethernet, UART, WiFi, or simulator I/O,
  • a run() entry point that starts the scheduler or main loop.

Board packages are optional for POSIX but useful for RTOS and bare-metal targets. See Custom Board Package.

Data Flow

Publishing follows this path:

user message -> CDR serializer -> RMW publish bytes -> transport

Receiving reverses it:

transport -> RMW receive bytes -> CDR deserializer -> user handle/callback

This boundary keeps the transport layer small and lets the same message types work across Rust, C, and C++ APIs.

Where to Go Next

Two-Layer API

nano-ros exposes the same communication primitives — subscriptions, publishers, services, service clients, action servers, action clients, timers — through two layers with disjoint verbs. Picking between them is a per-entity deployment choice. The two layers share the same session and compose freely.

Layer 1 — caller pollsLayer 2 — executor dispatches
VerbNode::create_polling_* (receive side), Node::create_publisher / create_clientExecutor::register_*, or rclrs’s Node::create_subscription(topic, callback) / create_service(name, callback)
ReturnsOwned handleHandle ID + dispatched closure
Receive shapetake() / call() → Promise<T> / try_accept_goal(...)Closure runs on rx / reply / timer fire
SchedulerCaller (RTIC, embassy, app loop)Executor::spin_once
Typical useRTIC tasks, single-task FreeRTOS, RT-bounded loopsCallback-shaped apps, multi-handler servers

Rationale

Embedded callers want both. RTIC owns scheduling and refuses to hand its loop to a library; an embassy task wants to .await a Promise; a FreeRTOS task-per-entity port wants tight take polling. None of those fit the L2 callback model.

But a posix app that handles a dozen topics + a couple of services + an action server wants L2 — write one closure per entity and let spin_once() route events. Forcing it through L1 means writing a manual dispatcher.

The two layers are deliberately separate verb sets so the choice is explicit in source:

  • node.create_polling_subscription::<M>("/topic") — caller polls. No executor magic.
  • executor.register_subscription::<M, _>("/topic", |m| { ... }), or rclrs’s spelling node.create_subscription::<M, _>("/topic", |m| { ... }) — executor dispatches.

phase-483 W2 gave the polled receive-side constructors their create_polling_* names: on nros::Node, rclrs’s create_subscription and create_service take a callback, and a polled form under the same name would compile a ported line into a different contract.

The C / C++ FFI mirrors this split: rclc_polling_subscription_init + rclc_polling_subscription_take_serialized (L1, the type rclc_polling_subscription_t, inline storage — the caller takes from the entity) vs rclc_subscription_init_default + rclc_executor_add_subscription_* (L2, executor arena, callback supplied at registration).

What goes where

graph LR
    EX["Executor::open(&config)"] --> NODE["executor.create_node(name)"]

    subgraph "L2 — register_*"
      direction LR
      EX --> R_T["register_timer"]
      EX --> R_S["register_subscription"]
      EX --> R_SVC["register_service"]
      EX --> R_AS["register_action_server"]
      EX --> R_AC["register_action_client"]
    end

    subgraph "L1 — create_*"
      direction LR
      NODE --> C_P["create_publisher"]
      NODE --> C_S["create_polling_subscription"]
      NODE --> C_SS["create_polling_service"]
      NODE --> C_SC["create_client"]
      NODE --> C_AS["create_action_server"]
      NODE --> C_AC["create_action_client"]
    end

Both layers are wired up by the same call to Executor::open — the register_* family attaches to the executor’s arena, create_* borrows the executor’s session through a short-lived Node. The handles returned by create_* outlive the Node (owned, not borrowed), so the canonical pattern is to scope the Node to creation time only:

let mut executor = Executor::open(&config)?;
let publisher = {
    let mut node = executor.create_node("talker")?;
    node.create_publisher::<Int32>("/chatter")?
};   // node drops here; publisher outlives it

Picking the right layer

Use L1 (Node::create_*) when:

  • The caller has its own scheduling primitive (RTIC dispatcher, embassy task, FreeRTOS task per entity).
  • The flow is fundamentally request-response — service-client Promise<Reply> and action-client send_goal → get_result work this way.
  • The receive logic isn’t simple enough to express as a one-shot closure — e.g. a Fibonacci server that publishes feedback in a loop after accepting a goal.
  • You want no executor arena overhead (the L2 path stores the closure + its captures inline in the arena; the arena byte budget is per-build-tunable but non-zero).

Use L2 (Executor::register_*) when:

  • The flow is event-driven with a simple handler — log every received message, reply to a service synchronously, fire a 1 Hz publish.
  • You want spin_once to dispatch all handlers from one site (less state to thread through main).
  • The application is callback-shaped (matches rclcpp / rclpy mental models).

Service clients and action clients stay on L1 in the current code — they have no typed register_*_client<T> API (only register_*_raw byte-level). The typed user path is Node::create_client::<S> + client.call(&req) → Promise<Reply>. The request-response + Promise::await model is the L1 shape.

C / C++ mirror

Both layers cross the FFI cleanly:

  • C, L1 polling — rclc_polling_subscription_init writes a RawSubscription inline in rclc_polling_subscription_t._opaque, and rclc_polling_subscription_take_serialized reads from the inline buffer. Like C++, C splits the two layers by TYPE (phase-487): a dispatched rcl_subscription_t has no inline storage at all. Services and clients have the same split: rclc_polling_service_t / rclc_polling_client_t with rclc_polling_service_take_request_raw / rclc_polling_client_take_response_raw, beside the dispatched rcl_service_t / rcl_client_t. Action servers and clients keep both roads in one type for now (phase-487 W4).
  • C, L2 callback — rclc_subscription_init_default creates the entity; the callback is supplied at registration (rclc_executor_add_subscription_with_context — rclc’s six arguments — for the typed path, rclc_executor_add_subscription_raw for the byte path), which also allocates the executor-arena entry. The arena entry owns the subscriber, the callback and its context; the struct keeps the arena handle and the description it was initialised with.
  • C++ — typed templates wrap each FFI surface: rclcpp::PollSubscription<M> + take, rclcpp::PollService<S> + take_request and rclcpp::PollClient<S> + send_request for L1, rclcpp::PollingActionServer<A> for the L1 action path (122.3.d.b), the L2 executor-registered callback model via the existing rclcpp::ActionServer<A> API. The L1/L2 split is a TYPE split in C++ since phase-456: one class serving both owners offered the L1 method set on an L2 object, where the storage it reads is never filled.

For the per-FFI-function spec, see the Doxygen reference. For the example migration tally (32 examples on L2, 16 intentionally L1), see the unify-api-paths roadmap doc.

Event-driven path (122.3.c.6.e)

L1 polling assumes the caller has a spin loop. RTOS / embassy applications want the kernel to wake them when data lands. The Raw*::register_waker(&Waker) Rust API and the matching *_set_wake_callback(state, cb, ctx) C / C++ FFI plug a wake source into the same L1 handle. RMW backends that support waking (currently zenoh-pico for subscribers, service-clients, and service-servers) fire the callback from their rx path; other backends fall back to polling silently.

For the underlying primitive (ServiceTrait::register_waker in nros-rmw), see the trait doc. For the C wake-callback POD shape (rclc_wake_state_t { fn_ptr, ctx }), see the Doxygen reference — same canonical-spec rule as the platform API.

Platform Model

nano-ros configures the library for a specific target through compile-time choices on three axes. nros itself carries one generic rmw-cffi runtime registry; the consuming Cargo.toml adds the chosen backend shim crate directly (one per RMW).

The Three Axes

RMW Backend

The RMW backend determines which middleware transport carries messages. nros exposes a single rmw-cffi feature — the generic C-vtable runtime registry. The consuming crate adds the concrete backend crate as a direct dependency; its #[ctor] registers a vtable with that registry before main.

Backend crateTransportDescription
nros-rmw-zenohzenoh-picoPeer-to-peer via Zenoh protocol; default, ROS-2-interop.
nros-rmw-xrce-cffiMicro-XRCE-DDS-ClientAgent-based via DDS-XRCE protocol.
nros-rmw-cycloneddsCyclone DDSC++ shim; standalone CMake project.

Unlike the platform axis, RMW is not mutually exclusive — Phase 104 lets one binary register multiple backends (bridge nodes). See Choosing an RMW Backend.

Platform (pick one)

The platform feature selects which OS primitives (threading, mutexes, clock, sleep, network) are linked. Each platform crate provides the symbols that the RMW backend’s C library requires at link time.

FeatureTargetThreadingNetwork
platform-posixLinux (host board), NuttXpthreadsBSD sockets
platform-zephyrZephyr RTOSk_thread_createZephyr sockets
platform-bare-metalCortex-M, RISC-V, ESP32Single-threadedsmoltcp
platform-freertosFreeRTOSxTaskCreatelwIP
platform-nuttxNuttX RTOSpthreadsBSD sockets
platform-threadxAzure RTOS / ThreadXtx_thread_createNetX Duo

ROS Edition (pick one)

The ROS edition controls wire-format compatibility with specific ROS 2 releases.

FeatureDescription
ros-humbleHumble Hawksbill wire format (no type hash)
ros-ironIron Irwini wire format (type hash in key expression)

Cross-Cutting Features

These features are orthogonal to the three axes above and can be combined freely.

FeatureDescription
stdEnables std-dependent APIs: spin(), spin_period(), system clock
allocEnables heap-dependent APIs: boxed callbacks, the parameter store heap fallback
safety-e2eCRC-32 integrity + sequence tracking (AUTOSAR E2E / EN 50159)
param-servicesROS 2 parameter service handlers (~/get_parameters, etc.). Uses alloc only for its fallback (issue 1812).
ffi-syncWraps transport FFI calls in critical_section::with() for RTOS reentrancy
sync-spinUse spin-lock mutex (default)
sync-critical-sectionUse critical-section mutex (for RTIC, Embassy)
unstable-zenoh-apiZero-copy receive path (Zenoh backend only)

Mutual Exclusivity Enforcement

The platform and ROS-edition axes are mutually exclusive — nros enforces this at compile time with compile_error!():

#[cfg(all(feature = "platform-posix", feature = "platform-zephyr"))]
compile_error!("Platform features are mutually exclusive — select at most one.");

#[cfg(all(feature = "ros-humble", feature = "ros-iron"))]
compile_error!("`ros-humble` and `ros-iron` are mutually exclusive.");

The RMW axis is not enforced this way — it is the consuming crate’s choice of which nros-rmw-* dependency to add (one, or several for bridge nodes).

Boards vs Platforms

Above the platform axis, board crates select a concrete chip + RTOS combination on top of one of the platform features. A board owns the linker layout, the vendor HAL wiring, the boot path, and any chip-specific peripherals (Ethernet PHY, IVC mailboxes, RTC). It does not carry its own platform axis — it pulls in one of the rows above.

Board crateUnderlying platformCPUNotes
nros-board-mps2-an385bare-metalCortex-M3QEMU MPS2-AN385 + LAN9118 + smoltcp
nros-board-esp32-qemubare-metalRISC-V (ESP32-C3)QEMU ESP32 + OpenETH + smoltcp
nros-board-orin-spefreertosCortex-R5FNVIDIA Jetson AGX Orin SPE + FreeRTOS-FSP

nros-board-orin-spe is the canonical example of the board-over-RTOS pattern: a Cortex-R5F SPE running NVIDIA’s FreeRTOS V10.4.3 FSP, with critical-section provided by the canonical nros_platform_critical_section_{acquire,release} C symbols (Cortex-R CPSR I-bit body inside the port) — not by a per-CPU Rust feature flag. See docs/roadmap/phase-100-orin-spe-infra.md for the wiring.

Example Configurations

Each config lists nros (with rmw-cffi + one platform-*) plus the chosen backend crate. POSIX additionally needs nros-platform-cffi with posix-c-port for a pure-cargo build.

QEMU bare-metal Cortex-M3 with Zenoh:

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi", "platform-bare-metal", "ros-humble",
] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal"] }

Zephyr with XRCE-DDS and safety features:

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi", "platform-zephyr", "ros-humble", "alloc", "safety-e2e", "ffi-sync",
] }
nros-rmw-xrce-cffi = { path = "…/nros-rmw-xrce-cffi", features = ["platform-zephyr"] }

Desktop Linux for development and testing:

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi", "platform-posix", "ros-humble", "std", "param-services",
] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-posix", "link-tcp", "ros-humble"] }
nros-platform-cffi = { path = "…/nros-platform-cffi", features = ["posix-c-port"] }

FreeRTOS with lwIP networking:

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi", "platform-freertos", "ros-humble",
] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-freertos"] }

NuttX with XRCE-DDS over serial:

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi", "platform-nuttx", "ros-humble",
] }
nros-rmw-xrce-cffi = { path = "…/nros-rmw-xrce-cffi", features = ["platform-nuttx", "xrce-serial"] }

Phase 208 decoupled nros from concrete backends — nros carries no RMW crate dependency. Instead:

  • RMW. The consuming crate’s nros-rmw-* dep ships a #[ctor] that calls nros_rmw_<name>_register() at lib load (POSIX .init_array) or the caller invokes it from main (bare-metal). The backend installs a nros_rmw_vtable_t into nros-rmw-cffi’s named registry. Executor::open resolves the registered backend; the concrete session type is always CffiSession (vtable-backed).
  • Platform. nros’s platform-* feature resolves to nros-platform-cffi. The canonical nros_platform_* C symbols ship from packages/platform/nros-platform-<plat>/src/*.c, linked at the consumer’s build site — posix-c-port for pure-cargo POSIX builds, the standalone lib<…>.a for CMake builds, or the kernel build system for NuttX / Zephyr.
  • Alias TUs. C translation units forward each transport C library’s z_* / uxr_* platform calls to the canonical nros_platform_* symbols: zpico-sys/c/zpico/platform_aliases.c (default-on platform-aliases feature) for zenoh-pico, and nros-rmw-xrce/src/platform_aliases.c (always compiled into nros-rmw-xrce-cffi) for XRCE-DDS. (These replaced the former zpico-platform-shim / xrce-platform-shim crates, deleted in Phase 129.)

The default feature set is std only. No RMW backend or platform is selected by default — the consuming crate chooses explicitly.

For implementation details on how to add a new platform, see the Porting Guide and Platform API Reference.

Board Integration

How you consume nano-ros depends on what your project’s build system looks like, not on what RTOS you’re targeting. This page maps user profile → recommended path. Pick your row, follow the linked guide.

The architecture behind the matrix lives in docs/design/0012-board-bsp-integration-architecture.md (layered model, vendor-BSP scaling, plan).

Consumption matrix

ProfileWorkflowRecommended path
Cargo-first Rust user (have RTOS sources)cargo build --target <triple> produces a single ELF. RTOS kernel built in-tree by Cargo.Generic board crate + env vars pointing at FREERTOS_DIR / THREADX_DIR / etc.
Vendor-IDE user (STM32CubeIDE, MCUXpresso IDE, etc.)Vendor’s existing FreeRTOS / ThreadX project; nano-ros as an add_subdirectory() library.add_subdirectory(third_party/nano-ros) from the vendor’s project.
Zephyr user (any board)west + DTS overlays. Vendor HALs come as Zephyr modules.Zephyr integration shell — projects: entry in your west.yml.
ESP32-C3 usercargo build --target riscv32imc-unknown-none-elf, bare-metal over esp-hal.ESP32 (esp-hal). The in-tree ESP-IDF component shell was retired in phase-468 W2; an out-of-tree idf.py integration is still possible against the unchanged C API, but nano-ros ships no shell for it.
NuttX user (any board)NuttX apps/external/ + Kconfig.NuttX integration shell — symlink under apps/external/.
PX4 userPX4 build pipeline.PX4 integration shell — EXTERNAL_MODULES_LOCATION.
Niche RTOS / vendor forkStock RTOS kernel + vendor driver SDK. Cargo-driven build.Generic board crate + vendor overlay crate (~50 LOC). See the Vendor Overlay cookbook.

Generic board crate

For Cargo-first users targeting one of the four supported kernel families:

KernelCrateSDK env vars you set
FreeRTOS + lwIPnros-board-freertosFREERTOS_DIR, FREERTOS_PORT, LWIP_DIR, FREERTOS_CONFIG_DIR
ThreadX + NetX-Duonros-board-threadxTHREADX_DIR, THREADX_CONFIG_DIR, NETX_DIR, NETX_CONFIG_DIR
NuttXnros-board-nuttxNUTTX_DIR (kernel built by NuttX itself)
bare-metal Cortex-M + smoltcp(no family crate — copy nros-board-mps2-an385)BOARD_LINKER_SCRIPT_DIR
# user_app/Cargo.toml — registry-style names resolved by the
# `nros sync`-managed [patch.crates-io] table (nano-ros is source-only;
# nothing is on crates.io):
[dependencies]
nros-board-freertos = { version = "*", default-features = false }
nros = { version = "*", default-features = false, features = ["rmw-cffi", "platform-freertos", "ros-humble"] }
nros-rmw-zenoh = { version = "*", default-features = false, features = ["platform-freertos"] }
std_msgs = { path = "generated/std_msgs", default-features = false }
#![allow(unused)]
fn main() {
// user_app/src/main.rs — the macro form; it expands to
// `nros_board_freertos::run_entry::<Board, _, _>(...)` dispatch.
#![no_std]
#![no_main]
nros::main!(spin = "forever");

// Manual form, for full control:
// nros_board_freertos::run_entry::<Mps2An385, _, _>(
//     Config::default(), None, |runtime| { /* register nodes */ Ok(()) })
}
export FREERTOS_DIR=$HOME/sdk/freertos/kernel
export FREERTOS_PORT=GCC/ARM_CM3
export LWIP_DIR=$HOME/sdk/freertos/lwip
cargo build --release --target thumbv7m-none-eabi

The generic crate’s build.rs compiles the kernel + network stack

  • nano-ros platform glue into a single ELF. No vendor driver glue — that’s what overlay crates are for.

Vendor overlay crate

When your board needs vendor-specific drivers (STM HAL, NXP fsl_*, NVIDIA FSP, Renesas Synergy SSP, …) on top of one of the generic crates, write a small (~50 LOC) overlay crate that:

  1. Depends on the matching generic board crate.
  2. Defines a board marker type and implements the nros_platform board traits on it (BoardInit::init_hardware() and siblings); re-exports Config.
  3. Provides strong definitions of the generic crate’s weak C network hooks (nros_board_register_netif, nros_board_poll_netif).
  4. Pulls vendor HAL .c sources via its own build.rs cc-rs invocation.

The full cookbook + the working precedent (nros-board-mps2-an385-freertos) live in Vendor Overlay Board Crate.

Why so many paths

Per the architecture doc, each RTOS already has its own package manager (Zephyr’s west + DTS, ESP-IDF’s component registry, NuttX’s apps/external/, PX4’s EXTERNAL_MODULES_LOCATION). nano-ros rides those rails instead of trying to re-invent a single “embedded library” mechanism that fits no vendor ecosystem cleanly.

The Cargo-first profile (with optional vendor overlay crates) covers the gap where a user is NOT inside an existing RTOS-IDE project and wants to drive their build from Cargo end-to-end. That’s the two-and-a-half rows at the top + bottom of the matrix.

Not on the matrix

  • No common driver HAL. STM HAL_*, NXP fsl_*, Espressif esp_*, Renesas R_* all stay vendor-owned. Overlay crates wrap them; nano-ros doesn’t abstract over them.
  • No DTS-equivalent for non-Zephyr platforms. Zephyr owns its board contract; everywhere else, board config is whatever your vendor IDE produces (CubeMX .ioc, NuttX defconfig, ESP-IDF sdkconfig).
  • No board crate per SKU. Generic + overlay covers the long tail. If you have an exotic board with custom HAL, write a ~50 LOC overlay; nano-ros doesn’t catalog every vendor SKU.

std and alloc Requirements

This document maps which parts of the nano-ros API require the std or alloc Cargo features. All core crates are #![no_std] by default and gate std/alloc-dependent code behind feature flags.

Feature Hierarchy

std  (default)
 └─ alloc
     └─ (base no_std)

Enabling std automatically enables alloc. Enabling alloc does not enable std. With --no-default-features, the entire library compiles without std or alloc.

Summary by Crate

Crateno_std basealloc additionsstd additions
nros-serdesCDR ser/de for primitives, heapless types, &str, [T; N]String and Vec<T> ser/de(none)
nros-coreTime, Duration, Clock (atomic fallback), lifecycle, logger, error types, action types(none)Clock::now() via SystemTime, std::error::Error impls
nros-rmwAll traits, QoS, sync primitives, safety/E2E protocolhandle_request_boxed() (Box<Reply>)(none)
nros-paramsParameterServer, ParameterValue, all parameter types (heapless)(none)ParameterVariant impls for std::string::String, std::vec::Vec
nros-nodeExecutor::open(), create_node(), spin_once(), spin_async(), Promise, pub/sub/service/action, timers (fn pointer callbacks)Boxed timer callbacks, handle_request_boxed(), parameter servicesspin(), spin_period(), ExecutorConfig::from_env(), halt flag
nrosRe-exports from above(same as above)SpinPeriodResult re-export

RMW Backend Crates

Crateno_std basealloc additionsstd additions
nros-rmw-zenohZenoh-pico RMW implementation, all pub/sub/service/action ops(none)(none)
zpico-sysFFI bindings to zenoh-pico C library(none)(none)
nros-rmw-xrceXRCE-DDS RMW implementation, all pub/sub/service/action ops(none)(none)
xrce-sysFFI bindings to Micro-XRCE-DDS-Client C library(none)(none)

All four backend crates are unconditionally #![no_std] and do not use alloc. The std feature gates only extern crate std (for macro availability in transport modules) and is propagated through the feature chain but does not add any API surface.

Detailed API Availability

Always Available (no_std, no alloc)

Executor and Node:

  • Executor::open(&config) – open RMW session
  • ExecutorConfig::new(locator) – manual configuration
  • executor.create_node(name) – create a node
  • executor.spin_once(timeout_ms) – single spin iteration
  • executor.spin_period(period) – periodic spin without std::thread::sleep

Two-layer API. Phase 122 unified the verb discipline:

  • Layer 1 (caller polls) – Node::create_* returns an owned handle. Caller drives take / call / try_accept_goal / take_request_raw itself. Good for RTIC, Embassy, task-per-entity FreeRTOS.
  • Layer 2 (executor dispatches) – Executor::register_* takes a closure; spin_once fires it on rx / reply / timer. Good for callback-shaped applications.

Both layers share the same session; mix per entity.

Publish/Subscribe:

  • L1 — node.create_publisher::<M>(topic), node.create_subscription::<M>(topic) (poll with take())
  • L2 — executor.register_timer(period, || publisher.publish(...)), executor.register_subscription::<M, _>(topic, |msg| { ... })
  • publisher.publish(&msg) / publish_raw(&bytes) — publish messages

Services:

  • L1 — node.create_service::<S>(name) (poll with handle_request()), node.create_client::<S>(name) + client.call(&request) → Promise<Reply> (poll with promise.take() or .await).
  • L2 — executor.register_service::<S, _>(name, |req| reply). Service clients keep the L1 Promise shape; the typed callback API isn’t surfaced (only register_service_client_raw exists for byte-level use).

Actions:

  • L1 — node.create_action_server::<A>(name) + try_accept_goal / complete_goal, or node.create_action_client::<A>(name) + send_goal → Promise<GoalId> / get_result → Promise<(GoalStatus, Result)>.
  • L2 — executor.register_action_server::<A, _, _>(name, goal_cb, cancel_cb) returns a handle for publishing feedback and completing goals. Action clients keep the L1 Promise shape for the same reason as service clients.

Async:

  • executor.spin_async() – async spin loop (drives I/O, dispatches callbacks, yields between iterations)
  • Promise<'a, T, Cli> – allocation-free promise, borrows client’s reply slot
  • Promise::take() – non-blocking poll for reply
  • Promise: Future – implements core::future::Future for .await
  • Uses only core::future and core::task – no external async runtime dependency

Timers:

  • TimerHandle with function pointer callbacks (fn())
  • TimerDuration, TimerMode, TimerState

Serialization:

  • CdrWriter / CdrReader – CDR serialization to/from byte buffers
  • Serialize / Deserialize traits
  • Implementations for: bool, u8-u64, i8-i64, f32, f64, char, &str, [T; N], heapless::String<N>, heapless::Vec<T, N>

Time:

  • Time::new(), Time::from_nanos(), Time::to_nanos()
  • Time::from_secs_f64(), Time::to_secs_f64()
  • Duration::new(), Duration::from_nanos(), Duration::to_nanos()
  • Duration::from_secs_f64(), Duration::to_secs_f64()
  • Clock with atomic counter fallback (no wall-clock time)

Parameters (local only):

  • ParameterServer – store and retrieve parameters
  • ParameterValue enum with heapless collections
  • ParameterDescriptor, ParameterType, Parameter
  • ParameterBuilder for declaring parameters with constraints

Other:

  • LifecycleState, LifecycleTransition, LifecyclePollingNode
  • Logger (uses core::sync::atomic)
  • GoalId, GoalStatus, GoalResponse, CancelResponse, CancelReturnCode
  • QoSProfile, TopicInfo, ServiceInfo
  • SafetyValidator, IntegrityStatus (with safety-e2e feature)
  • Sync primitives: spin::Mutex or critical-section (feature-selected)

Requires alloc

APILocationWhy
Serialize/Deserialize for String, Vec<T>nros-serdesHeap-allocated containers
TimerCallback (Box<dyn FnMut() + Send>)nros-node/timer.rsBoxed closure for timer callbacks
Timer::new_with_box(), set_callback_box()nros-node/timer.rsConstruct/update boxed timer callbacks
EmbeddedServiceServer::handle_request_boxed()nros-node/handles.rsReturns Box<Reply> for large response types
param-services heap fallbacknros-node/executor/spin.rsStore + service state when the backing carves none
Argv parameter overrides (-p, --params-file)nros-node/ros_args.rsOwned override strings from the hosted edge

Parameter services detail: since issue 1812 the param-services feature does not need alloc. The six handlers stream requests and replies, and the parameter store, the service state, the shared request/reply buffers and the slot a remote set decodes its value into are all carved from the executor backing whenever the build knows the image builds a store (NROS_PARAM_STORE=1, params: in the contract, or param_services in the bringup; capacity NROS_MAX_PARAMETERS). Without alloc, a build that carves no store is refused at compile time and the message names NROS_PARAM_STORE. With alloc, such a build falls back to the heap as before. The core ParameterServer API works without alloc.

Requires std

APILocationWhy
Clock::now() (system/steady clock)nros-core/clock.rsUses std::time::SystemTime / UNIX_EPOCH
std::error::Error for NanoRosError, RclReturnCodenros-core/error.rsTrait requires std
ExecutorConfig::from_env()nros-node/types.rsUses std::env::var() + Box::leak()
Executor::spin(options)nros-node/spin.rsUses std::thread::sleep(), Arc<AtomicBool>
Executor::spin_period(duration)nros-node/spin.rsUses std::time::Instant, std::thread::sleep()
Executor::halt_flag()nros-node/spin.rsReturns Arc<AtomicBool> for cross-thread cancellation
SpinPeriodResultnros-node/types.rsContains std::time::Duration
ParameterVariant for std::string::String, std::vec::Vecnros-params/types.rsConvenience conversions for std types

Typical Configurations

Phase 208 decoupled the nros umbrella from concrete RMW crates. A consuming Cargo.toml lists three path deps: nros (with rmw-cffi + a platform-* feature), the chosen backend crate (nros-rmw-zenoh / nros-rmw-xrce-cffi), and — on POSIX — nros-platform-cffi with posix-c-port so the C nros_platform_* symbols link into a pure-cargo build. The backend crate’s #[ctor] registers its vtable before main.

Bare-metal / RTOS (no allocator):

nros = { path = "…/nros", default-features = false, features = ["rmw-cffi", "platform-bare-metal"] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal"] }

Full pub/sub, services, actions, timers (fn pointers), parameters (local). Async: spin_async(), Promise, take(), .await – all available without std or alloc. Use spin_once() or spin_period() in your main loop, or spin_async() with an async runtime (Embassy, RTIC v2).

Embedded with allocator (e.g., Zephyr with heap):

nros = { path = "…/nros", default-features = false, features = ["alloc", "rmw-cffi", "platform-zephyr"] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-zephyr"] }

Adds boxed timer callbacks and handle_request_boxed() for large service replies.

Desktop / Linux:

nros = { path = "…/nros", default-features = false, features = ["std", "rmw-cffi", "platform-posix"] }
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-posix", "link-tcp", "ros-humble"] }
nros-platform-cffi = { path = "…/nros-platform-cffi", features = ["posix-c-port"] }

Full API including spin(), spin_period(), from_env(), system clock. For async, use an external runtime (tokio current_thread + spawn_local for background spin).

Desktop with parameter services: add param-services to the nros feature list above. Adds ~/get_parameters, ~/set_parameters, etc. for ros2 param CLI interop.

C-Level Allocation

Both RMW backends compile and link C libraries that perform heap allocation independently of Rust’s alloc feature. Disabling the Rust alloc feature eliminates Rust-side heap usage (Box, Vec, String) but does not eliminate allocation in the C transport layer.

Backend crateC LibraryAllocator by Platform
nros-rmw-zenohzenoh-pico 1.7.2POSIX: malloc; Zephyr: k_malloc; FreeRTOS: pvPortMalloc; bare-metal: custom bump allocator
nros-rmw-xrce-cffiMicro-XRCE-DDS 3.0.1POSIX: malloc; Zephyr: k_malloc; FreeRTOS: pvPortMalloc; NuttX: malloc

This is by design: the C libraries manage their own session state, stream buffers, and protocol metadata using platform-provided allocators. The Rust alloc feature controls only the Rust API surface (boxed callbacks, heap containers, etc.) and is orthogonal to C-level memory management.

Checking your own image

Because of the row above, a feature list is evidence that an image is heap-free and not proof of it: a vendored C dependency or a weak-symbol fallback can link an allocator without any Cargo feature changing. Ask the linker instead:

scripts/check-no-alloc-image.py path/to/your.elf

It reads the image’s symbol table and fails on any allocation symbol — libc’s malloc family, Rust’s __rust_alloc family, C++ operator new, and the RTOS heaps (k_malloc, pvPortMalloc, tx_byte_allocate, kmm_malloc, heap_caps_malloc) — reporting for each whether the image calls it or contains it. --tier unified takes --objects instead and enforces the weaker rule that allocation may happen only inside the nros_platform_* backend (RFC-0034 D6).

None of the examples shipped in this repo are built this way today — they all enable alloc — so run it against your own image, not against one of ours.

RTOS Cooperation

nano-ros runs on platforms that span pure cooperative bare-metal through multi-task preemptive RTOS to fully async / Future-driven runtimes. The executor’s spin loop has to cooperate with each of these without imposing a single execution model on all of them.

This page maps the common RTOS / runtime execution profiles to the configuration knobs the executor exposes. New apps pick a profile that matches their target’s scheduling discipline; the knobs translate that choice into bounded behaviour from drive_io.

The execution model spectrum

ModelDescriptionExample targets
Cooperative single-taskOne task / thread does all ROS work. No preemption from other tasks (there are none, or they’re all lower priority). Yielding happens at task boundaries.Bare-metal MPS2-AN385, single FreeRTOS task, single Zephyr thread
Preemptive priorityROS task runs at a fixed priority. Higher-priority tasks preempt mid-call by the kernel. ROS-internal entities (subs, timers, services, GCs) all share that priority — they don’t preempt each other.Typical FreeRTOS / ThreadX / Zephyr deployment with worker tasks
WCET-bounded real-timeEach “task” has a provable worst-case execution time. Tasks are dispatched directly from interrupts; no spin loops in the hot path.RTIC, Embassy, avionics WCET-validated code
Time-triggered cyclicFixed schedule. Each cycle does a fixed amount of work in a fixed time slot; ROS gets a fraction of the cycle and must yield.DO-178C / IEC 61508 controller frames
Async runtimeFutures registered with wakers; reactor drives. No spin loop visible to user code.tokio, Embassy futures, custom async runtimes

How drive_io behaves by default

The executor’s spin_once(timeout_ms) calls session.drive_io(...) and lets it drain all ready I/O before returning. After drive_io returns, the executor processes any expired timers and triggered guard conditions. If 10 messages arrived during the wait, all 10 callbacks fire in a single spin_once, then timer / GC dispatch happens once afterwards.

This is the right default for cooperative single-task apps and for async-runtime apps using spin_async. Both want throughput; neither benefits from per-callback scheduling opportunities.

For the other three models, the default has trade-offs the configuration knobs address.

Configuration knobs

The knobs live on SpinOptions (passed to Executor::spin(opts)), not on ExecutorConfig — the config struct carries identity/transport (locator, domain, node name, clock), while scheduling shape is a property of each spin call:

KnobDefaultWhen to change
SpinOptions::max_callbacks(n)None (drain all ready work)Set to 1 for upstream-rclcpp-style “one callback per iteration” — gives the executor a chance to re-check timers / GCs / yield between callbacks
SpinOptions::timeout_ms(ms)None (spin forever)Bound one spin call by wall clock — the time-triggered pattern calls spin once per cycle with the cycle’s ROS slot as the timeout
SpinOptions::spin_once() / only_nextoffOne round of work then return — the cooperative-loop building block

Backends opt into one additional behaviour automatically: Session::next_deadline_ms() tells the executor about the backend’s next internal event (lease keepalive, heartbeat). The executor caps drive_io’s timeout against it. No app configuration; transparent optimization.

Cooperative single-task

#![allow(unused)]
fn main() {
// One dedicated task; drain everything each round.
executor.spin(SpinOptions::default())?;
}

Drain everything; one task, no fairness concern.

#![allow(unused)]
fn main() {
loop {
    executor.spin(SpinOptions::new().max_callbacks(1))?;
}
}

max_callbacks(1) matches upstream’s rclcpp single-threaded executor pattern. Each spin_once fires one callback and then re-checks timers + GCs. ROS-internal entities share the task priority, but the spin-loop iteration is the scheduling unit; timer expiries are bounded by one callback’s WCET, not the sum across all ready callbacks.

If max-callback dispatch latency is still too high in this profile (e.g., a single callback is slow), the remaining bound is that one callback’s WCET — split the slow callback, or move it to its own tier/task (see Scheduling Models).

WCET-bounded real-time (RTIC / Embassy)

Don’t use the spin loop. Use the async path — a buffered subscription handle whose recv() is an async fn with waker integration:

#![allow(unused)]
fn main() {
let mut sub = node.create_polling_subscription::<MyMsg>("/topic")?;
loop {
    let msg = sub.recv().await?;        // suspends; waker integration
    handle(msg);
}
}

The async path doesn’t go through drive_io at all. Subscriptions register a Waker; the backend’s RX path wakes the waker; the async runtime schedules the receiving task. Per-task WCET analysis applies to each recv().await continuation, not to a spin loop.

Time-triggered cyclic

#![allow(unused)]
fn main() {
// Called once per cycle from the cyclic frame:
executor.spin(SpinOptions::new().timeout_ms(5))?;   // 5 ms ROS slot
}

The cycle gives ROS a fixed wall-clock slot; the timeout_ms bound returns control when the slot expires regardless of pending work. Pending work resumes next cycle. (There is no finer-grained per-callback budget knob today — if a single callback can overrun the slot, that callback’s WCET is your design constraint.)

Async runtime

drive_io not used in the hot path. The executor’s spin_async drives futures via wakers; drive_io becomes a polling tick internally with negligible overhead.

#![allow(unused)]
fn main() {
executor.spin_async().await
}

No knobs apply.

Trade-offs at a glance

ConfigurationThroughputPer-callback latencyTimer-callback fairnessCode-size cost
drain-all (default)HighBounded by ALL ready callbacks’ total WCETPoor under loadSmallest
max_callbacks(1)Slightly lower (more spin loop iterations)Bounded by ONE callback’s WCETGoodSame — the cap is just an integer
timeout_ms(N) per cycleLowerBounded by N ms wall clock + one callback’s WCETGood if N tightOne clock read per iteration
async / spin_asyncPer-futurePer-future Future pollCooperative — futures yield voluntarilyAsync runtime cost

Backends and their wait primitives

drive_io’s sleep behaviour is backend- and platform-dependent. The spin loop’s “where does the thread sleep” question maps as:

PlatformSleep primitive in drive_ioWhen CPU is sleeping
POSIXselect / epoll_wait with deadlineInside drive_io
Zephyrk_poll / condvar with deadlineInside drive_io
FreeRTOSxSemaphoreTake(g_spin_sem, ticks)Inside drive_io
NuttXsem_timedwait with absolute deadlineInside drive_io
ThreadXtx_event_flags_get(..., TX_OR, ..., ticks)Inside drive_io
Bare-metal smoltcp + BoardIdlesmoltcp poll + wfi() between iterationsOutside drive_io (in the spin loop’s idle hook)
Bare-metal smoltcp without BoardIdlesmoltcp poll, busy loopNowhere — CPU spins

In all cases the user-visible API is Executor::spin_once(timeout); the platform-correct sleep happens transparently underneath.

See also

Porting Overview

nano-ros is designed for customization at three independent levels: RMW (transport protocol), Platform (OS or RTOS), and Board (hardware). The core crates define stable interfaces through Rust traits; your implementation lives in your own crates. Be aware of the honest boundary: registering a new platform or an in-catalog board still touches a handful of files inside the nano-ros checkout (the registration steps below), so a vendored tree carries that small diff — plan a fork-with-rebase workflow against the nros-v<X.Y.Z> tags.

Which chapter do I need?

I want to…Chapter
Add a new transport protocol (MQTT, DDS, custom)Custom RMW Backend
Port to a new RTOS or bare-metal targetCustom Platform
Bring up nano-ros on a new MCU boardCustom Board Package

Most porting work falls into the second or third category. Adding a new RMW backend is rare and substantially more involved.

Quickstart — 2 crates + 2 tomls (RFC-0049)

Scaffold the two packages, fill in the TODOs:

nros new platform myrtos                       # nros-platform-myrtos/ + nros-platform.toml
nros new board myboard --for-platform myrtos   # nros-board-myboard/  + nros-board.toml
  • nros-platform.toml (platform package) — software-stack facts ([capabilities]), knob defaults ([knobs.*], only flip what you measured), and the zenoh-pico system-layer build block ([build.zenoh]). An empty file is valid: built-in defaults always produce a working build.
  • nros-board.toml (board package) — the RFC-0042 hardware descriptor plus per-board [capabilities]/[knobs] deltas.
  • Values resolve through a fixed ladder — builtin < platform < board < env/Kconfig/-D — and an explicit build-time setting (including an explicit 0/n) always wins. Debug any knob with:
nros config explain --platform myrtos [--board-toml path/to/nros-board.toml]
# knob                     value      set by
# zenoh.tx.batch           false      builtin

Kconfig appears only where the host framework is Kconfig-native (the Zephyr and NuttX packagings) — a hand-wired fragment whose defaults mirror the platform toml (drift-tested). A port to a non-Kconfig RTOS never touches it.

What stays untouched

The following core packages define the interfaces you implement. They compile and work without modification for any new target.

PackageRole
nrosFacade crate: re-exports and feature-axis enforcement
nros-coreMessage, service, and action type traits
nros-serdesCDR serialization
nros-nodeExecutor, Node, pub/sub/service/action handles
nros-rmwRMW trait definitions (Session, Publisher, Subscriber, etc.)
nros-platformPlatform trait definitions and ConcretePlatform type alias
zpico-sys alias TUMaps zenoh-pico z_* C symbols to nros_platform_* (default-on platform-aliases)
nros-rmw-xrce alias TUMaps XRCE-DDS uxr_* C symbols to nros_platform_*
nros-c, nros-cppC and C++ API wrappers

These define the interfaces. You implement them; you do not modify them.

The customization boundary

Everything in nano-ros sits on one side of a trait boundary defined in nros-platform/src/traits.rs.

Above the boundary (yours to write): board crates, platform crates, peripheral drivers, and application code.

Below the boundary (fixed): RMW backends, shim crates, core library, executor, and serialization.

Your platform crate implements traits as inherent methods on a zero-sized type. The shim crates automatically forward RMW-layer C symbols to your implementation through the ConcretePlatform type alias – no dynamic dispatch, no generics propagation.

Platform trait requirements by RMW backend

Not every trait is required. The set depends on which RMW backend the application uses.

Traitzenoh-picoXRCE-DDS
PlatformClockRequiredRequired
PlatformAllocRequired (~64 KB heap)Not needed
PlatformSleepRequiredNot needed
PlatformRandomRequiredNot needed
PlatformTimeRequiredNot needed
PlatformThreadingRequired (multi-threaded platforms)Not needed
PlatformTcpRequiredNot needed
PlatformUdpRequiredNot needed
PlatformSocketHelpersRequiredNot needed
PlatformNetworkPollBare-metal onlyNot needed
PlatformUdpMulticastDesktop platforms onlyNot needed
PlatformLibcBare-metal onlyNot needed

XRCE-DDS is significantly simpler to port: it is single-threaded, allocates only when a session or entity is created (nothing per message), and uses user-provided transport callbacks rather than a full socket API. A minimal XRCE-DDS port requires only PlatformClock and four C function pointers (open, close, read, write).

zenoh-pico requires a complete platform implementation but provides richer functionality (peer-to-peer, scouting, zero-copy receive, actions).

Registration

After implementing the required traits, you register your platform with two changes:

  1. Add a platform-<name> feature to nros-platform/Cargo.toml that pulls in your crate as an optional dependency.
  2. Add a ConcretePlatform type alias in nros-platform/src/resolve.rs gated by that feature.

The shim crates pick up the new platform automatically. No changes to RMW backends or core crates are needed.

Existing platform implementations

These serve as reference when writing a new port.

Platform crateTargetThreadingNetworking
nros-platform-posixLinux, *BSDpthreadslibc BSD sockets
nros-platform-freertosFreeRTOSFreeRTOS taskslwIP
nros-platform-nuttxNuttXpthreadsPOSIX sockets
nros-platform-threadxThreadXThreadX threadsNetX Duo
nros-platform-zephyrZephyrZephyr POSIXZephyr sockets
nros-platform-mps2-an385Cortex-M3 bare-metalSingle-threadedsmoltcp
nros-platform-stm32f4STM32F4 bare-metalSingle-threadedsmoltcp
nros-platform-esp32-qemuESP32-C3 (QEMU) bare-metalSingle-threadedsmoltcp + OpenETH

Further reading

The Board Trait Family

The Board trait family is the porting surface for a new MCU or host target. It lives in packages/platform/nros-platform/src/board/ and pins the contract every board crate (nros-board-<board> or a user-authored crate in a downstream Entry pkg) implements. Earlier prototypes used nros-board-common::board_init::*; those legacy traits stay as a transition shim while the in-tree boards finish migrating.

New board crates: implement nros-platform::board, not the legacy shim. The porting surface for new code is the Board family described on this page (Board = BoardInit + BoardPrint + BoardExit, driven by BoardEntry::run). The nros-board-common::board_init trait family is transition-legacy — kept only so not-yet-migrated in-tree boards keep building — and must not be implemented in new board crates. Convergence onto the single nros-platform::board family is tracked in issue 0243, sequenced with the RTIC/Embassy integration work.

A board impl tells nano-ros four things: how to initialize hardware, how to print a line of text, how to terminate, and (optionally) how to bring a transport up and gate on the network. With those four pieces the BoardEntry::run driver owns the boot lifecycle, and a user Entry pkg main.rs is a ~30 LoC shim.

Where the trait family sits

nros-platform::board
│
├── Board: BoardInit + BoardPrint + BoardExit     // super-trait (blanket impl)
│
│
└── BoardEntry: Board
        fn run<F, E>(setup: F) -> Result<(), E>
        where F: FnOnce(&mut RuntimeCtx) -> Result<(), E>;
  • BoardInit::init_hardware() — clock tree, pin mux, peripheral wakes. Runs once on boot before allocation. Panicking here is the same as panicking from fn main() — no recovery.
  • BoardPrint::println(args: core::fmt::Arguments<'_>) — emit a line. Boards wrap whatever stdout makes sense: cortex_m_semihosting::hprintln!, a vendor printf bridge, a UART writer, libc::write(STDOUT_FILENO, …), or printk.
  • BoardExit::{exit_success, exit_failure}() -> ! — terminate cleanly (or with failure). QEMU boards call cortex_m_semihosting::debug::exit; real hardware resets or halts; POSIX shells std::process::exit.
  • BoardEntry::run(setup) — the boot driver. Implementations live in the family driver crates (nros-board-{posix,freertos,threadx,…}); user Entry pkg main.rs calls it.

Board is itself a blanket-implemented super-trait: any type that carries BoardInit + BoardPrint + BoardExit automatically satisfies Board. Concrete board crates do not impl Board directly — they impl the three sub-traits (plus whichever mixins they need).

The BoardEntry::run lifecycle

BoardEntry::run owns the full boot → user-closure → exit flow. The exact body lives in the family driver crate (e.g. nros-board-linux, nros-board-freertos); each family folds its RTOS specifics in, but the order is fixed:

  1. BoardInit::init_hardware() — clocks, pinmux, MMIO setup.
  2. Device bring-up — the family crate’s job, inside run. Bring the link layer to L2 (Ethernet frames flow / WiFi associated / UART open at baud), then gate on carrier / DHCP if the board has an IP stack. There is no mixin trait for this: see the note below.
  3. Open the executor, build a RuntimeCtx with overlay knobs from the launch file / CLI, and invoke setup(&mut runtime). The codegen-emitted run_plan(runtime) body is what setup ultimately calls.
  4. Spin the executor to completion (or termination signal).
  5. BoardExit::exit_success() on Ok, BoardExit::exit_failure() on Err or any failed init step.

run returns Result<(), E> rather than ! so unit tests can drive it in a hosted process without exit() killing the test harness — but production boards still call exit_* from inside run’s body after spin returns, so in practice the caller’s Ok(()) arm is unreachable on a real target.

The setup callback is the only place user code runs inside run. Everything else is family-crate boilerplate.

Why there is no TransportBringup / NetworkWait mixin

Earlier revisions of this page documented two mixin traits and a family-crate blanket impl that would call them:

impl<B: Board + TransportBringup + NetworkWait> BoardEntry for B { fn run … }

That could not be built, and the traits were removed in phase-206 W4 (issue 1067). Two reasons, both structural:

  • The blanket impl overlaps the direct BoardEntry impls that twelve board crates already carry. Rust’s coherence rules do not allow both.
  • “Skipped if the board doesn’t impl the mixin” is not expressible — Rust has no way to call a method only when the type happens to implement a trait. The order was written as if specialization existed.

Measured before removal: TransportBringup had zero implementations and zero call sites; NetworkWait had one implementation and no callers, because the one place that would have called it — the nros::main! Zephyr arm — routed around it deliberately (ZephyrBoard::wait_link_up calls static inline Zephyr headers with no link symbol, so the native_sim link failed).

Devices are still brought up: inside BoardEntry::run, or the family helper it delegates to. That is the contract, and it is the one that runs.

RuntimeCtx

RuntimeCtx<'a> is the per-invocation overlay context the setup callback receives:

#![allow(unused)]
fn main() {
pub struct RuntimeCtx<'a> {
    pub params:  &'a [(&'a str, &'a str)],   // <param name=… value=…/>, projected at build time
    pub remaps:  &'a [(&'a str, &'a str)],   // topic/service/action renames
    pub env:     &'a [(&'a str, &'a str)],   // env-style key/value (rarely set on embedded)
}
}

Slice-of-tuples, no_std-safe, no allocation. Codegen owns the storage and passes a &mut RuntimeCtx<'_> whose backing slices live in statics — RuntimeCtx::EMPTY is a const placeholder for launch-less single-node examples or unit tests.

Picking your transport mixins

What you implement on the transport axis depends on what link layers your board exposes:

Board transport classImplementNotes
Ethernet (smoltcp / lwIP / NetX BSD)driver up, then DHCP/link gateboth, in run
WiFi (ESP32)same shape — association is L2, DHCP is L3both, in run
Serial UART onlyopen at baudno IP, so no link gate
CAN / USB CDC / IVClink layer onlyno IP
Bridged-net (threadx-linux veth)host kernel owns IPprobe the bridge in run
Native (host)NoneHost OS owns everything; the family crate’s run skips both mixins

Boards with multiple transports compose via an internal helper (e.g. a MultiTransport newtype) rather than blanket impls — each transport’s bringup is sequential and order-sensitive (init_link before link_up, sockets only after link).

Worked example — porting a new board

Suppose you’re adding nros-board-acme-cortex-m4-eth, a Cortex-M4 with a UART for println and an MII-attached PHY routed through smoltcp. The crate sits at packages/boards/nros-board-acme-cortex-m4-eth/ and depends on nros-platform, the family crate (nros-board-freertos if FreeRTOS is the RTOS), the matching packages/drivers/<phy>-smoltcp MAC driver, and a vendor HAL crate.

// packages/boards/nros-board-acme-cortex-m4-eth/src/lib.rs
#![no_std]

use nros_platform::board::{
    BoardEntry, BoardExit, BoardInit, BoardPrint,
    NetworkError, TransportError, RuntimeCtx,
};

pub struct AcmeCortexM4Eth;

impl BoardInit for AcmeCortexM4Eth {
    fn init_hardware() {
        acme_hal::clocks::init_hse_192mhz();
        acme_hal::pinmux::route_uart2();
        acme_hal::pinmux::route_eth_mii();
        acme_hal::eth::release_phy_reset();
    }
}

impl BoardPrint for AcmeCortexM4Eth {
    fn println(args: core::fmt::Arguments<'_>) {
        // 256-byte stack staging buffer is enough for our log lines;
        // pick whatever your UART driver wants.
        let mut buf = heapless::String::<256>::new();
        let _ = core::fmt::write(&mut buf, args);
        let _ = buf.push('\n');
        acme_hal::uart2::write_bytes(buf.as_bytes());
    }
}

impl BoardExit for AcmeCortexM4Eth {
    fn exit_success() -> ! { acme_hal::system::reset() }
    fn exit_failure() -> ! { acme_hal::system::halt_with_blinkenlight() }
}

// Device bring-up lives in the BoardEntry::run body — usually by delegating
// to the family helper, which is where the ORDER for that RTOS is fixed:
impl BoardEntry for AcmeCortexM4Eth {
    fn run<F, E>(setup: F) -> Result<(), E>
    where
        F: FnOnce(&mut RuntimeCtx<'_>) -> Result<(), E>,
        E: core::fmt::Debug,
    {
        let cfg = Config::default();          // MAC / IP / netmask / gateway
        nros_board_freertos::run_entry::<Self, F, E>(cfg, setup)
    }
}

That’s the whole board crate. A downstream Entry pkg consumes it as:

// pkgs/robot_acme_entry/src/main.rs
use nros_board_acme_cortex_m4_eth::AcmeCortexM4Eth;
use nros_platform::board::BoardEntry;

include!(concat!(env!("OUT_DIR"), "/run_plan.rs"));   // codegen-emitted

fn main() {
    let _ = <AcmeCortexM4Eth as BoardEntry>::run(|runtime| {
        run_plan(runtime)
    });
}

See the Role reference for the Entry-pkg surface.

Family driver crates

The family crate is where the BoardEntry::run body actually lives. The kernel families with a driver crate:

  • nros-board-linux — native host; the reach is linux, not posix — apply_tier_affinity calls sched_setaffinity with cpu_set_t, which libc does not define for macOS. init_transport/wait_link_up no-ops.
  • nros-board-freertos — FreeRTOS-Kernel + lwIP; run spawns the executor task, hands DHCP to lwIP’s hook.
  • nros-board-threadx — ThreadX + NetX BSD; same shape over NetX.
  • nros-board-nuttx — NuttX POSIX layer; init_transport shells ifup-style logic.
  • nros-board-zephyr — carve-out: Kconfig + DTS own BSP; the crate exposes an inherent wait_link_up over <zephyr/net/net_if.h>. The Rust staticlib cannot take over main on Zephyr.
  • Direct-exec (Cortex-M / RV32, no RTOS) has no family crate: each board implements BoardEntry::run itself with a single-thread zp_read loop. A nros-board-bare-metal family driver was written for this shape and no board ever opted into it — 135 of its 161 lines were doc comment — so a cleanup deleted it. nros-board-mps2-an385 is the worked reference.

Current state: the trait surface lives in nros-platform; family driver crates and per-board shims are migrating onto it. Some in-tree boards under packages/boards/nros-board-* still ride the legacy nros-board-common::board_init::* traits — same conceptual shape, different module path.

Cross-references

  • Workspace shape + how an Entry pkg consumes a board → Role reference.
  • Multi-node composition root → docs/design/0024-multi-node-workspace-layout.md.
  • Why the C ABI looks the way it does → Canonical Platform C ABI.
  • Platform trait set vs Board trait set — these are different traits with different roles. Platform* (clock / alloc / sockets / threading) sits below the RMW; Board* sits above the platform and owns the boot lifecycle. A bare-metal board crate typically depends on both: a nros-platform-* impl for the platform traits and a nros-board-* impl for the board traits.

Custom Board Package

A board crate is the top-level, batteries-included package that application code depends on. It provides three things:

  1. Config struct – network settings (IP, MAC, gateway), transport selection (Ethernet, serial, or WiFi via Cargo features), and a zenoh locator string.
  2. run() entry point – initializes hardware, starts the network stack, and calls a user-provided closure with &Config.
  3. Hardware initialization – PHY/MAC driver setup, interrupt configuration, clock init, and force-linking of platform shim crates.

Users never interact with platform crates or driver crates directly. They depend on a single board crate, call run(), and get a working environment for creating an Executor.

use nros_my_board::{Config, run};
use nros::prelude::*;

run(Config::default(), |config| {
    let exec_config = ExecutorConfig::new(config.zenoh_locator)
        .domain_id(config.domain_id);
    let mut executor = Executor::open(&exec_config)?;
    let mut node = executor.create_node("my_node")?;
    // publishers, subscriptions, services, actions, timers...
    Ok(())
})

Board = platform + hardware

A board crate combines two layers:

  • Platform crate (nros-platform-<os>) – generic RTOS or bare-metal primitives: clock, sleep, threading, memory, random number generation. Implements the traits defined in nros-platform. See Custom Platform for how to write one.
  • Hardware drivers – board-specific peripherals: Ethernet controller, WiFi radio, serial UART. Driver crates in packages/drivers/ (e.g., lan9118-smoltcp, openeth-smoltcp) implement the smoltcp Device trait for a specific Ethernet MAC/PHY.

The board crate glues these together: it depends on the platform crate for OS primitives, on driver crates for peripheral access, and on transport crates (nros-smoltcp, zpico-serial) for bridging the network stack to zenoh-pico. The result is a single dependency that gives application code everything it needs.

Application
    |
    v
Board crate (nros-my-board)
    |
    +-- Platform crate (nros-platform-<name>)
    +-- Driver crate (lan9118-smoltcp)
    +-- Transport bridge (nros-smoltcp or zpico-serial)
    +-- zpico-sys (its default-on alias TU supplies zenoh-pico z_* symbols)

Crate structure

A minimal board crate looks like this:

nros-my-board/
  Cargo.toml
  .gitignore        # /target/
  src/
    lib.rs          # extern crate force-links, pub use Config, pub fn run()
    config.rs       # Config struct with network fields
    node.rs         # Hardware init sequence + run() implementation

Cargo.toml

[package]
name = "nros-my-board"
version = "0.1.0"
edition = "2024"

[lib]
name = "nros_my_board"

[dependencies]
# Platform primitives (pick one)
nros-platform = { path = "../../platform/nros-platform", features = ["platform-freertos"] }
nros-platform-freertos = { path = "../../platform/nros-platform-freertos" }

# zenoh-pico FFI. Its default-on `platform-aliases` C TU emits every
# `z_*` / `_z_*` symbol zenoh-pico needs, forwarding to `nros_platform_*`.
# (Phase 129 retired the separate `zpico-platform-shim` crate.)
zpico-sys = { path = "../../rmw/zenoh/zpico-sys", default-features = false }

# Ethernet transport (optional, gated by feature)
nros-smoltcp = { path = "../../drivers/net/nros-smoltcp", optional = true }
lan9118-smoltcp = { path = "../../drivers/net/lan9118-smoltcp", optional = true }
smoltcp = { version = "0.12", default-features = false, optional = true, features = [
    "medium-ethernet", "proto-ipv4", "socket-tcp", "socket-udp",
] }

# Serial transport (optional, gated by feature)
zpico-serial = { path = "../../rmw/zenoh/zpico-serial", optional = true }
my-uart-driver = { path = "../../drivers/my-uart", optional = true }

# Board-specific dependencies
cortex-m = "0.7"
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
panic-semihosting = "0.6"

[features]
default = ["ethernet"]
ethernet = ["dep:nros-smoltcp", "dep:lan9118-smoltcp", "dep:smoltcp"]
serial = ["dep:zpico-serial", "dep:my-uart-driver"]

lib.rs

The lib module force-links shim crates and re-exports the public API.

#![no_std]

// Force-link: ensures `zpico-sys` (and its default-on `platform-aliases`
// C alias TU, which emits zenoh-pico's `z_*` FFI symbols) is pulled into
// the final binary even though Rust code never calls it directly. Without
// this, the linker drops the rlib and zenoh-pico fails to resolve. Phase
// 129 retired the separate `zpico-platform-shim` keep-alive.
extern crate zpico_sys;

mod config;
mod node;

pub use config::Config;
pub use node::{init_hardware, run};

// Re-export entry macro so examples can use #[entry]
pub use cortex_m_rt::entry;

// Convenience println! that routes to semihosting (QEMU) or UART
pub use cortex_m_semihosting;
#[macro_export]
macro_rules! println {
    ($($arg:tt)*) => { $crate::cortex_m_semihosting::hprintln!($($arg)*) };
}

pub fn exit_success() -> ! {
    cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_SUCCESS);
    loop {}
}

pub fn exit_failure() -> ! {
    cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_FAILURE);
    loop {}
}

config.rs

The Config struct holds all network and transport settings. Fields are gated by Cargo features so only relevant fields exist for a given build.

#[derive(Clone)]
pub struct Config {
    #[cfg(feature = "ethernet")]
    pub mac: [u8; 6],
    #[cfg(feature = "ethernet")]
    pub ip: [u8; 4],
    #[cfg(feature = "ethernet")]
    pub prefix: u8,
    #[cfg(feature = "ethernet")]
    pub gateway: [u8; 4],

    #[cfg(feature = "serial")]
    pub uart_base: usize,
    #[cfg(feature = "serial")]
    pub baudrate: u32,

    pub zenoh_locator: &'static str,
    pub domain_id: u32,
}

#[cfg(feature = "ethernet")]
impl Default for Config {
    fn default() -> Self {
        Self {
            mac: [0x02, 0x00, 0x00, 0x00, 0x00, 0x00],
            ip: [192, 0, 3, 10],
            prefix: 24,
            gateway: [192, 0, 3, 1],
            #[cfg(feature = "serial")]
            uart_base: 0x4000_4000,
            #[cfg(feature = "serial")]
            baudrate: 115200,
            zenoh_locator: "tcp/192.0.3.1:7447",
            domain_id: 0,
        }
    }
}

Provide builder methods (with_ip, with_mac, with_gateway, etc.) and factory presets (listener(), talker()) for common test topologies. See nros-board-mps2-an385/src/config.rs for a complete example including from_toml() parsing.

node.rs

The run() function is the entry point. For bare-metal targets it calls the user closure directly. For RTOS targets it creates a task, starts the scheduler, and runs the closure inside that task.

Bare-metal pattern:

pub fn run<F, E: core::fmt::Debug>(config: Config, f: F) -> !
where
    F: FnOnce(&Config) -> Result<(), E>,
{
    init_hardware(&config);
    match f(&config) {
        Ok(()) => exit_success(),
        Err(e) => {
            hprintln!("Error: {:?}", e);
            exit_failure()
        }
    }
}

RTOS pattern (FreeRTOS example):

pub fn run<F, E: core::fmt::Debug>(config: Config, f: F) -> !
where
    F: FnOnce(&Config) -> Result<(), E>,
{
    init_hardware(&config);
    // Create FreeRTOS task with the user closure
    create_app_task(f, config);
    // Start the scheduler -- never returns
    start_scheduler()
}

The init_hardware() function must follow this order:

  1. Clock – initialize the hardware timer (must happen before any clock reads)
  2. Cycle counter – enable DWT or equivalent for timing measurements
  3. RNG seed – seed PRNG with entropy (IP-based hash, semihosting time, etc.)
  4. Transport – Ethernet driver + smoltcp, or UART + zpico-serial
  5. Application – call the user closure

Ethernet peripherals and smoltcp state must live in static mut storage (they are referenced by FFI poll callbacks):

static mut ETH_DEVICE: MaybeUninit<Lan9118> = MaybeUninit::uninit();
static mut NET_IFACE: MaybeUninit<Interface> = MaybeUninit::uninit();
static mut NET_SOCKETS: MaybeUninit<SocketSet<'static>> = MaybeUninit::uninit();

Transport features

Board crates use Cargo features to select the communication transport:

  • ethernet (typically the default) – TCP/UDP via smoltcp or lwIP. Requires an Ethernet driver crate and nros-smoltcp.
  • serial – UART link via zpico-serial. Requires a UART driver crate.
  • wifi – WiFi via the platform’s native stack (ESP32). The zenoh-pico layer uses OS sockets directly.

At least one transport must be enabled. Enforce this at compile time:

#[cfg(not(any(feature = "ethernet", feature = "serial")))]
compile_error!("Enable at least one transport: `ethernet` or `serial`");

Config fields are #[cfg(feature = "...")]-gated per transport, so the struct only contains fields relevant to the enabled transport. Both ethernet and serial can be enabled simultaneously – the zenoh locator string determines which transport is used at runtime (tcp/... for Ethernet, serial/... for UART).

Reference implementations

Start from the crate closest to your target:

Board crateTransportPlatformLocation
nros-board-mps2-an385Ethernet + SerialBare-metalpackages/boards/nros-board-mps2-an385/
nros-board-mps2-an385-freertosEthernet (lwIP)FreeRTOSpackages/boards/nros-board-mps2-an385-freertos/
nros-board-esp32-qemuEthernet (OpenETH)Bare-metal (esp-hal)packages/boards/nros-board-esp32-qemu/
nros-board-nuttx-qemuBSD socketsNuttXpackages/boards/nros-board-nuttx-qemu/ (arm virt and rv-virt)
nros-board-threadx-qemu-riscv64NetX DuoThreadXpackages/boards/nros-board-threadx-qemu-riscv64/

The bare-metal nros-board-mps2-an385 is the simplest starting point. The FreeRTOS variant shows how to add RTOS task creation and lwIP networking.

See also

  • Custom Platform – writing the platform crate that sits underneath a board crate
  • Porting Overview – the three customization axes (RMW, platform, board)
  • Creating Examples – detailed example layout patterns across native, BSP, and Zephyr targets

Adding a FreeRTOS Board

nano-ros ships one FreeRTOS witness — MPS2-AN385 under QEMU (nros-board-mps2-an385-freertos, tier 1). This page is the answer to the only question that matters if your hardware is not that board: what do I actually have to write?

The answer is measured, not estimated. Everything below is the complete file set for a hypothetical second board (an NXP S32K344, Cortex-M7), and the line counts are wc -l of exactly these listings.

FileLinesWhat it is
config/lwipopts.h1#include the shared defaults
config/arch/cc.h1#include the shared defaults
config/FreeRTOSConfig.h3CPU clock + NVIC priority bits, then the shared file
config/s32k344.ld7memory map + _estack, then the shared section layout
c/board_s32k344.c64vector table, Reset_Handler, netif registration
per-board delta76
src/lib.rs57the board ZST + four trait impls
build.rs45driver + board C + NROS_APP_CONFIG + linker scripts
Cargo.toml27dependencies + the rmw-zenoh feature
total205

Read that table honestly: the part that is genuinely about your board is 76 lines. The other 129 is Cargo and trait scaffolding that is identical for every Cortex-M FreeRTOS board and is the next thing worth templating — see “What is still boilerplate” at the bottom.

For comparison, nros-board-mps2-an385-freertos before phase-337 W5 was ~1600 lines plus a 727-line startup.c.

What you do NOT write

The FreeRTOS family crate (nros-board-freertos) owns all of this, and your board gets it by depending on the crate:

  • the FreeRTOS kernel, lwIP and nros-platform-freertos compile
  • FreeRTOSConfig.h, lwipopts.h, arch/cc.h — shared defaults, in packages/boards/nros-board-freertos/config/
  • the Cortex-M section layout, nros-freertos-cortex-m.ld
  • the FreeRTOS hooks (assert / idle / malloc-failed / stack-overflow / SysTick) and the semihosting helpers — c/freertos_hooks.c
  • lwIP bring-up, the task wrappers and the FFI surface — c/network_glue.c
  • the C/C++ application boot path — c/freertos_c_entry.c
  • the multi-tier entry — c/freertos_run_tiers.c
  • the entry driver (run_entry / run_bare / run_tiers_entry) and Config

configure_cflags resolves your target’s compiler flags from the [arch.*] profiles in packages/platform/nros-platform-freertos/nros-platform.toml. Cortex-M3 and Cortex-M7 are declared there; a new arch is a profile block, not a build-script branch.

The files

config/lwipopts.h

#include "../../nros-board-freertos/config/lwipopts.h"

config/arch/cc.h

#include "../../../nros-board-freertos/config/arch/cc.h"

The include is relative because FREERTOS_CONFIG_DIR is a single directory, not a search path. If your board lives outside this repository and cannot spell that path, take ladder rung 3 (see The customization ladder) and own the file — that is what rung 3 is for, and nothing else in nano-ros changes.

Out-of-tree variant. The worked example’s build.rs reaches sibling crates with ../../ walks (manifest.parent().join("nros-board-freertos/config"), drivers, nros-c includes) — those only resolve inside packages/boards/. An out-of-tree board crate resolves the nano-ros root ONCE by walking up from where its nros dependency was compiled — the SDK root holds the marker packages/core/nros-core/Cargo.toml — and derives every include from it (no environment variable names it any more, RFC-0103 D6): hal.include(root.join("packages/api/nros-c/include")); — same files, absolute anchor.

config/FreeRTOSConfig.h

Two numbers. Everything else in the 111-line shared file is generic.

#define NROS_BOARD_CPU_CLOCK_HZ 160000000 /* S32K344 CORE_CLK */
#define NROS_BOARD_PRIO_BITS    4         /* Cortex-M7 __NVIC_PRIO_BITS */
#include "../../nros-board-freertos/config/FreeRTOSConfig.h"

config/s32k344.ld

Three numbers. INCLUDE resolves against the linker’s -L search path, which build.rs populates with OUT_DIR.

MEMORY
{
    FLASH (rx)  : ORIGIN = 0x00400000, LENGTH = 4M
    RAM   (rwx) : ORIGIN = 0x20400000, LENGTH = 320K
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
INCLUDE nros-freertos-cortex-m.ld

c/board_s32k344.c

The vector table, the reset handler, and the strong overrides for network_glue.c’s weak nros_board_register_netif / nros_board_poll_netif hooks. main is the firmware entry — the Rust main on a Rust image, or freertos_c_entry.c::main on a C/C++ image. One symbol, one board file.

#include <stdint.h>
#include <string.h>
#include "FreeRTOS.h"
#include "lwip/netifapi.h"
#include "gmac_lwip.h"   /* your MAC driver's lwIP netif init + poll */

extern uint32_t _etext, _sdata, _edata, _sbss, _ebss, _estack;
extern void xPortPendSVHandler(void);
extern void vPortSVCHandler(void);
void SysTick_Handler(void);      /* nros-board-freertos/c/freertos_hooks.c */
extern int main(void);           /* Rust entry, or freertos_c_entry.c::main */
void Reset_Handler(void);
void Default_Handler(void);

static struct netif s_netif;
static struct gmac_config s_cfg;

/* Strong overrides for network_glue.c's weak hooks. */
int nros_board_register_netif(const uint8_t mac[6], const uint8_t ip[4],
                              const uint8_t netmask[4], const uint8_t gw[4]) {
    ip4_addr_t a, m, g;
    IP4_ADDR(&a, ip[0], ip[1], ip[2], ip[3]);
    IP4_ADDR(&m, netmask[0], netmask[1], netmask[2], netmask[3]);
    IP4_ADDR(&g, gw[0], gw[1], gw[2], gw[3]);
    s_cfg.base_addr = GMAC_BASE_DEFAULT;
    memcpy(s_cfg.mac_addr, mac, 6);
    if (netifapi_netif_add(&s_netif, &a, &m, &g, &s_cfg, gmac_lwip_init, tcpip_input) != ERR_OK) {
        return -1;
    }
    netifapi_netif_set_default(&s_netif);
    netifapi_netif_set_up(&s_netif);
    netifapi_netif_set_link_up(&s_netif);
    return 0;
}

void nros_board_poll_netif(void) { gmac_lwip_poll(&s_netif); }

typedef void (*vector_fn)(void);
__attribute__((section(".isr_vector"), used))
const vector_fn isr_vector[] = {
    (vector_fn)(uintptr_t)&_estack,
    Reset_Handler,
    Default_Handler,  /* NMI */
    Default_Handler,  /* HardFault */
    Default_Handler,  /* MemManage */
    Default_Handler,  /* BusFault */
    Default_Handler,  /* UsageFault */
    0, 0, 0, 0,
    vPortSVCHandler,
    Default_Handler,  /* DebugMon */
    0,
    xPortPendSVHandler,
    SysTick_Handler,
};

void Reset_Handler(void) {
    uint32_t *src = &_etext, *dst = &_sdata;
    while (dst < &_edata) *dst++ = *src++;
    for (dst = &_sbss; dst < &_ebss;) *dst++ = 0;
    (void)main();
    for (;;) {}
}

void Default_Handler(void) { for (;;) {} }

If the board has no Ethernet, omit both hook overrides: the weak defaults in network_glue.c return “no netif”, and the board reaches the router over a serial locator instead.

build.rs

use std::{env, fs, path::PathBuf};

use nros_board_common::freertos_build::{
    add_freertos_includes, add_lwip_includes, app_stack_bytes_from_build_env, configure_cflags,
    emit_app_config_tu,
};
use nros_board_common::{BaseConfig, FreertosScheduling};

fn main() {
    if nros_board_common::host_probe::skip_cross_build("nros-board-s32k344", &["thumb"]) {
        return;
    }
    let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
    let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let shared = manifest.parent().unwrap().join("nros-board-freertos/config");

    fs::copy(manifest.join("config/s32k344.ld"), out.join("s32k344.ld")).unwrap();
    fs::copy(
        shared.join("nros-freertos-cortex-m.ld"),
        out.join("nros-freertos-cortex-m.ld"),
    )
    .unwrap();
    println!("cargo:rustc-link-search={}", out.display());

    let freertos = nros_build_paths::freertos_dir();
    let port = freertos.join("portable/GCC/ARM_CM7/r0p1");
    let lwip = nros_build_paths::lwip_dir();
    let cfg = manifest.join("config");

    let mut b = cc::Build::new();
    configure_cflags(&mut b);
    add_freertos_includes(&mut b, &freertos, &port, &cfg);
    add_lwip_includes(&mut b, &lwip);
    b.include(manifest.join("../../drivers/net/gmac-lwip/include"));
    b.include(manifest.join("../../api/nros-c/include"));
    b.file(manifest.join("c/board_s32k344.c"));
    b.file(manifest.join("../../drivers/net/gmac-lwip/src/gmac_lwip.c"));
    let sched = FreertosScheduling {
        app_stack_bytes: app_stack_bytes_from_build_env(),
        ..FreertosScheduling::default()
    };
    b.file(emit_app_config_tu(&out, &BaseConfig::default(), &sched));
    b.compile("startup");
    println!("cargo:rustc-link-lib=static=startup");
}

src/lib.rs

#![allow(unused)]
#![no_std]

fn main() {
extern crate nros_platform as _;
#[cfg(target_os = "none")]
use panic_semihosting as _;
#[cfg(feature = "rmw-zenoh")]
extern crate zpico_sys;

pub use nros_board_freertos::{BaseConfig, Config};

/// Per-board marker for trait dispatch into the `nros_board_freertos::run_*`
/// family driver.
pub struct S32k344;

impl nros_platform::BoardInit for S32k344 {
    fn init_hardware() {}
}

impl nros_platform::BoardPrint for S32k344 {
    fn println(args: core::fmt::Arguments<'_>) {
        use core::fmt::Write;
        if let Ok(mut out) = cortex_m_semihosting::hio::hstdout() {
            let _ = writeln!(out, "{}", args);
        }
    }
}

impl nros_platform::BoardExit for S32k344 {
    fn exit_success() -> ! {
        cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_SUCCESS);
        loop {}
    }
    fn exit_failure() -> ! {
        cortex_m_semihosting::debug::exit(cortex_m_semihosting::debug::EXIT_FAILURE);
        loop {}
    }
}

impl nros_platform::BoardEntry for S32k344 {
    fn run<F, E>(setup: F) -> Result<(), E>
    where
        F: FnOnce(&mut nros_platform::RuntimeCtx<'_>) -> Result<(), E>,
        E: core::fmt::Debug,
    {
        nros_board_freertos::run_entry::<S32k344, F, E>(Config::default(), None, setup)
    }

    fn run_with_deploy<F, E>(deploy: &nros_platform::DeployOverlay, setup: F) -> Result<(), E>
    where
        F: FnOnce(&mut nros_platform::RuntimeCtx<'_>) -> Result<(), E>,
        E: core::fmt::Debug,
    {
        let mut config = Config::default();
        config.base.apply_overlay(deploy);
        nros_board_freertos::run_entry::<S32k344, F, E>(config, deploy.boot_config, setup)
    }
}
}

Cargo.toml

[package]
name = "nros-board-s32k344"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"

[dependencies]
nros-board-freertos = { path = "../nros-board-freertos" }
nros-platform = { path = "../../platform/nros-platform", default-features = false, features = [
    "platform-freertos",
    "global-allocator",
] }
zpico-sys = { path = "../../rmw/zenoh/zpico-sys", default-features = false, features = [
    "freertos",
    "platform-aliases",
], optional = true }
cortex-m-semihosting = "0.5"
panic-semihosting = "0.6"

[build-dependencies]
nros-board-common = { path = "../nros-board-common", features = ["build-helpers"] }
nros-build-paths = { path = "../../tooling/nros-build-paths" }
cc = "1"

[features]
default = ["rmw-zenoh"]
rmw-zenoh = ["dep:zpico-sys", "nros-board-freertos/rmw-zenoh"]

The one thing that is not a line count

The MAC driver. board_s32k344.c above assumes a gmac_lwip.c exists — an lwIP netif driver for your chip’s Ethernet MAC. nano-ros’s reference is packages/drivers/net/lan9118-lwip/src/lan9118_lwip.c at ~507 lines. If your vendor SDK already ships an lwIP netif (most do), you wire it up in the four lines of nros_board_register_netif and write none of it. If it does not, that driver is the real cost of the port, and it is a cost nano-ros cannot remove — it is between your silicon and lwIP.

If your vendor SDK owns the whole build (S32DS, MCUXpresso, ESP-IDF, PlatformIO), you likely want an integration shell instead of a board crate: see integrations/s32ds/ and RFC-0064. The shell composes freertos_platform from artefacts the vendor build already produced and nano-ros runs board-less — no board crate at all.

What is still boilerplate

Measured above: 129 of the 205 lines are not about the board.

  • src/lib.rs (57): BoardPrint / BoardExit are pure Cortex-M semihosting and BoardEntry is a two-line delegation to the family driver. Every Cortex-M FreeRTOS board writes the same file with a different type name. A declarative macro in nros-board-freertos would take this to one line.
  • Cargo.toml (27) and build.rs (45): the dependency set and the build sequence are fixed; only the driver paths, the FreeRTOS port directory and the linker-script name vary.

Phase-337 W5 templated the C and config half (299 lines of headers and 135 lines of linker script became 12 and 7). The Rust half is the same exercise, not yet done. Until it is, quote 205 lines for a second FreeRTOS board, not 80.

Vendor Overlay Board Crate

An overlay crate is a small Cargo crate that depends on a generic per-kernel board crate (nros-board-freertos, nros-board-threadx, nros-board-nuttx — there is no bare-metal family crate; that one was deliberately deleted) and patches the deltas a specific vendor board / fork needs:

  • Vendor HAL source files (NXP fsl_*, STM HAL_*, NVIDIA FSP, …).
  • Per-chip linker script + startup assembly.
  • Custom kernel-config header (FreeRTOSConfig.h, tx_user.h).
  • Custom network-stack glue (vendor Ethernet driver wired to lwIP / NetX-Duo).
  • Custom clock-tree / pin-mux init.

This page documents the contract: what the generic crate exposes, what the overlay overrides, and how to distribute an overlay. (nano-ros is source-only — nothing is published to crates.io — so overlays are consumed as path or git dependencies.)

Why overlays

nano-ros’s generic board crates cover the “stock RTOS source + your own drivers” workflow. Vendor SDKs (NXP MCUXpresso, STM32Cube, Espressif ESP-IDF, Renesas Synergy, NVIDIA FSP) ship forked kernels + custom drivers; bolting those into a generic crate would force a build-script branch per vendor. The overlay pattern keeps the generic crate clean: nano-ros ships the kernel-family scaffolding, vendors / community ship the per-fork glue.

See docs/roadmap/phase-152-board-bsp-abstraction-layer.md for the phase that landed the architecture.

Contract

A generic per-kernel board crate (taking nros-board-freertos as the worked case) exposes:

ItemTypePurpose
Config / BaseConfigstructsNetwork + transport config; overlay can extend.
run_bare / run_entry / run_tiers_entrygeneric functionsEntry points, generic over a board marker type. Initialise kernel + network, then call your closure inside the app task.
nros_platform::BoardInittraitOne required method — init_hardware(), called once before the executor opens. Your marker type implements it (plus BoardPrint / BoardExit / BoardEntry from the same family).
nros_board_register_netif / nros_board_poll_netifweak C hooks (c/network_glue.c)The network attach points: the overlay provides strong definitions wiring the vendor Ethernet driver into lwIP.

The overlay’s build.rs:

  1. Inherits the generic crate’s FREERTOS_DIR / THREADX_DIR / etc. env-var contract (overlay doesn’t override unless needed).
  2. Adds vendor HAL .c sources via its own cc::Build.
  3. Optionally regenerates the linker script (e.g. STM32F4 vs STM32F7 sector layout).

Minimal overlay shape

#![allow(unused)]
fn main() {
// nros-board-stm32f4-freertos/src/lib.rs
#![no_std]

// Re-export the generic config types from the kernel-family crate.
pub use nros_board_freertos::{BaseConfig, Config};

/// Per-board marker for trait dispatch into `nros_board_freertos::run_*`.
pub struct Stm32F4;

impl nros_platform::BoardInit for Stm32F4 {
    fn init_hardware() {
        // HAL_RCC_OscConfig + HAL_RCC_ClockConfig + pin mux + ...
    }
}
// (implement BoardPrint / BoardExit / BoardEntry the same way —
//  `packages/boards/nros-board-mps2-an385-freertos/src/lib.rs` is the
//  working precedent to copy.)

/// The network attach point is a strong definition of the generic
/// crate's WEAK C hook (c/network_glue.c) — typically provided from a
/// C file your build.rs compiles, wiring the vendor Ethernet driver
/// into lwIP:
///
///   int nros_board_register_netif(const uint8_t *mac, ...);
///   void nros_board_poll_netif(void);
}
// nros-board-stm32f4-freertos/build.rs
use std::{env, path::PathBuf};

fn main() {
    let stm_hal_dir = env::var("STM32_HAL_DIR")
        .expect("set STM32_HAL_DIR to your STMicroelectronics HAL source dir");

    let mut hal = cc::Build::new();
    hal.flag("-mcpu=cortex-m4")
       .flag("-mthumb")
       .flag("-mfpu=fpv4-sp-d16")
       .flag("-mfloat-abi=hard")
       .include(format!("{stm_hal_dir}/Inc"));

    for f in &[
        "Src/stm32f4xx_hal_eth.c",
        "Src/stm32f4xx_hal_uart.c",
        "Src/stm32f4xx_hal_rcc.c",
        // ...
    ] {
        hal.file(format!("{stm_hal_dir}/{f}"));
    }
    hal.compile("stm32f4_hal");

    // Board-specific linker script wired via the generic crate's
    // BOARD_LINKER_SCRIPT_DIR env var.
    println!(
        "cargo:rustc-env=BOARD_LINKER_SCRIPT_DIR={}",
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
            .join("config")
            .display()
    );
    println!("cargo:rerun-if-env-changed=STM32_HAL_DIR");
}
# nros-board-stm32f4-freertos/Cargo.toml
[package]
name = "nros-board-stm32f4-freertos"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
authors = ["Your Name <you@example.com>"]
description = "STM32F4 + FreeRTOS overlay on nros-board-freertos"
repository = "https://github.com/<you>/nros-board-stm32f4-freertos"

[dependencies]
# Path into your vendored nano-ros checkout (or a git dep on your fork).
# nano-ros is source-only: these names are NOT on crates.io.
nros-board-freertos = { path = "../nano-ros/packages/boards/nros-board-freertos" }
nros-platform = { path = "../nano-ros/packages/platform/nros-platform", default-features = false }

[build-dependencies]
cc = "1.0"

User application code stays identical to the generic-crate case except for the [dependencies] line:

#![allow(unused)]
fn main() {
use nros_board_stm32f4_freertos::{Config, Stm32F4};

// `nros::main!()` emits this dispatch for a leaf whose `system.toml` names
// the board (the board crate states `[package.metadata.nros.board] type =
// "::nros_board_stm32f4_freertos::Stm32F4"`); the manual form is:
nros_board_freertos::run_entry::<Stm32F4, _, _>(Config::default(), None, |runtime| {
    // register your nodes on `runtime`
    Ok(())
})
}

Canonical in-tree precedent

packages/boards/nros-board-mps2-an385-freertos/ is the canonical “stock kernel + custom Ethernet driver” overlay, and the ONLY in-tree one (the former orin-spe overlay was removed):

  • Re-exports BaseConfig + Config from nros-board-freertos.
  • Defines the Mps2An385 marker and implements the nros_platform board traits on it.
  • build.rs adds the LAN9118 driver C sources + per-board linker script.
  • Provides the strong nros_board_register_netif / nros_board_poll_netif definitions binding LAN9118 into lwIP.

Read it for working code; Adding a FreeRTOS Board walks the same contract step by step.

Naming convention

Name the crate nros-board-<vendor>-<chip-or-board>-<rtos>. Examples:

  • nros-board-stm32f4-freertos
  • nros-board-stm32h7-threadx
  • nros-board-nxp-mimxrt1064-freertos
  • nros-board-renesas-synergy-s7g2-threadx
  • nros-board-nordic-nrf5340-zephyr (rare — Zephyr generally owns board contract via DTS; only needed when a non-Zephyr nano-ros consumer wants to target an nRF board outside the Zephyr build)

The nros-board- prefix is the informal namespace; keep it so a reader can tell a board crate from an app crate at a glance.

What overlays DO

  • ✅ Re-export Config + run (or extend Config with vendor- specific fields and re-implement run if needed).
  • ✅ Add vendor HAL C sources via cc::Build.
  • ✅ Provide strong definitions of the generic crate’s weak C hooks (nros_board_register_netif, nros_board_poll_netif).
  • ✅ Ship board-specific config files (linker script, FreeRTOSConfig.h, tx_user.h).
  • ✅ Read vendor-SDK env vars (STM32_HAL_DIR, NXP_SDK_DIR, NV_SPE_FSP_DIR) and inject paths into cc-rs.

What overlays DON’T

  • ❌ Re-implement kernel build glue (that’s the generic crate’s job).
  • ❌ Add features that should live in the generic crate (push them upstream instead).
  • ❌ Duplicate nros-platform-<rtos> registration (the generic crate handles it).
  • ❌ Override nros-rmw-* selection (user picks RMW via Cargo features on nros, same as any nano-ros consumer).
  • ❌ Ship a fork of zenoh-pico / Cyclone DDS / mbedTLS (use the upstream’s manifest).

Testing an overlay locally

# 1. Clone or scaffold the overlay crate next to your application.
git clone https://github.com/<you>/nros-board-<your-vendor>-<rtos>

# 2. Point your application's Cargo.toml at it (path dep for dev).
[dependencies]
nros-board-<your-vendor>-<rtos> = { path = "../nros-board-<your-vendor>-<rtos>" }

# 3. Build with the vendor SDK env vars set.
export FREERTOS_DIR=$HOME/sdk/freertos/kernel
export FREERTOS_PORT=GCC/ARM_CM4F
export LWIP_DIR=$HOME/sdk/freertos/lwip
export STM32_HAL_DIR=$HOME/sdk/stm32cube/STM32F4xx_HAL_Driver
cargo build --release --target thumbv7em-none-eabihf

Skeleton template

templates/overlay-board/ ships a minimal skeleton:

  • Cargo.toml.template — deps on the generic kernel crate.
  • src/lib.rs.template — marker type + trait-impl and hook stubs.
  • build.rs.template — cc-rs HAL-source injection scaffold.
  • README.md — env vars + setup recipe.

Copy the directory, rename the placeholder, and fill in the vendor-specific bits. See templates/overlay-board/README.md for the per-file walkthrough.

Distributing an overlay

nano-ros is source-only (decision 2026-05-14): nothing — including board crates — is on crates.io, so do not cargo publish. Ship the overlay as a git repository and have consumers take it as a git or path dependency next to their vendored nano-ros checkout. Tag releases against the nros-v<X.Y.Z> tag of nano-ros you developed against, and state that pairing in your README — the board-trait surface is versioned with the tree, not semver-independent.

  • docs/design/0012-board-bsp-integration-architecture.md — the layered model + the consumption matrix.
  • docs/roadmap/phase-152-board-bsp-abstraction-layer.md — the phase doc.
  • Custom Board Package — older guide; covers monolithic board crates before the overlay split.
  • Custom Platform — nros-platform-<rtos> guide (the Layer 1 contract overlays rely on).

Worked Example — STM32F4 Out of Tree

This chapter takes one real board all the way through the customization ladder: an STM32F429/F407 with Ethernet, built and maintained entirely outside the nano-ros repository.

It exists because that is the honest arrangement for this board. nano-ros carried nros-board-stm32f4 and nros-board-rtic-stm32f4 in-tree for a long time, and no CI lane ever booted either of them — the hardware is not in the test rack and QEMU does not model the STM32 MAC. An in-tree crate reads as a support promise, so the two crates left the supported matrix (RFC-0064, phase-337 W7.a) and became this page instead. The hardware works exactly as well as it did; what changed is who verifies it, and that is now you.

If you want the general shape rather than this specific board, read Custom Board Package and Vendor Overlay Board Crate first — this chapter assumes them and only shows the STM32F4-specific decisions.

What this board actually needs

Four facts drive everything below. They came from the retired crate’s own descriptor:

FactValue
Targetthumbv7em-none-eabihf
Link-C link-arg=-Tlink.x, plus a memory map for the chip
Net stack ownershipnanoros-owned — nano-ros supplies both the MAC driver and the IP stack
TransportsEthernet (STM32 MAC + smoltcp) or serial (USART)

The third row is the expensive one, and it is why this board costs more than a Zephyr or NuttX port. On an RTOS-owned board the host ecosystem already brings up the NIC and the IP stack, and nano-ros only asks it for a socket. Here, nothing else brings up the NIC — so the driver and the stack are part of your board, not part of the platform. Budget for that before anything else: RFC-0064 measures a MAC driver at roughly 500 lines when one does not already exist.

Which rung you are on

The ladder has three rungs, and this board sits on different ones depending on what you are changing.

What you wantRungWhat you write
A different chip in the F4 family (F407 vs F429)1 — declareone chip value + its memory map
A different PHY, same MAC2 — hookthe board’s Ethernet init function
A proprietary link instead of Ethernet1 + 2link.custom + the transport vtable
An IP stack that is not smoltcp3 — escapethe platform ABI’s net functions directly

Rung 3 always exists, and it carries a rule worth stating plainly: if you ship an artifact by hand, the generator does not overwrite it and the drift gate does not flag it. You can adopt generated glue one file at a time and keep the files you have reason to own.

The shape of the crate

Your board crate depends on nano-ros; nano-ros does not depend on it. That is the whole point of taking it out of tree, and it is what lets you pin a nano-ros release and upgrade on your own schedule.

my-stm32f4-board/
  Cargo.toml          # depends on nros + the bare-metal platform
  nros-board.toml      # the descriptor the `nros` CLI reads
  build.rs             # emits the linker script / memory map
  memory.x             # FLASH + RAM origin and length for your chip
  src/lib.rs           # the board ZST, its trait impls, and `run()`

Compose your Config on nros_board_common::BaseConfig rather than declaring the network fields again — it carries {mac, ip, netmask, gateway, zenoh_locator, domain_id} and the DeployOverlay merge, and keeps your board aligned with every other one when those fields change. Board-specific settings (a UART base address, a PHY address) stay on your own struct beside it:

#![allow(unused)]
fn main() {
pub struct Config {
    pub base: nros_board_common::BaseConfig,
    pub phy_addr: u8,
}
}

BaseConfig stores the netmask; prefix() and with_prefix() convert if you think in CIDR.

The descriptor

nros-board.toml is what makes the nros CLI able to generate an entry for your board. The retired crate’s descriptor is a working template — the fields that matter for a bare-metal Cortex-M board with its own stack:

[[board]]
names               = ["my-stm32f4"]
platform            = "stm32"
toolchain           = "stable"
platform_feature    = "platform-bare-metal"
link_kind           = "none"
entry_kind          = "board-run"
supported_netstacks = ["smoltcp"]
chip                = "stm32f429"
board_crate         = "my-stm32f4-board"

# entry_kind = "board-run" needs the matching [board.entry] block —
# copy the one from packages/boards/nros-board-mps2-an385/nros-board.toml
# and swap the crate name.
[board.entry]
crate_name = "my_stm32f4_board"
signature  = "#[my_stm32f4_board::entry]\nfn main() -> !"

cargo_config = '''
[build]
target = "thumbv7em-none-eabihf"

[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]
'''

supported_netstacks names who brings up the link (here your crate’s smoltcp glue — nothing outside it). Beware unknown keys: the descriptor parser ignores what it does not know rather than erroring, so a typo’d key silently does nothing.

What you take on

Be clear-eyed about the trade, because it is the reason this page exists rather than a crate:

  • You own verification. No nano-ros lane boots this board. A nano-ros release being green says nothing about your board; only your hardware does.
  • You own the driver. A MAC or PHY change is yours to make and yours to test.
  • You gain release control. Your board no longer moves when nano-ros refactors its board tree — you upgrade when you choose, and a breaking change in the seam is visible as a version bump instead of a surprise.

The seam you depend on is deliberately narrow: the platform ABI is 92 C functions, and no rung of the ladder requires nano-ros to know what your stack is. That is what makes an out-of-tree board sustainable rather than a fork.

If you want it back in tree

The bar is a witness: a lane that boots the board and asserts real delivery. That means hardware in the test rack or a QEMU machine that models the MAC. A board with no Runtime cell cannot be tier 1 or 2 — scripts/check-board-tiers.py enforces exactly that, which is what moved these crates out in the first place.

Importing a Board Crate

This chapter is for consumers of a nano-ros board crate – vendors and downstream applications (the Autoware Safety Island archetype) who want to bring a board into their own Zephyr app with as little glue as possible. If you are writing a new board crate, see The Board Trait Family and Custom Board Package instead.

Goal

Consume a nano-ros board crate from a downstream Zephyr application with a single CMake call. The consumer’s build script SHOULD NOT carry any of:

  • a hand-curated EXTRA_CONF_FILE listing the board crate’s prj.conf and per-board Kconfig snippets
  • a hand-curated DTC_OVERLAY_FILE
  • a hardcoded BOARD=<id> string
  • a hardcoded -DNROS_RMW=<rmw> define
  • a hand-rolled FVP / qemu launch shell

All of those are owned by the board crate. The consumer’s CMakeLists.txt declares which board it wants; nano-ros layers the rest in.

Prereqs

Before importing a board crate, the consumer needs:

  1. A Zephyr 3.7+ workspace managed by west. Zephyr 3.7 is the floor (the official in-tree zephyr-lang-rust module did not exist below it, so nothing earlier can link the nano-ros Rust staticlib); newer LTS lines work too.
  2. All gated SDK packages for the target board. Run nros doctor --board <name> to check; missing items can be installed with nros setup <board> (or nros setup --tool <t> for a single dependency). See nros setup in the build commands reference.
  3. nano-ros listed as a project in your west.yml, and nano-ros’s Zephyr module exported on ZEPHYR_EXTRA_MODULES so Zephyr picks up its Kconfig + DTS roots.

A minimal west.yml fragment:

manifest:
  remotes:
    - name: newslab
      url-base: https://github.com/NEWSLabNTU
  projects:
    - name: nano-ros
      remote: newslab
      revision: nros-v0.6.0    # pin a release tag — a moving branch
                                 # unships your tested pairing (see
                                 # installation.md "Pinning a version")
      path: deps/nano-ros
      import: false
  self:
    west-commands: deps/nano-ros/scripts/west-commands.yml

And the matching environment:

export ZEPHYR_EXTRA_MODULES="$PWD/deps/nano-ros/zephyr"

The one-call pattern

A consumer CMakeLists.txt should look like this and nothing more:

cmake_minimum_required(VERSION 3.20)

# Single import call. Layers in BOARD, EXTRA_CONF_FILE, DTC_OVERLAY_FILE,
# NROS_RMW default, and the runner hint.
nano_ros_use_board(fvp-aemv8r-smp)

find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app LANGUAGES CXX)

target_sources(app PRIVATE src/main.cpp)

Call order matters. nano_ros_use_board() MUST precede find_package(Zephyr ...). Zephyr reads BOARD, EXTRA_CONF_FILE, and DTC_OVERLAY_FILE during its find_package call; setting them after that point has no effect. The pattern above is the canonical shape – copy it verbatim and only fill in the board name.

nano_ros_use_board() is shipped by the nano-ros Zephyr module. It is available the moment ZEPHYR_EXTRA_MODULES includes deps/nano-ros/zephyr; no extra include() is required.

Out-of-tree boards. The lookup searches <nano-ros>/packages/boards/ plus every root named by NROS_EXTRA_BOARD_PATH (environment, or a CMake cache var) — so a board crate in YOUR tree is found without copying it into the checkout. Each root has the same layout as packages/boards/. Board keys are global: a name resolving under more than one root is a fatal ambiguity, never shadowed by search order.

What the call layers in

SourceEffect
BOARDSet to NROS_BOARD_ZEPHYR_ID (from the board’s descriptor) if the user did not pass -DBOARD=... on the command line.
EXTRA_CONF_FILEThe board crate’s prj.conf and any per-board Kconfig fragments (e.g. an HWv2 snippet) are appended. Any consumer-supplied EXTRA_CONF_FILE is preserved and layered AFTER the board’s.
DTC_OVERLAY_FILEThe board crate’s per-board DTS overlay is appended. Consumer overlays are preserved and layered after.
NROS_RMWDefaulted to NROS_BOARD_DEFAULT_RMW (from the board’s descriptor) when the consumer did not pass -DNROS_RMW=....
NROS_BOARD_RUNNERCached so a launcher can pick up the right simulator binary / target-launcher.
NROS_BOARD_RUST_SUPPORT_MODULEAppended to ZEPHYR_EXTRA_MODULES when the board carries a Rust support module. (Not yet in the board.cmake ↔ Cargo.toml drift-audit field list — drift here is unaudited.)

The values come from ONE authored file, the board’s nros-board.toml. cmake cannot read TOML, so nano_ros_use_board() runs nros board cmake-vars to write a mechanical projection of the descriptor into the build directory and includes that. The projection is a build artifact: never committed, regenerated every configure, and editing it edits nothing that survives.

This replaced a second AUTHORED file per board (board.cmake, 14 NROS_BOARD_* variables) plus a [package.metadata.nros.board] mirror in Cargo.toml. Two faces of the same facts need a drift audit; that audit examined zero boards for two independent reasons and reported OK for as long as it existed. One authored file has nothing to drift against.

Per-app overrides

The one-call pattern still gives the consumer escape hatches:

  • Pin a different Zephyr board id. Pass -DBOARD=<other> on the CMake / west build command line; nano_ros_use_board() notices the user value and emits a warning rather than clobbering it.
  • Pick a different RMW. Pass -DNROS_RMW=<rmw> (zenoh, xrce, or cyclonedds – see Backend Reference). The board crate’s default is used only when nothing is set.
  • Layer extra Kconfig / DTS. Set EXTRA_CONF_FILE and DTC_OVERLAY_FILE after the call to nano_ros_use_board(). They will be applied on top of the board crate’s contributions, not in place of them.
nano_ros_use_board(fvp-aemv8r-smp)

# Extra app-specific Kconfig layered on top of the board's defaults.
list(APPEND EXTRA_CONF_FILE "${CMAKE_CURRENT_SOURCE_DIR}/boards/fvp-aemv8r-smp.conf")

find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app LANGUAGES CXX)

Running

The board’s [board.zephyr] runner reaches cmake as NROS_BOARD_RUNNER, and Zephyr’s own emulator target does the rest:

west build -d build
west build -d build -t run

For an FVP board, nros setup board <name> has already fetched the model ([tool.arm-fvp], a pinned Arm CDN permalink with a checked digest), so the resolver finds it in the SDK store. Export the directory Zephyr’s armfvp runner looks in:

export ARMFVP_BIN_PATH="$(bash scripts/zephyr/resolve-fvp-bin.sh)"
west build -d build -t run

There used to be a west fvp run verb here. It read NROS_BOARD_RUNNER from CMakeCache.txt, ran that same resolver, exported ARMFVP_BIN_PATH, and then exec’d west build -t run — so it was env wiring in front of the stock verb, and env wiring belongs in your environment. It was retired in RFC-0064 revision 5.

Inspecting the manifest

To see exactly what nano_ros_use_board() will layer in for a given board:

nros board info fvp-aemv8r-smp

This prints the resolved Zephyr board id, the prj.conf + Kconfig fragment list, the DTS overlay, the default RMW, and the runner hint – everything the call would set.

nros board info also prints the exact cmake_vars text the build will include, so “what does cmake actually see for this board?” is answerable without configuring anything.

There is no drift audit any more, and nothing to audit: the descriptor is the only file a human writes.

Anti-patterns

Things that LOOK reasonable but the one-call pattern obviates:

  • Don’t hand-list the board’s prj.conf in EXTRA_CONF_FILE. It is already there; doing it again either duplicates or fights the layering order.
  • Don’t hardcode BOARD=<id> in a build.sh. The call sets it; hardcoding it short-circuits the nros board info inspection and the drift audit.
  • Don’t carry your own copy of boards/<id>.conf or boards/<id>.overlay mirroring the board crate’s. Vendor a delta only – the base ships in the crate.
  • Don’t reimplement the FVP launch in a build.sh. Export ARMFVP_BIN_PATH and use west build -t run; Zephyr’s own armfvp runner covers FVP_BaseR_AEMv8R and the other supported simulators with the right CLI flags, the same way west build covers compilation.
  • Don’t include() files from deps/nano-ros/cmake/ directly. The public surface is nano_ros_use_board(); anything else is internal and may move.

Migrating an existing hand-glued consumer

Most downstream consumers (the ASI archetype is the canonical example) carry years of accumulated glue. The migration is mechanical:

  1. Find the per-board Kconfig and overlay entries in the current CMakeLists.txt / build.sh. Anything listing the board crate’s prj.conf, board overlay, or HWv2 snippet – delete it. Keep only entries that point at the consumer’s own deltas.
  2. Find any hardcoded BOARD=<id> string (in CMake set(BOARD ...), in west build -b <id>, or in build.sh). Delete it. nano_ros_use_board() will set it from board.cmake.
  3. Replace find_package(Zephyr) + ... + manual FVP launch with the canonical pattern above – one nano_ros_use_board() call, then find_package(Zephyr), then project(), then target_sources(app ...). Replace the FVP shell script call with west fvp run -d build.
  4. Keep only app-specific deltas in boards/<id>.conf / boards/<id>.overlay. For the ASI archetype this typically means Autoware-msg sizing (large topic / participant memory) and the application’s own GPIO map – everything generic moves into the board crate.

After migration, the consumer’s CMake should be roughly twenty lines, with nano_ros_use_board() as the only nano-ros-specific call.

Cross-references

Custom Platform

This guide walks through porting nano-ros to a new RTOS or bare-metal environment. A “platform” provides the OS-level primitives that nano-ros needs at runtime: clock, memory, sleep, threading, and networking. The core library is #![no_std] and makes zero platform calls directly – everything flows through your platform crate.

Canonical interface spec. The function-pointer signatures, parameter docs, ownership rules, blocking allowance, and failure modes for every method live in the platform-cffi Doxygen reference. Read it side-by-side with this guide — the tables below summarise the set of traits you need; the Doxygen documents the contract each call must obey.

Quick differences table. For a per-platform comparison (clock source, allocator, threading, networking, multicast support) across the platforms nano-ros already supports, see Platform Differences.

What you implement

All platform traits are defined in nros-platform/src/traits.rs. Your platform crate implements some or all of them as inherent methods on a zero-sized type (ZST). The set you need depends on your RMW backend.

Required for all backends

TraitMethodsPurpose
PlatformClockclock_ms(), clock_us()Monotonic time. Must use a hardware timer or OS tick – never a software counter that only advances when polled.

Required for zenoh-pico (rmw-zenoh)

TraitMethodsPurpose
PlatformAllocalloc(), realloc(), dealloc()Heap allocation. zenoh-pico needs ~64 KB.
PlatformSleepsleep_us(), sleep_ms(), sleep_s()Delay. On bare-metal with smoltcp, poll the network during busy-wait.
PlatformRandomrandom_u8() through random_u64(), random_fill()PRNG for session IDs and protocol nonces.
PlatformTimetime_now_ms(), time_since_epoch()Wall-clock time for logging. Return monotonic time if no RTC.
PlatformThreadingTasks, mutexes, recursive mutexes, condvars (19 methods)OS threading primitives. Single-threaded platforms provide no-op stubs.

Networking

TraitMethodsPurpose
PlatformTcpopen(), read(), send(), close(), …TCP client and server sockets.
PlatformUdpopen(), read(), send(), close(), …UDP unicast sockets.
PlatformSocketHelpersset_non_blocking(), accept(), close(), wait_event()Socket utility operations.

Optional

TraitWhen needed
PlatformUdpMulticastDesktop platforms using zenoh scouting. Not needed for embedded client mode.
PlatformNetworkPollBare-metal platforms using smoltcp. Called during sleep to process packets.
PlatformLibcBare-metal targets without a C runtime. Provides strlen, memcpy, etc.

For full method signatures, see the Platform API Reference.

Wiring into nros

Five files need changes to register a new platform. This example adds a fictional “MyOS” platform.

1. Create the platform crate

packages/platform/nros-platform-myos/
  Cargo.toml
  src/
    lib.rs

The crate must have zero nros-* dependencies. It may depend on your RTOS bindings, HAL crates, or embedded-alloc.

2. Add the feature to nros-platform

In packages/platform/nros-platform/Cargo.toml:

[features]
platform-myos = ["dep:nros-platform-myos"]

[dependencies]
nros-platform-myos = { version = "0.1.0", path = "../nros-platform-myos", optional = true }

3. Add the ConcretePlatform alias

In packages/platform/nros-platform/src/resolve.rs:

#![allow(unused)]
fn main() {
#[cfg(feature = "platform-myos")]
pub type ConcretePlatform = nros_platform_myos::MyOsPlatform;
}

4. Propagate through the nros facade

In packages/api/nros/Cargo.toml, add platform-myos to the feature list so users can write nros = { features = ["rmw-zenoh", "platform-myos"] }.

5. Register your platform as an ABI marker

The platform shim crates are gone (Phase 129). Each transport C library’s platform symbols now come from a default-on C alias translation unit that forwards to the canonical nros_platform_* ABI — zpico-sys’s platform-aliases feature for zenoh-pico (z_* / _z_*), and nros-rmw-xrce’s always-compiled src/platform_aliases.c for XRCE-DDS (uxr_*). You do not activate or configure a shim.

What you add is a pure ABI marker feature so each transport crate’s build.rs can do per-platform source selection (e.g. strip the vendor system/<rtos>/system.c, gate the alias TU’s network section to bare-metal):

# packages/rmw/zenoh/zpico-sys/Cargo.toml  (rmw-zenoh)
[features]
myos = []   # ABI marker only — no shim dependency

Only zenoh needs one. XRCE-DDS has no per-platform feature to add: phase-129.C.1 deleted all of nros-rmw-xrce-cffi’s platform-<rtos> features, and its build selection is driven by target_os instead, over a platform-blind transport_nros_udp.c. There is also no crate left to add one to — the xrce-sys crate was deleted in phase-321 W1.d and the directory is now a submodule host for micro-xrce-dds-client/ and micro-cdr/, whose README says in as many words: do not re-add a crate here. If your port targets XRCE only, skip this step entirely.

The alias TU (default-on platform-aliases) covers the full z_* surface — memory, sleep, random, time, yield, threading, condvar, clock, and network. zpico-sys’s build.rs defines NROS_PLATFORM_ALIASES and emits the network wrappers only where the vendor stack doesn’t already provide them (bare-metal). If your RTOS’s vendor system.c already supplies these symbols natively (as on Orin SPE’s FSP FreeRTOS), turn platform-aliases off for that platform to avoid duplicate-symbol link errors.

Rust path

This is the recommended approach. Create a ZST and implement each capability as inherent methods (not trait impls). nros-platform-cffi exposes these methods as the canonical nros_platform_* C symbols, which the alias TUs call.

Skeleton

#![allow(unused)]
fn main() {
// packages/platform/nros-platform-myos/src/lib.rs
#![no_std]
use core::ffi::c_void;

/// Zero-sized type implementing platform methods for MyOS.
pub struct MyOsPlatform;

// -- Clock --
impl MyOsPlatform {
    pub fn clock_ms() -> u64 {
        // Call your RTOS tick API, e.g.:
        // unsafe { myos_get_tick_count() as u64 }
        todo!()
    }

    pub fn clock_us() -> u64 { Self::clock_ms() * 1000 }
}

// -- Alloc --
impl MyOsPlatform {
    pub fn alloc(size: usize) -> *mut c_void {
        // unsafe { myos_malloc(size) }
        todo!()
    }

    pub fn realloc(ptr: *mut c_void, size: usize) -> *mut c_void {
        // If your RTOS lacks realloc: alloc new, copy, free old
        todo!()
    }

    pub fn dealloc(ptr: *mut c_void) {
        // unsafe { myos_free(ptr) }
        todo!()
    }
}

// -- Sleep --
impl MyOsPlatform {
    pub fn sleep_us(us: usize) { Self::sleep_ms(us.div_ceil(1000)); }
    pub fn sleep_ms(ms: usize) {
        // unsafe { myos_thread_sleep(ms as u32) }
        todo!()
    }
    pub fn sleep_s(s: usize) { Self::sleep_ms(s * 1000); }
}

// -- Threading (stubs for single-threaded, real impls for RTOS) --
impl MyOsPlatform {
    pub fn mutex_init(m: *mut c_void) -> i8 {
        // Create a mutex via your RTOS API. Store the handle in `m`.
        // Return 0 on success, -1 on failure.
        todo!()
    }
    pub fn mutex_lock(m: *mut c_void) -> i8 { todo!() }
    pub fn mutex_unlock(m: *mut c_void) -> i8 { todo!() }
    // ... remaining threading methods (see traits.rs for the full list)
}
}

Key points

  • Inherent methods, not trait impls. The shim calls ConcretePlatform::clock_ms() directly. The traits in traits.rs document the contract, but the ZST uses inherent impl blocks.
  • c_void pointers for handles. Mutex, condvar, and task handles are opaque #[repr(C)] structs sized to hold your RTOS handle. Cast the *mut c_void to your internal type.
  • Recursive mutexes are required. zenoh-pico locks the same mutex recursively. On FreeRTOS this maps to xSemaphoreCreateRecursiveMutex; on pthreads, PTHREAD_MUTEX_RECURSIVE.
  • Seed the PRNG. A deterministic seed (like FreeRTOS rand() starting from 1) causes duplicate zenoh session IDs across QEMU instances. Seed from hardware entropy, IP address, or semihosting wall-clock.

Reference implementation

packages/platform/nros-platform-freertos/src/lib.rs is a complete real-world example covering all categories: clock via xTaskGetTickCount, heap via pvPortMalloc/vPortFree, sleep via vTaskDelay, xorshift32 PRNG, and full threading with tasks, recursive mutexes, and condvars built on counting semaphores.

C/C++ path

If your platform is easier to implement in C, use the nros-platform-cffi C ABI. It is the canonical layer between nros and any C-implemented platform port.

1. Implement the platform symbols

The canonical header lives at packages/platform/nros-platform-cffi/include/nros/platform.h. It declares roughly 45 free extern "C" functions — one per platform capability. Your port supplies a definition for each; the linker resolves them into the nros binary directly. There is no runtime registration call. Browse the rendered reference at /api/platform-cffi/ for per-function return-value, threading, and blocking conventions.

Include the header in your port:

// my_platform.c
#include <nros/platform.h>

Then define each symbol:

uint64_t nros_platform_clock_ms(void) {
    return myos_get_ticks();  // your RTOS tick API
}

void *nros_platform_alloc(size_t size) {
    return myos_malloc(size);
}

void nros_platform_dealloc(void *ptr) {
    myos_free(ptr);
}

void *nros_platform_realloc(void *ptr, size_t size) {
    return myos_realloc(ptr, size);
}

int8_t nros_platform_mutex_init(void *m) {
    myos_mutex_t *mx = (myos_mutex_t *)m;
    *mx = myos_mutex_create();
    return (*mx != NULL) ? 0 : -1;
}

/* ... define every other symbol declared in <nros/platform.h> ... */

Compile your translation unit into a static or object library and link it into the nros binary alongside the nros static library. No registration step is required at boot:

int main(void) {
    myos_init();

    /* nros calls nros_platform_* symbols directly */
    rclc_executor_t exec;
    nros_executor_open(&exec, &config);
    /* ... */
}

3. Build configuration

Enable the platform-cffi feature instead of a platform-specific feature:

nros = { features = ["rmw-zenoh", "platform-cffi"] }

The rmw-zenoh feature is the lowering of the declared RMW: the backend is declared once in system.toml ([system].rmw / [image.<id>].rmw) and the toolchain emits the cargo feature. The feature is the build mechanism, not the user-facing knob — see RFC-0031.

All symbols declared in <nros/platform.h> are required. For capabilities your platform does not support (e.g., threading on single-threaded bare-metal), supply stubs that return 0 for mutex/condvar operations and -1 for nros_platform_task_init.

Networking

There are two paths for providing TCP/UDP sockets to zenoh-pico.

Option A: Rust networking (preferred)

Implement PlatformTcp, PlatformUdp, and PlatformSocketHelpers on your ZST. These methods map to your OS socket API (BSD sockets, lwIP, NetX Duo, etc.).

#![allow(unused)]
fn main() {
impl MyOsPlatform {
    pub fn tcp_open(sock: *mut c_void, endpoint: *const c_void, timeout_ms: u32) -> i8 {
        // Parse endpoint, call connect(), store fd in sock
        todo!()
    }

    pub fn tcp_read(sock: *const c_void, buf: *mut u8, len: usize) -> usize {
        // Call recv() on the socket fd
        todo!()
    }

    pub fn tcp_send(sock: *const c_void, buf: *const u8, len: usize) -> usize {
        // Call send() on the socket fd
        todo!()
    }

    pub fn tcp_close(sock: *mut c_void) {
        // Close the socket fd
    }
}
}

Activate the network shim feature in zpico-sys so the shim provides the _z_open_tcp, _z_read_tcp, etc. C symbols by forwarding to your Rust methods.

For bare-metal with smoltcp, use nros-smoltcp (in packages/drivers/) as the networking driver. It provides PlatformTcp and PlatformUdp implementations using smoltcp’s TCP/UDP sockets. The MAC/PHY driver lives in a sibling driver crate (e.g. lan9118-smoltcp, openeth-smoltcp) and implements smoltcp’s Device trait; nros-smoltcp consumes that Device and exposes the transport to zenoh-pico. Your platform crate implements PlatformNetworkPoll so the sleep loop can process packets — the platform crate stays free of smoltcp itself.

Option B: Keep zenoh-pico’s C network.c

If your platform already has a working zenoh-pico network.c (e.g., freertos/lwip/network.c or unix/network.c), you can compile it directly instead of implementing the Rust networking traits.

In this case, do not activate the network shim feature in zpico-sys. Instead, link the appropriate network.c through your build system. The C file provides the _z_open_tcp, _z_read_tcp, etc. symbols directly, bypassing the Rust shim.

This is the approach used by platforms with mature C networking stacks (lwIP on FreeRTOS, BSD sockets on NuttX, NetX Duo on ThreadX).

Common pitfalls

  • Poll-driven clocks. If the clock only advances when you call a function, timeouts and keep-alives break silently. Use a free-running hardware timer.
  • Stack overflow on RTOS. The application task’s frame is dominated by the transport session open and by the Executor value the constructor builds and returns; size it by measuring an overflow, not by copying a number. (The executor’s arena is no longer part of that frame — it is borrowed from caller-supplied backing, which is a .bss static on every in-tree entry.)
  • Deterministic PRNG seeds. Duplicate zenoh session IDs cause silent connection failures. Seed from a source that varies across instances.
  • Missing recursive mutexes. zenoh-pico re-enters the same mutex. Non-recursive mutexes deadlock.
  • QEMU clock drift. Use -icount shift=auto for QEMU targets so the virtual clock tracks wall time during WFI.

Next steps

Adding a Platform (CMake Layer)

Scope. This page covers the CMake-side glue: how the build system picks the right platform shim, links the right archives, and dispatches per-app fixups (linker scripts, startup files, ISR vectors). For the runtime traits a new platform must implement in Rust / platform-cffi, see Custom Platform.

Since, porting a new platform to nano-ros is one new file:

nano-ros/cmake/platform/nano-ros-<plat>.cmake

Adding a platform no longer touches the root CMakeLists.txt, never edits per-example trees, and never duplicates platform helpers across 20+ examples.

Module contract (§A)

Every cmake/platform/nano-ros-<plat>.cmake must expose:

SymbolKindPurpose
NanoRos::PlatformALIAS libraryAliased to the platform-specific INTERFACE target; linked into NanoRos::NanoRos umbrella by the root CMakeLists.
nros_platform_<plat>_ifaceINTERFACE libraryCarries the platform staticlib + host-system libs + transitive deps.
nros_platform_link_app(target)functionPer-app fixups: linker script, startup objects, ISR vectors, RTOS-specific final-link tweaks. Empty for POSIX.
NROS_PLATFORM_LINK_FEATUREScache variableDefault link-feature set for this platform (e.g. tcp udp_unicast).

The root CMakeLists.txt dispatches via:

include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/platform/nano-ros-${NROS_PLATFORM}.cmake")

When the user’s project sets NROS_PLATFORM=foo before add_subdirectory(nano-ros), your module at cmake/platform/nano-ros-foo.cmake runs and supplies the four contract elements above.

Minimal skeleton

# cmake/platform/nano-ros-foo.cmake

if(DEFINED _NROS_PLATFORM_FOO_INCLUDED)
    return()
endif()
set(_NROS_PLATFORM_FOO_INCLUDED TRUE)

set(NROS_PLATFORM_LINK_FEATURES tcp udp_unicast
    CACHE STRING "Default link features for the Foo platform")

# Build / pull in the platform staticlib. May be add_subdirectory(...)
# into packages/platform/nros-platform-foo/ for a Cargo + cmake hybrid, or
# may declare an IMPORTED target pointing at a prebuilt RTOS archive.
add_subdirectory(
    "${CMAKE_CURRENT_LIST_DIR}/../../packages/platform/nros-platform-foo"
    nros_platform_foo_build)

add_library(nros_platform_foo_iface INTERFACE)
if(TARGET nros_platform_foo)
    target_link_libraries(nros_platform_foo_iface INTERFACE nros_platform_foo)
endif()

if(NOT TARGET NanoRos::Platform)
    add_library(NanoRos::Platform ALIAS nros_platform_foo_iface)
endif()

function(nros_platform_link_app target)
    # Per-app fixups go here. Linker scripts, startup objects, ISR
    # vectors. Delegate to cmake/board/nano-ros-board-${NROS_BOARD}.cmake
    # when the platform supports multiple boards.
    if(DEFINED NROS_BOARD)
        include("${CMAKE_CURRENT_LIST_DIR}/../board/nano-ros-board-${NROS_BOARD}.cmake")
        if(COMMAND nros_board_link_app)
            nros_board_link_app(${target})
        endif()
    endif()
endfunction()

That’s it. The root CMakeLists picks up the new platform automatically — the validation block resolves cmake/platform/nano-ros-foo.cmake and fatals out only when no such file exists.

Board overlays

When your platform spans multiple boards (different MCUs, link scripts, peripheral inits), carve a board overlay:

nano-ros/cmake/board/nano-ros-board-<board>.cmake

Board overlays provide nros_board_link_app(target) and run inside nros_platform_link_app when the user sets NROS_BOARD. The board layer owns:

  • Linker script selection (target_link_options(${target} PRIVATE -T<script>))
  • Startup object emission (crt0.o, vector table, etc.)
  • MCU-specific final-link flags

See cmake/board/nano-ros-board-mps2-an385-baremetal.cmake for a working example.

Where the existing layer-2 helpers live

For RTOS ports that compose the kernel + netstack + glue inside CMake (FreeRTOS, ThreadX, NuttX), the per-RTOS helper functions (nros_freertos_build_kernel, nros_threadx_compose_platform, …) stay at packages/api/nros-c/cmake/nros-<rtos>.cmake. The modules include(...) those helpers — the per-platform file is the dispatch entry, not the implementation.

See also

Custom Transport

nano-ros lets you plug a custom transport (USB-CDC, BLE GATT, RS-485 with framing, ring-buffer loopback, semihosting bridge, …) at runtime, without changing the board crate, Cargo features, or rebuilding. This is the runtime equivalent of micro-ROS’s rmw_uros_set_custom_transport(framing, params, open, close, write, read).

When to use

  • You have a serial-over-USB device that doesn’t fit serial, ethernet, or wifi.
  • You’re bridging through a host-side proxy (semihosting, RTT, OpenOCD).
  • You’re prototyping a transport on a target where the static path isn’t built yet.
  • You want a single firmware image that picks transport at boot from a config block.

If your transport fits one of the prebuilt static variants (platform-posix, platform-freertos, platform-nuttx, platform-threadx, platform-zephyr, platform-bare-metal), prefer that — the runtime hook trades binary-size optimisation for flexibility.

API layering (L0 / L1 / L2)

The custom transport surface is the project’s first canonical-C-ABI interface, designed per docs/design/0006-portable-rmw-platform-interface.md:

LayerOwnsCrates / files
L0 — canonical C ABI#[repr(C)] struct + abi_version: u32 field + four unsafe extern "C" fn pointers + user_data: *mut c_voidnros-rmw::custom_transport (Rust source); <nros/transport.h> is the cbindgen-emitted C header
L1 — language wrappersmechanical glue, no new design decisionsnros-rmw::set_custom_transport (Rust); rclc_set_custom_transport (C); rclcpp::set_custom_transport (C++)
L2 — typed app APIn/a — transport is platform-side—

All design decisions live at L0. A new feature — say, a flush callback — lands in the L0 struct first; L1 wrappers follow mechanically.

The same canonical-C-ABI pattern was generalised to the full RMW backend surface (nros_rmw_vtable_t). For the host-language policy that decides whether a given backend lives in Rust, C, or C++, see RMW Backends — Host-Language Policy.

ABI versioning

The abi_version: u32 field at the head of the struct is mandatory. Consumers must fill in NROS_TRANSPORT_OPS_ABI_VERSION_V1 (Rust: nros_rmw::NROS_TRANSPORT_OPS_ABI_VERSION_V1; C: NROS_TRANSPORT_OPS_ABI_VERSION_V1; C++: filled in automatically by rclcpp::set_custom_transport). Mismatched versions are rejected at registration time with NROS_RMW_RET_INCOMPATIBLE_ABI (-14); the slot stays whatever it was before the bad call.

The version bumps under two rules:

  • Major (e.g. V1 → V2): existing fields are removed or reordered. Old consumers fail cleanly via the version check.
  • Minor (struct gains an appended fn pointer / data field): version stays the same. New consumers detect the new fn via the size of the trailing _reserved region. Today there’s no such appendage — V1 is the inaugural version.

Implementing in another language

The L0 struct is plain C ABI (#[repr(C)] Rust ↔ struct C). Any language with C-FFI support can author both sides of the boundary — Zig, Python (ctypes / cffi), Lua-FFI, Go (cgo), Swift (@_cdecl), etc.

The reference implementation of “custom transport written in pure C” lives at packages/rmw/cffi/tests/c_stubs/c_stub_transport.c. ~80 LOC; no Rust headers / cbindgen output / Rust types involved on the C side. Use it as a template for ports to other languages.

The corresponding Rust integration test (tests/c_stub_transport.rs) exercises the round-trip: register the C-built struct → drive each fn pointer from Rust → confirm the C-side counters bumped → confirm abi_version mismatch is rejected. Run via:

cargo test -p nros-rmw-cffi --features c-stub-test --test c_stub_transport

API

Rust

#![allow(unused)]
fn main() {
use core::ffi::c_void;
use nros_rmw::{NrosTransportOps, set_custom_transport};

unsafe extern "C" fn my_open(_ud: *mut c_void, _params: *const c_void) -> i32 { 0 }
unsafe extern "C" fn my_close(_ud: *mut c_void) {}
unsafe extern "C" fn my_write(_ud: *mut c_void, buf: *const u8, len: usize) -> i32 {
    // hand `buf[..len]` to the underlying medium, return 0 on success
    0
}
unsafe extern "C" fn my_read(_ud: *mut c_void, buf: *mut u8, len: usize, timeout_ms: u32) -> i32 {
    // read up to `len` bytes within `timeout_ms`, return non-negative count
    0
}

unsafe {
    set_custom_transport(Some(NrosTransportOps {
        abi_version: nros_rmw::NROS_TRANSPORT_OPS_ABI_VERSION_V1,
        _reserved: 0,
        user_data: my_uart_handle as *mut c_void,
        open: my_open,
        close: my_close,
        write: my_write,
        read: my_read,
    })).expect("abi_version must match runtime");
}
}

C

#include <nros/transport.h>

static rcl_ret_t my_open(void *ud, const void *params) {
    (void)params;
    return my_uart_open((my_uart_t *)ud);
}
static void my_close(void *ud) { my_uart_close((my_uart_t *)ud); }
static rcl_ret_t my_write(void *ud, const uint8_t *buf, size_t len) {
    return my_uart_write((my_uart_t *)ud, buf, len);
}
static int32_t my_read(void *ud, uint8_t *buf, size_t len, uint32_t timeout_ms) {
    return my_uart_read((my_uart_t *)ud, buf, len, timeout_ms);
}

int main(void) {
    nros_transport_ops_t ops = {
        .abi_version = NROS_TRANSPORT_OPS_ABI_VERSION_V1,
        ._reserved = 0,
        .user_data = &g_uart,
        .open = my_open,
        .close = my_close,
        .write = my_write,
        .read = my_read,
    };
    rclc_set_custom_transport(&ops);

    // ... continue with rclc_support_init, rclc_node_init_default, etc.
}

C++

#include <rclcpp/transport.hpp>

rclcpp::TransportOps ops;
ops.user_data = &g_uart;
ops.open  = [](void *ud, const void*) -> int { return my_uart_open((MyUart*)ud); };
ops.close = [](void *ud)               { my_uart_close((MyUart*)ud); };
ops.write = [](void *ud, const uint8_t *buf, std::size_t len) -> int {
    return my_uart_write((MyUart*)ud, buf, len);
};
ops.read  = [](void *ud, uint8_t *buf, std::size_t len, std::uint32_t to) -> std::int32_t {
    return my_uart_read((MyUart*)ud, buf, len, to);
};
auto r = rclcpp::set_custom_transport(ops);
RCLCPP_TRY(r);

Captureless lambdas only. All four fields are raw C function pointers, not std::function. Pass per-instance state through user_data.

Threading contract

ConstraintRationale
read and write MAY be invoked concurrently from different threads on threaded backends.The zenoh-pico backend runs a dedicated read-task thread on multi-threaded platforms (POSIX, Zephyr, FreeRTOS, NuttX), so read runs there while the application/tx thread drives write. Single-threaded platforms (bare-metal, ThreadX) instead serialise both through the spin-once / drive_io path. A custom transport must therefore be full-duplex safe: do not serialise read and write behind one lock held across a blocking read, or the read thread will starve the writer and deadlock session declaration. Most media (TCP, full-duplex UART/USB-CDC) already allow concurrent read and write; mirror that. With std::net::TcpStream, store the stream directly and use &TcpStream (which implements Read/Write) from both callbacks rather than a Mutex<TcpStream>.
Callbacks must NOT be invoked from interrupt context.The runtime path may take internal locks; ISR context could deadlock. Wrap ISR-driven hardware in a queue + read poller.
user_data must outlive the transport’s active period.The runtime never copies it. Lifetime is from the first callback invocation through close returning.
set_custom_transport must be called BEFORE rclc_support_init.The active backend reads the slot during Rmw::open. Calling after init is implementation-defined: the backend may already have consumed the previously-registered vtable, and no backend rejects the second call (this row promised an ALREADY_INIT code that neither ABI defines — issue 1126).

Return-code conventions

  • open / write return 0 (NROS_RMW_RET_OK) on success, a negative rcl_ret_t (e.g. NROS_RMW_RET_TIMEOUT, NROS_RMW_RET_ERROR) on failure.
  • read returns the non-negative byte count on success (may be less than len); a negative rcl_ret_t on error / timeout.
  • close returns nothing — failures during teardown are best-effort.

Framing

Some transports need wire-level framing (HDLC for serial, length-prefix for stream sockets). The active backend decides whether framing is applied; the user vtable just sees raw bytes.

  • XRCE-DDS: pass framing=true to the backend’s init_transport_from_custom_ops(framing) for byte-stream transports (UART, USB-CDC). Pass framing=false for packet-oriented transports (UDP, BLE GATT).
  • zenoh-pico: framing is built into the wire protocol — always framing=false regardless of the underlying medium.

Backend coverage

BackendStatus
XRCE-DDS✅ Wired. nros_rmw_xrce::init_transport_from_custom_ops(framing) pulls the registered vtable into uxr_set_custom_transport_callbacks via four C trampolines.
zenoh-pico🟡 Deferred (zenoh). zenoh-pico’s custom-link API needs a per-platform _z_link_t shim; tracked separately. Zenoh users with custom transports today fork a zpico-platform-* crate.
Cyclone DDS➖ N/A. Cyclone DDS binds the platform’s network stack directly (POSIX / NSOS / lwIP / NetX-Duo BSD sockets) rather than going through the nano-ros custom-transport vtable; “porting” Cyclone to a new link means providing those sockets, not a transport plug-in.

Loopback test

packages/rmw/xrce/nros-rmw-xrce/tests/custom_transport.rs exercises the slot lifecycle + the XRCE bridge round-trip with stub callbacks (no real session). Run via:

cargo test -p nros-rmw-xrce --features platform-posix \
    --test custom_transport

See also

Porting a Custom RMW Backend

nano-ros ships with three RMW backends – zenoh-pico, Micro-XRCE-DDS, and Cyclone DDS. To add your own transport (MQTT, a proprietary bus, etc.), implement a small set of traits or fill in a C function table.

Two paths are available:

  • Rust path – implement the nros-rmw traits directly.
  • C/C++ path – fill in nros_rmw_vtable_t and register it at startup via nros-rmw-cffi.

What you implement

Your backend provides concrete types for six traits:

Rmw                    -- factory: opens a Session from RmwConfig
  Session              -- connection lifecycle, creates handles
    Publisher          -- send CDR-encoded messages
    Subscription       -- non-blocking receive (poll-based)
    ServiceTrait       -- receive requests, send replies
    ClientTrait        -- send requests, poll for replies

(Phase-301 aligned the vocabulary with upstream rmw: Subscriber → Subscription, ServiceServerTrait → ServiceTrait, ServiceClientTrait → ClientTrait, and the create methods below match.)

Most methods have default implementations. The required methods per trait are:

TraitRequired methods
Rmwopen()
Sessioncreate_publisher(), create_subscription(), create_service(), create_client(), close()
Publisherpublish_raw(), buffer_error(), serialization_error()
Subscriptiontake_serialized(), deserialization_error()
ServiceTraittake_request(), send_response()
ClientTraitsend_request_raw(), take_response_raw()

For full trait signatures and associated types, see RMW API Reference.

Use nros-platform for networking

Call ConcretePlatform::tcp_open() / udp_bind() from nros-platform rather than OS sockets directly. This makes your backend portable across every platform (POSIX, Zephyr, FreeRTOS, NuttX, ThreadX, bare-metal). If your transport library already abstracts networking (like zenoh-pico does), you can use its own I/O layer instead.


Rust path

1. Create the crate

Create packages/myproto/nros-rmw-myproto/ with nros-rmw and nros-core as dependencies (both default-features = false for no_std support). Follow the std/alloc feature forwarding pattern used by the existing backends.

2. Implement the traits

#![allow(unused)]
#![no_std]
fn main() {
use nros_rmw::*;

#[derive(Default)]
pub struct MyProtoRmw {
    // Optional pre-open configuration (agent address, TLS CA, serial
    // device handle, …) can live here and move into the Session at
    // `open` time. Every backend in-repo implements `Default` so the
    // caller can spell the common case as
    // `MyProtoRmw::default().open(&config)`.
}
impl Rmw for MyProtoRmw {
    type Session = MyProtoSession;
    type Error = TransportError;
    fn open(self, config: &RmwConfig) -> Result<MyProtoSession, TransportError> {
        todo!() // Parse config.locator, connect, map config.domain_id
    }
}

pub struct MyProtoSession { /* connection state */ }
impl Session for MyProtoSession {
    type Error = TransportError;
    type PublisherHandle = MyProtoPub;
    type SubscriptionHandle = MyProtoSub;
    type ServiceHandle = MyProtoServer;
    type ClientHandle = MyProtoClient;

    fn create_publisher(&mut self, t: &TopicInfo, q: QoSProfile)
        -> Result<MyProtoPub, TransportError> { todo!() }
    fn create_subscription(&mut self, t: &TopicInfo, q: QoSProfile)
        -> Result<MyProtoSub, TransportError> { todo!() }
    fn create_service(&mut self, s: &ServiceInfo, q: QoSProfile)
        -> Result<MyProtoServer, TransportError> { todo!() }
    fn create_client(&mut self, s: &ServiceInfo, q: QoSProfile)
        -> Result<MyProtoClient, TransportError> { todo!() }
    fn close(&mut self) -> Result<(), TransportError> { todo!() }
    fn drive_io(&mut self, timeout_ms: i32) -> Result<(), TransportError> {
        let _ = timeout_ms; Ok(()) // poll network, dispatch to buffers
    }
}

pub struct MyProtoPub;
impl Publisher for MyProtoPub {
    type Error = TransportError;
    fn publish_raw(&self, data: &[u8]) -> Result<(), TransportError> { todo!() }
    fn buffer_error(&self) -> TransportError { TransportError::BufferTooSmall }
    fn serialization_error(&self) -> TransportError { TransportError::SerializationError }
}

pub struct MyProtoSub;
impl Subscription for MyProtoSub {
    type Error = TransportError;
    fn take_serialized(&mut self, buf: &mut [u8])
        -> Result<Option<usize>, TransportError> { todo!() }
    fn deserialization_error(&self) -> TransportError { TransportError::DeserializationError }
}

pub struct MyProtoServer;
impl ServiceTrait for MyProtoServer {
    type Error = TransportError;
    fn take_request<'a>(&mut self, buf: &'a mut [u8])
        -> Result<Option<ServiceRequest<'a>>, TransportError> { todo!() }
    fn send_response(&mut self, seq: i64, data: &[u8])
        -> Result<(), TransportError> { todo!() }
}

pub struct MyProtoClient;
impl ClientTrait for MyProtoClient {
    type Error = TransportError;
    fn send_request_raw(&mut self, req: &[u8])
        -> Result<(), TransportError> { todo!() }
    fn take_response_raw(&mut self, buf: &mut [u8])
        -> Result<Option<usize>, TransportError> { todo!() }
}
}

Pick the right TransportError variant

The runtime maps every TransportError variant to a named rmw_ret_t constant at the C boundary. Picking a specific variant gives the caller actionable information instead of an opaque “failed somehow”:

VariantWhen to use
TimeoutBounded wait elapsed (drive_io).
WouldBlockResource momentarily unavailable; caller should retry. Distinct from NoData — NoData means the queue is empty, WouldBlock means it’s contended.
NoDataNon-blocking receive found the queue empty.
BufferTooSmallCaller’s &mut [u8] smaller than the next message.
MessageTooLargeIncoming message exceeds the backend’s static capacity.
InvalidArgumentNULL pointer, out-of-range value, missing required config.
UnsupportedBackend genuinely cannot perform this operation (e.g., uORB has no service surface).
IncompatibleQosEndpoints’ QoS profiles differ in a way the backend cannot reconcile.
TopicNameInvalidTopic / service name failed validation (empty, too long, illegal characters).
BadAllocAllocation failed on a heap-equipped backend.
LoanNotSupportedLending requested on an entity that doesn’t support it.
ConnectionFailed / DisconnectedTransport-level failures.
PublisherCreationFailed / SubscriberCreationFailed / ServiceServerCreationFailed / ServiceClientCreationFailedCatch-all for entity creation when no more specific code applies.
Backend(&str) / BackendDynamic(String)Backend-specific diagnostic that doesn’t map to any of the above.

The Rust trait surface is the source of truth; the C header <nros/rmw_ret.h> exposes the matching NROS_RMW_RET_* constants for C porters.

Factory shape

Rmw::open consumes self, not a &self. That shape asks every backend to treat its factory type as a value that carries any pre-open configuration (agent address, serial device, TLS CA, …) and moves that state into the returned Session:

// Default constructor (picks config from `&RmwConfig`):
let session = MyProtoRmw::default().open(&config)?;

// Explicit constructor when the backend has pre-open state
// that isn't in the middleware-agnostic `RmwConfig`:
let session = MyProtoRmw::with_endpoint("10.0.0.1", 7447).open(&config)?;

Conventions:

  • Every backend implements Default. Keeps the common call site short and lets generic code build a factory without knowing the backend type.
  • Provide new(...) / with_*(...) helpers for backend-specific pre-open state. Don’t bake it into RmwConfig — that type is the middleware-agnostic contract. If your backend needs an agent IP, a serial device path, or a certificate slot, take it on the factory constructor.
  • Read your own environment variables in <Backend>::from_env() if you want zero-boilerplate POSIX configuration. The shipped ExecutorConfig::from_env() only reads the middleware-agnostic NROS_LOCATOR / NROS_SESSION_MODE / ROS_DOMAIN_ID; anything backend-specific (e.g. NROS_XRCE_AGENT) stays on the backend side.
  • Post-open state lives in Session, never in static mut. The open(self, …) signature makes it natural to move the configured transport into the Session return value, which then owns the connection for the rest of its lifetime. A backend that still uses static mut session-global state will fail any multi-session test (backend.open(...) twice in one process should succeed).

3. Wire into nros

Three changes are needed to integrate the new backend:

a) In nros/Cargo.toml, add a feature and optional dependency:

rmw-myproto = ["dep:nros-rmw-myproto", "nros-node/rmw-myproto"]

b) In nros-node, add the concrete session type alias:

#![allow(unused)]
fn main() {
#[cfg(feature = "rmw-myproto")]
pub type ConcreteSession = nros_rmw_myproto::MyProtoSession;
}

c) Add compile_error! guards to enforce mutual exclusivity with the other backends (see existing guards in nros-node/src/session.rs).

Applications then select your backend with nros = { features = ["rmw-myproto", "platform-posix"] }.


C/C++ path

If your transport library is C or C++, use nros-rmw-cffi — a vtable of C function pointers that map one-to-one onto the Rust trait methods.

The hand-written header lives at packages/core/nros-rmw-abi/include/nros/rmw_vtable.h. Browse the rendered reference at /api/rmw-cffi/ for per-field return-value, threading, and blocking conventions.

1. Fill in the vtable

#include <nros/rmw_vtable.h>
#include <nros/rmw_ret.h>
#include <nros/rmw_entity.h>

// -- Session lifecycle --
static rmw_ret_t my_create_session(const char *locator, uint8_t mode,
                              uint32_t domain_id, const char *node_name,
                              rmw_session_t *out) {
    /* Connect. On success: write out->backend_data with your session
     * pointer, return NROS_RMW_RET_OK. On failure: return one of the
     * named codes (NROS_RMW_RET_INVALID_ARGUMENT, NROS_RMW_RET_TIMEOUT,
     * NROS_RMW_RET_BAD_ALLOC, NROS_RMW_RET_ERROR). out->node_name is
     * already set by the runtime — read it for diagnostics. */
}
static rmw_ret_t my_destroy_session(rmw_session_t *session) { /* ... */ }
static rmw_ret_t my_drive_io(rmw_session_t *session, int32_t timeout_ms) {
    /* Dispatch network I/O for up to timeout_ms; return NROS_RMW_RET_OK
     * on success, NROS_RMW_RET_TIMEOUT / NROS_RMW_RET_ERROR otherwise. */
}

// -- Publisher --
static rmw_ret_t my_create_publisher(
        rmw_session_t *session,
        const char *topic, const char *type_name, const char *type_hash,
        uint32_t domain_id, const rmw_qos_profile_t *qos,
        const rmw_publisher_options_t *options,  /* hints (tx_express); NULL = defaults */
        rmw_publisher_t *out) {
    /* Runtime has already filled out->topic_name / type_name / qos.
     * Backend writes out->backend_data with its publisher handle.
     *
     * Do NOT write out->can_loan_messages: the runtime DERIVES it from
     * whether your vtable fills borrow_loaned_message, and overwrites
     * whatever you put there (issue 0814). You advertise the
     * loan_publish / commit_publish primitive by IMPLEMENTING it, which
     * is the one statement that cannot disagree with itself. To refuse a
     * loan for one particular publisher, return RMW_RET_UNSUPPORTED from
     * borrow_loaned_message. */
}
static void           my_destroy_publisher(rmw_publisher_t *publisher) { /* ... */ }
static rmw_ret_t my_publish_raw(rmw_publisher_t *publisher,
        const uint8_t *data, size_t len) { /* ... */ }

// -- Subscription --
static rmw_ret_t my_create_subscription(
        rmw_session_t *session,
        const char *topic, const char *type_name, const char *type_hash,
        uint32_t domain_id, const rmw_qos_profile_t *qos,
        const rmw_subscription_options_t *options,  /* hints (rx_buffer_hint); NULL = defaults */
        rmw_subscription_t *out) { /* ... */ }
static void    my_destroy_subscription(rmw_subscription_t *subscription) { /* ... */ }
static int32_t my_take_serialized(rmw_subscription_t *subscription,
        uint8_t *buf, size_t buf_len) {
    /* >= 0 = bytes received (0 = no data),
     * negative rmw_ret_t (e.g. NROS_RMW_RET_NO_DATA, _BUFFER_TOO_SMALL). */
}
static int32_t my_has_data(rmw_subscription_t *subscription) { /* 1 = yes, 0 = no */ }

// -- Service (server side) --
static rmw_ret_t my_create_service(
        rmw_session_t *session,
        const char *service, const char *type_name, const char *type_hash,
        uint32_t domain_id, const rmw_qos_profile_t *qos,
        rmw_service_t *out) { /* ... */ }
static void    my_destroy_service(rmw_service_t *server) { /* ... */ }
static int32_t my_take_request(rmw_service_t *server,
        uint8_t *buf, size_t buf_len, int64_t *seq_out) { /* ... */ }
static int32_t my_has_request(rmw_service_t *server) { /* 1 = yes, 0 = no */ }
static rmw_ret_t my_send_reply(rmw_service_t *server,
        int64_t seq, const uint8_t *data, size_t len) { /* ... */ }

// -- Client --
static rmw_ret_t my_create_client(
        rmw_session_t *session,
        const char *service, const char *type_name, const char *type_hash,
        uint32_t domain_id, const rmw_qos_profile_t *qos,
        rmw_client_t *out) { /* ... */ }
static void    my_destroy_client(rmw_client_t *client) { /* ... */ }
static rmw_ret_t my_send_request_raw(rmw_client_t *client,
        const uint8_t *request, size_t req_len) {
    /* Send without blocking for the reply; return NROS_RMW_RET_OK. */
}
static int32_t my_take_response_raw(rmw_client_t *client,
        uint8_t *reply_buf, size_t reply_buf_len) {
    /* >= 0 = reply bytes, NROS_RMW_RET_NO_DATA = no reply yet. */
}

static const nros_rmw_vtable_t MY_RMW = {
    .create_session      = my_create_session,
    .destroy_session     = my_destroy_session,
    .drive_io            = my_drive_io,
    .create_publisher    = my_create_publisher,
    .destroy_publisher   = my_destroy_publisher,
    .publish         = my_publish_raw,
    .create_subscription = my_create_subscription,
    .destroy_subscription = my_destroy_subscription,
    .take        = my_take_serialized,
    .has_data            = my_has_data,
    .create_service      = my_create_service,
    .destroy_service     = my_destroy_service,
    .take_request    = my_take_request,
    .has_request         = my_has_request,
    .send_response          = my_send_reply,
    .create_client       = my_create_client,
    .destroy_client      = my_destroy_client,
    .send_request    = my_send_request_raw,
    .take_response  = my_take_response_raw,
};

2. Register before opening a session

int main(void) {
    nros_rmw_cffi_register_named("my_rmw", &MY_RMW);    // before any nros call
    /* now use the nano-ros C or C++ API normally */
}

Build the static library with the matching feature combo:

cargo build -p nros-c --features rmw-cffi,platform-posix,ros-humble

3. Lifecycle and threading contract

The Rust traits behind this vtable (nros_rmw::Session, Publisher, …) document the per-method contract: thread safety, buffer ownership, blocking allowance. The C vtable inherits the same rules:

  • The vtable itself is registered once and read concurrently. Function pointers must be safe to invoke from any executor thread.
  • drive_io may block up to timeout_ms; it must not hold application-visible locks across the wait.
  • publish, take, and send_response may run concurrently from different executor threads — your backend handles serialisation.
  • take, take_request, and take_response are non-blocking: return 0 / NROS_RMW_RET_NO_DATA if nothing is ready. The executor will retry after drive_io.
  • There is no blocking client slot (phase-301 deleted the deprecated call_raw): send_request + take_response is the one request/reply path; the executor drives the wait.

All strings are null-terminated and borrowed (caller owns the storage). Entities are typed structs with an opaque backend_data slot — the runtime fills metadata fields (topic_name, qos, …) before calling create_*; the backend writes backend_data. Return convention: NROS_RMW_RET_OK = success, negative = named rmw_ret_t constant, positive = byte count (only on take_*).


Example: local echo RMW

Loops published messages back to subscribers – no real transport. Only pub/sub shown; service types are no-op stubs.

#![allow(unused)]
fn main() {
static mut ECHO_BUF: [u8; 1024] = [0; 1024];
static mut ECHO_LEN: usize = 0;

pub struct EchoPub;
impl Publisher for EchoPub {
    type Error = TransportError;
    fn publish_raw(&self, data: &[u8]) -> Result<(), TransportError> {
        unsafe {
            let len = data.len().min(ECHO_BUF.len());
            ECHO_BUF[..len].copy_from_slice(&data[..len]);
            ECHO_LEN = len;
        }
        Ok(())
    }
    fn buffer_error(&self) -> TransportError { TransportError::BufferTooSmall }
    fn serialization_error(&self) -> TransportError { TransportError::SerializationError }
}

pub struct EchoSub;
impl Subscription for EchoSub {
    type Error = TransportError;
    fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, TransportError> {
        unsafe {
            if ECHO_LEN == 0 { return Ok(None); }
            let len = ECHO_LEN;
            buf[..len].copy_from_slice(&ECHO_BUF[..len]);
            ECHO_LEN = 0;
            Ok(Some(len))
        }
    }
    fn deserialization_error(&self) -> TransportError { TransportError::DeserializationError }
}
}

Wire EchoPub/EchoSub into an EchoSession the same way as the skeleton above – create_publisher returns Ok(EchoPub), etc. The Rmw::open() impl just returns Ok(EchoSession) unconditionally.


What the ROS 2 ecosystem expects

Implementing the six traits compiles and runs, but a backend that stops there will not interoperate cleanly with ros2 CLI, RQt, or rmw_zenoh_cpp nodes. Real ROS 2 interop requires four extra invariants the traits do not express:

1. Discovery / liveliness tokens

ros2 node list, ros2 topic list, ros2 service list rely on discovery traffic. How you emit it depends on the backend protocol:

  • Zenoh-flavoured backends: publish a liveliness token per endpoint under @ros2_lv/<domain>/<zid>/<entity_kind>/<id>/…. See rmw-zenoh-protocol.md for the exact key grammar.
  • DDS-flavoured backends: use the SPDP/SEDP discovery traffic that your DDS stack provides, plus the ROS 2–specific USER_DATA payload (node name, namespace, enclave).

If your backend is brand new (not wire-compatible with zenoh or DDS), you still need some discovery channel for ros2 CLI tools to find your endpoints. The traits currently do not cover this — discovery happens inside create_publisher / create_subscription / create_service / create_client as a side effect.

2. RMW attachments (per-message metadata)

Every published message carries ROS 2 metadata that consumers read through MessageInfo:

FieldSizeMeaning
sequence_number8 bytesint64 LE — monotonic per publisher
timestamp8 bytesint64 LE — source nanoseconds
gid16 bytesrandom per publisher, constant over its lifetime
  • Zenoh: the attachment rides alongside the payload as a zenoh Attachment. Humble uses a simple concatenation; Jazzy onward uses a VLE-encoded gid_length prefix.
  • DDS: sample identity and source timestamp fall out of the DDS sample info — your backend only has to forward them.

If you skip this, subscriptions built with the .message_info() builder (node.subscription(topic).generic(...).message_info().build(cb), or .typed::<M>().message_info()) on consumers always report MessageInfo::default(), and downstream features (safety-e2e checks, source-timestamp ordering) silently degrade.

3. Actions decompose into five underlying channels

ROS 2 actions are not a transport primitive — they are a pattern built on services and topics. Each action server exposes:

Sub-entityKindName suffix
send_goalService_action/send_goal
cancel_goalService_action/cancel_goal
get_resultService_action/get_result
feedbackTopic (pub)_action/feedback
statusTopic (pub)_action/status

A backend that implements the six core traits automatically supports actions — nros-node composes the five channels itself. The only backend-specific piece is the key / topic construction, which the Session::create_service and create_publisher methods already handle.

4. Type hashes

ROS 2 distroType hash
Humbleliteral string "TypeHashNotSupported"
Iron / Jazzy / RollingRIHS01_<sha256_hex> computed from the IDL

Humble is the DEFAULT, not the ceiling: --ros-edition {humble,iron,jazzy,rolling} selects the edition, and iron/jazzy emit the real REP-2011 hash — computed in-tree, not read out of an ament index (phase-41, complete). A new backend inherits that: TopicInfo::type_hash arrives already filled in for the selected edition, so the backend’s job is to put it in the right place on its own wire, not to compute it. On zenoh that place is the keyexpr tail; get it wrong and delivery fails silently, because a peer that disagrees about the hash simply never matches.

5. QoS mapping

ROS 2 QoS (reliability, durability, history, depth) maps differently onto each backend:

  • Zenoh has reliable/best-effort and a KEEP_LAST(N) / KEEP_ALL buffering policy — direct mapping.
  • DDS has native QoS — almost 1:1.
  • Custom backends must either honour the requested QoS or document which fields are ignored. A best_effort publisher matched with a reliable subscriber is a QoS mismatch in ROS 2 — the transport must refuse the subscription (or flag it at runtime) rather than silently lose messages.

Further reading

  • RMW API Reference – full trait signatures, QoS profiles, error types, configuration structs.
  • RMW API Design – architectural motivation and comparison with the ROS 2 rmw interface.
  • RMW API: Differences from upstream rmw.h – side-by-side C-API comparison for porters coming from upstream ROS 2 RMW backends; covers the renames, the collapses, and the reason for each.
  • Zenoh-pico Symbol Reference – FFI symbol mapping for the zenoh-pico backend (useful as a reference for how an existing backend is structured).

Platform Porting Pitfalls

Hard-won lessons from porting nros to bare-metal ARM (QEMU MPS2-AN385), ESP32-C3, STM32F4, FreeRTOS, NuttX, ThreadX, and Zephyr. Read this before adding a new platform or debugging mysterious failures on an existing one.

Memory Layout

DMA buffer placement

On MCUs with multiple SRAM regions, DMA descriptors and buffers must be in DMA-accessible memory. The STM32F4 has 64 KB of CCM RAM that is NOT DMA-accessible — placing Ethernet descriptors there causes silent data corruption with no error interrupt.

Fix: Use linker script sections to force placement:

/* stm32f4.x — place DMA descriptors in SRAM1, not CCM */
.eth_descriptors (NOLOAD) : ALIGN(4) {
    *(.eth_descriptors .eth_descriptors.*)
} > RAM

In Rust, annotate the buffer:

#![allow(unused)]
fn main() {
#[link_section = ".eth_descriptors"]
static mut ETH_DMA_DESC: MaybeUninit<[u32; 256]> = MaybeUninit::uninit();
}

Stack sizing

Bare-metal and RTIC applications share a single stack. FreeRTOS/Zephyr give each task its own stack. In both cases, zenoh-pico’s internal processing needs significant stack space:

ContextMinimum stack
Bare-metal / RTIC main stack8 KB
FreeRTOS zenoh read task2 KB
FreeRTOS zenoh lease task1 KB
Zephyr zpico work queue thread2 KB

Stack overflow on embedded targets causes silent corruption — there is no guard page. FreeRTOS can detect overflow with configCHECK_FOR_STACK_OVERFLOW=2 and the high-water-mark API (uxTaskGetStackHighWaterMark), but only after the fact.

Heap sizing

zenoh-pico allocates heap memory during session open, publisher/subscriber creation, and message processing. Typical heap consumption:

PlatformMinimum heap
MPS2-AN385 (bare-metal)64 KB
ESP32-C364 KB (~16 KB static + dynamic)
STM32F464 KB
FreeRTOS (lwIP + zenoh)256 KB (configTOTAL_HEAP_SIZE)

Undersized heaps cause _Z_ERR_SYSTEM_OUT_OF_MEMORY (-78) during z_open() or entity creation, not during message I/O.

Rust struct move invalidation

When Rust returns a struct from a function, it moves the value to a new address. If C code stored a pointer to the original location (e.g., during init()), that pointer is now dangling.

Symptom: Callback crashes or garbage field values after struct is returned from an init function.

Fix: Use static storage, Box::pin(), or index-based references instead of raw pointers. See Troubleshooting: Subscriber Callback Crashes.

Clock Sources

Use hardware timers, not poll-driven counters

Every platform must provide a monotonic millisecond clock via smoltcp_clock_now_ms() (for smoltcp timestamping) and z_clock_now() / z_clock_elapsed_*() (for zenoh-pico timeouts). This clock must be backed by a hardware timer or OS clock — never by a software counter that only advances when spin() or poll() is called.

A poll-driven clock causes:

  • Incorrect timeouts: zenoh-pico uses z_clock_elapsed_ms() for session keep-alive, query timeout, and transport timeout. If the clock only advances during poll, idle periods (waiting for network I/O, sleeping between spins) are invisible to the timeout logic.
  • Timeout storms: After a long idle period, the next poll advances the clock by a large jump, causing multiple timeouts to fire simultaneously.
  • Broken RTIC integration: RTIC tasks yield with Mono::delay() between spins. A poll-driven clock doesn’t advance during the delay, making all timeouts effectively infinite.

Platform clock status

PlatformClock sourceNotes
MPS2-AN385 (zpico)CMSDK APB Timer0Hardware 25 MHz free-running counter. Wraps at ~171s, extended via WRAP_COUNT.
ESP32-C3esp_hal::time::InstantHardware timer, no manual updates needed.
ESP32-C3 (QEMU)esp_hal::time::InstantWorks with QEMU -icount 3.
STM32F4ARM DWT cycle counterHardware-backed. update_from_dwt() reads elapsed DWT cycles and advances the software counter. Must be called at least once per ~25.6s (at 168 MHz) to avoid missing DWT wraps.
FreeRTOSFreeRTOS kernel tickOS-managed via xTaskGetTickCount().
NuttXPOSIX clock_gettime()OS-managed.
ZephyrZephyr kernel tickOS-managed via k_uptime_get().
POSIX (native)std::time::InstantOS-managed.
XRCE MPS2-AN385CMSDK APB Timer0Same hardware timer as zpico MPS2-AN385. Call init_hardware_timer() before use.

Porting checklist for new platforms

  1. Identify a hardware timer — SysTick, a general-purpose timer, or DWT cycle counter. On RTOS platforms, use the OS tick API.
  2. Export smoltcp_clock_now_ms() -> u64 — called by zpico-smoltcp for TCP/IP timestamping.
  3. Export zenoh-pico clock symbols — z_clock_now(), z_clock_elapsed_us/ms/s(), z_clock_advance_us/ms/s(). These read from the same underlying counter.
  4. Never advance the clock inside smoltcp_network_poll() — the poll callback runs during network I/O and is not a reliable time source. Read the hardware timer directly.
  5. Handle timer wraps — 32-bit timers wrap. Track wrap count in an atomic or use a 64-bit hardware counter if available.

QEMU clock synchronization

QEMU’s virtual clock races ahead of wall-clock time during WFI, causing hardware timer-backed timeouts to fire before TAP network I/O completes. This is solved with -icount shift=auto, which makes virtual time advance at wall-clock speed during CPU sleep states.

See QEMU icount reference for the full explanation, parameter reference, and tradeoffs.

Networking

Ephemeral port conflicts

smoltcp’s ephemeral port allocator starts at port 49152 with a static counter that resets to zero on each boot. When QEMU instances are killed and restarted, the new instance picks the same source port, creating a 4-tuple collision with stale host-side TCP sockets.

Symptom: ConnectionFailed or TransportOpenFailed on the second test run. ss -tnap shows FIN-WAIT-1 sockets from the previous QEMU instance.

Fix (test infrastructure): Kill stale host TCP sockets between test runs:

#![allow(unused)]
fn main() {
// Clean stale TCP connections to QEMU IPs
for ip in &["192.0.3.10", "192.0.3.11"] {
    let _ = Command::new("ss")
        .args(["-K", "dst", ip])
        .status();
}
}

Fix (Zephyr native_sim): Pass different --seed values to each instance so the entropy source produces different ephemeral ports:

./build-listener/zephyr/zephyr.exe --seed=12345
./build-talker/zephyr/zephyr.exe --seed=67890

QEMU single-threaded I/O starvation

QEMU emulates the CPU and processes TAP network I/O in one thread. If the guest never executes WFI (Wait For Interrupt), QEMU never yields to its I/O event loop, and the TAP file descriptor is never serviced.

Symptom: First few packets work (buffered), then all networking stops. Services and actions time out. Pub/sub may appear to work because subscriber callbacks fire inline during zp_read().

Fix: In RTIC, yield between spin_once() calls:

#![allow(unused)]
fn main() {
// CORRECT — yields to idle task → WFI → QEMU processes TAP
cx.local.executor.spin_once(0);
Mono::delay(10.millis()).await;

// WRONG — busy-loops, starving QEMU I/O
loop {
    cx.local.executor.spin_once(0);
}
}

In bare-metal (no RTIC), call cortex_m::asm::wfi() in your idle loop.

TAP device assignment

Each QEMU peer must use a different TAP device. Two QEMU instances on the same TAP cause packet collision and loss.

QEMU talker  → tap-qemu0 ─┐
                           ├── qemu-br (192.0.3.1/24)
QEMU listener → tap-qemu1 ─┘

Subscriber startup ordering

Zenoh does not buffer messages for unknown subscribers. If the publisher starts before the subscriber’s declaration propagates through the router, early messages are lost.

Rule: Start subscriber first, wait 5 seconds for stabilization, then start publisher.

TAP queue discipline

The default fq_codel qdisc drops packets when QEMU emulation is slow (CoDel’s default 5ms target interprets emulation pauses as congestion, and per-flow scheduling disrupts zenoh-pico’s service reply timing). This causes TCP data segments to be dropped, breaking zenoh session establishment.

Fix: Use pfifo instead:

sudo tc qdisc replace dev tap-qemu0 root pfifo limit 1000
sudo tc qdisc replace dev tap-qemu1 root pfifo limit 1000

pfifo queues packets without dropping, which QEMU needs to absorb bursts during emulation pauses. Stale packets from killed QEMU processes accumulate in the queue but are harmless — the firmware seeds smoltcp’s ephemeral port from the host’s wall clock via ARM semihosting SYS_TIME, so each QEMU run uses a different source port and stale packets are silently ignored.

See TAP qdisc analysis for why fq_codel and noqueue don’t work for QEMU TAP devices.

zenoh-pico

Z_FEATURE_INTEREST causes multi-client failures

When multiple zenoh-pico clients connect to the same router, the interest protocol’s write filter creation can fail with -78 (_Z_ERR_SYSTEM_OUT_OF_MEMORY) even when memory is available.

Fix: Disable in all builds:

#![allow(unused)]
fn main() {
// build.rs
cmake::Config::new(&zenoh_pico_path)
    .define("Z_FEATURE_INTEREST", "0")
    .define("Z_FEATURE_MATCHING", "0")
    .build();
}

Z_FEATURE_MATCHING depends on Z_FEATURE_INTEREST — both must be disabled.

See Troubleshooting: zenoh-pico Multiple Client Issues.

Blocking TCP reads in cooperative schedulers

_z_read_tcp() can block for up to SOCKET_TIMEOUT_MS (default 10 seconds). In cooperative schedulers (RTIC, bare-metal main loop), this blocks the entire system since C FFI cannot yield.

Impact: Pub/sub works (callbacks fire inline during zp_read()), but services and actions fail because z_get() query replies need multiple zp_read() cycles, and the 5-second query timeout expires while _z_read_tcp blocks.

Current status: zpico_spin_once(0) is non-blocking (single_read=true), but the underlying z_get() timeout is still 5 seconds. RTIC service/action E2E tests are #[ignore]d pending a fully non-blocking TCP read path.

Version pinning

The zenoh router is ROS 2’s own (ros2 run rmw_zenoh_cpp rmw_zenohd, RFC-0075) — nano-ros no longer vendors one, so the router version always matches the rmw_zenoh_cpp your ROS nodes use. zenoh-pico is pinned at zpico-sys/zenoh-pico/ (1.7.2); its wire protocol is stable across zenoh 1.x, so it interoperates with a newer ROS-shipped router (issue 0291). A transport-level failure (-100, _Z_ERR_TRANSPORT_TX_FAILED) that looks like a network issue can still be a protocol-major mismatch — check both versions before debugging the network.

ESP32-C3 (RISC-V)

picolibc errno TLS crash

picolibc’s errno.h uses __thread (thread-local storage), which crashes on bare-metal RISC-V because TLS is not initialized. The crash is a hard fault with no useful backtrace.

Fix: Shadow errno.h with a non-TLS version:

// errno_override.h — no __thread, just a global
extern int errno;
#define EPERM  1
#define ENOENT 2
// ... subset of errno codes

Include this with -include errno_override.h in CFLAGS before picolibc headers.

Flash image format

ESP32-C3 QEMU requires a merged flash image, not a raw binary:

espflash save-image --merge --chip esp32c3 target/riscv32imc-unknown-none-elf/release/app app.bin

The --merge flag creates a 4 MB image with bootloader, partition table, and application combined.

FreeRTOS

Task priority inversion

The LAN9118 RX FIFO poll task and zenoh-pico read task must run at the same priority (typically priority 4). If the read task has higher priority, it monopolizes the CPU, preventing RX FIFO drain. TCP keep-alives are missed, and zenoh sessions expire.

Similarly, lwIP’s tcpip_thread must run at the same priority. Lower priority stalls packet processing.

Recursive mutexes required

zenoh-pico’s FFI layer requires recursive mutexes. Enable in FreeRTOS config:

#define configUSE_RECURSIVE_MUTEXES 1

Interrupt priority constraints

On Cortex-M3 (3-bit priority, 8 levels): ISRs at priority >= 5 (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY) cannot call FreeRTOS APIs. Ethernet IRQ handlers that signal tasks must use a lower (numerically higher) priority.

Build System

Parallel test build races

When nextest runs test files in parallel and multiple tests build the same example with different features, cargo fingerprinting creates race conditions — one test overwrites the binary another test is about to execute.

Fix: Use --target-dir for each feature variant:

#![allow(unused)]
fn main() {
Command::new("cargo")
    .args(["build", "--release", "--features", "safety-e2e"])
    .arg("--target-dir").arg("target-safety")
    .status()?;
}

Add each target dir to the example’s .gitignore:

/target/
/target-safety/
/target-zero-copy/

CMake cache invalidation

Changes to CMake defines (like Z_FEATURE_INTEREST=0) are cached. Changing the value without cleaning the build has no effect.

Fix:

# Cargo (native)
cargo clean -p zpico-sys && cargo build

# Zephyr (west)
west build --pristine

Feature flag mutual exclusivity

The three platform axes — RMW backend, platform, ROS edition — are mutually exclusive within each axis. Enabling two platforms (e.g., platform-posix,platform-zephyr) causes compile errors with confusing messages about duplicate symbol definitions, not a clear “pick one” error.

Test Infrastructure

Orphan process prevention

When nextest is killed (Ctrl-C, OOM, timeout), child processes (zenohd, QEMU) can become orphans that hold ports and TAP devices.

Fix: Use PR_SET_PDEATHSIG(SIGKILL) in the pre-exec hook:

#![allow(unused)]
fn main() {
use std::os::unix::process::CommandExt;

let mut cmd = Command::new("zenohd");
unsafe {
    cmd.pre_exec(|| {
        // Kill this child when parent dies
        libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
        // New process group for tree cleanup
        libc::setpgid(0, 0);
        Ok(())
    });
}
}

Important: Do not use pkill to clean up zenohd — other agents or users may have their own zenohd instances. Use process groups and death signals instead.

Sequential test execution for shared resources

Tests that use QEMU networking (TAP devices, zenohd on fixed ports) must run sequentially. Use nextest test groups:

# .config/nextest.toml
[test-groups.qemu-network]
max-threads = 1

Stale TCP connection cleanup

Between sequential QEMU tests, host-side TCP sockets may linger in FIN-WAIT-1 (the QEMU peer was killed before completing the TCP close handshake). These cause 4-tuple collisions when the next QEMU instance reuses the same source port.

Fix: Call ss -K dst <ip> between tests to force-destroy stale sockets:

#![allow(unused)]
fn main() {
pub fn cleanup_tap_network() {
    for ip in &["192.0.3.10", "192.0.3.11"] {
        let _ = Command::new("ss")
            .args(["-K", "dst", ip])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
    std::thread::sleep(Duration::from_secs(1));
}
}

Design Overview

This section explains why nano-ros has the shape it does. It is not a tutorial (see Getting Started) and not an API reference (see the Reference chapters). It is the rationale a contributor needs to evaluate proposed changes, and the context a porter needs to make judgment calls when the Porting guides don’t cover their case.

Three design choices shape almost everything else:

1. The RMW layer was rewritten, not adopted

ROS 2’s rmw.h assumes a libc heap, an OS scheduler with preemptable threads, dynamic loaders, and middleware-owned background dispatch. None of those hold on a Cortex-M3 with 64 KB of RAM. nano-ros defines its own RMW abstraction (nros-rmw) that pushes I/O buffers to the caller, replaces wait sets with explicit drive_io(), and selects the backend at LINK time — reached through a registered C ABI vtable (RFC-0054), so a backend can be written in C or C++ as well as Rust. Graph discovery was dropped originally and is being added back deliberately, as visitor callbacks rather than caller-owned tables (phase-376 W4).

→ RMW API Design

2. Future/Promise unifies blocking and async, with no internal spin

rclcpp owns the spin loop (rclcpp::spin(node) blocks the thread it was called on); rclc has no future/promise type at all. Neither model works on a single-threaded MCU. nano-ros uses a single Promise<T> value that supports three patterns – callback-driven polling, blocking-with-timeout, and .await – and never owns the spin loop. Every blocking helper takes the executor as an argument and drives it internally.

→ Client Library Model

3. Platform traits are independent and contract-first

zenoh-pico needs a heap, threading, networking, and a clock. XRCE-DDS only needs a clock and four C function pointers. Bare-metal has no scheduler. Each capability is a separate trait the platform may stub if unsupported. Every method has an explicit behavior contract: blocking? may-fail? unsupported-fallback?

→ Platform API Design

How to read this section

If you are evaluating a new feature: start with the design page closest to the change (RMW for transport-level changes, Client Library for executor or callback-shape changes, Platform API for OS-primitive changes). Each page lists the constraints the current shape satisfies; a new design must satisfy them or argue for relaxing one.

If you are porting nano-ros to new hardware or a new transport: the Porting chapters tell you what to implement. Read the matching design page to understand why each trait exists, which is what you need when the porting guide leaves a judgment call to you.

Client Library Model: nros vs rclc / rclcpp / rclrs

The RMW layer decouples the transport from the client library. The client library is the user-facing API on top of RMW: nodes, executors, publishers, subscribers, services, clients, callbacks, futures. ROS 2 ships three: rclc (C, MCU-focused), rclcpp (C++, desktop), and rclrs (Rust, experimental). nano-ros adds a fourth – the nros-node crate plus its nros-c / nros-cpp wrappers.

This page explains the shape of nros-node and how it differs from the three official client libraries. For trait signatures see Rust API / C API / C++ API.

Side-by-side

Concernrclcrclcpprclrsnros
TargetMCU (Micro-XRCE-DDS)Desktop / robotDesktop (alpha)MCU + RTOS + desktop
LanguageC99C++17Rust (std)Rust (no_std) + C + C++14
Node ownershipApp owns node + executorshared_ptr<Node> + ExecutorArc<Node> + executorExecutor owns the session; node borrows from it
Executor modelSingle-threaded, polledSingle-/multi-threaded, owned threadSingle-threaded, polledSingle-threaded, polled
Spin entry pointrclc_executor_spin_some(&exec, timeout)rclcpp::spin(node) (blocks, owns thread)executor.spin() (blocks current thread)executor.spin_once(timeout_ms)
Blocking primitiveNone (callback-only)std::shared_future::wait_for(timeout)Promise::wait(timeout) (Tokio-style)Promise::wait(&mut executor, timeout_ms)
Async primitiveNonestd::shared_future + spin_until_future_completeimpl FuturePromise<T> (impls Future + manual poll + executor-driven blocking wait)
Heap requirementOptional (static allocator)RequiredRequiredOptional (caller buffers; only zenoh-pico transport heap)
Threading requirementNoneRequired (std::thread)Required (std::thread)None (single-threaded valid)

Future / Promise as the unifying primitive

The same Promise<T> value serves three usage patterns. None of the other client libraries achieves this with a single type.

Pattern 1 – callback-driven (no blocking call):

let promise = client.call(&request)?;
// ... continue running other work ...
executor.spin_once(10);
if let Some(reply) = promise.take()? {
    handle(reply);
}

Pattern 2 – blocking with timeout:

let mut promise = client.call(&request)?;
let reply = promise.wait(&mut executor, 5000)?;  // blocks up to 5s, drives I/O

Pattern 3 – async runtime (Tokio, Embassy, smol):

let reply = client.call(&request)?.await;  // Promise: Future<Output = T>

Contrast this with the alternatives:

  • rclcpp uses std::shared_future<Response>, which is bound to std::thread and std::condition_variable. There is no way to drive it on a cooperative single-threaded MCU. The blocking helper rclcpp::spin_until_future_complete() requires an owned thread.
  • rclc has no future or promise concept. The only way to receive a service reply is to register a callback that fires from rclc_executor_spin_some(). Building request/response chains becomes an explicit state machine.
  • rclrs has impl Future but no callback-poll path; you must .await from a Tokio task. No blocking-with-timeout helper that drives I/O internally.

In nano-ros, the same Promise carries a borrow of the standalone service client and a sequence number; take() polls it, wait() polls it in a loop while spinning the executor, and the Future impl integrates with any executor that calls register_waker(&Waker) underneath.

No internal spin

rclcpp::spin(node) blocks the calling thread inside the library and owns the dispatch loop. This works on Linux but cannot work on bare-metal: there is no thread to give up, and the application loop must remain in user code (for power management, interrupt servicing, smoltcp polling, RTIC integration).

nano-ros never owns the spin loop. The user always writes the loop:

loop {
    executor.spin_once(10);
    // user can also: poll smoltcp, service interrupts, run other tasks, sleep
}

Convenience wrappers exist for desktop-style use cases – spin(SpinOptions) (rclrs’s name and shape: run until cancelled, a timeout expires, or a callback count is reached), spin_some(max) (rclcpp’s drain verb: execute what is ready, return), spin_forever(SpinOptions) (-> !, the body of an RTOS task) and spin_period(duration) (fixed-rate, drift-compensated) – but each is implemented as a spin_once() loop that the user could write by hand. spin and spin_period need alloc; spin_once, spin_some and spin_forever reach a no_std no-alloc target.

This is the reason every blocking API in nros takes &mut Executor. Promise::wait, Stream::wait_next, Client::call_blocking, the C++ Future::wait(executor.handle(), ...), and the C rclc_client_call(..., timeout_ms) all internally call spin_once() to keep I/O moving while waiting. They cannot rely on a background thread doing it for them, because there is none.

Executor is the session owner, not a singleton

In rclcpp, the Executor is a separate object that you attach nodes to (exec.add_node(node)). The node owns its own RMW context. Multiple executor classes (SingleThreadedExecutor, MultiThreadedExecutor, StaticSingleThreadedExecutor) exist as parallel implementations.

In nano-ros, the Executor is the RMW session owner. Calling Executor::open(&config) opens the transport; nodes are derived from the executor:

let mut executor = Executor::open(&config)?;
let mut node = executor.create_node("my_node")?;
let pub_ = node.create_publisher::<Int32>("/topic")?;

There is exactly one executor type. There is no separate “context” object, no add/remove-node lifecycle, no executor selection at runtime. The single-threaded model is baked in – adding a multi-threaded variant would require redesigning the callback dispatch, which we deliberately avoid to keep the bare-metal path viable.

The trade-off: you cannot share one transport session between two executors. In practice no embedded application wants this, and on desktop you can run multiple processes if you need it.

Explicit spin in blocking ops – why pass the executor?

Every blocking operation takes &mut Executor (Rust) or an executor handle (C/C++). This looks redundant when the executor is also the entity that created the operation. The reason is borrow-checker hygiene plus single-threaded I/O.

The standalone communication handles (StandaloneClient, StandaloneSubscription, the Promise returned from call) borrow from the session. They cannot also borrow the executor at the same time, because the executor owns the session. Passing &mut executor at the wait call – after the promise has been created and the borrow released – is the only way to get a mutable executor reference while a promise is in flight.

The deeper reason: there is no other thread that can drive I/O. If Promise::wait() did not have the executor, it could not call spin_once() – the network would freeze and the wait would always time out. By forcing the executor parameter, the API makes the I/O dependency explicit and impossible to forget.

Language parity

The same Future/Promise + explicit-executor model is preserved in C and C++ wrappers.

Rust uses Promise<'_, T> directly. It implements core::future::Future and exposes take() + wait(&mut executor, ms).

C++ wraps the promise as rclcpp::Future<T>:

auto fut = client.send_request(req);
ResponseType resp;
RCLCPP_TRY(fut.wait(executor.handle(), 5000, resp));

Future::wait() takes void* executor_handle (from executor.handle() or rclcpp::global_handle()) for the same reason the Rust API takes &mut executor. There is no global executor singleton; the handle is explicit.

C uses paired _async and blocking entry points:

// Blocking: drives the executor internally, returns the reply or an error.
size_t reply_len = 0;
rclc_client_call(&client, req_buf, req_len, reply_buf, sizeof(reply_buf), &reply_len);

// Async: returns immediately, result via callback registered ahead of time.
rclc_action_send_goal_async(client, &goal, sizeof(goal));
rclc_action_client_set_result_callback(client, on_result);
// User keeps calling rclc_executor_spin_some() to drive callbacks.

The C API has no Future/Promise type because C lacks generics, but the pattern is the same: an _async send-without-wait paired with a callback (or polled status), and a blocking call that drives the executor for you.

Summary

The four design choices that shape nano-ros’s client library:

  1. Single executor type that owns the session – no separate context object, no executor variants.
  2. No internal spin – the user always owns the spin loop; blocking helpers exist but are wrappers.
  3. Explicit &mut Executor on every blocking op – the API enforces the I/O dependency at the call site.
  4. Future/Promise as the unifying primitive – one type for callback-drive, blocking-with-timeout, and .await.

These choices are what make the same client library viable on a Cortex-M3 and a Linux workstation without a separate “MCU client library” like rclc.

RMW API Design: nros-rmw vs ros2/rmw

nano-ros defines its own RMW (ROS Middleware) abstraction in the nros-rmw crate. While it serves the same purpose as the official ros2/rmw interface – decoupling the client library from the transport backend – it is designed for no_std embedded systems and uses a fundamentally different approach.

This page documents the architectural differences and trade-offs. For trait signatures and the available backends, see RMW API Reference. For implementing a new backend, see Custom RMW Backend.

Why We Revised rmw.h

rmw.h was designed for ROS 2 on Linux: a process with a libc heap, an OS scheduler, dynamic loaders, and middleware-owned background threads. None of those assumptions hold on a Cortex-M3 with 64 KB of RAM. Each constraint below drove a specific change.

Heap availability

rmw.h heap-allocates everywhere – handles, serialized message buffers, wait sets, type support tables. Bare-metal targets often have no allocator; RTOS targets have allocators with hard total budgets (~16-256 KB) that must cover the application as well.

nros-rmw moves all I/O buffers to the caller. publish_raw(&[u8]) and take_serialized(&mut [u8]) operate on slices that the caller stack- or statically-allocates. Type metadata is a string-only TopicInfo struct, not a pointer-laden rosidl_message_type_support_t table. What the abstraction itself allocates is nothing. What the BACKENDS allocate is enumerated by scripts/rmw-alloc-sites.py, which reports each site with its file and line and — the distinction that decides whether you care — whether it sits on the STEADY-STATE path (per message, so it is latency and a heap that must sustain traffic) or in CREATE/INIT (bounded, so it lands in startup):

backendsteady-statecreate / init
Cyclone DDS18
XRCE-DDS07
uORB03
zenoh-pico00

Run the script rather than trusting this table; it is the re-runnable source and this is a snapshot of it. Two caveats it states and this page inherits: it counts nano-ros’s OWN sites, so allocations inside Cyclone below dds_write and inside zenoh-pico’s z_malloc are real but not listed (they are not ours to remove), and zenoh-pico’s internal transport buffers (~64 KB) reach the image through PlatformAlloc, where a bump allocator suffices on bare-metal.

Threading model

rmw.h assumes the middleware owns threads. rmw_wait() blocks the calling thread on a wait set; some implementations also spawn internal dispatch threads that fire callbacks asynchronously. Bare-metal has no scheduler; cooperative RTOS configurations can’t tolerate hidden threads.

nros-rmw replaces rmw_wait with Session::drive_io(timeout_ms) – a single call the executor invokes from its own (and only) thread. There is no wait set object, and no entity is implicitly polled by the middleware. The application drives all I/O explicitly. For async runtimes, subscribers and service clients expose register_waker(&Waker) so the transport’s C receive callback can wake a Rust future without a wait set abstraction.

Single-threaded callback dispatch

rmw.h permits multi-threaded executors and reentrant callbacks. Cooperative single-threaded targets cannot guarantee atomicity around RMW state without locks they don’t have.

nros-rmw assumes a single-threaded executor that owns the session for its lifetime. Callbacks run sequentially on the executor thread; no callback can preempt another. This eliminates the need for internal locking around publisher state, subscriber buffers, or service queues – a measurable code-size and runtime win on MCUs.

No dynamic discovery tables

rmw.h provides rmw_get_topic_names_and_types(), rmw_count_publishers(), rmw_get_node_names(), and similar graph-introspection APIs. These require maintaining a dynamic discovery cache, which costs heap and CPU continuously even when nothing reads it.

nros-rmw carried none of these for most of its life: discovery happened at the transport layer (zenoh liveliness, XRCE-DDS session establishment) and was never surfaced as queryable graph state.

That is changing. Phase-376 W4 added the graph-enumeration slots to the ABI — get_node_names, get_topic_names_and_types, count_publishers and the by-node variants — as visitor callbacks rather than allocated names-and-types arrays, so a backend walks its own discovery data and the caller never owns a table. The slots are declared; backend wiring is the in-flight part. The generated Per-RMW Feature Matrix is the current per-backend truth, and it is derived from the vtables rather than from this page.

Compile-time backend selection

rmw.h selects backends at runtime via dlopen() of librmw_*.so. This requires a dynamic loader (no embedded MCU has one) and forces every call through a vtable.

nros-rmw selects the backend at link time via Cargo features, and reaches it through a C ABI vtable (nros_rmw_vtable_t, RFC-0054) that the backend hands the runtime once via nros_rmw_cffi_register_named() before any session is created. There is no loader, no .so, and no path search: the only backends reachable are the ones linked into the image.

This page used to claim the opposite — “no vtables, no dynamic dispatch”, monomorphized through Rust generics. That was true before RFC-0054 made the C headers the SSoT so a backend could be written in C or C++ (Cyclone DDS is); nros-rmw’s Rust traits are still the surface a Rust backend implements, and rust_adapter.rs in nros-rmw-cffi is what turns such an impl into the vtable the runtime calls. The cost is one indirect call per operation; what it buys is a backend boundary that is not Rust-only.

Architectural Pattern

AspectROS 2 rmwnros-rmw
LanguageC API (rmw/rmw.h)Rust traits
DispatchRuntime plugin loading (shared library via rmw_implementation)Link-time selection; calls cross a C ABI vtable registered at startup (RFC-0054)
no_stdNo (requires libc, heap, POSIX)Yes — but “no heap” is a property of the BACKEND, not of the abstraction (see below)
Error modelrmw_ret_t integer codesrmw_ret_t at the ABI, using upstream rmw’s VALUES (phase-376 W3.d); TransportError on the Rust side

ROS 2 selects the RMW backend at runtime by loading a shared library (e.g., rmw_fastrtps_cpp.so). This enables switching backends without recompilation but requires a dynamic loader — which no MCU has.

nros-rmw links exactly the backends the image was built with and dispatches through a registered vtable. What that removes is the loader and the relocation work at startup, not the indirect call.

On heap: the abstraction adds none — I/O buffers are caller-owned, and entity handles are inline. The BACKENDS are another matter, and this page overstated it for years (“no heap”, flatly). Issue 0777 established that; scripts/rmw-alloc-sites.py now answers it precisely and repeatably. Only Cyclone DDS allocates on the steady-state path in nano-ros’s own code (1 site); XRCE and uORB allocate at entity/transport setup only. Plan the heap budget from your backend’s row in that report — and remember it excludes what the middleware libraries do underneath, which for Cyclone and zenoh-pico is a general allocator call per message regardless.

Object Model

ROS 2

ROS 2 rmw has a deep initialization hierarchy:

rmw_init() → rmw_context_t
  → rmw_create_node() → rmw_node_t
    → rmw_create_publisher() → rmw_publisher_t*
    → rmw_create_subscription() → rmw_subscription_t*
    → rmw_create_service() → rmw_service_t*
    → rmw_create_client() → rmw_client_t*

Nodes are first-class RMW objects. Each rmw_node_t carries its own context, name, namespace, and security credentials. The RMW layer is responsible for node lifecycle and graph participation.

nros-rmw

nros-rmw is flatter – there is no node at the RMW level:

Rmw::open(&RmwConfig) → Session
  → session.create_publisher(&TopicInfo, QoSProfile) → Self::PublisherHandle
  → session.create_subscription(&TopicInfo, QoSProfile) → Self::SubscriptionHandle
  → session.create_service(&ServiceInfo, QoSProfile) → Self::ServiceHandle
  → session.create_client(&ServiceInfo, QoSProfile) → Self::ClientHandle

Node lives one layer up in nros-node. It is purely a namespace and liveliness concern – it borrows the session from the executor and creates typed communication handles. The RMW layer only knows about sessions and communication endpoints.

Serialization Boundary

This is the most significant design difference.

ROS 2: The rmw layer operates on pre-serialized data. rcl and rosidl handle CDR serialization before calling rmw_publish() with an rmw_serialized_message_t. The rmw layer never sees typed messages – it only moves byte buffers. Type metadata is passed separately via rosidl_message_type_support_t structs.

nros-rmw: The traits include both raw and typed methods:

pub trait Publisher {
    // Raw: caller handles serialization
    fn publish_raw(&self, data: &[u8]) -> Result<(), Self::Error>;

    // Typed: serialize + publish in one call
    fn publish<M: RosMessage>(&self, msg: &M, buf: &mut [u8]) -> Result<(), Self::Error>;
}

pub trait Subscription {
    fn take_serialized(&mut self, buf: &mut [u8]) -> Result<Option<usize>, Self::Error>;
    fn take<M: RosMessage>(&mut self, buf: &mut [u8]) -> Result<Option<M>, Self::Error>;
}

The typed methods have default implementations that call the raw methods with CDR serialization/deserialization from nros-serdes. This keeps the RMW layer self-contained – no separate serialization layer is needed.

Type metadata uses simple structs (TopicInfo { name, type_name, type_hash }) instead of C type support function tables.

I/O and Readiness Model

ROS 2: Uses rmw_wait() with a wait set (rmw_wait_set_t) containing subscriptions, services, clients, guard conditions, and events. The caller constructs a wait set, adds handles, and blocks until any handle is ready. This is similar to select()/epoll().

nros-rmw: Uses a single drive_io(timeout_ms) method on the Session trait:

pub trait Session {
    fn drive_io(&mut self, timeout_ms: i32) -> Result<(), Self::Error> {
        let _ = timeout_ms;
        Ok(())
    }
}

This is a pull-based model: the executor calls drive_io() to poll the network and dispatch incoming data to internal subscriber buffers, then checks each entity with has_data(). There is no wait set – the executor iterates its dispatch table directly.

For async integration, subscribers and service clients expose register_waker(&Waker) instead of guard conditions. The transport backend calls waker.wake() from its C receive callback, bridging to Rust Future waking without the wait set abstraction.

Memory Model

ROS 2: Heap-allocates handles, messages, and serialization buffers. rmw_serialized_message_t wraps a dynamically-sized rcutils_uint8_array_t. Loaned message APIs (rmw_borrow_loaned_message, rmw_take_loaned_message) provide optional zero-copy for transports that support shared memory.

nros-rmw: Uses caller-provided &mut [u8] buffers everywhere. All receive and serialize operations write into stack-allocated or statically-allocated buffers:

// Caller provides the buffer
let mut buf = [0u8; 512];
let msg: Option<MyMsg> = subscriber.take(&mut buf)?;

Zero-copy receive is supported via process_raw_in_place(), which invokes a closure with a reference to the subscriber’s internal receive buffer, avoiding the copy into a caller-provided buffer. This is gated behind the unstable-zenoh-api feature.

QoS Settings

ROS 2 rmw_qos_profile_t includes:

FieldROS 2nros-rmw
History (keep last/all)YesYes
DepthYesYes
Reliability (reliable/best-effort)YesYes
Durability (volatile/transient local)YesYes
DeadlineYesNo
LifespanYesNo
Liveliness (automatic/manual)YesNo
avoid_ros_namespace_conventionsYesNo

nros-rmw provides the four QoS policies that zenoh-pico and XRCE-DDS can actually enforce. The time-based policies (deadline, lifespan, liveliness) are omitted because the supported transports do not implement them.

Standard QoS profiles (QOS_PROFILE_DEFAULT, QOS_PROFILE_SENSOR_DATA, QOS_PROFILE_SERVICES_DEFAULT, etc.) match their ROS 2 equivalents for interoperability.

Service Client Model

ROS 2: Service clients are always asynchronous at the rmw level. rmw_send_request() sends a request and returns a sequence number. The reply is retrieved later via rmw_take_response(), typically driven by rmw_wait().

nros-rmw: The same async split (phase-301 deleted the deprecated blocking call_raw path — like upstream, there is no blocking call at the RMW level):

pub trait ClientTrait {
    // Async: send request, poll for reply separately
    fn send_request_raw(&mut self, request: &[u8]) -> Result<(), Self::Error>;
    fn take_response_raw(&mut self, reply_buf: &mut [u8]) -> Result<Option<usize>, Self::Error>;
}

Blocking waits are composed above the RMW by the executor (which keeps driving drive_io between polls).

APIs Present in ROS 2 rmw but Absent in nros-rmw

Phase-376 is closing much of this table: several rows below have gained ABI slots, and the ones that are DECLINED now carry a written reason rather than silence. Treat the generated Per-RMW Feature Matrix as current and this table as the design rationale behind the original omissions.

ROS 2 rmw APIPurposeWhy absent
rmw_node_t / rmw_create_node()Node lifecycle at RMW levelNo longer absent (phase-376 W5/B1): entities are created ON a node, upstream’s shape — create_publisher and its siblings take a const rmw_node_t *. Node lifecycle still lives above the RMW in nros-node.
rmw_wait_set_t / rmw_wait()Multiplexed readiness waitingReplaced by drive_io() + per-entity has_data()
rmw_guard_condition_tWake wait set from application codeReplaced by register_waker(&Waker)
rmw_event_tQoS event callbacks (deadline missed, etc.)Partly present: subscription_event_init / publisher_event_init are ABI slots and zenoh wires them; the other backends leave them NULL.
rmw_get_topic_names_and_types()Graph introspectionSlot declared (phase-376 W4) as a VISITOR callback — the backend walks its own discovery data, the caller owns no table. Backend wiring in flight.
rmw_get_node_names()Node discoverySame — declared as a visitor.
rmw_count_publishers() / rmw_count_subscribers()Graph statisticsSame — declared, wiring in flight.
rosidl_message_type_support_tC type support tables for serializationReplaced by TopicInfo string metadata
rmw_serialize() / rmw_deserialize()Standalone serializationCDR handled by nros-serdes
rmw_borrow_loaned_message()Zero-copy shared memory publishNot supported (smoltcp/zenoh-pico don’t use shared memory)
Content-filtered topicsServer-side topic filteringNot supported

APIs Present in nros-rmw but Absent in ROS 2 rmw

nros-rmw APIPurpose
Publisher::publish<M>(msg, buf)Typed publish with built-in CDR serialization
Subscription::take<M>(buf)Typed receive with built-in CDR deserialization
Subscription::process_raw_in_place(f)Zero-copy in-place processing via closure
Subscription::take_validated()E2E safety validation (CRC-32 + sequence tracking)
ServiceTrait::handle_request<S>()Typed request handling with automatic CDR roundtrip
Session::drive_io(timeout_ms)Explicit network polling (ROS 2 rmw relies on middleware threads)

Summary

The core difference is that ROS 2 rmw is a C plugin interface designed for desktop systems with dynamic linking, heap allocation, and OS threading. nros-rmw is a Rust trait hierarchy designed for MCUs with static dispatch, stack allocation, and cooperative scheduling. The trade-off is flexibility (ROS 2 can swap backends at runtime) vs efficiency (nros eliminates all abstraction overhead at compile time).

Despite these differences, the two are wire-compatible when using the same transport. An nros node using nros-rmw-zenoh communicates with a ROS 2 node using rmw_zenoh_cpp through the same router, with matching QoS profiles and CDR encoding.

The zenoh pairing (phase-362 / RFC-0075)

The router is the one ROS ships — ros2 run rmw_zenoh_cpp rmw_zenohd. It links the same libzenohc.so that rmw_zenoh_cpp does, so it cannot drift from the RMW you are actually talking to, and it is what a ROS 2 deployment runs. nano-ros no longer ships a router of its own.

The table below is data to diff a future failure against, not a constraint we enforce. rmw_zenoh_cpp lives on your machine, installed by your distro; we cannot pin it, and the interesting number is not the ROS package version anyway.

sidecomponentobserved
hostrmw_zenoh_cpp router + RMWzenoh-c 1.6.2 (ROS 2 Humble, measured 2026-08-16)
firmwarezenoh-pico1.7.2

Read the zenoh version from the header, never from the package manager:

/opt/ros/<distro>/opt/zenoh_cpp_vendor/include/zenoh_configure.h   #define ZENOH_C "…"
packages/rmw/zenoh/zpico-sys/zenoh-pico/version.txt

The ROS package version (ros-humble-rmw-zenoh-cpp 0.1.9) is a wrapper version and says nothing about the zenoh inside it — issue 0609 measured that same package moving its vendored zenoh 1.2.0 → 1.8.0 in a patch-level bump, and reading the package version instead of the header is what produced a wrong version claim in that issue’s first filing.

The two sides need not match. Under zenoh’s 1.x wire guarantee a firmware pin should move for its own reasons — footprint, features, fixes — rather than to chase a host package.

RMW API: Differences from upstream rmw.h

This page is for developers who already know upstream ROS 2’s rmw/rmw.h and want to write — or just understand — a backend for nano-ros. Side-by-side: what rmw.h looks like, what nros-rmw-cffi looks like, and why nano-ros diverges.

The C signatures shown for nano-ros come from <nros/rmw_vtable.h>. The Rust trait counterparts are an alternative entry point for porters who already work in Rust; this page sticks to the C-vtable surface throughout.

Phase-301 rename. The vtable historically diverged from rmw’s vocabulary (subscriber, service_server, service_client, open/close). Phase-301 (issues 0240/0241) aligned it: the entity types are now rmw_subscription_t / rmw_service_t / rmw_client_t, the session slots are create_session / destroy_session, and the deprecated blocking call_raw slot is deleted (send_request + take_response is the one request/reply path). Transport hints (tx_express, rx_buffer_hint) moved out of rmw_qos_profile_t into per-create options structs. This page uses the new names throughout.

Looking for the exhaustive list? This page is the rationale — the divergences worth understanding before writing a backend. The complete table, every upstream symbol with both signatures side by side and the reason for each difference, is RMW API vs upstream — every symbol, and it is GENERATED, so it cannot drift from the headers the way prose can.

TL;DR

Concernupstream rmw.hnros-rmw-cffi
Plugin loadingdlopen("librmw_*.so") at runtimeSingle vtable registered at init
Init sequencermw_init_options_t → rmw_context_t → entitiesOne create_session() call, returns the session
Entity typesrmw_publisher_t / rmw_subscription_t / rmw_service_t / rmw_client_tTyped-with-opaque-tail: rmw_publisher_t / _subscription_t / _service_t / _client_t (visible metadata + opaque backend_data)
Waitrmw_wait(waitset, timeout) blocks the callerdrive_io(session, timeout_ms) drives I/O once
Serializationtypesupport-driven (rosidl)Pre-serialized CDR bytes only
Graph queriesrmw_get_topic_names_and_types, …None
QoS profilesFull DDS profile match between endpointsSame field set; per-backend support advertised; synchronous IncompatibleQos on create instead of runtime mismatch event
DDS eventsrmw_event_t (rmw_take_event)None
Loaned messagesOptional rmw_borrow_loaned_messageFirst-class loan_publish / loan_recv
Error returnsrmw_ret_t (RMW_RET_OK, …)rmw_ret_t (NROS_RMW_RET_OK, …) — same named-constant style

1. Plugin loading vs. compile-time backend

Upstream. rmw_implementation resolves the backend at process start by opening a shared library:

RCUTILS_DLLIMPORT
const char * rmw_get_implementation_identifier(void);

The runtime calls dlopen against the path encoded by RMW_IMPLEMENTATION and binds every rmw_* symbol through the loader.

nano-ros. The backend is linked into the binary. A C backend registers its vtable once before any nros call:

#include <nros/rmw_vtable.h>

static const nros_rmw_vtable_t MY_VTABLE = { ... };

int main(int argc, char** argv) {
    nros_rmw_cffi_register_named("my_rmw", &MY_VTABLE);
    rclc_support_init(&support, argc, argv, &allocator);
}

Why. Most embedded targets have no dynamic loader. Even where dlopen exists, a 32 KB Flash budget can’t afford to link every backend’s C client and pick at runtime. Compile-time selection cuts the binary by 60–80 % and lets the linker drop unused entity paths.

2. Init sequence

Upstream. Three-step init:

rmw_init_options_t options = rmw_get_zero_initialized_init_options();
rmw_init_options_init(&options, allocator);
rmw_context_t context = rmw_get_zero_initialized_context();
rmw_init(&options, &context);
/* ... use context to create rmw_node_t, then rmw_publisher_t, … */
rmw_shutdown(&context);

nano-ros. One step — a session covers what upstream splits across options + context:

rmw_session_t session = {0};
rmw_ret_t ret = vtable->create_session(locator, mode, domain_id, node_name, &session);
/* ... use &session to create_publisher / create_subscription / … */
vtable->destroy_session(&session);

Why. Upstream’s split is useful when an application owns multiple RMW instances with different options. Nano-ros assumes one session per process — one transport, one wire-protocol, one set of QoS defaults — so the options/context separation buys nothing.

3. Entity handles

Upstream. Every entity is a typed C struct with backend-private state hidden behind void * data:

typedef struct RMW_PUBLIC_TYPE rmw_publisher_t {
  const char * implementation_identifier;
  void * data;
  const char * topic_name;
  rmw_publisher_options_t options;
  bool can_loan_messages;
} rmw_publisher_t;

nano-ros. Hybrid: typed-with-opaque-tail. Each entity is a typed C struct exposing the metadata the runtime actually reads (topic name, type name, QoS, lending capabilities) inline; backend-private state stays behind an opaque backend_data pointer.

typedef struct rmw_publisher_t {
    const char *   topic_name;          /* borrowed; outlives the publisher */
    const char *   type_name;           /* borrowed */
    rmw_qos_profile_t qos;
    bool           can_loan_messages;   /* matches upstream's field of the same name */
    uint8_t        _reserved[7];        /* forward-compat; must be zero */
    void *         backend_data;        /* opaque */
} rmw_publisher_t;

rmw_ret_t (*create_publisher)(
    rmw_session_t * session,
    const char * topic_name, const char * type_name, const char * type_hash,
    uint32_t domain_id, const rmw_qos_profile_t * qos,
    const rmw_publisher_options_t * options,   /* transport hints; NULL = defaults */
    rmw_publisher_t * out);   /* runtime-allocated; backend fills */

Same shape for rmw_subscription_t (whose rmw_subscription_options_t carries rx_buffer_hint, as the publisher’s carries tx_express). Service entities (rmw_service_t, rmw_client_t) and rmw_session_t have no qos and no can_loan_messages — service-level QoS doesn’t generalise across non-DDS backends (see QoS, Section 7) and service request/reply uses the byte-buffer API rather than the loan primitive.

Forward-compat reserved bytes. Each entity carries an explicit _reserved[N] byte block (sized to fill the natural alignment slot before backend_data). New fields up to N bytes can be added later without changing struct size or the offset of any field after backend_data. Backends and runtime keep these bytes zero.

Storage ownership. The runtime allocates the entity-struct shell; the backend writes its backend_data into the runtime-supplied out-parameter at create_* time (can_loan_messages is derived by the runtime — see below). The backend never mallocs a struct shell — embedded targets cannot afford a per-entity heap allocation. destroy_* releases only the backend’s backend_data; the shell stays valid until the runtime drops its owner.

Differences from upstream’s rmw_publisher_t.

  • Borrowed strings, not backend-owned copies. Upstream’s topic_name points to a backend-allocated string copied at create_publisher time. Ours points to caller (runtime) storage that outlives the publisher — no allocation per entity.
  • No implementation_identifier field. Backend selection is compile-time (see Section 1); there’s no plugin loader to dispatch through, so no need to identify which backend owns a struct.
  • can_loan_messages matches upstream in shape, but the runtime DERIVES it (issue 0814). Same bool, same name, same meaning — true if the backend exposes the loan primitive (the CDR-byte zero-copy loan path) — except that a backend does not write it. Its value is exactly vtable->borrow_loaned_message != NULL, because that is the same fact stated twice, and the two spellings had drifted in both directions: nothing outside a test ever wrote true (so a zenoh build whose loans worked advertised that it could not loan), while a backend writing true with a NULL slot was believed. This paragraph used to claim the runtime “dispatches the publish path with no per-call branch” from the flag; it never did — dispatch reads the vtable slot on every call.
  • depth: uint16_t. Upstream uses 32-bit; embedded queue depths are 1–100, the 16-bit width saves 2 bytes × N entities.
  • Explicit _reserved[N] bytes. Upstream uses an embedded rmw_publisher_options_t struct as the extension point; we reserve raw bytes inline. Same forward-compat property — new fields up to N bytes don’t break ABI — without the indirection.

Why this shape. Fully-opaque void * (the previous nano-ros design) forced every introspection through a vtable callback. Upstream’s “expose every field, backend keeps them in sync” forces duplicated state. The typed-with-opaque-tail middle ground exposes exactly the fields the runtime reads — no callback indirection — and keeps backend implementation state private. The struct layout is ABI; adding or reordering fields is a major-version bump.

4. drive_io vs. rmw_wait

Upstream. Clients block on a waitset that aggregates entities:

rmw_ret_t rmw_wait(
  rmw_subscriptions_t * subscriptions,
  rmw_guard_conditions_t * guard_conditions,
  rmw_services_t * services,
  rmw_clients_t * clients,
  rmw_events_t * events,
  rmw_wait_set_t * wait_set,
  const rmw_time_t * wait_timeout);

The middleware can also spawn its own background threads that fire callbacks asynchronously.

nano-ros. The executor calls a single drive-I/O entry point:

rmw_ret_t (*drive_io)(rmw_session_t * session, int32_t timeout_ms);

The backend dispatches whatever receive / send / wakeup work is pending and returns within timeout_ms. There is no waitset, no guard-condition aggregation, no implicit middleware thread.

How the two models differ in practice

The two designs distribute work across different layers:

PhaseUpstream rmw_waitnano-ros drive_io
Build the wait setExecutor rebuilds a waitset every spin_once, adds every entityExecutor registers entities once at construction; no per-spin rebuild
Blockrmw_wait blocks the thread on a kernel waitable (DDS WaitSet, condvar, kqueue) until any entity is ready or timeoutdrive_io blocks (sleep-model backends) or polls (poll-model backends) for up to timeout_ms
Signal readinessWait primitive raises per-entity ready flags; backend writes flags into the waitset’s status arraysBackend’s RX worker pulls bytes; the data is “ready” by virtue of being received
Dispatch user callbackExecutor’s spin_once picks one ready entity, calls rmw_take, fires its user callbackBackend’s RX worker / drive_io loop fires user callbacks while it has work to do
Per spin_onceExactly one user callback runs (single-threaded executor)All ready callbacks run before drive_io returns

The upstream model separates wait (kernel-blockable) from dispatch (executor-controlled). nano-ros today fuses them — the backend handles both inside one call.

What this buys, what this costs

Fusing wins on:

  • No per-spin waitset rebuild. Upstream’s add_handles_to_wait_set allocates and walks every entity each iteration. On a 100-entity executor at 1 kHz that’s 100 000 per-second add/remove operations plus heap churn. nano-ros registers entities once; the backend tracks them statically.
  • No kernel-waitable per entity. Upstream’s per-entity ready flags need a per-entity wakeable resource (DDS Condition, eventfd, pipe). nano-ros’s backend uses one wait primitive per session; per-entity tracking is in user-space backend state.
  • Backend RX worker fires callbacks directly. zenoh-pico’s _z_session_read_task invokes user callbacks during its read. nano-ros’s drive_io drains it; upstream’s rmw_zenoh would still have to round-trip through rmw_take.

Fusing costs on scheduling control. Upstream’s “one callback per spin_once” rule gives the executor an opportunity between every two callbacks to:

  • Re-check timer expirations
  • Re-check guard conditions
  • Yield to higher-priority work (multi-threaded executor)
  • Apply per-callback priority ordering

nano-ros’s drive_io runs all ready callbacks back-to-back, then the spin loop processes timers + GCs between drive_io calls. For a 100 ms drive_io call that fires 10 sub callbacks in 80 ms, a timer that should have fired 5 ms in is delayed 75 ms.

Where this fits each RTOS execution model

Execution modelFits drive_io today?
Cooperative single-task (one task does ROS, no priority competition)Yes — no other task to preempt; entity scheduling fairness is moot
Async / tokio / Embassy (futures, wakers)Yes — spin_async drives futures; drive_io not used in the hot path
Preemptive priority RTOS, ROS at one priority (FreeRTOS / ThreadX / Zephyr typical)Partial — kernel preemption from higher-priority tasks works; ROS-internal entity scheduling is batch-FIFO, timer expiries can be delayed by long sub callbacks at the same priority
WCET-bounded real-time (RTIC, DO-178C)No — drive_io has unbounded execution time; callers needing per-callback WCET use the async path with explicit Waker integration instead
Time-triggered cyclicNo — no way to bound drive_io to a fixed wall-clock budget

The “Yes” rows are where nano-ros ships today. The “Partial” / “No” rows are addressed by the work below.

How RTOS cooperation will improve

Three forward-looking knobs land incrementally as the drive_io interface is extended. None breaks the default behaviour; each is opt-in for apps that need it.

  1. Backend-internal-deadline visibility. Session::next_deadline_ms() tells the executor when the backend’s next internal event (lease keepalive, heartbeat, ACK retransmit) is due. The executor caps drive_io’s timeout against it so the call doesn’t return sooner than expected on otherwise-quiet links. Saves one round-trip per quiet period.

  2. Per-call user-callback cap. drive_io accepts max_callbacks: an upper bound on user callbacks fired per call. Setting it to 1 reproduces upstream’s “one callback per spin_once” pattern. The runtime spin loop calls drive_io again to drain pending work, with timer / GC checks between iterations. Closes the priority-inversion footgun for preemptive priority RTOS.

  3. Wall-clock budget per call. drive_io accepts time_budget_ms: a wall-clock cap that bounds total time spent firing callbacks. Time-triggered cyclic apps configure a fixed slot per cycle; drive_io yields when the slot expires even if max_callbacks isn’t reached.

Once the cap (knob 2) ships, an additional refinement moves timer and guard-condition dispatch into the backend’s drive_io loop so the cap applies uniformly across all callback sources, not just backend-RX-driven ones. This unifies the dispatch path and makes max_callbacks = 1 mean “exactly one callback per spin_once, regardless of whether it’s a sub, service, timer, or guard condition.”

For the per-RTOS-model recommendations (which knobs to set, which defaults to use), see the RTOS Cooperation concepts page.

Why drive_io and not rmw_wait. Cooperative single-task runtimes (bare-metal, single-threaded RTOS) have one execution context. A waitset abstraction with kernel-blockable per-entity resources doesn’t fit — there is no kernel to provide them. drive_io makes the cooperative model explicit and lets backends pick the most efficient wait primitive available on their target (kernel block on multi-threaded; cooperative yield + WFI on bare-metal). The scheduling-control trade-off the upstream waitset gives up is recovered through the optional knobs above for apps that need it.

5. Serialization: CDR bytes, not typesupport

Upstream. Publish/take APIs accept typed messages plus typesupport pointers, and each backend implements serialization-from-typesupport itself:

rmw_ret_t rmw_publish(
  const rmw_publisher_t * publisher,
  const void * ros_message,
  rmw_publisher_allocation_t * allocation);

The backend dereferences ros_message according to a rosidl_message_type_support_t table to compute the wire bytes.

nano-ros. The runtime serializes upstream of the RMW. Backends receive already-CDR-encoded bytes:

rmw_ret_t (*publish)(rmw_publisher_t * publisher,
                     const uint8_t * data, size_t len);

int32_t (*take)(rmw_subscription_t * subscription,
                uint8_t * buf, size_t len);

Why. rosidl typesupport is heavy: dynamic dispatch through a function-pointer table per field type, dependence on dynamic allocators for nested sequences, megabytes of generated symbol tables linked even for one message. Splitting the layers means:

  • Codegen (nros_generate_interfaces() from CMake) writes a fixed-shape C struct per message and a single <MsgType>_serialize_cdr(...) function.
  • The runtime calls it before handing bytes to the RMW.
  • The RMW backend stays transport-only — no rosidl, no typesupport, no allocator coupling.

6. No graph cache

Upstream. Graph introspection sits in the RMW:

rmw_ret_t rmw_get_topic_names_and_types(...);
rmw_ret_t rmw_count_publishers(...);
rmw_ret_t rmw_get_node_names(...);

Implementations maintain a discovery cache that costs heap and CPU continuously even when nothing reads it.

nano-ros. None of the above. Backends do whatever discovery their transport mandates (zenoh liveliness, XRCE-DDS session establishment), but no get_topic_names / count_publishers / node_names exists in the vtable.

Why. Graph introspection is fundamentally a host-side debugging need. An MCU has no terminal, no ros2 topic list. Wire-protocol interop with rmw_zenoh_cpp means standard ROS 2 tools running on a laptop can introspect the same domain as the MCU — at zero cost on the MCU.

7. QoS: full DDS-shaped profile, per-backend support advertised

Upstream. Full DDS QoS profile family with profile matching between endpoints:

typedef struct RMW_PUBLIC_TYPE rmw_qos_profile_t {
  rmw_qos_history_policy_t history;
  size_t depth;
  rmw_qos_reliability_policy_t reliability;
  rmw_qos_durability_policy_t durability;
  rmw_time_t deadline;
  rmw_time_t lifespan;
  rmw_qos_liveliness_policy_t liveliness;
  rmw_time_t liveliness_lease_duration;
  bool avoid_ros_namespace_conventions;
} rmw_qos_profile_t;

The backend negotiates compatibility (rmw_qos_profile_check_compatible) and surfaces mismatches as runtime events.

nano-ros. Same field set, packed into 24 bytes:

typedef struct rmw_qos_profile_t {
    uint8_t  reliability;
    uint8_t  durability;
    uint8_t  history;
    uint8_t  liveliness_kind;
    uint16_t depth;
    uint16_t _reserved0;
    uint32_t deadline_ms;             /* 0 = infinite */
    uint32_t lifespan_ms;             /* 0 = infinite */
    uint32_t liveliness_lease_ms;     /* 0 = infinite */
    uint8_t  avoid_ros_namespace_conventions;
    uint8_t  _reserved1[3];
} rmw_qos_profile_t;

Standard profile constants (NROS_RMW_QOS_PROFILE_DEFAULT, _SENSOR_DATA, _SERVICES_DEFAULT, _SYSTEM_DEFAULT, _PARAMETERS) match upstream rmw_qos_profile_* field-for-field, so applications porting from rclcpp / rclrs can pull the equivalent profile constant unchanged.

_SYSTEM_DEFAULT is the one where “field-for-field” is worth spelling out: like upstream’s, it names no concrete policy at all. Every field is the sentinel — NROS_RMW_{RELIABILITY, DURABILITY,HISTORY,LIVELINESS}_SYSTEM_DEFAULT are all 0, and the depth sentinel is 0 too, matching RMW_QOS_POLICY_DEPTH_SYSTEM_DEFAULT. The backend resolves it at entity-create time, and the answer differs per backend, which is the whole point of the name: cyclonedds resolves to RELIABLE / VOLATILE / KEEP_LAST(1), mirroring what rmw_cyclonedds_cpp does with the same sentinel; zenoh resolves the depth to the subscriber ring the shim actually enforces; xrce resolves the three policies the same way and leaves the depth absent from the wire for the Agent to fill. Upstream’s own two reference RMWs disagree here as well — rmw_cyclonedds_cpp picks depth 1, rmw_zenoh_cpp picks 42 — so a profile constant that baked a number would be wrong for every backend but one. Ask for _DEFAULT if you want a stated reliable / volatile / keep-last(10) profile. (issue 0829)

Per-backend support, no silent downgrade

Each backend advertises which policies it can honour via Session::supported_qos_policies(), returning a QoSPolicyMask bitfield. Policies a backend can’t enforce are explicit.

#![allow(unused)]
fn main() {
pub trait Session {
    fn supported_qos_policies(&self) -> QoSPolicyMask {
        QoSPolicyMask::NONE     // "I have not said", which is not "I honour the basics"
    }
}
}

The default is EMPTY, and that is the whole point of the mechanism (phase-428 W9). It was QoSPolicyMask::CORE — four policies granted by the trait to any implementation that had written no code at all — until measurement found the zenoh shim honouring none of the four. A bit is now earned: check-qos-mask-derivation requires each one to be backed by a claim sited on the code that reads the mapped profile field and either applies the request or refuses a value it cannot serve.

A backend reached through the C ABI answers the same question through nros_rmw_vtable_t::supported_qos_policies, per backend (issue 1329). The cffi route used to answer the UNION of what any nano-ros-supported RMW honours, which over-claims for each of them; a NULL slot there means the backend has not said, and carries the same meaning as the trait default above.

The runtime validates the requested QoS against the backend’s mask at entity-create time. Requesting a policy the backend doesn’t support returns TransportError::IncompatibleQos (NROS_RMW_RET_INCOMPATIBLE_QOS at the C boundary) synchronously. There is no silent degradation — applications either get the requested QoS or a hard error.

Apps that need cross-backend portability check the mask at startup:

#![allow(unused)]
fn main() {
if session.supported_qos_policies()
    .contains(QoSPolicyMask::DEADLINE)
{
    pub.create_with_qos(...deadline_ms = 100, ...);
} else {
    // app-side fallback: timeout monitoring in user code
}
}

Manual liveliness assertion

For LIVELINESS_MANUAL_BY_TOPIC and LIVELINESS_MANUAL_BY_NODE, publishers call assert_liveliness() explicitly:

#![allow(unused)]
fn main() {
pub.assert_liveliness()?;   // refresh this publisher's lease
}

C side: rcl_publisher_assert_liveliness(&pub). C++ side: pub.assert_liveliness(). Nothing to do for AUTOMATIC and NONE kinds, which answer Ok on every backend.

Under a manual kind the answer is per backend, and Ok means the assertion reached the wire: cyclonedds performs it (dds_assert_liveliness), while zenoh-pico, XRCE-DDS and uORB have no per-topic lease and answer UNSUPPORTED (phase-467 RMW gap-closure study, Row 7).

Differences from upstream’s matching

Upstream surfaces QoS mismatches via runtime events (RMW_EVENT_REQUESTED_INCOMPATIBLE_QOS). nano-ros surfaces them synchronously at create time as IncompatibleQos. The mismatch is a configuration error visible at startup; the runtime path doesn’t need to handle it.

Two related choices:

  • No profile matching between publisher and subscriber. Each endpoint requests the QoS it wants from its backend; the backend enforces locally. Cross-endpoint compatibility is the wire protocol’s concern — DDS endpoints negotiate via DDS Discovery, zenoh endpoints communicate intent via the topic-key encoding, uORB endpoints share an in-process queue. nano-ros’s executor doesn’t run a profile-matching pass.
  • Wire metadata per backend. Lifespan needs per-sample timestamps; liveliness needs a keepalive mechanism. Each backend uses its native attachment / sample-info mechanism (Zenoh attachments, DDS RTPS sample-info, XRCE session pings) — no cross-backend metadata header.

Per-backend QoS coverage

The mask actually advertised by each backend’s Session::supported_qos_policies():

BackendReliability + Durability + History/DepthDeadlineLifespanLiveliness AutomaticLiveliness ManualLiveliness Leaseavoid_ros_namespace_conventions
Cyclone DDS✅ Native✅ Native✅ Native✅ Native✅ Native✅ Native✅ honoured
XRCE-DDS✅ Native (binary uxrQoS_t)✅ Shim-side clock check (sub: RequestedDeadlineMissed; pub: OfferedDeadlineMissed) + agent-side via FastDDS XML profile✅ Agent-side via FastDDS XML profile✅ Native✅ Configured via XML✅ Configured via XML✅ honoured
zenoh-pico✅ Shim-emulated✅ Clock-based check (sub + pub)✅ Subscriber-side filter using attachment timestamp✅ Trivial via session keepalive✅ Shim-side keepalive timer at pub publish path✅ Honouredn/a (no /rt/ prefix)
uORB✅ CORE only (intra-process, no wire)❌ No rate concept❌ No expiry concept❌ No wire-level liveliness❌❌n/a

Key takeaways:

  • The default QoS profile (RELIABLE + VOLATILE + KEEP_LAST(10) + AUTOMATIC) works on every backend.
  • Apps that need extended QoS but want to stay backend-portable: check supported_qos_policies() at startup and degrade gracefully.
  • For full DDS QoS: Cyclone DDS is the native DDS path; XRCE-DDS auto-routes through FastDDS XML profile when extended policies are set. Zenoh-pico fills the gap with shim-side emulation. uORB is intra-process only.

See Status events for how the deadline / liveliness / message-lost policies translate into runtime events, and User guide → Configuration for code examples.

8. Status events: callback-on-entity, Tier-1 subset

Upstream. Event APIs surface DDS-shaped notifications via a waitset-take pattern:

rmw_ret_t rmw_subscription_event_init(
  rmw_event_t * event, const rmw_subscription_t * sub,
  rmw_event_type_t type);

/* Add event_handle to a waitset alongside subscriptions. */
rmw_ret_t rmw_wait(...);

/* Poll fired events. */
rmw_ret_t rmw_take_event(
  const rmw_event_t * event_handle,
  void * event_info, bool * taken);

Eleven event types covering liveliness, deadline, QoS-incompatibility, match, message-lost, type-incompatibility — all dispatched through the waitset.

nano-ros. Callback-on-entity for a Tier-1 subset (liveliness changes, deadline misses, message lost). Skips the waitset. Skips Tier-2 (MATCHED) and Tier-3 (QOS_INCOMPATIBLE, INCOMPATIBLE_TYPE) — see “What’s skipped” below.

#![allow(unused)]
fn main() {
sub.on_liveliness_changed(|status| {
    if status.alive_count == 0 { trigger_failover(); }
})?;

sub.on_requested_deadline_missed(
    Duration::from_millis(15),
    |status| metric_inc(&LATE_SAMPLE_COUNT, status.total_count_change),
)?;

sub.on_message_lost(|status| log::warn!("dropped {}", status.total_count_change))?;
}

C side mirrors with nros_subscription_set_*_callback functions.

What lands

EventProducerSubscriber callback / Publisher callback
Liveliness changedsubon_liveliness_changed(LivelinessChangedStatus)
Liveliness lostpubon_liveliness_lost(DeadlineMissedStatus)
Requested deadline missedsubon_requested_deadline_missed(deadline, DeadlineMissedStatus)
Offered deadline missedpubon_offered_deadline_missed(deadline, DeadlineMissedStatus)
Message lostsubon_message_lost(MessageLostStatus)

What’s skipped

  • MATCHED — embedded apps usually have static topology; rarely load-bearing. Add the kind if a discovery-tracking app shows up; additive.
  • QOS_INCOMPATIBLE / INCOMPATIBLE_TYPE — these surface at create time, not as runtime events. The existing rmw_ret_t codes (NROS_RMW_RET_INCOMPATIBLE_QOS) carry the diagnostic synchronously from create_publisher / create_subscription. No event needed.

Dispatch — callback-on-entity, not waitset-take

Events fire from inside the existing drive_io callback-dispatch path. The backend’s RX worker detects an event in the same place it detects messages; runs the registered callback; loops. No separate waitset, no per-call take.

This reuses the message-callback dispatch model; events count against the max_callbacks cap from Section 4 the same way message callbacks do.

Why callback-on-entity instead of waitset-take. The waitset-take pattern requires a waitset abstraction nano-ros deliberately doesn’t have (see Section 4). Replacing it with per-entity callbacks reuses existing machinery, matches the message-callback ergonomics users already know, and keeps the bounded-storage property — each registered event-callback is a fixed-size struct embedded in the entity, no per-call allocation.

The trade-off: users can’t bulk-poll all events at once. For the Tier-1 events this isn’t load-bearing — events are rare, callbacks are cheap.

Backend coverage

Coverage is uneven and surfaces through Subscription::supports_event (Rust) / register_*_event returning NROS_RMW_RET_UNSUPPORTED (C). Apps must handle “not supported” — not every backend will generate every event:

BackendLivelinessDeadlineMessage lost
Cyclone DDS✅ Polled: dds_get_liveliness_changed_status (sub) / _liveliness_lost_status (pub), drained by the runtime from the entity’s data path✅ Polled: dds_get_requested_deadline_missed_status / _offered_deadline_missed_status. The deadline is the entity’s QoS deadline_ms; the on_* argument is not forwarded on this path✅ Polled: dds_get_sample_lost_status
XRCE-DDS❌ XRCE protocol carries no session→client liveliness callback🟡 Sub: shim-side clock check on take; pub: shim-side check on publish. LivelinessChanged / LivelinessLost not feasible.❌ topic_callback carries no per-sample sequence
zenoh-pico✅ Sub: per-publisher count via zpico_liveliness_get_count + wildcard keyexpr query; pub: shim-side keepalive timer fires LivelinessLost from publish when MANUAL_BY_* lease expires.✅ Clock-based check at sub + pub, rate-limited to ≤ 1 fire per deadline period✅ Sequence-gap detection from RMW attachment
uORB❌ No wire-level liveliness❌ No rate concept✅ Native: RustSubscriptionCallback publish-counter delta on host mock + real PX4

assert_liveliness() (manual): cyclonedds performs a real assertion (dds_assert_liveliness). zenoh-pico, XRCE-DDS and uORB have no per-topic lease, so under MANUAL_BY_TOPIC / MANUAL_BY_NODE they answer UNSUPPORTED; under AUTOMATIC / NONE they answer Ok(()), because there was nothing to assert. This said the unsupported default was Ok(()) whatever the kind, which reported an assertion that never happened (phase-467 RMW gap-closure study, Row 7). zenoh’s shim-side watchdog is unchanged and still fires LivelinessLost on expiry — it is LOCAL, and the return value is about what a peer can see.

9. Loaned messages first-class

Upstream. Optional, often unimplemented:

rmw_ret_t rmw_borrow_loaned_message(
  const rmw_publisher_t * publisher,
  const rosidl_message_type_support_t * type_support,
  void ** ros_message);

rmw_ret_t rmw_return_loaned_message_from_publisher(
  const rmw_publisher_t * publisher,
  void * loaned_message);

A backend that doesn’t implement these returns RMW_RET_UNSUPPORTED and the client falls back to a copying path.

nano-ros. Lending is a separate vtable surface. When the backend supports zero-copy publish, it implements loan_publish_* / commit_publish_*; when it supports zero-copy receive, it implements loan_recv_* / release_recv_*. The runtime checks the function pointers for non-NULL once at session open and takes the lending path for the lifetime of the session.

Why. Zero-copy isn’t optional on embedded — every avoidable copy is a copy of a CDR-encoded sensor frame from a 64 KB heap. Promoting the lending surface to first-class makes it visible at compile time (missing pointer = compile error against the lending build) and lets the runtime pre-arrange arena slots once instead of probing on every publish.

10. Error returns

Upstream. Single error type for everything:

typedef int32_t rmw_ret_t;
#define RMW_RET_OK              0
#define RMW_RET_ERROR           1
#define RMW_RET_TIMEOUT         2
#define RMW_RET_UNSUPPORTED     3
/* ... */

Pointer-returning calls indicate failure with NULL and set a thread-local error string via rmw_set_error_string.

nano-ros. Same named-constant style (<nros/rmw_ret.h>), different sign convention, no thread-local error string:

typedef int32_t rmw_ret_t;
#define NROS_RMW_RET_OK                       0
#define NROS_RMW_RET_ERROR                   -1
#define NROS_RMW_RET_TIMEOUT                 -2
#define NROS_RMW_RET_BAD_ALLOC               -3
#define NROS_RMW_RET_INVALID_ARGUMENT        -4
#define NROS_RMW_RET_UNSUPPORTED             -5
#define NROS_RMW_RET_INCOMPATIBLE_QOS        -6
#define NROS_RMW_RET_TOPIC_NAME_INVALID      -7
#define NROS_RMW_RET_NODE_NAME_NON_EXISTENT  -8
#define NROS_RMW_RET_LOAN_NOT_SUPPORTED      -9
#define NROS_RMW_RET_NO_DATA                -10
#define NROS_RMW_RET_WOULD_BLOCK            -11
#define NROS_RMW_RET_BUFFER_TOO_SMALL       -12
#define NROS_RMW_RET_MESSAGE_TOO_LARGE      -13

Two return-shape conventions, picked by call shape:

ReturnsSuccessFailure
rmw_ret_t + entity-struct out-param (create_session, create_publisher, create_subscription, …)NROS_RMW_RET_OK, out->backend_data non-NULLnegative named constant
rmw_ret_t (destroy_session, drive_io, publish, send_response, …)NROS_RMW_RET_OKnegative named constant
int32_t byte count (take, take_request, take_response)>= 0 (bytes received)negative rmw_ret_t

Differences from upstream.

  • Negative for error. Upstream uses positive integer codes (RMW_RET_ERROR = 1); nano-ros uses negative so the byte-count convention can be unified into the same int32_t return.
  • No thread-local error string. No rmw_set_error_string, no rmw_get_error_string. The thread-local heap allocation that pattern needs is unaffordable on embedded targets. Backends log verbose diagnostics at the failure site through the platform’s printk equivalent — never buffered, never thread-local.
  • Smaller code-set. 13 codes total (vs upstream’s ~25). Phase set started from upstream’s and dropped codes that don’t apply (e.g., DDS event codes, RMW_RET_NODE_INVALID). Adding a code is a <nros/rmw_ret.h> header change only.

Why same style. Named constants make switch statements possible at call sites; bare negative ints don’t.

Publisher GID & message-info: functional-parity, different shape (issue 0242)

Two upstream rmw.h capabilities exist in nano-ros in a different shape — the functionality is present, but the upstream-exact C entry points are deliberately not mirrored. Recorded here so the divergence reads as a decision, and updated (2026-07-27) to describe what nano-ros actually provides today.

Message-info: surfaced via a builder, not a take_with_info slot

Upstream’s rmw_take_with_info fills an rmw_message_info_t (source + received timestamp, publisher GID, publication + reception sequence numbers) alongside the payload.

nano-ros provides the same information — MessageInfo carries all five fields — but through a different seam:

  • Application API (present). A subscription opts into message-info with the message_info() builder:
    #![allow(unused)]
    fn main() {
    node.subscription::<Msg>("/topic")
        .message_info()
        .build(|msg, info: Option<&MessageInfo>| {
            if let Some(i) = info {
                log::info!("from {:?} at {:?}", i.publisher_gid(), i.source_timestamp());
            }
        })?;
    }
    C mirrors this with rclc_executor_add_subscription_with_info (the callback receives the payload plus the attachment/metadata arguments directly — there is no info struct on the C side), which registers into a rcl_subscription_t that rclc_executor_remove_subscription takes back out (issue 1668).
  • Two backend channels. The hot take vtable slot returns bytes only. A backend that fills the optional take_with_info slot reports rmw_message_info_t in caller-owned storage, and the runtime dispatches it whenever it is present — that is how a C/C++ backend (Cyclone) reports metadata (issue 1495). A Rust backend behind the adapter writes a MessageInfoSlot side-channel instead, which the runtime pairs with the payload. Either way the message_info() builder sees the same MessageInfo.

Publisher identity: one GID, from one source

Upstream exposes rmw_get_gid_for_publisher(pub, out_gid) and stamps every sample with the writer’s GID, so a subscriber can name the publisher that sent a message. nano-ros does both, and — since issue 1495 — the two answers are the same bytes:

backendget_gid() and MessageInfo::publisher_gid
zenoh-picoXXH3-128 of the entity’s liveliness keyexpr, 16 bytes zero-extended to 24 — rmw_zenoh_cpp’s own derivation, so a stock peer’s ros2 topic info --verbose prints the same GID
cycloneddsthe DDS writer GUID, 16 bytes zero-extended; the take path reports it through the take_with_info slot
XRCE-DDS, uORBUNSUPPORTED; no metadata on a take

The field is 24 bytes, upstream’s RMW_GID_STORAGE_SIZE. The 16 is zenoh’s wire width and stays there: rmw_zenoh_cpp’s attachment reader rejects any other length.

One upstream divergence worth knowing when comparing against a stock peer: a Humble rmw_cyclonedds_cpp subscriber puts the 8-byte publication handle in message_info.publisher_gid, not the writer GUID it reports everywhere else, so its take-side gid matches no gid query. Ours reports the GUID.

See also

Platform API Design

The platform API (nros-platform) sits below the RMW backend. It exposes the OS or hardware primitives that zenoh-pico and XRCE-DDS need: a clock, optionally a heap, optionally threading, optionally networking. This page explains why the trait surface is grouped the way it is.

Canonical interface spec. The function-pointer signatures, parameter docs, ownership rules, blocking allowance, and failure modes for every platform entry live in the platform-cffi Doxygen reference. This doc is rationale, not spec — when the two disagree, the Doxygen wins. The Rust trait crate nros-platform-api mirrors the C signatures one-for-one.

For per-platform behaviour comparison, see Platform Differences. For writing a new port, see Custom Platform.

Trait groups and rationale

The traits cluster into seven concern groups. Each group is independent: a platform can provide some and stub others, and the RMW backend declares which it actually needs.

Time – PlatformClock

Every backend needs a monotonic clock. zenoh-pico uses milliseconds for socket timeouts and lease management; it uses microseconds for finer-grained protocol math (KeepAlive intervals, lease expiry calculations). We expose both clock_ms and clock_us rather than a single clock_ns because:

  • 32-bit MCUs without a hardware microsecond tick cannot serve clock_ns accurately. Synthesizing nanoseconds from a 1 ms tick is a lie that hides clock resolution from the caller.
  • u64 nanoseconds wraps after ~584 years, but on a Cortex-M0 every multiply-divide on a 64-bit value costs cycles that the lease task does not have to spare.
  • The two functions can share a single hardware source: clock_us returns the raw counter, clock_ms divides by 1000. Platforms with a slow tick (1 ms) implement clock_us as clock_ms * 1000 and accept the resolution loss.

The clock is monotonic and wraparound-free for system lifetime. There is no failure mode – if the platform cannot produce time, it cannot run nano-ros at all.

Memory – PlatformAlloc

Only zenoh-pico needs PlatformAlloc. XRCE-DDS does not allocate. The trait is a thin malloc/realloc/free shim because zenoh-pico’s internal buffer types expect that contract.

Bare-metal platforms back this with a bump allocator (linked-list-allocator or embedded-alloc). RTOS platforms back it with pvPortMalloc (FreeRTOS), tx_byte_allocate (ThreadX), k_malloc (Zephyr), or malloc (NuttX, POSIX). zenoh-pico’s working set is ~64 KB total; the allocator must have at least that much budget, ideally more.

Threading – PlatformThreading

Three sub-areas: tasks (spawn/join/exit), mutexes (regular + recursive), and condition variables. Single-threaded targets (bare-metal) provide stub implementations: task_init returns -1 (so zenoh-pico’s lease task spawn fails gracefully and the application drives lease-keepalive itself), and mutex_lock/condvar_wait_until are no-ops that always succeed. There is no unbounded condvar_wait in the ABI (removed by issue 1196): a caller whose contract is unbounded loops over condvar_wait_until and re-tests its predicate.

The condvar API is the load-bearing one: zenoh-pico’s blocking z_get and the C++ Future::wait both block on a condvar that the receive callback signals. On single-threaded platforms there is no thread to block, so the blocking C++ wait paths are not used (the C++ action client status note covers the migration to non-blocking polling).

Sleep / Random / Wall-time

Three small zenoh-pico-only traits grouped for convenience:

  • PlatformSleep – delay APIs. On bare-metal with smoltcp, the implementation must call network_poll() while busy-waiting, otherwise packets queue and the lease times out.
  • PlatformRandom – a 32-bit xorshift PRNG seeded with hardware entropy (or a user-supplied seed). Used for session IDs and protocol nonces, not cryptography.
  • PlatformTime – wall-clock time for log timestamps. On bare-metal with no RTC, return monotonic time as a fallback.

Networking – PlatformTcp / PlatformUdp / PlatformSocketHelpers

zenoh-pico’s network layer is split into three traits because the original C interface (unix/network.c) has three concerns:

  • TCP and UDP each have their own open/read/send/close because the backend opens different socket types for each.
  • PlatformSocketHelpers carries the cross-cutting operations – set_non_blocking, accept, generic close, and wait_event – that apply to either socket family.

Sockets and endpoints are opaque *mut c_void pointers; their underlying types vary per platform (POSIX int, lwIP struct netconn*, Zephyr socket descriptor, smoltcp SocketHandle). The shim layer auto-detects the type sizes from C headers at build time so the FFI boundary stays type-erased.

PlatformUdpMulticast is split out as a fourth networking trait because embedded targets that connect to a fixed tcp/host:port locator never multicast and should not pay the code-size cost of multicast plumbing.

NetworkPoll – PlatformNetworkPoll

Bare-metal only. network_poll() advances the smoltcp state machine, processing pending RX/TX. Platforms with kernel-level networking (Linux, lwIP-on-FreeRTOS, NetX-on-ThreadX, Zephyr sockets) drive their own NIC and don’t need this.

PlatformSleep and the wait_event helper both call network_poll() while waiting, so packets keep flowing during otherwise-idle time. Without this hook, smoltcp would only receive when the application explicitly asked for it – a recipe for dropped TCP segments.

Libc – PlatformLibc

zenoh-pico uses strlen, memcpy, errno, etc. directly. Bare-metal targets that link picolibc or newlib-nano get these for free. Targets without a C runtime (some no_std builds) provide the trait, which forwards to Rust implementations of the same functions.

This trait exists because the alternative – patching zenoh-pico to call platform shims for every libc function – would require modifying upstream sources we don’t control.

Why clock_ms and clock_us, not clock_ns

Summarized from above:

APIProsConsVerdict
clock_ns onlySingle function, finest resolution64-bit math on every call; lies on 1 ms-tick MCUsRejected
clock_ms onlyCheap, fits zenoh-pico’s lease mathInsufficient resolution for sub-millisecond protocol timingInsufficient
Both clock_ms and clock_usEach call is cheap and honest about its resolutionTwo functions to implementChosen

Behavior contracts

Each trait below has a contract table. Columns:

  • Method – name (matches the trait definition).
  • Blocking? – whether the method may suspend the caller.
  • May fail? – whether the method has a meaningful failure mode.
  • Unsupported fallback – what to do when the platform cannot provide the capability.
  • Notes – extra constraints (monotonicity, reentrancy, side effects).

PlatformClock

MethodBlocking?May fail?Unsupported fallbackNotes
clock_msNoNoRequired for any backendMonotonic; wraparound-free for system lifetime
clock_usNoNoRequired for any backendSame monotonic base as clock_ms

PlatformAlloc

MethodBlocking?May fail?Unsupported fallbackNotes
allocNoYes (null)Required for zenoh-picoCaller checks for null and propagates as RMW error
reallocNoYes (null)Required for zenoh-picoExisting block must be preserved on failure
deallocNoNoRequired for zenoh-picodealloc(null) is a no-op

PlatformSleep

MethodBlocking?May fail?Unsupported fallbackNotes
sleep_usYesNoRequired for zenoh-picoBare-metal must call network_poll() during busy-wait
sleep_msYesNoRequired for zenoh-picoSame
sleep_sYesNoRequired for zenoh-picoSame; typically implemented as sleep_ms(s * 1000)

PlatformRandom

MethodBlocking?May fail?Unsupported fallbackNotes
random_u8 / _u16 / _u32 / _u64NoNoRequired for zenoh-picoxorshift32 is sufficient; not cryptographic
random_fillNoNoRequired for zenoh-picoFills len bytes; no upper bound check

PlatformTime (wall-clock)

MethodBlocking?May fail?Unsupported fallbackNotes
time_now_msNoNoRequired for zenoh-picoReturn clock_ms if no RTC
time_since_epochNoNoRequired for zenoh-picoReturn (monotonic_s, 0) if no RTC

PlatformThreading – tasks

MethodBlocking?May fail?Unsupported fallbackNotes
task_initNoYes (-1)Return -1 on single-threadedzenoh-pico’s lease task must degrade gracefully
task_joinYesYesReturn 0 (success)Single-threaded never spawned a task to join
task_detachNoYesReturn 0Same
task_cancelNoYesReturn 0Same
task_exitNoNoNo-opCaller is the only thread
task_freeNoNoNo-opNo allocation to free

PlatformThreading – mutexes (regular and recursive)

MethodBlocking?May fail?Unsupported fallbackNotes
mutex_init / mutex_rec_initNoYesReturn 0Single-threaded has no mutex state
mutex_drop / mutex_rec_dropNoYesReturn 0Same
mutex_lock / mutex_rec_lockYesYesReturn 0 (success)Single-threaded: no contention possible
mutex_try_lock / mutex_rec_try_lockNoYesReturn 0Always “succeeds” on single-threaded
mutex_unlock / mutex_rec_unlockNoYesReturn 0Same

PlatformThreading – condition variables

MethodBlocking?May fail?Unsupported fallbackNotes
condvar_initNoYesReturn 0No state on single-threaded
condvar_dropNoYesReturn 0Same
condvar_signalNoYesReturn 0No waiter to wake
condvar_signal_allNoYesReturn 0Same
condvar_wait_untilYesYes (timeout)Return 0 immediatelySame; blocking C++ Future::wait deadlocks on single-threaded (use non-blocking polling instead)

PlatformTcp

MethodBlocking?May fail?Unsupported fallbackNotes
create_endpointYes (DNS)YesRequired for zenoh-picoBacked by getaddrinfo or platform equivalent
free_endpointNoNoRequiredMirrors freeaddrinfo
openYesYesRequiredConnect with timeout in ms
listenNoYesOptional (server mode)Bare-metal client typically returns -1
closeNoNoRequiredShutdown + close
readNo (after set_non_blocking)Yes (usize::MAX)RequiredReturns 0 if no data; must be non-blocking for zenoh-pico’s poll loop
read_exactYesYesRequiredUsed for length-prefixed framing
sendYesYesRequiredMay block on socket buffer full

PlatformUdp

MethodBlocking?May fail?Unsupported fallbackNotes
create_endpointYes (DNS)YesRequired for zenoh-picoSame as TCP but SOCK_DGRAM
free_endpointNoNoRequired
openNoYesRequiredUDP socket open is non-blocking
closeNoNoRequired
readNoYesRequiredrecvfrom; returns 0 if no datagram
read_exactYesYesRequiredRarely used (UDP is message-oriented)
sendYesYesRequiredsendto

PlatformSocketHelpers

MethodBlocking?May fail?Unsupported fallbackNotes
set_non_blockingNoYesRequiredCritical: enables non-blocking read
acceptNo (after set_non_blocking)YesOptionalServer-mode only
closeNoNoRequiredGeneric socket close
wait_eventYesYesRequiredMulti-threaded: yields to scheduler. Single-threaded: spins + network_poll

PlatformUdpMulticast (optional)

MethodBlocking?May fail?Unsupported fallbackNotes
mcast_openYesYesStub returns -1Skipped on embedded targets without scouting
mcast_listenYesYesStub returns -1Same
mcast_closeNoNoNo-op
mcast_read / mcast_read_exactVariesYesStub returns usize::MAX
mcast_sendYesYesStub returns usize::MAX

PlatformNetworkPoll (bare-metal only)

MethodBlocking?May fail?Unsupported fallbackNotes
network_pollNoNoOS-driven platforms: no-opAdvances smoltcp once; called by sleep_* and wait_event

PlatformLibc (bare-metal without libc)

MethodBlocking?May fail?Unsupported fallbackNotes
strlen, strcmp, strncmp, strchr, strncpyNoNoLinker provides if libc presentSame semantics as the C standard
memcpy, memmove, memset, memcmp, memchrNoNoSameSame
strtoulNoYes (errno)SameUsed by zenoh-pico to parse locator strings
errno_ptrNoNoSameReturns pointer to thread-local (or static) errno

Cross-cutting rules

Two contract rules apply to every trait method:

  1. Reentrancy. Methods may be called from any context the executor enters: a publisher callback, a service handler, or directly from user code. Implementations must not assume a particular calling thread or critical-section state. Single-threaded platforms get this for free; RTOS platforms must use reentrant primitives.

  2. No panics across the FFI boundary. All trait methods are exposed to C through the shim crates. Panicking through C is undefined behavior. Implementations return error codes (or usize::MAX for byte counts) instead of panicking. The exception is PlatformClock – if the clock cannot be read, the system is fundamentally broken and there is nothing useful to return.

Codegen — the pack pipeline

nano-ros generates ROS 2 message/service/action types (and their per-package scaffolding) from .msg/.srv/.action files. The generator is a four-stage pipeline (RFC-0068): parse → resolve → lower → render. This page covers the last stage — render — and how to change or add a target language.

Render = a data pack + a runtime template

Every backend renders through one minijinja environment (packages/cli/rosidl-codegen/src/render.rs) over data packs under packages/cli/rosidl-codegen/packs/:

packoutput
packs/c/C headers + sources
packs/rmw/RRR-compatible Rust message layer
packs/rust/idiomatic (rclrs-style) Rust
packs/nros/embedded (no_std) Rust
packs/cpp/C++ headers + the Rust FFI glue
packs/scaffold/per-package Cargo.toml / lib.rs / build.rs
packs/shared/partials more than one pack includes (the codegen-version stamp)

Each pack directory carries a pack.toml that declares it: its language, its registry_order, its templates as { key, file } rows, and — for a pack that names C-family artifacts — the header_extension / guard_suffix / source_extension that generator::naming derives every artifact name from. build.rs reads every manifest and generates the template registry and the Surface enum, so a pack directory is a pack because it exists and describes itself (phase-469 W1). Nothing in render.rs names a template.

A pack is just .jinja templates. The Rust side hands each template a serde-serialized view struct (the render context) and never spells a type itself — the type strings are composed in the pack by registered filters:

  • c_type / c_array_suffix, cpp_type / cpp_array_suffix, cpp_repr_c_type / cpp_view_repr_type
  • rust_type_rmw / rust_type_idiomatic, rclc_type
  • snake_case

The view struct carries only neutral facts (the parsed field_type, resolved capacity, storage mode, current_package, …); the filter maps those to the language’s syntax. This is RFC-0068’s “what vs how” seam: what a type is lives in the IR; how a language spells it lives in the pack + its filter.

Changing a template

Edit the .jinja under packs/<lang>/ and rebuild the CLI (just setup-cli). The codegen fingerprint (RFC-0061) hashes every bundled pack’s content, so any template edit marks the affected fixtures stale — no separate bookkeeping.

Overriding a pack at runtime — no rebuild

Point the renderer at an external directory of .jinja files: a file named <template-name> or <template-name>.jinja there overrides the bundled pack of that name; anything absent falls back to bundled.

export NROS_TEMPLATE_DIR=/path/to/my/pack
nros generate-rust …          # uses the override, no recompile

(Equivalently, rosidl_codegen::render::set_template_dir(dir) from Rust, called once before the first render.)

The stable template names are the templates keys in each packs/<dir>/pack.toml (e.g. message.h, message_nros.rs, cargo.toml, _field.jinja); nros’s own copy of the generated registry is at target/**/build/rosidl-codegen-*/out/ pack_registry.rs. tests/external_pack_smoke.rs proves the override + fallback.

Do not set NROS_TEMPLATE_DIR during fixture or CI builds. The fingerprint hashes the bundled packs; an external override would silently produce output that disagrees with the recorded fingerprint.

Adding a language

Step 0 — decide which SURFACES you need

This is the step that sizes the work, and skipping it is why “add a language” sounds bigger than it is. A pack is a (language × surface) pair, not a language: Rust has four message packs, and the cpp pack emits Rust as well as C++.

There are two independent axes. On the message side: idiomatic, embedded-idiomatic (no_std), FFI/ABI (repr(C)), bridge glue, packaging. On the entry side: the entry TU, and the component seam. A language can take an entry surface with no message surface (it consumes another language’s messages) or the reverse.

RFC-0091 §6 has the full table, including what each surface drags in — an FFI surface inherits the cross-language memory-agreement gate, a bridge surface is two spellings of one type that must move together. A language can ship with two surfaces and gain the rest later: a Zig component installed by a C or C++ entry needs the idiomatic and FFI message surfaces and the component seam, and nothing else.

Step 1 — the message pack

  1. add its .jinja templates (a new packs/<lang>/) and a pack.toml declaring them. No Rust edit: the registry is discovered from the manifest, and a pack directory with no manifest — or a .jinja no manifest claims — fails the build naming the file. If the pack names C-family artifacts, its three naming fields make it a generator::naming surface and the Surface variant is generated for it;
  2. if it needs type spelling the existing filters don’t cover, add a filter set for the language (rosidl_codegen::filters::FILTER_SETS) wrapping a *_spelling function in types.rs. The filter set is all the Rust that type SPELLING takes; the set is keyed by the pack that CALLS the filter, not by the syntax it emits — cpp_repr_c_type returns Rust;
  3. add a generator per kind (generator/{msg,srv,action}.rs, or one file like generator/cpp.rs) that builds the context, names the output files and calls render::render("<template-name>", &ctx), and its arm in nros generate (cmd/generate.rs).

Step 2 — the entry pack, if the language writes entries

An entry pack is DATA. Since phase-494 there is no per-language entry emitter: one renderer (codegen/entry/emit/mod.rs) lowers the plan once into nros_entry_lower::LoweredEntry and hands THAT to the pack’s template.

  1. packs/entry/<surface>/entry.<ext>.jinja over LoweredEntry. Nodes (with their component kind, resolved name and namespace, params, remaps, QoS), board family and boot shape, the C-ABI runner names, the executor layout (one executor, sched contexts, or per-tier setups), tier rows, the boot blob and the monitor tables all arrive computed and RAW — a template decides where a value goes and spells it, never how to quote it;
  2. packs/entry/<surface>/pack.toml — the extension, whether the TU is C-family, the entry template, its templates = [{ key, file }] rows, and what the renderer must know: context = "lowered-entry", the filters its templates call, the component kinds it constructs (components), and whether it calls the board’s C-ABI runners (c_abi_runners). CMake reads it through nros codegen entry-pack. The build discovers the directory — there is no registry row to add;
  3. one variant on Language in nros-lang. Every consumer sees it — one enumeration;
  4. only if the language needs a spelling no existing filter provides (the set is c_str, pkg_ident, cpp_board_class): one function and one row in codegen/entry/filters.rs. A spelling is a correctness property, so it is Rust; everything else about the pack is not.

testdata/entry-packs/zig/ is a whole entry pack written this way — a manifest and one template — and a unit test renders every golden C plan through it.

Step 3 — the goldens

Add the coordinate to the entry golden harness (codegen/entry/golden.rs; its Emitter enum names the entry point a row renders through), run NROS_UPDATE_GOLDEN=1 cargo test -p nros-cli-core --lib codegen::entry::golden, and read the diff. The generated source is a file, not a claim.

just check entry-pack-conformance refuses a half-wired pack in EITHER family — entry and message; the name is historical (see the recipe comment). A directory with no manifest, a language pack missing the fields its consumer needs, a registry key or registry_order claimed twice, a template file that does not exist, a .jinja no manifest claims, a Language variant nothing renders, or a language whose bytes are recorded in no golden — and, since phase-494, a new codegen/entry/emit_<x>.rs, because an entry language is a pack. Run it before you believe the pack works.

What this does NOT make cheap

The toolchain story — how CMake compiles the language, how it links libnros, how its components declare themselves. The codegen cost becomes a pack; the build-integration cost does not. Budget for it separately.

No per-language TYPE SPELLING lives in the message builders — the packs and their filters own it, and since phase-469 W1 so do the artifact-naming suffixes (file and guard names). The builders still hold the per-language Rust that is a RULE rather than a parameter — which generator builds which context, and the ROS kind word, which is identical on every surface. An entry language brings no emitter (phase-494): its pack and, at most, a spelling filter. Implemented by phase-335 (RFC-0068), phase-432 (RFC-0091), phase-469 and phase-494.

Canonical Platform C ABI

nano-ros separates the platform layer (clock, sleep, allocator, threading, critical section, network, timer, …) from the rest of the code via a single canonical C ABI of free extern "C" symbols. Every supported port — POSIX, FreeRTOS, NuttX, ThreadX, Zephyr, bare-metal Cortex-M, bare-metal RISC-V (ESP32-C3) — implements the same symbols against its host kernel. RMW backends, codegen output, and the nros-node runtime all link against the ABI; nobody links against a specific port’s Rust crate.

This page is the contract: what the surface looks like, how to add a port, and why the shape is what it is. It is the implementation companion to Platform Model (user- facing axis description) and docs/design/0006-portable-rmw-platform-interface.md (L0/L1/L2 design rationale across RMW + platform).

Surface

Three hand-written headers under packages/platform/nros-platform-cffi/include/nros/:

HeaderPurposeSymbol count
platform.hCore kernel surface: clock, sleep, alloc + heap stats, threading, scheduler, time, yield, random, critical section, opaque wake primitive.59
platform_net.hNetwork surface: TCP/UDP/multicast socket helpers, endpoint resolution, IVC.29
platform_timer.hPeriodic timer surface (nros_platform_timer_*).8

Every symbol has the prefix nros_platform_. A drift gate (scripts/check-platform-abi-mirror.sh) walks each header and asserts that the unsafe extern "C" {} mirror block in packages/platform/nros-platform-cffi/src/lib.rs declares the same set — no symbol can land in C without its Rust mirror, no Rust mirror can declare a symbol without a C header decl. just check runs the gate on every CI build.

Why free symbols (not a vtable struct)

The RMW layer uses a NrosRmwVtable fn-ptr struct because a single binary registers multiple backends at runtime (bridge nodes). Platforms are different: one platform per binary, resolved at link time. A vtable would add an indirection on every clock read, every mutex lock, every socket send — overhead with no upside, because there is nobody to swap. Free symbols let the linker resolve calls direct, let LTO inline across the boundary, and let static analysis treat them like any other extern.

The full rationale is in docs/design/0006-portable-rmw-platform-interface.md under “Platform ABI: free symbols (no vtable)”.

How a port is built

A port can be written in either of two ways. The choice is per-port and not visible to consumers — both shapes resolve to the same symbols.

Path A — Rust trait + macro export

The Rust crate implements PlatformClock, PlatformAlloc, PlatformThreading, … on a marker type, then invokes one of the export macros:

#![allow(unused)]
fn main() {
use nros_platform_api::{
    PlatformClock, PlatformSleep, PlatformAlloc, PlatformThreading,
    PlatformCriticalSection,
    nros_platform_export, nros_platform_export_net, nros_platform_export_timer,
};

pub struct PosixPlatform;
impl PlatformClock for PosixPlatform { /* … */ }
impl PlatformSleep for PosixPlatform { /* … */ }
// … etc.

nros_platform_export!(PosixPlatform);
nros_platform_export_net!(PosixPlatform);
nros_platform_export_timer!(PosixPlatform);
}

The macros expand to the full set of #[unsafe(no_mangle)] pub extern "C" fn nros_platform_* bodies forwarding to the trait calls. This is what nros-platform-posix does today. It is also what the bare-metal board crates (nros-platform-mps2-an385, nros-platform-stm32f4, nros-platform-esp32, nros-platform-esp32-qemu) use: there is no host kernel to write idiomatic C against, so the single-task stub impls are written in Rust and exported via the macro.

Path B — pure C port

The kernel-side ports (FreeRTOS, NuttX, ThreadX, Zephyr) write the bodies directly in src/platform.c, src/net.c, src/timer.c inside each packages/platform/nros-platform-<rtos>/ directory. The Rust side has no implementation file — the build script (or parent build system: NuttX make, Zephyr west module) compiles the C sources against the kernel’s headers.

Pure-C bodies are the more natural fit when the kernel already speaks C and ships C headers (xSemaphoreCreate*, k_sem_*, tx_thread_*): the impl is one-to-one with the kernel call, no FFI dance through Rust’s calling convention. The shared crate nros-platform-critical-section is the canonical example for a single-symbol shim: the Rust side just calls the externs and registers the result with critical_section::set_impl!.

Adding a new port — checklist

  1. Decide A vs B. Greenfield host build with a Rust crate available? Path A. Vendor RTOS with a C SDK and no Rust toolchain on the build machine? Path B.
  2. Mirror, don’t extend. Implement every symbol in the three headers. The drift gate fails the build otherwise. If the kernel genuinely cannot provide a primitive, return the documented sentinel value (-1 for task_init on single-task RTOS, 0 for mutex_* on single-core no-preempt hardware) rather than skipping the symbol.
  3. Write the smoke test. Land a tests/<port>-c-smoke/ mini-app that links the new C port against the canonical headers and calls a representative symbol from each capability group. Wire it into just <port> test-c-port. The smoke tests are the runtime parity layer that the drift gate cannot enforce.
  4. For platforms that emit critical_section::Impl (i.e. anything that consumes a critical-section-using crate), make sure the binary pulls nros-platform-critical-section once — it does the critical_section::set_impl!(PlatformCs) registration against the canonical externs. Binaries that don’t need the global registration don’t pay for it.
  5. Update the drift gate’s smoke list (the HEADERS_REQUIRE_MACRO array in scripts/check-platform-abi-mirror.sh) if you add a new header. Adding a symbol to an existing header needs no script change — the grep is generic.

Capability groups

Within platform.h, the symbols are grouped by trait. Each group is exported by one Rust trait + one C-port section + one drift-gate match. Adding a capability means adding a row to every column.

CapabilityRust traitC sectionSymbols
ClockPlatformClockclock_nsnros_platform_clock_ns
SleepPlatformSleepsleep_msnros_platform_sleep_ms
AllocPlatformAllocmalloc/realloc/freenros_platform_alloc{,_realloc,_free}
ThreadingPlatformThreadingmutex/condvar/tasknros_platform_{mutex,condvar,task}_*
Critical sectionPlatformCriticalSectionper-CPU interrupt masknros_platform_critical_section_{acquire,release}
SchedulerPlatformSchedulertask hintsnros_platform_scheduler_*
TimePlatformTimewall-clocknros_platform_time_now_ns
YieldPlatformYieldcooperative yieldnros_platform_yield_now
RandomPlatformRandombest-effort RNGnros_platform_random_*
WakePlatformThreading (wake methods)opaque binary-semaphorenros_platform_wake_{init,drop,wait_ms,signal,signal_from_isr,storage_size,storage_align}

platform_net.h covers TCP/UDP/multicast/IVC, and platform_timer.h covers periodic timers. The shapes follow the same pattern: trait → macro → header → drift gate.

Status and architecture

The platform tier today is:

  • One canonical header per capability family (platform.h, platform_net.h, platform_timer.h). Every port mirrors them exactly; a drift gate fails the build on divergence.
  • Pure-C ports under nros-platform-{posix,freertos,nuttx,threadx,zephyr} — the per-RTOS Rust platform crates were retired in favour of one C body per RTOS. (nros-platform-esp-idf was a sixth until phase-468 W2 retired the ESP-IDF port.)
  • critical_section promoted to a canonical platform capability owned by every port’s C body; nros-platform-critical-section is the global-registration shim.
  • An opaque-storage wake primitive (nros_platform_wake_*) — a binary semaphore that lets the executor block on RMW activity without burning a thread.

The current canonical surface is 59 + 29 + 8 = 96 symbols across three headers, mirrored exactly by the Rust extern block, exported by six ports plus four bare-metal board crates, and gated by one drift script. Adding a capability touches all four columns in one PR.

The scalar / opaque-struct boundary (RFC-0034 D2)

Not every kernel service unifies the same way. The ABI splits into two classes, and the split is a design boundary, not tech debt:

  • Scalar services — fully unified. Alloc, sleep, clock, time, yield, random. Their ABI is plain scalars (sizes, millisecond counts, byte buffers) with no host-type in the signature, so a single canonical nros_platform_* symbol works for every RTOS and every consumer (core, RMW, vendored zenoh-pico/XRCE) funnels through it. The D8 gate (check-no-direct-kernel-alloc.sh) enforces this for allocation; the same pattern extends to the other scalars (the vendored z_*/uxr_* funnel is the CI-relink-gated remainder of phase-230).

  • Opaque-struct services — stay per-RTOS-vendored. Threading (mutex/condvar/task), the wake primitive, and network sockets carry RTOS-defined structs (SemaphoreHandle_t, TX_MUTEX, struct k_mutex, socket/NX_* control blocks) whose layout the vendor owns. A single C ABI cannot name those types portably, so each port vendors its own body and nano-ros exposes only opaque storage (*_storage_size/*_storage_align + an init/drop pair). This is why the ThreadX board’s tx_byte_allocate thread-stack / NetX-pool sites are not D8 violations — they are the task and net opaque services, allowed on a documented symbol-scoped lint allowlist.

Escape hatch (if an opaque service must move later). The way out is NOT to widen the C ABI with host types; it is to pin a canonical fixed-layout struct in platform.h, give each port a compile-time size_probe / _Static_assert that the canonical layout is ≥ the vendor struct (so opaque storage stays sound), and let the port memcpy/place the vendor object inside the canonical slot. Net is the first candidate — platform_net.h already exposes 29 symbols over an opaque socket handle — but it is deferred (RFC-0034 “out of scope”) until there is a consumer that needs portable socket structs. Threads and sync are the least likely to move (deepest struct coupling).

Measuring Static Memory

The Static Pool Inventory tells you which knobs exist and what each one costs: a byte figure at its default where the pool is a plain product of knobs, and otherwise where its bytes go — a named static, the executor’s storage, a heap — and why no figure can be stated without building. It cannot tell you what your image costs — your knobs differ, your backend differs, and several pools are sized by a sizeof that no comment can see.

For that, measure the image you built:

just mem-report path/to/your/binary

You get RAM broken down by symbol, by owner, by storage role and by pool, plus the section totals, so you can see how much is not attributable to any symbol:

RAM (writable allocated sections): 518,234 bytes
RAM attributed to symbols:         489,771 bytes
unattributed (padding, linker reservations, symbol-less data): 28,463 bytes (5.5%)

That last line matters. Alignment padding and linker-script reservations are real RAM that no symbol names, so a budget built only from a list of pools will come up short. “Writable allocated sections” means every section the linker marks allocated and writable, whatever it is called — on Zephyr that includes the per-file .noinit.* sections holding thread stacks and the kernel heap.

Who owns the bytes

The RAM by owner table puts every byte under the owner you would name:

       177,112   34.2%  [executor storage]
       206,358   39.8%  nros_rmw_zenoh
        88,280   17.0%  (C / asm / no path)
         8,064    1.6%  [component storage]
  • [executor storage] — the executor’s backing, whichever language placed it: the C/C++ entry’s __nros_executor_storage / __nros_tier_executor_storage, the C++ boot storage Node::GlobalStorageHolder<0>::storage, and the Rust EXECUTOR_BACKING / __NROS_TIER_EXECUTOR_BACKING. In a tiered image this is usually the largest single owner.
  • [component storage] — the generated per-component storage (__nros_comp_buf_N for C++, __NROS_COMPONENT_<pkg>_SLOT_STORE for Rust).
  • A Rust crate name, C++ <namespace>, or (C / asm / no path) otherwise. The language comes from the symbol’s mangling, so a C++ namespace that happens to share a Rust crate’s name is never filed under that crate.

For the C and C++ executor storage, the report also reads the build’s own sizes header and tells you how many executors’ worth the image reserved:

        95,904  executor storage   __nros_tier_executor_storage
                                   = 4 x 23,976 (NROS_CPP_EXECUTOR_STORAGE_SIZE = 23,976)

The header is found beside the image (the build’s nros-cpp-generated/ mirror); pass --sizes-header <path> if your build keeps it elsewhere. If the header is newer than the image, or two headers disagree, the report says so instead of pricing.

Finding what to cut

The per-symbol list is sorted, so the first few lines are usually the whole story:

## top 5 RAM symbols

       131,072   25.3%  nros_rmw_zenoh::shim::subscriber::LARGE_PAYLOADS
        88,560   17.1%  linux_entry::__nros_entry_run::__NROS_TIER_EXECUTOR_BACKING
        88,552   17.1%  nros_node::executor::backing::EXECUTOR_BACKING
        87,072   16.8%  g_sessions
        33,536    6.5%  nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX

The last two sections of the report join these symbols to their knobs for you: declared pools lists the pools that carry a formula (and whether the image agrees with it), and knob-sized pools lists every other static the inventory knows a knob for, with its exact measured size:

        87,072  g_sessions
                pool sized by NROS_ZENOH_MAX_SESSIONS, NROS_ZENOH_MAX_PUBLISHERS, ...

Be aware that today these pools are sized by which backend you link, not by what your node actually does: a publisher-only node still reserves the service and large-payload pools in full. If your image looks far larger than the entities you created would suggest, that is expected rather than a misconfiguration on your side — see issue 0827. Turning the corresponding knobs down is the current remedy.

The three service inboxes (zenoh)

Every service server is a zenoh queryable, and each queryable owns one inbox ring: requests wait there between the read task and the executor’s spin. The rings sit in three tables, one per family, so a family is sized for its own requests rather than for the largest request any service could send:

symbolholdsone ring isknobs
nros_rmw_zenoh::shim::service::USER_SERVICE_INBOXyour servicesdepth × (slot + 12 B)NROS_SERVICE_INBOX_BYTES, NROS_SERVICE_INBOX_DEPTH (4)
nros_rmw_zenoh::shim::service::ACTION_INBOXthe four queryables of each action serverdepth × (slot + 12 B)NROS_ACTION_INBOX_BYTES, NROS_ACTION_INBOX_DEPTH (4)
nros_rmw_zenoh::shim::service::BUILTIN_INBOXthe runtime’s own parameter and lifecycle servicesdepth × (slot + 12 B)NROS_PARAM_SERVICE_INBOX_BYTES, NROS_PARAM_SERVICE_INBOX_DEPTH (1)

The 12 B is a ring entry’s length, sequence and overflow flag (on a 32-bit target). Each table holds one ring per queryable of its family, for every session: the image’s queryable pool (NROS_MAX_QUERYABLES) is partitioned between the three. A per-queryable header, SERVICE_BUFFERS, is separate and paid whatever the ring is.

Where a slot size comes from, in the order the build takes it:

  • a STATED knob (environment, Kconfig, or [image.<id>.knobs.service|action|param]) wins;
  • otherwise it is DERIVED: the user and action slots from the largest request type the image declares a server for, and the builtin slot from the largest set_parameters request the contract’s declared parameters can produce;
  • with nothing declared, the user and action slots keep 1,024 B, and the builtin table is empty.

Read the bytes off the image with just mem-report, not off this formula: the compiler sizes the table, so the report is exact. Two worked readings, from the Autoware safety island on an S32K344: BUILTIN_INBOX = 24 parameter queryables × 1 × (1,016 + 12) = 24,672 B, and USER_SERVICE_INBOX fell from 3,744 B to 288 B when those 24 queryables left it.

The parameter family costs nothing here at all when the bringup declares params instead of param_services: the store is built, no service is registered, and no queryable is claimed for it.

Showing a saving

Take a baseline, change something, and compare:

just mem-report my-binary --json > before.json
# ... tune a knob, rebuild ...
just mem-report my-binary --baseline before.json

Every symbol row then carries an annotation: (+12,288) for a change, (=) for a symbol that matched and did not move, (new) for one the baseline does not have. A baseline join section lists what matched, what is new and what is gone, and the owner table shows each owner’s delta — so a pool that disappears entirely is reported, not silently absent.

Symbols are matched across builds by name with the compiler’s per-build decorations removed (.llvm.<hash>, .0, .constprop.0, Rust crate hashes), so a static the linker renamed between two builds still compares. A baseline written by an older version of the tool lists only its top symbols; the report marks it incomplete and will not call a symbol “new” on its evidence.

This is also how a change to nano-ros itself should report a memory saving: as a measured difference between two named images, not as an estimate.

Cross-compiled images

The tool prefers llvm-nm, which reads ELF files for any target; your host’s GNU nm is built for one target family and refuses a cross-built image with “File format not recognized”.

You probably already have llvm-nm without knowing it: rustup ships it as part of the llvm-tools component, under the toolchain’s own bin directory rather than on your PATH, and the tool looks there. If it reports that it found no usable nm, run:

rustup component add llvm-tools

llvm-cxxfilt (from a system LLVM) is used when present to demangle; without it the tool falls back to nm -C.

RMW Backends — Host-Language Policy

This page documents which language each RMW backend is implemented in, and the rule that decides it. The matrix was originally frozen 2026-05-07; the L tier (+ L.8, landed 2026-05-12) collapsed the public RMW surface so every backend now reaches the runtime via the same nros_rmw_vtable_t bridge. The underlying implementations still ship in whichever language their upstream library prefers, but consumers never see that — they only see the C vtable.

The rule (post-115.L)

Every backend installs itself via the nros_rmw_vtable_t bridge. The underlying library’s host language is an implementation detail of the per-backend -cffi shim; it does not appear on the consumer surface.

The original rule (a backend’s host language matches its underlying library’s native language unless overridden) is still how we pick the inside of each -cffi shim — but the shim itself is uniform: a small Rust or C++ TU that fills in a vtable and calls nros_rmw_cffi_register_named(name, &vtable) once at startup.

Hierarchy

nros-core (Rust) ──→ Rmw trait (internal; bridged by RustBackendAdapter<R>)
                        └──→ nros-rmw-cffi   (C ABI bridge, registry)
                                ↓ nros_rmw_vtable_t  (~17 fn ptrs)
                                ├──→ nros-rmw-zenoh    (wraps Rust nros-rmw-zenoh)
                                ├──→ nros-rmw-xrce-cffi     (links C nros-rmw-xrce)
                                ├──→ nros-rmw-cyclonedds    (C++ direct, no Rust)
                                └──→ nros-rmw-uorb      (C++ direct, no Rust)

The shims are the canonical consumer surface. Public Cargo features on nros / nros-c / nros-cpp (rmw-{zenoh,xrce}-cffi, cffi-{zenoh-cffi,xrce-c}) all route through the same nros_rmw_vtable_t runtime. The pre-L.7 direct-Rust-trait features (rmw-zenoh, rmw-xrce, rmw-uorb) are gone.

Phase 169 (2026-05-19) — dust-dds retired. The nros-rmw-dds Rust shim and the dust-dds upstream Rust DDS implementation have been removed (Phase 169.4). Cyclone DDS is the sole DDS backend; the nros-rmw-cyclonedds shim registers under its canonical name "cyclonedds" only. The previous "dds" generic slot is not aliased — callers always select Cyclone by its specific name (NROS_RMW=cyclonedds, target_link_libraries(... NanoRos::Rmw::cyclonedds), etc.).

Any language with stable C-ABI interop (C, C++, Zig, Rust, Go-via-cgo, Python-via-ctypes…) can implement a backend by filling in the vtable and calling nros_rmw_cffi_register_named(name, &vtable) once at startup.

Decision matrix (post-115.L, updated by Phase 171)

BackendUnderlying libUnderlying langShim crateVerdict
Cyclone DDSCyclone DDSC / C++nros-rmw-cyclonedds (C++ direct vtable; canonical DDS backend)keep
XRCEmicro-XRCE-DDS-ClientCnros-rmw-xrce-cffi (Rust shim over the C nros-rmw-xrce static lib; 115.K.2 ported)keep
zenoh-picozenoh-picoCnros-rmw-zenoh (Rust → vtable via RustBackendAdapter<ZenohRmw>)keep
uORBPX4 module SDKC++nros-rmw-uorb (C++ direct vtable; 115.K.4 port replaces legacy nros-rmw-uorb Rust crate)keep

Dust-DDS was retired in Phase 169 (2026-05-19) after repeated bring-up failures on embedded targets. It is intentionally absent from the active backend table; Cyclone DDS now fills the DDS slot.

Rust-backend cffi shape

For backends whose upstream library is Rust (zenoh-pico) the cffi shim ships as a tiny crate that calls RustBackendAdapter::<UnderlyingRmw>::register(). The adapter monomorphizes a static nros_rmw_vtable_t over the Rust Rmw trait impl and installs it into the C registry. Consumer code never sees the trait surface; it only sees the vtable.

The legacy direct-Rust-trait crates (nros-rmw-zenoh, nros-rmw-xrce) stay in the workspace as internal-only implementation libs of these shims. They have no public Cargo feature reaching them after.

C-/C++-backend cffi shape

For backends whose upstream library is C/C++ (Cyclone DDS, uORB, XRCE) the cffi shim is a standalone CMake project that builds a static C/C++ library and registers a nros_rmw_vtable_t at startup via nros_rmw_cffi_register_named. No RustBackendAdapter is involved. The Rust runtime sees these via the same registry; the NROS_RMW=<name> CMake selector flips a build-time macro that ensures the register call is wired into nros::init.

Cyclone DDS: runtime type introspection (Phase 212.K.7)

The Cyclone DDS shim does not require per-type backend code on the generated msg crate. Phase 212.K.7 inverted the original design where each msg crate carried an optional cyclonedds Cargo feature plus a Cyclone-specific descriptor sidecar.

In the runtime-introspection design every generated msg crate is purely the wire-format data type (#[derive]d struct + a tiny impl nros_serdes::Message exposing const TYPE_NAME + const FIELDS). On the first typed create_publisher<M> / create_subscription<M> for a given M, the nros-rmw-cyclonedds shim walks the static field schema, builds a Cyclone ddsi_sertype via Cyclone’s dynamic-type C API, and caches the pointer in a bounded heapless::FnvIndexMap<u64, NonNull<ddsi_sertype>, MAX_TYPES> guarded by a platform-selected mutex. Subsequent uses of the same M are hash-map hits.

End state: no <msg-pkg>/cyclonedds Cargo feature anywhere, no per-msg-pkg backend code, no codegen branching on the active RMW. The shape matches upstream rclcpp’s introspection typesupport + rclrs’s plain <pkg> = "*" consumer manifest.

Sizing knob: NROS_CYCLONEDDS_MAX_TYPES (default 32), wired through the existing nros-sizes build probe (same pattern as EXECUTOR_OPAQUE_U64S). See section 212.K.7 of docs/roadmap/phase-212-ux-cargo-native-and-file-consolidation.md for the work-item ledger.

When to revisit

This matrix is a snapshot. Update it when any backend’s situation changes:

  • A new backend lands → add a row + shim-crate + verdict.
  • An existing backend’s underlying library changes language (e.g. zenoh-pico ships a Rust port upstream) → swap the shim shape (Rust adapter vs. C/C++ direct) but the vtable bridge stays.

The rule stays. Only per-backend verdicts and shim shapes move.

Registry + naming

nros-rmw-cffi holds a fixed-size named registry of backend vtables. Each backend registers under a canonical name at process startup:

BackendNameRegistered by
zenoh-pico"zenoh"nros_rmw_zenoh_register() (auto-ctor on POSIX)
micro-XRCE-DDS-Client"xrce"nros_rmw_xrce_register() (C ctor on POSIX)
Cyclone DDS"cyclonedds"nros_rmw_cyclonedds_register() (Phase 169.5 — canonical DDS backend; no generic "dds" alias)
uORB"uorb" (future)TBD

Naming policy

  • Lowercase ASCII identifying the protocol / wire format. Not the transport variant — "xrce" covers both XRCE-UDP and XRCE-serial; the transport is selected via the locator (udp/... vs serial:/dev/...).
  • Stable across releases. Renaming a registered name is a breaking change for bridge code that selects backends by string.
  • No "default" for new backends. It was the implicit name of the legacy single-arg nros_rmw_cffi_register_named, which phase-482 W6 deleted; name the protocol instead.

Capacity

Registry size: NROS_RMW_MAX_BACKENDS build-time env var consumed by nros-rmw-cffi/build.rs. Default 8. Range [1, 64]. Set lower on Cortex-M0+ (where each slot’s ~40 B costs); set higher for bridge nodes with 4+ backends. Hitting the cap = subsequent nros_rmw_cffi_register_named returns NROS_RMW_RET_ERROR.

Default-backend convention

Executor::open and any create_node call without an explicit .rmw(name) selector use the first-registered backend — the nano-ros equivalent of ROS 2’s RMW_IMPLEMENTATION. Single- backend binaries with one auto-registering backend Just Work without user code mentioning the backend’s name.

The user-facing knob is a declared, language-agnostic, per-deploy value (system.toml [system].rmw / [image.<id>].rmw, or a CLI/build flag) that the toolchain lowers to each language’s native link mechanism. The Cargo feature / shim dep and the CMake cache var below are those lowering targets — what the build uses, not how a user picks a backend (see RFC-0031):

  • Cargo (Rust): the declared RMW lowers to the nros rmw-<x> feature plus the matching nros-rmw-<x> shim dep in the consumer’s [dependencies]. Linking the shim crate is what registers the backend.
  • CMake: cmake -DNROS_RMW=zenoh ... (C/C++ users). The nano_ros_link_rmw(... RMW zenoh) helper auto-generates the per-target nros_app_register_backends() strong stub.

No runtime env-var override; selection is fixed at link time (RTOS-friendly, matches our static-link world).

Symbol-survival mechanism

Backend register symbols must survive linker dead-strip. Four mechanisms, layered:

  1. linkme distributed-slice (/ 128.H.2) — each backend contributes an RMW_INIT_ENTRIES entry through the nros_rmw_register_backend! macro. rclc_support_init / Executor::open walks the slice and calls each entry. Canonical on the hosted targets whose linker sections linkme recognises — of which nano-ros builds only the native host board. Macro expands to a no-op on RTOS targets where linkme can’t recognise the section (NuttX, Zephyr, FreeRTOS, bare-metal).
  2. Rust ctor (legacy fallback): #[unsafe(link_section = ".init_array")] #[used] static AUTO_REGISTER_CTOR. #[used] keeps rustc from dead-stripping.
  3. C ctor (legacy fallback): __attribute__((constructor)) static void nros_rmw_<name>_register_ctor. Same survival via .init_array walk by libc startup.
  4. CMake strong stub (landed): the nano_ros_link_rmw(<target> RMW <name>) helper at cmake/NanoRosLink.cmake:62-117 emits an auto-generated TU per target that defines a strong nros_app_register_backends() calling every linked RMW’s nros_rmw_<name>_register(). The weak default in libnros_c_weak_stubs.a is overridden. This is the canonical path on every RTOS where linkme can’t survive.
  5. Explicit user call (Rust no_std bridges): nros_rmw_<name>::register() from main() — drags the rlib’s CGU into the binary so the linkme entry is reachable. See the gateway fixture packages/testing/nros-tests/bins/bridge-zenoh-to-xrce-fwd/.

Bare-metal + RTOS targets that don’t run .init_array rely on (4). Pure-Rust no_std binaries with multiple backends rely on (5). POSIX builds get (1) + (2) + (3) for free.

Ctor ordering

POSIX .init_array runs ctors in link order, not in any user-controlled sequence. When multiple backends auto-register in one binary, the first to fire owns the default slot — the one selected by Executor::open() / nros::init() with no .rmw(name) argument. The order is reproducible per link graph but not portable across linkers (lld vs. mold vs. gold) or build configs (LTO can reorder via --print-icf-sections collapse) and must not be relied on for correctness.

Disambiguation is the user’s job in multi-backend binaries. Use the named entry points:

  • Rust: Executor::open_with_rmw("zenoh", &cfg) for the primary session; node_builder("name").rmw("cyclonedds").build() for additional Nodes.
  • C: rclc_node_init_ex with rcl_node_options_t.rmw_name set.
  • C++: rclcpp::Executor::open_with_rmw(...) and rclcpp::NodeBuilder::rmw(...) mirror the Rust API (Phase 104.C.9).

The packages/testing/nros-tests/bins/bridge-zenoh-to-cyclonedds-fwd/ gateway fixture (run by the mixed-RMW bridge e2e) shows the pattern end-to-end: both Zenoh and Cyclone DDS backend ctors fire at lib-load (so the registry has both "zenoh" and "cyclonedds" slots populated), and open_with_rmw("zenoh", ...) plus node_builder("egress").rmw("cyclonedds") pin each session to its intended backend without depending on link-order luck.

Single-backend builds keep the legacy ergonomics — only one ctor fires, the default-slot convention picks it up, and no user-visible name is ever required. The cost of naming is paid only when multiple backends coexist.

Real-time budget per backend

The poll loop’s worst-case execution time is dominated by the backend’s transport drain. Bridge users summing bridge_wcet = Σ poll_i + Σ dispatch_j need each backend’s contribution; this table captures the current best-effort estimates from per-backend microbenchmarks (packages/testing/nros-bench/wcet-cycles-qemu/, packages/testing/nros-bench/wake-latency-cortex-m3/) + heap-usage stats from cargo build --release symbol dumps.

Backendpoll_wcet_usBuffer-pool sizeNotes
zenoh-pico (nros-rmw-zenoh)~50–200 µs nominal on Cortex-M3 (FreeRTOS QEMU — emulated timing, host-load-dependent; no hardware measurement exists)Z_BATCH_UNICAST_SIZE (default 6500 B/peer) + 4 KB per subscription buffered ringWake-cb collapses idle wait to kernel xSemaphore post — sub-poll-period latency when transport notifies. POSIX cv-wait path same shape, ~1 µs notify-to-dispatch.
XRCE-DDS (nros-rmw-xrce-cffi)~100–500 µs per uxr_run_session_time on POSIX; agent-round-trip dominates over local poll. Bare-metal targets pay the same poll cost.STREAM_HISTORY (4) × UXR_CONFIG_UDP_TRANSPORT_MTU (512 B default) ≈ 2 KB/stream; one input + one output stream per sessionPoll-only — set_wake_callback slot is NULL; spin_once cv-wait still wakes on its deadline. Agent does the reliable-retransmit accounting; client adds ~10 µs per stream per tick.
Cyclone DDS (nros-rmw-cyclonedds)~150–600 µs on POSIX; C++ listener callback latency depends on Cyclone’s reader-cache scan.Cyclone’s RTPS history per the DDS QoS History.depth (default 10) + Cyclone’s own DDSI buffer pool (~32 KB default)Listener-side set_wake_callback wiring is follow-up — today the C++ vtable sets the slot NULL. Memory footprint dominated by Cyclone itself, not the nano-ros shim.

Bridge users: sum the poll_wcet_us for every backend the bridge process opens, then add per-callback dispatch budget (typically <10 µs for the executor’s arena dispatch + the user callback’s own work). A bridge_picas_priority regression test (blocked on the PiCAS dispatcher) will eventually pin a bar to this table.

Per-backend README.md files live at packages/{zpico/nros-rmw-zenoh,dds/nros-rmw-cyclonedds,xrce/nros-rmw-xrce-cffi}/README.md (when present); reach out to the backend’s maintainer for fresh microbench numbers on a different target class than the ones above.

See also

  • roadmap doc — Appendix D carries LOC sizing, port shapes, and risk notes.
  • Custom Transport porting guide — how the transport vtable composes with the RMW vtable.
  • packages/rmw/cyclonedds/nros-rmw-cyclonedds/ — reference layout for the C++ vtable consumer.
  • packages/rmw/uorb/nros-rmw-uorb/ — reference layout for the C++ vtable consumer with PX4 SDK integration.

ROS 2 rmw_zenoh Interoperability

This document describes how nros communicates with standard ROS 2 nodes using rmw_zenoh_cpp.

Status: WORKING

nros <-> ROS 2 rmw_zenoh communication is fully operational as of January 2025.

Architecture

┌─────────────────────────┐          ┌─────────────────────────┐
│   ROS 2 Node            │          │   nros Node             │
│   (rmw_zenoh_cpp)       │<-------->│   (zenoh-pico)          │
│                         │  Zenoh   │                         │
│   ros2 topic echo       │  Router  │   native-rs-talker      │
│   /chatter              │ (zenohd) │                         │
└─────────────────────────┘          └─────────────────────────┘

Both nodes connect to the same Zenoh router (zenohd). zenoh’s router-free peer mode is not compiled into the nano-ros shim — NROS_SESSION_MODE=peer is refused at session open (issue 0682).

Quick Start

# Terminal 1: Start zenoh router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Run nros talker
cargo run -p native-rs-talker --features zenoh -- --tcp 127.0.0.1:7447

# Terminal 3: Run ROS 2 listener
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Protocol Requirements

1. Data Topic Key Expression Format

For ROS 2 Humble, the data key expression format is:

<domain_id>/<topic_name>/<type_name>/<type_hash>

Important: For Humble, use TypeHashNotSupported as the type hash (no RIHS01_ prefix).

Example:

0/chatter/std_msgs::msg::dds_::String_/TypeHashNotSupported

Note: Newer ROS 2 versions (Iron+) use RIHS01_<hash> format with actual type hashes.

Implementation: TopicInfo::to_key() in the transport traits module

2. Liveliness Token Format

rmw_zenoh uses Zenoh liveliness tokens for discovery. Without these, ros2 topic list won’t show nros topics.

Node Liveliness Token:

@ros2_lv/<domain>/<zid>/0/0/NN/%/%/<node_name>

Publisher Liveliness Token:

@ros2_lv/<domain>/<zid>/0/11/MP/%/%/<node_name>/<topic>/<type>/RIHS01_<hash>/<qos>

Subscriber Liveliness Token:

@ros2_lv/<domain>/<zid>/0/11/MS/%/%/<node_name>/<topic>/<type>/RIHS01_<hash>/<qos>

Key differences from data keyexprs:

  • Liveliness tokens do use RIHS01_ prefix
  • Topic names use % instead of / (e.g., %chatter not /chatter)
  • ZenohId must be in LSB-first hex format

Implementation: Ros2Liveliness in the transport/zenoh module

3. QoS String Format

The QoS portion of liveliness tokens uses this format:

reliability:durability:history,depth:deadline_sec,deadline_nsec:lifespan_sec,lifespan_nsec:liveliness,liveliness_sec,liveliness_nsec

Values:

  • Reliability: 1=RELIABLE, 2=BEST_EFFORT
  • Durability: 1=TRANSIENT_LOCAL, 2=VOLATILE
  • History: 1=KEEP_LAST

For BEST_EFFORT/VOLATILE with KEEP_LAST depth 1:

2:2:1,1:,:,:,,

Important: Empty values default to RELIABLE which causes QoS mismatch. Always specify explicit values.

4. CDR Message Format

Messages use CDR (Common Data Representation) little-endian encoding:

[0x00, 0x01, 0x00, 0x00]  // CDR encapsulation header (LE)
[... CDR payload ...]     // Message data

Implementation: the serdes crate

5. RMW Attachment Format

rmw_zenoh requires metadata attached to each published message. For Humble, the serialization format is:

FieldSizeFormat
sequence_number8 bytesint64, little-endian
timestamp8 bytesint64, little-endian (nanoseconds)
gid_length1 byteVLE encoded (value: 16)
gid16 bytesRandom bytes, constant per publisher

Total: 33 bytes

The serialization uses Zenoh’s ze_serializer to ensure compatibility with zenoh-cpp’s deserializer.

Implementation: serialize_rmw_attachment() in zenoh-pico/src/serializer.rs

6. ZenohId Format

The ZenohId in liveliness tokens must be formatted in LSB-first (little-endian) hex format.

#![allow(unused)]
fn main() {
// Correct: LSB-first
fn to_hex_string(&self) -> String {
    let mut hex = String::new();
    for byte in self.bytes.iter() {  // LSB first
        write!(&mut hex, "{:02x}", byte).ok();
    }
    hex
}
}

Common Issues and Solutions

Issue: Discovery works but no messages received

Symptom: ros2 topic list shows the topic, ros2 topic info shows correct QoS, but ros2 topic echo receives nothing.

Cause: Data keyexpr format mismatch.

Solution: For Humble, don’t use RIHS01_ prefix in data keyexprs:

  • Wrong: 0/chatter/std_msgs::msg::dds_::String_/RIHS01_TypeHashNotSupported
  • Correct: 0/chatter/std_msgs::msg::dds_::String_/TypeHashNotSupported

Issue: Topic not visible in ros2 topic list

Symptom: nros publishes but ROS 2 doesn’t see the topic.

Cause: Liveliness token format incorrect.

Solutions:

  1. Check ZenohId is LSB-first hex format
  2. Ensure topic name uses % prefix (e.g., %chatter)
  3. Verify QoS string has explicit values, not defaults

Issue: QoS incompatibility warnings

Symptom: ROS 2 logs QoS incompatibility or subscriber doesn’t receive.

Cause: Publisher and subscriber QoS don’t match.

Solution: Use BEST_EFFORT for both:

ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Issue: rmw_zenoh not connecting to router

Symptom: rmw_zenoh uses peer mode by default, not connecting to zenohd.

Solution: Force client mode:

export ZENOH_CONFIG_OVERRIDE='mode="client";connect/endpoints=["tcp/127.0.0.1:7447"]'

Implementation Status

ComponentStatusLocation
Data keyexpr formatDoneTopicInfo::to_key()
Liveliness tokensDoneRos2Liveliness
QoS formatDone2:2:1,1:,:,:,,
CDR serializationDoneserdes crate
RMW attachmentDoneserialize_rmw_attachment()
ZenohId formatDoneZenohId::to_hex_string()
Node integrationDoneConnectedNode

Version Compatibility

ROS 2 VersionType Hash FormatStatus
HumbleTypeHashNotSupportedWorking
Iron+RIHS01_<sha256>Needs type hash computation

References

FreeRTOS LAN9118 Debugging

This page collects the low-level FreeRTOS + lwIP + QEMU LAN9118 notes that are useful when the platform guide is not enough.

Stack

  • Board: QEMU MPS2-AN385, Cortex-M3, 25 MHz.
  • Ethernet: LAN9118 MMIO at 0x4020_0000, IRQ 13.
  • Software: FreeRTOS, lwIP threaded mode (NO_SYS=0), zenoh-pico over BSD sockets.

Default task layout:

PriorityTaskRole
4tcpip_threadlwIP protocol processing
4poll taskdrains LAN9118 RX FIFO into lwIP
4zenoh read / leasezenoh-pico background I/O
3app tasknano-ros executor and user code
0idlemust execute WFI for QEMU networking

The poll task must run at least as high as the zenoh read task; if it cannot drain the RX FIFO, TCP keep-alives are missed and zenoh sessions expire.

LAN9118 Registers

Useful direct registers:

OffsetNamePurpose
0x00RX_DATA_PORTRX FIFO data
0x20TX_DATA_PORTTX FIFO data
0x40RX_STAT_PORTRX packet status
0x50ID_REVchip ID / revision
0x54IRQ_CFGinterrupt output configuration
0x58INT_STSinterrupt status
0x6CRX_CFGRX configuration
0x70TX_CFGTX configuration
0x7CRX_FIFO_INFRX FIFO status / bytes used
0x80TX_FIFO_INFTX FIFO free space
0xA4MAC_CSR_CMDindirect MAC CSR command
0xA8MAC_CSR_DATAindirect MAC CSR data

Indirect MAC CSR registers include MAC_CR (TXEN/RXEN), ADDRH, ADDRL, MII_ACC, and MII_DATA. Always wait for the CSR busy bit to clear before starting another access.

QEMU Delivery Model

QEMU routes LAN9118 traffic through a legacy hub:

guest LAN9118 NIC <-> QEMU hub <-> backend (Slirp / TAP / socket)

When the guest transmits, QEMU delivers the frame synchronously through the hub. Slirp or TAP replies, such as ARP responses, may arrive in the RX FIFO before the guest TX register write returns. This is useful for diagnostics: check RX_FIFO_INF immediately after TX, before yielding to FreeRTOS.

QEMU drops frames that arrive before MAC_CR_RXEN is enabled, and its LAN9118 model does not flush queued packets when RXEN changes. Enable RX before traffic starts.

Common Pitfalls

Diagnostic shows zero RX packets. The poll task may have consumed the packet before diagnostics read the register. Check RX_FIFO_INF immediately after TX or temporarily prevent the poll task from running.

Network init hangs. lwIP threaded mode requires the FreeRTOS scheduler to be running before tcpip_init(). Initialize networking from a task, not before vTaskStartScheduler().

HardFault on first context switch. The Cortex-M3 FreeRTOS port expects vPortSVCHandler, xPortPendSVHandler, and xPortSysTickHandler at the vector table positions. Avoid wrapper functions except for the guarded SysTick case.

lwIP core-lock assertion. Keep LWIP_TCPIP_CORE_LOCKING=0; the setup path calls netif functions from the app task after tcpip_init().

Intermittent stalls. Always write IRQ_CFG_DEFAULT (0x22000111) during LAN9118 init, even when polling.

Corrupted RX frames. RX_STAT_PORT packet length includes the 4-byte FCS. Read all aligned FIFO words, including CRC bytes, but pass only pkt_len - 4 bytes to lwIP.

Second QEMU node cannot connect. Seed rand() uniquely per node. zenoh-pico uses z_random_fill() for session IDs; identical QEMU boots otherwise produce duplicate IDs.

lwIP ASSERT: Invalid mbox. The application stack is too small. The executor arena lives on the FreeRTOS task stack; use 64 KB for service and action examples.

Action client times out on result. Manual-polling action servers created with create_action_server() must call try_handle_get_result() explicitly after completing a goal.

No inbound frames in QEMU. The idle hook must execute WFI so QEMU’s main event loop can service network file descriptors:

void vApplicationIdleHook(void) {
    __asm__ volatile("wfi");
}

Debugging Tips

  • Use ARM semihosting for early printf output.
  • Launch QEMU with -monitor telnet:127.0.0.1:4444,server,nowait and run info network or info qtree.
  • Send a diagnostic ARP request and check for immediate RX FIFO data.
  • Confirm ID_REV, MAC_CR TXEN/RXEN, TX_FIFO_INF, RX_FIFO_INF, and INT_STS during bring-up.

References

  • FreeRTOS-Plus-TCP MPS2_AN385 driver.
  • Zephyr drivers/ethernet/eth_smsc911x.c.
  • ARM CMSIS LAN9220 Ethernet driver templates.
  • QEMU hw/net/lan9118.c.
  • nano-ros packages/drivers/net/lan9118-smoltcp/.

Patched qemu-system-arm Binary

nano-ros ships a project-local QEMU build that the test harness uses in preference to whatever qemu-system-arm is on $PATH. This chapter explains why it exists, where it comes from, how a test picks it up, and how to change it.

Why

Two production-blocking issues motivated the patched build:

  1. LAN9118 RX FIFO drain bug (mainline QEMU through at least 11.0). Under sustained burst RX, the LAN9118 emulator’s lan9118_can_receive() can stop returning true even after the guest drains the FIFO, so frames silently disappear. The fix is a commit on our fork branch (hw/net/lan9118: flush queued packets when RX is enabled). Bare-metal MPS2-AN385 RTPS / Zenoh tests on the system QEMU sporadically fail without it.

  2. -netdev dgram,local.type=unix,… requires QEMU 7.2+. Ubuntu jammy ships QEMU 6.2, and the dgram-tunnel pattern is how NuttX / ThreadX DDS multi-instance tests cross-deliver frames between two QEMU processes after retired the broken -netdev socket,mcast= cross-process path. With a too-old system QEMU, those tests fall back to [SKIPPED].

Both problems disappear once tests use the patched build.

Where it comes from

The patches live on a branch of our QEMU fork, NEWSLabNTU/qemu nano-ros-v11.0.0-patches, and nano-ros-sdk publishes it as a prebuilt dist. nros-sdk-index.toml pins it as one row, [tool.qemu] (version = "11.0.0-nros6" at the time of writing — read the index, not this line), with a [tool.qemu.source] recipe for a host that has no prebuilt. There is no QEMU submodule in this tree any more.

Two places can hold it:

~/.nros/sdk/qemu/<version>/bin/qemu-system-arm   # the store: `nros setup --tool qemu`
build/qemu/bin/qemu-system-arm                   # project-local: `just qemu setup-qemu`

The store path is keyed by version, so it cannot drift from the pin. The project prefix is ONE directory across pin bumps, which is why it carries the .nros-provenance marker nros setup writes, naming the version it holds (issue 0930). just qemu setup-qemu runs nros setup --tool qemu --prefix build/qemu, which is a no-op when that marker names the pinned version and replaces the prefix when it does not.

How tests pick it up

ONE ladder (issue 1851), in packages/testing/nros-tests/src/qemu.rs (qemu_system_path) and its shell twin scripts/lib/qemu-bin.sh (nros_qemu_bin):

  1. QEMU_SYSTEM_<ARCH> (QEMU_SYSTEM_ARM) — an explicit override.
  2. The project prefix build/qemu/bin/ — only while its .nros-provenance names the version the index pins. A prefix holding another version, or none (a hand build), is REFUSED with the remedy (just qemu setup-qemu, or remove build/qemu), never skipped: a test must not run on an emulator nobody chose. just check tier-preconditions reports the same thing before a run.
  3. The store copy at the pinned version.
  4. $PATH.

Every QEMU launch in the test crates, just/ and scripts/ goes through it (check-qemu-one-ladder).

Smoke test

packages/testing/nros-tests/tests/qemu_patched_binary.rs asserts:

  • qemu_system_arm_path() resolves to either QEMU_SYSTEM_ARM or <workspace>/build/qemu/bin/qemu-system-arm.
  • The patched binary reports version ≥ 7.2.
  • -netdev help advertises dgram (the multi-instance backend).

Tests use nros_tests::unmet! when the patched build is absent — a fresh clone without just qemu setup-qemu FAILS with a clear [UNMET PRECONDITION] naming the remedy instead of silently passing.

Adding a patch, or moving the pin

  1. Land the change on the fork branch (nano-ros-v11.0.0-patches), one logical change per commit.
  2. Cut a new nano-ros-sdk QEMU release from it.
  3. Bump [tool.qemu] version and the dist rows in nros-sdk-index.toml.

Every checkout then sees its build/qemu as stale until it re-runs just qemu setup-qemu; the store copy is fetched at the new version on the next nros setup --tool qemu.

Scope

Only qemu-system-arm is unified. qemu-system-riscv32 is Espressif’s fork (different upstream, different patches); qemu-system-riscv64 and qemu-system-aarch64 ship no patches today. Other arches stay on the system binary until they accumulate their own patches; the helper pattern is easy to copy when that happens.

Opaque Storage Sizing

nano-ros exposes a C and C++ API on top of Rust types whose layout is chosen by the Rust compiler. The C/C++ side has to allocate storage for those types — by value, on the user’s stack or BSS — without knowing their exact byte size. The runtime makes Rust the single source of truth for those sizes: the values flow from core::mem::size_of of the real Rust types into auto-generated C/C++ headers, with no hand-tuned constants.

The pattern

Every size that crosses the Rust / C boundary follows the same path:

  1. Export from nros::sizes — the nros umbrella crate defines a pub const FOO_SIZE: usize = core::mem::size_of::<T>(); and emits a #[used] static __NROS_SIZE_FOO: [u8; FOO_SIZE] = [0u8; FOO_SIZE] with a #[no_mangle] symbol whose storage size in the rlib is FOO_SIZE. The two artefacts come from a single export_size! macro invocation. The static exists only under the ffi-size-markers feature, which only the size probe’s own nested build of nros turns on, so a linked image never carries the markers (issue 1765):

    #![allow(unused)]
    fn main() {
    // packages/api/nros/src/sizes.rs
    export_size!(pub PUBLISHER_SIZE = RmwPublisher);
    export_size!(pub SUBSCRIBER_SIZE = RmwSubscriber);
    export_size!(pub EXECUTOR_SIZE = nros_node::Executor);
    // ...etc
    }
  2. Probe from consumer build scripts — nros-c/build.rs and nros-cpp/build.rs use the helper crate nros-sizes-build to find build nros in an isolated probe target directory with ffi-size-markers added, and read the __NROS_SIZE_* symbol storage sizes with the object crate. No subprocess, no llvm-nm; pure Rust.

  3. Emit #define NROS_FOO_SIZE into a generated header — nros_config_generated.h (C) and nros_cpp_config_generated.h (C++) carry the probe values. types.h includes the generated config transitively, so every nros C header sees NROS_*_SIZE automatically.

  4. C/C++ structs use the macros —

    typedef struct rcl_publisher_t {
        /* ... */
        _Alignas(8) uint8_t _opaque[NROS_PUBLISHER_SIZE];
    } rcl_publisher_t;
    
    class Publisher {
        alignas(8) uint8_t storage_[NROS_PUBLISHER_SIZE];
        /* ... */
    };
    

    The Rust side reads/writes the same bytes via &mut *(opaque as *mut RmwPublisher). C and Rust agree on the size by construction.

What the SSoT covers today

The nros::sizes module exports:

SymbolTypeUsed by
SESSION_SIZERmwSessionrclc_support_t._opaque
PUBLISHER_SIZERmwPublisherrcl_publisher_t._opaque, nros::Publisher<M>::storage_
SUBSCRIBER_SIZERmwSubscriberrclcpp::PollSubscription<M>::storage_
SERVICE_CLIENT_SIZERmwServiceClientrclcpp::PollClient<S>::storage_
SERVICE_SERVER_SIZERmwServiceServerrclcpp::PollService<S>::storage_
EXECUTOR_SIZEnros_node::Executorrclc_executor_t._opaque, nros::Executor::storage_
GUARD_CONDITION_SIZEnros_node::GuardConditionrcl_guard_condition_t._guard_opaque, nros::GuardCondition::storage_
LIFECYCLE_CTX_SIZEnros_node::lifecycle::LifecyclePollingNodeCtxrcl_lifecycle_state_machine_t._opaque_storage
ACTION_SERVER_INTERNAL_SIZEActionServerInternalLayoutrcl_action_server_t._internal
CPP_ACTION_SERVER_SIZECppActionServerLayoutrclcpp::ActionServer<A>::storage_
CPP_ACTION_CLIENT_SIZECppActionClientLayoutrclcpp::ActionClient<A>::storage_

Plus three *Internal C-API shim structs (ServiceServerInternal, ServiceClientInternal, ActionClientInternal) that cbindgen now emits directly into nros_generated.h because they’re #[repr(C)] — the C side just embeds them as typed fields, no opaque storage at all.

Layout-mirror trick

Some downstream types — nros-c::ActionServerInternal, nros-cpp::CppActionServer, nros-cpp::CppActionClient — embed C-API pointer types (*mut rcl_action_server_t, *const rclc_goal_handle_t) that aren’t visible from the nros umbrella crate. They can’t be referenced from nros::sizes directly, but their byte size only depends on the field shape: pointers are pointers, Option<extern "C" fn> collapses to a fn-pointer-sized slot via niche optimization, etc.

The fix is a layout-mirror struct in nros::sizes:

#![allow(unused)]
fn main() {
// packages/api/nros/src/sizes.rs
#[repr(C)]
#[doc(hidden)]
pub struct ActionServerInternalLayout {
    pub handle: nros_node::ActionServerRawHandle,
    pub executor_ptr: *mut c_void,
    // ...same field shape as the real `ActionServerInternal`,
    // with C-API pointers replaced by `*mut c_void`...
}
export_size!(pub ACTION_SERVER_INTERNAL_SIZE = ActionServerInternalLayout);
}

Downstream then asserts byte-equivalence at compile time:

#![allow(unused)]
fn main() {
// packages/api/nros-c/src/opaque_sizes.rs
const _: () = assert!(
    size_of::<crate::action::ActionServerInternal>()
        == size_of::<nros::sizes::ActionServerInternalLayout>(),
    "ActionServerInternal size diverges from nros::sizes::ActionServerInternalLayout — \
     update the layout mirror in `nros/src/sizes.rs`"
);
}

The tripwire: any field-shape change in the real wrapper (adding a field, changing a pointer to a value, etc.) must be paired with an update to the mirror. The build fails immediately if they diverge.

How sizing works today

  • All four *Internal shim types are #[repr(C)] and embedded as typed fields in their outer nros_*_t structs.
  • All seven C++ wrappers (Publisher, Subscription, Service, Client, ActionServer, ActionClient, GuardCondition) use the NROS_*_SIZE / NROS_CPP_*_STORAGE_SIZE probe macros.
  • types.h ships zero *_OPAQUE_U64S macros; the four consumer module headers route through the probe.
  • The remaining hand-coded “upper bound” assertions (e.g., the EXECUTOR_OPAQUE_U64S envelope check in nros-c/build.rs) are defence-in-depth: they fire if a Rust type accidentally exceeds a configured envelope (which would normally also be flagged by the config knobs).

Adding a new size export

When a new Rust handle type needs to cross the FFI boundary:

  1. Add the export_size!(pub FOO_SIZE = nros_node::Foo) line to nros/src/sizes.rs. (If Foo lives in a downstream crate that nros can’t import, define a FooLayout mirror struct first and add a byte-equivalence assert in the owning crate.)
  2. Add a let probe_foo = probed.get("FOO_SIZE").copied().unwrap_or(0) line to nros-c/build.rs (and/or nros-cpp/build.rs), and emit #define NROS_FOO_SIZE {probe_foo} into the generated config header.
  3. Use NROS_FOO_SIZE in the C/C++ struct that needs the storage.

Cross-target verification: the same probe runs for every target triple. There’s no per-platform branching — size_of::<T>() resolves to the right value at the target’s compile time.

See also

  • packages/tooling/nros-sizes-build/src/lib.rs for the rlib probe implementation.
  • packages/api/nros/src/sizes.rs for the canonical exports.

Scheduling Models

This chapter introduces the real-time scheduling models used by the platforms nano-ros supports. Understanding these models helps you make informed decisions about task priorities, deadline guarantees, and platform selection for your application.

Background: What Is Real-Time Scheduling?

A real-time scheduler decides which task (or thread) runs on the CPU at any given moment. The key property is predictability — not raw speed. A system that always finishes a 10 ms task in exactly 10 ms is more “real-time” than one that finishes it in 1 ms but occasionally takes 50 ms.

Two key concepts recur across all models:

  • Priority: a numeric value that determines which task runs when multiple tasks are ready. Higher-priority tasks preempt lower-priority ones.
  • Preemption: the ability to interrupt a running task to run a higher-priority one. Non-preemptive (cooperative) systems only switch tasks at explicit yield points.

Hard vs. Soft Real-Time

TypeGuaranteeConsequence of missed deadline
HardDeadline must never be missedSystem failure (safety hazard)
SoftDeadline should rarely be missedDegraded quality (dropped frame, late message)

nano-ros targets soft real-time by default (bounded message latency, bounded memory). Hard real-time is achievable on RTIC and with careful priority assignment on RTOS platforms.

Scheduling Algorithms

Fixed-Priority Preemptive (FPP)

The most common RTOS scheduling algorithm. Each task has a static priority assigned at creation time. The scheduler always runs the highest-priority ready task. When a higher-priority task becomes ready, it immediately preempts the running task.

Time ──────────────────────────────────────►
        ┌───────┐           ┌───────┐
High    │ Task A│           │ Task A│        (preempts B when ready)
        └───┬───┘           └───┬───┘
            │ ┌───┐    ┌───┐   │ ┌───┐
Low         └►│ B │    │ B │   └►│ B │       (runs when A is blocked)
              └───┘    └───┘     └───┘

Schedulability analysis uses Rate-Monotonic Analysis (RMA): assign higher priority to tasks with shorter periods. RMA is optimal for independent periodic tasks — if any fixed-priority assignment can meet all deadlines, RMA can too.

The response time of a task under FPP is:

R_i = C_i + Σ ⌈R_i / T_j⌉ × C_j    (for all higher-priority tasks j)

Where C_i is worst-case execution time, T_j is the period of higher-priority task j. The task meets its deadline if R_i ≤ D_i.

Used by: FreeRTOS, ThreadX, NuttX (SCHED_FIFO), Zephyr (preemptive threads), RTIC.

Round-Robin (Time-Sliced)

Tasks at the same priority level share CPU time in equal time slices (quanta). When a task’s quantum expires, the scheduler switches to the next same-priority task. Tasks at different priority levels still follow FPP rules.

Time ──────────────────────────────────────►
        ┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐
Pri 3   │A ││B ││A ││B ││A ││B │            (equal time slices)
        └──┘└──┘└──┘└──┘└──┘└──┘

Round-robin prevents starvation among equal-priority tasks but adds scheduling jitter (a task may wait up to (N-1) × quantum before running, where N is the number of same-priority tasks).

Used by: NuttX (SCHED_RR), FreeRTOS (when configUSE_TIME_SLICING=1), ThreadX (when time_slice > 0).

Cooperative (Non-Preemptive)

Tasks run until they explicitly yield the CPU. No preemption occurs. This eliminates the need for locks (no race conditions) but requires every task to yield frequently. A single task that runs too long blocks all others.

Time ──────────────────────────────────────►
        ┌──────────┐     ┌────┐
Task A  │ runs     │     │    │              (runs until yield())
        └────┬─────┘     └──┬─┘
             │ ┌────────┐   │ ┌──────┐
Task B       └►│ runs   │   └►│ runs │       (gets CPU only when A yields)
               └────────┘     └──────┘

Used by: Zephyr (cooperative threads via K_PRIO_COOP), bare-metal super-loops, Embassy async executor.

Interrupt-Driven (Hardware Scheduling)

The CPU’s interrupt controller acts as a hardware scheduler. Each interrupt source has a hardware priority level. When an interrupt fires, the hardware saves context and jumps to the handler — no software scheduler overhead. Nested interrupts provide preemption between priority levels.

On ARM Cortex-M, the Nested Vectored Interrupt Controller (NVIC) provides:

  • Up to 256 priority levels (typically 8–16 usable)
  • Zero-cycle context switch for tail-chaining interrupts
  • Deterministic latency (12 cycles to handler entry on Cortex-M3)
                          NVIC
IRQ Priority    ┌──────────────────┐
   0 (highest)  │ SysTick          │──► timing / OS tick
   1            │ UART RX          │──► message receive
   2            │ Timer            │──► periodic publish
   3 (lowest)   │ PendSV           │──► background work
                └──────────────────┘

This is the most deterministic scheduling model — no jitter from software task switching, no priority inversion, and worst-case latency is bounded by the longest critical section that disables interrupts.

Used by: RTIC (exclusively), bare-metal interrupt handlers.

Earliest Deadline First (EDF)

A dynamic-priority algorithm: the task with the nearest deadline always runs next. EDF is optimal — it can schedule any task set that is schedulable by any algorithm, up to 100% CPU utilization (vs. ~69% for RMA with harmonic periods).

Time ──────────────────────────────────────►
        ┌───┐       ┌───┐
Task A  │ d=5│      │d=15│     (deadline 5, then 15)
        └───┘       └───┘
            ┌────┐      ┌────┐
Task B      │d=10│      │d=20│  (deadline 10, then 20)
            └────┘      └────┘

EDF is harder to analyze than FPP (no simple closed-form response time) and harder to implement (priority changes every scheduling decision). Overload behavior is also less predictable — under FPP, low-priority tasks miss deadlines first; under EDF, deadline misses can cascade unpredictably.

Used by: Zephyr (CONFIG_SCHED_DEADLINE + k_thread_deadline_set()) as the kernel-native capability, and the portable executor’s SchedClass::Edf ready-set inside dispatch. Which platform gets which is decided by the realizer — see the generated Scheduling Wiring Matrix.

Platform Scheduling Comparison

RTIC (ARM Cortex-M)

RTIC is not an RTOS — it is a concurrency framework that compiles directly to hardware interrupt handlers. There is no scheduler, no task control blocks, and no context switch overhead.

PropertyValue
ModelInterrupt-driven (NVIC hardware scheduling)
PreemptionYes — hardware interrupt nesting
Priority levels4–16 (depends on Cortex-M variant)
Priority directionLower number = higher priority (ARM convention)
Context switch12 cycles (Cortex-M3 tail-chain)
Mutual exclusionStack Resource Policy (SRP) — compile-time deadlock-free
Scheduling analysisFully static — all priorities and resources known at compile time

RTIC’s key innovation is the Stack Resource Policy (SRP): shared resources are protected by ceiling-based priority elevation, not locks. The compiler proves at build time that no deadlock or unbounded priority inversion can occur. This gives interrupt-driven scheduling the safety of a cooperative model.

#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac)]
mod app {
    #[task(priority = 2, shared = [sensor_data])]
    async fn read_sensor(mut ctx: read_sensor::Context) {
        ctx.shared.sensor_data.lock(|data| {
            *data = read_adc();  // runs at ceiling priority
        });
    }

    #[task(priority = 1, shared = [sensor_data])]
    async fn publish(mut ctx: publish::Context) {
        ctx.shared.sensor_data.lock(|data| {
            node.publish(*data);  // cannot deadlock — SRP guarantee
        });
    }
}
}

nano-ros integration: RTIC tasks call nano-ros directly — no executor needed. Each RTIC task can own a publisher, subscription, or service handle. See examples/mps2-an385-baremetal/rust/talker-rtic/.

FreeRTOS

The most widely deployed RTOS. Uses fixed-priority preemptive scheduling with optional round-robin time-slicing for same-priority tasks.

PropertyValue
ModelFixed-priority preemptive (FPP)
PreemptionYes — configUSE_PREEMPTION=1 (default)
Priority levelsconfigMAX_PRIORITIES (typically 8–32)
Priority directionHigher number = higher priority
Context switch~80 cycles on Cortex-M (PendSV handler)
Mutual exclusionMutexes with optional priority inheritance
Time slicingOptional — configUSE_TIME_SLICING

FreeRTOS tasks are created with xTaskCreate(), specifying a priority and stack size. The scheduler runs the highest-priority ready task. When multiple tasks share a priority and time-slicing is enabled, they round-robin at tick boundaries.

Priority inheritance: FreeRTOS mutexes optionally support priority inheritance to mitigate priority inversion. When a low-priority task holds a mutex needed by a high-priority task, the low-priority task temporarily inherits the high priority until it releases the mutex.

Without inheritance:        With inheritance:
  H ──blocks──►             H ──blocks──►
  M ──runs────►             L ──promoted──► (runs at H's priority)
  L ──holds mutex           L ──releases──► H runs immediately
     (M preempts L →        (no M preemption → bounded inversion)
      unbounded inversion)

nano-ros task layout on FreeRTOS:

TaskDefault PriorityStackRole
nros_app3 (Normal)64 KBExecutor, callbacks, spin
net_poll4 (AboveNormal)1 KBPoll LAN9118 RX FIFO
zenoh read4 (AboveNormal)5 KBSocket read, message decode
zenoh lease4 (AboveNormal)5 KBKeep-alive, lease monitor
tcpip_thread4 (AboveNormal)4 KBlwIP protocol processing

ThreadX (Azure RTOS)

ThreadX is designed for deeply embedded systems with a unique preemption-threshold feature not found in other RTOSes.

PropertyValue
ModelFixed-priority preemptive (FPP) with preemption-threshold
PreemptionYes — with configurable threshold per thread
Priority levels32 (0–31)
Priority directionLower number = higher priority
Context switch~60 cycles (optimized assembly for each architecture)
Mutual exclusionMutexes with priority inheritance
Time slicingPer-thread configurable (time_slice parameter)

The preemption-threshold is ThreadX’s distinguishing feature. Each thread has two priority values:

  1. Priority: determines scheduling order (which thread runs next)
  2. Preemption-threshold: the minimum priority that can preempt this thread
Thread A: priority=10, preempt_threshold=5
  → Scheduled based on priority 10
  → Only threads with priority 0–4 can preempt it
  → Threads with priority 5–9 must wait, even though they're higher priority

Thread B: priority=10, preempt_threshold=10  (threshold = priority)
  → Normal behavior — any higher-priority thread can preempt

This effectively creates non-preemptive regions without disabling interrupts. A thread performing a critical sequence of operations can set a low preemption-threshold to prevent most preemption while still allowing the highest-priority threads through.

The academic basis is the dual-priority model: preemption-threshold reduces context switches (and thus stack usage) while preserving schedulability. Research shows it can reduce RAM requirements by 30–50% compared to pure FPP, because fewer threads need independent stacks for preemption frames.

nano-ros on ThreadX: multi-tier run_tiers is real (phase-297, RFC-0053) — one executor per tier over one shared session, with per-tier stacks allocated from the ThreadX byte pool via nros_threadx_create_task. The tier’s preempt_threshold is passed natively at tx_thread_create (a -1 sentinel means “use the priority”, i.e. normal preemption). Tiers are resolved descending by raw priority number, so on ThreadX (lower number = higher priority) tiers[0] is the lowest-priority tier; the boot tier runs first and re-prioritizes itself to its declared priority.

NuttX

NuttX is a POSIX-compliant RTOS, meaning it implements the full pthread and sched APIs from IEEE 1003.1. This makes it the most portable platform — standard POSIX real-time scheduling is well-understood and widely taught.

PropertyValue
ModelPOSIX SCHED_FIFO (FPP), SCHED_RR, or SCHED_SPORADIC
PreemptionYes (FIFO/RR), configurable
Priority levels1–255 (POSIX sched_param.sched_priority)
Priority directionHigher number = higher priority
Context switchKernel-managed, architecture-dependent
Mutual exclusionPOSIX mutexes with PTHREAD_PRIO_INHERIT or PTHREAD_PRIO_PROTECT
Scheduling policyPer-thread via sched_setscheduler()

NuttX supports three POSIX scheduling policies:

SCHED_FIFO (First-In-First-Out): Pure fixed-priority preemptive. A task runs until it blocks, yields, or is preempted by a higher-priority task. Tasks at the same priority run in FIFO order — no time-slicing.

SCHED_RR (Round-Robin): Same as SCHED_FIFO but with time-slicing for same-priority tasks. Each task gets a time quantum before the scheduler switches to the next task at that priority.

SCHED_SPORADIC (NuttX extension): Implements the sporadic server algorithm for aperiodic event handling. A task alternates between a high “normal” priority and a low “background” priority based on its execution budget:

Budget = 5ms, Replenish period = 20ms

Time ──────────────────────────────────────────────────►
        ┌─────┐                    ┌─────┐
High    │5ms  │                    │5ms  │     (budget)
        └──┬──┘                    └──┬──┘
           │ ┌──────────────┐        │
Low        └►│ background   │        └►...    (budget exhausted)
             └──────────────┘
                    ▲ replenish after 20ms

The sporadic server bounds the interference that an aperiodic task imposes on periodic tasks, making it analyzable with standard RMA techniques. This is valuable for event-driven ROS callbacks that fire at irregular intervals.

Priority inversion protocols: NuttX supports both POSIX priority inheritance (PTHREAD_PRIO_INHERIT) and priority ceiling (PTHREAD_PRIO_PROTECT):

ProtocolHow it worksTrade-off
Priority inheritanceHolder inherits waiter’s priorityDynamic — may chain through multiple locks
Priority ceilingMutex has fixed ceiling priority; holder runs at ceilingStatic — simpler analysis, avoids chained inversion

nano-ros on NuttX: Currently runs with kernel defaults (no explicit scheduling policy). Future work will add SCHED_FIFO and priority configuration.

Zephyr

Zephyr provides the most flexible scheduling model, supporting cooperative threads, preemptive threads, and EDF in a single system.

PropertyValue
ModelCooperative + preemptive + optional EDF
PreemptionConfigurable per-thread
Priority levelsCooperative: K_PRIO_COOP(0) to K_PRIO_COOP(N) (negative values); Preemptive: K_PRIO_PREEMPT(0) to K_PRIO_PREEMPT(N) (positive values)
Priority directionLower number = higher priority
Context switchArchitecture-dependent (~100 cycles on Cortex-M)
Mutual exclusionk_mutex with priority inheritance
Meta-IRQUltra-high-priority threads that preempt even cooperative threads

Zephyr’s scheduling model has three tiers:

Priority Number Line:
◄── higher priority                    lower priority ──►
    │                │                    │
    │  Meta-IRQ      │  Cooperative       │  Preemptive
    │  (negative,    │  (negative,        │  (0 or positive)
    │   special)     │   non-preemptible) │
    │                │                    │

Cooperative threads (K_PRIO_COOP): Cannot be preempted by other threads (only by interrupts). They run until they explicitly yield or block. This is useful for critical sections that must not be interrupted by other threads, while still allowing hardware interrupts.

Preemptive threads (K_PRIO_PREEMPT): Standard FPP behavior. Can be preempted by any higher-priority thread (cooperative or preemptive).

Meta-IRQ threads (CONFIG_NUM_METAIRQ_PRIORITIES): Special ultra-high-priority threads that can preempt even cooperative threads. Used for work that must complete with interrupt-like urgency but needs thread context (e.g., stack, blocking calls). This fills the gap between ISR context (limited API) and thread context (preemptible).

Deadline scheduling: Zephyr optionally supports EDF via CONFIG_SCHED_DEADLINE. Threads call k_thread_deadline_set() to declare their next deadline. Among threads at the same priority level, the scheduler picks the one with the earliest deadline. This allows EDF within a priority band while preserving FPP across bands — a hybrid approach that combines EDF’s optimality with FPP’s predictable overload behavior.

nano-ros on Zephyr: Currently uses a single main thread with default priority. The async service example uses Embassy’s executor with kernel-backed waking (zephyr::embassy::Executor).

Priority Inversion

Priority inversion is a well-studied problem in real-time systems. It occurs when a high-priority task is indirectly blocked by a low-priority task through a shared resource, while a medium-priority task runs unimpeded.

The classic example (from the Mars Pathfinder incident, 1997):

  High-priority task ──► blocks on mutex held by Low
  Medium-priority task ──► preempts Low (doesn't need mutex)
  Low-priority task ──► holds mutex, can't run (M preempts)

  Result: High is blocked by Medium indefinitely

Solutions

SolutionApproachPlatforms
Priority inheritanceMutex holder inherits highest waiter’s priorityFreeRTOS, ThreadX, NuttX, Zephyr
Priority ceilingMutex has fixed ceiling; holder runs at ceilingNuttX (PTHREAD_PRIO_PROTECT)
SRP (Stack Resource Policy)Compile-time ceiling, zero runtime overheadRTIC
Preemption-thresholdLimit which tasks can preemptThreadX
Lock-free designAvoid shared resources entirelynano-ros single-slot buffers

nano-ros mitigates priority inversion architecturally: subscriptions use single-slot buffers with atomic overwrites — no mutex needed between publisher and subscriber tasks. The executor processes callbacks in a single task, eliminating inter-task resource sharing for most use cases.

Choosing a Scheduling Model

CriterionRTICFreeRTOSThreadXNuttXZephyr
DeterminismBest (hardware)Good (FPP)Good (FPP+threshold)Good (POSIX FPP)Good (FPP+coop+EDF)
Worst-case latency12 cycles~80 cycles~60 cyclesKernel-dependent~100 cycles
Priority inversionImpossible (SRP)InheritanceInheritance + thresholdInheritance + ceilingInheritance
Analysis toolsCompile-time proofsRMA/RTARMA/RTA + thresholdPOSIX standard RMARMA + EDF analysis
FlexibilityLow (ARM only)MediumMedium-HighHigh (POSIX)Highest
RAM overheadLowest (no TCBs)LowLow (threshold reduces stacks)Medium (kernel)Medium (kernel)

Use RTIC when: you need hard real-time on ARM Cortex-M, want compile-time scheduling proofs, and can live without dynamic task creation.

Use FreeRTOS when: you need a widely supported RTOS with a small footprint and a large ecosystem. Good for projects where portability across MCU vendors matters more than advanced scheduling features.

Use ThreadX when: you need deterministic scheduling with reduced RAM (preemption-threshold), or your project targets Azure IoT infrastructure. ThreadX is also safety-certified (IEC 61508 SIL 4, ISO 26262 ASIL D).

Use NuttX when: you want POSIX compatibility (reuse Linux-targeted code on embedded), need SCHED_SPORADIC for aperiodic events, or want PTHREAD_PRIO_PROTECT for static priority ceiling analysis.

Use Zephyr when: you need maximum scheduling flexibility (cooperative + preemptive + EDF in one system), want a Linux Foundation backed project with broad hardware support, or need meta-IRQ for interrupt-like thread priorities.

Further Reading

  • Liu, C.L. and Layland, J.W. (1973). “Scheduling Algorithms for Multiprogramming in a Hard-Real-Time Environment.” Journal of the ACM, 20(1), 46–61. — The foundational paper for Rate-Monotonic Analysis.
  • Sha, L., Rajkumar, R., and Lehoczky, J.P. (1990). “Priority Inheritance Protocols: An Approach to Real-Time Synchronization.” IEEE Transactions on Computers, 39(9), 1175–1185. — Priority inheritance and priority ceiling protocols.
  • Baker, T.P. (1991). “Stack-Based Scheduling of Realtime Processes.” Real-Time Systems, 3(1), 67–99. — The Stack Resource Policy used by RTIC.
  • Wang, Y. and Saksena, M. (1999). “Scheduling Fixed-Priority Tasks with Preemption Threshold.” RTCSA. — The theoretical basis for ThreadX’s preemption-threshold.
  • Buttazzo, G.C. (2011). Hard Real-Time Computing Systems: Predictable Scheduling Algorithms and Applications. Springer. — Comprehensive textbook covering FPP, EDF, and hybrid approaches.

Dispatch Strategy

Phase 216 design rationale. This chapter explains why DispatchStrategy is shaped the way it is — the trichotomy, the per-Node granularity, the tag-based callback API, the __nros_node_<pkg>_dispatch_strategy() ABI symbol, and the backward-compat contract. For the user-facing tutorials see RTIC Integration and Embassy Integration.

The Inline / Deferred / FromIsr trichotomy

A nano-ros Node declares Node::DISPATCH: DispatchStrategy to tell the codegen + lint layers how its callbacks need to be delivered:

#![allow(unused)]
fn main() {
// File: packages/platform/nros-platform/src/board/dispatch.rs
#[repr(u8)]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum DispatchStrategy {
    Inline = 0,
    Deferred = 1,
    FromIsr = 2,
}
}

The variants:

  • Inline — Callbacks fire from the executor’s spin loop, in the same task that drives transport I/O. Default for every Node (preserves every pre-Phase-216 Node pkg unchanged). Served by every runtime: POSIX, FreeRTOS, NuttX, Zephyr, ThreadX, bare-metal, RTIC (proxied via __nros_dispatch task when needed), Embassy (likewise — though Inline is unusual under cooperative async; see the matrix below).

  • Deferred — Callbacks land in a board-side queue (heapless::spsc::Queue on RTIC, embassy_sync::channel::Channel on Embassy). A framework-owned dispatch task drains the queue and drives ExecutableNode::on_callback from its own task context, decoupling callback latency from network polling. Required for callback-driven Nodes on RTIC + Embassy.

  • FromIsr — Callbacks fire directly from an ISR handler. Design slot only — implementation deferred to Phase 216.E.1. Reserved so the lint matrix has a stable discriminant to reject against; current builds error out at the nros check layer.

Framework × Strategy matrix

The matrix nros check (Phase 216.D.1) enforces:

              ┌──────────────────────────────────────────────────────┐
              │            DispatchStrategy                          │
              │  Inline      │  Deferred       │  FromIsr            │
┌─────────────┼──────────────┼─────────────────┼─────────────────────┤
│ posix       │  OK          │  OK             │  ERR: no ISRs       │
│ rtos        │  OK          │  OK             │  ERR: no ISRs       │
│ rtic        │  WARN: pref. │  OK (canonical) │  FUTURE (216.E.1)   │
│             │  Deferred    │                 │                     │
│ embassy     │  WARN: pref. │  OK (canonical) │  ERR: no ISR exec   │
│             │  Deferred    │                 │                     │
└─────────────┴──────────────┴─────────────────┴─────────────────────┘
  • POSIX + RTOS: both Inline and Deferred work. Inline is the default because the executor’s spin loop is the natural place to dispatch callbacks on hosted targets.
  • RTIC + Embassy: Deferred is canonical. Inline is permitted (the framework adapter still serves it) but nros check warns: callback-driven Nodes that run inline tie their handler latency to spin task scheduling, which is rarely what you want under hardware-interrupt or async-cooperative scheduling.
  • FromIsr on POSIX/RTOS: rejected — there’s no meaningful “ISR context” for nano-ros to dispatch from on a hosted OS.
  • FromIsr on RTIC: future — needs the reentrancy audit + SPSC rework + per-Node #[isr_safe] contract called out in Phase 216.E.1.
  • FromIsr on Embassy: rejected — Embassy has no concept of “callback fires from an ISR handler”; ISR-driven work hands off to an async task via embassy_sync::signal::Signal or similar.

Why per-Node, not per-callback

A Node that wants its /chatter subscription to run inline but its /heartbeat subscription to run deferred is conceptually expressible — we could put DispatchStrategy on each create_subscription call. We don’t, for two reasons:

  1. The lint matrix collapses. Per-callback strategies multiply the (framework, strategy) matrix by the number of callbacks per Node. The error surface grows quadratically and the messages get harder to phrase. Per-Node keeps each Node either fully Inline or fully Deferred — one strategy to reason about per pkg.
  2. Implementation simplicity. The dispatch task drains a single queue and routes each entry by CallbackId to a single Node’s on_callback. Per-callback would mean tagging each entry with its own strategy at enqueue time and forking the dispatch path. Adds weight for a feature no real user has asked for yet.

If a real user demonstrates a Node that genuinely wants mixed strategies — typically because one subscription handler must hold a high-priority lock while another doesn’t — Phase 216.E.3 is the slot to reconsider. Until then: YAGNI.

Why FromIsr is a design slot, not an impl

The FromIsr discriminant exists in the enum today so that:

  • The [repr(u8)] discriminants are stable. Inline = 0, Deferred = 1, FromIsr = 2 are wire-frozen — the __nros_node_<pkg>_dispatch_strategy() ABI symbol returns a u8 that nros check reads without linking the Node crate. Adding FromIsr later (when 216.E.1 lands) would either renumber existing discriminants (breaking already-compiled Node binaries) or require a new ABI symbol.
  • The lint matrix has somewhere to point. Without the variant in the enum, the nros check matrix would have to special-case “user wrote FromIsr but it doesn’t exist yet” via spelling-comparison rather than enum match. Worse error messages, worse evolvability.

The actual implementation needs three pieces that aren’t there yet:

  • Reentrancy audit. Every step of the dispatch path — signal_callback, queue push, RMW raw-CDR buffer ownership — must be re-entrant against ISR-priority producers. Today’s path assumes thread-context callers.
  • Lock-free SPSC variant. heapless::spsc::Queue is single- producer / single-consumer at thread priority. ISR-priority producer + thread-priority consumer needs a stronger ordering contract (memory barrier on the ISR-side push at minimum, possibly a different queue type).
  • Per-Node #[isr_safe] proof contract. on_callback must not call anything that can block, panic, or allocate. Statically proving that for arbitrary user code is a documentation + attribute exercise we haven’t undertaken.

The substrate Phase 214.J built (atomic_waker for cross-task notification) is the building block for the SPSC variant; landing 214.J was a precondition for being able to even prototype FromIsr. The full impl is deferred until a real ISR-driven driver demands it.

Tag-based callback API rationale

The closure-based registration API:

#![allow(unused)]
fn main() {
// Pre-216.A.4 — still valid for Inline Nodes.
ctx.create_subscription("/chatter", |msg: Int32| {
    handle(msg);
});
}

works fine when the callback runs synchronously on the spin task: the closure captures state by value (or by &mut) and gets called inline during spin_once. The captured environment lives on the spin task’s stack frame, no heap, no boxing.

Deferred dispatch breaks that assumption. The callback now fires from a different task than the one that called register, which means the closure environment must either:

  • Outlive both tasks (require 'static capture — move || everywhere, but state still needs somewhere to live), or
  • Be stored generically in the runtime, which means erasing the closure type to Box<dyn FnMut(...)> (alloc-dependent) or to extern "C" fn (no captures at all).

Both options conflict with keeping dispatch heap-free and framework-task-routed. (Heap-free here is a checked property of the executor path, not of an image with a transport: heap-free-poc-mps2 links the executor, component install and spin path with no allocation symbol, and the L3 CI lane fails if that changes. Every shipped RMW backend allocates in C.) So Phase 216.A.4 introduces tags:

#![allow(unused)]
fn main() {
// File: packages/api/nros/src/dispatch_tag.rs
pub struct SubscriptionTag(&'static str);
pub struct ServiceTag(&'static str);
pub struct ActionTag(&'static str);

impl From<SubscriptionTag> for CallbackId<'static> { /* ... */ }
impl PartialEq<Callback<'_>> for SubscriptionTag { /* ... */ }
}

The tag carries only the &'static str callback identifier — zero runtime cost, no captures, FFI-safe. State lives on ExecutableNode::State; the macro-emitted init() body wires the tag fields by calling the _static registration variants (create_subscription_static / create_service_static / create_action_static) and storing the returned tag onto Self::State.

Dispatch then matches the tag against the Callback<'_> event:

#![allow(unused)]
fn main() {
fn on_callback(state: &mut Self::State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
    if state.sub_chatter == cb {
        let msg: Int32 = ctx.downcast().unwrap();
        // ...
    } else if state.sub_heartbeat == cb {
        let beat: Heartbeat = ctx.downcast().unwrap();
        // ...
    }
}
}

The PartialEq<Callback<'_>> impl on each tag type does the comparison in &'static str terms — O(ptr_eq) in the common case when the runtime hands back the same &'static the user registered.

Inline keeps closures. The closure-vs-tag split is enforced by the macro lint (216.A.6): a Deferred Node using create_subscription (the closure form) fails to compile with a clear error pointing at the registration call and suggesting the _static form. An Inline Node using _static is allowed — but Deferred → closure is rejected. Zero migration cost for pre-216 Node pkgs (all defaulted to Inline) and a forced-correct API for the Deferred path.

The __nros_node_<pkg>_dispatch_strategy() ABI symbol

The nros::node!() macro emits, per Node pkg:

#![allow(unused)]
fn main() {
// Emitted by nros::node!(Talker) — Phase 216.A.5.
#[unsafe(no_mangle)]
extern "C" fn __nros_node_talker_pkg_dispatch_strategy() -> u8 {
    <Talker as ::nros::Node>::DISPATCH as u8
}
}

Three consumers care about this symbol:

  1. nros check (Phase 216.D.1). Statically inspects the Node crate’s .rmeta or links the staticlib + reads the symbol via dlsym/GetProcAddress (host-side check; embedded targets only read it from .rmeta). Compares against the Entry pkg’s board framework metadata using the matrix above; rejects mismatches at nros check time, before the user runs cargo build.

  2. The nros::main!() proc-macro (Phase 216.B.3 / C.3). When expanding the Entry pkg’s main.rs it walks the registered Node list and reads each pkg’s strategy. If any Node is Deferred, the generated #[rtic::app] / #[embassy_executor::main] body includes the __nros_dispatch task; if all are Inline, the dispatch task is omitted (zero overhead for Inline-only workspaces).

  3. Future runtime diagnostic tools. A nros doctor or nros topology style introspection tool can read the symbols from a linked binary and print the (pkg, strategy) table without having to re-parse the source. Useful for post-mortem on a binary you didn’t build yourself.

The extern "C" + [repr(u8)] ABI is the contract. It must not break across nano-ros versions — adding a new strategy variant means adding a new discriminant (FromIsr = 2 was reserved up front exactly to avoid this), never renumbering an existing one.

The trait surface split (post-214.K.1)

Phase 214.K.1 renamed the board-side dispatch sink from NodeRuntime to NodeDispatchRuntime (the user-facing sink kept the NodeRuntime name, which is now in packages/api/nros/src/node.rs). Phase 216 lands its new methods on NodeDispatchRuntime:

#![allow(unused)]
fn main() {
// File: packages/platform/nros-platform/src/board/runtime.rs
pub trait NodeDispatchRuntime {
    // ... existing methods unchanged ...

    fn signal_callback(&mut self, _cb_id: CallbackId<'_>, _ctx: &mut CallbackCtx<'_>) {
        panic!("signal_callback not implemented for Inline runtime");
    }
    fn dispatch_strategy(&self) -> DispatchStrategy {
        DispatchStrategy::Inline
    }
}
}

Both methods are defaulted — zero-touch for the existing Inline impls (ExecutorNodeRuntime in nros, NullNodeRuntime in nros-platform). A Deferred runtime overrides both:

#![allow(unused)]
fn main() {
// File: packages/boards/nros-board-mps2-an385/src/rtic.rs (sketch)
impl NodeDispatchRuntime for RticRuntime {
    fn dispatch_strategy(&self) -> DispatchStrategy {
        DispatchStrategy::Deferred
    }
    fn signal_callback(&mut self, cb_id: CallbackId<'_>, ctx: &mut CallbackCtx<'_>) {
        // SAFETY: SPSC producer is single-threaded by RTIC priority assignment.
        self.queue_producer.enqueue((cb_id, ctx.snapshot())).unwrap();
        // Wake the dispatch task; RTIC will schedule it at its declared priority.
        __nros_dispatch::spawn().ok();
    }
}
}

signal_callback’s default panic is the right behavior: the Inline path never calls it (callbacks flow through the existing inline trampoline). If a Deferred Node ends up on an Inline runtime — for example because the user manually picked the wrong board — the panic is louder than silently dropping the callback. The lint at Phase 216.D.1 prevents this combination from compiling in the first place; the panic is a belt-and-braces backstop.

Backward compatibility

Two contracts:

  1. Defaulted associated const. Node::DISPATCH is const DISPATCH: DispatchStrategy = DispatchStrategy::Inline; in the trait definition. Edition 2024 supports defaulted associated consts as stable, so every pre-216 impl Component for ... block that doesn’t mention DISPATCH continues to compile and is treated as Inline.
  2. Closure API preserved on the Inline path. The Inline runtime keeps the closure-based registration path (create_subscription, create_service, create_action). The macro lint only rejects closure use when DISPATCH = Deferred. Every Phase 212 Node pkg using closures stays valid without changes.

The migration shape for a Phase 212 Node that wants to move to Deferred is:

#![allow(unused)]
fn main() {
// Before — Phase 212 Inline-by-default.
impl Component for Listener {
    const NAME: &'static str = "listener";
    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        ctx.create_subscription::<Int32>("/chatter", |msg| {
            defmt::info!("Received: {}", msg.data);
        })?;
        Ok(())
    }
}

// After — Phase 216 Deferred.
impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Listener {
    type State = ListenerState;
    fn init() -> Self::State {
        ListenerState { sub_chatter: SubscriptionTag::placeholder() }
    }
    fn on_callback(state: &mut Self::State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
        if state.sub_chatter == cb {
            let msg: Int32 = ctx.downcast().unwrap();
            defmt::info!("Received: {}", msg.data);
        }
    }
}
}

The migration adds three things: the DISPATCH const, the tag-typed State field, and the on_callback body. The closure body in the “before” version becomes the if state.sub_chatter == cb { ... } branch in the “after” version — same code, hoisted to a method.

See also

  • RTIC Integration — user-facing tutorial for the RTIC side of dispatch.
  • Embassy Integration — user-facing tutorial for the Embassy side of dispatch + spawn-from-sync.
  • Scheduling Models — the real-time scheduling backdrop against which dispatch strategy choices are made.
  • docs/roadmap/archived/phase-216-baremetal-framework-integration.md — the locked spec.
  • packages/platform/nros-platform/src/board/dispatch.rs — the DispatchStrategy enum.
  • packages/api/nros/src/dispatch_tag.rs — the tag types.

Real-Time Analysis

This chapter describes static analysis methods and tools for detecting anti-patterns that violate real-time guarantees in Rust embedded applications.

Overview

Real-time systems require deterministic execution times. Common anti-patterns that break real-time guarantees include:

Anti-PatternProblemDetection Method
Unbounded loopsInfinite execution timeClippy + custom lints
RecursionStack overflow, unbounded depthcargo-call-stack
Heap allocationNon-deterministic timing, fragmentationno_std + forbid patterns
Blocking I/OUnbounded wait timesCustom lints
Missing timeoutsOperations can hang foreverCustom lints
Large stack framesStack overflowcargo-call-stack

Built-in Clippy Lints

Loop and Iteration Lints

# Enable all loop-related lints
cargo clippy -- \
    -D clippy::infinite_iter \
    -D clippy::while_immutable_condition \
    -D clippy::never_loop \
    -D clippy::empty_loop
LintDetects
infinite_iterIterator chains guaranteed to be infinite
while_immutable_conditionLoop conditions that can never change
never_loopLoops that exit on first iteration
empty_looploop { } without body (use loop { hint::spin_loop() })

Memory and Performance Lints

cargo clippy -- \
    -W clippy::large_stack_arrays \
    -W clippy::large_types_passed_by_value \
    -W clippy::box_collection \
    -W clippy::rc_buffer

Create clippy.toml in your project root:

# Maximum size for stack-allocated arrays (bytes)
array-size-threshold = 512

# Warn on types larger than this passed by value
trivial-copy-size-limit = 16

# Cognitive complexity threshold
cognitive-complexity-threshold = 15

Running Clippy for Real-Time Code

# Strict mode for real-time code
cargo clippy --all-targets -- \
    -D warnings \
    -D clippy::all \
    -W clippy::pedantic \
    -D clippy::infinite_iter \
    -D clippy::while_immutable_condition \
    -A clippy::module_name_repetitions

Stack Analysis with cargo-call-stack

Installation and Usage

# Install (requires nightly)
cargo +nightly install cargo-call-stack

# Build with stack size info
cd examples/mps2-an385-baremetal/rust/talker-rtic
RUSTFLAGS="-Z emit-stack-sizes" cargo +nightly build --release

# Generate call graph with stack sizes
cargo +nightly call-stack --release > call_graph.dot

# Visualize (requires graphviz)
dot -Tsvg call_graph.dot -o call_graph.svg

Interpreting Results

The output shows:

  • Each function’s stack frame size
  • Call graph relationships
  • Maximum stack depth through any path
  • Cycles (recursion) in the call graph

Example output:

digraph {
    "main" [label="main\n256 bytes"]
    "zenoh_poll" [label="zenoh_poll\n128 bytes"]
    "publisher_task" [label="publisher_task\n512 bytes"]

    "main" -> "zenoh_poll"
    "main" -> "publisher_task"
}

Limitations

  • Requires fat LTO (lto = "fat" in Cargo.toml)
  • Limited support for programs linking std
  • Indirect calls (function pointers, trait objects) may not be analyzed
  • Best for embedded no_std programs

Preventing Heap Allocation

Method 1: no_std Without Allocator

The simplest approach – don’t provide a global allocator:

#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
// No #[global_allocator] defined
// Attempting to use Box, Vec, String will fail to compile
}

Method 2: Compile-Time Enforcement

#![allow(unused)]
#![no_std]
#![forbid(unsafe_code)]  // Also prevents custom allocators

fn main() {
use heapless::{Vec, String};  // Static-sized alternatives

// This will NOT compile:
// let v = alloc::vec::Vec::new();  // Error: no allocator

// This works:
let v: heapless::Vec<u8, 256> = heapless::Vec::new();
}

Method 3: Custom Lint (Dylint)

For projects that need alloc but want to restrict usage in certain modules:

#![allow(unused)]
fn main() {
// In real-time critical code, add:
#![deny(clippy::disallowed_methods)]
}

With clippy.toml:

disallowed-methods = [
    { path = "alloc::vec::Vec::push", reason = "Use heapless::Vec in RT code" },
    { path = "alloc::boxed::Box::new", reason = "No heap in RT code" },
    { path = "alloc::string::String::new", reason = "Use heapless::String" },
]

Detecting Missing Timeouts

Pattern: I/O Operations Without Timeout

Anti-pattern:

#![allow(unused)]
fn main() {
// BAD: No timeout - can block forever
let data = socket.read(&mut buf)?;
}

Correct pattern:

#![allow(unused)]
fn main() {
// GOOD: Explicit timeout
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
let data = socket.read(&mut buf)?;
}

Manual Audit Checklist

For code review, check that these operations have timeouts:

  • TcpStream::connect() – use connect_timeout()
  • socket.read() / socket.write() – set socket timeout
  • channel.recv() – use recv_timeout()
  • zenoh operations – configure session timeout
  • Any blocking syscall

Detecting Unbounded Loops

Clippy Detection

cargo clippy -- -D clippy::infinite_iter -D clippy::while_immutable_condition

Manual Patterns to Audit

Potentially unbounded:

#![allow(unused)]
fn main() {
// BAD: No termination guarantee
loop {
    if let Some(msg) = queue.pop() {
        process(msg);
    }
}

// BAD: Condition may never be false
while !flag.load(Ordering::Relaxed) {
    do_work();
}
}

Bounded alternatives:

#![allow(unused)]
fn main() {
// GOOD: Bounded iteration count
for _ in 0..MAX_ITERATIONS {
    if let Some(msg) = queue.pop() {
        process(msg);
    } else {
        break;
    }
}

// GOOD: Timeout-based termination
let deadline = Instant::now() + Duration::from_millis(10);
while Instant::now() < deadline {
    if let Some(msg) = queue.pop() {
        process(msg);
    } else {
        break;
    }
}
}

Detecting Recursion

cargo-call-stack Cycle Detection

# Will report cycles in call graph
cargo +nightly call-stack --release 2>&1 | grep -i "cycle"

Clippy Recursion Lint

# Warn on unconditional recursion
cargo clippy -- -D clippy::unconditional_recursion

Manual Pattern Detection

#![allow(unused)]
fn main() {
// BAD: Direct recursion
fn process(node: &Node) {
    process(&node.child);  // May overflow stack
}

// BAD: Mutual recursion
fn a() { b(); }
fn b() { a(); }

// GOOD: Iterative with explicit stack
fn process(root: &Node) {
    let mut stack: heapless::Vec<&Node, 32> = heapless::Vec::new();
    stack.push(root).ok();

    while let Some(node) = stack.pop() {
        // Process node
        if stack.push(&node.child).is_err() {
            // Handle stack full - bounded!
            break;
        }
    }
}
}

Advanced: Custom Dylint Lints

Setting Up Dylint

# Install dylint
cargo install cargo-dylint dylint-link

# Create a new lint library
cargo dylint new realtime_lints
cd realtime_lints
LintPurpose
blocking_in_asyncDetect std::thread::sleep, blocking I/O in async
unbounded_loop_in_taskFlag loops without bounds in RTIC tasks
heap_in_interruptDetect allocation in #[interrupt] handlers
mutex_in_interruptFlag std::sync::Mutex in interrupt context
float_in_isrWarn about FP operations in ISRs (soft-float targets)
missing_timeoutI/O operations without timeout

Tool Summary

ToolDetectsEffortCI-Ready
ClippyInfinite iters, recursion, large arraysLowYes
cargo-call-stackStack usage, recursion cyclesMediumYes
MiriUndefined behavior, memory errorsLowYes
DylintCustom patterns (blocking, heap, etc.)HighYes
MIRAIAbstract interpretation, all pathsHighExperimental
no_stdPrevents std heap allocationLowAutomatic
heaplessStatic collectionsLowN/A

Quick Reference: Lint Flags

# Copy-paste for strict real-time checking
cargo clippy -- \
    -D warnings \
    -D clippy::all \
    -D clippy::infinite_iter \
    -D clippy::while_immutable_condition \
    -D clippy::never_loop \
    -D clippy::empty_loop \
    -D clippy::unconditional_recursion \
    -W clippy::large_stack_arrays \
    -W clippy::large_types_passed_by_value \
    -W clippy::cognitive_complexity

WCET Benchmark Harness (no measurements yet)

A Worst-Case Execution Time (WCET) benchmark harness for nros core operations, built around DWT cycle counting on Cortex-M3. It has produced no numbers to date — every in-tree run is under QEMU, where DWT reads zero (see the limitation below), so the tables in this section list the benchmark categories, not results. Producing real baselines needs physical hardware (e.g. STM32F4 @ 168 MHz).

Measurement Platform

PropertyValue
TargetARM Cortex-M3 (thumbv7m-none-eabi)
MachineQEMU lm3s6965evb (for infrastructure validation)
ClockDWT CYCCNT (cycle-accurate on real hardware)
Iterations100 per benchmark (10 warmup)
Optimizationrelease profile (opt-level = "s")

DWT limitation on QEMU: QEMU’s Cortex-M3 emulation does not implement the DWT cycle counter – all reads return 0. The benchmark infrastructure is validated by the fact that it compiles, runs, and produces structured output. Actual cycle counts must be collected on real hardware (e.g., STM32F4 at 168 MHz, STM32F7 at 216 MHz).

Benchmark Categories

CDR Serialization:

FunctionNotes
serialize Int32Single i32 field
deserialize Int32Single i32 field
serialize TimeTwo fields (i32 + u32)
roundtrip Int32Serialize + deserialize
serialize w/ headerCDR encapsulation header + Int32

Node API:

FunctionNotes
Node::new()standalone node creation (nros_node::StandaloneNode)
create_publisher()Register Int32 publisher
serialize_message()Node-level serialize to buffer

Safety E2E:

FunctionNotes
crc32 (64B)CRC-32/ISO-HDLC, 64-byte payload
crc32 (256B)CRC-32/ISO-HDLC, 256-byte payload
crc32 (1024B)CRC-32/ISO-HDLC, 1024-byte payload
validate()SafetyValidator sequence check
full pipeline (128B)Extract attachment + CRC + validate

Sanity Bound

The harness asserts a 100,000-cycle ceiling per function for typical payloads — a design assertion baked into the benchmark, not a measured bound (no measured WCET exists yet; see above). Extract the harness’s artifact with just qemu wcet-artifact:

  • CRC-32 is O(n) in payload size with a single table lookup per byte
  • SafetyValidator::validate() is O(1) – a few comparisons and an increment
  • The full pipeline combines CRC computation with attachment parsing and validation

Running the Benchmark

# Build all QEMU examples (includes rs-wcet-bench)
just qemu build

# Run the WCET benchmark
just qemu test-wcet

Static WCET Analysis

For certification (ISO 26262 ASIL C/D, DO-178C DAL A/B), dynamic measurement is insufficient – static WCET analysis is required. Candidate tools:

ToolTypeLicenseNotes
PlatinStatic (IPET)Open sourceAnalyzes LLVM IR + machine code
aiTStatic (abstract interpretation)Commercial (AbsInt)Industry standard for DO-178C / ISO 26262
SWEETStatic (flow analysis)AcademicResearch tool; good for Cortex-M
ChronosStatic (IPET)Open sourceAcademic; limited embedded target support

Recommended path: Use Platin for open-source WCET bounding on the LLVM IR produced by rustc. For certification evidence, aiT provides the strongest tool qualification story (pre-qualified per ISO 26262 Part 8).

References

Build System & Caching

Audience. Contributors working on the repo build/test matrix. End users never need this — they consume nano-ros with a single add_subdirectory(<repo-root>), no install step, no find_package. This handbook documents the other build world: the in-repo just orchestration that builds + tests every platform × RMW, and the caching that keeps it incremental and correct.

Canonical sources this page summarises: CLAUDE.md (“Build”, “Build tiers”, “Build parallelism”), Phase 176 (unified jobserver), Phase 181 (fixture SSOT

  • Ninja), Phase 177.9 (staleness probes).

TL;DR cheat-sheet

just setup                 # one-time SDK/toolchain install (tiered; see CLAUDE.md)
direnv allow               # once after clone — else zpico-sys/build.rs panics

just build                 # workspace + transports (fast inner loop)
just build-examples        # + every example
just build-all             # + test fixtures (everything test-all consumes)
just build-test-fixtures   # just the fixtures + writes the test-all stamp
just build-test-fixtures lane=linux   # only what tier 1 (`just ci`) runs

just test-unit             # ~5s          ⊂
just test-integration      # ~30s         ⊂
just test                  #              ⊂
just test-all              # heavy QEMU/Zephyr/ROS-interop/miri/codegen

just <plat> build|build-all|test|ci   # narrow to one platform first
NROS_BUILD_JOBS=8 just build-all       # cap parallelism

Rule of thumb: a platform-specific failure → run the narrow just <plat> build-all (or closest build/build-examples/build-fixtures) before the root just build-all. Always just ci after a task. Never sudo — if a step needs it, tell the user.

Generators — Ninja by default

The repo’s CMake builds use Ninja (-G Ninja), falling back to Make only when ninja is absent. Ninja gives reliable incremental rebuilds and fits the Phase 176 fifo jobserver; Make was dropped from the staleness path because make -q mis-reported up-to-dateness.

  • Configure-once: scripts/build/cmake-incremental.sh::nros_cmake_configure_if_needed configures a build dir only when its CMakeCache.txt / generated build system is missing; cmake --build then handles reconfigure on CMakeLists changes.
  • Generator-mismatch wipe: a dir configured with the other generator is rm -rf’d and reconfigured (you can’t switch generators in place).
  • Pinned tools: the unified jobserver needs make ≥ 4.4 + ninja ≥ 1.13 (apt’s 4.3/1.10 lack the fifo jobserver). Both are SDK-store tools — nros setup --tool make (source tarball; upstream ships no binary) and nros setup --tool ninja (upstream prebuilt zip) — and scripts/sdk-path-tools.txt puts them on PATH (incl. a gmake → make-4.4 alias). Resolve an exact path with nros sdk-path <tool>.

Per-RMW build dirs = cache isolation

Each example builds into a per-RMW directory with its own cargo target dir:

examples/<plat>/<lang>/<example>/
  build-zenoh/       cargo/   …   # -DNROS_RMW=zenoh
  build-xrce/        cargo/   …   # -DNROS_RMW=xrce
  build-cyclonedds/  cargo/   …   # -DNROS_RMW=cyclonedds

Because each RMW (and its Corrosion cargo target dir) is physically separate, selecting a different RMW cannot collide with another RMW’s cache — there is no shared target dir to invalidate. Platform is fixed per build dir (-DNROS_PLATFORM=<plat> at configure). This layout is why the old manual cache-invalidation idea (archived Phase 145) was retired: the directory shape makes it unnecessary. (Rust-only examples build via plain cargo into target-<rmw>/; the per-RMW principle is the same.)

Parallelism — NROS_BUILD_JOBS + the unified jobserver

One knob scales every parallel recipe:

NROS_BUILD_JOBS=8 just build-all     # default: nproc

Unified jobserver (Phase 176). just build-all auto-routes to build-all-jobserver when the pinned make 4.4 + ninja 1.13 are present: a single GNU-make fifo jobserver spans cargo + build-script cc + ninja-via-west

  • cmake, allocating tokens dynamically instead of a static per-tool split. Under the jobserver (NROS_JOBSERVER=1) recipes drop their explicit -j/--parallel/CMAKE_BUILD_PARALLEL_LEVEL so the tools inherit the pool.
  • Same artifacts either way; without the pinned tools it falls back to a static split.
  • NROS_NO_JOBSERVER=1 forces the static path.
  • Never re-introduce a hardcoded parallel --jobs <n> without threading ${NROS_BUILD_JOBS:-N} through.

Stall watchdog (issue 1403). Every fixture/jobserver make (the fixture make drivers, fixtures-build.sh, workspace-fixtures-build.sh, the jobserver-pool, build-all-jobserver) runs under scripts/build/make-stall-watchdog.py. If make sits with no live child and no CPU for NROS_JOBSERVER_STALL_SECS (default 600; 0 disables; sampled every NROS_JOBSERVER_STALL_POLL_SECS, default 15), the watchdog writes jobserver-stall-<pid>-<time>.txt into that build’s log dir (make’s wchan/syscall/status or stack, its fds, the jobserver FIFO and its FIONREAD token count, every other process holding a jobserver, a gdb backtrace when ptrace allows), kills make and its descendants — by pid, nothing else — and exits 75 with NO VERDICT: jobserver stall — <file>. That is a lane that never answered, not a build failure; just nightly-triage counts it as NO VERDICT. Attach the file to issue 1403. A normal build is untouched: same exit status, no output.

See docs/roadmap/archived/phase-176-unified-jobserver-build-orchestration.md.

Build & test tiers

Each tier is a strict superset of the previous:

Buildcontains
buildworkspace + transports
build-examples⊃ build + every example
build-all⊃ build-examples + test fixtures
Testwall-clockcontains
test-unit~5 sunit
test-integration~30 s⊃ + integration
test—⊃
test-allminutes⊃ + heavy QEMU/Zephyr/ROS-interop + doc + miri + C codegen

Per-platform: just <plat> build ⊂ build-examples ⊂ build-fixtures ⊂ build-all and just <plat> test|test-all|ci. <plat> = target families (qemu, zephyr, board groups). Support services (zenohd, cyclonedds) are not platform scopes. Orchestration lives in justfile + just/*.just.

Fixture SSOT — examples/fixtures.toml

Per-fixture build options (features, --target-dir, env, per-RMW variants, cmake -D defs, cross target) live in one manifest, examples/fixtures.toml, consumed by both the build recipes and the test-all staleness probe — so the build and the probe use identical options (no feature-thrash).

  • Reader: scripts/build/fixtures-manifest.py list --platform <p> --lang <l> [--rmw <r>] [--for-probe]. Emits \x1f-separated records (unit-separator, not tab — tab is IFS-whitespace).
  • Builder: scripts/build/fixtures-build.sh <plat> [lang] [rmw] — one shared loop; rust cells go through cargo build, C/C++ cells through cmake --build (configure-once + per-RMW dir). Platform-wide -D defs (toolchain, codegen tool, SDK dirs) are injected by the recipe via NROS_CMAKE_EXTRA_DEFS.
  • Cross-toolchain platforms inject RUSTUP_TOOLCHAIN / SDK env via the recipe (e.g. ESP32-C3 = riscv32imc on the workspace nightly + build-std).

Staleness discipline

A prebuilt fixture that’s silently stale would let test-all run against the wrong binary. The discipline:

  • Coverage gate. build-test-fixtures stamps target/nextest/.fixtures-built with the lane and the fixture coordinates it built, not just a timestamp; test-all’s _require-fixtures fast-fails (~1 s) if the stamp is absent or does not cover the run implied by NROS_FIXTURE_LANE (bypass: NROS_SKIP_FIXTURE_CHECK=1). So a lane=linux build satisfies just ci (tier 1) and is correctly rejected by an unscoped test-all. A pre-issue-0393 timestamp-only stamp is read as lane=all, which is what it meant.

    Note “the run”, not “the lane” (issue 0482). A lane’s coordinates are what it keeps fresh; what must exist is decided by which tests the recipe executes. The two are answered separately, and nros_lane_build_lane is the mapping between them (CiLane::run_scope is the declaration; lane-coords <lane> --build-lane prints it).

    Every lane narrows its run by fixture COORDINATE (NROS_TEST_COORDS), and each is its own build lane. Tier 1 used to narrow by test/binary NAME (NROS_TEST_SCOPE=linux); phase-395 W19 moved it to coordinates, and issue 1900 retired the name filter’s reader, which no runner had set since. Each test asks the lane BEFORE it probes the host, so a test of a platform the lane did not select reports [SKIPPED:lane] on every host; scripts/test/check-out-of-lane-deselected.py proves it on the just ci gate lane.

    A name filter could never have expressed tier 2 anyway: it is 1-wise over platform, every platform is in the lane, so a platform-token filter excludes nothing, and the saving is in lang × rmw within a platform, which test names do not encode (issues 0357, 0482). So the narrowing happens in the fixture resolver (phase-340 W3, nros_tests::fixtures::lane): a resolved artifact is attributed back to its examples/fixtures.toml row through row_artifact_root(), and a row outside the lane reports SKIPPED instead of missing. Build and run then apply the same row_coord() predicate to the same coordinate file, so just build-test-fixtures lane=tier2 && just ci-matrix is a coherent pair. Before W3 tier 2 ran the whole suite, and a lane=tier2 build satisfied the preflight for a run that then failed STALE on 34 unbuilt coordinates.

    The skip is keyed on the coordinate, never on the artifact being absent: an in-lane fixture that is missing or stale still fails hard, and a path the manifest cannot attribute (Zephyr west leaves, the compile-check lane — both built module-level, so nothing is omitted) is never skipped.

    The preflight enforces the pairing rather than trusting it: accepting a build narrower than all requires NROS_TEST_COORDS to be set and to hold exactly that lane’s coordinates, so a hand-run NROS_FIXTURE_LANE=tier2 just test-all is refused instead of reproducing issue 0482. A full build needs no narrowing, so NROS_FIXTURE_LANE=tier2 on top of lane=all still works — scope the freshness gate, keep the run wide.

  • Rust cells. scripts/test/rust-fixture-stale.sh reuses cargo’s own fingerprint — cargo build … --message-format=json reports "fresh":false for a stale unit, so the probe both detects and self-heals.

  • C/C++ cells. scripts/test/cmake-fixture-stale.sh runs the incremental cmake --build (near-no-op when fresh) and flags cells that actually rebuilt. (ninja -n is unusable here — Corrosion’s cargo step is an always-run custom command, so it always reports pending.)

  • Probe opt-out. Cells needing a recipe-injected toolchain the probe can’t supply (e.g. the ESP32 nightly + build-std) set skip_probe = true; the reader’s --for-probe omits them so they don’t toolchain-thrash.

  • Source-list / drift gates. zpico-sys (vendored zenoh-pico, Phase 136.6) and nros-rmw-xrce-cffi (vendored uxr/micro-cdr, Phase 145.4) verify each vendored source root resolves to a real dir with .c files and panic with a git submodule update --init hint on drift. cbindgen build scripts (nros-c, nros-cpp, zpico-sys) emit cargo:rerun-if-changed=cbindgen.toml

    • src/.
  • Probe runs in preflight. _check-fixtures-stale runs both probes (rust + cmake) over the manifest before the test-all nextest stage; it warns + self-heals rather than hard-failing.

Patched QEMU

QEMU networked tests use a patched qemu-system-arm (icount + MPS2 fixes) built by just qemu setup-qemu into build/qemu/bin/. The harness picks it up automatically (nros_tests::qemu::qemu_system_arm_cmd()); the system binary is the fallback. See Patched qemu-system-arm.

See also

  • Build Commands — the user-facing quick reference.
  • zpico-sys Build Architecture — the zenoh-pico cross-compile.
  • CLAUDE.md — the authoritative build/test-tier + parallelism policy.
  • Phase docs (docs/roadmap/archived/): 176 (jobserver), 181 (fixture SSOT + Ninja), 177.9 (staleness probes).

CLI lives in the monorepo (Phase 218)

The nros CLI lives in-tree at packages/cli/ as a sub-workspace — own Cargo.toml, own Cargo.lock, own member crates (nros-cli, nros-cli-core, nros-build, cargo-nano-ros, rosidl-*, colcon-cargo-ros2, nros-msg-to-idl). It is built per checkout via just setup-cli → packages/cli/target/release/nros, then put on PATH by the activate file. See also:

Why sub-workspace, not root member

The runtime workspace’s no_std feature-unification view (Phase 214.F.3) is the project’s most fragile invariant — a single workspace member adding a std-activating dep without target-gating turns the embedded build red on a path the test matrix doesn’t always exercise. The CLI’s deps (clap, askama, syn, ureq, …) are aggressively std-only; making them workspace siblings of the runtime crates would either (a) require target-gating every single one or (b) effectively disable the F.3 guard.

The sub-workspace shape keeps the two surfaces categorically separate. The pattern mirrors packages/testing/nros-{tests,bench,smoke}/, which carved out the same way for the same reason.

Why per-checkout, not ~/.nros/bin

Confirmed during the Phase 218 UX walkthrough: contributors with multiple nano-ros worktrees (phase branches, ASI integration trees, downstream forks) need each tree to point at its own CLI. A global install would silently version-skew across trees the moment the user cds — and because the CLI’s codegen format is structurally bound to the nros-core / nros-c / nros-cpp crates in the same checkout, a global CLI is a known footgun.

The per-checkout shape makes the activate file the single switch — no which nros surprise across cwd boundaries. One checkout = one CLI version = one runtime ABI, by construction; no version-pin matrix to police.

Resolution chain

scripts/build/cargo.sh::nros_cli_bin resolves the active binary:

  1. $NROS_CLI (explicit override)
  2. nros on $PATH (activate file puts the repo-local one here)
  3. packages/cli/target/release/nros (per-checkout)
  4. ${NROS_HOME:-~/.nros}/bin/nros (transitional, removed once every active branch lands on 218)
  5. error

The Phase 218.E ABI guard checks the in-tree CLI build matches the runtime crates’ codegen ABI hash; opt out via NROS_SKIP_VERSION_CHECK=1 when intentionally bisecting a CLI mismatch.

See also

zpico-sys Build Architecture

zpico-sys is nano-ros’s Rust *-sys crate for zenoh-pico. After (2026-05-18) the build path is a single cc-rs invocation driven entirely by a TOML manifest. Earlier phases shipped a CMake/cc-rs hybrid with per-RTOS Rust functions; the unified path collapses that surface to one consumer + one data file.

The manifest

packages/rmw/zenoh/zpico-sys/zenoh_platforms.toml declares every per-platform datum the build needs. Two top-level table groups:

  • [platform.<name>] — one block per supported platform (posix, zephyr, freertos-lwip, nuttx, threadx, bare-metal, generic, orin-spe).
  • [arch.<name>] — reusable target-arch compiler-flag profiles shared across platforms that target the same CPU family (cortex-m3, cortex-m4f, cortex-a7, cortex-r5-softfp, riscv32imc, riscv32gc, riscv64gc).

Per-platform fields

FieldTypeUse
inheritsstringMerge from another [platform.*] block before applying this one.
defineslist[str]Unconditional cc::Build::define(name, None).
defines_kvmapcc::Build::define(name, Some(value)).
defines_envmapValue from env, falls back to default.
includelist[str]Glob roots under zenoh-pico/src/. The drift gate validates these exist.
excludelist[str]Drop entries from include matches.
extra_sourceslist[ExtraSource]Additional .c files outside zenoh-pico/src/. See below.
required_envlist[RequiredEnv]SDK paths the build needs. See below.
include_pathslist[str]Header search paths; interpolated.
include_paths_conditionallist[ConditionalPath]Header paths gated by when.
archstring | list[str]Name(s) of [arch.*] block(s) to apply. Single name (TOML scalar) for single-arch platforms; list (TOML array) for multi-arch platforms like bare-metal (cortex-m3 + riscv32imc). build.rs walks the list in order and applies the first arch whose target_match hits the build target..
compiletableopt_level / warnings / cflags.
picboolcc::Build::pic override (NuttX flat builds use false).
link.*mapPer-link-feature policy. Values: true / false (force on/off) or "feature" (defer to CARGO_FEATURE_LINK_<X>).
mbedtlsstringpkg-config / vendored / none.
system_libslist[str]cargo:rustc-link-lib= entries.
rerun_if_env_changedlist[str]cargo:rerun-if-env-changed=… triggers.

Per-arch fields

FieldTypeUse
target_matchstringSubstring or <prefix>* glob the target triple must match.
target_excludestringVeto when the target triple contains this substring (e.g. cortex-m3 excludes thumbv7em so Cortex-M4 doesn’t pick it).
cflagslist[str]Compiler flags.
needs_picolibcboolAdd picolibc sysroot’s include/ to the search path.
needs_errno_overrideboolGenerate + prepend the errno-override shadow header (RISC-V picolibc TLS-errno workaround).
needs_riscv_compilerboolProbe for a RISC-V cross-cc.

Interpolation tokens

Every path / include_paths / extra_sources.path / include_paths_conditional.path string is run through a small interpolator before being passed to cc-rs:

TokenValue
{nros}CARGO_MANIFEST_DIR (zpico-sys/).
{out}OUT_DIR.
{src}zenoh-pico/src/ under {nros}.
{env:VAR}Value of env var VAR; build fails with a clear error if unset.

when matcher

Conditional includes carry a when table. Each field is optional; present fields ALL must hold for the matcher to fire.

FieldMeaning
target_matchSubstring (or <prefix>* glob) that must be in the target triple.
target_notSubstring that must NOT be in the target triple. Special value "embedded" matches any of the known embedded RTOSes.
if_envEnv var that must be set (any value).

ExtraSource shape

extra_sources = [
  { path = "{nros}/c/platform/threadx/task.c" },
  { path = "{nros}/c/platform/threadx/log_uart.c",
    if_env = "NROS_ZPICO_LOG_TO_UART",
    with_define = ["ZENOH_LOG_PRINT", "zpico_log_print"] },
]

if_env skips the file when the env var is absent. with_define adds the matching cc::Build::define(name, Some(value)) whenever the source is included (use name alone for unconditional None).

RequiredEnv shape

required_env = [
  { name = "THREADX_DIR",
    help = "ThreadX kernel source. Run just setup threadx_linux or export THREADX_DIR=$PWD/third-party/threadx/kernel",
    validate_subdir = "common/inc" },
]

Missing env vars panic at build time with the help string; present env vars whose value lacks the validate_subdir subdir also panic with the offending path printed. No silent fallthroughs.

Adding a new platform

Adding a new RTOS is a TOML edit:

  1. Pick an [arch.<name>] block for the target CPU. If the family isn’t already there (or compiler flags differ), add an [arch.*] block. Reusable across platforms.
  2. Write the [platform.<your_rtos>] block with defines + include + extra_sources + required_env + include_paths.
  3. The Cargo feature for the new platform must be added in Cargo.toml and wired into the existing match in build.rs that maps Cargo feature → manifest platform name.
  4. Verify with cargo build -p zpico-sys --features <your_rtos> --target <triple>.

No build.rs edits needed for the data layer.

Adding a new target arch

Adding a new CPU family is one [arch.*] block + any of the needs_picolibc / needs_errno_override / needs_riscv_compiler flags it requires. Existing platforms that need to target it set arch = "<your_arch>" (or extend their existing arch = [...] list with the new name).

For platforms that span multiple architectures (e.g. bare-metal covers cortex-m3 for mps2-an385-baremetal / stm32f4 AND riscv32imc for ESP32-C3), declare every arch the platform supports and let build.rs’s first-match dispatch pick the right one per target triple:

[platform.bare-metal]
arch = ["cortex-m3", "riscv32imc"]   # first arch_matches wins

This is what makes cargo check work on both examples/mps2-an385-baremetal/rust/talker and examples/esp32-c3-baremetal/rust/{listener,talker} from the same platform entry — the picolibc sysroot wired up by arch.riscv32imc.needs_picolibc = true is added to the cc-rs -I list only when the build target is riscv32imc-*.

mbedTLS source policy

Manifest’s mbedtls field per-platform:

  • pkg-config — discover via the pkg-config build crate. POSIX hosts only. The build script also synthesizes .pc files when the host doesn’t ship them (Ubuntu’s libmbedtls-dev is the motivating case).
  • vendored — pull from the in-tree mbedtls/ submodule sources. Bare-metal / RTOS targets without a system mbedTLS.
  • none — link-tls users on this platform get a link error; the platform doesn’t support TLS.

The selection only matters when link-tls is on (controlled by the CARGO_FEATURE_LINK_TLS env var).

Source-drift gate

Every include root in zenoh_platforms.toml must (a) resolve to a real directory under zenoh-pico/src/, (b) contain ≥1 .c file or sub-directory. A failed check panics build-time with the offending key + expected path. owns this; full set-equality vs. the resolved cc-rs source list lands as a follow-up.

Manifest-driven consumer

build.rs’s build_zenoh_pico_unified consumes a ResolvedPlatform (the merged inherits chain). It:

  1. Validates required_env + validate_subdir.
  2. Generates the version header in {out}/zenoh-pico-version/.
  3. Applies the [arch.*] profile if its target_match / target_exclude predicates pass.
  4. Adds core sources (8 protocol subdirs + system/common) and per-platform extra_sources (with if_env / with_define).
  5. Sets include paths (unconditional + conditional after matches).
  6. Adds defines (defines / defines_kv / defines_env).
  7. Handles mbedTLS per the manifest’s mbedtls field.
  8. Applies shim slot counts.
  9. Applies compile settings + pic override.
  10. Compiles to libzenohpico.a.
  11. Registers rerun_if_env_changed.

The consumer is the only function that actually invokes cc-rs for zenoh-pico. Every per-platform delta lives in the TOML.

  • docs/roadmap/phase-136-zpico-sys-unified-build.md — the phase doc that drove this refactor.
  • book/src/concepts/platform-model.md — Boards vs Platforms; manifest is the platform-side knob.
  • book/src/internals/rmw-backends.md — RMW host-language policy.

Formal Verification

nano-ros uses three complementary verification tools to ensure correctness of its core libraries:

  • Kani – bounded model checking with 148 harnesses across 6 crates. Checks memory safety, integer overflow, and panic-freedom within bounded inputs.
  • Verus – deductive (unbounded) proofs with 83 verified properties. Proves functional correctness of scheduling, serialization, time arithmetic, and safety protocols.
  • Miri – runtime undefined behavior detection for unsafe code.

All three run via just recipes:

just verify          # Run both Kani + Verus
just verify-kani     # Kani only (~3 min)
just verify-verus    # Verus only (~1 sec)
just test-miri       # Miri UB detection

Kani

Kani is a bounded model checker that translates Rust code to a verification IR and exhaustively explores all possible executions up to a given bound.

just verify-kani

148 harnesses are spread across 6 crates (counts as of the last audit — regenerate with grep -rc '#[kani::proof' packages/):

CrateFocus
nros-serdesCDR serialization round-trip, buffer bounds
nros-coreTime arithmetic, type invariants
nros-paramsParameter value constraints, range checking
nros-cFFI boundary safety
nros-ghost-typesBuffer state machine transitions (overflow/lock)
nros-nodeExecutor scheduling, subscription handling

Kani checks three properties by default:

  1. Memory safety – no out-of-bounds access, no use-after-free
  2. Overflow freedom – no integer overflow on arithmetic operations
  3. Panic freedom – no reachable panic!, unwrap(), or assert! failure

A typical run takes approximately 3 minutes on a modern machine.

Miri

Miri is an interpreter for Rust’s Mid-level IR that detects undefined behavior at runtime:

just test-miri

Miri catches issues that neither the compiler nor Kani can detect:

  • Invalid pointer dereferences
  • Uninitialized memory reads
  • Violations of aliasing rules (Stacked Borrows / Tree Borrows)
  • Data races in concurrent code

If Miri fails with “contains outdated or invalid JSON”, clean the cache:

rm -rf target/miri

Verus

Verus is a deductive verification tool for Rust. Unlike Kani (which checks bounded executions), Verus proves properties hold for all possible inputs using SMT solving.

Quick Reference

just verification verus   # Provision the pinned Verus release into the SDK store (or `just verification setup` for kani + verus)
just verify-verus    # Run all Verus proofs
just verify          # Run both Kani + Verus

Verification crate: packages/verification/nros-verification/

Type Specifications

Every type used inside verus! { } that is defined outside the macro needs an external_type_specification. How you write it determines whether Verus treats the type as transparent or opaque.

Transparent enums (variant matching allowed)

Use external_type_specification without external_body:

#![allow(unused)]
fn main() {
use nros_node::TriggerCondition;

verus! {

#[verifier::external_type_specification]
pub struct ExTriggerCondition(TriggerCondition);

// Now we can match on variants in spec functions:
pub open spec fn trigger_eval_spec(cond: TriggerCondition, ready: Seq<bool>) -> bool {
    match cond {
        TriggerCondition::Any => ...,
        TriggerCondition::All => ...,
        TriggerCondition::Always => true,
        TriggerCondition::One(index) => ...,
    }
}

} // verus!
}

This works for enums whose variants are visible (public). Verus sees the full variant structure and allows pattern matching in specs and proofs.

Opaque types (no internal access)

Use external_type_specification with external_body:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
#[verifier::external_body]
pub struct ExSomeOpaqueType(SomeOpaqueType);
}

Use this for types with private fields, complex internals, or when you only need to pass them around without inspecting their structure. You cannot match on variants or access fields.

Structs with public fields

Use external_type_specification without external_body – fields become accessible in specs:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
pub struct ExDuration(nros_core::time::Duration);

// Fields are accessible:
pub assume_specification[ Duration::to_nanos ](self_: &Duration) -> (n: i64)
    ensures n == (self_.sec as i64) * 1_000_000_000i64 + (self_.nanosec as i64);
}

Linking Specs to Production Code

assume_specification

Axiomatically declares a contract on a production function. The contract is trusted (not verified by Verus) – a human auditor must confirm the spec matches the implementation.

#![allow(unused)]
fn main() {
pub assume_specification[ TriggerCondition::evaluate ](
    self_: &TriggerCondition,  // &self becomes a named parameter
    ready: &[bool],
) -> (ret: bool)
    ensures
        ret == trigger_eval_spec(*self_, ready@);
}

Rules:

  • &self must be written as self_: &Type (named parameter, not method syntax)
  • ready@ converts &[bool] to Seq<bool> (Verus view conversion)
  • *self_ dereferences &TriggerCondition to TriggerCondition for spec matching
  • The return value must be named: -> (ret: bool)

Ghost models

For types that can’t be imported (private fields, feature-gated behind C FFI, etc.), create a ghost model – a manually maintained mirror:

#![allow(unused)]
fn main() {
/// Ghost representation of TimerState (mirrors nros_node::timer::TimerState).
pub struct TimerGhost {
    pub period_ms: u64,
    pub elapsed_ms: u64,
    pub mode: TimerModeGhost,
    pub canceled: bool,
}
}

Ghost models have weaker guarantees than assume_specification. Correctness relies on manual comparison with the production source code.

Trust Levels

Every proof falls into one of three trust levels:

LevelMechanismWhat’s trustedStrength
Formally linkedassume_specification + external_type_specificationThe spec matches the impl (human audit of ~4 lines)Strongest
Ghost modelManual struct/enum mirrorLine-by-line correspondence with production sourceMedium
Pure mathArithmetic identitiesOnly the math itselfWeakest (no code link)

Document the trust level of each proof in the module-level doc comment.

Pitfalls

verify = true on production crates

Never add [package.metadata.verus] verify = true to a production crate that contains function pointers, dyn Trait, or closures. Verus will attempt THIR erasure on all items in the crate and panic at erase.rs:237.

Only the verification crate (nros-verification) should have verify = true. Production crates are used as regular dependencies – Verus compiles them with standard rustc without attempting verification.

vstd dependency

vstd is published on crates.io (vstd = "0.0.0-2026-02-08-0120"). Use the registry version, not a path dependency. The pre-built Verus release does not include the full source tree needed for path dependencies (missing dependencies/prettyplease etc.).

Items outside verus! are external

Any type, function, or trait defined outside the verus! { } macro is treated as external by Verus. To use it in specs, you must register it with external_type_specification or reference it via assume_specification.

Closures and iterators

Verus cannot verify code containing closures (including .iter().any(|&r| r)) or function pointers. Mark such items with #[verifier::external] if they’re in a crate being verified, or keep them in production code that Verus never touches.

Edition 2024

Verus’s bundled rustc (1.93.0) supports edition 2024. The verification crate uses edition = "2024" to match the nros workspace. No special configuration is needed.

Adding a New Proof

  1. Identify the production function and its source location
  2. Determine the trust level:
    • Can you import the type? Use external_type_specification
    • Is the type behind a feature gate with C FFI? Use a ghost model
    • Is it pure arithmetic? Pure math proof
  3. Write the spec function inside verus! { }
  4. If formally linking: add assume_specification and document which source lines the auditor should compare
  5. Write the proof function with ensures clauses
  6. Run just verify-verus to check
  7. Update the module doc comment with the new proof’s trust level

File Organization

packages/verification/nros-verification/src/
├── lib.rs            # Module declarations
├── scheduling.rs     # Timer + trigger + executor proofs
├── communication.rs  # CDR serialization safety proofs
├── cdr.rs            # CDR round-trip integrity proofs
├── time.rs           # Duration/Time arithmetic proofs
├── action.rs         # GoalStatus state machine proofs
├── params.rs         # ParameterValue + range proofs
├── progress.rs       # Executor progress / liveness proofs
├── safety.rs         # Safety-protocol proofs
└── e2e.rs            # End-to-end data path proofs

Proofs are organized by what they guarantee to the application developer, not by source crate. A single proof module may reference types from multiple production crates.

E2E Safety Protocol

The safety-e2e feature described in this document has been implemented. It is available in nros-rmw, nros-node, and the top-level nros crate.

Problem Statement

nano-ros treats zenoh as a trusted transport. Messages are serialized (CDR), transmitted over zenoh, and deserialized without any integrity verification. In safety-critical deployments (Autoware safety island), the transport channel must be treated as untrusted – the EN 50159 “black channel” principle.

Without E2E protection:

  • No CRC or checksum on message payloads (corruption undetected)
  • No subscriber-side sequence tracking (message loss undetected)
  • No duplicate detection (message repetition undetected)
  • No freshness validation (stale messages accepted silently)
  • No source authentication (masquerade undetected)

Existing Infrastructure

The nano-ros transport layer carries metadata that partially addresses these concerns, but historically only on the publisher side.

RMW Attachment (33 bytes)

Every published message includes a zenoh attachment with:

OffsetSizeFieldPurpose
0-78sequence_number (i64 LE)Monotonically increasing per publisher
8-158timestamp (i64 LE, nanos)Publication time
161VLE length (always 16)GID size prefix
17-3216rmw_gidRandom per-publisher identifier

This exists for rmw_zenoh_cpp interoperability. Without safety-e2e, the subscriber parses it into MessageInfo but does not validate sequence continuity, freshness, or source identity.

Message Data Flow

Publisher side:
  User msg -> CdrWriter::serialize() -> CDR payload (with 4-byte header)
  CDR payload -> ShimPublisher::publish_raw()
    -> Compute seq++, timestamp
    -> Serialize 33-byte RMW attachment
    -> zenoh publish(payload, attachment)

Subscriber side:
  zenoh callback -> Copy payload + attachment to static SubscriberBuffer
  User calls take_serialized() -> Copy from static buffer to user buffer
  User buffer -> CdrReader::deserialize() -> User msg
  (Attachment parsed into MessageInfo but NOT validated without safety-e2e)

Key Observations

  1. Sequence numbers exist but are only checked by subscribers when safety-e2e is enabled.
  2. Timestamps exist but freshness validation requires a clock source (deferred).
  3. Publisher GID exists but source authentication requires a registration mechanism.
  4. CRC is added by the safety-e2e feature. It is computed over the CDR payload and appended to the attachment.
  5. The attachment travels out-of-band from the CDR payload in zenoh. This is beneficial – the CRC in the attachment provides a diverse check (different data paths).

EN 50159 Threat Model

EN 50159 defines 7 threat classes for communication over untrusted channels:

ThreatEN 50159 DefenseIntegration Approach
CorruptionCRCCRC-32 covering CDR payload, stored in extended attachment
RepetitionSequence numberSubscriber tracks expected sequence, flags duplicates
DeletionSeq + timeoutSubscriber detects sequence gaps
InsertionSeq + authSequence validation rejects unexpected messages
ResequencingSequence numberSubscriber validates monotonic sequence
DelayTimestamp/timeoutSubscriber compares message timestamp to current time
MasqueradeAuthenticationSubscriber validates expected source GID

5 of 7 defenses only require subscriber-side validation of data that already exists in the attachment. Only CRC requires new publisher-side computation.

CRC Architecture

The safety-e2e feature extends the zenoh attachment from 33 to 37 bytes:

Existing attachment (33 bytes):
  [seq:8][timestamp:8][vle:1][gid:16]

Extended attachment (37 bytes):
  [seq:8][timestamp:8][vle:1][gid:16][crc32:4]

The CRC covers the CDR payload bytes (not the attachment itself).

Algorithm: CRC-32/ISO-HDLC

Standard Ethernet CRC (polynomial 0xEDB88320 reflected), used by both AUTOSAR E2E Profile 1/2 and EN 50159. The 1KB lookup table is const-generated at compile time. Deterministic execution time for WCET analysis.

Interoperability

  • rmw_zenoh_cpp reads exactly 33 bytes using VLE parsing – extra bytes are ignored
  • nano-ros without safety-e2e reads 33 bytes – succeeds normally
  • nano-ros with safety-e2e reads 37 bytes – extracts CRC from bytes 33-36

Subscriber-Side Validation

Sequence Tracking

The subscriber maintains an expected_seq counter (initialized to -1). On each received message:

  • First message: initializes expected_seq to message_seq + 1
  • Contiguous: message_seq == expected_seq – normal delivery
  • Duplicate: message_seq < expected_seq – flagged in IntegrityStatus
  • Gap: message_seq > expected_seq – gap count reported in IntegrityStatus

CRC Validation

If the attachment is longer than 33 bytes, the subscriber extracts the CRC-32 from bytes 33-36 and compares it against a locally computed CRC of the received payload. If the attachment is 33 bytes (legacy or ROS 2 publisher), crc_valid is None.

Feature Integration

The safety-e2e feature flag propagates through the crate hierarchy:

nros (top-level) -> nros-node -> nros-rmw
  safety-e2e        safety-e2e   safety-e2e

Changes by layer

nros-rmw (core changes):

  • safety module: CRC-32 function, IntegrityStatus type, SafetyValidator state tracker
  • ShimPublisher::publish_raw(): computes CRC and extends attachment to 37 bytes
  • SubscriberBuffer: attachment buffer sized to 37 bytes
  • ShimSubscriber: SafetyValidator field, take_validated() method

nros-node (API surface):

  • ShimNodeSubscription: take_validated() returning the message + IntegrityStatus

Unchanged:

  • CDR serialization (nros-serdes) – payload format unchanged
  • Core types (nros-core) – no new traits needed
  • Zenoh backend (nros-rmw-zenoh) – attachment handling supports variable sizes
  • Existing take() API – unchanged behavior

Memory Impact

ComponentWithout safety-e2eWith safety-e2e
CRC-32 lookup table0+1024 bytes (.rodata)
Subscriber attachment buffers (8x)8 x 33 = 264 bytes8 x 37 = 296 bytes (+32)
SafetyValidator per subscriber (8x)08 x ~24 = 192 bytes
Total–+1248 bytes

ROS 2 Interoperability

SenderReceiverCRCSequenceResult
nano-ros (safety)nano-ros (safety)ValidatedTrackedFull E2E protection
nano-ros (safety)nano-ros (no safety)IgnoredNot trackedWorks, no protection
nano-ros (safety)ROS 2 (rmw_zenoh)IgnoredNot trackedWorks, no protection
nano-ros (no safety)nano-ros (safety)NoneTrackedPartial (seq only)
ROS 2 (rmw_zenoh)nano-ros (safety)NoneTrackedPartial (seq only)

No interoperability is broken. Safety degrades gracefully when one side doesn’t support it.

AUTOSAR E2E Comparison

FeatureAUTOSAR E2E P01AUTOSAR E2E P02nano-ros E2E
CRCCRC-8 (8-bit)CRC-8 (8-bit)CRC-32 (32-bit, stronger)
Counter4-bit (0-14)4-bit (0-14)64-bit (i64, no rollover in practice)
Data ID16-bit16-bit128-bit GID (stronger)
Alive counterYesYesYes (via sequence)
TimeoutConfigurableConfigurableDeferred (needs clock abstraction)
State machineE2E_SMSameSimpler (valid/invalid per message)

nano-ros E2E is stronger than AUTOSAR P01/P02 in CRC and counter width, but currently lacks the state machine and timeout features.

Future Extensions

  • Freshness validation: Requires a MonotonicClock trait and platform implementations. The existing timestamp field already carries the publication time.
  • Source authentication: The rmw_gid (16-byte random publisher ID) can be used for masquerade detection if the subscriber registers expected sources.
  • Watchdog supervision: Separate from E2E protocol – monitors alive heartbeats, deadline completion, and logical execution flow.
  • Formal verification: CRC correctness, sequence tracking invariants, and IntegrityStatus::is_valid() are candidates for Verus proofs.

Production Readiness Checklist

A copy-out checklist for teams piloting nano-ros toward production deployment. Each box is a concrete validation step, not a marketing claim. The book documents the framework’s intent + plumbing; this page is what your team needs to confirm on your target before shipping.

Why a separate checklist? nano-ros is production-capable, but some acceptance items are hardware-gated (P99 latency on real Cortex-M3, multicast on real silicon, NuttX SCHED_SPORADIC under kernel config) and can’t be validated in CI. The checklist gives you the steps to close those gaps for your deployment.

1. Real-time metrics (hardware-validated)

The book intentionally quotes almost no latency numbers, because the in-tree harnesses run under QEMU, where DWT cycle counters read zero and wall-clock percentiles track host load. For production claims, measure on your actual silicon.

  • End-to-end P99 latency (publisher → executor callback) on your target MCU at its production clock + load. Target: ≤ design budget. Tooling: wake-latency-cortex-m3 bench at packages/testing/nros-bench/wake-latency-cortex-m3/.
  • Worst-case stack depth per task. Tool: cargo-call-stack, cargo-stack-sizes, or the ARM stack-analyzer for C/C++.
  • Heap fragmentation pattern over 24 h at nominal load. Spot-check with mallinfo or your RTOS’s heap-stats API.
  • Wake latency (transport-rx interrupt → first user callback dispatched). Required if you use the async / poll- blocked spin path.
  • Spin-loop budget overrun rate under sustained pub load. Executor::spin_once(timeout) returns the overrun count.

2. Platform-specific validation

Per-RTOS gaps that the book documents as “tested in CI” cover reference boards — your actual board / kernel-config combination may differ.

  • Multicast / IGMP if using DDS (RTPS). Confirm SPDP discovery actually fires on your RTOS + driver. Untested on FreeRTOS + ThreadX as of writing.
  • Clock wraparound + extension correctness on long-running deployments. The platform’s nros_platform_clock_ns must extend a narrower hardware counter (a u32 millisecond count wraps at 49.7 days) to a monotonic u64.
  • Allocator behavior under memory pressure. Boot-time alloc OK on most RTOSes; mid-run alloc only on std POSIX. Confirm your hot paths don’t allocate.
  • Network packet loss recovery. Drop 5% of packets in your lab and confirm the talker / listener recovers.
  • Critical-section regions are short. The platform’s nros_platform_critical_section_* ABI is the IRQ-disable surface; long critical sections starve other ISRs.

3. RMW backend certification

  • Backend version pinned to a tested tuple. Zenoh-pico 1.7.2 (matches rmw_zenoh_cpp). Cyclone DDS 0.10.5 (matches ros-humble-cyclonedds). XRCE-DDS Micro-Client at its workspace pin.
  • All required QoS policies supported by your backend. The per-policy table is in RMW API: Differences from upstream rmw.h §7; the authoritative source is each backend’s supported_qos_policies().
  • Discovery stability over your network topology. Zenoh-pico in client mode needs the router reachable; loss of router = lost routing but local node lives. XRCE needs Agent uptime. Cyclone DDS discovers via multicast SPDP.
  • Bridge stability if multi-backend. Confirm no memory bloat over 72 h with two registered RMWs running.
  • Cyclone DDS limitations checked. Status events and some stock-ROS interop slices are still in progress; embedded RTOS ports remain gated on a hosted Cyclone runtime.

4. Safety + formal verification

  • just verify-kani clean against your build. 148 bounded harnesses; non-trivial coverage of CDR + scheduling + RMW glue.
  • just verify-verus clean. 83 deductive proofs.
  • CRC32 attached if using safety-e2e feature. The 37-byte attachment is transparent to stock ROS 2 (ignored gracefully) and detected by other nano-ros nodes.
  • Timeout bounds on every blocking call. spin_once timeout, Promise::wait_for(timeout), recv_timeout. No WAIT_FOREVER.
  • Parameter store capacity ≥ declared parameter count ([param-services] feature gate).
  • Stack overflow detection enabled by your platform (FreeRTOS configCHECK_FOR_STACK_OVERFLOW, Zephyr stack sentinels, NuttX CONFIG_DEBUG_STACK).

5. Interop testing

  • Publish from nano-ros, subscribe with stock ROS 2. RMW_IMPLEMENTATION=rmw_zenoh_cpp for Zenoh, rmw_cyclonedds_cpp for Cyclone, rmw_fastrtps_cpp for DDS (interop tier).
  • Message type compatibility for any custom .msg you’ve added. Round-trip a sample message through ROS 2’s rosbag2 to confirm wire-level parity.
  • QoS profile matching. Mismatched reliability / durability / history kill discovery silently on DDS / RTPS.
  • Lifecycle callbacks fire on node startup / shutdown if you’ve opted into lifecycle-services.
  • Cross-RTOS interop: if your fleet mixes RTOSes (e.g. Zephyr sensor + FreeRTOS actuator + POSIX coordinator), confirm all three sides see each other.

6. Failure recovery

  • Agent / router restart: kill zenohd (Zenoh) or MicroXRCEAgent (XRCE) mid-run. Confirm reconnection. For DDS / Cyclone this is N/A (no central process).
  • Network partition → reconnection. Block the talker’s egress with iptables for 30 s, then unblock. Verify the listener resumes within your design SLA.
  • Heap exhaustion path: graceful degradation OR clean crash + restart? If hosted-RTOS + watchdog, restart is usually correct. If bare-metal, you probably have no restart story — confirm your design assumes this.
  • Stack overflow detection triggers a panic / fault rather than silent corruption.
  • Power loss mid-write (if persisting state). Not nano-ros’s concern, but mention it in your design review.

7. Operational concerns

  • Bootloader + OTA strategy. Out of scope for nano-ros but mandatory for fleet deployments — name it explicitly in your project plan.
  • Log / diagnostics exfiltration. nros-log provides the logging surface; pick a sink (UART, RTT, semihosting, or ROS 2 /rosout over the wire — the last is real since phase-467 Q4, Rust-only, and costs .bss: see Logging).
  • Time synchronization (NTP, PTP, RTC). nano-ros doesn’t ship a time-sync layer; your fleet design must.
  • Watchdog coverage: the executor’s spin_period reports overruns, but it doesn’t pet a hardware watchdog. Wire one manually.

8. License + governance

  • License: MIT OR Apache 2.0 (dual). Both permissive, no GPL copyleft, OK for proprietary firmware. Confirm your legal team is comfortable.
  • Third-party dependencies: zenoh-pico (Eclipse), Cyclone DDS (Eclipse), Micro-XRCE-DDS-Client (Apache-2). Vendored dependencies carry license files in each third-party/*/LICENSE.
  • Patent grant: Apache 2.0 carries an explicit patent grant; MIT does not. Most adopters rely on the Apache half.
  • Support model: nano-ros has no commercial support entity as of writing. Plan accordingly — either staff in-house expertise or contract a consultancy.
  • Roadmap visibility: track the project’s roadmap directory in the upstream repo. Items are numbered and dated.

9. Maintainability pledge

The single-maintainer signal in §8 is a real adoption risk. Add explicit mitigations to your project plan rather than treating “open-source” as the answer:

  • Maintenance horizon: record the date you adopted and the upstream commit cadence (e.g. git log --since=… --oneline | wc -l over the last 90 days). Re-check quarterly.
  • Escalation path: identify the primary maintainer contact (from Cargo.toml authors + GitHub commit history). For CVEs, use a public GitHub security advisory.
  • Governance model: nano-ros is BDFL-style (benevolent-dictator-for-life) with no formal RFC / vote process. Major design decisions are documented in the roadmap directory; align your expectations accordingly.
  • Fork mitigation: the dual MIT / Apache-2.0 license lets your team fork and maintain independently if upstream goes dormant. Budget for that possibility, not the assumption that it will happen.
  • Distro tracking: nano-ros targets ROS 2 Humble today. Iron / Jazzy support depends on the type-hash work tracked in the upstream roadmap (no public ETA yet). If your product must ship on Iron / Jazzy at launch, plan to contribute the type-hash port or wait for upstream.

Scoring rubric

For each section above, count [x] boxes as your readiness score. Suggested gates:

Sections carry 4–6 boxes each (45 total):

Score per sectionStatus
All boxesProduction-ready for that axis
Missing onePilot deployment OK; close the gap before scale
Missing twoLab / prototype only
Missing three+Block on these items first

Sum across all 9 sections (§1–9). Below ~32 / 45 you have foundational work to do; above ~40 / 45 you’re at production quality on every axis where nano-ros can be validated externally.

See also

Zenoh-pico Symbol Reference

This page documents the ~55 FFI symbols that zenoh-pico requires at link time. These symbols are provided by zpico-sys’s C alias translation unit (c/zpico/platform_aliases.c, built by the default-on platform-aliases feature), which forwards each z_* / _z_* call to the canonical nros_platform_* ABI. When porting to a new platform, you implement an nros-platform-<name> crate (see Custom Platform) that supplies the nros_platform_* symbols — you do not provide the z_* symbols directly.

Historical note: this mapping used to live in a separate zpico-platform-shim crate; Phase 129 deleted it and folded the forwarders into the zpico-sys alias TU.

This page serves as a reference for understanding what the alias TU maps and what capabilities your nros-platform-<name> crate must provide.

Platform crate structure

packages/platform/nros-platform-<name>/
├── Cargo.toml
└── src/
    ├── lib.rs
    ├── clock.rs
    ├── memory.rs
    ├── sleep.rs
    ├── random.rs
    ├── time.rs
    ├── threading.rs          # no-op stubs or real RTOS impl
    ├── socket_stubs.rs       # if using smoltcp
    └── network.rs            # if using smoltcp

Cargo.toml must have zero nros-* dependencies. It may depend on:

  • Hardware HAL crate (e.g., stm32f4xx-hal, esp-hal)
  • zpico-smoltcp (if using smoltcp networking)
  • embedded-alloc (for heap on bare-metal)
  • RTOS bindings crate

Required FFI symbols

Clock (critical)

The clock is the most important primitive. zenoh-pico uses it for session keep-alive, query timeouts, and transport timeouts. It must be backed by a hardware timer or OS tick — never by a software counter that only advances when polled.

// Monotonic clock — returns an opaque timestamp (lower 32 bits of ms)
usize   z_clock_now(void);

// Elapsed time since a previous z_clock_now() value
c_ulong z_clock_elapsed_us(usize *time);
c_ulong z_clock_elapsed_ms(usize *time);
c_ulong z_clock_elapsed_s(usize *time);

// Advance a timestamp value by a duration
void    z_clock_advance_us(usize *clock, c_ulong duration);
void    z_clock_advance_ms(usize *clock, c_ulong duration);
void    z_clock_advance_s(usize *clock, c_ulong duration);

If using smoltcp (bare-metal networking), also provide:

// Called by zpico-smoltcp for TCP/IP timestamping
u64 smoltcp_clock_now_ms(void);

Implementation checklist:

  1. Identify a hardware timer (SysTick, GPT, DWT) or use the OS tick API (xTaskGetTickCount, k_uptime_get, clock_gettime)
  2. Handle 32-bit timer wraps — track a wrap count in an atomic or use a 64-bit counter
  3. Never advance the clock inside smoltcp_network_poll() — read the hardware timer directly
  4. Verify with QEMU: use -icount shift=auto to synchronize virtual time with wall-clock time

Reference implementations:

PlatformClock sourceFile
MPS2-AN385CMSDK APB Timer0 (25 MHz)nros-platform-mps2-an385/src/clock.rs
STM32F4ARM DWT cycle counternros-platform-stm32f4/src/clock.rs
ESP32-C3 (QEMU)esp_hal::time::Instantnros-platform-esp32-qemu/src/clock.rs
FreeRTOSxTaskGetTickCount()Use OS tick directly
NuttXclock_gettime(CLOCK_MONOTONIC)POSIX API

Memory

zenoh-pico allocates heap during session open and entity creation. Typical minimum: 64 KB heap.

void *z_malloc(usize size);
void *z_realloc(void *ptr, usize size);
void  z_free(void *ptr);

Options:

  • embedded-alloc FreeListHeap (bare-metal)
  • RTOS heap (pvPortMalloc / tx_byte_allocate)
  • System malloc (POSIX, NuttX)

Sleep

i8 z_sleep_us(usize time);
i8 z_sleep_ms(usize time);
i8 z_sleep_s(usize time);

All return 0 (_Z_RES_OK). On bare-metal, busy-wait using the clock. On RTOS, delegate to vTaskDelay / tx_thread_sleep / k_sleep.

If using smoltcp, poll the network stack during busy-wait sleep to avoid missing packets.

Random

zenoh-pico needs randomness for session IDs, SN initialization, and scouting nonces.

u8   z_random_u8(void);
u16  z_random_u16(void);
u32  z_random_u32(void);
u64  z_random_u64(void);
void z_random_fill(void *buf, usize len);

A simple xorshift32 PRNG is sufficient. Seed it with hardware entropy (RNG peripheral, ADC noise, semihosting wall-clock time) during init.

Time

System time (wall clock). Used for logging and z_time_now_as_str().

u64         z_time_now(void);
const char *z_time_now_as_str(char *buf, c_ulong buflen);
c_ulong     z_time_elapsed_us(u64 *time);
c_ulong     z_time_elapsed_ms(u64 *time);
c_ulong     z_time_elapsed_s(u64 *time);
i8          _z_get_time_since_epoch(ZTimeSinceEpoch *t);

Where ZTimeSinceEpoch is:

#[repr(C)]
struct ZTimeSinceEpoch {
    u32 secs,
    u32 nanos,
}

On bare-metal without an RTC, return monotonic time or zeros.

Threading

For single-threaded platforms (bare-metal, RTIC), provide no-op stubs. For RTOS platforms, implement real task/mutex/condvar operations.

Task operations:

i8   _z_task_init(ZTask *task, ZTaskAttr *attr, void*(*fun)(void*), void *arg);
i8   _z_task_join(ZTask *task);
i8   _z_task_detach(ZTask *task);
i8   _z_task_cancel(ZTask *task);
void _z_task_exit(void);
void _z_task_free(ZTask **task);

Mutex operations:

i8 _z_mutex_init(ZMutex *m);
i8 _z_mutex_drop(ZMutex *m);
i8 _z_mutex_lock(ZMutex *m);
i8 _z_mutex_try_lock(ZMutex *m);
i8 _z_mutex_unlock(ZMutex *m);

Recursive mutex operations:

i8 _z_mutex_rec_init(ZMutexRec *m);
i8 _z_mutex_rec_drop(ZMutexRec *m);
i8 _z_mutex_rec_lock(ZMutexRec *m);
i8 _z_mutex_rec_try_lock(ZMutexRec *m);
i8 _z_mutex_rec_unlock(ZMutexRec *m);

Condition variable operations:

i8 _z_condvar_init(ZCondvar *cv);
i8 _z_condvar_drop(ZCondvar *cv);
i8 _z_condvar_signal(ZCondvar *cv);
i8 _z_condvar_signal_all(ZCondvar *cv);
i8 _z_condvar_wait(ZCondvar *cv, ZMutex *m);
i8 _z_condvar_wait_until(ZCondvar *cv, ZMutex *m, u64 *abstime);

Single-threaded stubs: Return 0 for all mutex/condvar operations. Return -1 for _z_task_init (task creation is not supported).

RTOS implementations: Map to xTaskCreate/xSemaphoreCreateMutex (FreeRTOS), tx_thread_create/tx_mutex_create (ThreadX), or pthread_create/pthread_mutex_init (NuttX, POSIX). zenoh-pico requires recursive mutexes (configUSE_RECURSIVE_MUTEXES=1 on FreeRTOS).

Sockets

If using smoltcp (bare-metal), socket operations are handled by zpico-smoltcp. Your platform crate provides thin shims:

#[repr(C)]
struct ZSysNetSocket { i8 _handle, bool _connected }

i8   _z_socket_set_non_blocking(const ZSysNetSocket *sock);  // Return 0
i8   _z_socket_accept(const ZSysNetSocket *in, ZSysNetSocket *out);  // Return -1
void _z_socket_close(ZSysNetSocket *sock);
i8   _z_socket_wait_event(void *peers, ZMutexRecRef *mutex);  // Return 0

If using OS sockets (POSIX, NuttX, Zephyr), zenoh-pico’s built-in socket layer handles everything — no socket stubs needed.

libc stubs (bare-metal only)

Bare-metal targets without a C runtime need standard C library functions:

usize  strlen(const char *s);
int    strcmp(const char *s1, const char *s2);
int    strncmp(const char *s1, const char *s2, usize n);
char  *strchr(const char *s, int c);
char  *strncpy(char *dest, const char *src, usize n);
void  *memcpy(void *dest, const void *src, usize n);
void  *memmove(void *dest, const void *src, usize n);
void  *memset(void *dest, int c, usize n);
int    memcmp(const void *s1, const void *s2, usize n);
void  *memchr(const void *s, int c, usize n);
c_ulong strtoul(const char *nptr, char **endptr, int base);
int   *__errno(void);

RTOS platforms (FreeRTOS, NuttX, ThreadX, Zephyr) ship their own libc — you do not need these stubs.

Network poll callback (smoltcp only)

If using smoltcp for networking, provide a poll callback that zpico-smoltcp calls to process network events:

void smoltcp_network_poll(void);

And a Rust API for the board crate to register the network state:

#![allow(unused)]
fn main() {
pub unsafe fn set_network_state(
    iface: *mut Interface,
    sockets: *mut SocketSet<'static>,
    device: *mut (),
);

pub unsafe fn clear_network_state();
}

Step-by-step procedure

  1. Create the platform crate (nros-platform-<name>) – see Custom Platform for the full guide
  2. Implement and verify the clock — this is the #1 cause of porting failures. Print clock_ms() in a loop and verify monotonic advance
  3. Implement remaining primitives — memory, random, sleep, time, threading, sockets. Each module is independent
  4. Wire into nros-platform — add a feature and ConcretePlatform alias
  5. Create the board crate — see Custom Board Package
  6. Add the platform feature to nros with mutual exclusivity checks
  7. Write an example — see Creating Examples
  8. Add test infrastructure — just test-<name> recipe + nextest group

Platform capability summary

CapabilityBare-metalRTOS (FreeRTOS/ThreadX)POSIX-like (NuttX/Zephyr)
ClockHardware timerOS tick APIclock_gettime
Memoryembedded-allocRTOS heapSystem malloc
SleepBusy-wait + pollvTaskDelaynanosleep
ThreadingNo-op stubsReal tasks + mutexespthreads
Socketssmoltcp shimslwIP or NetX socketsBSD sockets
RandomSeeded xorshiftRTOS RNG or xorshift/dev/urandom
libcHand-written stubsRTOS libcSystem libc
Network pollsmoltcp_network_pollStack-specific pollNot needed

Common pitfalls

See Platform Porting Pitfalls for detailed failure modes including poll-driven clocks, DMA buffer placement, QEMU I/O starvation, recursive mutexes, stack sizing, and heap sizing.

XRCE-DDS Symbol Reference

XRCE-DDS has a minimal platform abstraction layer. It is single-threaded, heap-less, and delegates networking to user-provided transport callbacks. The 2-3 required FFI symbols (uxr_millis, uxr_nanos) are provided by nros-rmw-xrce’s C alias translation unit (src/platform_aliases.c, compiled into nros-rmw-xrce-cffi on every target), which forwards them to the canonical nros_platform_* ABI. When porting to a new platform, you implement an nros-platform-<name> crate (see Custom Platform) that supplies the nros_platform_* symbols — you do not provide the uxr_* symbols directly.

Historical note: this mapping used to live in a separate xrce-platform-shim crate; Phase 129 deleted it and moved the uxr_* forwarders into the nros-rmw-xrce alias TU.

Platform crate structure

The XRCE-DDS clock symbols are a subset of what the platform crate provides. Your nros-platform-<name> crate provides the canonical monotonic clock, nros_platform_clock_ns, and the alias TU maps it to the uxr_* symbols XRCE-DDS expects.

packages/platform/nros-platform-<name>/
├── Cargo.toml
└── src/
    └── lib.rs          # clock + other primitives

Cargo.toml must have zero nros-* dependencies. It may depend on:

  • Hardware HAL crate (e.g., stm32f4xx-hal, esp-hal)
  • xrce-smoltcp (if using smoltcp networking)

Required FFI symbols

Clock (2 symbols)

XRCE-DDS uses the clock for session timeouts and time synchronization.

// Monotonic millisecond clock
i64 uxr_millis(void);

// Monotonic nanosecond clock
i64 uxr_nanos(void);

These must be backed by a hardware timer or OS tick — same rules as the zenoh-pico clock.

Reference implementations:

PlatformClock sourceFile
MPS2-AN385CMSDK APB Timer0 (25 MHz)nros-platform-mps2-an385/src/clock.rs
Zephyrk_uptime_get()xrce-zephyr/src/xrce_zephyr.c
POSIXclock_gettime(CLOCK_MONOTONIC)Built-in time.c from XRCE library

smoltcp clock (1 symbol, if using smoltcp)

If using xrce-smoltcp for bare-metal networking:

u64 smoltcp_clock_now_ms(void);

This is the same symbol used by zpico-smoltcp — shared clock implementations work for both RMW backends.

Transport callbacks

Instead of requiring socket FFI symbols, XRCE-DDS uses a custom transport abstraction. You register four callbacks via uxr_set_custom_transport_callbacks():

bool   open(uxrCustomTransport *transport);
bool   close(uxrCustomTransport *transport);
size_t write(uxrCustomTransport *transport, const uint8_t *buf, size_t len, uint8_t *errcode);
size_t read(uxrCustomTransport *transport, uint8_t *buf, size_t len, int timeout_ms, uint8_t *errcode);

These are typically provided by a transport crate, not the platform crate:

TransportCrateDescription
smoltcp UDPxrce-smoltcpBare-metal networking via smoltcp
Zephyr UDPxrce-zephyrZephyr BSD sockets
POSIX UDPBuilt-inUses XRCE library’s POSIX transport

The transport crate registers its callbacks during initialization. Your platform crate does not need to implement sockets.

What you do NOT need

Unlike zenoh-pico, XRCE-DDS does not require:

  • Memory symbols — XRCE-DDS is heap-less; all buffers are statically sized
  • Sleep symbols — polling is driven by uxr_run_session_time()
  • Random symbols — no session ID randomization needed
  • Threading symbols — single-threaded by design
  • libc stubs — Micro-XRCE-DDS has minimal C dependencies

This makes XRCE-DDS the easiest RMW backend to port to new platforms.

Platform-conditional compilation in xrce-sys

The xrce-sys crate uses Cargo features to select platform behavior:

FeatureEffect
posixCompiles built-in time.c (uses clock_gettime, BSD sockets)
bare-metalSkips time.c; expects uxr_millis()/uxr_nanos() from platform crate
zephyrCompiles xrce_zephyr.c (Zephyr kernel APIs)
freertosRTOS-specific; skips time.c
nuttxRTOS-specific; skips time.c
threadxRTOS-specific; skips time.c

When adding a new platform, you may need to add a feature to xrce-sys that skips the default time.c compilation and lets your platform crate provide the clock symbols.

Step-by-step procedure

  1. Create the platform crate — nros-platform-<name>/ (see Custom Platform)
  2. Implement the canonical clock primitive — nros_platform_clock_ns(); the alias TU (nros-rmw-xrce/src/platform_aliases.c) derives uxr_millis() / uxr_nanos() from it for you
  3. Implement smoltcp_clock_now_ms() if using smoltcp transport
  4. Add a feature to xrce-sys for the new platform if needed
  5. Choose or implement a transport crate — reuse xrce-smoltcp for bare-metal, or implement a new transport if the platform has its own networking stack
  6. Create the board crate — see Custom Board Package
  7. Add the platform feature to nros with mutual exclusivity checks
  8. Write an example and tests

Example: bare-metal MPS2-AN385

The simplest reference is nros-platform-mps2-an385. It supplies the canonical nros_platform_clock_ns; the XRCE alias TU (nros-rmw-xrce/src/platform_aliases.c) then derives uxr_millis / uxr_nanos from it — you never hand-write the uxr_* symbols:

/* nros-rmw-xrce/src/platform_aliases.c — provided for you, not the porter */
int64_t uxr_millis(void) {
    return (int64_t) (nros_platform_clock_ns() / 1000000u);
}
int64_t uxr_nanos(void) {
    return (int64_t) nros_platform_clock_ns();
}

So a porter only implements the canonical primitive in their platform crate (nros_platform_clock_ns), plus smoltcp_clock_now_ms() if using the smoltcp transport. The MPS2-AN385 platform reads it from a hardware timer with wrap detection.

Common pitfalls

  • Agent connectivity — XRCE-DDS requires a Micro-XRCE-DDS Agent running on the host. Ensure the agent is reachable from the target before debugging the platform layer.
  • Reliable stream history — STREAM_HISTORY must be >= 2 (recommend 4). History=1 fails to recycle slots between separate uxr_run_session_until_all_status calls.
  • Flush after request_data — uxr_buffer_request_data must be flushed with uxr_run_session_time immediately after being called. Unflushed requests in the reliable output stream cause intermittent timeouts.

Creating Examples

This guide describes the canonical example shape adopted (layout) + (consumption). When adding a new example, copy the nearest existing peer; the per-platform working examples are the authoritative templates.

Canonical layout

Every example is a self-contained, copy-out project under one of:

PathUsed for
examples/<plat>/<lang>/<example>/The standard cell. RMW is selected at build time (Cargo features / -DNROS_RMW= / Kconfig overlay), not encoded in the path. A single-package “app” example here is the canonical starter shape; the multi-package workspace shape (Node + Bringup + Entry pkgs) kicks in at ≥2 nodes — see Multi-Node Projects.
examples/templates/<name>/Multi-platform copy-out recipes (e.g. multi-package-workspace).

A cross-RMW gateway is not a separate category: it is a workspace whose system.toml declares a [[bridge]] row (examples/workspaces/bridge-cyclonedds/, examples/workspaces/bridge-xrce/). The examples/bridges/ category that held hand-written gateways was deleted in phase-477 — no lane built them.

The <plat> × <lang> coverage matrix (RMW chosen at build time) is authoritative in examples/README.md. Intentionally empty cells (bare-metal C/C++, PX4 Rust, …) are listed in the same file; do not fill them without lifting the underlying constraint.

Variant naming uses suffix form so peers sort together: talker-rtic, service-client-async, talker-rtic-mixed. Avoid parallel parent directories like async-*/ or rtic-*/.

Non-example binaries

Tests / benches / smokes are not under examples/. They live under packages/testing/:

UseLocation
Performance, fairness, stress, large-msgpackages/testing/nros-bench/<name>/
Driver / board bringup smoke (no nros API)packages/testing/nros-smoke/<name>/
Fixture binaries built by integration testspackages/testing/nros-tests/bins/<name>/

Consumption shape

Rust (native or Cargo cross-target)

Each example is a standalone Cargo package with empty [workspace] table — it does not participate in any walking-up workspace:

[package]
name = "native-rs-zenoh-talker"
edition = "2024"
publish = false

[[bin]]
name = "talker"
path = "src/main.rs"

[dependencies]
nros = { version = "*", default-features = false,
         features = ["std", "rmw-cffi", "ros-humble"] }
nros-rmw-zenoh = { version = "*",
                   features = ["std", "platform-posix", "ros-humble"] }

[workspace]

nano-ros crates are declared registry-style: they are not on crates.io, so something has to resolve them into the checkout. That something is no longer a file you write. Since RFC-0098 an example carries no .cargo/ directory at all: nros sync writes the [patch.crates-io] rows, the board’s cargo settings and the derived [env] into build/<image>/nros-cargo.toml, and cargo reads that file through --config. After adding or renaming nros deps or msg <depend> rows, re-run nros sync in the example dir — and commit nothing, because everything it wrote is build output. This is what makes the copy-out promise real: a copied example re-runs nros sync at its new location.

The canonical invocation from inside the example directory:

nros sync     # generated/ msg crates + build/<image>/nros-cargo.toml
nros build    # every declared image; or `nros build <image-id>`

Driving cargo yourself works too — hand it the generated settings file. Since phase-445 W6 deleted the leaf’s .cargo/, the working directory no longer matters: there is nothing beside the package for cargo to read a second time, and a tracked one is refused (check-example-cargo-dirs).

cargo build --manifest-path <leaf>/Cargo.toml \
            --config <leaf>/build/<image-id>/nros-cargo.toml

There is still no workspace-wide cargo build that picks up examples — they are explicitly out-of-workspace.

Multi-package workspace examples

A workspace example under examples/workspaces/ has no root Cargo.toml and no root CMakeLists.txt (RFC-0098 D9) — it is a directory of packages, like a colcon workspace, and everything generated lives under build/, dist/ and log/. There is no entry package to write either: the entry is generated per [image.*], at build/<coord>/<entry>/Cargo.toml (cargo) or build/<coord>/CMakeLists.txt (cmake). The bringup package’s system.toml carries the images. nros sync then nros build is the flow; a workspace with no bringup at all builds every package in dependency order, each into its own build/<pkg>/.

The board, stated once — and the one place it is still stated twice

An example names its board in system.toml and nowhere else in its build configuration:

[system]
name      = "my_talker"
rmw       = "zenoh"          # zenoh | cyclonedds | xrce
domain_id = 0

[[component]]
pkg   = "my_talker"
class = "my_talker::Talker"
name  = "talker"

[image.mps2]                 # an embedded image also carries its identity
board   = "qemu-mps2-an385"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

The same schema serves a workspace bringup (src/<name>_bringup/system.toml), so the resolver has one input shape either way. Retired by it, and refused wherever they still appear: [package.metadata.nros.entry] (including deploy = — an entry is the package an image claims, not one a manifest table marks), [package.metadata.nros.deploy.<board>], and the package.xml <nano_ros deploy= board= rmw=/> tuple. On a single-package leaf [package.metadata.nros.node] / component are refused too (its system.toml [[component]] says it); on a workspace NODE package they are kept — they are the Rust spelling of node registration, the counterpart of nros_components_register_node in a C/C++ package’s CMakeLists.txt, not a deployment (issue 1289).

A single-package Rust leaf names no board crate. It is its own entry, so something has to give nros::main! the board: a generated package. The leaf declares one fixed dependency, <pkg>_nros_selection = { path = "build/nros/selection" } (target-scoped when the node must also build for the host probe), and nros sync / nros build write that package for the image’s board — the board crate with its features, the RMW backend, nros-platform’s platform feature and the runtime and panic crates (RFC-0098 D6). So a new leaf names none of those, and switching [image.*] board is the only edit. If main.rs says nros::main!(panic = "platform"), state panic = "platform" in the [image.*] too, or the package also links the board’s panic handler. Naming a board crate beside the selection package is refused at nros sync, and a unit test walks every in-tree leaf for it. A C/C++ app on a cargo link root (examples/mps2-an385-baremetal/{c,cpp}/*) takes the same dependency and reaches the board’s re-exports through it from its own main.rs. The leaves outside this shape BY SHAPE (board code in the node, harness-templated test fixtures, a lib a CMake image links) are listed with their reasons in leaf_settings::NOT_ON_THE_SELECTION; see issue 1305.

C / C++ (CMake)

The canonical shape is an ament package — 152 of the 162 example CMakeLists.txt files in the tree, and what a new one should copy. Verbatim from examples/linux/c/talker/:

cmake_minimum_required(VERSION 3.22)
project(c_talker LANGUAGES C CXX)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)

nano_ros_add_executable(c_talker src/main.c)
ament_target_dependencies(c_talker std_msgs)

install(TARGETS c_talker DESTINATION lib/${PROJECT_NAME})
ament_package()

No platform, no board, no RMW on the command line or in the file: find_package(nano_ros) reads the example’s system.toml through nros ws leaf-system and derives the platform from the board it names. -DNROS_PLATFORM, -DNROS_BOARD and -DNROS_RMW are retired as the way a user chooses. A bare cross configure still passes -DCMAKE_TOOLCHAIN_FILE by hand (or uses nros init + cmake --preset <board>); nros build in the leaf maps the board to its toolchain file and runs the same configure into build/<image>/cmake (issue 1296, resolved).

A single-package C/C++ leaf needs no nros sync: its message bindings are a CMake-time output, so a clean copy outside the checkout configures and builds with no sync at all.

The other shape — a standalone CMake project pulling nano-ros in by path with add_subdirectory(<repo-root>), setting NROS_PLATFORM / NROS_RMW / NROS_BOARD itself, linking NanoRos::NanoRos and calling nros_platform_link_app() + nano_ros_link_rmw() — is what the ThreadX Rust leaves and the examples/templates/ recipes use, and is the right shape for an out-of-tree consumer that owns its whole CMakeLists.txt. Its cache-variable contract is in docs/reference/c-api-cmake.md.

nano_ros_link_rmw emits the strong-stub nros_app_register_backends() that calls every linked RMW’s nros_rmw_<x>_register() symbol — the auto-registration path for targets where linkme’s distributed-slice contribution isn’t picked up by the linker (FreeRTOS, NuttX, Zephyr, bare-metal).

Note find_package(nano_ros) above is the ament package. There is still no find_package(NanoRos) CMake-config export — that was deleted along with just install-local, every install(...) rule, and every Config.cmake.in template.

Per-example contents

examples/<plat>/<lang>/<example>/
├── system.toml                    # board, RMW, domain, components, net identity
├── package.xml                    # ROS-style manifest for the example
├── Cargo.toml | CMakeLists.txt    # language-toolchain facts only
├── src/                           # main.rs / main.c / main.cpp
├── generated/                     # codegen output — `nros sync`, gitignored
├── build/                         # `nros sync` / `nros build` output, gitignored
└── README.md                      # usage instructions

There is no .cargo/. Cargo.toml and CMakeLists.txt carry language-toolchain facts; every build setting the board implies is generated under build/. Each example’s Cargo.toml / CMakeLists.txt builds in isolation — no workspace reliance, no path heuristics walking up the source tree.

Message generation

Examples with custom .msg, .srv, or .action files generate bindings in-tree under generated/. The generated/ directory is gitignored per-example (only the core pre-generated set at packages/interfaces/generated/ lives in git, Arduino bundle exception aside).

source /opt/ros/humble/setup.sh        # for rosidl tooling
nros sync                              # what you run in an example

nros generate-rust (and generate-c / generate-cpp / generate-all) is the codegen-only primitive underneath it: it emits generated/ and nothing else. nros sync is the verb an example needs, because it also writes the image’s build/<image>/nros-cargo.toml.

Its --generate-config / --config / --nano-ros-path / --nano-ros-git flags still parse, and are retired as a way to configure an example: they wrote a leaf .cargo/config.toml, which no longer exists. (--nano-ros-path is a documented no-op on nros sync itself, kept for back-compat.)

For CMake consumers:

nros_find_interfaces(LANGUAGE C SKIP_INSTALL)
nano_ros_generate_interfaces(... LANGUAGE C)

See Message Generation for the full reference + package.xml schema.

Adding a new example — checklist

  1. Pick the canonical cell. Confirm <plat>/<lang>/<name> isn’t in the “intentionally empty” list in examples/README.md.
  2. Copy the nearest peer. Identical-RMW + adjacent-platform is the lowest-risk template (e.g. copy examples/mps2-an385-freertos/c/talker to make a new FreeRTOS C/zenoh example).
  3. Update names + package.xml. Rename Cargo.toml’s name and [[bin]] entries (Rust) or project(...) and nano_ros_add_executable(...) targets (CMake).
  4. Point system.toml at your board. [system] name, the [[component]] rows (pkg, class, name), and [image.<id>] board plus any network identity the board needs (locator, and the address in net). A Rust leaf also needs its [dependencies] board crate changed to match — see the exception above. If the board cannot be host-probed (a foreign [build] target, build-std, or a board crate with no host build), declare the component’s entities here too.
  5. Sync. nros sync in the example directory — generated message crates plus build/<image>/nros-cargo.toml. Custom messages need their own package.xml in the consuming example. A single-package C/C++ leaf can skip this; everything else cannot, and nros build refuses with one line naming it.
  6. Build standalone. nros build from the example directory (C/C++ single-package leaves can also run raw CMake: cmake -B build -Dnano_ros_ROOT="$(nros sdk-root)" && cmake --build build). No walking-up workspace allowed.
  7. Wire the test fixture (optional). If the example needs an E2E gate, add a row to examples/fixtures.toml and the matching cell in matrix::CELLS; the builder lives in packages/testing/nros-tests/src/fixtures/binaries/<plat>.rs.
  8. Update examples/README.md coverage matrix if you filled a previously-empty cell.

Per-platform notes

The build command is nros build (Rust and workspaces) or cmake --build (single-package C/C++) almost everywhere; the table records what differs.

PlatformSource file shapeBuildNotes
nativesrc/main.rs, src/main.c, src/main.cppnros build / cmake --buildFull std.
mps2-an385-baremetalsrc/main.rs with #[entry]nros buildNo std. Pure Cortex-M3. The board’s QEMU runner is in the generated settings, so cargo run --config … boots it.
mps2-an385-freertossrc/main.rs / src/main.cpp / src/main.cnros build (Rust) or cmake --build (C/C++)FreeRTOS kernel + lwIP.
nuttxsrc/main.rs / src/main.ccmake --build (NuttX export tarball)NuttX kernel.
threadx-linux / threadx-riscv64src/main.rscmake --buildThreadX + NetX Duo.
esp32src/main.rsnros build + espflash save-imagebare-metal esp-hal; no cargo runner.
zephyrsrc/lib.rs (staticlib) or src/main.cppwest build (after nros sync)Kconfig + west module — the one carve-out where nros build is not the verb.

Artifacts land under <leaf>/build/<image-id>/target/[<triple>/]<profile>/<bin>: examples/linux/rust/talker gives build/linux/target/debug/talker, examples/mps2-an385-baremetal/rust/talker gives build/mps2-an385-baremetal/target/thumbv7m-none-eabi/debug/qemu-bsp-talker.

Per-platform deep-dives — toolchain setup, Kconfig variables, runner scripts — live in the Platform Guides.

See Also

Per-Platform Contributor Lanes

These are the in-tree just recipes contributors use to build fixture sets, boot QEMU lanes, and run per-platform test sweeps from a nano-ros checkout. Users never need them — the user path (setup, build, run with the vendor’s own tools) is on each platform’s own page, which links back here per platform. Every lane assumes source ./activate.sh (or direnv allow) in the checkout first.

FreeRTOS

Lanes for FreeRTOS (QEMU MPS2-AN385).

The prebuilt test fixtures bake per-language allocator ports instead of 7447 (the copy-out examples’ port) so suites run in parallel; just freertos talker boots the fixture, so pair it with just freertos zenohd (listens on 7800, the Rust pub/sub fixture port).

just freertos build-fixtures builds every in-tree zenoh + DDS example across Rust / C / C++ in one pass.

just freertos zenohd & starts the router on the fixture port (7800) and just freertos talker boots the prebuilt talker fixture in QEMU instead of the copy-out example. For batch testing without manual QEMU launches, just freertos test runs every E2E (pub/sub, service, action) against a temporary in-test zenohd.

just freertos build / just freertos test build and exercise the in-tree fixtures.

ESP32 (dormant)

ESP32 support is dormant (issue 1525): the board, platform crate and examples are kept in the tree for future use, but nothing builds, tests or provisions them — the esp32 just module is not mounted, its fixture rows and CI job are gone, and nros new --platform no longer offers it.

NuttX

Lanes for NuttX (QEMU) and Integration: NuttX external app.

Build (arm QEMU):

just nuttx build

This cross-compiles all NuttX examples for armv7a-nuttx-eabi using cargo +nightly build --release.

Build (RISC-V rv-virt):

just nuttx build-riscv-c        # C example fixtures
just nuttx build-riscv-rust     # Rust example fixtures

Test:

just nuttx test        # arm QEMU integration tests
just nuttx test-all    # including the networked E2E lanes

Run: for nano-ros’s own in-tree QEMU examples, just nuttx zenohd & starts the router on the fixture port (8200) and just nuttx talker wraps qemu-system-arm with the right wiring. talker there is the Rust variant; the C / C++ variants boot through the make-driven path described under “Auto-configure glue” in Integration: NuttX external app.

Zephyr

Lanes for Zephyr (native_sim) and Zephyr Integration (west module).

E2E testing:

# Zenoh examples
just zephyr build           # Build Rust zenoh examples
just zephyr build-c         # Build C zenoh examples
just zephyr test            # Run zenoh E2E tests

# XRCE examples
just zephyr build-xrce      # Build all XRCE examples (Rust + C)
just zephyr test-xrce       # Run XRCE E2E tests

# All examples
just zephyr build-all       # Build everything
just zephyr ci              # Doctor + test (CI shortcut)

nano-ros’s own zephyr talker has a matching recipe for the canonical native_sim build path: just zephyr talker, paired with just zephyr zenohd & (listens on the fixture port 7400).

To completely recreate the in-tree Zephyr workspace:

just setup zephyr --force

Arm FVP

Lanes for ARM FVP (FVP_BaseR_AEMv8R).

Doctor: nros doctor --board fvp-aemv8r-smp runs the FVP resolution cross-check (it delegates part of its report to just doctor, so it needs just on PATH). The just zephyr run-fvp-ws-entry / run-fvp-board-import recipes do the equivalent inline via scripts/zephyr/resolve-fvp-bin.sh. When the binary can’t be found, a recipe you NAMED fails with the remedy (nros setup --tool arm-fvp); reached through a fixture fan-out it skips and is reported, like every platform lane.

Build: just zephyr build-fvp-all runs the FVP build lanes.

# The workspace C++ RT-tiers Entry — the canonical ASI-consumption
# reference (nano_ros_use_board + run_tiers).
just zephyr build-fvp-ws-entry

# The minimal board-crate IMPORT surface: nano_ros_use_board() and a
# trivial printk app, so the import path can be checked on its own.
just zephyr build-fvp-board-import

Each recipe shells west build -b fvp_baser_aemv8r/fvp_aemv8r_aarch64/smp from the Zephyr workspace and writes zephyr.elf into the checkout’s Zephyr build root (scripts/lib/zephyr-workspace.sh build-root, by default build/zephyr-workspace-builds/<version>/):

  • <build root>/build-fvp-ws-entry/zephyr/zephyr.elf
  • <build root>/build-fvp-board-import/zephyr/zephyr.elf

Run (once the build artifacts and ARM_FVP_DIR / ARMFVP_BIN_PATH are in place):

# Boot the workspace RT-tiers Entry (prints `[ctrl] tick=` / `[telem] tick=`).
just zephyr run-fvp-ws-entry

# Boot the board-import smoke (prints `nros: smoke ok`).
just zephyr run-fvp-board-import

Under the hood the recipe:

  1. Verifies west + the Zephyr workspace + zephyr.elf exist; skips with a hint otherwise.
  2. Resolves the FVP binary directory via scripts/zephyr/resolve-fvp-bin.sh (priority order: ARMFVP_BIN_PATH → ARM_FVP_DIR/models/Linux64_GCC-*/ → dirname $(command -v FVP_BaseR_AEMv8R)).
  3. Exports ARMFVP_BIN_PATH=<dir> and shells west build -d <build-dir> -t run, which drives Zephyr’s cmake/emu/armfvp.cmake target with the canonical boards/arm/fvp_baser_aemv8r/board.cmake -C flags — UART 0–3 piped to stdout, GICv3, cache state, NUM_CORES from CONFIG_MP_MAX_NUM_CPUS. No flags are duplicated in the just recipe.

Exit cleanly with Ctrl-C.

ThreadX

Lanes for ThreadX (Linux sim / RISC-V64 QEMU).

just <flavour> build-fixtures produces threadx_cpp_* and riscv64_threadx_cpp_* binaries alongside the Rust + C ones:

just threadx_linux build-fixtures     # build all rust + c examples
just threadx_riscv64 build-fixtures

just threadx_riscv64 talker boots the prebuilt riscv64 talker fixture in qemu-system-riscv64 with the virtio-net + Slirp wiring baked in. For batch testing, just threadx_linux test runs every pubsub / service / action against an in-test zenohd.

Cyclone DDS

Lanes for the Cyclone DDS backend (Choosing an RMW Backend). These are contributor-only recipes — a bare cmake / cargo consumer build needs no just cyclonedds pre-step; the consumer build self-provisions Cyclone from source.

just setup cyclonedds       # build Cyclone DDS from third-party/dds/cyclonedds (tag 0.10.5)
just cyclonedds build-rmw   # build packages/rmw/cyclonedds/nros-rmw-cyclonedds
just cyclonedds test        # run the CTest harness

Serial transport (QEMU)

Lane for Serial Transport: the integration test test_qemu_serial_pubsub_e2e in packages/testing/nros-tests/tests/emulator.rs automates the full QEMU serial pub/sub workflow. Run it with:

source ./activate.sh
just qemu build-fixtures                  # the test consumes a prebuilt
                                          # baremetal fixture
cargo nextest run -p nros-tests --test emulator test_qemu_serial_pubsub_e2e

(A bare cargo nextest counts a skipped-precondition test as a FAILURE; the contributor lane just test-all is what rewrites those into skips. Read the panic text before treating a red here as a regression.)

Contributing

This chapter covers the development workflow for nano-ros.

Development Setup

Choose the setup tier. base is enough for most code review and native development; all prepares the full test-all matrix:

just setup               # print setup choices
just setup base          # workspace tools + zenohd
just setup all           # all supported SDKs/services
source ./activate.sh

The test and fixture stages need the vendored-source UNION, not one board. nros setup <board> --rmw <x> provisions one board+rmw’s sources — right for building an app for that board. But just test links every RMW’s -sys crate and just build-test-fixtures resolves component graphs that path-dep platform sources (e.g. nuttx/libc) even for a native component, so the contributor build needs the whole set. Both recipes preflight it and, if a source is missing, name the exact remedy — nros setup --source <name>, or nros setup --build-sources to provision the union at once (issue 0390).

Build the entire workspace including examples:

just build

Use groups to inspect the command surface:

just --group main --list
just --group full-matrix --list
just --group full-matrix --list zephyr

Quality Checks

Always run just ci after completing a task that needs the full maintainer gate. This runs the normal checks plus the full test matrix entry points:

just ci

For a narrower local iteration, run just check and just test.

For platform-specific CI, use just <platform> ci (e.g., just freertos ci).

Testing

nano-ros has several test tiers, each with its own just recipe:

RecipeWhat it testsExternal deps
just test-unitUnit tests (no external deps)None
just test-integrationRust integration tests, no heavy QEMU/Zephyr (builds zenohd automatically)None
just testtest-unit + test-integration (default dev tier)None
just test-docrustdoc doctests for the nros umbrella crateNone
just test-miriUndefined behavior detection (standalone)None
just qemu testQEMU bare-metal examplesqemu-system-arm
just freertos testFreeRTOS QEMU E2Eqemu-system-arm + arm-none-eabi-gcc
just nuttx testNuttX QEMU E2Enightly + qemu-system-arm
just threadx_linux testThreadX Linux user-space E2Egcc
just threadx_riscv64 testThreadX RISC-V QEMU E2Eqemu-system-riscv64
just zephyr testZephyr E2E (native_sim + NSOS)west
just test-allEverything: test + heavy QEMU/Zephyr/ROS-interop + test-doc + test-miri + C codegenAll of the above

All test recipes accept a verbose argument for live output.

Test organization

  • Reusable Rust tests go in packages/testing/nros-tests/tests/
  • Shell-based test scripts go in tests/ with corresponding justfile entries
  • Temporary tests can be run directly in the shell, then converted to proper tests once validated

Build isolation

Nextest runs each test file as a separate process in parallel. When multiple tests build the same example with different features, use --target-dir to isolate output directories (e.g., target-safety/, target-zero-copy/). Add new target dirs to the example’s per-directory .gitignore.

Code Style

Rust Edition 2024

nano-ros uses Rust edition 2024, which requires:

  • unsafe extern "C" { ... } – extern blocks require the unsafe keyword
  • #[unsafe(no_mangle)] – no_mangle requires the unsafe attribute
  • Unsafe operations inside unsafe fn need explicit unsafe { ... } blocks

The nros-c crate keeps #![allow(unsafe_op_in_unsafe_fn)] due to 420+ FFI operations.

Unsafe conventions

  • Minimize unsafe usage; prefer safe abstractions
  • Document safety invariants with // SAFETY: comments
  • Use wrapper types (like SubscriberBufferRef) to encapsulate unsafe access patterns

no_std patterns

All core crates support #![no_std] with optional std/alloc features:

  • #[cfg(feature = "alloc")] gates Vec/Box usage
  • #[cfg(feature = "std")] gates std-only features
  • heapless::Vec is used in no_std contexts

Unused variables

  • Rename to _name with a comment explaining why
  • Use #[allow(dead_code)] for test struct fields

Message Types

Message types are always generated, never hand-written. Use the codegen tool:

nros generate-rust

Bundled interface definitions live in packages/cli/interfaces/. Example generated/ directories are gitignored and recreated by just generate-bindings. Only the core pre-generated set checks its generated/ into git: ONE driver package at packages/interfaces/, one output tree at packages/interfaces/generated/humble/, six crates, regenerated with just generate-interfaces on a ROS 2 ament host.

System Packages

Never install system packages or run sudo directly. If a system dependency is needed, document what the user should install.

Temporary Files

Create temporary files in the project’s tmp/ directory (git-ignored), not in /tmp.

PR Workflow

  1. Create a branch from main
  2. Make changes
  3. Run just ci – all checks must pass
  4. Submit for review

Verification

For changes to core crates, run the formal verification suite:

just verify          # Kani + Verus
just verify-kani     # Kani bounded model checking (~3 min)
just verify-verus    # Verus deductive proofs (~1 sec)

nros CLI reference

The nros binary is the user entry point for nano-ros: scaffolding, message codegen, SDK provisioning, topology planning, static checks, diagnostics, and board introspection.

The old cargo nano-ros … cargo subcommand has been removed. Use nros directly.

Install

Two front doors, and which one is right depends on who you are.

Using nano-ros — install a release, with no checkout and no cargo:

curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh

It verifies the sha256, installs into ~/.nros/sdk/nros/<version>/ and points ~/.nros/bin/nros at the newest installed version — one command, however many versions accumulate. (No release is cut yet; the installer says so and sends you to the source build.)

Working on nano-ros — build the CLI from the checkout. That is not a fallback: in a checkout the tree’s own build is the only binary this tree accepts, because a released nros emits its own generated code and this runtime is what compiles it (RFC-0090, phase-431 W1). just doctor fails when another nros shadows it. The CLI ships from the in-tree sub-workspace at packages/cli/; bootstrap builds it (installing rustup if needed), then activate the workspace to put it on PATH:

./scripts/bootstrap.sh
source ./activate.sh        # OR: direnv allow / source ./activate.fish

Equivalent, if you already have cargo (same build, same binary): git submodule update --init packages/cli/third-party/play_launch then cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros (bootstrap runs both for you; just setup-cli is the internal alias).

The resulting binary lives at packages/cli/target/release/nros. One checkout = one CLI version = one runtime ABI; contributors with multiple nano-ros worktrees get per-tree CLIs with no global PATH skew. Once on PATH, nros --help lists every verb. The transitional ${NROS_HOME:-~/.nros}/bin/nros install location remains supported for users mid-migration from the pre-218 release-fetch shape.

Verbs

nros setup [<board>] [--rmw <rmw>] [--tool <name>] [--source <name>] [--prefix <dir>] [--list] [--licenses] [--dry-run]

Provision the toolchain/SDK for a board. nros setup is the single canonical provisioning command. Most components are prebuilt per platform per RMW (cross-compiler, emulator, RMW host daemon, SDK sources), fetched from a pinned index into a shared store (${NROS_HOME:-~/.nros}/sdk). No hand-installed cross-toolchains; no ROS distro required.

Packages are prebuilt where the index has a binary for your host and built from source otherwise; the vendored submodules (zenoh-pico, mbedtls, RTOS sources) are the source builds. --dry-run prints the plan without fetching. Full explanation: Installation.

Note setup does not provision a zenoh router: since RFC-0075 that is ROS’s rmw_zenoh_cpp/rmw_zenohd. See Do I need ROS 2 installed?.

nros setup linux --rmw zenoh            # host build (router comes from ROS)
nros setup mps2-an385-freertos --rmw xrce  # arm-none-eabi-gcc, qemu, FreeRTOS, XRCE agent
nros setup zephyr                        # Zephyr west workspace + SDK bits
FlagEffect
<board>resolve + fetch this board’s package set (see Supported Boards)
--rmw <zenoh|xrce|cyclonedds>also provision the RMW’s host daemon/tool (default zenoh); resolves board ∪ rmw packages
--tool <name>install tools by name (e.g. --tool qemu); repeatable — --tool ninja --tool make is one plan, with one system-package ask for the union
--source <name>provision a single source package by name (repeatable)
--prefix <dir>install a --tool here instead of the shared store
--listlist every package in the index + its version
--licensesshow license-gated packages + how to install them
--dry-runresolve + print the plan without fetching anything

Board names (the full set — RFC-0093 names each board for the reach of its build): native, posix, mps2-an385-baremetal, mps2-an385-freertos, mps3-an536-freertos, s32z270-freertos, freertos-posix, qemu-armv7a-nuttx, rv-virt-nuttx, rv-virt-threadx, threadx-linux, esp32-c3-baremetal, fvp-aemv8r-smp, zephyr. A wrong one lists the rest; nros setup <board> --dry-run resolves without fetching. Names retired by phase-437 are in the retired board names ledger.

nros init [<dir>]

Generate a project CMakePresets.json (into <dir>, default the current directory) that includes the per-board preset fragments nros setup <board> wrote under ~/.nros/presets/ (RFC-0048 §6). After it, cmake --preset <board> cross-configures a nano-ros ament package with no hand-set -DCMAKE_TOOLCHAIN_FILE / -Dnano_ros_ROOT. Idempotent — re-run after provisioning a new board to pick up its fragment.

nros setup qemu-armv7a-nuttx
nros init                 # writes ./CMakePresets.json
cmake --preset qemu-armv7a-nuttx

nros new <name> --platform <plat> [--rmw <rmw>] [--lang <lang>] [--use-case <case>] [--force]

Scaffold a new nano-ros project. Emits a colcon-compatible package.xml plus a hello-world Rust / C / C++ skeleton tuned for the chosen platform.

FlagValuesDefault
--platformlinux, freertos, nuttx, threadx, zephyr, esp32, baremetal(required)
--rmwzenoh, xrce, cycloneddszenoh
--langrust, c, cpprust
--use-casetalker, listener, service, actiontalker
--forceoverwrite an existing directoryoff

--platform native and --platform posix are refused with a hint naming linux — the host board’s one name (phase-500).

Image / host mode: nros new --image <name> [--board <b>] scaffolds an [image.<name>] — a buildable image (RFC-0065 D6) — into the bringup package’s system.toml; nros new --host <name> scaffolds a [host.<name>], a machine nodes run on. Both accept [--bringup <pkg>] [--from-launch <path>] [--from-profile <name>]. The bringup package is discovered automatically when the workspace exposes exactly one; pass --bringup <pkg> to pick one when there are several. --from-launch also seeds the bringup [system].default_launch; --from-profile forks an existing block of the same table.

An image names a --board and derives its rustc triple from that board’s descriptor, so there is no --target. A host takes no board: a host says WHERE nodes run, and a board is what an image is BUILT for — passing one is refused rather than written.

nros new --deploy <name> is DEPRECATED (issue 0951): [deploy.*] split into those two tables, and a system.toml that still carries one is refused by name (phase-486 W8). The flag still works, dispatched by the --kind it always took — self scaffolds a host, anything else an image — and says which flag to use instead. It never writes [deploy.*].

nros generate <lang> [--manifest <path>] [--output <dir>] [--ros-edition <edition>] [--force] [--verbose] [--generate-config]

Generate ROS 2 message bindings from a package.xml. Rust users should prefer the direct nros generate-rust command; C and C++ users normally use the CMake integration.

ArgumentValuesDefault
<lang>rust, c, cpp, all(required)
--manifestpath to package.xmlpackage.xml
--outputoutput directorygenerated
--ros-editionhumble, iron, jazzyhumble
--codegen-configexplicit per-field capacity config (nros-codegen.toml)discovered by walking up
--generate-configlegacy — emit a .cargo/config.toml patch table (Rust only). Use nros syncoff

nros generate-rust [--manifest <path>] [--output <dir>] [--ros-edition <e>] [--codegen-config <f>] [--rename <old=new>] [--force] [--verbose]

The Rust-only codegen primitive. Same binding generation as nros generate rust, and with no side-effects: it writes generated/ and nothing else. Use it when you want bindings and only bindings; use nros sync for an actual project, because a leaf also needs its generated build settings.

--generate-config (alias --config), --nano-ros-path and --nano-ros-git still parse and are legacy. They wrote a leaf .cargo/config.toml — the file RFC-0098 retired. The patch rows now live in the generated build/<image>/nros-cargo.toml that nros sync writes, alongside the board’s cargo settings and the image’s derived [env], and cargo reads that through --config. (nros sync’s own --nano-ros-path is documented in its help as a no-op kept for back-compat.)

nros generate-px4-msgs

Generate CDR-serializable px4_msgs::msg::* bindings from a PX4 .msg tree — the XRCE/PX4 interop path (see the PX4 starter).

nros codegen <resolve-deps|cyclonedds-descriptors|entry> …

Internal build-tool interface — invoked by the CMake integration and build.rs scripts, not meant for direct use. Subcommands resolve message-package dependency closures, emit CycloneDDS type descriptors, and generate C/C++ entry glue. (nros codegen-system below is the user-facing system bake.)

nros metadata <system_pkg> [--workspace <path>] [--out-dir <dir>] [--metadata <existing.json>] [--build [--nano-ros-workspace <path>]] — walk a colcon-style workspace under <workspace>/src/

collecting component source metadata into build/<system_pkg>/nros/source-metadata.json. The result feeds nros plan. With --build, any declared component (component_nros.toml) missing its source-metadata is produced by the metadata-mode build: nano-ros compiles + runs the component against an in-memory recorder to emit the JSON. --build needs the nano-ros workspace (--nano-ros-workspace or NROS_WORKSPACE).

nros plan <system_pkg> <launch_file> [LAUNCH_ARGS...] [options] — resolve a ROS 2 launch file (or a precomputed

play_launch_parser record.json) plus the source metadata into a typed build/<system_pkg>/nros/nros-plan.json IR. Picks per-component SchedContext bindings, node graph wiring, parameter remaps, and generated-package layout.

FlagUse
--record <file>Skip launch-file parsing; consume an existing record.json
--workspace <path>Override the workspace root (default $PWD)
--out-dir <dir>Override build/<system_pkg>/nros/
--metadata <file>Reuse a prior source-metadata.json
--manifest <file>ROS launch manifest YAML overlay
--nros-toml <file>nano-ros deployment overlay (nros.toml)

nros check [plan]

Validate an nros-plan.json (default build/nros/nros-plan.json): static checker — catches unconnected required topics, conflicting QoS, missing parameters, and SchedContext binding errors before the platform build runs. A .toml argument is instead validated as a root nros.toml (the workspace deployment SSOT) — [system]/[image.<id>]/[host.<name>] shape, default-deploy + system references, bridge endpoints, etc.

nros explain [<plan>]

Render a generated nros-plan.json in human-readable form.

nros explain                            # reads build/nros/nros-plan.json
nros explain path/to/nros-plan.json     # explicit path

Run after nros plan to inspect the resolved component graph, topic wiring, parameter bindings, and SchedContext assignments before committing to a platform build.

nros config show --system <pkg> [--workspace <dir>]

Prints the resolved effective config for a bringup system (rmw, domain_id, locator, and the safety / param_services / lifecycle capability axes) with a per-value provenance column — system.toml [section] vs the built-in default. If a legacy per-package nros.toml overlay still declares any of those blocks, it is flagged DEPRECATED by name (RFC-0004 §3.1). Omit the <pkg> value to default to the workspace’s default_system (or the sole bringup); --workspace defaults to the cwd. The nros check command surfaces the same overlay warnings when validating a system.toml.

The legacy nros config show/check --config <path> reader for config.toml was removed: config.toml is retired (RFC-0004 §8) and no example ships one. Embedded runtime config — board, RMW, domain, locator, IP — lives in system.toml: [image.<id>] and [system]. The [package.metadata.nros.deploy.<board>] manifest table that used to hold it is retired by RFC-0098 D5 and refused wherever it still appears.

nros config explain --platform <name> [--board-toml <f>] [--platforms-dir <d>]

Print the resolved BUILD-time knob ladder for a platform: every knob’s final value plus the rung that set it (builtin / platform / board / env, RFC-0049). This is the provenance tool for anything generated — when a pool size or a hardware budget is not what you expected, it says which rung decided. --board-toml supplies the board rung’s [knobs] deltas by explicit path.

nros sync [<workspace>] [--build-dir <d>] [--ros-edition <e>] [--dry-run] [--check] [--base-paths <p>…]

The pre-build step. Codegen every *.msg package the declared deps name, and write the generated build settings for each declared image — for a standalone package or a colcon-style workspace (it picks single-pkg vs workspace mode by layout). Run it after editing *.msg, package.xml or system.toml.

eval "$(nros ws env)"   # add src/ to NROS_INTERFACE_SEARCH_PATH
nros sync               # generated/ msg crates + build/<image>/nros-cargo.toml
nros build              # or: nros build <image-id>

What it writes into build/<image>/nros-cargo.toml (RFC-0098 D1): the board’s cargo_config (its [build] target, [target.*] rustflags/linker, [unstable]), this image’s target-dir, its entity facts and derived pool knobs as [env], the in-repo [patch.crates-io] rows, and the nano-ros cargo profiles. Nothing generated is written beside the package, and [env] never uses force = true, so a value you set in the environment wins.

Transitionally, sync also still maintains the [patch.crates-io] block in each Rust consumer’s patch-authority .cargo/config.toml (never in Cargo.toml) — that is the road phase-445 W6 removes, and it is what the ws clean / ws doctor subcommands below still talk about.

--check exits non-zero when anything is missing or stale — the CI hook, and what nros ws status reports non-fatally. --dry-run prints what it would do.

nros generate-rust stays the low-level codegen-only primitive (no side effects beyond generated/).

nros build [<image>…] [--workspace <dir>] [--all] [--dry-run] [--offline] [--check[=clippy]] [--packages-select <p>…] [--packages-up-to <p>…] [-- <native args>]

The colcon-like build verb: discover packages, resolve the image, preflight, generate the build root, then hand off to the native tool. The final stage is an exec, so compiler diagnostics are byte-identical to running cargo / cmake / west yourself. With no image argument it builds [system] default_images; --all builds every declared image.

cd <leaf-or-workspace>
nros sync
nros build                       # every default image
nros build linux                # one image by id
nros build linux -- --release   # arguments after `--` go to the native tool

A build without a sync fails at the first preflight with one line naming nros sync, rather than four frames down in cargo’s manifest parser. --dry-run prints the stages and the command that would run, and performs no I/O.

--check type-checks a cargo image instead of building it: every stage runs as for a build (the image’s settings file, its lock), and only the final command becomes cargo check, or cargo clippy with --check=clippy. Lint flags go after a second --. A cmake or west image is refused, since neither tool has a check that is not a build. Like a build, a check needs nros sync first.

nros build linux --check                          # cargo check
nros build linux --check=clippy -- -- -D warnings # cargo clippy, warnings denied

In a single-package C/C++ leaf, nros build configures the leaf’s own CMakeLists.txt into build/<image>/cmake with -DNROS_IMAGE=<id> and the board’s toolchain file, then runs cmake --build: the leaf’s own cmake pair, with the inputs a bare configure cannot know (issue 1296). Zephyr keeps west build as its build verb, with nros sync still run first.

Driving cargo yourself is supported — point it at the generated file, from the directory above the package:

cargo build --manifest-path <leaf>/Cargo.toml \
            --config <leaf>/build/<image-id>/nros-cargo.toml

The working-directory rule is temporary: until phase-445 W6 deletes the leaf’s .cargo/, running cargo inside the leaf reads it a second time and joins the board’s rustflags onto themselves.

nros materialize <image> [--workspace <dir>] [--force]

Take ownership of a generated entry package (RFC-0065 D5) — the last resort, after which nros build stops regenerating it. panic and profile are declarations on the image and need no materialising. A second run without --force is refused, because it would discard the edits the first one existed to enable.

nros ws <subcommand>

Workspace-level message-package utilities (the env / status / list / clean / doctor helpers; sync was promoted to top-level nros sync).

eval "$(nros ws env)"   # add src/ to NROS_INTERFACE_SEARCH_PATH
nros ws status          # freshness check (non-fatal): n up-to-date / n stale / n missing
nros ws list            # list discovered msg + Rust-consumer pkgs
nros ws clean           # remove generated/ + auto-managed patch entries
nros ws doctor          # lint workspace pkgs (package.xml markers, stale patches, …)
SubcommandDescription
envPrint shell export adding <dir> (default ./src) to NROS_INTERFACE_SEARCH_PATH
statusNon-fatal freshness check — sibling of nros sync --check
listList discovered msg + Rust-consumer pkgs (kind, name, dir per row)
cleanRemove generated/ + auto-managed [patch.crates-io] entries from each .cargo/config.toml; leaves user-written keys + sections alone
doctorLint: warn on missing <member_of_group> rosidl_interface_packages</member_of_group>, malformed package.xml, missing nros-managed [patch.crates-io] entries in the authority .cargo/config.toml

nros codegen-system [--bringup <pkg>] [--target <id> | --for-entry <pkg>] [--out <dir>] [--launch <file>] [--ahead-of-vendor <pio|px4>]

Host-time system bake: reads <bringup>/system.toml + <bringup>/launch/system.launch.xml and emits the baked compile-time C config + component registration glue consumed by every embedded RTOS adapter.

nros codegen-system --bringup demo_bringup
nros codegen-system --bringup demo_bringup --target zephyr_native_sim  # an [image.<id>]
nros codegen-system --bringup demo_bringup --for-entry zephyr_app      # the image claiming it
nros codegen-system --bringup demo_bringup --ahead-of-vendor pio   # + PlatformIO library.json
nros codegen-system --bringup demo_bringup --ahead-of-vendor px4   # + PX4 module dirs
FlagDescription
--workspace <path>Workspace root (default: cwd)
--bringup <pkg>Bringup pkg name or path. Defaults to [workspace.metadata.nros].default_system
--target <id>The [image.<id>] this bake is for — it selects the domain, RMW, locator, launch and tier sub-table. A triple or platform name that matches no block selects nothing and every one of those falls back to the [system] default (issue 1312)
--for-entry <pkg>Name the ENTRY PACKAGE instead (a name, or a path to its directory) and the [image.*] that claims it supplies the target. What a framework configure can answer: west/ESP-IDF know the application directory, never the image id. Mutually exclusive with --target
--nano-ros-path <dir>nano-ros checkout holding packages/boards, for the board catalog a resolved target needs. Default: a walk up from the workspace, then this nros binary’s own SDK root (a release’s share/nano-ros, or the checkout an in-tree nros was built in)
--out <dir>Output directory; nros-system/ subdir created inside. Default: <workspace>/build/<bringup>/
--launch <file>Multi-launch disambiguation: pick <bringup>/launch/<file> (--file is an alias)
--exec <exec><node exec="…"> override for synthesised launches
--ahead-of-vendorpio: emit PlatformIO library.json; px4: emit one PX4-native nros_<component>/ module dir per component

nros doctor [--platform <name>] [--workspace <path>]

Shells out to just doctor from the auto-detected workspace root. The justfile orchestrates every per-module doctor recipe (just nuttx doctor, just zephyr doctor, …) and is the source of truth for “healthy”. --platform <name> scopes the check to a single module.

nros board list [--workspace <path>]

Enumerate every nros-board-* crate under packages/boards/. Output columns are name | description; structured chip | flash | ram | supported_rmw fields are deferred to a future board-descriptor TOML.

nros version

Print toolchain + library versions.

nros completions <shell>

Emit shell completion scripts to stdout.

ShellInstall snippet
bashnros completions bash > ~/.local/share/bash-completion/completions/nros
zshnros completions zsh > "${fpath[1]}/_nros"
fishnros completions fish > ~/.config/fish/completions/nros.fish
powershellnros completions powershell > $PROFILE.parent\nros.ps1

Comparison: nros vs. just

Want to …Use
Scaffold a projectnros new …
Generate bindings + the image’s build settingsnros sync (primitive: nros generate-rust)
Buildnros build — or the platform tool pointed at the generated settings (cargo --config …, cmake --build, west build)
Plan + check a multi-component system from a ROS 2 launch filenros metadata → nros plan → nros check
Flash / runPlatform tools: espflash, probe-rs, qemu-system-*, idf.py
Orchestrate the checkout (contributor: setup, doctor, CI, multi-platform sweeps)just …

nros routes through the nros-cli-core library. just recipes that wrap user-flow operations call into nros for consistency; internal recipes (build matrices, CI orchestration) keep their current shape.

Release pipeline status

There is no crates.io / Arduino zip / ESP-IDF binary / GitHub Releases tarball channel. Per the archive decision (2026-05-19), nano-ros is consumed by git clone --branch=v<X.Y.Z> plus the in-tree build recipes documented at Installation. (A hidden, maintainer-only nros release verb exists behind the release cargo feature; the default build does not carry it.) Downstream RTOS package managers (Zephyr west, ESP-IDF idf_component_yml, NuttX Kconfig) consume the same source tree via the integration shells under integrations/<rtos>/.

Retired names

nano-ros’s cmake variables use one prefix, NROS_. Older releases used NANO_ROS_ for some of them, and the release before this one still accepted those spellings as deprecated aliases. They are now retired: a configure that sets one — in a CMakeLists.txt, with -D on the command line, or left behind in a build directory’s CMakeCache.txt — stops with an error that names the replacement. Nothing is adopted under the new name any more, so rename the old spelling where you set it.

RetiredUse instead
NANO_ROS_PLATFORMNROS_PLATFORM
NANO_ROS_FEATURESNROS_FEATURES
NANO_ROS_SAFETY_E2ENROS_SAFETY_E2E
NANO_ROS_ROS_EDITIONNROS_ROS_EDITION
NANO_ROS_GEN_CACHE_DIRNROS_GEN_CACHE_DIR
NANO_ROS_SKIP_BOOTSTRAPNROS_SKIP_BOOTSTRAP
NANO_ROS_BUILD_CODEGENNROS_BUILD_CODEGEN
NANO_ROS_CMAKE_DIRNROS_CMAKE_DIR
NANO_ROS_RMWNROS_RMW
NANO_ROS_BOARDNROS_BOARD
NANO_ROS_ROOT (cmake variable)NROS_REPO_DIR
NANO_ROS_ROOT <path> (keyword of nano_ros_workspace() and friends)NROS_REPO_DIR <path>

Retired knob spellings

Build-time knobs have one env spelling, NROS_<ID> (RFC-0103 D2b), held in the knob registry packages/tooling/nros-zephyr-build/knobs.toml. The older spellings below were read with a deprecation warning for one release. They are now retired: a build that finds one set — exported in the environment, in a cargo [env] table, or as an [image.<id>] env row in system.toml — stops with an error that names the replacement. Nothing is read under the old name any more, so rename it where you set it.

RetiredUse instead
ZPICO_BATCH_MULTICAST_SIZENROS_ZENOH_BATCH_MULTICAST_SIZE
ZPICO_BATCH_UNICAST_SIZENROS_ZENOH_BATCH_UNICAST_SIZE
ZPICO_FRAG_MAX_SIZENROS_ZENOH_FRAG_MAX_SIZE
ZPICO_GET_POLL_INTERVAL_MSNROS_ZENOH_GET_POLL_INTERVAL_MS
ZPICO_GET_REPLY_BUF_SIZENROS_ZENOH_GET_REPLY_BUF_SIZE
ZPICO_GRAPH_CACHE_SIZENROS_ZENOH_GRAPH_CACHE_SIZE
ZPICO_GRAPH_DISCOVERYNROS_ZENOH_GRAPH_DISCOVERY
ZPICO_LEASE_TASK_PRIORITYNROS_ZENOH_LEASE_PRIORITY
ZPICO_MAX_LARGE_SUBSCRIBERSNROS_ZENOH_MAX_LARGE_SUBSCRIBERS
ZPICO_MAX_LIVELINESSNROS_ZENOH_MAX_LIVELINESS
ZPICO_MAX_PENDING_GETSNROS_ZENOH_MAX_PENDING_GETS
ZPICO_MAX_PUBLISHERSNROS_ZENOH_MAX_PUBLISHERS
ZPICO_MAX_QUERYABLESNROS_ZENOH_MAX_QUERYABLES
ZPICO_MAX_SESSIONSNROS_ZENOH_MAX_SESSIONS
ZPICO_MAX_SUBSCRIBERSNROS_ZENOH_MAX_SUBSCRIBERS
ZPICO_MAX_TL_PUBLISHERSNROS_ZENOH_MAX_TL_PUBLISHERS
ZPICO_PUBLISHER_TX_BUFFER_SIZENROS_ZENOH_PUBLISHER_TX_BUFFER_SIZE
ZPICO_READ_TASK_PRIORITYNROS_ZENOH_READ_PRIORITY
ZPICO_SERVICE_BUFFER_SIZENROS_ZENOH_SERVICE_BUFFER_SIZE
ZPICO_SMOLTCP_BUFFER_SIZENROS_SMOLTCP_BUFFER_SIZE
ZPICO_SMOLTCP_CONNECT_TIMEOUT_MSNROS_SMOLTCP_CONNECT_TIMEOUT_MS
ZPICO_SMOLTCP_MAX_SOCKETSNROS_SMOLTCP_MAX_SOCKETS
ZPICO_SMOLTCP_MAX_UDP_SOCKETSNROS_SMOLTCP_MAX_UDP_SOCKETS
ZPICO_SMOLTCP_SOCKET_TIMEOUT_MSNROS_SMOLTCP_SOCKET_TIMEOUT_MS
ZPICO_SUBSCRIBER_LARGE_SIZENROS_ZENOH_SUBSCRIBER_LARGE_SIZE
ZPICO_SUBSCRIBER_RING_DEPTHNROS_ZENOH_SUBSCRIBER_RING_DEPTH
ZPICO_SUBSCRIBER_SIZE_THRESHOLDNROS_ZENOH_SUBSCRIBER_SIZE_THRESHOLD
ZPICO_TL_RETAIN_BYTESNROS_ZENOH_TL_RETAIN_BYTES
ZPICO_TL_RETAIN_DEPTHNROS_ZENOH_TL_RETAIN_DEPTH
ZPICO_TX_BATCHNROS_ZENOH_TX_BATCH
ZPICO_TX_BATCH_FLUSH_MSNROS_ZENOH_TX_BATCH_FLUSH_MS
ZPICO_TX_SPLIT_LOCKNROS_ZENOH_TX_SPLIT_LOCK

The same ZPICO_* words remain the zenoh-pico shim’s own C macro names (a -DZPICO_MAX_QUERYABLES=… the build passes to the C compiler). Those are generated from the knob, never set by hand, and are not on this list.

A board descriptor’s knobs are a top-level [knobs.<group>] table. The per-board [board.knobs.<group>] spelling is retired the same way: a descriptor that still states it fails to load, naming [knobs].

Retired runtime aliases and macro forms

Deprecated in release 0.5.0-nros2, retired after it. Each is now an ERROR naming its replacement — never silently ignored, because an ignored locator dials the default router and reads as “no delivery”.

RetiredUse insteadWhat happens now
ZENOH_LOCATOR (env)NROS_LOCATORstartup error (Rust panic / InitError::RetiredEnvVar; C/C++ init returns invalid-argument and logs it)
ZENOH_MODE (env)NROS_SESSION_MODEsame
nros::main!(board = X)nros::main!() + [image.<id>] board in system.toml; an out-of-tree board crate states [package.metadata.nros.board] type = "::my_board::MyBoard"compile_error!
--features rmw-xrce on the PX4 companionsnothing — the xrce backend is unconditionalthe feature no longer exists (cargo: unknown feature)

A build directory from an older release

An older nano-ros wrote some of these names into the cache itself, so a build directory it first configured carries them even though nothing sets them now. The error lists those entries and prints the command that drops exactly them:

cmake -U NANO_ROS_PLATFORM -U NANO_ROS_RMW <build-dir>

Deleting the build directory’s CMakeCache.txt works too.

What is not on this list

nano_ros_DIR and nano_ros_ROOT are CMake’s own find_package(nano_ros) hints and keep their spelling.

NANO_ROS_ROOT as an environment variable is retired by its own rule: a configure that finds it set stops, naming NROS_REPO_DIR.

The error table stays: a retired name is refused for good, naming what to type. Dropping the rows would turn -DNANO_ROS_PLATFORM=zephyr (or a stale cache entry an older nano-ros wrote) into an unknown variable, and the configure would build the default platform without a word.

Rust API

The Rust user-facing surface lives in the nros umbrella crate. Generated by rustdoc --no-deps; always reflects the current main branch.

API reference

nros rustdoc — start here.

That single crate re-exports every type a user application needs: Executor, Node, Publisher<M>, Subscription<M>, Service<S>, Client<S>, ActionServer<A>, ActionClient<A>, Timer, GuardCondition, plus the nros::prelude glob-import.

Where to start

  • New to nano-ros? → nros::prelude
  • Two-layer API (L1 polling vs L2 callback)? → Two-Layer API — concept page with the verb discipline and per-layer use cases.
  • Executor-driven app? → nros::Executor
  • Async/spin_async? → the async surface lives on nros::Executor and the entity handles (recv().await, call().await) — see the crate root docs

Generating locally

cargo doc --no-deps -p nros --open

Internals (porting only)

These crates are not part of the user API. They surface only when writing a custom RMW backend or porting to a new platform — see Custom RMW Backend / Custom Platform.

C API

The C user-facing surface lives in packages/api/nros-c/include/, at upstream’s paths (rclc/, rcl/, rcl_action/, …). rclc/detail/ is internal. Generated by Doxygen; always reflects the current main branch.

API reference

C API Doxygen — start here.

The umbrella header rclc/rclc.h pulls in every public surface a user application needs.

Where to start

Two-layer API. Every entity exposes two parallel C entry-point sets: rclc_*_init_default (Layer 1, caller polls) and rclc_executor_add_* (Layer 2, executor callback). Layer 1 grew an _init_polling + take_*_raw / send_*_raw family for inline-storage callers (no executor arena). Layer 2 pairs the same rclc_*_init_default with the executor-add call, which is where the callback is supplied (rclc’s binding site). See Two-Layer API for the verb discipline and per-layer trade-offs.

Event-driven wake callbacks. Each L1 polling entity supports nros_*_set_wake_callback(entity, &state, cb, ctx) for event- driven RTOS / embassy callers. State is a caller-owned rclc_wake_state_t POD; the backend fires cb(ctx) on rx / reply / request arrival. Per-channel for actions (set_{goal,cancel,get_result}_wake_callback on the server, set_{goal_response,cancel_response,result,feedback}_wake_callback on the client). See the Doxygen for signatures.

Async action client

<rclc/action.h> exposes two parallel entry-point families for sending goals, requesting results, and cancelling:

  • Async (non-blocking): rclc_action_send_goal_async(), rclc_action_get_result_async(), rclc_action_cancel_goal(), rclc_action_try_recv_feedback(). Return immediately. Replies are delivered through the callbacks registered with rclc_action_client_set_goal_response_callback(), rclc_action_client_set_feedback_callback(), and rclc_action_client_set_result_callback(). The callbacks fire from rclc_executor_spin_some() — your spin loop is the only place callbacks run.
  • Blocking convenience: rclc_action_send_goal() and rclc_action_get_result(). These call the async variant and then drive rclc_executor_spin_some() internally on a wall-clock budget until the reply lands (or 15 s / 30 s timeout). They take the rclc_executor_t* explicitly because the action client stores its handle as an opaque slot inside the executor (registered via rclc_executor_attach_action_client()). The same applies to rclc_action_client_wait_for_action_server() and rclc_action_client_action_server_is_ready(). Calling any of them from inside a dispatch callback returns RCLC_RET_REENTRANT.
  • Teardown: rcl_action_server_fini() / rcl_action_client_fini() reset only the struct. To take an action off an executor that keeps running, call rclc_executor_remove_action_server() / rclc_executor_remove_action_client() first: they drop the arena entry (the action leaves the graph), free its callback slot and arena bytes for reuse, and bring rclc_executor_get_handle_count() back down. A program that tears the executor down with rclc_executor_fini() needs neither — that drops every entry (issue 1609).
  • The same holds for every other entity the executor registers (issue 1631): rclc_executor_remove_subscription(), rclc_executor_remove_timer(), rclc_executor_remove_service() and rclc_executor_remove_client() — rclc’s rclc_executor_remove_* shape — each drop the arena entry (receive ring included), free the slot and bring handle_count back down. Call one before the entity’s fini when the executor keeps running; a timer or client that is fini’d without it is still dispatched into. A removed client returns to INITIALIZED and can be re-added. All return RCLC_RET_NOT_FOUND for an entity not registered on that executor and RCLC_RET_REENTRANT from inside a callback. A subscription whose callback also receives the sample’s attachment is registered with rclc_executor_add_subscription_with_info() into a rcl_subscription_t and removed the same way (issue 1668); the older direct-arg rclc_executor_add_subscription_raw_with_info() takes no subscription object, so its entry lives until rclc_executor_fini().

The equivalent service client helper rclc_client_call() does not take an explicit executor — the client stashes the executor pointer during rclc_executor_attach_client() and recovers it internally — but it follows the same async-then-spin contract.

Canonical pattern: declare the executor, register the client, then either call the blocking helper or drive rclc_executor_spin_some() yourself between async calls until your callback flags the result. See the example layouts in examples/linux/c/action-client/ and examples/mps2-an385-freertos/c/action-client/.

Phase 122.3 added a separate L1 polling family (rclc_polling_action_client_init + the _raw send/recv siblings) that stores ActionClientCore inline in the separate type rclc_polling_action_client_t (phase-487 W4) and skips the executor entirely; see the Doxygen for the full L1 verb list.

CMake integration

Out-of-tree projects link the NanoRos::NanoRos CMake target and generate C bindings with nano_ros_generate_interfaces(<pkg> LANGUAGE C) (the function defaults to LANGUAGE CPP; the full which-spelling table is in Message Generation) — covered in docs/reference/c-api-cmake.md.

Generating locally

doxygen packages/api/nros-c/Doxyfile
xdg-open target/doc/c-api/html/index.html

Internals (porting only)

The rmw-cffi and platform-cffi Doxygen sites are not part of the user API — they document the C vtables for porters writing a custom RMW backend or platform port. See RMW API and Platform API.

C++ API

The C++ user-facing surface lives in packages/api/nros-cpp/include/, at upstream’s paths: rclcpp/, rclcpp_action/ and rclcpp_lifecycle/ (rclcpp/detail/ is internal). Generated by Doxygen; always reflects the current main branch.

API reference

C++ API Doxygen — start here.

The umbrella header rclcpp/rclcpp.hpp pulls in every public surface a user application needs.

Where to start

  • rclcpp/rclcpp.hpp — convenience umbrella include
  • rclcpp::init_in / rclcpp::shutdown — session lifetime
  • rclcpp::Node — node + create_publisher/subscription/service/client/action_*
  • rclcpp::Publisher<M> / Subscription<M>
  • rclcpp::Service<S> / Client<S> — the DISPATCH service server and client. A handler is registered into the executor arena, which owns the entity. Service<S>::SharedPtr is rclcpp::ServiceHandle<S>, a two-word keep-alive with nothing to dereference (phase-456 W5); Client<S>::SharedPtr is rclcpp::ClientHandle<S>, the same two words WITH one verb — async_send_request, which is all a census of the corpus found anyone calling on a dispatch client (phase-456 W9). Neither has an operator->.
  • rclcpp::PollService<S> — L1 polling service server. The caller owns the server and drains it with take_request / send_response from its own spin loop. node.create_service<S>(name) with no handler hands one back.
  • rclcpp::PollClient<S> — L1 future-style service client, the client half of the same split (phase-456 W9). The caller owns the client and drains the reply itself: send_request returns a rclcpp::Future<T>, call blocks while driving the executor, call_polling blocks without driving it, and wait_for_service / service_is_ready probe the server. node.create_client<S>(name) with no handler hands one back.
  • rclcpp_action::Server<A> / Client<A> — L2 callback model (executor-arena registered). rclcpp::ActionServer<A> / rclcpp::ActionClient<A> are the same two types.
  • rclcpp::PollingActionServer<A> / PollingActionClient<A> — L1 polling model. Caller drives take_* / accept_goal / complete_goal from a spin loop.
  • rclcpp::Future<T> — async result handle
  • rclcpp::Executor, Timer, GuardCondition
  • Parameters are methods ON the node — declare_parameter<T> / get_parameter<T> / set_parameter<T> / has_parameter, upstream’s names, forwarding to the one store the rcl_interfaces services read. rclcpp::Seq<T, N> is the array VALUE a freestanding node declares with (std::vector<T> under NROS_CPP_STD). The standalone rclcpp::ParameterServer<Cap> is GONE (phase-426 W4): it was a second store, and what it held was invisible to ros2 param get. See Differences from ROS 2 §9 for what is intentionally smaller than rclcpp.

The library is freestanding C++14 — no STL, no exceptions. A second, std-flavoured surface exists for porting upstream rclcpp code and is reached by defining NROS_CPP_STD; see Two surfaces below for whether you need it.

Two-layer API. The rclcpp::ActionServer<A> / ActionClient<A> templates are L2 callback handles (set callbacks via nros_cpp_action_server_set_callbacks, dispatched by the executor). The new rclcpp::PollingActionServer<A> / PollingActionClient<A> are L1 polling templates with the same typed serdes glue but caller-driven control flow. Subscriptions / services / service clients are already L1-shaped in nros-cpp. See Two-Layer API for the per-layer rationale.

Event-driven wake callbacks. PollingActionServer<A> and PollingActionClient<A> expose a nested WakeState POD and typed set_{goal,cancel,get_result,goal_response, cancel_response,result,feedback}_wake_callback(state, cb, ctx) methods. Pair an nros_cpp_wake_state_t with each entity / channel to wake on rx instead of polling.

Two surfaces: freestanding, and NROS_CPP_STD

One set of headers ships two API shapes, and a build picks one.

The freestanding surface is the default and it is not a fallback. It is plain C++14 with no standard library, no exceptions and no RTTI: rclcpp::Node, create_* writing through an out-reference and returning an rclcpp::Result, const char* for names, uint64_t period_ms for durations. Every nano-ros program in this repository is on it — the native/host examples included.

The std surface is an opt-in and it is reached only by defining NROS_CPP_STD. Defining it turns on the six NROS_CPP_HAS_* capability macros in rclcpp/detail/std_detect.hpp, and with them: the std::string / std::function / std::chrono / std::vector convenience overloads in nros/std_compat.hpp, the RCLCPP_*_STREAM logging macros, and — the piece that actually motivates the flag — rclcpp::Node, whose factories return std::shared_ptr and whose name parameters are const std::string&.

Do I need this flag?

The split is ported rclcpp code versus code written for nano-ros. It is not host versus embedded. A native Linux build does not want the flag any more than a Cortex-M3 build does; a ported node wants it on both.

You are…FlagWhy
writing a new node against rclcpp::Node / rclcpp::Publisher<M> / rclcpp::ExecutornoThe freestanding surface is the whole API you are using. This is the common case on every platform, native included.
compiling an upstream ROS 2 .cpp unmodified — it says class MyNode : public rclcpp::Node, std::make_shared<MyNode>(), create_publisher<M>(...) returning a SharedPtryesThose signatures are spelled in std::shared_ptr and std::string. Without the flag they do not exist and you get a compile error naming a missing overload or an unknown rclcpp::Node.
passing a std::string topic name or a std::chrono period into an otherwise nano-ros-native fileyesThose are the std_compat.hpp overloads.
building for Zephyr, FreeRTOS, NuttX, ThreadX, or any -ffreestanding toolchainnoAnd here it is not merely unnecessary: asking for the std surface asks the headers to #include <string>, which on those toolchains is either absent or a hard #error.

If you are unsure, build without it. The failure direction is loud: a missing overload at compile time, never a silent behaviour change.

Turning it on

Define it on the target, not per file:

target_compile_definitions(my_ported_node PRIVATE NROS_CPP_STD=1)

Per-target rather than per-#define-before-include, because the macro changes the layout of rclcpp::Node (it carries hosted-only members and an enable_shared_from_this base), so two translation units of one image disagreeing about it is an ODR/layout break rather than a missing function. That disagreement is reachable in practice — examples/px4/cpp/bridge/ sets the flag on one module of a larger image deliberately — so keep the definition at target granularity and give every TU of a target the same answer.

A ported project usually needs none of this. cmake/NanoRosAmentSurface.cmake sets NROS_CPP_STD=1 on every target its ament_* verbs create, and find_package(rclcpp) sets it on whatever links rclcpp::rclcpp, so a ported package already has the surface it needs; see Porting a ROS 2 C++ node.

Why it is not detected for you

It used to be. Until phase-438 the capability macros were discovered from the include path with __has_include, so a hosted compiler handed you the std-flavoured API whether you asked for it or not. That was removed because no probe answers the question correctly on both embedded lanes:

__STDC_HOSTED____has_include(<string>)#include <string>
arm-none-eabi 13.2, -ffreestanding (FreeRTOS lane)0TRUEhard #error
Zephyr -nostdinc++, minimal libcpp1FALSEabsent
hosted g++ 12.31TRUEworks

Each probe is right on exactly one embedded lane and wrong on the other: libstdc++ 13 added bits/requires_hosted.h, so header presence stopped implying usability. Only the request is right on both, because it is not a probe — it is you saying which surface you want. The full measurement lives in packages/api/nros-cpp/include/rclcpp/detail/std_detect.hpp.

Generating locally

doxygen packages/api/nros-cpp/Doxyfile
xdg-open target/doc/cpp-api/html/index.html

RMW API

The RMW (ROS middleware) vtable is the porting boundary between nano-ros and a concrete pub/sub transport (zenoh-pico, XRCE-DDS, Cyclone DDS, uORB, …). RMW is internal — user applications use the Rust / C / C++ APIs, not the vtable directly.

Canonical reference

The C vtable nros_rmw_vtable_t is the source of truth. Every function pointer’s brief, parameter docs, ownership rules (buffer-borrowed vs caller-owned), blocking / non-blocking classification, return-code conventions, and lifetime contract for loaned slots live in the Doxygen output.

SurfaceLink
rmw-cffi Doxygen (canonical)HTML · header

To regenerate locally:

just doc-rmw-cffi   # produces target/doxygen/rmw-cffi/

This page does not duplicate the interface specification — read the Doxygen for that.

Reference implementations

Concrete backends. Each crate’s README.md walks the implementation; the source is the worked example to copy.

BackendSourceNotes
zenoh-picopackages/rmw/zenoh/nros-rmw-zenohDefault. C transport via zenoh-pico. Native zero-copy publish via z_bytes_from_static_buf.
micro-XRCE-DDS-Clientpackages/rmw/xrce/nros-rmw-xrceC-only shim; agent-based.
Cyclone DDSpackages/rmw/cyclonedds/nros-rmw-cycloneddsC++ shim; standalone CMake project.
PX4 uORBpackages/rmw/uorb/nros-rmw-uorbTyped-trampoline registry over PX4 uORB.

The zenoh-pico shim is the canonical reference port.

Writing a custom backend

Platform API

The platform API is the porting boundary between nano-ros and a concrete OS / RTOS / bare-metal target. Each platform provides a clock, optionally a heap, optionally threading, optionally networking. Platform is internal — user applications use the Rust / C / C++ APIs, not the platform vtable directly.

Canonical reference

The SSoT C headers in packages/platform/nros-platform-api/include/nros/ (platform.h + platform_net.h, platform_timer.h, platform_zephyr.h; RFC-0054 — Rust consumes committed bindgen output from them) are the source of truth. Every symbol’s brief, parameter docs, ownership rules, blocking / non-blocking classification, and ISR-safe contract live in the Doxygen compiled from those headers.

SurfaceLink
platform ABI Doxygen (canonical)HTML · headers

To regenerate locally:

just doc-platform-cffi   # produces target/doxygen/platform-cffi/

This page does not duplicate the interface specification — read the Doxygen for that.

Reference implementations

Each row is a complete worked example. The crate’s README.md walks the implementation; the source is the worked solution to copy.

CrateTargetSource
nros-platform-posixLinux / *BSDpackages/platform/nros-platform-posix
nros-platform-nuttxNuttX RTOSpackages/platform/nros-platform-nuttx
nros-platform-freertosFreeRTOSpackages/platform/nros-platform-freertos
nros-platform-threadxAzure RTOS / ThreadXpackages/platform/nros-platform-threadx
nros-platform-zephyrZephyr RTOSpackages/platform/nros-platform-zephyr
nros-platform-mps2-an385Cortex-M3 (QEMU)packages/platform/nros-platform-mps2-an385
nros-platform-stm32f4STM32F4packages/platform/nros-platform-stm32f4
nros-platform-esp32-qemuESP32-C3 (QEMU)packages/platform/nros-platform-esp32-qemu

The POSIX implementation is the canonical reference port.

Writing a custom platform

Platform Differences

This page covers OS-port differences (clock, allocator, threading, sockets). For per-RMW feature coverage — services, events, QoS — see the Per-RMW Feature Matrix.

Per-platform behaviour comparison. The Doxygen reference defines what each vtable entry must do; this page covers how each shipped platform crate fulfils that contract and which optional capabilities each platform exposes.

At a glance

PlatformClock sourceAllocatorThreadingNetworkingUDP multicastWall-clockNotes
POSIXclock_gettime(CLOCK_MONOTONIC)libc mallocpthreadslibc BSD sockets (Rust)Yesclock_gettime(CLOCK_REALTIME)Canonical reference port.
NuttXPOSIX aliaslibc mallocpthreadszenoh-pico unix/network.c (C)YesPOSIXMost paths inherit POSIX behaviour.
FreeRTOSxTaskGetTickCountpvPortMallocFreeRTOS taskslwIP via freertos-lwip-sys (Rust)—Tick-based, no RTCMulticast gated on lwIP LWIP_IGMP=1 (untested).
Zephyrk_uptime_getk_mallocZephyr POSIX pthreadsZephyr POSIX sockets (Rust)Yes (native_sim via NSOS)clock_gettime(CLOCK_REALTIME)native_sim multicast routes through the NSOS IPPROTO_IP patch.
ThreadXtx_time_gettx_byte_allocateThreadX threadsNetX Duo BSD network.c (C)—tx_time_get fallbackMulticast gated on NetX Duo nx_igmp_* (untested).
Bare-metal (MPS2-AN385)CMSDK Timer0bump allocatorsingle-threadednros-smoltcp (Rust)Yes (smoltcp 0.12 IGMP)monotonic fallbackCortex-M3 QEMU.
Bare-metal (STM32F4)DWT cycle counterbump allocatorsingle-threadednros-smoltcp (Rust)Yesmonotonic fallbackCortex-M4F.
Bare-metal (ESP32-S3)esp_timer_get_timebump allocatorsingle-threadednros-smoltcp (Rust)Yesmonotonic fallbackXtensa LX7 (build-only board crate).
Bare-metal (ESP32-C3 QEMU)esp_timer_get_timebump allocatorsingle-threadednros-smoltcp (Rust)Yesmonotonic fallbackRISC-V.

Canonical interface contract. What “Yes” / “—” / specific functions mean — buffer ownership, blocking allowance, valid value ranges — is in the platform-cffi Doxygen reference. This page is a comparison table, not a spec.

Behaviour notes by trait group

Time (PlatformClock)

All ports surface a monotonic clock. Resolution varies:

  • POSIX / NuttX: nanosecond-resolution clock_gettime, exposed at ms + µs.
  • FreeRTOS: xTaskGetTickCount ticks (typically 1 ms). clock_us = clock_ms × 1000 — the resolution lie is documented.
  • Zephyr: k_uptime_get ms + k_cyc_to_us_floor64(k_cycle_get_64()) µs.
  • ThreadX: tx_time_get ticks (default 100 Hz; bump via TX_TIMER_TICKS_PER_SECOND for finer resolution).
  • Bare-metal (Cortex-M): hardware timer counter (CMSDK Timer0, DWT cycle counter). µs is the native resolution; ms is µs / 1000.
  • Bare-metal (ESP32): esp_timer_get_time returns µs natively.

The clock is monotonic and wraparound-free from init to shutdown. Platforms with 32-bit timers run a software extender on overflow.

Memory (PlatformAlloc)

Only zenoh-pico calls PlatformAlloc::{alloc, realloc, free}. XRCE-DDS does not allocate. Recommended heap budget: 64 KB minimum for zenoh-pico’s working set; bare-metal ports typically allocate 128 KB+ via a bump allocator (linked-list-allocator or embedded-alloc).

The RTOS allocators (pvPortMalloc, tx_byte_allocate, k_malloc, libc malloc) honour their respective heap-region configurations. On bare-metal the heap region is defined in the linker script and consumed at startup by the bump allocator.

Threading (PlatformThreading)

Multi-threaded ports (POSIX, NuttX, FreeRTOS, Zephyr, ThreadX) expose real tasks, mutexes, and condition variables. zenoh-pico’s lease task and read task spawn into these. Single-threaded bare-metal ports stub task_init to return -1 so the spawn fails gracefully; the application drives spin_once from a single context.

Recursive mutexes are required by zenoh-pico. FreeRTOS uses xSemaphoreCreateRecursiveMutex; ThreadX uses tx_mutex_create(..., TX_INHERIT, ...); POSIX uses PTHREAD_MUTEX_RECURSIVE.

Sleep / Random / Wall-time (PlatformSleep, PlatformRandom, PlatformTime)

PlatformSleep on RTOS ports uses the kernel’s tickless sleep (vTaskDelay, tx_thread_sleep, k_msleep, libc usleep). On bare-metal with smoltcp, the sleep helper also runs network_poll() in a busy loop — packets must keep flowing during otherwise-idle waits or the zenoh lease times out.

PlatformRandom is a 32-bit xorshift PRNG seeded with hardware entropy (getrandom, RNG peripheral) or a build-time constant on platforms without entropy. Used for session IDs and protocol nonces; not cryptographic.

PlatformTime returns wall-clock for log timestamps. Bare-metal platforms with no RTC fall back to the monotonic clock — log timestamps then count from boot, not Unix epoch.

Networking (PlatformTcp, PlatformUdp, PlatformSocketHelpers, PlatformUdpMulticast, PlatformNetworkPoll)

The split into four traits maps directly onto zenoh-pico’s unix/network.c interface. Each port wires the four traits independently:

  • C-backed networking (NuttX, ThreadX): the platform shim forwards into zenoh-pico’s bundled unix/network.c (NuttX) or a NetX-Duo equivalent (ThreadX) — the C code talks to BSD-shape sockets directly.
  • Rust-backed networking (POSIX, FreeRTOS, Zephyr, bare-metal): the platform crate implements TCP / UDP in Rust. POSIX wraps libc, FreeRTOS wraps lwIP via freertos-lwip-sys, Zephyr wraps the POSIX layer, bare-metal goes through nros-smoltcp.

PlatformUdpMulticast (RTPS SPDP, zenoh scouting) ships fully wired on POSIX, NuttX, Zephyr, and bare-metal (smoltcp 0.12 IGMP group join). FreeRTOS and ThreadX have no multicast yet — gated by lwIP’s IGMP=1 (untested) and NetX Duo’s nx_igmp_* (untested).

PlatformNetworkPoll is bare-metal only. The implementation advances the smoltcp state machine; without it, smoltcp would only receive when the application explicitly asked. PlatformSleep and the wait_event helper both run network_poll() while waiting so packets continue flowing.

Libc (PlatformLibc)

zenoh-pico uses strlen, memcpy, errno, etc. directly. Bare-metal targets that link picolibc or newlib-nano satisfy these for free; the trait exists for no_std targets without a C runtime, which forward to Rust implementations.

Common pitfalls

For platform-specific gotchas (DMA buffer placement, ephemeral port conflicts, picolibc errno TLS, QEMU clock synchronization, Z_FEATURE_INTEREST mutex exhaustion, etc.), see Platform Porting Pitfalls.

For the design rationale (why these trait groups, why both ms and µs, why split PlatformNetworkPoll), see Platform API Design.

For writing a new port, see Custom Platform. For the canonical interface spec, see the Doxygen reference.

Support Status

What you get, at which version, tested how hard — the analogue of Rust’s platform-support page. Version strings on this page are parsed from nros-sdk-index.toml and rust-toolchain.toml (the pinning SSoTs), so they cannot drift from what nros setup actually installs.

CI tiers — what “supported” means

TierLaneRunsGuarantee
1ci (every change)Host-native builds + native fixtures, all gatesThe logic and the seams are sound. Says nothing about embedded targets.
2ci-matrix (when core/codegen/cmake move)1-wise cover: every platform, language, RMW, and kind appears at least once (~28 % of the coordinate space)Each axis value builds and runs somewhere — but pairings are NOT covered.
2-nightlyci-matrix-nightlyPairwise cover (~70 %)Platform×language and RMW×language interactions surface here, within a day.
3ci-full (pre-release)The whole matrixEverything the tree claims, verified together.

Per-board tier membership is generated on its own page: Board Support Tiers. The hand-maintained procurement view (including boards with no in-tree crate) is Supported Boards.

RTOS / platform source pins

nros setup <board> fetches exactly these (shared store ${NROS_HOME:-~/.nros}/sdk):

ComponentPinned versionUsed by
FreeRTOS kernel10.6.2FreeRTOS boards (QEMU MPS2-AN385, overlays)
lwIP2.2.0FreeRTOS networking
ThreadX6.4.1ThreadX (Linux sim + QEMU RISC-V64)
NetX Duo6.4.xThreadX networking
NuttX kernel12.13.0NuttX (QEMU armv7a / rv32)
Zephyr SDK0.16.8 (+ 1.0.1 for the 4.4 rolling line)Zephyr module builds
mbedTLS3.xzenoh TLS transport

These pins are decisions, not lags — they move only with a reason, tested together (see Integrating into a Vendored Tree).

RMW backends — versions and interop pairing

BackendClient side (pinned)Host sidePairing rule
Zenohzenoh-pico 1.7.2 (vendored submodule)Router = the one ROS ships: ros2 run rmw_zenoh_cpp rmw_zenohd — nano-ros deliberately vendors no router (RFC-0075)zenoh-pico’s wire protocol is stable across zenoh 1.x, so the pinned client interops with a newer ROS-shipped router (issue 0291)
XRCE-DDSMicro-XRCE-DDS client 2.4.3-nros1 + Micro-CDR 2.0.xAgent 2.4.3-nros1 (installed by nros setup … --rmw xrce)Client and agent are pinned to the same upstream release
Cyclone DDSfork 0.10.5-nros1 — upstream 0.10.5 + carried patches (fork delta)Talks RTPS directly to rmw_cyclonedds_cppPinned to the Cyclone ROS ships (ros-humble-cyclonedds 0.10.5) — upstream 11.x is deliberately NOT a rebase target until a distro migration (issue 0507)

Per-feature coverage across the three backends: Per-RMW Feature Matrix.

ROS 2 editions

EditionFeature / flagStatusNotes
Humbleros-humble (default)SupportedTypeHashNotSupported key expressions, XCDR1. No rmw_zenoh_cpp apt package — zenoh interop on Humble needs a source overlay, or use Cyclone DDS.
Ironros-ironSupportedRIHS01 type hashes. Same zenoh-apt caveat as Humble.
Jazzyros-jazzySupportedRIHS01; ships ros-jazzy-rmw-zenoh-cpp — the zenoh-interop reference edition. XCDR2/appendable is tracked separately (phase-303).

Pick the edition of the ROS system you interop with — mismatched editions discover nothing (different key expressions).

Toolchains

ToolPinned version
Ruststable channel (rust-toolchain.toml); pinned cross targets: thumbv7em-none-eabihf, armv7r-none-eabihf, aarch64-unknown-none. rustfmt runs on nightly (nightly-only options).
Arm GCC (arm-none-eabi)13.2-nros1
RISC-V GCC (riscv-none-elf)14.2-nros1
QEMU (patched, Arm)11.0.0-nros2
QEMU (Espressif fork, ESP32-C3)esp-develop-nros1
espflash4.5.0-nros1
Corrosion (CMake↔Cargo)0.6.1-nros1 (≥ 0.6.0 required — older versions share one build dir across workspace roots and break the mixed link)
OpenOCD0.12.0-nros1
sccache0.8.2-nros1

-nros<N> suffixes mark builds re-packaged (or patched) for the SDK store; the base version is the upstream release.

RMW API — every upstream symbol, side by side

What ROS 2’s rmw asks an implementation for, what nano-ros provides, and the reason wherever the two differ. Derived from three sources — the ROS 2 signature extract, the nano-ros ABI headers, and the authored reason map docs/reference/rmw-api-map.toml that just check rmw-api-parity also reads — so a slot changing shape moves this page in the same commit or fails the gate.

For the prose rationale behind the big divergences, see RMW API: Differences from upstream rmw.h.

How to read a row

The signature says which surface it is on. nano-ros answers an upstream symbol in one of two ways, and C syntax already distinguishes them:

rmw_ret_t (*count_publishers)(...)   // vtable slot — a function pointer,
                                     // may differ per backend
rmw_ret_t rmw_compare_gids_equal(...) // global — a plain exported function,
                                     // defined once for the image

Slot names drop the rmw_ prefix because that is genuinely the name in nros_rmw_vtable_t; showing rmw_count_publishers on the right would flatter the comparison.

Each side is spelled the way its own headers spell it. Upstream writes const rmw_publisher_t * publisher, we write const rmw_publisher_t *publisher, and the two columns keep their own convention rather than being normalised to a third that neither project uses. Parameter NAMES are shown on both sides; the comparison behind the highlighting is on TYPES only, because a renamed argument is not an ABI difference.

The chip says what we DID with the symbol, which is a different question from what the signature shows:

chipmeans
● sameidentical signature and name
● re-shapedone slot, one symbol, different signature
◆ re-mappedanswered, but NOT 1:1 — decomposed, merged, or off this seam. The arrows under it name what provides the capability
✕ not-supporteda decision, permanent; the reason names the constraint
○ not-implementeda gap, with the issue tracking it

same and re-shaped are derived from the signatures, so the map cannot assert a match the types deny. The other three are authored, because no signature can say whether an absent symbol was decomposed or dropped — and not-implemented must name an issue, so silence cannot turn a gap into a decision.

Marks show the difference. Red — upstream takes it, we do not. Green — we take it, upstream does not. Yellow — the name differs from the mechanical one (upstream minus rmw_). A row with no marks is identical on both sides and carries no reason, because there is nothing to explain.

Argument names appear on the right only. The ROS 2 side is read from a signature extract that drops parameter names on purpose — a renamed argument is not an ABI difference, and reporting one trains people to skim. Ours are kept because they are the only place a reader learns what an argument means: size_t twice in a row is not a signature anyone can act on. The comparison itself runs on types, so a name never colours a row.

An empty right-hand cell reads rejected when the symbol is deliberately absent, or answered elsewhere when the capability ships outside the RMW seam — in the executor, in codegen, or inside a backend. Both carry the reason.

A dimmed right-hand cell is an inert slot: declared in the vtable and read by nothing. A reserved shape, not a working capability (issue 0800) — and some of them ARE filled by a backend, which is an implementation no caller reaches rather than a capability (phase-428 W8).

What is being compared

Not rmw.h against rmw_vtable.h — that comparison is wrong twice. Upstream declares 177 RMW_PUBLIC functions, but most are utilities rmw itself defines; an implementation links those, so comparing against 177 manufactures ~90 phantom gaps. And our vtable is only the backend seam: plenty of what upstream calls rmw lives one layer up in the executor, one layer down in a backend, or in codegen.

So the contract is empirical — the 88 rmw_* symbols that librmw_fastrtps_cpp.so and librmw_zenoh_cpp.so both define. Two independent implementations with identical symbol sets is a better definition of “what an rmw must provide” than any reading of the headers.

vtableglobalexecutorplatformbuild timeserdesruntimenonetotal
same12113
re-shaped34135
re-mapped164133128
not supported — by decision1212
total622413311288

Read a row for what we did, a column for where it lives. Only not implemented should shrink over time; not supported is the one line that is a decision rather than a state, so it is expected to stay.

Vocabulary — why we say session where upstream says context

An rmw_context_t is two things in one struct: the options the process was initialised with (rmw_init_options_t — domain id, enclave, security, discovery) and an opaque impl the backend allocates to hold its running state. Upstream can fuse them because both are per-process, heap-allocated at rmw_init and freed at rmw_shutdown.

Ours are not one thing. The options half is a build-time POD — that is the recorded reason rmw_init_options_{init,copy,fini} are declined, since “copy” is = and “fini” is nothing — and it lives on nros::Context. The running half is a Session, one per backend, created and destroyed through vtable slots, and an image may hold several at once: the bridge entries run a zenoh ingress and a Cyclone or XRCE egress in one process. There is no per-process singleton to name.

So the rename is unavailable in both directions. Context is already taken by the half that is not the backend state, and calling the backend state context would promise a one-per-process object that a bridge image visibly does not have. The word session is also the one the transports themselves use (a zenoh session, an XRCE-DDS session), which is what a reader of create_session is actually looking at.

Read rmw_context_t on this page as our Context plus one or more Sessions, and the rmw_init/rmw_shutdown rows follow from that.

Every contract symbol

ROS 2nano-rosreason
rmw_ret_t
rmw_borrow_loaned_message(
  const rmw_publisher_t * publisher,
  const rosidl_message_type_support_t * type_support,
  void * * ros_message
)
rmw_ret_t
(*borrow_loaned_message)(
  const rmw_publisher_t *publisher,
  size_t requested_len,
  rmw_mut_byte_span_t *out_slot,
  rmw_loan_token_t **out_token
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_client_request_publisher_get_actual_qos(
  const rmw_client_t * client,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*client_request_publisher_get_actual_qos)(
  const rmw_client_t *client,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_client_response_subscription_get_actual_qos(
  const rmw_client_t * client,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*client_response_subscription_get_actual_qos)(
  const rmw_client_t *client,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_client_set_on_new_response_callback(
  rmw_client_t * client,
  rmw_event_callback_t callback,
  const void * user_data
)
—
✕ not-supported · by decision
as rmw_service_set_on_new_request_callback (issue 0960).
rmw_ret_t
rmw_compare_gids_equal(
  const rmw_gid_t * gid1,
  const rmw_gid_t * gid2,
  bool * result
)
rmw_ret_t
rmw_compare_gids_equal(
  const rmw_gid_t *gid1,
  const rmw_gid_t *gid2,
  bool *result
)
● same
rmw_ret_t
rmw_context_fini(rmw_context_t * context)
rmw_ret_t
(*destroy_session)(rmw_session_t *session)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is destroy_session.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_count_publishers(
  const rmw_node_t * node,
  const char * topic_name,
  size_t * count
)
rmw_ret_t
(*count_publishers)(
  const rmw_session_t *session,
  const char *topic_name,
  size_t *count
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_count_subscribers(
  const rmw_node_t * node,
  const char * topic_name,
  size_t * count
)
rmw_ret_t
(*count_subscribers)(
  const rmw_session_t *session,
  const char *topic_name,
  size_t *count
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_client_t *
rmw_create_client(
  const rmw_node_t * node,
  const rosidl_service_type_support_t * type_support,
  const char * service_name,
  const rmw_qos_profile_t * qos_policies
)
rmw_ret_t
(*create_client)(
  const rmw_node_t *node,
  const rmw_service_type_support_t *type_support,
  const char *service_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  rmw_client_t *out
)
● re-shaped
types are resolved at BUILD time — As the message typesupport.
rmw_guard_condition_t *
rmw_create_guard_condition(rmw_context_t * context)
—
◆ re-mapped · executor
executor: EntryKind::GuardCondition
EntryKind::GuardCondition; no transport variation, and once `wait` is declined there is no backend consumer
rmw_node_t *
rmw_create_node(
  rmw_context_t * context,
  const char * name,
  const char * namespace_
)
rmw_ret_t
(*create_node)(
  rmw_session_t *session,
  const char *name,
  const char *namespace_,
  rmw_node_t *out
)
● re-shaped
the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_publisher_t *
rmw_create_publisher(
  const rmw_node_t * node,
  const rosidl_message_type_support_t * type_support,
  const char * topic_name,
  const rmw_qos_profile_t * qos_profile,
  const rmw_publisher_options_t * publisher_options
)
rmw_ret_t
(*create_publisher)(
  const rmw_node_t *node,
  const rmw_message_type_support_t *type_support,
  const char *topic_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  const rmw_publisher_options_t *options,
  rmw_publisher_t *out
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.
rmw_service_t *
rmw_create_service(
  const rmw_node_t * node,
  const rosidl_service_type_support_t * type_support,
  const char * service_name,
  const rmw_qos_profile_t * qos_profile
)
rmw_ret_t
(*create_service)(
  const rmw_node_t *node,
  const rmw_service_type_support_t *type_support,
  const char *service_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  rmw_service_t *out
)
● re-shaped
types are resolved at BUILD time — As the message typesupport.
rmw_subscription_t *
rmw_create_subscription(
  const rmw_node_t * node,
  const rosidl_message_type_support_t * type_support,
  const char * topic_name,
  const rmw_qos_profile_t * qos_policies,
  const rmw_subscription_options_t * subscription_options
)
rmw_ret_t
(*create_subscription)(
  const rmw_node_t *node,
  const rmw_message_type_support_t *type_support,
  const char *topic_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  const rmw_subscription_options_t *options,
  rmw_subscription_t *out
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.
rmw_wait_set_t *
rmw_create_wait_set(
  rmw_context_t * context,
  size_t max_conditions
)
—
◆ re-mapped · executor
executor: the arena entry table, allocated once
the executor's arena entry table IS the set, allocated once; a per-wait set would be heap on the spin path
rmw_ret_t
rmw_deserialize(
  const rmw_serialized_message_t * serialized_message,
  const rosidl_message_type_support_t * type_support,
  void * ros_message
)
—
◆ re-mapped · 1 → 2
nros-serdes: Deserialize / DeserializeView
codegen packs
as rmw_serialize
rmw_ret_t
rmw_destroy_client(
  rmw_node_t * node,
  rmw_client_t * client
)
rmw_ret_t
(*destroy_client)(rmw_client_t *client)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_guard_condition(rmw_guard_condition_t * guard_condition)
—
◆ re-mapped · executor
executor: EntryKind::GuardCondition
as rmw_create_guard_condition
rmw_ret_t
rmw_destroy_node(rmw_node_t * node)
rmw_ret_t
(*destroy_node)(rmw_node_t *node)
● same
rmw_ret_t
rmw_destroy_publisher(
  rmw_node_t * node,
  rmw_publisher_t * publisher
)
rmw_ret_t
(*destroy_publisher)(rmw_publisher_t *publisher)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_service(
  rmw_node_t * node,
  rmw_service_t * service
)
rmw_ret_t
(*destroy_service)(rmw_service_t *server)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_subscription(
  rmw_node_t * node,
  rmw_subscription_t * subscription
)
rmw_ret_t
(*destroy_subscription)(rmw_subscription_t *subscription)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_wait_set(rmw_wait_set_t * wait_set)
—
◆ re-mapped · executor
executor: the arena entry table, allocated once
as rmw_create_wait_set
rmw_ret_t
rmw_event_set_callback(
  rmw_event_t * event,
  rmw_event_callback_t callback,
  const void * user_data
)
rmw_ret_t
(*publisher_event_init)(
  const rmw_publisher_t *publisher,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is publisher_event_init.

events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.

one wake callback per session — Upstream installs a callback per entity. Ours is `set_wake_callback` on the session: one executor drives several backends, so the wake has to be attributable to a session rather than to whichever entity fired.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
bool
rmw_feature_supported(rmw_feature_t feature)
bool
(*feature_supported)(rmw_feature_t feature)
◆ re-mapped
per-capability slots, answered by nullity or a dedicated probe
inert — declared and READ BY NOTHING, and filled by nothing.
rmw_ret_t
rmw_fini_publisher_allocation(rmw_publisher_allocation_t * allocation)
—
✕ not-supported · by decision
as above
rmw_ret_t
rmw_fini_subscription_allocation(rmw_subscription_allocation_t * allocation)
—
✕ not-supported · by decision
as above
rmw_ret_t
rmw_get_client_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_client_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_gid_for_publisher(
  const rmw_publisher_t * publisher,
  rmw_gid_t * gid
)
rmw_ret_t
(*get_gid_for_publisher)(
  const rmw_publisher_t *publisher,
  rmw_gid_t *gid
)
● same
const char *
rmw_get_implementation_identifier(void)
const char *
(*get_implementation_identifier)(void)
◆ re-mapped · runtime
runtime: the registry name
inert — declared and READ BY NOTHING, and filled by nothing.
rmw_ret_t
rmw_get_node_names(
  const rmw_node_t * node,
  rcutils_string_array_t * node_names,
  rcutils_string_array_t * node_namespaces
)
rmw_ret_t
(*get_node_names)(
  const rmw_session_t *session,
  rmw_node_visitor_t visitor  /* visit(ctx, node_name, node_namespace, enclave) */
)
◆ re-mapped · 2 upstream → 1 slot
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

visitor, not an owning out-param — As `rmw_names_and_types_t` — an owning array upstream, a visitor here.
rmw_ret_t
rmw_get_node_names_with_enclaves(
  const rmw_node_t * node,
  rcutils_string_array_t * node_names,
  rcutils_string_array_t * node_namespaces,
  rcutils_string_array_t * enclaves
)
rmw_ret_t
(*get_node_names)(
  const rmw_session_t *session,
  rmw_node_visitor_t visitor  /* visit(ctx, node_name, node_namespace, enclave) */
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is get_node_names.

the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

visitor, not an owning out-param — As `rmw_names_and_types_t` — an owning array upstream, a visitor here.
rmw_ret_t
rmw_get_publisher_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_publisher_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_publishers_info_by_topic(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_array_t * publishers_info
)
rmw_ret_t
(*get_publishers_info_by_topic)(
  const rmw_session_t *session,
  const char *topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_visitor_t visitor  /* visit(ctx, info) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — `rmw_topic_endpoint_info_array_t` and `rmw_network_flow_endpoint_array_t` are heap-owning arrays with their own `fini`. Visited one entry at a time instead.
const char *
rmw_get_serialization_format(void)
const char *
(*get_serialization_format)(void)
◆ re-mapped
vtable: get_serialization_format, per session
rmw_ret_t
rmw_get_serialized_message_size(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  size_t * size
)
—
◆ re-mapped · serdes
nros-serdes: size.rs (size_bound / max_serialized_size)
nros-serdes `size.rs` — `size_bound` / `max_serialized_size` / `buffer_fits` / `serialized_size`, per TYPE and `const` where the type is bounded (phase-380 W1, issue 0776, resolved). Not a vtable slot and never will be: upstream takes a `rosidl_message_type_support_t *`, declined ABI-wide since W3.c, so the symbol cannot cross this seam — but the CAPABILITY it provides is answered, which is what `layer` records. This read `gap` until 2026-08-27, after 0776 had closed
rmw_ret_t
rmw_get_service_names_and_types(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_service_names_and_types)(
  const rmw_session_t *session,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_service_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_service_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_subscriber_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_subscriber_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_subscriptions_info_by_topic(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_array_t * subscriptions_info
)
rmw_ret_t
(*get_subscriptions_info_by_topic)(
  const rmw_session_t *session,
  const char *topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_visitor_t visitor  /* visit(ctx, info) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — `rmw_topic_endpoint_info_array_t` and `rmw_network_flow_endpoint_array_t` are heap-owning arrays with their own `fini`. Visited one entry at a time instead.
rmw_ret_t
rmw_get_topic_names_and_types(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_topic_names_and_types)(
  const rmw_session_t *session,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_init(
  const rmw_init_options_t * options,
  rmw_context_t * context
)
rmw_ret_t
(*create_session)(
  const char *locator,
  uint8_t mode,
  uint32_t domain_id,
  const char *node_name,
  const rmw_session_options_t *options,
  rmw_session_t *out
)
● re-shaped
renamed — the slot is create_session.

build-time POD, no init/copy/fini — Upstream's options own heap and carry an allocator, which is why they need the init/copy/fini trio. Ours are a build-time POD passed by value.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_init_options_copy(
  const rmw_init_options_t * src,
  rmw_init_options_t * dst
)
—
◆ re-mapped · build time
build time: `copy` is `=`
as rmw_init_options_init — "copy" is `=`
rmw_ret_t
rmw_init_options_fini(rmw_init_options_t * init_options)
—
◆ re-mapped · build time
build time: `fini` is nothing
as rmw_init_options_init — "fini" is nothing
rmw_ret_t
rmw_init_options_init(
  rmw_init_options_t * init_options,
  rcutils_allocator_t allocator
)
—
◆ re-mapped · build time
build time: rmw_init_options_t is a POD
upstream needs the init/copy/fini trio because its options OWN heap and carry an rcutils_allocator_t, which cannot cross this seam; ours is a build-time POD. Does NOT decide what the options CARRY: `localhost_only` and `enclave` are gaps (issue 0785, shape deferred to 0331), `security_options` is declined on the target (a DDS-SROS2 keystore path, no filesystem and no security plugin there), and `discovery_options` is an IRON field this Humble contract does not have
rmw_ret_t
rmw_init_publisher_allocation(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  rmw_publisher_allocation_t * allocation
)
—
✕ not-supported · by decision
upstream's first two parameters are a `rosidl_message_type_support_t *` and a `rosidl_runtime_c__Sequence__bound *`. The sequence bound is declined ABI-wide (it is a rosidl runtime type), and the third parameter is an `rmw_publisher_allocation_t`, whose body is `{const char *implementation_identifier; void *data;}` — an opaque handle whose contents only the implementation that made it can read. Nothing crosses this seam usefully. THREE earlier reasons here were wrong, and the corrections are the record worth keeping: 'pools are baked' (false for four backends of five — issue 0777, now RESOLVED with the finding that every deviation reason built on that clause was false); 'upstream pre-sizes an rcutils_allocator_t the caller owns' (false — verified against Humble's rmw/types.h); and, until phase-406, a tail deferring the capability question to 0777 as though it were still open. It is not: 0777 closed, and the pre-sizing CAPABILITY for cyclonedds is a separate question that needs its own issue if anyone wants it, not a citation here.
rmw_ret_t
rmw_init_subscription_allocation(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  rmw_subscription_allocation_t * allocation
)
—
✕ not-supported · by decision
as above
const rmw_guard_condition_t *
rmw_node_get_graph_guard_condition(const rmw_node_t * node)
rmw_ret_t
(*node_get_graph_guard_condition)(
  rmw_session_t *session,
  rmw_event_callback_t callback,
  const void *user_data
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_publish(
  const rmw_publisher_t * publisher,
  const void * ros_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish)(
  const rmw_publisher_t *publisher,
  rmw_byte_span_t payload
)
◆ re-mapped · 2 upstream → 1 slot
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publish_loaned_message(
  const rmw_publisher_t * publisher,
  void * ros_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish_loaned_message)(
  const rmw_publisher_t *publisher,
  rmw_loan_token_t *token,
  size_t actual_len
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publish_serialized_message(
  const rmw_publisher_t * publisher,
  const rmw_serialized_message_t * serialized_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish)(
  const rmw_publisher_t *publisher,
  rmw_byte_span_t payload
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is publish.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publisher_assert_liveliness(const rmw_publisher_t * publisher)
rmw_ret_t
(*publisher_assert_liveliness)(const rmw_publisher_t *publisher)
● same
rmw_ret_t
rmw_publisher_count_matched_subscriptions(
  const rmw_publisher_t * publisher,
  size_t * subscription_count
)
rmw_ret_t
(*publisher_count_matched_subscriptions)(
  const rmw_publisher_t *publisher,
  size_t *subscription_count
)
● same
rmw_ret_t
rmw_publisher_event_init(
  rmw_event_t * rmw_event,
  const rmw_publisher_t * publisher,
  rmw_event_type_t event_type
)
rmw_ret_t
(*publisher_event_init)(
  const rmw_publisher_t *publisher,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
◆ re-mapped · 2 upstream → 1 slot
events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.
rmw_ret_t
rmw_publisher_get_actual_qos(
  const rmw_publisher_t * publisher,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*publisher_get_actual_qos)(
  const rmw_publisher_t *publisher,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_publisher_get_network_flow_endpoints(
  const rmw_publisher_t * publisher,
  rcutils_allocator_t * allocator,
  rmw_network_flow_endpoint_array_t * network_flow_endpoint_array
)
—
✕ not-supported · by decision
DECLINED, phase-407: reporting the transport's 5-tuples is diagnostics, and the value is in a multi-homed deployment with dynamic discovery — the case least like this ABI's target, where one interface and a static config mean the answer is already in the file you wrote. Landed in phase-376 W5 for shape parity, never filled, never read (issue 0956). Cheap to revisit for Cyclone alone if `ros2` tooling ever needs to introspect a nano-ros node: it is read-only and has no wire effect.
rmw_ret_t
rmw_publisher_wait_for_all_acked(
  const rmw_publisher_t * publisher,
  rmw_time_t wait_timeout
)
rmw_ret_t
(*publisher_wait_for_all_acked)(
  const rmw_publisher_t *publisher,
  uint32_t timeout_ms
)
✕ not-supported · by decision
inert — declared and READ BY NOTHING, and filled by nothing.

milliseconds, not a struct — `rmw_time_t` is a `{sec, nsec}` pair. This ABI carries `uint32_t` milliseconds throughout — the resolution every supported platform clock actually offers, and the unit the deadline arithmetic already uses.
rmw_ret_t
rmw_qos_profile_check_compatible(
  const rmw_qos_profile_t publisher_profile,
  const rmw_qos_profile_t subscription_profile,
  rmw_qos_compatibility_type_t * compatibility,
  char * reason,
  size_t reason_size
)
rmw_ret_t
rmw_qos_profile_check_compatible(
  rmw_qos_profile_t publisher_profile,
  rmw_qos_profile_t subscription_profile,
  rmw_qos_compatibility_type_t *compatibility,
  char *reason,
  size_t reason_size
)
● re-shaped
by pointer, not by value — Upstream passes two `rmw_qos_profile_t` BY VALUE. The struct is ~80 bytes, and a by-value copy of it crosses the seam twice on every compatibility check — on a target where the call may run from a 512-byte task stack. Ours takes `const rmw_qos_profile_t *`, which is the same information without the copies.
rmw_ret_t
rmw_return_loaned_message_from_publisher(
  const rmw_publisher_t * publisher,
  void * loaned_message
)
rmw_ret_t
(*return_loaned_message_from_publisher)(
  const rmw_publisher_t *publisher,
  rmw_loan_token_t *token
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_return_loaned_message_from_subscription(
  const rmw_subscription_t * subscription,
  void * loaned_message
)
rmw_ret_t
(*return_loaned_message_from_subscription)(
  const rmw_subscription_t *subscription,
  rmw_loan_token_t *token
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_send_request(
  const rmw_client_t * client,
  const void * ros_request,
  int64_t * sequence_id
)
rmw_ret_t
(*send_request)(
  const rmw_client_t *client,
  rmw_byte_span_t request,
  int64_t *sequence_id
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_send_response(
  const rmw_service_t * service,
  rmw_request_id_t * request_header,
  void * ros_response
)
rmw_ret_t
(*send_response)(
  const rmw_service_t *server,
  int64_t seq,
  rmw_byte_span_t response
)
● re-shaped
visitor, not an owning out-param — As `rmw_service_info_t` — the sequence number and writer GUID travel in the borrowed view rather than a separate out-param.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_serialize(
  const void * ros_message,
  const rosidl_message_type_support_t * type_support,
  rmw_serialized_message_t * serialized_message
)
—
◆ re-mapped · 1 → 2
nros-serdes: Serialize
codegen packs
nros-serdes (`Serialize`/`Deserialize`/`DeserializeView`) plus the per-language codegen packs; CDR for an IDL type is fixed by ROS interop, so a per-backend answer would be a DEFECT. Not a slot for the same reason it is not per-backend, and because upstream's parameters are two things this ABI declined anyway — a typesupport pointer and an `rmw_serialized_message_t`, which is an `rcutils_uint8_array_t` carrying an ALLOCATOR, at a seam with no allocator
rmw_ret_t
rmw_service_request_subscription_get_actual_qos(
  const rmw_service_t * service,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*service_request_subscription_get_actual_qos)(
  const rmw_service_t *service,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_service_response_publisher_get_actual_qos(
  const rmw_service_t * service,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*service_response_publisher_get_actual_qos)(
  const rmw_service_t *service,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_service_server_is_available(
  const rmw_node_t * node,
  const rmw_client_t * client,
  bool * is_available
)
rmw_ret_t
(*service_server_is_available)(
  const rmw_client_t *client,
  bool *out_available
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_service_set_on_new_request_callback(
  rmw_service_t * service,
  rmw_event_callback_t callback,
  const void * user_data
)
—
✕ not-supported · by decision
DECLINED, phase-407: readiness belongs to the EXECUTOR here, not to the backend. `set_wake_callback` says "something on this session moved" and `spin_once` then walks its entry table calling `has_data` per entity — one vtable call each, on images whose entity count is single digits. Upstream's per-entity callback carries `number_of_events`, so honouring it means every backend tracking per-entity pending DEPTH: new state on a target where static RAM is already the pressure (issue 0827 measures a talker reserving 275 KB), bought for a scan nobody has measured as costly and a consumer that does not exist. If rclcpp `EventsExecutor` compatibility is ever wanted, that is a phase with a stated goal, not three slots kept warm (issue 0960).
rmw_ret_t
rmw_set_log_severity(rmw_log_severity_t severity)
rmw_ret_t
(*set_log_severity)(rmw_log_severity_t severity)
● same
rmw_ret_t
rmw_shutdown(rmw_context_t * context)
rmw_ret_t
(*destroy_session)(rmw_session_t *session)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is destroy_session.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_subscription_count_matched_publishers(
  const rmw_subscription_t * subscription,
  size_t * publisher_count
)
rmw_ret_t
(*subscription_count_matched_publishers)(
  const rmw_subscription_t *subscription,
  size_t *publisher_count
)
● same
rmw_ret_t
rmw_subscription_event_init(
  rmw_event_t * rmw_event,
  const rmw_subscription_t * subscription,
  rmw_event_type_t event_type
)
rmw_ret_t
(*subscription_event_init)(
  const rmw_subscription_t *subscription,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
● re-shaped
events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.
rmw_ret_t
rmw_subscription_get_actual_qos(
  const rmw_subscription_t * subscription,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*subscription_get_actual_qos)(
  const rmw_subscription_t *subscription,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_subscription_get_content_filter(
  const rmw_subscription_t * subscription,
  rcutils_allocator_t * allocator,
  rmw_subscription_content_filter_options_t * options
)
—
✕ not-supported · by decision
as rmw_subscription_set_content_filter — declined with it, since a getter for a filter nothing can set is not a capability (issue 0956).
rmw_ret_t
rmw_subscription_get_network_flow_endpoints(
  const rmw_subscription_t * subscription,
  rcutils_allocator_t * allocator,
  rmw_network_flow_endpoint_array_t * network_flow_endpoint_array
)
—
✕ not-supported · by decision
as rmw_publisher_get_network_flow_endpoints (issue 0956).
rmw_ret_t
rmw_subscription_set_content_filter(
  rmw_subscription_t * subscription,
  const rmw_subscription_content_filter_options_t * options
)
—
✕ not-supported · by decision
DECLINED, phase-407: network-side content filtering needs a DDS feature our pinned Cyclone does not have. 0.10.5 exposes `dds_set_topic_filter{,_and_arg,_extended}` — a LOCAL sample callback applied after the message arrives — and not `dds_create_topic_filtered`, which is the one that propagates the predicate to the writer. Shipping the local filter under this name would promise saved BANDWIDTH and deliver saved CPU, which is the class of deviation reason W4 spent its time undoing. The slot existed from phase-376 W5 to 2026-08-31 and no backend ever filled it (issue 0956).
rmw_ret_t
rmw_subscription_set_on_new_message_callback(
  rmw_subscription_t * subscription,
  rmw_event_callback_t callback,
  const void * user_data
)
—
✕ not-supported · by decision
as rmw_service_set_on_new_request_callback — and note the header had argued, correctly, that `set_wake_callback` does NOT cover this trio: it is session-scoped and serves subscriptions, services and clients identically. That is why these are DECLINED rather than re-mapped: nothing else answers them, and we have decided not to (issue 0960).
rmw_ret_t
rmw_take(
  const rmw_subscription_t * subscription,
  void * ros_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *out,
  bool *taken
)
◆ re-mapped · 2 upstream → 1 slot
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_event(
  const rmw_event_t * event_handle,
  void * event_info,
  bool * taken
)
rmw_ret_t
(*subscription_take_event)(
  const rmw_subscription_t *subscription,
  rmw_event_type_t kind,
  rmw_event_payload_t *out,
  bool *taken
)
● re-shaped
renamed — the slot is subscription_take_event.

events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_loaned_message(
  const rmw_subscription_t * subscription,
  void * * loaned_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_loaned_message)(
  const rmw_subscription_t *subscription,
  rmw_byte_span_t *out_view,
  rmw_loan_token_t **out_token,
  bool *taken
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_loaned_message_with_info(
  const rmw_subscription_t * subscription,
  void * * loaned_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_loaned_message_with_info)(
  const rmw_subscription_t *subscription,
  rmw_byte_span_t *out_view,
  rmw_loan_token_t **out_token,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 1 → 2
take_loaned_message
the attachment on the message already taken
inert — declared and READ BY NOTHING, and filled by nothing.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_request(
  const rmw_service_t * service,
  rmw_service_info_t * request_header,
  void * ros_request,
  bool * taken
)
rmw_ret_t
(*take_request)(
  const rmw_service_t *server,
  rmw_mut_byte_span_t *request,
  int64_t *seq_out,
  bool *taken
)
● re-shaped
visitor, not an owning out-param — The request/response metadata upstream writes into a caller-owned struct arrives here through the same borrowed-view discipline as the graph family.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_response(
  const rmw_client_t * client,
  rmw_service_info_t * request_header,
  void * ros_response,
  bool * taken
)
rmw_ret_t
(*take_response)(
  const rmw_client_t *client,
  rmw_mut_byte_span_t *reply,
  int64_t *seq_out,
  bool *taken
)
● re-shaped
visitor, not an owning out-param — The request/response metadata upstream writes into a caller-owned struct arrives here through the same borrowed-view discipline as the graph family.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_sequence(
  const rmw_subscription_t * subscription,
  size_t count,
  rmw_message_sequence_t * message_sequence,
  rmw_message_info_sequence_t * message_info_sequence,
  size_t * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_sequence)(
  const rmw_subscription_t *subscription,
  uint8_t *buf,
  size_t per_msg_cap,
  size_t max_msgs,
  size_t *out_lens,
  size_t *taken
)
● re-shaped
visitor, not an owning out-param — `rmw_take_sequence`'s message and info sequences are caller-owned arrays sized by an allocator. The bounded form is a repeated take, which is what the executor does.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_serialized_message(
  const rmw_subscription_t * subscription,
  rmw_serialized_message_t * serialized_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *out,
  bool *taken
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is take.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_serialized_message_with_info(
  const rmw_subscription_t * subscription,
  rmw_serialized_message_t * serialized_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_with_info)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *message,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 2 upstream → 1 slot
take
the attachment on the message already taken
renamed — the slot is take_with_info.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_with_info(
  const rmw_subscription_t * subscription,
  void * ros_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_with_info)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *message,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 2 upstream → 1 slot
take
the attachment on the message already taken
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_trigger_guard_condition(const rmw_guard_condition_t * guard_condition)
—
◆ re-mapped · platform
platform: the wake primitive, via GuardCondition::trigger
GuardCondition::trigger -> the platform wake primitive; ISR-safety is a platform-ABI guarantee no backend makes
rmw_ret_t
rmw_wait(
  rmw_subscriptions_t * subscriptions,
  rmw_guard_conditions_t * guard_conditions,
  rmw_services_t * services,
  rmw_clients_t * clients,
  rmw_events_t * events,
  rmw_wait_set_t * wait_set,
  const rmw_time_t * wait_timeout
)
—
◆ re-mapped · 1 → 5
has_data
has_request
drive_io
set_wake_callback
next_deadline_ms
has_data/has_request + drive_io + set_wake_callback + next_deadline_ms ARE this, decomposed. A vtable `wait` would add only the BLOCK, moved from the platform into a backend that can only block on its own handles — while one executor drives sessions from several backends, timers fire off the platform clock, and guard conditions fire from an ISR

Reproduce

commandasks
just check rmw-api-parityis every contract symbol classified
just check rmw-abi-shapedoes the vtable mirror it — name, args, return
just check rmw-slot-producerswhich slots anything actually writes or reads
python3 scripts/rmw-api-parity.py --contractre-derive the contract from an installed impl

Per-RMW Feature Matrix

One table per question the capability pages used to answer in contradictory prose. Derived from the backend sources — the two C vtables (vtable.cpp, vtable.c) and the zenoh shim’s trait overrides — so a backend gaining or losing a slot moves this page in the same commit or fails the gate.

wired = the backend implements it. — = not wired: the runtime surfaces UNSUPPORTED or falls back where a fallback exists (the vtable comments name which). The rows below the parity marker are the phase-376 W4 surface — slots declared in the ABI whose backend wiring is the in-flight campaign; all-dash rows there mean declared, not yet wired anywhere, and they flip automatically as backends land implementations.

Session / entity capabilities

CapabilityZenohXRCE-DDSCyclone DDS
Publish / subscribewiredwiredwired
Services (server side)wiredwiredwired
Service clientswiredwiredwired
Server-availability probewired—wired
Status events (deadline / liveliness / lost)wired——
Manual liveliness assertwired—wired
Event-driven wake (set_wake_callback)wired—wired
Deadline hint (next_deadline_ms)wired——
Zero-copy loan API———
Batch receive (take_sequence)wired—wired
Streamed publishwiredwired—
Connectivity pingwiredwired—
Identity / feature probe———
Publisher GID / matched counts——wired
Actual-QoS read-backwired—wired
Wait-for-acked———
Take-with-infowired—wired
Graph introspection (names/types/counts)wired—wired

Node-layer features

These live in nros-node on top of pub/sub + services — they are not backend slots, so the rule, not a vtable, decides the row:

FeatureZenohXRCE-DDSCyclone DDSRule
ActionsyesyesuntestedBuilt in nros-node on pub/sub + services. Cyclone has both since service.cpp, but no action example runs on it in CI — see known limitations.
Parameters (+ param services)yesyesyesNode-layer services (RFC-0004); available wherever services are.
Lifecycle (REP-2002)yesyesyesNode-layer state machine + services (lifecycle-services feature).

QoS policies

A backend advertises the policies it can enforce via supported_qos_policies(); requesting an unadvertised policy fails entity creation loudly (INCOMPATIBLE_QOS) — no silent downgrade. Per-policy semantics: RMW vs upstream §7.

PolicyZenohXRCE-DDS / Cyclone DDS (via C ABI) ¹
CORE✓—
DURABILITY_TRANSIENT_LOCAL✓—
DEADLINE✓—
LIFESPAN✓—
LIVELINESS_AUTOMATIC✓—
LIVELINESS_MANUAL_BY_TOPIC✓—
LIVELINESS_MANUAL_BY_NODE——
LIVELINESS_LEASE✓—
AVOID_ROS_NAMESPACE_CONVENTIONS——

¹ The C-ABI backends’ mask is asserted by the runtime shim (packages/rmw/cffi/src/lib.rs), not reported by the backend — the vtable has no supported_qos_policies slot yet (TODO 115.K.2.x). Treat the column as the runtime’s assumption; the backend’s own enforcement happens at entity creation.

Scheduling Wiring Matrix

Which scheduling capabilities nano-ros delivers per platform, and which portable executor classes are live in dispatch — derived from the two artifacts the runtime executes, not from prose. The per-platform rows are parsed from sched_caps_for() (nros-orchestration-ir), the exact function the workload realizer calls to decide Native-vs-Degrade for a deployment; its rule, quoted from the source: caps describe what nano-ros DELIVERS, not what the OS could do (issue 0261).

Kernel capabilities per platform

PlatformKernel EDFReservation / sporadic serverPreemption thresholdSMP affinityPriority levelsLow number = high priority
Linux host———✓99—
Zephyr✓——✓32✓
FreeRTOS———✓16—
ThreadX——✓✓32✓
NuttX—✓—✓255—
Bare-metal (RTIC / NVIC)————8—

Notes carried from the source arms: the Linux row is deliberately conservative — no SCHED_DEADLINE consumer exists, so edf is false even though Linux could; its affinity is sched_setaffinity, which is Linux and not POSIX (absent from libc’s apple module), so the row’s reach is linux while the platform layer beneath it stays posix; Zephyr’s EDF is CONFIG_SCHED_DEADLINE; NuttX’s reservation is POSIX SCHED_SPORADIC; ThreadX’s preemption threshold is native; the bare-metal row is NVIC hardware priorities with SRP ceilings (RTIC), single core. These are PLATFORM DEFAULTS and there is no per-image override today: [deploy.<board>] edf = <bool> / cores = <n> was documented here but never authorable (and [deploy.*] itself is retired, phase-486 W8) — the knob read an extras bag no table wrote, and upstream’s deny_unknown_fields rejected the keys outright. Retired with issue 0951; issue 0259 wants the override to exist and needs an authoring path first.

Portable executor classes (SchedClass)

These run inside the executor on every platform — they need no kernel support, which is what makes them portable; where the kernel HAS the native capability (table above), the realizer places the workload natively instead. “Dispatch sites” counts SchedClass::<X> references in the executor’s spin path — a class with zero sites is a type, not a behavior.

ClassDispatch sites in spin.rsStatus
Fifo2Live — the default class; bucketed FIFO ready-set.
Edf1Live — earliest-deadline-first ready-set ordering.
Sporadic5Live — budget/period enforcement in dispatch.

Supported Boards

Procurement-grade compatibility matrix. Each row lists a real vendor + board model and reports nano-ros’s status on it. Rows marked Tested boot in a CI lane — note this maps to the tier registry’s tier 1 (in just ci) or tier 2 (nightly/matrix lanes only): MPS2-AN385 and the RISC-V64 ThreadX virt machine are tier 2, so “Tested” there means the nightly matrix, not every push. Ready rows compile and run but have no in-CI gate yet; Build-only rows compile but cannot be booted by any CI lane (hardware or a license-gated model is required); Untested rows compile per the architecture support but no one has reported booting nano-ros on them. Per-crate tier truth is the generated Board Support Tiers.

For in-tree board packages, see the generated Board Support Tiers — it is checked against the test matrix, the fixture rows and the nightly lanes by just check board-tiers. This page stays hand-maintained because it also covers parts that have never had a crate in this tree (Nordic, NXP, TI…), which the registry cannot express.

It has drifted before — the ARM FVP row claimed Tested while it runs in no CI lane at all.

VendorBoardMCU / SoCArchDefault RTOSStatusExample / board crate
ARMMPS2-AN385 (QEMU)Cortex-M3Armv7-MFreeRTOS / bareTestedexamples/mps2-an385-freertos/, examples/mps2-an385-baremetal/
STMicroSTM32F4-DiscoverySTM32F407Cortex-M4FbareOut of treeWorked example — no in-CI lane boots it
STMicroSTM32H7-NucleoSTM32H743Cortex-M7FFreeRTOS / ZephyrReadyUse FreeRTOS / Zephyr starter with nros-board-freertos overlay
STMicroPixhawk 4 (FMUv5)STM32F765Cortex-M7FNuttX (PX4)Readyintegrations/px4/module-template/
STMicroPixhawk 6X / 6CSTM32H753Cortex-M7FNuttX (PX4)Readyintegrations/px4/module-template/
NordicnRF52840-DKCortex-M4FArmv7E-MZephyrUntestedZephyr starter — supply -b nrf52840dk_nrf52840
NordicnRF5340-DKCortex-M33 (dual)Armv8-MZephyrUntestedZephyr starter — supply -b nrf5340dk_nrf5340_cpuapp
EspressifESP32-C3 (QEMU)RISC-V (RV32IMC)RISC-Vbare (esp-hal)DormantCode kept in examples/esp32-c3-baremetal/rust/; nothing builds or tests it (issue 1525)
EspressifESP32-C3-DevKitRISC-V (RV32IMC)RISC-VESP-IDFRetiredThe in-tree component shell (integrations/nano-ros/) was deleted in phase-468 W2 after being measured on 2026-09-25: no workflow built it and no fixture row named it. The bare-metal esp-hal path that replaced it is itself dormant now — see the QEMU row above
EspressifESP32-S3-DevKitXtensa LX7Xtensa—Not supported (no board crate)Xtensa is not a supported target today — see ESP32 chapter
EspressifESP32-C6-DevKitRISC-VRISC-Vbare (esp-hal)UntestedSame bare-metal path as the C3; no board crate ships for it
NXPLPC55S69-EVKCortex-M33Armv8-MZephyrUntestedZephyr -b lpcxpresso55s69_cpu0
NXPMIMXRT1170-EVKCortex-M7 + M4Armv7-MFreeRTOS / ZephyrUntestedFreeRTOS starter + vendor BSP
TILP-CC1352P7Cortex-M4FArmv7E-MFreeRTOS / TI-RTOSUntestedFreeRTOS starter + TI driver overlay
RP2040Raspberry Pi PicoCortex-M0+Armv6-Mbare / FreeRTOSUntestedBare-metal Cortex-M3 path — Cortex-M0+ has only 4 NVIC priority levels (per-callback OS-priority dispatch is disqualified — pub/sub still works fine)
QEMUvirt RISC-V64rv64gcRISC-VThreadXTestedexamples/rv-virt-threadx/
QEMUCortex-A9 (Versatile)Cortex-A9Armv7-AZephyrTestedZephyr -b qemu_cortex_a9
QEMUarm virtCortex-A7Armv7-ANuttXTestedexamples/qemu-armv7a-nuttx/
QEMUrv-virt RISC-V32rv32imacRISC-VNuttXTestedjust nuttx build-riscv-c / build-riscv-rust (nros setup rv-virt-nuttx)
NVIDIAJetson Orin SPECortex-R5Armv7-RFreeRTOSNot supported (no board crate)Board crate removed (phase-337); FreeRTOS starter + vendor BSP is the path back
Arm FVPBase_RevC AEMv8R (SMP)AEMv8-R, AArch64 profileArmv8-RZephyr 3.7Build-onlySee ARM FVP getting-started chapter; just zephyr build-fvp-ws-entry / build-fvp-board-import + their run- siblings. Runtime is maintainer-run (just zephyr verify-fvp-runtime). The model is NOT license-gated — nros setup --tool arm-fvp fetches it from a pinned Arm CDN permalink, x86_64 Linux only; what keeps it out of CI is cost, not permission
Linux host(sim)x86-64 / aarch64x86 / ArmThreadX simTestedexamples/threadx-linux/
Linux host(native)x86-64 / aarch64x86 / ArmPOSIXTestedexamples/linux/

How to add a new board

  1. Pick the matching RTOS path. Cortex-M3 / M4 / M7 + RTOS → use FreeRTOS or Zephyr starter. Cortex-M0+ → bare-metal starter (limited; no NVIC priority headroom). Cortex-A / RISC-V64 → NuttX or Zephyr. RISC-V32 + Wi-Fi → esp-hal (bare-metal); Xtensa has no supported path here.
  2. Find or write a board crate. Existing crates under packages/boards/nros-board-*/ cover most QEMU + reference dev kits. Real-hardware boards need a thin board crate that supplies startup, linker script, and BoardIdle::wfi() (bare-metal) or wraps the RTOS’s BSP (FreeRTOS / Zephyr).
  3. Run the existing example tree. Each row above points at the canonical example dir. Cross-compile the talker / listener and verify against stock ROS 2 + RMW_IMPLEMENTATION=rmw_zenoh_cpp.
  4. Report back. Open an issue with the working build + flash + run commands so the row moves from Untested / Ready to Tested.

Caveats by chip family

  • Cortex-M0+ (RP2040, STM32F0, nRF51): only 4 NVIC priority levels. Per-callback OS-priority dispatch (a research scheduler shape originally proposed by Choi et al. as PiCAS, RTAS ’21) is disqualified on this class; nano-ros’s user-space EDF / FIFO scheduler is the only option. Pub/sub works fine.
  • Xtensa ESP32 / ESP32-S2 / ESP32-S3: needs the esp-rs fork of rustc (rustup target add does not cover Xtensa; install via https://github.com/esp-rs/rust-build).
  • Cortex-A9 / A53: hosted-RTOS only (NuttX, Zephyr). Heap + libc required; bare-metal Cortex-A is not in the coverage matrix.
  • PX4 boards: NuttX is the underlying kernel; the PX4 module template is the canonical entry path — see PX4 Autopilot.

See also

Board Support Tiers

What nano-ros promises for each in-tree board package, and why.

Every tier here is checked against evidence by just check board-tiers: a board cannot claim tier 1 or 2 without runtime cells in the test matrix, cannot claim tier 3 while holding runtime cells, and cannot be omitted from this table at all. That gate exists because the previous hand-written table drifted into claiming a license-gated target was CI-tested.

For procurement guidance on parts that have no crate in this tree (Nordic, NXP, TI and friends), see Supported Boards. That table is hand-maintained and covers hardware this one cannot describe; this table is generated and covers what is actually in the repo.

Tier 1 — supported

just ci exercises it, it has asserted runtime coverage, and a nightly lane builds it. A regression fails before merge.

Board packagePlatformMaintainersNotes
nros-board-linuxLinuxunassignedThe reference platform: the densest Runtime coverage of any target, all three RMWs. phase-337 W8.a merged the native shim and the posix family driver into this one crate — the registry carried TWO rows for one implementation because board_path_for mapped both keys to the same ZST.
nros-board-threadx-linuxThreadxLinuxunassigned
nros-board-zephyrZephyrNativeSimunassignedCAVEAT: only ever built for native_sim/native/64. No real Zephyr hardware board is built by anything.

Tier 2 — verified

Asserted runtime coverage and a nightly lane, but not in just ci. Breakage is found within a day.

Board packagePlatformMaintainersNotes
nros-board-freertos-posixFreertosPosixunassigned
nros-board-mps2-an385QemuBaremetalunassignedphase-337 W6.a folded nros-board-rtic-mps2-an385 in as the rtic feature — one crate, two entry shapes (direct-exec + RTIC), one witness row.
nros-board-mps2-an385-freertosFreertosMps2unassigned
nros-board-nuttx-qemuNuttxArmunassigned
nros-board-nuttx-qemuNuttxRiscvunassignedHONEST LABEL: C runtime-proven; Rust and C++ Pubsub are explicit CarveOuts and every EntryPubsub row is BuildOnly.
nros-board-threadx-qemu-riscv64ThreadxRiscv64unassignedAll three Action rows are BuildOnly by a deliberate wall-clock choice (182.5), not by breakage.
nros-board-zephyrZephyrQemuCortexMunassignedC, C++ and Rust pubsub are Runtime; service/action are BuildOnly pending the runner’s peer half. It also carries the tree’s first non-native_sim live-peer interop cell (phase-441 W1, zephyr-cortex-m-pubsub-c-zenoh). This note used to say NO RUST ROW, because zephyr-lang-rust could not compile for any board whose devicetree has gpio nodes — issue 0432, RESOLVED 2026-08-12 by phase-346 W2/W3, after which the Rust leaf built and zephyr_cortex_m_rust_zenoh_pubsub_e2e ran it while four documents still said it could not. Tier 2 rather than 1 because just ci is host-only and this needs QEMU.

Tier 3 — build-only

Compiles. No CI lane can boot it — real hardware or a license-gated model is required. Nobody has booted it recently unless a maintainer did so by hand.

Board packagePlatformMaintainersNotes
nros-board-s32z270-freertos—unassignedS32Z270 RTU Cortex-R52 (ASI phase-4 W5.b consumer). No matrix_platform: no witness exists for this board, and borrowing FreertosMps2’s would claim its Runtime cells. Link-completeness is proven by the workspace cmake lane with the CRx_No_GIC default port.
nros-board-zephyrFvpunassignedRuntime is maintainer-run via just zephyr verify-fvp-runtime — hence execution_class = \"hardware\", which is what exempts this row from phase-375 W4’s smoke floor. The model is NOT licence-gated (that claim stood here until 2026-09-06 and was wrong): nros setup --tool arm-fvp fetches it from a pinned Arm CDN permalink, and the board-import fixture was verified booting on it (nros: smoke ok) the same day. What still keeps CI out is cost and x86_64-only hosting, not permission. Kept in-tree: it is the ASI reference consumer’s target (phase-292), a real downstream user the CI evidence cannot see. phase-337 W9.a folded the nros-board-fvp-aemv8r-smp CRATE into nros-board-zephyr/boards/fvp-aemv8r-smp/ — the crate’s Rust half (a Config and a run loop) had zero consumers, so what it actually shipped was a prj.conf, a DTS overlay and a board.cmake: a config bundle wearing a Cargo.toml. Third row for this crate, which is exactly what W1.c’s (crate, matrix_platform) keying is for.

Scaffold — not supported

Structurally incomplete. Distinct from tier 3: tier 3 is finished-but-unverified, scaffold is unfinished. Do not build on these.

Board packagePlatformMaintainersNotes
nros-board-esp32-qemuEsp32QemuunassignedDORMANT (issue 1525): code kept for future use; no lane builds or tests it. Was tier 2 (two-way QEMU e2e, esp32_emulator.rs, now #[ignore]d).
nros-board-mps2-an385-zephyr—unassigned
nros-board-mps3-an536-freertos—unassigned
nros-board-qemu-cortex-a53—unassigned

Infrastructure — not boards

Shared traits, family drivers, ABI mirrors, PACs, and descriptor directories. No platform, no tier promise.

Board packagePlatformMaintainersNotes
linux—unassignednros-board.toml descriptor directory, not a crate (renamed from posix by phase-337 W8.d). phase-321 W2 moves these out of packages/boards/.
mps2-an385-pac—unassignedIRQ enum only. Kept because RTIC’s #[rtic::app(device = ...)] needs a nameable path.
nros-board-cffi—unassignedHeader ABI mirror with ZERO in-tree C consumers; kept alive by its own drift gate. Spec artifact, not a library.
nros-board-common—unassignedBuild-helper library plus one trait; 2180 of 2252 code lines are behind build-helpers.
nros-board-freertos—unassignedReal family driver: the MPS2 overlay is pub use nros_board_freertos::Config and carries zero config code. The pattern the forked boards should copy.
nros-board-nuttx—unassignedNuttX FAMILY DRIVER, not a façade: owns run_entry / run_tiers / the tier spin loops, which the one board overlay (nros-board-nuttx-qemu) delegates to. phase-337 W3 merged the two board crates below it; this layer stays.
nros-board-threadx—unassignedReal family driver: a 1120-line generic entry.py both ThreadX boards call into.
nros-board-threadx-port-riscv64—unassignedNOT a board: RFC-0064 layer 2, the ThreadX RISC-V64/GNU ARCH PORT. Upstream types ULONG as 8 bytes there; NetX Duo’s packet code does ULONG * arithmetic assuming 4, and retyping it shifts every TX_THREAD offset the context-switch assembly loads — so the fork is a header plus five .S files, and it is arch, not board. Extracted from nros-board-threadx-qemu-riscv64 by phase-337 W4.a; a second riscv64 ThreadX board would need every line of it unchanged. threadx-linux has none because upstream’s Linux port already types ULONG as 4 bytes — which is why the two ThreadX boards did NOT merge.
zephyr—unassignednros-board.toml descriptor directory, not a crate.

Maintainers

A tier is a promise, and just check board-tiers requires someone to have made it: tier 1 needs 3 named maintainers, tier 2 needs 2, tier 3 needs 1 (phase-375 W1). Rust’s target-tier policy is the model — a named person per target, and demotion when the maintainer becomes unreachable; that mechanism, not the directory layout, is what actually retires abandoned targets.

Rows that predate the rule are grandfathered by scripts/board-maintainer-baseline.json, a list that only shrinks. So the rule binds a NEW board immediately and an existing one as its owner is found — a gate that failed all 22 rows on the day it landed would have been bypassed instead. Recording an owner is the point; inventing one would be worse than leaving the row at the tier it can carry.

Environment Variables Reference

Configuration File

All environment variables can be set in a .env file at the project root:

cp .env.example .env
# Edit .env — uncomment and adjust values as needed
  • justfile — .env is auto-loaded. Missing file silently ignored.
  • direnv — .envrc sources .env if present.
  • Manual — set -a; source .env; set +a before cargo build.

Variables in .env take precedence over justfile defaults but are overridden by explicit shell exports.

Runtime Configuration

Examples use ExecutorConfig::from_env() for configuration:

VariableDescriptionDefault
ROS_DOMAIN_IDROS 2 domain ID0
NROS_LOCATORRMW locator (tcp/…, udp/…, serial/…, or tls/…)tcp/127.0.0.1:7447
NROS_SESSION_MODESession mode: client or peerclient
ZENOH_TLS_ROOT_CA_CERTIFICATEPath to CA certificate (PEM) for TLS(none)
ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64Base64-encoded CA certificate for TLS(none)
ZENOH_TLS_VERIFY_NAME_ON_CONNECTVerify server hostname in TLS (true/false)(none)

Retired legacy names (phase-484 W8c): ZENOH_LOCATOR and ZENOH_MODE are no longer read. Setting either is a startup ERROR naming its replacement (NROS_LOCATOR / NROS_SESSION_MODE) — Rust panics in resolve_hosted / returns InitError::RetiredEnvVar from nros::init, C / C++ init returns RCL_RET_INVALID_ARGUMENT / NROS_CPP_RET_INVALID_ARGUMENT and logs the reason. Refused rather than ignored: an ignored locator would dial the default router and read as “no delivery”. ZENOH_TLS_* names are kept because TLS is currently zenoh-specific.

TLS Notes

  • POSIX: requires libmbedtls-dev (just setup base checks it). File-path and base64 cert loading are both supported.
  • Bare-metal: only ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64 is supported (no filesystem). The certificate is embedded at build time.
  • The link-tls Cargo feature must be enabled on both the example and the nros crate.

Build-Time Configuration

VariableDescriptionRequired
ZENOH_PICO_DIRCMake install prefix for pre-built zenoh-pico (use with system-zenohpico feature on zpico-sys)Only with system-zenohpico
SSIDWiFi network name for ESP32 examplesRequired for build-examples-esp32
PASSWORDWiFi password for ESP32 examplesRequired for build-examples-esp32
NROS_EXTRA_BOARD_PATHExtra board-search roots (PATH-style : separated). Each entry is a directory shaped like packages/boards/ — subdirs carrying nros-board.toml crates or */boards/<name>/board.cmake bundles. Read by the nros CLI’s board catalog AND nano_ros_use_board() (also settable as a CMake cache var there). Board keys stay global: a name found under two roots is an error, never shadowed.(unset)

ARM FVP (FVP_BaseR_AEMv8R)

Not license-gated: nros setup --tool arm-fvp fetches the model from a pinned Arm CDN permalink ([tool.arm-fvp] in nros-sdk-index.toml, digest-checked) into the SDK store. Arm publishes it for x86_64 Linux only. You need none of the variables below unless you installed a model yourself. See the ARM FVP getting-started chapter for the end-to-end build+run walk-through.

VariableDescriptionDefault
ARMFVP_BIN_PATHDirectory containing FVP_BaseR_AEMv8R (Zephyr-canonical, highest priority).(unset)
ARM_FVP_DIRInstall root; resolver scans models/Linux64_GCC-*/ underneath.(unset)

scripts/zephyr/resolve-fvp-bin.sh resolves, in order: ARMFVP_BIN_PATH, ARM_FVP_DIR, FVP_BaseR_AEMv8R on PATH, then the store copy (nros sdk-path arm-fvp). The just zephyr run-fvp-* / verify-fvp-runtime recipes export the result as ARMFVP_BIN_PATH at BUILD time as well as run time, because Zephyr’s armfvp.cmake caches the location at configure. When nothing resolves, a recipe you NAMED fails with the remedy (nros setup --tool arm-fvp); reached through a fixture fan-out it skips and is reported.

For a model you downloaded by hand, scripts/installers/arm-fvp-installer.sh (with ARM_FVP_DIR set to the extraction root) symlinks it to ~/.nros/sdks/arm-fvp/current/ and prints the export ARMFVP_BIN_PATH=… line. nros doctor --board fvp-aemv8r-smp reports whether the model resolves.

Vendored source trees

You normally set none of these. nros setup puts each tree in the shared store, and every build locates it through one ladder (RFC-0103 D3): this variable, then the project’s [sources] row in system.toml, then a checkout submodule you are editing, then the store, then the checkout’s own copy. nros locate <name> --why prints which rung answered. Set a variable only to build against a tree of your own.

VariableTree
FREERTOS_DIRFreeRTOS kernel source
LWIP_DIRlwIP source
NUTTX_DIRNuttX RTOS source
NUTTX_APPS_DIRNuttX apps source
THREADX_DIRThreadX kernel source
NETX_DIRNetX Duo source
CYCLONEDDS_SOURCE_DIRCyclone DDS source

FREERTOS_PORT, FREERTOS_CONFIG_DIR, THREADX_CONFIG_DIR and NETX_CONFIG_DIR are board facts: each board’s nros-board.toml states them, and the build carries them from there. They are not user inputs.

Buffer Tuning

All optional – platform-appropriate defaults apply if unset. See Configuration for deployment-scenario guidance and platform guides for target-specific sizing.

Zenoh-pico (NROS_ZENOH_*)

Each knob here was spelled ZPICO_<X> before phase-484 W5a (RFC-0103 D2b). That old spelling is now retired (phase-484 W8): set in the environment or in an [image.<id>] env row, it stops the build with an error naming the NROS_ZENOH_* name to use. The full list is the retired-names table.

VariableDescriptionDefaultCrate
NROS_ZENOH_FRAG_MAX_SIZEMax reassembled message size after defragmentation65536 / 2048zpico-sys
NROS_ZENOH_BATCH_UNICAST_SIZEMax unicast batch size before fragmentation65536 / 1024zpico-sys
NROS_ZENOH_BATCH_MULTICAST_SIZEMax multicast batch size8192 / 1024zpico-sys
NROS_ZENOH_MAX_PUBLISHERSMax concurrent publishers in zenoh shim8zpico-sys
NROS_ZENOH_MAX_SUBSCRIBERSMax concurrent subscribers in zenoh shim8zpico-sys
NROS_ZENOH_MAX_QUERYABLESMax concurrent queryables in zenoh shim8zpico-sys
NROS_ZENOH_MAX_LIVELINESSMax concurrent liveliness tokens in zenoh shim. DERIVED when the image declares anything: one per node name plus the session’s own, one per publisher, subscriber, service server and client, and the parameter and lifecycle families’ servers and publisher (issue 1713). A stated value wins.16 when nothing is declaredzpico-sys
NROS_ZENOH_MAX_PENDING_GETSMax concurrent in-flight service calls4zpico-sys
NROS_ZENOH_MAX_TL_PUBLISHERSRetention slots for TRANSIENT_LOCAL publishers in the zenoh shim. Each also takes one NROS_ZENOH_MAX_QUERYABLES slot: a transient-local publisher declares a queryable so a late joiner can query its last sample. An image whose endpoints are declared DERIVES this (an action_server row counts one for its /status), and a stated value below the declared demand fails the build naming the knob.2nros-rmw-zenoh
NROS_ZENOH_TL_RETAIN_BYTESBytes one retained TRANSIENT_LOCAL sample may hold. A larger publish is SENT but not retained, and says so once at WARN.1024nros-rmw-zenoh
NROS_ZENOH_TL_RETAIN_DEPTHSamples each TRANSIENT_LOCAL publisher retains and replays, oldest first, to a late joiner. DERIVED from the deepest KEEP_LAST(N) a transient-local publisher declares (issue 1709); the publisher is granted and advertises min(asked, this). Must be >= 1.1 when nothing is declarednros-rmw-zenoh
NROS_SUBSCRIBER_BUFFER_SIZEsmall receive-class slot per subscriber in the zenoh shim; the executor arena reads the same name. (ZPICO_SUBSCRIBER_BUFFER_SIZE is a dead spelling — nothing reads it, issue 1181.)1024nros-rmw-zenoh
NROS_ZENOH_SUBSCRIBER_LARGE_SIZElarge size-class slot, for subscriptions whose type does not fit the small block16384nros-rmw-zenoh
NROS_ZENOH_SUBSCRIBER_SIZE_THRESHOLDThe small/large class split: a subscription whose rx_buffer_hint exceeds it is served from large. The cmake message-bound derivation assumes the shipped value (NROS_MESSAGE_BOUNDS_DEFAULT_SMALL_CEILING), so moving it can make a derived class count disagree with runtime routing, which surfaces as a failed create_subscription.2048nros-rmw-zenoh
NROS_ZENOH_PUBLISHER_TX_BUFFER_SIZEPer-publisher TX arena for the zero-copy loan path; costs NROS_ZENOH_MAX_PUBLISHERS x this (issue 0813).1024nros-rmw-zenoh
NROS_ZENOH_MAX_LARGE_SUBSCRIBERSHow many large-class blocks this image reserves. 0 is legal (phase-403 W4): an image whose subscribed types all fit NROS_SUBSCRIBER_BUFFER_SIZE declares 0 and stops paying RING_DEPTH x LARGE_SIZE — 65,536 B at the defaults — for a class it never routes into. A hint no class can hold then fails create_subscription rather than dropping every sample. DERIVED, on both lanes — a Zephyr image gets it from Kconfig’s -1 sentinel, and since issue 1125 a cargo leaf gets it from nros sync’s gitignored .cargo/nros-managed-env.toml, joined over the types the leaf’s own components subscribe to. Setting it here still wins.2nros-rmw-zenoh
NROS_ZENOH_SERVICE_BUFFER_SIZEPer-service-server static buffer in zenoh shim1024nros-rmw-zenoh
NROS_PARAM_SERVICE_INBOX_BYTESBytes one request slot holds in the inbox ring of a ROS parameter service queryable (six per node) or a REP-2002 lifecycle one (five per node) – the BUILTIN family, which the zenoh shim selects by service name (phase-461 W2b). DERIVED: with nothing stated, the contract’s declared parameters bound it, as the largest request a set_parameters over the worst-declared node can produce (672 B on the Autoware Safety Island, against the transport’s 1,024). A contract declaring a string- or array-valued parameter needs the parameter store’s capacities to price, which the RMW cannot see, so the shim abstains there and the user-service slot stands. Setting it here still wins.derived, else NROS_SERVICE_INBOX_BYTESnros-rmw-zenoh
NROS_PARAM_SERVICE_INBOX_DEPTHRequests one parameter or lifecycle service queryable holds before the newest is dropped. One, because this traffic is one request and one reply from a client that waits (ros2 param, rclcpp’s SyncParametersClient) and a node’s services are polled serially in one spin. Raise it to 2 for an image serving a parameter dashboard. Must be >= 1.1nros-rmw-zenoh
NROS_ZENOH_GET_REPLY_BUF_SIZEStack buffer for service client replies4096zpico-sys
NROS_ZENOH_GET_POLL_INTERVAL_MSSingle-threaded polling interval in zenoh_shim_get()10zpico-sys
NROS_SMOLTCP_MAX_SOCKETSMax concurrent TCP sockets (smoltcp); brokered default since Phase 204.2. (ZPICO_SMOLTCP_MAX_SOCKETS is retired.)1 (brokered)nros-smoltcp
NROS_SMOLTCP_MAX_UDP_SOCKETSMax concurrent UDP sockets (smoltcp); 1 by default, 4 with the nros-smoltcp/rtps feature (Phase 204.2). (ZPICO_SMOLTCP_MAX_UDP_SOCKETS is retired.)1 (brokered)nros-smoltcp
NROS_SMOLTCP_BUFFER_SIZEPer-socket staging buffer (smoltcp). (ZPICO_SMOLTCP_BUFFER_SIZE is retired.)2048nros-smoltcp
NROS_SMOLTCP_CONNECT_TIMEOUT_MSTCP connection timeout (smoltcp). (ZPICO_SMOLTCP_CONNECT_TIMEOUT_MS is retired.)30000nros-smoltcp
NROS_SMOLTCP_SOCKET_TIMEOUT_MSTCP read/write timeout (smoltcp). (ZPICO_SMOLTCP_SOCKET_TIMEOUT_MS is retired.)10000nros-smoltcp

XRCE-DDS (NROS_XRCE_*)

These are read by nros-rmw-xrce-cffi’s build script, either from the environment or — on Zephyr — from CONFIG_<name> in .config. They set C #defines of the same name without the NROS_ prefix; the define is not itself an environment variable, so exporting XRCE_BUFFER_SIZE has no effect and reports no error.

VariableDescriptionDefaultMinCrate
NROS_XRCE_BUFFER_SIZEPer-slot receive buffer. See the note below — this is the receive ceiling.102464nros-rmw-xrce
NROS_XRCE_SUBSCRIBER_RING_DEPTHQueued samples per subscriber321nros-rmw-xrce
NROS_XRCE_MAX_SUBSCRIBERSMax concurrent subscribers. On Zephyr, DERIVED — see below.80nros-rmw-xrce
NROS_XRCE_MAX_SERVICE_SERVERSMax concurrent service servers. On Zephyr, DERIVED — see below.40nros-rmw-xrce
NROS_XRCE_MAX_SERVICE_CLIENTSMax concurrent service clients40nros-rmw-xrce
NROS_XRCE_STREAM_HISTORYReliable stream history depth; sizes the per-session output buffer164nros-rmw-xrce
NROS_XRCE_CUSTOM_TRANSPORT_MTUCustom transport MTU; stream buffers are MTU x STREAM_HISTORY4096128nros-rmw-xrce
NROS_XRCE_TRANSPORT_MTUUDP/TCP transport MTU, and the Zephyr default of CUSTOM_TRANSPORT_MTU4096 (Zephyr 512)128nros-rmw-xrce
NROS_XRCE_SUBSCRIBER_BUFFER_SIZESubscriber ring entry size; derived from the declared subscriptions when unstated (Zephyr: -1)BUFFER_SIZE1nros-rmw-xrce
NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZEService-server request entry sizeBUFFER_SIZE1nros-rmw-xrce
NROS_XRCE_SERVICE_REPLY_BUFFER_SIZEService-client reply slot sizeBUFFER_SIZE1nros-rmw-xrce

Dropping MAX_SUBSCRIBERS, MAX_SERVICE_* and SUBSCRIBER_RING_DEPTH to 1, BUFFER_SIZE to 256, STREAM_HISTORY to 4 and CUSTOM_TRANSPORT_MTU to 512 takes the session struct from ~390 KB to ~10-20 KB.

Every variable in this table is a knob-registry row, so a project states it per image as [image.<id>.knobs.xrce] buffer_size = 256 — the name without NROS_XRCE_, lowercased (max_subscribers, max_service_servers, max_service_clients, subscriber_ring_depth, subscriber_buffer_size, service_request_buffer_size, service_reply_buffer_size, stream_history, transport_mtu, custom_transport_mtu) — rather than as an env row (RFC-0103 D6).

On Zephyr the three entity caps DERIVE, and the minimum is 0

CONFIG_NROS_XRCE_MAX_SUBSCRIBERS and CONFIG_NROS_XRCE_MAX_SERVICE_SERVERS default to -1, which means take the number from the entities this image declares (the contract sidecar beside the bringup’s launch file, or a standalone leaf’s system.toml [[component]] entities rows). A listener that declares one subscription and no service is built with one subscriber slot and zero service-server slots, which is what took the session struct from 427,968 bytes to 59,088 on the zephyr cpp listener (issue 1033). Stating a number in Kconfig or the environment still wins over the derivation, in both directions.

Zero is a legal, honoured value for all three caps — it is not rounded up to 1. So creating an entity the image never declared does not truncate silently: the create fails with InvalidConfig and the backend logs which cap it hit, which knob moves it, and that the count came from the declaration.

NROS_XRCE_SUBSCRIBER_RING_DEPTH is deliberately NOT derivable — no inventory knows how deep a burst a subscriber must survive — but it does now have a Kconfig symbol. It did not before issue 1033, which made it unsettable on Zephyr however it was spelled: a Zephyr cargo build sees only the knobs nros_cargo_build.cmake forwards, and shell exports do not survive that.

NROS_XRCE_BUFFER_SIZE is the XRCE receive ceiling

A subscriber’s receive ring entry is a fixed uint8_t data[XRCE_BUFFER_SIZE], so a subscription’s own buffer size cannot raise it: asking for create_subscription_sized::<M, 16384> still stops at 1024, because the 16384 buffer is the destination of a copy that never happens. A sample that does not fit is refused on take with MessageTooLarge.

The static cost is XRCE_SUBSCRIBER_RING_DEPTH x XRCE_BUFFER_SIZE per subscriber, which is why the default is small — raise it deliberately, and consider lowering the ring depth at the same time.

Raising it stops helping at the transport MTU: at 4096 samples arrive corrupted rather than refused, with no error and no counter. That is issue 0819, open at time of writing. Keep payloads comfortably below the MTU.

Compile-time only (not environment variables)

These are #defines in packages/rmw/xrce/nros-rmw-xrce/src/internal.h with no env or Kconfig knob. Changing them means editing that header.

DefineDescriptionDefault
XRCE_ENTITY_CREATION_TIMEOUT_MSTimeout for entity creation1000
XRCE_SERVICE_REPLY_TIMEOUT_MSPer-attempt service reply timeout50
XRCE_SERVICE_REPLY_TOTAL_MSTotal service reply budget5000
XRCE_SESSION_FLUSH_TIMEOUT_MSSession flush timeout100
XRCE_SESSION_CREATION_RETRIESSession creation retries3
XRCE_MAX_PENDING_REPLIESIn-flight service replies4
XRCE_SERVICE_REQUEST_RING_DEPTHQueued service requests per server4
XRCE_DEFAULT_AGENT_PORTDefault agent port2018

Core (NROS_*)

VariableDescriptionDefaultCrate
NROS_EXECUTOR_MAX_CBSMax executor callback slots (compile-time fixed array size)4nros-node
NROS_EXECUTOR_ARENA_SIZEExecutor arena size in bytes (compile-time fixed array size)4096nros-node
NROS_SUBSCRIPTION_BUFFER_SIZEDefault subscription/service buffer size (bytes)1024nros-node
NROS_LET_BUFFER_SIZEBuffer size for LET semantics per handle512nros-c
NROS_MAX_CONCURRENT_GOALSMax concurrent goals per action server (compile-time constant, not env-var configurable)4nros-c
NROS_MAX_PARAMETERSMax parameters in parameter server32nros-params
NROS_MAX_PARAM_NAME_LENMax parameter name length64nros-params
NROS_MAX_STRING_VALUE_LENMax string parameter value length256nros-params
NROS_MAX_ARRAY_LENMax parameter array length32nros-params
NROS_MAX_BYTE_ARRAY_LENMax byte array parameter length256nros-params
NROS_MAX_PARAM_DESCRIPTION_LENMax parameter description length; longer is truncated and logged, 0 = none (max 256)256nros-params

Static pool inventory

Every build-time sizing knob nano-ros reads, with the default it uses when you do not set it. Set them as environment variables at BUILD time.

This page exists because knowing a knob exists is the hard part. Issue 0271 audited a 256 KB image that already tuned nine of these and still carried ~145 KB of defaults it did not know to change — every knob it needed was already there. Four separate features had each added a static pool with a knob, silently.

Pools with a computed size

Bytes are at the DEFAULTS below, computed from the pool’s own declared arithmetic (// nros-pool: in the source). Change a knob and the figure moves with it.

poolbytes at defaultformuladeclared in
LARGE_PAYLOADS131,072 (builtin — an image that declares its entities derives its own)NROS_ZENOH_MAX_LARGE_SUBSCRIBERS * NROS_ZENOH_SUBSCRIBER_RING_DEPTH * NROS_ZENOH_SUBSCRIBER_LARGE_SIZEpackages/rmw/zenoh/nros-rmw-zenoh/src/shim/subscriber.rs:205
SLOTS8,192 (builtin — an image that declares its entities derives its own)NROS_RMW_SUBSCRIBER_SLOTS * NROS_RMW_SUBSCRIBER_SLOT_BYTESpackages/rmw/cffi/src/rust_adapter.rs:127
SMALL_PAYLOADS— (knob NROS_ZENOH_MAX_SUBSCRIBERS has a computed default)NROS_ZENOH_MAX_SUBSCRIBERS * NROS_ZENOH_SUBSCRIBER_RING_DEPTH * NROS_SUBSCRIBER_BUFFER_SIZEpackages/rmw/zenoh/nros-rmw-zenoh/src/shim/subscriber.rs:204
SOCKET_RX_BUFFERS— (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default)NROS_SMOLTCP_MAX_SOCKETS * NROS_SMOLTCP_BUFFER_SIZEpackages/drivers/net/nros-smoltcp/src/bridge.rs:260
SOCKET_TX_BUFFERS— (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default)NROS_SMOLTCP_MAX_SOCKETS * NROS_SMOLTCP_BUFFER_SIZEpackages/drivers/net/nros-smoltcp/src/bridge.rs:261
UDP_SOCKET_RX_BUFFERS— (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default)NROS_SMOLTCP_MAX_UDP_SOCKETS * NROS_SMOLTCP_BUFFER_SIZEpackages/drivers/net/nros-smoltcp/src/bridge.rs:317
UDP_SOCKET_TX_BUFFERS— (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default)NROS_SMOLTCP_MAX_UDP_SOCKETS * NROS_SMOLTCP_BUFFER_SIZEpackages/drivers/net/nros-smoltcp/src/bridge.rs:318

Pools sized by a knob, measured per image

These statics move with the knobs beside them, but their element is a struct (or the region is a heap), so no formula over defaults can state their size exactly — and a guessed multiplier is the defect this page exists to avoid. just mem-report <image> reports each one’s exact bytes in the image you built, beside the knobs listed here.

symbolkindsized by
g_sessionspoolNROS_ZENOH_GRAPH_CACHE_SIZE, NROS_ZENOH_MAX_LIVELINESS, NROS_ZENOH_MAX_PENDING_GETS, NROS_ZENOH_MAX_PUBLISHERS, NROS_ZENOH_MAX_QUERYABLES, NROS_ZENOH_MAX_SESSIONS, NROS_ZENOH_MAX_SUBSCRIBERS
nros_log::early::SLOTSpoolNROS_LOG_EARLY_RECORDS
nros_log::pool::NAMESpoolNROS_LOG_DYNAMIC_LOGGERS
nros_log::pool::POOLpoolNROS_LOG_DYNAMIC_LOGGERS
nros_log::rosout::SLOTSpoolNROS_LOG_ROSOUT_RECORDS
nros_node::boot_report::NROS_BOOT_REPORTpoolNROS_BOOT_REPORT
nros_node::parameter_services::PARAM_INBOXpoolNROS_PARAM_SERVICE_INBOX_BYTES, NROS_PARAM_SERVICE_INBOX_DEPTH
nros_platform::zephyr_heap::HEAPheapNROS_ZEPHYR_HEAP_SIZE
nros_rmw_cffi::MESSAGE_INFO_TABLEpoolNROS_RMW_MESSAGE_INFO_SLOTS
nros_rmw_cffi::NODE_TABLEpoolNROS_RMW_MAX_NODES
nros_rmw_cffi::REGISTRYpoolNROS_RMW_MAX_BACKENDS
nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTSpoolNROS_ZENOH_MAX_TL_PUBLISHERS, NROS_ZENOH_TL_RETAIN_BYTES, NROS_ZENOH_TL_RETAIN_DEPTH
nros_rmw_zenoh::shim::service::ACTION_INBOXpoolNROS_ACTION_INBOX_BYTES, NROS_ACTION_INBOX_DEPTH
nros_rmw_zenoh::shim::service::SERVICE_BUFFERSpoolNROS_ZENOH_MAX_QUERYABLES, NROS_ZENOH_MAX_SESSIONS, NROS_ZENOH_SERVICE_BUFFER_SIZE
nros_rmw_zenoh::shim::service::USER_SERVICE_INBOXpoolNROS_SERVICE_INBOX_BYTES, NROS_SERVICE_INBOX_DEPTH, NROS_ZENOH_MAX_QUERYABLES, NROS_ZENOH_MAX_SESSIONS
nros_rmw_zenoh::shim::subscriber::SMALL_PAYLOADSpoolNROS_SUBSCRIBER_BUFFER_SIZE
nros_rmw_zenoh::shim::subscriber::SUBSCRIBER_BUFFERSpoolNROS_ZENOH_MAX_SUBSCRIBERS
nros_smoltcp::bridge::SOCKET_RX_BUFFERSpoolNROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_SOCKETS
nros_smoltcp::bridge::SOCKET_TABLEpoolNROS_SMOLTCP_MAX_SOCKETS
nros_smoltcp::bridge::SOCKET_TX_BUFFERSpoolNROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_SOCKETS
nros_smoltcp::bridge::UDP_SOCKET_RX_BUFFERSpoolNROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_UDP_SOCKETS
nros_smoltcp::bridge::UDP_SOCKET_TABLEpoolNROS_SMOLTCP_MAX_UDP_SOCKETS
nros_smoltcp::bridge::UDP_SOCKET_TX_BUFFERSpoolNROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_UDP_SOCKETS
ucHeapheapNROS_FREERTOS_HEAP_KB

Every sizing knob

Every knob says what it costs: a byte figure where a formula prices it, otherwise WHERE its bytes go and why no static figure exists. Nothing in this column is blank, and nothing here is free unless it says so.

knobdefaultread bycost
NROS_ACTION_INBOX_BYTEScomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:160packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::service::ACTION_INBOX: slot bytes of each action-family inbox ring; the element is an InboxStorage struct (ring header + slots), so only the compiler knows the product
NROS_ACTION_INBOX_DEPTHcomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:166packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::service::ACTION_INBOX: ring depth of each action-family inbox; same InboxStorage element
NROS_BOOT_REPORTcomputed — see packages/core/nros-node/build.rs:378packages/core/nros-nodepool nros_node::boot_report::NROS_BOOT_REPORT: a switch, not a count: on (the native_sim default since issue 1424, and wherever CONFIG_NROS_BOOT_REPORT=y) it links the one boot-record static, measured per image by mem-report; off, it links nothing
NROS_CAPABILITY_PARAM_SERVICEScomputed — see packages/core/nros-params/build.rs:276packages/core/nros-paramsexecutor: a switch: the bringup declares param_services (Zephyr Kconfig); like NROS_PARAM_STORE it makes the executor’s storage carve the parameter store (phase-382 W3’); whether the six services’ sets and request/reply pair are carved too is the param-services cargo feature (issue 1897; a STATED 0 here did it until then, issue 1856)
NROS_EXECUTOR_ACTION_CLIENTScomputed — see packages/tooling/nros-zephyr-build/knobs.toml:73packages/tooling/nros-zephyr-buildexecutor: action-client entries the executor arena is sized for
NROS_EXECUTOR_ARENA_SIZEcomputed — see packages/core/nros-node/build.rs:1128packages/core/nros-nodeexecutor: the executor arena itself; the backing is EXECUTOR_BACKING (Rust) or the entry’s __nros_executor_storage / tier table (C/C++)
NROS_EXECUTOR_BACKING_U64Scomputed — see packages/tooling/nros-zephyr-build/knobs.toml:139packages/tooling/nros-zephyr-buildexecutor: STATES the boot executor’s backing size in words; EXECUTOR_BACKING is exactly 8x this
NROS_EXECUTOR_MAX_CBScomputed — see packages/tooling/nros-zephyr-build/src/lib.rs:692packages/tooling/nros-zephyr-buildexecutor: callback slots inside the executor
NROS_EXECUTOR_MAX_NODEScomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:351packages/rmw/zenoh/nros-rmw-zenohexecutor: node slots inside the executor; the zenoh backend also sizes its per-node liveliness cap from it
NROS_EXECUTOR_MAX_SCcomputed — see packages/tooling/nros-zephyr-build/src/lib.rs:703packages/tooling/nros-zephyr-buildexecutor: sched-context slots inside the executor
NROS_EXECUTOR_MAX_SHUTDOWN_CBS2packages/core/nros-nodeexecutor: shutdown-callback slots inside the executor
NROS_EXECUTOR_MAX_VIOLATIONS8packages/core/nros-nodeexecutor: violation-ring slots carved from the executor backing (24 B each on a 32-bit target); with the boot report it also sizes the NROS_VIOLATION_RECORD static (40 B + 28 B a slot), measured per image by mem-report (phase-474 I1)
NROS_FREERTOS_APP_STACK_KBcomputed — see packages/tooling/nros-platform-config/src/platform_config.rs:1412packages/tooling/nros-platform-configstack: the application task’s stack, which FreeRTOS allocates out of ucHeap — it is inside that heap’s figure, never a symbol of its own
NROS_FREERTOS_HEAP_KBcomputed — see packages/tooling/nros-platform-config/src/platform_config.rs:1411packages/tooling/nros-platform-configheap ucHeap: FreeRTOS heap_4’s ucHeap[configTOTAL_HEAP_SIZE]; the symbol IS the region, measured per image
NROS_GRAPH_MAX_ENTITIES0packages/rmw/zenoh/nros-zpico-buildinput: the entity count ZPICO_GRAPH_CACHE_SIZE is derived from; storage is that knob’s
NROS_KEYEXPR_STRING_SIZE256packages/rmw/zenoh/nros-rmw-zenohelement: the key-expression buffer inside each subscriber/service shim entry; lands in SUBSCRIBER_BUFFERS/SERVICE_BUFFERS
NROS_LET_BUFFER_SIZEcomputed — see packages/tooling/nros-build-helpers/src/c.rs:125packages/tooling/nros-build-helperselement: the per-handle LET buffer inside each C/C++ API handle, which lives in the CALLER’s storage
NROS_LOG_BUFFER_SIZEcomputed — see packages/core/nros-log/build.rs:106packages/core/nros-logstack: the per-call Rust FormatBuffer is a stack frame in the logging caller (issue 1037); it is also the message element of nros_log::early::SLOTS and nros_log::rosout::SLOTS
NROS_LOG_DYNAMIC_LOGGERScomputed — see packages/core/nros-log/build.rs:100packages/core/nros-logpool nros_log::pool::POOL, nros_log::pool::NAMES: runtime-logger slots (phase-479 W5): POOL holds a Logger per slot, a struct whose size moves with its fields; NAMES is exactly 24 bytes per slot, so mem-report reads the image’s CAPACITY back out of its size
NROS_LOG_EARLY_RECORDScomputed — see packages/core/nros-log/build.rs:113packages/core/nros-logpool nros_log::early::SLOTS: records held before init (issue 1037): each slot holds a formatted message of NROS_LOG_BUFFER_SIZE bytes plus a logger name, so SLOTS moves with both knobs; 0 declines the ring
NROS_LOG_MAX_LEVELcomputed — see packages/core/nros-log/build.rs:129packages/core/nros-lognot-a-size: the compile-time severity CEILING (issue 1037): a threshold, not a count; a lower ceiling can only REMOVE code (Rust call sites below it fold away), never add storage
NROS_LOG_ROSOUT_RECORDScomputed — see packages/core/nros-log/build.rs:120packages/core/nros-logpool nros_log::rosout::SLOTS: the /rosout queue (issue 1037), linked only with nros-node/rosout: each slot holds a message of NROS_LOG_BUFFER_SIZE bytes plus a logger name
NROS_MAX_ARRAY_LENcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:508packages/tooling/nros-zephyr-buildelement: bounds an array parameter value; paid by every ParameterValue, i.e. every slot
NROS_MAX_BYTE_ARRAY_LENcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:526packages/tooling/nros-zephyr-buildelement: bounds a byte-array parameter value; paid by every ParameterValue, i.e. every slot
NROS_MAX_PARAMETERScomputed — see packages/core/nros-params/build.rs:51packages/core/nros-paramsexecutor: slots of the parameter store. CARVED into the executor’s storage when the build implies a store (NROS_PARAM_STORE / NROS_CAPABILITY_PARAM_SERVICES / a contract params:, phase-382 W3’), so mem-report’s [executor storage] prices it; otherwise boxed on the heap once per executor that declares (issue 0756)
NROS_MAX_PARAM_CONSTRAINTS_LENcomputed — see packages/core/nros-params/build.rs:131packages/core/nros-paramselement: bounds a parameter descriptor’s constraints text inside every ParameterStorage slot
NROS_MAX_PARAM_DESCRIPTION_LENcomputed — see packages/core/nros-params/build.rs:105packages/core/nros-paramselement: bounds a parameter descriptor’s description inside every ParameterStorage slot
NROS_MAX_PARAM_NAME_LENcomputed — see packages/core/nros-params/build.rs:59packages/core/nros-paramselement: bounds a parameter NAME inside every parameter-store slot (executor storage or heap, see NROS_MAX_PARAMETERS)
NROS_MAX_STRING_VALUE_LENcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:627packages/tooling/nros-zephyr-buildelement: bounds a string parameter VALUE — ParameterValue is sized by its largest variant, so this is paid by every slot
NROS_MONITOR_ARM_GRACE_MS0packages/core/nros-nodenot-a-size: a time: arm the contract monitors this long after the first spin (phase-474 I2)
NROS_MONITOR_ARM_ON_CALLcomputed — see packages/core/nros-node/build.rs:662packages/core/nros-nodenot-a-size: a switch: whether contract monitors wait for the application’s arm call (phase-474 I2)
NROS_MULTIGOAL_GOALScomputed — see packages/testing/nros-tests/bins/action-client-multigoal/src/main.rs:132packages/testing/nros-tests/bins/action-client-multigoaltest-only: action-client-multigoal test binary
NROS_MULTIGOAL_SOAK_MS0packages/testing/nros-tests/bins/action-client-multigoaltest-only: action-client-multigoal test binary
NROS_PARAM_EVENTS_DEPTHcomputed — see packages/core/nros-params/build.rs:147packages/core/nros-paramsnot-a-size: a QoS history depth handed to the RMW when the /parameter_events publisher is created; what it costs is the backend’s (a reliable DDS writer’s history), not a nano-ros pool
NROS_PARAM_SERVICE_BUFFER_SIZEcomputed — see packages/core/nros-node/build.rs:524packages/core/nros-nodeexecutor: request/reply buffers of the builtin parameter and lifecycle services, held in the executor’s value
NROS_PARAM_SERVICE_INBOX_BYTES0packages/rmw/xrcepool nros_node::parameter_services::PARAM_INBOX: slot bytes of the parameter-service inbox ring (CallerInboxStorage element)
NROS_PARAM_SERVICE_INBOX_DEPTH1packages/core/nros-nodepool nros_node::parameter_services::PARAM_INBOX: ring depth of the parameter-service inbox (CallerInboxStorage element)
NROS_PARAM_STOREcomputed — see packages/core/nros-params/build.rs:277packages/core/nros-paramsexecutor: a switch: the image builds a parameter store, so the executor’s storage carves NROS_MAX_PARAMETERS slots of it (phase-382 W3’); off, the store costs the backing nothing
NROS_PUBSUB_QOS_DEPTHcomputed — see packages/core/nros-node/build.rs:925packages/core/nros-nodeexecutor: the per-subscription receive queue depth the arena budgets for; derived from the declared QoS where an image states one
NROS_RMW_MAX_BACKENDS8packages/rmw/cffipool nros_rmw_cffi::REGISTRY: backend slots in the RMW registry; the element is a BackendSlot struct
NROS_RMW_MAX_NODES4packages/rmw/cffipool nros_rmw_cffi::NODE_TABLE: node slots in the RMW node table; the element is a NodeSlot struct
NROS_RMW_MESSAGE_INFO_SLOTS64packages/rmw/cffipool nros_rmw_cffi::MESSAGE_INFO_TABLE: message-info slots; the MessageInfoSlot element gains fields under alloc + safety-e2e, so a constant would be right for one build (issue 0739)
NROS_RMW_SUBSCRIBER_SLOTS8packages/rmw/cffiprices SLOTS (8,192 B at defaults); default DERIVED per image when it declares its entities
NROS_RMW_SUBSCRIBER_SLOT_BYTES1024packages/rmw/cffiprices SLOTS (8,192 B at defaults)
NROS_RUNTIME_COMPONENT_SLOT_BYTES512packages/api/nroscomponent: byte budget of each component slot (TypedSlot<C>)
NROS_RUNTIME_MAX_CELL_ENTITIES8packages/api/nroscomponent: entity-registry slots per component cell
NROS_RUNTIME_MAX_CLASS_INSTANCES2packages/api/nroscomponent: instances one component class’s macro-emitted store (__NROS_COMPONENT_<pkg>_SLOT_STORE) holds
NROS_RUNTIME_MAX_COMPONENTS4packages/api/nroscomponent: component slots of the runtime’s type-erased pool
NROS_SERVICE_INBOX_BYTEScomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:153packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX: slot bytes of each user-service inbox ring (InboxStorage element); its default is DERIVED from the declared request types, so no static figure exists either
NROS_SERVICE_INBOX_DEPTHcomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:159packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX: ring depth of each user-service inbox (InboxStorage element)
NROS_SERVICE_TIMEOUT_MS30000packages/rmw/zenoh/nros-rmw-zenohnot-a-size: milliseconds
NROS_SMOLTCP_BUFFER_SIZEcomputed — see packages/drivers/net/nros-smoltcp/build.rs:54packages/drivers/net/nros-smoltcpsizes SOCKET_RX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default); sizes SOCKET_TX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default); sizes UDP_SOCKET_RX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default); sizes UDP_SOCKET_TX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default); pool nros_smoltcp::bridge::SOCKET_RX_BUFFERS, nros_smoltcp::bridge::SOCKET_TX_BUFFERS, nros_smoltcp::bridge::UDP_SOCKET_RX_BUFFERS, nros_smoltcp::bridge::UDP_SOCKET_TX_BUFFERS: per-socket buffer bytes; formula-priced, but the default is computed per platform
NROS_SMOLTCP_CONNECT_TIMEOUT_MScomputed — see packages/drivers/net/nros-smoltcp/build.rs:57packages/drivers/net/nros-smoltcpnot-a-size: milliseconds
NROS_SMOLTCP_MAX_SOCKETScomputed — see packages/drivers/net/nros-smoltcp/build.rs:45packages/drivers/net/nros-smoltcpsizes SOCKET_RX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default); sizes SOCKET_TX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default); pool nros_smoltcp::bridge::SOCKET_RX_BUFFERS, nros_smoltcp::bridge::SOCKET_TX_BUFFERS, nros_smoltcp::bridge::SOCKET_TABLE: TCP socket count; the byte buffers carry nros-pool: formulas, the socket table’s element is a struct
NROS_SMOLTCP_MAX_UDP_SOCKETScomputed — see packages/drivers/net/nros-smoltcp/build.rs:51packages/drivers/net/nros-smoltcpsizes UDP_SOCKET_RX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default); sizes UDP_SOCKET_TX_BUFFERS — not priceable at defaults (knob NROS_SMOLTCP_MAX_UDP_SOCKETS has a computed default); pool nros_smoltcp::bridge::UDP_SOCKET_RX_BUFFERS, nros_smoltcp::bridge::UDP_SOCKET_TX_BUFFERS, nros_smoltcp::bridge::UDP_SOCKET_TABLE: UDP socket count; byte buffers formula-priced, the table’s element is a struct
NROS_SMOLTCP_SOCKET_TIMEOUT_MScomputed — see packages/drivers/net/nros-smoltcp/build.rs:60packages/drivers/net/nros-smoltcpnot-a-size: milliseconds
NROS_SUBSCRIBER_BUFFER_SIZEcomputed — see packages/core/nros-node/build.rs:855packages/core/nros-node (conflicting defaults — see below)sizes SMALL_PAYLOADS — not priceable at defaults (knob NROS_ZENOH_MAX_SUBSCRIBERS has a computed default); pool nros_rmw_zenoh::shim::subscriber::SMALL_PAYLOADS: the small payload class (formula-priced); read by two crates with two defaults — the page flags that; default DERIVED per image when it declares its entities
NROS_SUBSCRIPTION_BUFFER_SIZEcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:984packages/tooling/nros-zephyr-buildexecutor: each subscription’s receive buffer inside the executor arena (DEFAULT_RX_BUF_SIZE)
NROS_TRACE_TAKEScomputed — see packages/core/nros-node/build.rs:671packages/core/nros-nodenot-a-size: a switch: whether the callback trace emits a take event per subscription sample (phase-474 I3)
NROS_TRACE_TIMER_EVERY1packages/core/nros-nodenot-a-size: a ratio: one timer tick in N keeps its callback-trace start/end pair (phase-474 I3)
NROS_VIOLATION_DRAIN_REPORTcomputed — see packages/core/nros-node/build.rs:653packages/core/nros-nodenot-a-size: a switch: whether a new executor drains its violation ring every spin and logs each entry (phase-474 I1)
NROS_XRCE_BUFFER_SIZE1024packages/rmw/xrceheap: the default the three XRCE buffer sizes above alias to
NROS_XRCE_CUSTOM_TRANSPORT_MTU4096packages/rmw/xrceheap: the custom-transport twin of NROS_XRCE_TRANSPORT_MTU
NROS_XRCE_MAX_SERVICE_CLIENTS4packages/rmw/xrceheap: service-client slots inside xrce_session_state_t
NROS_XRCE_MAX_SERVICE_SERVERS4packages/rmw/xrceheap: service-server slots inside xrce_session_state_t
NROS_XRCE_MAX_SUBSCRIBERS8packages/rmw/xrceheap: subscriber slots inside xrce_session_state_t, which the XRCE backend allocates from the platform heap (nros_xrce_calloc) when a session opens — no named static, so mem-report cannot see it
NROS_XRCE_SERVICE_REPLY_BUFFER_SIZE1024packages/rmw/xrceheap: per-client reply buffer inside xrce_session_state_t
NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE1024packages/rmw/xrceheap: per-server request buffer inside xrce_session_state_t
NROS_XRCE_STREAM_HISTORY16packages/rmw/xrceheap: reliable-stream history depth; multiplies the stream buffers inside the session state
NROS_XRCE_SUBSCRIBER_BUFFER_SIZE1024packages/rmw/xrceheap: bytes per XRCE subscriber ring entry inside xrce_session_state_t
NROS_XRCE_SUBSCRIBER_RING_DEPTH32packages/rmw/xrceheap: ring depth of each XRCE subscriber slot inside xrce_session_state_t
NROS_XRCE_TRANSPORT_MTU4096packages/rmw/xrceheap: Micro-XRCE-DDS-Client transport MTU (UDP/TCP): sizes the transport’s buffer inside its transport struct
NROS_ZENOH_BATCH_MULTICAST_SIZEcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1295packages/tooling/nros-zephyr-buildheap: the multicast twin of the unicast batch buffers — heap at session open, no symbol
NROS_ZENOH_BATCH_UNICAST_SIZEcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1300packages/tooling/nros-zephyr-buildheap: zenoh-pico allocates its unicast TX/RX batch buffers (_z_wbuf_make/_z_zbuf_make) when a session opens — heap bytes, no symbol
NROS_ZENOH_FRAG_MAX_SIZEcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1316packages/tooling/nros-zephyr-buildheap: zenoh-pico’s defragmentation buffer, _z_wbuf_make(Z_FRAG_MAX_SIZE) on first fragment — heap, no symbol
NROS_ZENOH_GET_POLL_INTERVAL_MScomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1332packages/tooling/nros-zephyr-buildnot-a-size: milliseconds
NROS_ZENOH_GET_REPLY_BUF_SIZEcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1348packages/tooling/nros-zephyr-buildstack: the blocking z_get reply context is stack-allocated per call (zpico.c), so it costs stack in the caller, never static RAM
NROS_ZENOH_GRAPH_CACHE_SIZEcomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1304packages/rmw/zenoh/nros-zpico-buildpool g_sessions: the graph cache’s byte buffer, embedded in each struct zpico_session beside its mutex and subscriber handle
NROS_ZENOH_GRAPH_DISCOVERYcomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1319packages/rmw/zenoh/nros-zpico-buildnot-a-size: switches the graph cache on; its bytes are ZPICO_GRAPH_CACHE_SIZE
NROS_ZENOH_LEASE_PRIORITY16packages/rmw/zenoh/nros-zpico-buildnot-a-size: a task priority
NROS_ZENOH_MAX_LARGE_SUBSCRIBERS2packages/rmw/zenoh/nros-rmw-zenohprices LARGE_PAYLOADS (131,072 B at defaults)
NROS_ZENOH_MAX_LIVELINESScomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1131packages/rmw/zenoh/nros-zpico-buildpool g_sessions: liveliness-token entries inside each struct zpico_session
NROS_ZENOH_MAX_PENDING_GETS4packages/rmw/zenoh/nros-zpico-buildpool g_sessions: pending-get slots inside each struct zpico_session
NROS_ZENOH_MAX_PUBLISHERScomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1118packages/rmw/zenoh/nros-zpico-buildpool g_sessions: publisher entries inside each struct zpico_session; default derived per image from the entity inventory
NROS_ZENOH_MAX_QUERYABLEScomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1102packages/rmw/zenoh/nros-zpico-buildpool g_sessions, nros_rmw_zenoh::shim::service::SERVICE_BUFFERS, nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX: queryable entries inside each struct zpico_session and the per-queryable shim pools; default derived per image (a service server, a TRANSIENT_LOCAL publisher and the builtin param/lifecycle services each take one)
NROS_ZENOH_MAX_SESSIONS1packages/rmw/zenoh/nros-zpico-buildpool g_sessions, nros_rmw_zenoh::shim::service::SERVICE_BUFFERS, nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX: session count: the outer dimension of the zpico session table and of every per-session shim pool; the session element is the struct zpico_session C struct
NROS_ZENOH_MAX_SUBSCRIBERScomputed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1122packages/rmw/zenoh/nros-zpico-buildsizes SMALL_PAYLOADS — not priceable at defaults (knob NROS_ZENOH_MAX_SUBSCRIBERS has a computed default); pool g_sessions, nros_rmw_zenoh::shim::subscriber::SUBSCRIBER_BUFFERS: subscriber entries inside each struct zpico_session and the shim’s SubscriberBuffer table (also priced through SMALL_PAYLOADS); default derived per image
NROS_ZENOH_MAX_TL_PUBLISHERScomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:948packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS: retention slots for TRANSIENT_LOCAL publishers; the element is a RetainSlot struct and the count is derived per image
NROS_ZENOH_PUBLISHER_TX_BUFFER_SIZE1024packages/rmw/zenoh/nros-rmw-zenohelement: each publisher’s zero-copy lend arena (ZENOH_TX_BUF), inside the publisher entry wherever that is stored
NROS_ZENOH_READ_PRIORITY16packages/rmw/zenoh/nros-zpico-buildnot-a-size: a task priority
NROS_ZENOH_SERVICE_BUFFER_SIZEcomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:128packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::service::SERVICE_BUFFERS: payload bytes inside each ServiceBuffer struct; also the default for the two inbox slot sizes above
NROS_ZENOH_SUBSCRIBER_LARGE_SIZE16384packages/rmw/zenoh/nros-rmw-zenohprices LARGE_PAYLOADS (131,072 B at defaults)
NROS_ZENOH_SUBSCRIBER_RING_DEPTH4packages/rmw/zenoh/nros-rmw-zenohprices LARGE_PAYLOADS (131,072 B at defaults); sizes SMALL_PAYLOADS — not priceable at defaults (knob NROS_ZENOH_MAX_SUBSCRIBERS has a computed default); default DERIVED per image when it declares its entities
NROS_ZENOH_SUBSCRIBER_SIZE_THRESHOLD2048packages/rmw/zenoh/nros-rmw-zenohnot-a-size: the hint above which a subscription routes to the LARGE payload class — a routing threshold, not storage
NROS_ZENOH_TL_RETAIN_BYTEScomputed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:373packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS: bytes retained inside each RetainSlot
NROS_ZENOH_TL_RETAIN_DEPTH1packages/rmw/zenoh/nros-rmw-zenohpool nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS: samples each RetainSlot retains (issue 1709): multiplies the slot’s retained bytes and attachment, derived per image from the deepest declared transient-local KEEP_LAST; default DERIVED per image when it declares its entities
NROS_ZENOH_TX_BATCHcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1781packages/tooling/nros-zephyr-buildnot-a-size: a switch: whether zenoh-pico batches outgoing messages (phase-282)
NROS_ZENOH_TX_BATCH_FLUSH_MScomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1811packages/tooling/nros-zephyr-buildnot-a-size: a time: the longest a batched message waits before it is flushed (phase-282)
NROS_ZENOH_TX_SPLIT_LOCKcomputed — see packages/tooling/nros-zephyr-build/knobs.toml:1833packages/tooling/nros-zephyr-buildnot-a-size: a switch: whether the TX path takes its own lock instead of the session’s (phase-282)
NROS_ZEPHYR_HEAP_SIZEcomputed — see packages/platform/nros-platform/build.rs:66packages/platform/nros-platformheap nros_platform::zephyr_heap::HEAP: the rlsf arena both zenoh-pico’s z_malloc and Rust’s allocator draw from on Zephyr; the symbol IS the region, measured per image
ROSOUT_PROBE_PERIOD_MS250packages/testing/nros-tests/bins/rosout-talkertest-only: rosout-talker test binary
ROSOUT_PROBE_RECORDS60packages/testing/nros-tests/bins/rosout-talkertest-only: rosout-talker test binary

Conflicting defaults

These knobs are read in more than one place with DIFFERENT defaults, so setting one moves only part of the tree — the issue-0135 split-brain shape one layer up.

  • NROS_SUBSCRIBER_BUFFER_SIZE

Configuration surface

One fact can be spelled several ways. Only one of them is a place to make a decision; the rest carry that decision across a layer boundary. This page separates the two, because a list that does not is a list of nine ways to set a locator (issue 0934).

Every symbol on this page is one a user may set. Kconfig says so itself: each carries a PROMPT, which is what a prompt means. An earlier draft split these into public and carrier by asking who read them, and got the layer wrong — cmake reading ${CONFIG_X} to emit ZPICO_X=<value> is cmake CARRYING the choice, not owning it.

The real carriers are one layer down and are NOT Kconfig symbols: the ZPICO_* / NROS_* environment variables, the -D cache variables and the compile definitions this page’s symbols feed. Setting one of those by hand bypasses the question Kconfig asked.

The evidence column is where each value is consumed, so a symbol with no reader stands out: a prompt asking a question whose answer is discarded is worse than no prompt.

131 Kconfig symbols — 61 settable, 1 provided for application code, 69 derived, 0 dead.

Facts with more than one spelling

Where a decision has an authority OUTSIDE Kconfig, that authority is the place to set it and these symbols carry it.

RMW backend

Set it here: [system].rmw in the bringup’s system.toml

symboldefaultclassevidence
CONFIG_NROS_RMW_ZENOH—publiccmake, rust-source, kconfig select/depends
CONFIG_NROS_RMW_XRCE—publicc-source, cmake, rust-source
CONFIG_NROS_RMW_CYCLONEDDS—publiccmake, rust-source, kconfig select/depends

nano_ros_workspace(BACKEND …) and -DNROS_RMW are the cmake carriers. cmake already warns that BACKEND silently beats the cache var; nothing yet compares either against [system].rmw.

ROS domain id

Set it here: [system].domain_id in system.toml

symboldefaultclassevidence
CONFIG_NROS_DOMAIN_ID0publicforwarded-to-cargo, c-source, cmake, rust-source, kconfig select/depends
CONFIG_NROS_CYCLONE_DOMAIN_IDNROS_DOMAIN_IDpublicc-source, cmake, rust-source

CONFIG_NROS_CYCLONE_DOMAIN_ID defaults FROM NROS_DOMAIN_ID — the one derivation edge in this cluster. Pinning it to a literal is the phase-180 split-brain (CLAUDE.md).

Router / agent endpoint

Set it here: [system].locator in system.toml

symboldefaultclassevidence
CONFIG_NROS_ZENOH_LOCATORtcp/127.0.0.1:7447publicc-source, cmake, rust-source
CONFIG_NROS_XRCE_AGENT_ADDR127.0.0.1publicc-source, rust-source
CONFIG_NROS_XRCE_AGENT_PORT2018publicc-source, rust-source

Zephyr is exempt from the cmake precedence chain by design (see the exemption comments in NanoRosEntry.cmake — no line ref, they move), so the Kconfig spelling is the carrier there rather than an override.

Executor sizing

Set it here: environment knobs at build time (see the pool inventory)

symboldefaultclassevidence
CONFIG_NROS_EXECUTOR_MAX_CBS-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_MAX_SC-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_MAX_NODES-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_ARENA_SIZE0derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_MAX_MONITORS-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_MAX_AGE_MONITORS-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_MAX_SHUTDOWN_CBS2derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_ACTION_CLIENTS-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SUBSCRIPTION_BUFFER_SIZE-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_PARAM_SERVICE_BUFFER_SIZE-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PARAMETERS-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PARAM_NAME_LEN-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_STRING_VALUE_LEN-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_ARRAY_LEN-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_BYTE_ARRAY_LEN-1derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PARAM_DESCRIPTION_LEN256derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PARAM_CONSTRAINTS_LEN0derivedforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_PARAM_EVENTS_DEPTH1derivedforwarded-to-cargo, cmake, rust-source

On Zephyr the Kconfig spelling IS how a user sets these — it is a carrier only in the sense that the env var is what the build script reads. Issue 0460 is what happens when the bridge breaks.

Public — set these

Read by compiled code or forwarded to a build script. On Zephyr the CONFIG_ spelling IS the way to state these; the env var is the carrier.

symboldefaultevidence
CONFIG_NROS_ARENA_EXHAUSTION_IS_FATALy if NROS_BOOT_REPORTcmake, rust-source
CONFIG_NROS_BOOT_REPORTy if ARCH_POSIXc-source, cmake, rust-source, kconfig select/depends
CONFIG_NROS_CAPABILITY_LIFECYCLE—rust-source, kconfig select/depends
CONFIG_NROS_CAPABILITY_PARAM_SERVICES—cmake, rust-source, kconfig select/depends
CONFIG_NROS_CODEGEN_TOOL—cmake
CONFIG_NROS_CPP_API—cmake, rust-source, kconfig select/depends
CONFIG_NROS_CYCLONE_CONFIG_XML—c-source
CONFIG_NROS_C_API—cmake, rust-source
CONFIG_NROS_FAULT_LOGncmake, kconfig select/depends
CONFIG_NROS_FAULT_LOG_FLASHnc-source
CONFIG_NROS_HEAP_EXHAUSTION_IS_FATALy if NROS_BOOT_REPORTc-source, rust-source
CONFIG_NROS_MONITOR_ARM_GRACE_MS0forwarded-to-cargo, c-source, cmake, rust-source
CONFIG_NROS_MONITOR_ARM_ON_CALL—c-source, cmake, rust-source
CONFIG_NROS_PARAM_STORE—cmake, rust-source, kconfig select/depends
CONFIG_NROS_RUST_APINROS_RMW_ZENOHcmake, kconfig select/depends
CONFIG_NROS_SNTP_EPOCH—c-source, rust-source, kconfig select/depends
CONFIG_NROS_SNTP_SERVER10.0.2.2c-source
CONFIG_NROS_SNTP_TIMEOUT_MS3000c-source
CONFIG_NROS_TRACE_CALLBACKS—cmake, kconfig select/depends
CONFIG_NROS_TRACE_TAKES—c-source, cmake, rust-source
CONFIG_NROS_TRACE_TIMER_EVERY1forwarded-to-cargo, c-source, cmake, rust-source
CONFIG_NROS_TRANSPORT_SERIAL—kconfig select/depends
CONFIG_NROS_VIOLATION_DRAIN_REPORT—c-source, cmake, rust-source
CONFIG_NROS_ZENOH_AUTO_RECONNECTycmake
CONFIG_NROS_ZENOH_LEASE_FACTOR3c-define, cmake
CONFIG_NROS_ZENOH_LEASE_MS60000c-define, cmake
CONFIG_NROS_ZENOH_LINK_CANncmake, kconfig select/depends
CONFIG_NROS_ZENOH_LINK_ISOTPncmake, kconfig select/depends
CONFIG_NROS_ZENOH_LINK_SERIALncmake, kconfig select/depends
CONFIG_NROS_ZENOH_LINK_TCPycmake
CONFIG_NROS_ZENOH_LINK_UDP_MULTICASTncmake
CONFIG_NROS_ZENOH_LINK_UDP_UNICASTncmake
CONFIG_NROS_ZENOH_LINK_WSncmake
CONFIG_NROS_ZENOH_MULTI_THREADycmake, kconfig select/depends
CONFIG_NROS_ZENOH_PUBLICATIONycmake
CONFIG_NROS_ZENOH_QUERYycmake
CONFIG_NROS_ZENOH_QUERYABLEycmake
CONFIG_NROS_ZENOH_RAWETH_TRANSPORTncmake
CONFIG_NROS_ZENOH_SCOUTINGncmake
CONFIG_NROS_ZENOH_SERIAL_FIFO_RXWATER1c-define
CONFIG_NROS_ZENOH_SERIAL_FIFO_WATERMARK—cmake, kconfig select/depends
CONFIG_NROS_ZENOH_SERIAL_ISR_STATS—cmake
CONFIG_NROS_ZENOH_SERIAL_RX_RING_BYTES1024cmake
CONFIG_NROS_ZENOH_SERIAL_TCM—cmake, kconfig select/depends
CONFIG_NROS_ZENOH_SERIAL_TX_RING_BYTES2048cmake
CONFIG_NROS_ZENOH_SERIAL_UART_ASYNC—cmake, kconfig select/depends
CONFIG_NROS_ZENOH_SERIAL_UART_ASYNC_RX_TIMEOUT_US25cmake
CONFIG_NROS_ZENOH_SUBSCRIPTIONycmake
CONFIG_NROS_ZEPHYR_MAX_TIERS4c-define, c-source, cmake, rust-source
CONFIG_NROS_ZEPHYR_STACKS_IN_DTCMncmake
CONFIG_NROS_ZEPHYR_TASK_SLOTS4c-define, c-source, cmake
CONFIG_NROS_ZEPHYR_TASK_STACK_SIZE0c-define, c-source, cmake
CONFIG_NROS_ZEPHYR_TIER_STACK_SIZE16384c-define, c-source, cmake, rust-source

Provided for application code

No reader in THIS tree, and not dead: the guides teach an application to use these in its own main(). Kconfig generating the macro is the service. Each names where that contract is written down.

symboldefaultevidence
CONFIG_NROS_INIT_DELAY_MS2000docs/guides/cpp-api.md shows an application calling nros_platform_zephyr_wait_network(CONFIG_NROS_INIT_DELAY_MS) from its own main(); docs/guides/zephyr-setup.md:220 documents it as a knob.

Derived — selected by another symbol

No Kconfig prompt, so not asked about directly.

symboldefaultevidence
CONFIG_NROS_ACTION_INBOX_BYTES-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_ACTION_INBOX_DEPTH4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_BATCH_UNICAST_SIZE1024forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_EXECUTOR_BACKING_U64S-1rust-source
CONFIG_NROS_EXECUTOR_MAX_VIOLATIONS8forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_FRAG_MAX_SIZE2048forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_GET_POLL_INTERVAL_MS10forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_GET_REPLY_BUF_SIZE4096forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_GRAPH_CACHE_SIZE65536cmake, rust-source
CONFIG_NROS_GRAPH_MAX_ENTITIES0rust-source
CONFIG_NROS_LOG_BUFFER_SIZE256forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_LOG_DYNAMIC_LOGGERS16forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_LOG_EARLY_RECORDS4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_LOG_MAX_LEVEL0forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_LOG_ROSOUT_RECORDS16forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_LARGE_SUBSCRIBERS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_LIVELINESS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PENDING_GETS4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_PUBLISHERS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_QUERYABLES-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_MAX_SUBSCRIBERS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_PARAM_SERVICE_INBOX_BYTES-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_PARAM_SERVICE_INBOX_DEPTH1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_PUBSUB_QOS_DEPTH10rust-source
CONFIG_NROS_RMW_LOCAL_QUERYABLE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_RMW_SUBSCRIBER_SLOTS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SERVICE_BUFFER_SIZE1024forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SERVICE_INBOX_BYTES-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SERVICE_INBOX_DEPTH4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SUBSCRIBER_LARGE_SIZE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_SUBSCRIBER_RING_DEPTH4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_BUFFER_SIZE1024forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_CUSTOM_TRANSPORT_MTUNROS_XRCE_TRANSPORT_MTUforwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTS4forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_MAX_SUBSCRIBERS-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_SERVICE_REPLY_BUFFER_SIZE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_STREAM_HISTORY16forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_SUBSCRIBER_BUFFER_SIZE-1forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTH32forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_XRCE_TRANSPORT_MTU512forwarded-to-cargo, cmake, rust-source, kconfig select/depends
CONFIG_NROS_ZENOH_GRAPH_DISCOVERY`n if (NROS_ZENOH_LINK_SERIAL
CONFIG_NROS_ZENOH_LEASE_PRIORITY200c-define, cmake, rust-source
CONFIG_NROS_ZENOH_READ_PRIORITY200c-define, cmake, rust-source
CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS100c-define, cmake, rust-source
CONFIG_NROS_ZENOH_TX_BATCHycmake, rust-source, kconfig select/depends
CONFIG_NROS_ZENOH_TX_BATCH_FLUSH_MS50forwarded-to-cargo, cmake, rust-source
CONFIG_NROS_ZENOH_TX_SPLIT_LOCKycmake, rust-source
CONFIG_NROS_ZEPHYR_HEAP_SIZE1048576 if NROS_RMW_CYCLONEDDSforwarded-to-cargo, c-source, cmake, rust-source

Quick Reference

Finding Commands

Root commands are grouped by audience:

just --list                         # grouped root command overview
just --groups                       # group names
just --group main --list            # normal development workflows
just --group full-matrix --list     # build-all / fixtures / test-all / ci
just --group setup --list           # setup and doctor entry points
just --group maintenance --list     # clean and generated-binding commands
just --group docs --list            # book and API docs

Platform and backend commands stay namespaced:

just --group full-matrix --list zephyr
just zephyr build-fixtures
just zephyr build-all
just qemu setup-network             # QEMU TAP networking only
just zephyr help                    # Zephyr-specific help

Install the local book tooling before previewing docs:

just setup-docs                     # mdbook + mdbook-mermaid
just book-serve                     # serve book/src with live reload
just book                           # full deployed preview with API docs

Test profiling & slow-test reporting

just test and just test-all always print the slowest tests after the run summary — the top 20 by duration (binary, test name, time, status), parsed from the active profile’s target/nextest/<profile>/junit.xml. No flag needed; it is part of the normal output.

Deeper profiling is opt-in and adds nextest’s experimental event/output recording plus artifact export. It preserves the normal nextest execution model (same filters, cargo profile, parallelism) — it only records and exports, so leave it off for routine runs:

NROS_NEXTEST_RECORD=1 just test
NROS_NEXTEST_RECORD=1 just test-all

Each profiled run writes a timestamped directory under tmp/ with a stable -latest symlink:

tmp/nextest-profile-test-YYYYMMDD-HHMMSS/      tmp/nextest-profile-test-latest -> …
tmp/nextest-profile-test-all-YYYYMMDD-HHMMSS/  tmp/nextest-profile-test-all-latest -> …

Artifacts in that directory:

  • nextest-run.zip — portable recording archive; replay with full captured output (incl. successful tests) via cargo nextest replay.
  • nextest-trace.json — Chrome/Perfetto timeline (slot/group occupancy, idle slots, retries, long-pole tests). Canonical concurrency artifact.
  • junit.xml — copy of the run’s JUnit report.
  • env.txt, command.txt — the knobs and exact command used.

Knobs:

VariableEffect
NROS_NEXTEST_RECORD=1Enable recording + artifact export.
NROS_NEXTEST_RECORD_DIR=<path>Override the output dir (and its -latest link).
NROS_NEXTEST_TRACE_GROUP_BY=slot|binaryPerfetto grouping; default slot (wall-clock/concurrency view).
NROS_NEXTEST_REPLAY_LOG=1Also write nextest-replay.log (full captured stdout/stderr — can be large on chatty tests). Off by default; rely on the portable archive otherwise.
NROS_NEXTEST_RECORD_KEEP_STATE=1Keep the temp NEXTEST_STATE_DIR (<dir>/state); removed after export otherwise.
NROS_NEXTEST_RUN_PROFILE=fail-fastStop at the first failure instead of the default --no-fail-fast full report (uses target/nextest/fail-fast/junit.xml).

Overhead and retention: recording adds event/output-store writes during the run, and nextest-run.zip (plus an enabled nextest-replay.log) can get sizable on output-heavy suites — keep it opt-in for local runs and prune old tmp/nextest-profile-* directories. Recording uses a profile-local NEXTEST_STATE_DIR, so it never pollutes the user’s global nextest record store. Do not reach for --no-capture to inspect output: it serializes execution and skews every timing. Use the replay archive instead.

Manual Testing

# Base setup builds/checks the in-tree zenoh router.
just setup base
source ./activate.sh

# Terminal 1: Router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Talker
cd examples/linux/rust/talker && nros sync && nros build
RUST_LOG=info ./build/linux/target/debug/talker

# Terminal 3: Listener
cd examples/linux/rust/listener && nros sync && nros build
RUST_LOG=info ./build/linux/target/debug/listener

Each leaf’s RMW comes from its system.toml [system] rmw, and its artifact path is build/<image-id>/target/[<triple>/]<profile>/<bin>.

UDP Transport

On the native (host) build, zenoh-pico has built-in UDP support via OS sockets:

# Use UDP instead of TCP for the zenoh locator
NROS_LOCATOR=udp/127.0.0.1:7447 ./build/linux/target/debug/talker

On bare-metal, enable the link-udp-unicast feature to use UDP over smoltcp:

nros = { features = ["rmw-zenoh", "platform-bare-metal", "link-tcp", "link-udp-unicast"] }

The rmw-zenoh feature here is the lowering of the declared RMW — you declare the backend once in system.toml ([system].rmw / [image.<id>].rmw) and the toolchain sets the cargo feature; the feature is what the build uses, not the user-facing selector. See RFC-0031.

TLS Transport

TLS layers on top of TCP using mbedTLS. Requires a self-signed certificate (or real CA cert) and the link-tls Cargo feature.

Generate a test certificate:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

Native (host) (requires libmbedtls-dev – installed by just setup base):

# Terminal 1: Router with TLS
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tls/localhost:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd \
  --cfg 'transport/link/tls/listen_certificate:"cert.pem"' \
  --cfg 'transport/link/tls/listen_private_key:"key.pem"'

# Terminal 2: Talker with TLS
cd examples/linux/rust/talker && nros sync
cd .. && cargo build --manifest-path talker/Cargo.toml \
      --config talker/build/linux/nros-cargo.toml --features link-tls
NROS_LOCATOR=tls/localhost:7447 \
  ZENOH_TLS_ROOT_CA_CERTIFICATE=cert.pem \
  ./talker/build/linux/target/debug/talker

link-tls is a transport feature of the leaf’s own crate, so it stays a --features flag; the RMW is not — that is [system] rmw in system.toml.

Bare-metal (QEMU ARM):

On bare-metal, only base64-encoded certificates are supported (no filesystem). Build with --features link-tls:

# Build TLS-enabled examples
cd examples/mps2-an385-baremetal/rust/talker && nros sync
cd .. && cargo build --release --features link-tls \
      --manifest-path talker/Cargo.toml \
      --config talker/build/mps2-an385-baremetal/nros-cargo.toml

The CA certificate must be passed via ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64 at runtime (through the zenoh config), or embedded in the binary at build time.

ROS 2 Interop

# Terminal 1: Router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: nros talker
cd examples/linux/rust/talker && nros sync && nros build
RUST_LOG=info ./build/linux/target/debug/talker

# Terminal 3: ROS 2 listener
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Actions

# Terminal 1: Router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Action server (Fibonacci example)
cd examples/linux/rust/action-server && nros sync && nros build
./build/linux/target/debug/action-server

# Terminal 3: Action client
cd examples/linux/rust/action-client && nros sync && nros build
./build/linux/target/debug/action-client

Zephyr action tests:

just zephyr build-all          # Build all Zephyr examples (Rust + C + C++ + XRCE)
just zephyr test               # Run zenoh E2E tests (covers actions)

Docker Development Environment

Docker provides QEMU 7.2 (from Debian bookworm) which fixes TAP networking issues present in Ubuntu 22.04’s QEMU 6.2.

# One-time setup: add yourself to docker group
sudo usermod -aG docker $USER
# Log out and back in, or run: newgrp docker

# Build and use Docker environment
just docker build              # Build nano-ros-qemu image
just docker shell              # Interactive shell
just docker test-qemu          # Run QEMU tests in container
just docker help               # Show all Docker commands

QEMU Bare-Metal Testing

Run bare-metal Cortex-M3 examples on QEMU (MPS2-AN385 machine with LAN9118 Ethernet).

# Build prerequisites
just qemu build-zenoh-pico    # Build zenoh-pico for ARM Cortex-M3
just qemu build                # Build all QEMU examples

# Non-networked tests (no setup required)
just qemu test                # Bare-metal QEMU integration tests

# Networked talker/listener test (Docker Compose - recommended)
just docker test-qemu         # Runs zenohd, talker, listener in separate containers

Docker Compose Architecture:

┌─────────────────────────────────────────────────────────────┐
│              Docker Network: 172.20.0.0/24                  │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   zenohd    │  │   talker    │  │      listener       │  │
│  │ 172.20.0.2  │  │ 172.20.0.10 │  │    172.20.0.11      │  │
│  │             │  │  ┌───────┐  │  │  ┌───────────────┐  │  │
│  │             │  │  │ QEMU  │  │  │  │     QEMU      │  │  │
│  │             │  │  │ ARM   │──┼──┼──│     ARM       │  │  │
│  │             │  │  │ TAP   │  │  │  │     TAP       │  │  │
│  │             │  │  └───────┘  │  │  └───────────────┘  │  │
│  └──────▲──────┘  └──────┼──────┘  └─────────┼───────────┘  │
│         └────────────────┴───────────────────┘              │
│                    NAT to zenohd                            │
└─────────────────────────────────────────────────────────────┘

Manual networked test (3 terminals, requires host TAP setup):

# Terminal 1: Setup network + start router
just qemu setup-network                    # Requires sudo
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Talker (192.0.2.10)
bin=build/cargo-fixtures/baremetal/thumbv7m-none-eabi/nros-relwithdebinfo
./scripts/qemu/launch-mps2-an385.sh --tap tap-qemu0 --binary "$bin/qemu-bsp-talker"

# Terminal 3: Listener (192.0.2.11)
./scripts/qemu/launch-mps2-an385.sh --tap tap-qemu1 --binary "$bin/qemu-bsp-listener"

Build those first with just qemu build-fixtures (or just build-test-fixtures lane=linux).

The artifact directory is computed, not fixed. Since phase-340 P2 a platform’s cargo fixtures build into a shared group directory under build/cargo-fixtures/<group>/ rather than each leaf’s own target/, and the profile is nros-relwithdebinfo, not release. The group name can carry a hash suffix when a row builds with distinct env, so treat the path above as what it is on a default build, not as a guarantee — scripts/build/fixtures-target-dir.sh (nros_fixture_row_artifact_dir) is the single source of truth, and it is what the test side uses to find the same file. That helper takes the row’s cargo args and env as arguments, so a script that did not itself run the build should ask for the row by id — nros_fixture_row_artifact_dir_by_id <row-id> reads all three key fields from the manifest, and cannot disagree with the builder about which group a row landed in (issue 1025).

Run just qemu help for more options.

Zephyr Setup

just setup zephyr       # Initialize workspace at $repo/zephyr-workspace/
just zephyr test        # Run zenoh tests (native_sim uses NSOS on host loopback)
just zephyr test-xrce   # Run XRCE tests

The workspace lives at $repo/zephyr-workspace/ by default (gitignored). Set $NROS_ZEPHYR_WORKSPACE to install elsewhere. Legacy sibling installs at ../nano-ros-workspace/ are auto-detected; run ./scripts/zephyr/migrate-workspace.sh to consolidate.

See Zephyr for full details.

nros-bridge.toml — bridge configuration schema

Consumed by nros::run_from_config (Rust) and the future C/C++ mirror (nros::run_from_config in nros-cpp).

Not the orchestration nros.toml. This is the bridge runtime config (multi-RMW byte forwarding). It is named nros-bridge.toml to avoid colliding with the orchestration nros.toml (component/system build config) — different lifecycle, different schema.

The file lives next to the binary (or anywhere the program can read it) and selects:

  1. Which RMW backends to open, under what locator / domain.
  2. How those backends are wired into bridge entries that forward traffic between them.

The binary’s Cargo.toml (or target_link_libraries) still selects which backends are linked — the TOML file selects which of the linked backends are used in this run. Backend names in the file that don’t match a linked backend surface as ConfigError::OpenSession.

Top-level structure

# nros-bridge.toml — sibling of the binary
[[node]]
name    = "field"
rmw     = "zenoh"
locator = "tcp/10.0.0.1:7447"

[[node]]
name    = "control"
rmw     = "cyclonedds"
locator = "domain=0"

[[bridge]]
type      = "std_msgs/Int32"
type_hash = "RIHS01_..."
from      = { node = "field",   topic = "/sensor/raw" }
to        = { node = "control", topic = "/sensor/raw" }

Run via:

fn main() -> Result<(), nros_bridge::ConfigError> {
    nros_bridge::run_from_config("nros-bridge.toml")
    // or, via the umbrella feature: nros::run_from_config("nros-bridge.toml")
}

[[node]]

One block per backend session. The first [[node]] becomes the primary session (extra_sessions[0] in Executor::open_multi); the rest open as extras keyed by rmw.

KeyTypeDefaultNotes
namestringrequiredLogical name. Bridge entries reference this.
rmwstringrequiredCanonical backend name: "zenoh", "xrce", "cyclonedds". Must match a backend the binary linked in.
locatorstring""Backend-specific locator. Zenoh uses tcp/..., udp4/..., serial/...; DDS uses domain=<n>; XRCE uses udp4://...:port.
domain_idu320ROS domain id passed to the backend.
namespacestring"/"Default namespace applied to handles created on this node.

Locator scheme grammar (zenoh)

SchemeExampleMeaning
tcp/<host>:<p>tcp/10.0.0.1:7447TCP unicast
udp/<host>:<p>udp/10.0.0.1:7447UDP unicast
serial/<dev>serial//dev/ttyUSB0UART (host-side) or board UART (bare-metal)
tls/<host>:<p>tls/router.example.org:7447TLS over TCP (requires link-tls on backend)

DDS / XRCE follow their native locator conventions; consult each backend’s docs for the exact form.

[[bridge]]

One block per topic forwarded between two [[node]]s. Each bridge creates a RawSubscription on the source side and an EmbeddedRawPublisher on the destination side and pumps bytes once per executor tick.

KeyTypeDefaultNotes
typestringrequiredROS type name ("std_msgs/Int32"). Backends use it for liveliness / discovery.
type_hashstring""ROS 2 RIHS type hash for the typed binding. May be left empty if both sides ignore it.
fromendpointrequired{ node = "<name>", topic = "<topic>" }. The source node + topic.
toendpointrequired{ node = "<name>", topic = "<topic>" }. The destination node + topic.

Bidirectional bridges are two [[bridge]] entries — one in each direction. The runtime stamps a bridge_origin attachment field on forwarded frames and drops on receive when the origin matches the local backend, so an echo pair does not loop.

Error semantics

run_from_config returns ConfigError:

VariantCause
IoFile read failed (missing / unreadable).
ParseTOML malformed or a required field missing.
UnknownNodeA [[bridge]] references a node name no [[node]] declared.
OpenSessionExecutor::open_multi rejected a spec — usually a backend name no RMW_INIT_ENTRIES entry registered under.
BuildNodecreate_node_on failed (registry exhausted, name too long, …).
BuildEntityCreating the source subscription or destination publisher failed (typically backend rejection of the topic name / type / QoS).

Any error short-circuits the runtime; bridges that opened cleanly before the failure are not pumped.

Linked-backend matrix

Backend nameCargo dep that contributes the RMW_INIT_ENTRIES entry
zenohnros-rmw-zenoh = { ... }
xrcenros-rmw-xrce-cffi = { ... }
cycloneddsnros-rmw-cyclonedds static lib (CMake side, --whole-archive)

Add a backend to your Cargo.toml (or target_link_libraries) to make its name available in the TOML; remove it to fence off use even when the TOML file lists it (yields OpenSession at startup).

Migrating off just install-local

TL;DR. deleted just install-local, the build/install/ prefix, every install(...) rule, every Config.cmake.in template, and the find_package(NanoRos) consumption path. nano-ros is now consumed exclusively via add_subdirectory(<repo-root>) from the user’s CMakeLists.txt.

If your project was on a pre-140 checkout, this page is the one-page rewrite that gets you onto the supported shape.

Before (pre-140)

cmake_minimum_required(VERSION 3.16)
project(my_app C)

find_package(NanoRos REQUIRED CONFIG)
nano_ros_generate_interfaces(std_msgs SKIP_INSTALL)
add_executable(my_app src/main.c)
target_link_libraries(my_app PRIVATE
    std_msgs__nano_ros_c
    NanoRos::NanoRos)

Build:

# In the nano-ros checkout:
just install-local                                # populates build/install/

# In the user project:
cmake -S . -B build -DCMAKE_PREFIX_PATH=<nano-ros>/build/install
cmake --build build

After (post-140)

cmake_minimum_required(VERSION 3.22)
project(my_app C)

set(NROS_PLATFORM posix)
set(NROS_RMW     zenoh)
add_subdirectory(<path-to-nano-ros> nano_ros)

nano_ros_generate_interfaces(std_msgs SKIP_INSTALL)
add_executable(my_app src/main.c)
target_link_libraries(my_app PRIVATE
    std_msgs__nano_ros_c
    NanoRos::NanoRos)
nros_platform_link_app(my_app)

Build:

# No nano-ros-side install step. Just configure your project.
cmake -S . -B build
cmake --build build

What changed

Pre-140Post-140
find_package(NanoRos REQUIRED CONFIG)add_subdirectory(<path-to-nano-ros>) after set(NROS_PLATFORM ...) + set(NROS_RMW ...)
just install-local(deleted) — nano-ros builds in-tree per-example via Corrosion
cmake -DCMAKE_PREFIX_PATH=<nano-ros>/build/install(not needed)
nano_ros_link_platform(target) / nano_ros_link_rmw(target)(folded into NanoRos::NanoRos’s INTERFACE link libraries)
nros_freertos_compose_platform(target) / nros_threadx_compose_platform(target)nros_platform_link_app(target) — single per-app fixup hook
find_package(NrosRmwCyclonedds CONFIG REQUIRED)(auto-wired when NROS_RMW=cyclonedds)
cmake_minimum_required(VERSION 3.16)cmake_minimum_required(VERSION 3.22) (matches root nano-ros)

RTOS-specific notes

  • Zephyr. Consume nano-ros via the zephyr/ module — drop nano-ros into your west.yml, set CONFIG_NROS=y in prj.conf, and state the RMW as the image’s rmw in a system.toml beside it (phase-481).
  • ESP-IDF. integrations/nano-ros/ is a component manifest — add it to your idf_component.yml.
  • NuttX. integrations/nuttx/ is a apps/external/ shim — symlink (or copy) and enable via make menuconfig.
  • PX4. integrations/px4/module-template/ is a EXTERNAL_MODULES_LOCATION template — copy out, edit the user glue, point PX4 at it.

Each shell internally does add_subdirectory(<nano-ros>) with the right NROS_PLATFORM cache var set; user-side code is the same as the C/C++ snippet above.

What you lose

  • A pre-built <prefix>/lib/libnros_c.a you could link from arbitrary external projects. Post-140, each consumer builds the staticlib in its own build tree (Corrosion handles caching via the Cargo target dir).
  • The build/install/share/nano-ros/interfaces/ bundled-interface drop. Codegen now resolves interface files via the ament index (when colcon-discovered) or directly from packages/cli/interfaces/ inside the nano-ros checkout.
  • The cmake --install build step. There is nothing nano-ros-side to install. Your own project ships its binary; nano-ros is a static dependency.

Why this change

find_package(NanoRos) was a Debian-style “library installed once, consumed by many projects” model. The RTOS workflows that nano-ros actually targets (Zephyr west, ESP-IDF, NuttX apps/external, PX4 EXTERNAL_MODULES_LOCATION) all consume dependencies as source trees inside the user’s workspace — not from an installed prefix. The install path drifted out of sync with the cargo path more than once (UDP multicast linker error was the proof) and added ~30 s warm / ~10 min cold to every test-all run. Removing it collapses two surfaces into one.

See docs/roadmap/phase-140-install-local-rip-off.md for the full design notes and the audit table that drove the deletion.