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;
node.create_timer_repeating(core::time::Duration::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.
What you get
- One node, any board. You write a node once. Moving it to another board
is one line, the
board =in the image’ssystem.toml. The target triple, linker flags, backend and pool sizes all follow from that line, so neither your code nor your build files change. - Talk to ROS 2 as it is. Your nodes join a stock ROS 2 graph: over Zenoh
with
rmw_zenoh_cpp, or over plain RTPS withrmw_cyclonedds_cpp. Topics, services and actions work in both directions. - Pick the middleware, keep the code. Zenoh (no agent), XRCE-DDS (agent,
micro-ROS compatible) or Cyclone DDS (RTPS). Switching is one line in
system.toml, then build again. - Fits a microcontroller. The core is
no_std, with theallocandstdfeatures opt-in, and runs on a bare-metal Cortex-M3. Whether your image needs an allocator depends on the backend you choose: Choosing an RMW Backend says which does. - Rust, C or C++. The core is Rust, with C and C++ APIs over it; the C API follows rclc conventions. One workspace can mix all three.
- Your ROS 2 install’s messages. Message bindings are generated from the message definitions of the ROS 2 install you build against, so the types and their hashes match your ROS 2 peers. Source ROS 2 once for a project’s first build; later builds reuse what it recorded.
- Real-time you can reason about. Fixed-size pools sized at build time, plus scheduling and safety features for RTOS targets: see Real-Time and Safety.
- Checked, not only tested. Serialization, scheduling and the safety protocol are covered by model checking and machine-checked proofs, and every example is held identical across platforms by a test.
Does it work on the board I have?
Each board’s support status, where it runs (host, emulator or hardware) and
how often it is checked come from the board registry, and the book shows them
on generated pages: Supported Boards lists
every board, Board support levels
says what each status means, and each board has its own evidence page.
nros board list and nros board show <board> print the same facts from the
nros you installed.
A board that is not in the registry can still run nano-ros through the customization ladder: the out-of-tree STM32F4 worked example shows the pattern, and the PX4 module template brings nano-ros into PX4 firmware as an external module (PX4; C++ only, because PX4’s uORB binding is C++-only). MPS2-AN385 bare-metal is Rust-only: the C and C++ APIs need an RTOS for libc and a heap.
The ESP32 (both the ESP-IDF component and the esp-hal bare-metal board) is retired: ESP32 — retired.
Supported platforms (by RTOS)
| Platform | RTOS | Network Stack | Targets |
|---|---|---|---|
| POSIX | Linux | OS sockets | x86-64, aarch64 |
| Bare-metal | None | smoltcp | Cortex-M3 |
| FreeRTOS | FreeRTOS | lwIP | Cortex-M3 (QEMU) |
| NuttX | NuttX | BSD sockets | Cortex-A7 (QEMU), RISC-V32 (QEMU rv-virt) |
| ThreadX | ThreadX | NetX Duo | RISC-V 64 (QEMU), Linux sim |
| Zephyr | Zephyr | Zephyr sockets | Various 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.
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 2rmw_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 stockrmw_cyclonedds_cpp.
Application code is identical regardless of backend — switching is editing
[system] rmw and building again. (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.
| Capability | Status |
|---|---|
| Pub/Sub | Complete |
| Services | Complete |
| Actions | Complete |
| Parameters | Complete |
| ROS 2 interop | Complete |
| Zenoh backend | Complete |
| XRCE-DDS backend | Complete |
| Cyclone DDS backend | Complete (native + embedded; some embedded action paths in progress) |
| Zephyr support | Complete |
| QEMU bare-metal | Complete |
| C API | Complete |
| C++ API | Complete |
| Message codegen | Complete |
How This Book Is Organized
- Get Started: install, build and run your first project.
- Build a Project: workspace layout, messages, configuration, build profiles.
- Run on a Target:
nros run, and each platform’s setup. - Communicate: choosing a backend, QoS, ROS 2 interop, transports.
- Real-Time and Safety: execution model, scheduling, static memory, production readiness.
- Operate and Debug: logging, time, diagnostics.
- Concepts and Rationale: why nano-ros is shaped the way it is.
- Extending nano-ros: add a board, a platform or an RMW backend from outside the tree.
- Reference: the CLI, APIs, boards, configuration.
- Migrating: moving from an older release.
Changing nano-ros itself is a different job, with its own book: nano-ros Internals.
Choose Your Entry
This book is organized around what you are trying to do with nano-ros, not around who you are. Pick the purpose closest to yours. Each section lists the pages to read, in order.
Everyone: your first project
Every purpose starts the same way:
-
Install the
nrosCLI. -
First Project scaffolds a talker for a Cortex-M3 board, runs it in QEMU and shows its messages in ROS 2:
nros setup mps2-an385-baremetal --rmw zenoh nros new sensor_ws --board mps2-an385-baremetal --template talker cd sensor_ws nros run sensor_nodeNo QEMU or ROS 2? The same chapter runs it on your Linux host.
-
Anatomy of What You Just Built explains the workspace in nano-ros’s own terms. You do not need to open the bringup package before you have seen it run.
New to ROS entirely? Two ideas carry the whole book. A node is a unit that computes, and nodes talk through named topics (publish/subscribe) and request/reply services. An RMW (“ROS MiddleWare”) backend is the transport that moves the bytes. nano-ros is a small implementation of that model for microcontrollers.
If anything goes wrong in the first steps, see Troubleshooting — First 10 Minutes.
Industrial: reproducible, auditable, offline
You build products where safety and robustness matter. You need the same inputs to give the same image, every fact on a page, and an install that runs with no network.
- What is supported, and how it is checked.
Supported Boards and
Board support levels are generated
from the board registry.
nros doctor --reportprints the status of every board your bringup’s images name, as a report you can file. - Pinning and offline builds.
Pinning a version
(
nros pin), and Integrating into a Vendored Tree for a company tree with air-gapped CI.nros build --offlinefails, naming what is missing, rather than fetching it. - Your RTOS and your build tool.
How Integration Works:
your RTOS keeps its own build tool (west, make, CMake, your IDE) and
nano-ros plugs into it. Build as a CMake
subdirectory covers a tree
with no
system.toml. - Real-time and memory bounds.
Execution Model and Two-Layer API,
Scheduling Models and the generated
Scheduling Wiring Matrix,
no_std,alloc, andstd, Dispatch Strategy, and the Static Pool Inventory. - Safety and adoption gates. Safety Protocol (E2E CRC, EN 50159 mapping) and the Production Readiness Checklist.
- Your own board or BSP.
Custom Board Package,
Vendor Overlay and the
STM32F4 out-of-tree worked example.
A board of yours lives in your tree (
NROS_EXTRA_BOARD_PATH). A new platform (a new RTOS) still means changing nano-ros itself. - Comparisons. Differences from Standard ROS 2, nano-ros vs micro-ROS, Choosing an RMW Backend and the generated Per-RMW Feature Matrix.
Field solutions: a working system per customer
You build a system for each customer, often on a board you were handed, and you need it publishing data that a ROS 2 system can see, quickly.
- Your first project (above), on the host.
- Your board. Find it in Supported Boards; its evidence page says where it runs and which chapter covers it. Then read your platform’s chapter (FreeRTOS, Zephyr, NuttX, ThreadX or bare-metal). Every embedded chapter has a QEMU flow that works without hardware.
- Run it on the target. The target model
and
nros runfor a host process or an emulator. - Talk to ROS 2. Choosing an RMW
and ROS 2 Interoperability. Cyclone DDS
talks to
rmw_cyclonedds_cppdirectly, with no router. Zenoh needs the ROS 2 router (rmw_zenoh_cpp) on the host. - Grow the system. More nodes, parameters and images: Growing your workspace, and Mixed-language workspaces for other languages. Bridging several networks: Cross-backend Bridges.
- Coming from rclcpp or rclpy? Setup Compared to Standard ROS 2, Porting a ROS 2 C++ node, C / C++ multi-node workspaces and the Migration Guide.
Education: labs that fit a session
You teach a course or run a lab. Every seat needs the same result, the install must be short, and each lab must fit one session.
- Install once per seat: Installation. Nothing in the first project needs ROS 2, a router or hardware.
- The first lab is the first project (above), in C++ or, on the same page, in Rust. The output it prints is the result to check on every seat.
- Concepts for the lecture. Anatomy, Architecture Overview and Execution Model and Two-Layer API.
- Embedded labs without hardware. Each platform chapter runs its example under QEMU, for example FreeRTOS or bare-metal.
- Labs that talk to ROS 2. Choosing an RMW; Cyclone DDS needs no router, which keeps a lab machine simple.
Still not sure?
Read the Introduction for the one-page overview.
Design background
- RFC-0116: adoption purposes and front doors — the three purposes this page routes by
Installation
Three steps take a bare Linux machine to one that builds nano-ros: install a
few host packages, install the nros CLI, and let nros setup provision the
toolchains for your board.
A release carries everything else. The nros CLI provisions toolchains,
scaffolds projects, generates message bindings and drives the build. The
library ships inside the release as source, and nros build compiles it into
your project. There is no prebuilt library, no system-wide install, and no
nano-ros clone: your project never names a path into nano-ros.
Working on nano-ros itself? That needs a checkout and its own build of the CLI; see Contributing.
Host prerequisites
The host needs only the basics: git, curl, a C toolchain, CMake,
pkg-config, Python 3, zstd, and the Python packages ROS message
generation uses. nros setup provides everything toolchain-shaped later.
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
Arch:
sudo pacman -S --needed git curl base-devel cmake python python-pip zstd
Fedora and Arch ship no rosidl packages, so install the Python side with pip (in a venv if your Python is externally managed):
pip install --user catkin_pkg 'empy==3.3.4' lark PyYAML
macOS is not supported. Installation Details says what each package is for.
Install the nros CLI
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
install.sh downloads the newest release for your host, checks its sha256,
and installs it under ~/.nros. Add the export PATH=… line to your shell’s
startup file. Check the install:
nros version # the CLI and library versions
nros doctor # SDK paths, toolchains, environment
Provision your board
nros setup fetches what a board needs: the cross-compiler, the emulator,
the RMW host tools and the board’s SDK sources, plus a Rust toolchain if the
host has none. You do not install cross-toolchains by hand.
nros setup <board> --rmw <zenoh|xrce|cyclonedds>
For a first project on Linux, use Cyclone DDS. It runs in-process, so it needs no router and no agent:
nros setup linux --rmw cyclonedds
nros board list names every board. nros setup <board> --dry-run prints
what a board pulls without fetching anything. Inside a workspace, a bare
nros setup provisions every image it declares.
Do I need ROS 2 installed?
Yes, to build. nros build, nros run and nros configure generate
your project’s message bindings from the message definitions of your ROS 2
install, so the types and their hashes match the ROS 2 nodes you talk to.
Every talker uses std_msgs, so install ROS 2 (the book uses Humble) and
source it before the first build of a project:
source /opt/ros/humble/setup.bash
That first configure records the ROS 2 prefix in the project, so later
builds and runs of that project work from a shell that has not sourced ROS.
Without a sourced ROS 2 and without that record, the build stops with
`AMENT_PREFIX_PATH` is unset and no configure of … has recorded one to reuse; Troubleshooting
has the fix.
ROS 2 also provides:
- the Zenoh router, ROS’s
rmw_zenohd, which two Zenoh nodes meet through (Cyclone DDS needs no router); ros2 topic echoandros2 node list, to watch your nodes;- the existing ROS 2 graph your nodes interoperate with.
Installation Details covers the router and the XRCE agent.
Next steps
- First Project: scaffold a talker, run it in QEMU, and see it in ROS 2.
- Installation Details: other install options, provisioning in depth, pinning a project to a release, and using nano-ros from your own build.
- First Node in Rust, C or C++: a single talker on Linux.
Installation Details
Installation is the short path. This page is the rest: what each host package is for, other ways to install, provisioning in depth, ROS 2 and the RMW daemons, pinning a project to a release, and using nano-ros from a build that is not a nano-ros workspace.
Host packages
cmake builds the C/C++ quick start and every workspace root. zstd
unpacks the release and the prebuilt SDK assets nros setup fetches.
python3-dev is only used by a from-source build of the launch resolver,
which a release ships prebuilt. The Python packages feed rosidl_adapter,
the ROS message-to-IDL step the Cyclone DDS backend uses; empy must stay 3.x.
On Arch, base-devel is a package group that covers pkg-config, and
ca-certificates ships with the base system. Each tool’s absence produces an
error naming it.
These are the only packages 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
The host platform layer is POSIX-clean, but the host board is linux,
and only Linux is tested. macOS is not supported: the board’s CPU-affinity
code uses cpu_set_t, which macOS’s libc does not define.
Install options
install.sh 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. It refuses an asset whose checksum it
cannot verify.
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh \
| sh -s -- --version 0.6.0-nros4 # a specific release instead of the newest
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh \
| sh -s -- --dry-run # print what would happen, change nothing
NROS_HOME moves the install root away from ~/.nros. The repository’s
GitHub Releases page lists every release.
Provisioning 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
tools and any SDK sources a board needs are resolved from the index the
release pins and placed in a shared store (~/.nros/sdk). You do not
install cross-toolchains by hand. It provides no zenoh router — that is
ROS’s rmw_zenohd — and Cyclone DDS needs no daemon.
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 …
nros setup <board> --dry-run prints the whole plan, prebuilt vs
source, without fetching anything.
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 host tools + board SDK sources (no zenoh router: that is ROS’s
rmw_zenohd). --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.
| Command | Provisions |
|---|---|
nros setup linux | host build; zenoh-pico + mbedtls sources (no router: see ROS 2 and the Zenoh router) |
nros setup linux --rmw xrce | host build; the Micro-XRCE-DDS agent |
nros setup linux --rmw cyclonedds | host build; Cyclone DDS runtime + idlc |
nros setup mps2-an385-freertos | arm-none-eabi-gcc, patched qemu-system-arm, FreeRTOS + lwIP sources |
nros setup qemu-armv7a-nuttx | arm-none-eabi-gcc, qemu, NuttX sources |
nros setup rv-virt-threadx | riscv64-*-gcc, qemu, ThreadX/NetX sources |
nros setup threadx-linux | ThreadX POSIX-sim sources |
nros setup zephyr | the Zephyr west workspace + Zephyr SDK bits |
nros setup mps2-an385-baremetal | bare-metal arm-none-eabi-gcc + qemu |
Provision a whole workspace
Once you have a workspace, you do not need to name its boards. Run
nros setup with no board at the workspace root (the directory that
holds src/), or pass --workspace <dir>. nros setup --dry-run
prints the plan without fetching anything.
For example, a Rust workspace with a Linux image and a FreeRTOS image, both on zenoh:
nros new two_ws --board linux --template talker --lang rust --rmw zenoh
cd two_ws
nros new --image freertos --board mps2-an385-freertos # adds [image.freertos]
One nros setup provisions both boards:
nros setup
Then every image builds. The first build generates the message bindings from your ROS 2 install, so source it in that shell; the build records the prefix, and later builds of the workspace need no sourced shell:
source /opt/ros/humble/setup.bash
nros build --all
It reads every system.toml in the workspace and provisions each
[image.*]‘s board and RMW in one plan. For a zenoh image it reports
ROS’s rmw_zenohd: the path it found, or the apt packages that provide
it. It then lists the OS packages the workspace itself names, which are
its build types’ host tools (for example cmake for ament_cmake) and
any <depend> that is a system key, and prints the install command.
Add --sudo to run that command. --check only reports.
Every <depend> must resolve before anything is fetched. A name that is
not a package in the workspace, a message package, a system key, a
package your ROS install provides or a rosdep key stops nros setup.
The error names the package.xml that declares it, which is the same
check nros build makes.
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.
A distro cross compiler is a fallback with a floor. When the store has no pinned
arm-none-eabi-gcc(orriscv-none-elf-gcc), the CMake configure uses one fromPATHand says so. Below GCC 11 a C++ image is refused at configure: GCC 10’s newlib libstdc++ has nostd::this_thread, which ordinary ROS 2 C++ uses — Ubuntu 22.04’s aptgcc-arm-none-eabiis 10.3. Install the pin withnros setup --tool arm-none-eabi-gcc, or point-DNROS_ARM_NONE_EABI_PREFIX=<dir>/bin/arm-none-eabiat a GCC ≥ 11. A C-only image is not refused; it gets a warning.
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 xrceinstalls the agent into the SDK 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’srmw_zenoh_cpp/rmw_zenohd, so that nano-ros is tested against the router a ROS 2 deployment actually runs (see the RMW design). 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_zenohdIt is not configured on the command line —
rmw_zenohdignores argv and readsZENOH_CONFIG_OVERRIDE, as above. To pick a different endpoint, change the port in thelisten/endpointsentry.Without a router the talker blocks forever on
Executor::openwith no output. Default ports:tcp/127.0.0.1:7447on POSIX,tcp/10.0.2.2:7447on 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.
ROS 2 and the Zenoh router
Building, yes, once per project. Running two zenoh nodes, yes, or use
Cyclone. nros build, nros run and nros configure generate the message
bindings from the message definitions of the ROS 2 install you sourced, so the
generated layouts and type hashes are the ones your ROS 2 peers use. A
release does not fall back to a copy of its own: that would pick a ROS 2
distro for you without saying so.
Source ROS 2 before the first build, run or configure of a project:
source /opt/ros/humble/setup.bash
That configure records the prefix (AMENT_PREFIX_PATH) in the project, and
every later configure of the same project reuses it, so a later shell, an
IDE or a CI step that has not sourced ROS still builds. A project whose
packages depend on no message package at all configures without ROS 2. With
neither a sourced shell nor a record, configure stops and names the setup
step; see
Troubleshooting.
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 that router is
rmw_zenoh_cpp/rmw_zenohd, which ships with ROS 2. nano-ros does not
vendor one. So:
--rmw cycloneddsneeds no router — Cyclone is in-process, there is no daemon to start.--rmw zenoh(the default) needs ROS’s router. Either installros-<distro>-rmw-zenoh-cpp, or pointNROS_RMW_ZENOHDat armw_zenohdyou obtained another way.
The same sourced setup is what makes the router findable. 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:
PATHis not searched. Azenohdthere — 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 setROS_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.
| Task | Needs a ROS 2 install? |
|---|---|
nros setup | No |
The first nros build / nros run / nros configure of a project with messages | Yes, sourced: the bindings are generated from it |
| Later builds and runs of that project | No sourced shell: the first configure recorded the prefix |
| Building any node (Rust / C / C++), any RMW, any board | Yes, as above: every talker uses std_msgs |
| Running a Cyclone DDS example | No router: Cyclone is in-process |
| Running a multi-process zenoh example | Yes — the router is ROS’s rmw_zenohd (or set NROS_RMW_ZENOHD) |
Verifying with ros2 topic echo / ros2 node list | Yes |
Interop against rmw_zenoh_cpp / a real ROS 2 graph | Yes |
If your distro has no ROS 2 packages (Arch, for example), install ROS 2
from source or from a container image, and source its setup.bash (or
your overlay’s install/setup.bash) the same way: nano-ros reads only
AMENT_PREFIX_PATH, wherever the install is.
Pinning a version
A release fixes the whole surface at once: the CLI, the library source
in its SDK root, and the nros-sdk-index.toml that pins every
toolchain and SDK version nros setup provisions. So pinning nano-ros
is pinning a release:
# install a specific release (several can sit side by side in ~/.nros/sdk/nros/):
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh \
| sh -s -- --version 0.6.0-nros4
# report this project's pin (nros-toolchain.toml), or move it:
nros pin
nros pin 0.6.0-nros4
The pin is a file, nros-toolchain.toml, beside your project — commit it.
With one in a directory, every nros you run there (or below it) is the
pinned release: the nros on PATH hands the command to the pinned one,
fetching it with that release’s own install.sh the first time. A project
pinned to an older release than the one you installed, from nothing:
mkdir pinned_app && cd pinned_app
printf '[toolchain]\nversion = "0.6.0-nros4"\n' > nros-toolchain.toml
Ask for the pin. The release that answers is the pinned one, fetched on first use, and it names its own manifest:
nros pin
sdk/nros/0.6.0-nros4/share/nros/manifest.toml
and everything after it builds with that release. The pinned release is
older than this page, so it is driven with the scaffold spelling it shipped
with, --workspace.
nros new pinned_ws --workspace
cd pinned_ws
nros setup linux --rmw cyclonedds
nros build
nros pin <version> reports the codegen delta between the two
releases before it writes anything, so an upgrade that would invalidate
already-generated bindings warns first; the next nros build
regenerates them. After installing a newer release, re-run nros setup <board>
— it reconciles the SDK store against the new index, and packages that
are already current are no-ops. See
versioning
for how the release number is chosen.
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:
~/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 build resolves the launch files, generates what the board choice
implies and the entry and build root for each [image.*] under build/,
then hands off to CMake, cargo or west:
nros build # or: nros build linux
nros new my_robot_ws --board linux --template talker scaffolds that layout
with one node package — see First Project — and
nros new <pkg> --to <bringup> adds the next. 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 single node is the same shape with one node package; --lang <rust|c|cpp>
picks its language (the First Node chapters).
system.toml lives only in the bringup, never beside a Cargo.toml or
CMakeLists.txt.
A complete working example to read lives at
examples/templates/multi-package-workspace/.
Pattern B: nano-ros inside an existing CMake tree
For a C/C++ project that is not a nano-ros workspace — an existing
CMake tree with its own layout — the platform and backend are cache
variables set before nano-ros is added, 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.
Rust-only consumers
Nothing of nano-ros is published to crates.io (decision 2026-05-14);
the crates ship as source in the release’s SDK root. The full nros
crate can’t be published because it depends transitively on C/C++
sources (zenoh-pico, mbedtls); nros-core isn’t carved out for
crates.io either, to avoid a hybrid distribution model with version
drift between a crates.io snapshot and the SDK.
So a Rust package declares registry-style names and lets the build
redirect them: nros = { version = "*" } (+ msg crates), resolved into
the SDK root the nros on PATH carries (--nano-ros-path names a
different 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; the next nros build re-points it.
A Rust package carries no build settings of its own — no target triple,
no linker flag, no patch table. Its bringup’s system.toml states the
board and nros build derives the rest; 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.
Migrating from an older nano-ros
If you used nano-ros from a checkout with the old install-local
recipe, see
migration-install-local-removal.md
for the one-page rewrite.
First Project
Support level:
mps2-an385-baremetaland the Linux host. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
In this chapter you scaffold a talker for an Arm Cortex-M3 board, run it in QEMU, and watch its messages arrive in ROS 2. You write no code and edit no configuration to get there.
What this assumes: a Linux host with ROS 2 Humble and its Zenoh
middleware (ros-humble-rmw-zenoh-cpp) installed, or the container from
Installation Details,
and the install done, so nros is on your PATH.
What it leaves for later: the board talks to your machine over its serial port, the way a real board’s first program does. The network comes in the board’s own chapter, Bare-metal Cortex-M3. WiFi is not covered yet: it arrives with the Zephyr 4.4 line.
Provision the board
nros setup mps2-an385-baremetal --rmw zenoh
This fetches the Arm cross-compiler, a QEMU that emulates the MPS2-AN385 board, and the Rust target. Nothing is installed system-wide.
Scaffold
nros new sensor_ws --board mps2-an385-baremetal --template talker
cd sensor_ws
That wrote two packages:
sensor_ws/
└── src/
├── sensor_node/ # your node: package.xml, Cargo.toml, src/lib.rs
└── sensor_node_bringup/ # which board it runs on; you don't need to open it yet
sensor_node is the code: a talker that publishes std_msgs/String on
/chatter once a second. sensor_node_bringup says what that code runs on.
The scaffold filled it in, and this chapter never needs you to open it.
This is the shape of
examples/mps2-an385-baremetal/rust/talker/
in the nano-ros repository, the example kept for reading beside this chapter:
one node package and a bringup with no launch file of its own, because
nros run generates it.
Run
Source ROS 2 first, then build and run. ROS 2 provides the router the board
talks to, and its message definitions are what nros run generates the
talker’s std_msgs bindings from. This first build records the ROS 2
prefix in the workspace, so a later build of it needs no sourced shell
(the router still does):
source /opt/ros/humble/setup.bash
nros run sensor_node --expect 'Publishing:'
Publishing: 'Hello World: 1'
nros run: matched after
nros run does everything between your source and a running board: it
builds the firmware (the first build compiles nano-ros for the Cortex-M3 and
takes a few minutes; later builds take seconds), boots it in QEMU with the
board’s serial port connected to a terminal device on your host, and starts
the router on that device. The talker’s own output is the board’s console.
With --expect, nros run stops everything and exits 0 the moment the
console prints a line containing that text, and exits 1 if the board stops
first, which makes it a one-line check that the whole path works.
Without --expect it keeps streaming until you press Ctrl-C, which stops
QEMU and the router together. Start it that way in one terminal:
nros run sensor_node
Publishing: 'Hello World: 2'
See it in ROS 2
Leave nros run going. In a second terminal:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
data: 'Hello World:
The talker publishes best-effort, and ros2 topic echo defaults to reliable,
which receives nothing from it. That is why the command asks for best-effort.
Change it
Open src/sensor_node/src/lib.rs, change the "Hello World" text, and run
nros run sensor_node again. It rebuilds what changed and boots the new
image.
No QEMU, no router: run it on this machine
The same flow runs on your Linux host. Cyclone DDS needs no router, so
nothing else has to be running. The build still generates the messages from
ROS 2, so source it before the first run (later runs of host_ws need no
sourced shell):
source /opt/ros/humble/setup.bash
nros setup linux --rmw cyclonedds
nros new host_ws --board linux --template talker --lang cpp --rmw cyclonedds
cd host_ws
nros run host_node --expect 'Publishing:' --timeout 120
Only the board in the scaffold line changed. The node code is the same shape, and so is every command after it.
If nothing appears
nros runstops at once and namesAMENT_PREFIX_PATH: this shell has not sourced ROS 2, and no earlier build of the workspace recorded it. Runsource /opt/ros/humble/setup.bashand run again.nros runstops at once and names the router: it could not find the Zenoh router. Source ROS 2 in that shell (and installros-humble-rmw-zenoh-cppif it is missing), or pointNROS_RMW_ZENOHDat anrmw_zenohdbinary.- No
Publishing:line within a minute of boot: the router started but the board did not reach it.nros runnames the router’s log when it stops. nros runlists several names and refuses: you are in a workspace with more than one thing to run. Name one of the spellings it prints.ros2 topic echoprints nothing: check the--qos-reliability best_effortflag and theRMW_IMPLEMENTATIONline.
First 10 Minutes has the rest.
Next
- Anatomy of What You Just Built: what each package is for,
and what
nros rungenerated. - Growing your workspace: add a second node, another board, or a launch file.
- Bare-metal Cortex-M3: the same board over its network.
Design background
The chapter follows RFC-0116 D5 and D6 and RFC-0121 D4 and D5. Its pointer to the copy-out talker follows RFC-0122 (behaviour bringups, single-node witnesses, and cadence-backed CI) D3.
Anatomy of What You Just Built
Two packages, two roles. Everything you add to this project later is another package with one of these roles, or a few more lines in the bringup. The shape never changes; it only gains packages.
If you come from ROS 2, each role is something you already know:
| Your workspace | Role | In ROS 2 terms |
|---|---|---|
src/sensor_node/ | Node package | a composable node (rclcpp_components) |
src/sensor_node_bringup/ | Bringup package | <pkg>_bringup: configuration and launch, no code |
(generated under build/) | Entry | the composition container, the main() |
The node package: the code
A node package is one node, as a library. It has no main() and names no
board:
src/sensor_node/
├── Cargo.toml # a library crate; dependencies only
├── package.xml # ROS package manifest: std_msgs is declared here
└── src/lib.rs # the Talker: a 1 s timer that publishes on /chatter
lib.rs declares what the node creates (a publisher, a timer) and what its
callback does. Message types come from package.xml’s dependencies and are
generated during the build; you never write them by hand.
The same node builds for any board. Nothing in this package says
mps2-an385, zenoh or serial: those are the bringup’s.
The bringup package: what runs where
src/sensor_node_bringup/ holds no code, only package.xml and
system.toml:
[system]
name = "sensor_node"
rmw = "zenoh" # which middleware
ros_edition = "humble" # which ROS 2 your peers run
domain_id = 0
[[component]] # which nodes run
pkg = "sensor_node"
class = "sensor_node::Talker"
name = "sensor_node"
[image.baremetal] # one block per thing you deploy
board = "mps2-an385-baremetal"
locator = "serial/UART_0#baudrate=115200"
Every configuration choice lives here, never in a node package. Moving the
talker to another board is a new [image.*] block; switching middleware is
the rmw line. Neither touches src/sensor_node/.
The launch file: generated until you need one
ROS 2 projects describe what to start in a launch file. This bringup has no
launch/ directory, so nros run generated one from the [[component]]
rows, after the first build. To see it:
cat build/nros/launch/sensor_node_bringup/launch/system.launch.xml
When you need what only
a launch file can say (a parameter, a remap, a launch argument), add
launch/system.launch.xml to the bringup and it replaces the generated one.
The file is the ROS 2 launch XML schema, so a launch file from another ROS 2
project pastes in unchanged.
The entry: the binary, also generated
There is no entry package under src/. nros run (and nros build) reads
each [image.*] block and generates the entry for it under build/: a
main() that constructs each component, applies the launch file’s
parameters, and runs them on one executor. Running the entry is launching
the system; there is no separate ros2 launch step.
What is yours and what is generated
Everything under src/ is yours to edit and commit. build/ holds
everything derived: generated message code, the launch file, the resolved
system and the entry. Nothing in build/ is committed; if you can
regenerate it, it does not belong in git.
Next
- Your own message package
- Growing your workspace: a second node, a second board, a launch file.
- First Node in C or C++: the same node package in another language.
Design background
RFC-0121 D1 to D3.
First Node — Rust (Linux)
Build, run, and verify a single nano-ros publisher node on Linux in
about ten minutes. Uses the canonical Zenoh backend. The zenoh router comes from ROS 2
(ros2 run rmw_zenoh_cpp rmw_zenohd) — nano-ros no longer ships one.
Rather not run a router? Use --rmw cyclonedds instead: it needs no
router at all (see Choosing an RMW).
Either way the build generates its messages from your ROS 2 install, so
you need ROS 2 sourced for the first build.
Stuck? See Troubleshooting — First 10 Minutes for the common first-build errors.
Prereqs
The nros CLI on PATH (Installation — install.sh
puts it in ~/.nros/bin), then provision the Linux host (installs the
zenoh client stack into a shared store; the router itself comes from
your ROS 2 install):
nros setup linux --rmw zenoh
Scaffold the talker
nros new talker_ws --board linux --template talker --lang rust --rmw zenoh
cd talker_ws
That wrote a workspace with two packages:
talker_ws/
└── src/
├── talker_node/ # node package: the code
│ ├── package.xml # ROS manifest: <depend>std_msgs</depend>
│ ├── Cargo.toml # a library crate; names no board and no RMW
│ └── src/lib.rs # the Talker: a 1 s timer publishing /chatter
└── talker_node_bringup/ # bringup package: what runs where
├── package.xml
└── system.toml # the board, the RMW, the node it runs
The node package
talker_node is a library. It has no main(), and nothing in it names a
board, an RMW or a ROS edition. src/lib.rs declares what the node creates
(a publisher on /chatter, a 1 s timer) and what the timer callback does,
and its last line exports the node:
#![allow(unused)]
fn main() {
nros::node!(Talker);
}
The program’s main() is generated by the build, from the bringup. That is
what lets the same node package go into a Linux image today and a Cortex-M
image later without a second copy.
Growing your workspace walks
through lib.rs line by line.
Cargo.toml holds Rust facts only: the [lib] target, the class the
package registers, and its dependencies.
[lib]
path = "src/lib.rs"
[package.metadata.nros.node]
class = "talker_node::Talker"
name = "talker_node"
[dependencies]
nros = { version = "*", default-features = false, features = ["rmw-cffi", "macros"] }
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
nano-ros crates are declared registry-style (version = "*"), but
nothing of nano-ros is on crates.io: the build points them at the SDK the
nros on PATH carries. The settings that takes (the [patch.crates-io]
table, the target triple, linker flags) are generated under build/ from
the bringup, never written by hand and never committed. No path in the
package names where nano-ros lives, so the workspace copies anywhere.
Message packages are not listed by hand either: package.xml’s
<depend>std_msgs</depend> is the one statement, and the build writes the
matching # nros-managed row. To use another message package, add its
<depend>; the next nros build adds the row.
The bringup package
talker_node_bringup/system.toml is the whole deployment statement:
[system]
name = "talker_node"
rmw = "zenoh"
ros_edition = "humble"
domain_id = 0
[[component]]
pkg = "talker_node"
class = "talker_node::Talker"
name = "talker_node"
[image.linux]
board = "linux"
The [[component]] row says which node runs; [image.linux] says it runs
on this machine. The --rmw you passed to nros new lives here and nowhere
else, so switching RMW or ROS edition is editing rmw or ros_edition and
building again.
Native (host) images read the locator and domain from environment
variables (NROS_LOCATOR — the retired ZENOH_LOCATOR is refused at startup — and
ROS_DOMAIN_ID). Embedded images bake theirs from the same
[image.<id>] table (locator, and the address in net) — the shape
shows up under the Embedded Starters section.
This is the same project as
examples/linux/rust/talker/
on GitHub, the copy-out example for Rust on Linux: one node package and a
bringup with no launch file of its own, since the build generates it. Its
bringup goes further than the scaffold, with one image per RMW and ROS
edition.
Configure
Three runtime knobs, each an env override on a built-in default:
| Knob | Default | Env override |
|---|---|---|
| Zenoh locator | tcp/127.0.0.1:7447 | NROS_LOCATOR |
| ROS domain ID | 0 | ROS_DOMAIN_ID |
| Zenoh mode | client | NROS_SESSION_MODE |
On native these stay environment variables — [image.linux] declares
no locator. An embedded image bakes them from its own [image.<id>]
table instead; see the
Configuration Guide.
Build
The build generates std_msgs from your ROS 2 install, so source it first.
This first build records the ROS 2 prefix in the workspace; later builds and
runs of talker_ws need no sourced shell.
source /opt/ros/humble/setup.bash
nros build # builds the image `[image.linux]` declares
nros build prepares everything first: the message bindings
package.xml declares, the generated main() for [image.linux], and the
cargo settings derived from board = "linux", all under build/. Then it
runs cargo. There is no step to run before it; when nothing changed, the
preparation costs a comparison.
First build pulls dependencies (~3 minutes). Re-builds finish in
seconds. When it finishes, nros build prints where the binary landed
(under build/, never beside your sources) and how to run it.
Driving cargo yourself (an IDE, a CI step, --release) is
supported: nros build --dry-run prints the exact cargo line, against the
generated entry under build/. Before you open the workspace in an IDE,
run the preparation on its own so rust-analyzer finds the generated
crates:
nros configure
What it writes under
build/ is regenerated on every build; never edit it, and never add a
.cargo/config.toml of your own to carry board facts. Your own
Rust-toolchain preferences go where cargo already looks for them — a
parent directory or $CARGO_HOME.
Run
Two terminals (each command below blocks; keep them open):
# Terminal 1 — zenoh router (ROS 2's own). Blocks the shell until Ctrl-C.
source /opt/ros/humble/setup.bash
ros2 run rmw_zenoh_cpp rmw_zenohd
# Terminal 2 — the talker. The talker logs via `log::info!`, so set
# RUST_LOG=info — without it `env_logger` only shows errors and the
# `Publishing:` lines stay hidden.
cd talker_ws
RUST_LOG=info nros run talker_node
# Expected output (on stderr):
# [INFO talker_node] Publishing: 'Hello World: 1'
# [INFO talker_node] Publishing: 'Hello World: 2'
# …
nros run talker_node runs the bringup whose only node is talker_node,
rebuilding first if anything changed. It keeps running until Ctrl-C.
That’s the nano-ros side fully working. Optional step: verify interop with stock ROS 2.
# Terminal 3 — stock ROS 2 with rmw_zenoh_cpp. It talks to the SAME
# router already running in terminal 1 (default tcp/127.0.0.1:7447).
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
# Talker publishes best-effort; stock `ros2 topic echo` defaults to
# RELIABLE, so the QoS-mismatched echo silently delivers nothing.
# Force best-effort to receive:
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
If ros2 topic echo shows no output despite the talker printing
Publishing:, the routers aren’t peering — confirm both processes
point at the same port (default tcp/127.0.0.1:7447).
Readiness signal. Within ~6 seconds of the program starting
(session open + the first 1 s timer tick), the talker should print
Publishing: 'Hello World: 1' — the count starts at 1, matching the
official ROS 2 demo_nodes_cpp talker. If no Publishing: line in 30
seconds:
- Confirm
RUST_LOGis set. WithoutRUST_LOG=info(ordebug),env_loggerfilters out thePublishing:lines and the run looks silent even when it’s working. - Confirm the router is running (terminal 1). Without it, the talker
blocks on
Executor::openindefinitely. - Re-run with
RUST_LOG=debugand look for “RMW session open failed” — usually a wrong locator or wrong port. - See Troubleshooting — First 10 Minutes.
Retargeting
To run the same node on a board, add an image for it to
src/talker_node_bringup/system.toml, named by that board’s id
(Supported Boards), and provision it:
[image.baremetal]
board = "mps2-an385-baremetal"
locator = "serial/UART_0#baudrate=115200"
nros setup mps2-an385-baremetal --rmw zenoh
nros run talker_node_bringup:baremetal
Nothing in src/talker_node/ changes: the board’s descriptor supplies the
target triple, the board crate and the memory budgets. With two images the
short nros run talker_node no longer picks one, so name the image you
mean; nros run lists the spellings when it refuses. The platform chapters
scaffold with --board <board> from the start, which writes the image for
you.
Next
- Add a subscription:
nros new listener_node --template listener --to talker_node_bringupfrom the workspace root adds a second node package and its component row (Growing your workspace). - Generate bindings for custom
.msg/.srv/.actionfiles: Message Generation - Cross-compile for an RTOS: pick the right Embedded Starter from the next section (FreeRTOS / Zephyr / NuttX / ThreadX / ESP32 / Bare-metal Cortex-M3).
Design background
- RFC-0121: workspace-first project layout
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker kept for each platform and language, and the language workspaces that hold the paired examples.
First Node — C (Linux)
Build, run, and verify a single nano-ros publisher node on Linux from C. The node is a C library built with CMake; the program around it is generated. Uses the Zenoh backend.
Stuck? See Troubleshooting — First 10 Minutes for the common first-build errors.
Prereqs
The nros CLI on PATH (Installation — install.sh
puts it in ~/.nros/bin), then provision the Linux host (installs the
zenoh client stack into a shared store; the router itself comes from
your ROS 2 install — ros2 run rmw_zenoh_cpp rmw_zenohd):
nros setup linux --rmw zenoh
Scaffold the talker
nros new c_talker_ws --board linux --template talker --lang c --rmw zenoh
cd c_talker_ws
That wrote a workspace with two packages:
c_talker_ws/
└── src/
├── c_talker_node/ # node package: the code
│ ├── package.xml # ROS manifest: <depend>std_msgs</depend>
│ ├── CMakeLists.txt # a library and its registration; no board facts
│ └── src/Talker.c # the talker: a 1 s timer publishing /chatter
└── c_talker_node_bringup/ # bringup package: what runs where
├── package.xml
└── system.toml # the board, the RMW, the node it runs
This is the same project as
examples/linux/c/talker/
on GitHub, the copy-out example for C on Linux: one node package and a
bringup with no launch file of its own, since the build generates it.
The bringup package
c_talker_node_bringup/system.toml is the whole deployment statement,
the same schema whatever language the node is written in:
[system]
name = "c_talker_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "c_talker_node"
class = "c_talker_node::Talker"
name = "c_talker_node"
[image.linux]
board = "linux"
The [[component]] row says which node runs; [image.linux] says it runs on
this machine. Nothing in the node package 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. Moving the talker to a board is
another [image.<id>] block here, with no edit to src/c_talker_node/.
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 in the board
network file os_config names) and they are baked at build time (see
Configuration).
The node package
A C node package is a library, not a program. The scaffolded
CMakeLists.txt is (comments trimmed):
cmake_minimum_required(VERSION 3.22)
project(c_talker_node VERSION 0.1.0 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
find_package(nano_ros REQUIRED)
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_node::Talker
EXECUTABLE c_talker_node
SHAPE configure
TYPED)
nano_ros_auto_add_library generates the C bindings the package declares
and links nano-ros. nros_components_register_node registers the class the
bringup’s [[component]] row names, the CMake spelling of ROS 2’s
rclcpp_components_register_node.
There is no main(). The build generates one for each image, which opens the
session and executor, creates the node, and hands both to your code. Your code
is a state struct and a configure function, exported with
RCLC_C_COMPONENT:
#include <stdio.h>
#include <rclc/rclc.h>
#include <std_msgs/msg/string.h>
typedef struct {
rcl_publisher_t pub;
rcl_timer_t timer;
int32_t count;
char text[32];
} talker_t;
static void on_tick(rcl_timer_t* timer, int64_t last_call_time) {
talker_t* self = RCLC_CONTAINER_OF(timer, talker_t, timer);
(void)last_call_time;
self->count++;
int n = snprintf(self->text, sizeof self->text, "Hello World: %d", (int)self->count);
std_msgs__msg__String msg;
msg.data.data = self->text; // your buffer; nothing allocates
msg.data.size = (size_t)n;
msg.data.capacity = sizeof self->text;
if (rcl_publish(&self->pub, &msg, NULL) == RCL_RET_OK) {
printf("Publishing: '%s'\n", self->text);
}
}
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, String), "/chatter");
if (rc != RCL_RET_OK) return rc;
self->timer = rcl_get_zero_initialized_timer();
rc = rclc_timer_init_default(&self->timer, NULL, 1000000000ULL, on_tick);
if (rc != RCL_RET_OK) return rc;
return rclc_executor_add_timer(executor, &self->timer);
}
RCLC_C_COMPONENT(talker_t, talker_configure)
configure runs once at startup, on the generated entry’s own node and
executor; after that the executor calls on_tick once a second. Because the
entry owns startup, the same file runs on Linux and on a board whose C runtime
never calls main (Zephyr, an RTOS startup task).
--template picks a different node: listener, server / client
(AddTwoInts) or action-server / action-client (Fibonacci).
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 above and as micro-ROS
code does. Nothing on this path allocates, so rosidl’s allocating
<Msg>__init / __fini are not declared.
Configure
Three runtime knobs:
| Knob | Default | Env override |
|---|---|---|
| Zenoh locator | tcp/127.0.0.1:7447 | NROS_LOCATOR |
| ROS domain ID | 0 | ROS_DOMAIN_ID |
| Node name | c_talker_node | the [[component]] row’s name (a launch file can remap it) |
On an embedded image the same knobs are compile-baked from that image’s
[image.<id>] table in system.toml (see
Configuration).
Build
The build generates std_msgs from your ROS 2 install, so source it first.
This first build records the ROS 2 prefix in the workspace; later builds and
runs of c_talker_ws need no sourced shell.
source /opt/ros/humble/setup.bash
nros build
nros build prepares the build from the bringup (the generated main() for
[image.linux] and a CMake root that adds your package), configures it with
the SDK root and the toolchain file the board names, and builds it. The C
message bindings are generated during that CMake configure, from the
package’s declared dependencies. There is no step to run before it.
When it finishes, nros build prints what it built and how to run it:
nros build: next for `c_talker_node_bringup:linux`:
artifact: /home/you/c_talker_ws/build/posix-zenoh-linux/cmake/linux_entry
run:
nros run c_talker_node
Driving CMake yourself. The CMake root nros build configures is
generated under build/, never at the workspace root, and
nros build --dry-run prints the exact cmake lines it would run. To have
that root in place without building (before opening the workspace in an IDE,
or to run those lines by hand), prepare it on its own:
nros configure
For a CMake tree that is not a nano-ros workspace at all, see Build as a CMake subdirectory.
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
# 2. Run the talker:
cd c_talker_ws
nros run c_talker_node
# Expected output, once a second:
# 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 (session open + the first 1 s
timer tick), the talker prints Publishing: 'Hello World: 1' on stdout —
Rust + C + C++ all start the count at 1, matching the official ROS 2
demo talker. If it does not appear in 30 seconds:
- Confirm the router is running (terminal 1). Without it, the
generated entry’s session open fails at once with
-4(RCLC_RET_NOT_FOUND— connection refused) and the program stops, naming the step. - Wrong locator / unreachable host → same
-4signature in stderr. Reachable host but mismatched port → talker hangs on session-open handshake rather than returning a code. - See Troubleshooting — First 10 Minutes.
Next
- Add a subscriber: from the workspace root,
nros new c_listener_node --lang c --template listener --to c_talker_node_bringupadds a second node package and its component row to the bringup (Growing your workspace). - Service / action shapes, and a workspace of several C node packages:
the C language workspace,
examples/workspaces/c/. The paired examples are image rows there, one bringup per behaviour (pubsub_bringup,service_bringup,action_bringup), andnros run service_bringup:linux_service_serverruns one role. - Custom
.msg/.srv/.action: Message Generation - Cross-compile for an RTOS: pick the right Embedded Starter from the next section.
Design background
- RFC-0121: workspace-first project layout
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker kept for each platform and language, and the language workspaces that hold the paired examples.
First Node — C++ (Linux)
Build, run, and verify a single nano-ros publisher node on Linux from C++. The node is a C++ library built with CMake, in the ament shape; the program around it is generated. Uses the Zenoh backend.
Stuck? See Troubleshooting — First 10 Minutes for the common first-build errors.
Prereqs
The nros CLI on PATH (Installation — install.sh
puts it in ~/.nros/bin), then provision the Linux host (installs the
zenoh client stack into a shared store; the router itself comes from
your ROS 2 install — ros2 run rmw_zenoh_cpp rmw_zenohd):
nros setup linux --rmw zenoh
Scaffold the talker
nros new cpp_talker_ws --board linux --template talker --lang cpp --rmw zenoh
cd cpp_talker_ws
That wrote a workspace with two packages:
cpp_talker_ws/
└── src/
├── cpp_talker_node/ # node package: the code
│ ├── package.xml # ROS manifest: <depend>std_msgs</depend>
│ ├── CMakeLists.txt # a library and its registration; no board facts
│ ├── include/cpp_talker_node/Talker.hpp
│ └── src/Talker.cpp # the talker: a 1 s timer publishing /chatter
└── cpp_talker_node_bringup/ # bringup package: what runs where
├── package.xml
└── system.toml # the board, the RMW, the node it runs
This is the same project as
examples/linux/cpp/talker/
on GitHub, the copy-out example for C++ on Linux: one node package and a
bringup with no launch file of its own, since the build generates it.
cpp_talker_node_bringup/system.toml is the whole deployment statement —
[system] for the RMW and domain, one [[component]] row naming the node,
and one [image.<id>] per target naming its board. No line of the node
package names a platform, a board or an RMW, so moving the talker to a board
is another [image.<id>] block in the bringup;
First Node — C shows the file in full.
The package.xml <nano_ros deploy=… board=… rmw=…/> export that used to
carry that tuple is retired and refused.
The node package
The CMakeLists is the ament shape you already know — this is the
scaffolded file, comments trimmed (the message-package Find-stubs make
find_package(std_msgs) generate bindings on the fly):
cmake_minimum_required(VERSION 3.22)
project(cpp_talker_node VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)
nano_ros_auto_add_library(talker_lib STATIC src/Talker.cpp)
nros_components_register_node(talker_lib
PLUGIN cpp_talker_node::Talker
EXECUTABLE cpp_talker_node
SHAPE configure)
nros_components_register_node has rclcpp_components_register_node’s
keywords: the class the bringup’s [[component]] row names, and its
executable name.
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 the bringup,
not CMake code and not a configure-line cache variable.
There is no main(). The node is a class with a configure(rclcpp::Node&)
method, the shape of a composable node: the generated entry initialises
nano-ros, creates the node, constructs your class in static storage and
calls configure on it, then spins the executor.
// include/cpp_talker_node/Talker.hpp
#pragma once
#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"
namespace cpp_talker_node {
class Talker {
::rclcpp::Publisher<std_msgs::msg::String> pub_;
::rclcpp::Timer timer_;
int count_ = 0;
void on_tick(); // bound by &Talker::on_tick, not by a name
public:
::rclcpp::Result configure(::rclcpp::Node& node);
};
} // namespace cpp_talker_node
// src/Talker.cpp
#include "cpp_talker_node/Talker.hpp"
#include <cstdio>
namespace cpp_talker_node {
void Talker::on_tick() {
char payload[64];
std::snprintf(payload, sizeof payload, "Hello World: %d", ++count_);
std_msgs::msg::String m;
m.data = payload;
if (pub_.publish(m).ok()) std::printf("Publishing: '%s'\n", m.data.c_str());
}
::rclcpp::Result Talker::configure(::rclcpp::Node& node) {
::rclcpp::Result r = node.create_publisher(pub_, "/chatter");
if (!r.ok()) return r;
return node.create_wall_timer<Talker, &Talker::on_tick>(timer_, 1000, this);
}
} // namespace cpp_talker_node
The publisher and timer are typed wrappers over the C ABI, and every call
returns a Result rather than throwing: every nano-ros build is
-fno-exceptions. configure returns the first failure, and the generated
entry halts naming the node that failed, so there is no unchecked
construction to forget. Porting a ROS 2 C++ node
covers the rclcpp-shaped constructors, shared_from_this() and
rclcpp::TimerBase.
--template picks a different node: listener, server / client
(AddTwoInts) or action-server / action-client (Fibonacci).
Configure
Three runtime knobs:
| Knob | Default | Override |
|---|---|---|
| Zenoh locator | tcp/127.0.0.1:7447 | NROS_LOCATOR |
| ROS domain ID | 0 | ROS_DOMAIN_ID |
| Node name | cpp_talker_node | the [[component]] row’s name (a launch file can remap it) |
On a hosted target the generated entry takes the locator and domain from the
build, then from the environment variables. An embedded image declares its
locator in its own [image.<id>] table and its address in the board network
file os_config names, and bakes them at build time.
Build
The build generates std_msgs from your ROS 2 install, so source it first.
This first build records the ROS 2 prefix in the workspace; later builds and
runs of cpp_talker_ws need no sourced shell.
source /opt/ros/humble/setup.bash
nros build
nros build prepares the build from the bringup (the generated main() for
[image.linux] and a CMake root that adds your package), configures it with
the SDK root and the board’s toolchain file, and builds it. The C++ message
bindings are generated during that CMake configure. There is no step to run
before it.
When it finishes, nros build prints what it built and how to run it:
nros build: next for `cpp_talker_node_bringup:linux`:
artifact: /home/you/cpp_talker_ws/build/posix-zenoh-linux/cmake/linux_entry
run:
nros run cpp_talker_node
Driving CMake yourself. The CMake root is generated under build/, and
nros build --dry-run prints the exact cmake lines it would run. To have
that root in place without building (before opening the workspace in an
IDE, or to run those lines by hand), prepare it on its own:
nros configure
For a CMake tree that is not a nano-ros workspace at all, see Build as a CMake subdirectory.
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
# 2. Run the talker:
cd cpp_talker_ws
nros run cpp_talker_node
# Expected output:
# 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 (session open + the first 1 s
timer tick), the talker prints Publishing: 'Hello World: 1' — Rust + C
- C++ all start the count at 1, matching the official ROS 2 demo talker. If it does not appear in 30 seconds:
- Confirm the router is running (terminal 1). Without it, the generated
entry’s session open fails with
-100(TransportError) and the program stops, naming the step. -3/-100on stderr both mean the transport did not open: checkNROS_LOCATORand the router’s port.- See Troubleshooting — First 10 Minutes.
Next
- Add a subscriber: from the workspace root,
nros new cpp_listener_node --lang cpp --template listener --to cpp_talker_node_bringupadds a second node package and its component row (Growing your workspace). - Services, actions and several C++ node packages in one workspace:
the C++ language workspace,
examples/workspaces/cpp/, where each paired example is an image row under its behaviour’s bringup (pubsub_bringup,service_bringup,action_bringup), and C / C++ multi-node workspaces. - Custom
.msg/.srv/.action: Message Generation - Cross-compile for an RTOS: pick the right Embedded Starter from the next section.
Design background
- RFC-0121: workspace-first project layout
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker kept for each platform and language, and the language workspaces that hold the paired examples.
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 build tools run: even therclcpp-shaped consumer below compiles against nano-ros’s ownrclcppCMake package.nros buildstill reads message definitions from your ROS 2 install, so source it for the first build (below). colcon build— upstreamrosidl_default_generatorsproduces 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 scaffoldedmy_robotworkspace the same drop-in applies: putsrc/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()
Build it with nros build
There is no nano-ros-specific file to add: a workspace has no root build file. With no bringup, nros build builds it the way colcon build
does — every package it finds by its package.xml, in <depend> order, each
in its own build/<pkg>/. For my_app it generates the CMake root that used
to be hand-written (nano-ros, NROS_INTERFACE_SEARCH_PATH at ./src/, the
rclcpp / ament surface); my_msgs is not built on its own — its bindings are
generated into the package that uses it.
The first nros build of the workspace needs ROS 2 sourced: it generates
the bindings against your ROS 2 install’s message definitions, and refuses
without one. It records the prefix, so later builds need no sourced shell.
source /opt/ros/humble/setup.bash
nros build
./build/my_app/cmake/pkg/my_app/my_app
In a workspace that also has a bringup (what nros new <ws> --board <board>
writes), the same nros build builds my_msgs into whichever image’s packages depend on it.
Escape hatch — a hand-written umbrella. For a CMake tree that is not a
nano-ros workspace, one top-level CMakeLists.txt pulls nano-ros in as a
subdirectory, naming the SDK root on the configure line
(Build as a CMake subdirectory has the
mechanism):
cmake_minimum_required(VERSION 3.22)
project(my_workspace LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)
# Pull nano-ros — the installed SDK root, passed as -DNANO_ROS_SDK=….
set(NROS_PLATFORM posix)
set(NROS_RMW "zenoh" CACHE STRING "Active RMW.")
set(NROS_RMW "${NROS_RMW}")
add_subdirectory("${NANO_ROS_SDK}" 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("${NANO_ROS_SDK}/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)
cmake -B build -S . -DNANO_ROS_SDK="$(nros locate nano-ros)"
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
run nros build from the workspace root — that is the
whole change. The package index finds src/my_msgs/ by its
package.xml, generates the generated/my_msgs crate, and writes the
manifest row itself (my_msgs = { path = "../../generated/my_msgs", default-features = false } # nros-managed). 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:
| Layer | Source | Notes |
|---|---|---|
| 1 | NROS_INTERFACE_SEARCH_PATH | Colon/semicolon-separated colcon-src/-style roots; immediate subdirs with package.xml are candidates. |
| 2 | AMENT_PREFIX_PATH | The standard ROS install-prefix layout (<prefix>/share/<pkg>/{msg,srv,action}/). |
| 3 | Bundled | <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:
-
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. -
The configure pass emits a
message(STATUS ...)line noting the resolved path, e.g.-- nros: find_package(std_msgs) -> /path/to/workspace/src/std_msgsGrep your configure log for
nros: find_package(<pkg>)to confirm which layer supplied each pkg. -
Intra-workspace shadowing — when two roots under
NROS_INTERFACE_SEARCH_PATHboth ship the same pkg, thenros_workspace_interfaces()bulk orchestrator keeps the earlier-listed one and warns about the shadowed copy. -
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.msgtostd_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
- Something broke on the way here? Troubleshooting — First 10 Minutes.
- Single-node deep dive: First Node — Rust.
- Grow the workspace: Add a node package.
Troubleshooting — First 10 Minutes
Every first build of a project needs ROS 2 sourced, because the messages are
generated from it (A10). The
First Project runs a talker in QEMU over Zenoh, so it
also needs ROS 2 for the router; its Linux section runs on Cyclone DDS,
where no router exists, so “no output” there is never a router problem. The
First Node pages run on Linux over Zenoh and assume nros setup linux --rmw zenoh has run and the ROS 2 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. Cargo cannot find nros
error: no matching package named `nros` found
A node package’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 the nano-ros SDK is
GENERATED, under build/, and nros build hands it to cargo with
--config. nros build and nros run write it themselves, so this error
means cargo ran without it: a plain cargo build in a package, or an IDE
that opened the workspace before anything was generated. Fix:
cd <the workspace root>
nros build # or, to prepare without building: nros configure
nros configure writes the generated
settings without building; nros build --dry-run prints the cargo line that
uses them. Either is also what ties a copied-out workspace to an SDK (the
nros binary’s own, or the one --nano-ros-path names), and runs again by
itself on the next build after you move 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 CLI not found
nros (codegen tool) not found on PATH or in packages/cli/target/release/
or ${NROS_HOME:-~/.nros}/bin. …
(The rest of the message names how a contributor builds the CLI from a checkout; as a user you install it instead.)
The nros binary is not installed, or not on PATH. Install a release
and put ~/.nros/bin on PATH:
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh
export PATH="$HOME/.nros/bin:$PATH"
If ~/.nros/bin/nros exists but PATH doesn’t see it — that’s the
[PATH] doctor status (D2 below), not this branch.
The nros binary ships the codegen — there is no separate
nros-codegen build step. CMake projects auto-resolve nros from
PATH / ${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
nros-cargo.toml under build/, which nros build writes from the board
your bringup’s 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
A6. cargo build --features rmw-cyclonedds can’t link
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 bringup’s system.toml.
[system]
rmw = "cyclonedds"
cd my_ws # a workspace with C or C++ node packages
nros build
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.
A Rust node package does not take --features rmw-cyclonedds at all: its RMW
is its image’s. A bringup that declares a linux_cyclonedds
image (as
examples/linux/rust/talker
does) builds it with nros build linux_cyclonedds — 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 bringup 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
(mps2-an385-baremetal, baremetal and bare-metal all name one
descriptor).
The neighbouring refusals, when the image is missing rather than misspelt:
this workspace declares no `[image.*]`. An image is the buildable unit: add `[image.<id>] board = "<board>"` to system.toml.
…/system.toml: names no board; add `[image.<id>] board = "<board>"`
The first means no bringup in the workspace has a system.toml with an
image, so there is nothing for nros build to plan. The second means the file is
there and its [image.<id>] table has no board key.
A8. system.toml next to code
nros build refuses a system.toml in a directory that also holds a
Cargo.toml or a CMakeLists.txt. A system.toml belongs to a bringup
package, never to the package with the code, because it says where that code
runs and the code itself must not.
To fix it, give the configuration its own package:
- Make
src/<pkg>_bringup/beside your node package, with apackage.xml. - Move
system.tomlinto it. - Delete any
mainthat only starts the node; the entry is generated.
nros new <ws> --board <board> --template talker writes this shape, so a
scaffolded project never hits it.
A9. 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; “direnv allow” and
“build via just” in it refer to a contributor checkout.) nros build
passes these to cargo from the board’s settings, so a build driven by
nros build — or by cargo with the generated --config file — does not
hit it. If a cargo command you drive yourself does, you skipped the
--config build/<image>/nros-cargo.toml argument.
A10. AMENT_PREFIX_PATH is unset
nros build, nros run or nros configure stops before building anything:
Error: configure: `AMENT_PREFIX_PATH` is unset and no configure of `/home/you/sensor_ws` has recorded one to reuse. Run the setup step once from a shell that has ROS 2: `source /opt/ros/<distro>/setup.bash` (or your overlay's `install/setup.bash`), then `nros configure`; later runs without a sourced shell reuse what it records.
The project has messages to generate (every talker uses std_msgs), and
nano-ros generates them from the message definitions of your ROS 2 install,
so the types and their hashes match your ROS 2 peers. This shell has not
sourced ROS 2, and no earlier configure of the project ran in one that had.
It happens on every board and every RMW, Linux and Cyclone DDS included.
Source ROS 2 once, in this shell, and run the same command again:
source /opt/ros/humble/setup.bash # or your overlay's install/setup.bash
nros build
That configure records the prefix in the project. Later builds and runs of the same project reuse it, from any shell, an IDE or a CI step. A new project records its own, so its first build needs a sourced shell again.
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 reports this distinct from [MISSING] when the binary is
installed at ${NROS_HOME:-~/.nros}/bin/nros but PATH doesn’t see it.
Put the directory on PATH (and in your shell’s startup file):
export PATH="$HOME/.nros/bin:$PATH"
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
- Install + first build — full setup walkthrough
- First Node — Rust — the canonical Rust starter
- Troubleshooting — broader issue-by-issue reference for post-first-build problems
Building and Configuring
nros build is the recommended way to build an image: it prepares
everything the build needs (generated messages, the resolved system, the
image’s build settings) and then runs the host build tool, as cargo build
does. A project that stays in west, CMake, NuttX make or an IDE runs the
preparation step, nros configure, from that tool’s own configure step
instead.
This page is coming soon.
Design background
Growing your workspace
First Project left you with a workspace of two packages: a node and the bringup that says what it runs on. A project grows from there by one path, in four steps, and you take only the ones you need:
- add a node package;
- add its component row to the bringup;
- add
launch/when you need a launch argument, a remap or a parameter; - add an image for another board.
Each step adds a package or a few lines to the bringup. The shape of the workspace never changes, so nothing you did in an earlier step has to be undone.
Where you start
sensor_ws/
└── src/
├── sensor_node/ # node package: package.xml, Cargo.toml (a library), src/lib.rs
└── sensor_node_bringup/ # package.xml and system.toml; no launch/ yet
The two roles, and what each one never holds:
| Package | Holds | Never holds |
|---|---|---|
| Node package | one node: publishers, subscriptions, timers, services, callback bodies | a board, an RMW, a main() |
| Bringup package | system.toml: which nodes run, on which boards, with which RMW; optionally launch/ | compiled code |
There is no third package to write. For each [image.*] block in the
bringup’s system.toml, nros build and nros run generate the program’s
entry (its main()) under build/. A workspace has no root Cargo.toml or
CMakeLists.txt either: like a colcon workspace, it is a directory of
packages.
system.toml lives only in a bringup package. A system.toml beside a
Cargo.toml or CMakeLists.txt is refused, and the message says where to
move it.
1. Add a node package
From the workspace root:
nros new monitor_node --template listener --to sensor_node_bringup
Inside a workspace, nros new adds a package under src/ instead of
writing a new workspace. --to also adds the node’s component row to the
bringup (step 2). Pass --lang c or --lang cpp for a C or C++ node; the
rest of this page is the same in every language.
A node package is a library with three files:
src/monitor_node/
├── package.xml # ROS 2 manifest: one <depend> per message package
├── Cargo.toml # a [lib] crate and its dependencies, nothing else
└── src/lib.rs # the node; its last line is nros::node!(Listener);
It has no fn main() and names no board, RMW or board crate. That is what
lets one node package go into a Cortex-M image and a Linux image unchanged:
the difference between them is an [image.*] block, not a second copy of
the node.
The manifests
package.xml is a standard ROS 2 manifest. Each message package the node
uses is one <depend>:
<depend>std_msgs</depend>
That line is all you write for a message package. The build turns each
<depend> on a message package into a dependency row in Cargo.toml, tagged
# nros-managed, pointing at the crate it generates under generated/.
Removing the <depend> removes the row. A row without the tag is yours and
is never touched, so to override a generated row, delete its tag.
Cargo.toml registers the node class the package exports:
[lib]
path = "src/lib.rs"
[package.metadata.nros.node]
class = "monitor_node::Listener"
name = "monitor_node"
[dependencies]
nros = { version = "*", default-features = false, features = [
"alloc",
"rmw-cffi", "macros"] }
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
This registration is the Rust spelling of
nros_components_register_node(... PLUGIN pkg::Class ...) in a C or C++
package’s CMakeLists.txt. A package registers its class once, however many
bringups run it. Which bringup runs it is the component row’s business
(step 2). nros is never fetched from a registry: the build points it at
your installed nano-ros, so no path in the manifest names where nano-ros
lives and the package copies anywhere.
src/lib.rs
A Rust node implements Component (what the node creates, declared once at
startup) and ExecutableNode (what each callback does), then exports both
with nros::node!. The essential shape, from
examples/workspaces/rust/src/talker_pkg/src/lib.rs:
#![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";
// Per-instance slots, 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 node; this is the last line
}
registerdeclares; it does not run. It is called once at startup to declare publishers, subscriptions, timers and which callback publishes what. No message flows here.on_callbackis the body. The image’s executor calls it each time a callback fires;stateis the node’s own mutable storage.- The node is the same type everywhere.
ctx.create_node(...)returns thenros::Nodea standalone program gets, with rclrs’s methods (create_publisher,create_subscription, timers,logger(), the graph queries).use nros::DeclarativeNode;adds the constructors that bind a callback by name, which is what lets the board run callbacks from its own tasks and lets the build measure the node without running it. ENTITY_BOUNDSis static RAM.nros::node!emits astaticsized from it, and a publisher slot carries a loan arena. The default (8 of each kind) measured 50,824 bytes of.bssfor a one-service node, against 568 bytes when the node declared what it creates. Count the five kinds in yourregister(); timers and subscriptions need no slot here. Declaring fewer thanregister()creates is an error at boot, never a silent drop, so the exact number is safe.- Timers. In a timer callback,
ctx.timer_info()returns the activation’sexpected_call_timeandactual_call_time(lateness_ns()is their difference).create_timer_for_callback_name_with_autostart(name, period, clock, false)declares a stopped timer; any callback starts it withctx.reset_timer(nros::EntityId::new("on_tick"))and stops it withctx.cancel_timer(..). To control timers by id, add.with_timers(n)toENTITY_BOUNDS(16 bytes a timer).
Calling a service from a node
A node cannot make a blocking service or action call from on_callback: the
executor is mid-dispatch there, and the call would deadlock it. Client calls
go in ExecutableNode::tick, which the executor runs once per spin between
callbacks. Arm the call in the callback and make it in tick:
#![allow(unused)]
fn main() {
fn on_callback(state: &mut State, cb: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
state.want_call = true; // decide; do not call here
}
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 is safe here
}
}
}
A service server needs no such split: requests arrive in on_callback like
any other callback. In C++, rclcpp::bind_service<Svc, T, &T::method>(node, "/add_two_ints", this) binds a typed member that takes the generated
Svc::Request and returns Svc::Response.
2. Add a component row
--to sensor_node_bringup added this to
src/sensor_node_bringup/system.toml, beside the row the scaffold wrote for
sensor_node:
[[component]]
pkg = "monitor_node"
class = "monitor_node::Listener"
name = "monitor_node"
| Field | Meaning |
|---|---|
pkg | the node package (its <name> in package.xml) |
class | the type the package registers |
name | the node’s name at runtime; a launch file can remap it |
dispatch | optional: the dispatch shape, when it is not the default |
It also added <exec_depend>monitor_node</exec_depend> to the bringup’s
package.xml. To add a row by hand (for a node package you copied in), write
both.
Run the system. The workspace’s first build, in First Project, recorded your ROS 2 prefix, so a shell that has not sourced ROS 2 builds it too; a workspace you start fresh needs ROS 2 sourced for its first build, because the messages are generated from it:
source /opt/ros/humble/setup.bash # only needed for a fresh workspace's first build
nros run
With nothing named, nros run runs the workspace’s only image, which now
now holds both nodes in one program. nros run sensor_node no longer works. That spelling
means “the bringup whose only node is sensor_node”, and this bringup now
has two, so it refuses and prints the spellings that would resolve, here
sensor_node_bringup:baremetal.
The bringup still has no launch/, so the build generates its launch file
from the two rows. After a build, it is at:
cat build/nros/launch/sensor_node_bringup/launch/system.launch.xml
Before a build, two checks catch most mistakes in a bringup (a
[[component]] row naming a package or class that does not exist, a stray
build file in the bringup):
nros check --bringup src/sensor_node_bringup
nros check --workspace .
3. Add a launch file when you need one
The generated launch file starts every component with its default name and
nothing else. When you need a launch argument, a remap, a namespace or a
parameter, the bringup gets a launch/ directory. Start from the generated
file, so that adding it changes nothing until you edit it:
mkdir -p src/sensor_node_bringup/launch
cp build/nros/launch/sensor_node_bringup/launch/system.launch.xml \
src/sensor_node_bringup/launch/system.launch.xml
From then on launch/system.launch.xml is used instead of the generated one.
Deleting launch/ goes back to generation.
Keep each <node>’s pkg and exec as the generated file wrote them; the
examples below write exec="…" for that value.
The file is the ROS 2 launch XML schema, read with the same parser, so a launch file from another ROS 2 project pastes in. For example, a launch argument that moves both nodes to another topic:
<launch>
<arg name="topic" default="/chatter"/>
<node pkg="sensor_node" exec="…" name="sensor_node">
<remap from="/chatter" to="$(var topic)"/>
</node>
<node pkg="monitor_node" exec="…" name="monitor_node">
<remap from="/chatter" to="$(var topic)"/>
</node>
</launch>
| Tag | Purpose |
|---|---|
<arg name="…" default="…"/> | declare a launch argument |
<node pkg="…" exec="…" name="…"/> | start a node |
<param name="…" value="…"/> | set one parameter (inside <node>) |
<param from="….yaml"/> | load a ROS parameter file (inside <node>) |
<remap from="…" to="…"/> | remap a topic or service (inside <node>) |
<group ns="…"> | put a group of nodes in a namespace |
<include file="…"/> | include another launch file |
Substitutions: $(var <arg>) expands a launch argument, $(find <pkg>) a
package’s path, $(env <name>) an environment variable.
There is no ros2 launch step. The launch file is resolved when the image is
built, and its result is compiled into the program. An image binds launch
arguments, and can name another launch file in the same bringup:
[image.baremetal]
board = "mps2-an385-baremetal"
args = { topic = "/sensor" }
[image.baremetal_sim]
board = "mps2-an385-baremetal"
launch = "sim.launch.xml"
Parameters
<param from="…"/> takes a standard ROS parameter file, relative to the
launch file:
# launch/sensor.param.yaml
/**: # every node
ros__parameters:
use_sim_time: false
sensor_node: # this node only
ros__parameters:
rate_hz: 25
limits:
max_accel: 1.5 # reaches the node as `limits.max_accel`
<node pkg="sensor_node" exec="…" name="sensor_node">
<param from="sensor.param.yaml"/>
<param name="rate_hz" value="50"/> <!-- wins over the file -->
</node>
Precedence is ROS’s: the /** block, then the node’s own block (keyed
sensor_node, /ns/sensor_node or /**/sensor_node), then inline
<param name= value=>. The values are baked into the program at build time,
so an embedded board reads no file at run time; editing the YAML rebuilds. A
from= naming a missing file is a build error.
Two capabilities in [system] features decide what the image does with them:
| declared | parameter store (declare_parameter, launch values, --ros-args -p) | ros2 param services |
|---|---|---|
params | yes | none |
param_services | yes | six per node (get, set, list, describe, …) |
params suits a small board whose nodes only read their configuration: each
of the six services costs a queryable per node. What it gives up is
ros2 param get|set|list|describe|dump and the use_sim_time switch.
4. Add an image
An image is one program: the bringup’s nodes, built for one board with one
RMW. It is a block in system.toml, not a package. Your bringup has one,
written by the scaffold:
[image.baremetal]
board = "mps2-an385-baremetal"
locator = "serial/UART_0#baudrate=115200"
Running the same two nodes on your Linux machine is one more block:
[image.linux]
board = "linux"
rmw = "cyclonedds" # this image only; needs no router on a host
nros run linux
Nothing in either node package changes. The board’s descriptor supplies the
target triple, linker flags, cross compiler and memory budgets, so retargeting
is the board line and nothing else.
| Key | Meaning |
|---|---|
board | the nano-ros board id; Supported Boards lists them |
rmw | this image’s RMW, when it differs from [system] rmw |
locator, net | an embedded image’s network identity (where its router is, its own address) |
launch | a launch file in the bringup; default [system] default_launch |
args | launch arguments bound for this image |
features | capability axes added to [system] features |
os_config | RTOS configuration files, in order: Zephyr prj-*.conf and overlays, or a board network file |
panic | panic policy: platform, halt or own |
profile | a cargo or CMake build profile |
before_boot | a function to call before the board boots (below) |
[image_defaults] holds what every image shares, so eight images do not
repeat their RMW eight times. A specific block’s values replace it; args
merge, and os_config and features add up:
[image_defaults]
rmw = "zenoh"
The RMW, like the board, is chosen at build time; embedded targets have no
RMW_IMPLEMENTATION switch. On a Linux image, ROS_DOMAIN_ID and
NROS_LOCATOR still work at run time; an embedded image bakes the values from
its block. See Configuration.
Naming what to run
With two images, nros run alone refuses and lists them. The target argument
of nros run, nros build and nros flash accepts, in this order:
| You type | It means |
|---|---|
sensor_node_bringup:linux | that bringup’s image; always works |
linux | the image named linux, when only one bringup declares it |
sensor_node | the bringup whose only node is sensor_node, with its only image |
| nothing | the workspace’s only image |
Anything else refuses and lists the <bringup>:<image> spellings that would
resolve; a shortcut never picks one of several. --why says which row
resolved. To make a bare nros build build a chosen set, declare it:
[system]
default_images = ["linux"]
Zephyr images
A Zephyr image also carries Zephyr’s own configuration (prj.conf, overlays),
which nothing can derive. It lives in the bringup, one directory per board,
and the image names the fragments it adds:
src/sensor_node_bringup/boards/native_sim_native_64/
├── prj.conf
└── prj-zenoh.conf # named by [image.<id>] os_config
nros build writes the Zephyr application itself under build/ and runs
west build on it. The board id is still nano-ros’s (zephyr), never
Zephyr’s spelling. Configuring an image
has the rest. A Zephyr application that keeps its own CMakeLists.txt and
prj.conf and is built with plain west build is not a workspace image; it
has its own chapter.
What nros build generates
Everything generated lands under build/, and none of it is yours to edit or
commit:
- the generated launch file of each bringup without
launch/; - the resolved system for each image (
build/nros/models/); - the generated message crates;
- each image’s entry and the build root that compiles it: a cargo package when every node is Rust, a CMake project when any node is C or C++, a Zephyr application for a Zephyr board.
nros build then runs that host tool, so compiler errors are the compiler’s.
nros build --dry-run prints the command for an image, and running it
yourself works too. Where each binary lands is
What nros build Produces.
Customising an entry
The generated entry is one nros::main!(…) call, and the macro does a lot: it
opens the executor at the size the image declares, registers every node in the
launch file, and adds the lifecycle, parameter services and tiers the system
asks for. Take the smallest step that does what you need:
| You need | Use | Entry stays generated |
|---|---|---|
| another panic policy, build profile, Kconfig fragment or capability | an [image.*] key | yes |
| one step before the runtime starts (open a device, check a calibration) | before_boot | yes |
| hardware setup every image on a board needs (clocks, DMA) | the board’s init_hardware, in a board package | yes |
| startup none of the above can express | nros materialize | no |
before_boot names a function in one of the image’s node packages:
[image.linux]
board = "linux"
before_boot = "sensor_node::prepare"
#![allow(unused)]
fn main() {
// src/sensor_node/src/lib.rs
pub fn prepare() -> Result<(), &'static str> {
// open the device, check the calibration, wait for the peripheral
Ok(())
}
}
The entry calls it first, before the board does anything, so nothing has
booted when it runs: no executor, no session, and on bare metal no hardware
setup yet. Returning Err("reason") stops the image with that reason. It
works in Rust images only; a C or C++ image that sets it is refused, and so
is a board whose framework owns the first statement. The
worked example is image linux_rust_before_boot in
examples/workspaces/features.
nros materialize is the last resort. It copies an image’s generated
entry into src/<image>_entry/, and from then on nros build uses your copy
and does not regenerate it:
$ nros build linux
$ nros materialize linux
wrote …/src/linux_entry
This entry is YOURS now — `nros build` will not regenerate it.
What you get is the same nros::main!(…) call inside its crate root. While
you keep the macro, launch-file changes still reach the program; what you own
is the shell around it. Replacing the macro with your own main means
writing the boot, node registration, executor sizing and every capability
yourself, and keeping them in step with the launch file by hand. Deleting
src/<image>_entry/ returns the image to generation.
Coming from ROS 2
| ROS 2 | nano-ros |
|---|---|
ros2 pkg create | nros new <pkg> inside the workspace; --to <bringup> adds its component row |
colcon build | nros build (every default image) or nros build <image> |
ros2 run <pkg> <exe> | nros run <node> |
ros2 launch <pkg> <file> | nros run <bringup>:<image>; the launch file is compiled into the image |
One image is one program, which runs its nodes in one process on one board. Several processes are several images.
Other languages
Nothing on this page is Rust-specific except the node’s source file. C and C++ node packages, and workspaces that mix languages, are C / C++ multi-node workspaces and Mixed-language workspaces. Role reference lists every field of the three roles.
Design background
- RFC-0121: workspace-first project layout:
why every project is a workspace, why
system.tomllives only in a bringup, and the target spellingsnros runaccepts. - RFC-0120: one resolver for every build road:
why
nros buildprepares the build itself.
C / C++ multi-node workspaces
Growing your workspace describes 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
| Role | Rust | C / C++ |
|---|---|---|
| Node pkg | lib.rs with nros::node!(MyNode), declared by a [[component]] row in the bringup’s system.toml | Talker.{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 |
| Bringup pkg | package.xml + system.toml, optionally launch/*.launch.xml (no Cargo.toml) | identical (language-agnostic) |
| Image | [image.linux] board = "linux" in the bringup’s system.toml | identical (language-agnostic) |
| The entry | GENERATED 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 root | none — the generated entry’s own Cargo.toml is the cargo root | none — the generated build/<coord>/CMakeLists.txt is the cmake root, calling nano_ros_workspace(WORKSPACE_ROOT … BACKEND zenoh PLATFORM posix SYSTEM demo_bringup SUBDIRS …) |
| Build | nros build linux | nros build linux |
| Run | nros run linux (binary: build/posix-zenoh/linux_entry/target/debug/linux_entry) | nros run linux (binary: 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. 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
What nros build generates. 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:
- Sets
NROS_PLATFORM=posix+NROS_RMW=zenoh. add_subdirectory(<nano-ros>)once at root scope (so per-pkg subdirs don’t collide on re-include).include(NanoRosNodeRegister.cmake)+include(NanoRosEntry.cmake)once.add_subdirectory(<each member>)for eachSUBDIRSentry.
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 -Dnano_ros_ROOT=<sdk-root>, or a cmake --preset
from nros setup presets) or as part of the
workspace; the per-pkg CMakeLists doesn’t change between modes.
Node pkg
A typed component — 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)
# 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 a typed
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 bringup with no launch/ writes the same file at <bringup>/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; the build carries
this one to where the resolver looks. That is how a one-node bringup states its
QoS — and stating it is worth real memory: one sub: { chatter: { qos: { history: keep_last, depth: 1 } } } row takes a one-node Rust listener on Linux 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.
The build folds it into the resolved SystemModel, and the entry passes that model to the sizing automatically — nothing here takes an argument for it.
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 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 linux) checks it before it configures: the host
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. nros build does: before
it plans a cross image, it takes that image’s census. Taking it 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). A build driven some other way (a hand-run
west build or cmake) with no census, or a stale one, REFUSES at configure
and prints the command that takes it; running nros build once for the image
takes it too. 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 my_talker --lang cpp --template talker --to demo_bringup
From the workspace root this adds src/my_talker/ (class
my_talker::Talker) and, with --to, its [[component]] row in
demo_bringup.
Bringup pkg
Language-agnostic — copy verbatim from the
bringup steps. package.xml +
system.toml, and launch/system.launch.xml when you want one (without it the
launch file is generated from the [[component]] rows). 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 asks the nros CLI to generate the entry and
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. The build 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 build re-resolves the model whenever the bringup’s launch XML or
system.toml changed; with nothing changed it costs a digest comparison.
Writing the entry yourself
One case needs a hand-written one: startup the declarations cannot
express. nros materialize <bringup>:<image> writes the generated entry out to
src/<image>_entry/, as a package with nano_ros_entry(NAME … ) in its own
CMakeLists.txt, and nros build then leaves it alone. (A Rust image has a
smaller step first, [image.*] before_boot; the C/C++ entry has no such hook
yet.)
A Zephyr image is not a second case. nros build and nros run generate its
Zephyr application under build/ from the image, like every other entry, and
nros materialize is the way to own it there too
(the Zephyr chapter).
The materialized package needs no source file of its own: the cmake function
generates the whole body (the main above), so its CMakeLists.txt names the
bringup and launch file and nothing else.
Build + boot
nros build linux
nros run linux # or run ./build/posix-zenoh-linux/cmake/linux_entry
nros build prepares everything first: the generated message bindings, the
SystemModel (via the pinned nros-launch-resolve helper), and
build/<coordinate>/CMakeLists.txt. Then it configures and builds. Each piece
is redone only when its inputs changed, so there is no step to run before it.
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. Run nros configure first to write it without building;
nros build linux --dry-run prints the
exact cmake lines.
The build produces:
src/talker_pkg/libtalker_pkg_talker_component.a— the Node pkg static lib (_componentis the compatibility target suffix).src/listener_pkg/liblistener_pkg_listener_component.a— ditto.linux_entry— the image’s executable, with the generatedint main()+ register-call sequence + Board boot stub linked in.
cmake configure is incremental — pinned CMAKE_CONFIGURE_DEPENDS on
every file the entry generator reads (depfile from the CLI), so any
launch.xml / package.xml / Node pkg edit re-runs codegen.
What runs where
| Concern | Lives in |
|---|---|
| Node entities + real callbacks | Node pkg configure(::rclcpp::Node&) |
| Topology + launch args + per-target deploy | Bringup pkg system.toml + launch/*.launch.xml |
int main() + executor init + spin | Generated TU emitted by the entry’s cmake fn |
| Board + RMW selection | The 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
| Command | Output |
|---|---|
nros new <ws> --board <board> --template talker --lang cpp | A new workspace: a C++ Node pkg <x>_node plus its Bringup pkg <x>_node_bringup with one image |
nros new <name> --lang cpp --template talker [--to <bringup>] | Inside a workspace: a C++ Node pkg under src/; --to adds its [[component]] row |
nros new system <name>_bringup --components a,b | Bringup pkg (language-agnostic — works for both Rust and C++ workspaces) |
nros materialize <bringup>:<image> | The image’s generated entry (for a Zephyr image, its west application) copied to src/<image>_entry/ as a package you own |
--lang c scaffolds a pure-C Node pkg the same way. Pure-C and mixed C/C++ workspace examples
live under examples/workspaces.
See also
examples/workspaces/cpp/— the canonical reference workspace, all C++: the talker, listener, service and action Node pkgs, and one bringup per behaviour (pubsub_bringup,service_bringup,action_bringup) whose image rows are the paired examples, one per platform and role.- C++ Entry-pkg roadmap doc — full landing order + acceptance bar.
- Multi-node workspace layout design
§11 — LOCKED canonical shape (
Bringup + Node + Entry).
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The paired examples as image rows of behaviour bringups in the language workspaces.
Mixed-language workspace
Languages mix per package, not per file. A bringup’s [[component]] row
names a package and a class whatever language implements it, and node
packages are linked through a C ABI register trampoline, so one image can
compose node packages written in C, C++ and Rust. The
workspace shape does not change with the
language. 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/ # package.xml + system.toml (+ launch/)
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. So a mixed workspace builds exactly like the C++ one:
$ cd my_mixed_ws # e.g. a copy of examples/workspaces/mixed
$ source /opt/ros/humble/setup.bash # the first build generates messages from ROS 2
$ nros build linux
$ nros run linux
The first build records the ROS 2 prefix in the workspace, so later shells need not source ROS 2.
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, and neither the
root nor the entry is yours to write.
C Node pkg
A C Node pkg is a typed component: 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)
#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, int64_t last_call_time) {
c_talker_pkg_t* self = RCLC_CONTAINER_OF(timer, c_talker_pkg_t, timer);
(void)last_call_time;
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_default(&self->timer, NULL, 1000000000ULL, on_tick);
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 (here an authored launch/system.launch.xml; without one, the
file generated from the [[component]] rows) 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 resolved system is a build artifact,
resolved from the launch file under build/. Naming it in a build file is a
deprecated expert override, and committing one is refused.
Per-language notes
Rust. A Rust node package is a library crate ending in
nros::node!(YourNode). Its message crates and the settings cargo needs (the
board’s target triple, link flags, environment) are generated under build/,
never beside your packages, and nros build prepares them before it hands
off. In a mixed workspace the Rust packages are built through CMake (by
corrosion), so their compiler errors are still cargo’s.
C. A C node package is the configure component shape above, built by
the same per-package CMake as C++. Its message bindings are generated during
the CMake configure.
The RMW is spelled once, whatever the languages: [system] rmw in the
bringup’s system.toml, or rmw = on one [image.*] block when one image
differs. The features each package needs follow from it, so switching backend
is a one-word edit: Switching RMW in Config.
Scaffolding
Inside a workspace, nros new adds a node package in the language you name,
and --to adds its component row to a bringup:
nros new c_talker_pkg --lang c --template talker --to demo_bringup
nros new cpp_listener_pkg --lang cpp --template listener --to demo_bringup
nros new rust_heartbeat_pkg --lang rust --to demo_bringup
To start from a complete mixed tree, copy
examples/workspaces/mixed/
out instead. It runs a C talker, a C++ listener, a Rust heartbeat, and C and
C++ service and action pairs, composed into one program per image.
Design background
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. Nomain(), 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.xmlandsystem.toml, and optionallylaunch/*.launch.xml,config/andboards/. NoCargo.toml, no compiled code. Named<system>_bringup. - Image — a row, not a directory.
[image.<id>]in thatsystem.tomlnames a board, a launch file, and a backend;nros build <id>generates the entry package from it, underbuild/, and hands off to cargo / cmake / west / idf.py. The workspace itself has no root build file.
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.
Every project is a workspace, including one with a single node: one node package plus one bringup package, which
nros new <ws> --board <board>writes together.system.tomllives only in a bringup; one beside aCargo.tomlorCMakeLists.txtis refused. See One node, one bringup 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 asrlib + staticliband is linked into the entry the build generates. Codegen synthesises the spin driver; you never hand-write one. classmust be a namespace-qualified C++ name. The old prefix rule (classstarting with the pkg dir name) is retired —pkgon the[[component]]row is the identity authority, so upstream namespaces (autoware::x::Node) port verbatim.nros checkstill rejects an unqualifiedclass.- C++ / C analogue:
nano_ros_auto_add_library(<lib> STATIC <srcs>)+nros_components_register_node(<lib> PLUGIN … EXECUTABLE …)(rclcpp_components keyword parity) + a typed component in the source — C++ aconfigure(::rclcpp::Node&)method, C aRCLC_C_COMPONENT(StateT, configure_fn)seam. Same conceptual shape, no Cargo.toml. package.xmlis mandatory. Even pure-Rust Node pkgs ship one —<exec_depend>lines drive ROS 2 launch discovery when the system runs throughros2 launchoutside 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. [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.)
launch/ is optional. A bringup without it gets a launch file generated from
its [[component]] rows, written to
build/nros/launch/<bringup>/launch/system.launch.xml and resolved exactly as
an authored one. Adding launch/system.launch.xml replaces it. A bringup with
neither launch/ nor a [[component]] row is refused.
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
the build too (the launch resolver executes them).
See Add a launch file.
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
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" }
| Key | Meaning |
|---|---|
board | 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) |
launch | Launch file relative to the Bringup pkg. Absent ⇒ [system] default_launch |
args | Launch arguments bound at resolve time — how an image selects one machine out of a multi-host launch tree |
rmw | Backend for this image. Absent ⇒ [system] rmw |
panic | Panic policy: platform | halt | own |
profile | cargo / CMake build profile name |
os_config | Layer-1 files, in order: Zephyr prj-*.conf / .overlay fragments, or a board network file (.toml). os_config is read as its old alias |
variant | Framework build-variant name (Zephyr prj_<buildtype>.conf) |
features | Capability axes for this image, over [system]’s list |
serdes, ros_edition | Serialization provider and ROS edition. Absent ⇒ the [system] value |
Folding is per-key: scalars are replaced by the specific block, args merges,
os_config 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
The old [deploy.<id>] table conflated the two and is refused by
name, pointing at both.
The generated entry
The entry package is derived from (launch, args, board) and generated: 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. 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 the build writes — triple, link flags, [env], patch rows — which cargo
reads through --config. nros build writes all of it before it hands off;
nros configure writes it without building, for an IDE or a hand-run cargo.
All of it is build output under build/, and none of it is written beside your
source: no root build file, no .cargo/config.toml beside a package. 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:
| part | derived from |
|---|---|
the no_std / no_main shell | the 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
// RETIRED: `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 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; the build
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 hatches, smallest first: an [image.*] key (panic, profile,
os_config, features); before_boot = "<pkg>::<fn>" for one step before the
runtime starts; the board’s init_hardware for hardware every image on that
board needs; and, last, nros materialize <bringup>:<image>, which copies the generated
entry to src/<image_id>_entry/ for you to own. See
Customising an entry.
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, which you meet only in an entry you have taken over
with nros materialize. MODEL on either is the deprecated artifact-naming override,
mirroring the macro. Metadata flows through ${BUILD}/nros-metadata.json
rather than a sidecar TOML.
Zephyr images
A Zephyr image is no exception. Its entry is a Zephyr application — the app
target find_package(Zephyr) creates — and nros build generates it under
build/ like any other entry, then runs west build on it. 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.
What Zephyr adds is authored Kconfig (prj.conf, prj-<rmw>.conf,
boards/*.overlay), which nothing derives. It lives in the Bringup pkg, one
directory per board, and the image names the fragments it adds:
src/demo_bringup/boards/native_sim_native_64/
├── prj.conf
└── prj-zenoh.conf # named by [image.<id>] os_config
A change the image keys cannot express goes through the same last resort as on
every other platform: nros materialize <bringup>:<image> writes the generated
application into src/<image_id>_entry/ as a package you own.
The Zephyr chapter
has the details.
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 build <image> writes the generated message crates and the per-image
build settings, generates the entry and its build root under build/, and runs
the platform’s own tool; there is no step to run before it. 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, Zephyr images included.
The language workspaces (rust, c, cpp) also hold the paired examples,
one bringup per behaviour: pubsub_bringup (talker and listener),
service_bringup (service server and client) and action_bringup (action
server and client). Each holds one [image.<platform>_<role>] row per
platform and role, with a _<rmw> suffix for any RMW other than zenoh, so
every role stays its own image. Run one role by naming its bringup and image:
nros run pubsub_bringup:linux_listener
A variant with different code (a callback-style client, a polling listener) is its own node package with its own image row; a configuration (a serial link, a custom transport) is an image row that states the knob over the same packages.
One node, one bringup
The smallest project is still this shape, with one node package. The scaffold writes both packages:
nros new host_ws --board linux --template talker
host_ws/
└── src/
├── host_node/ # Node pkg: package.xml, Cargo.toml (a library), src/lib.rs
└── host_node_bringup/ # Bringup pkg: package.xml and system.toml; no launch/
# src/host_node_bringup/system.toml
[system]
name = "host_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "host_node"
class = "host_node::Talker"
name = "host_node"
[image.linux]
board = "linux"
There is no src/main.rs and no selection or board crate in the node’s
manifest: the image’s generated entry names the board. nros run host_node
builds and runs it. The node spelling means “the bringup whose only node is
host_node, with its only image”; once the bringup has a second node or a
second image, name <bringup>:<image> instead. --lang c or --lang cpp
writes a C or C++ node package; the bringup is the same.
The copy-out example for each platform and language,
examples/<platform>/<lang>/talker/, has this shape too: one node package and
one bringup with no launch/, so its launch file is generated. It is the
project to start from; the paired examples are the image rows above.
nros build (and nros configure) refuse a system.toml in a directory that also
holds a Cargo.toml or CMakeLists.txt, and the message says where to move
it. Projects that own their build and have no system.toml (a CMake
add_subdirectory consumer, a vendor tree, a Zephyr application you build
with west) are not affected: see
Build as a CMake subdirectory,
Vendored tree and
A Zephyr App You Build with West.
Quick reference
| You want… | Use |
|---|---|
| Reusable node logic, board-independent | Node pkg (nros::node!()) |
| A runnable program: N nodes on one board | An [image.<id>] row + nros build <id> |
| A single-node project | nros new <ws> --board <board> --template <t>: one Node pkg plus its Bringup pkg |
| Same nodes on multiple boards | One Node pkg set + one [image.*] row per board |
| Launch topology + the image table | Bringup pkg (declarative — the one package a workspace must have) |
| A Zephyr image | An [image.<id>] row with a Zephyr board; nros build generates the application |
| One step before the runtime starts | [image.<id>] before_boot = "<pkg>::<fn>" |
| Startup the declarations cannot express | nros materialize <bringup>:<image>, then edit src/<image>_entry/ |
| Board hardware bringup | Board trait family (see porting chapter) |
Design background
- RFC-0121: workspace-first project layout
- RFC-0065: colcon-like workspace builder (images, hosts, generated entries)
- RFC-0098: generated leaf build config
- RFC-0057: ament-parity component registration
- RFC-0060: launch toolchain
- RFC-0026: example directory layout
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The paired examples as image rows of behaviour bringups, and the single-node talker kept per platform and language.
Message Binding Generation
nros uses generated Rust bindings for ROS 2 message types. nros build generates no_std compatible bindings from each package’s package.xml dependencies before it builds (nros configure does that step alone, and nros interface generate rust the codegen alone).
Overview
The binding generator is part of the nros CLI and provides:
- Pure Rust,
no_stdcompatible output usingheaplesstypes - Automatic dependency resolution through your ROS 2 install’s ament index
- the
[patch.crates-io]rows that point the nano-ros runtime crates at the SDK root, written into the image’s generated build settings
Prerequisites
-
package.xml in each node package - Declares ROS interface dependencies
<?xml version="1.0"?> <package format="3"> <name>my_package</name> <version>0.1.0</version> <description>My nros package</description> <maintainer email="dev@example.com">Developer</maintainer> <license>Apache-2.0</license> <depend>std_msgs</depend> <depend>geometry_msgs</depend> <export> <build_type>ament_cargo</build_type> </export> </package> -
nros tool built + on PATH
Install a release;
install.shputsnrosin~/.nros/bin:curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh export PATH="$HOME/.nros/bin:$PATH"See Installation for the full walkthrough.
-
A ROS 2 install, sourced for the first build
The bindings are generated from the message definitions of your ROS 2 install,
std_msgsincluded, so the generated layouts and type hashes match the ROS 2 nodes you talk to. Source it before the firstnros build,nros runornros configureof a project:source /opt/ros/humble/setup.bashThat configure records the prefix (
AMENT_PREFIX_PATH) in the project, and later configures reuse it, so later shells need not source ROS. With neither, configure refuses and names this step. A project whose packages depend on no message package at all needs no ROS 2.
Workflow
Step 1: Create package.xml
Declare your ROS interface dependencies in <depend> tags:
<depend>std_msgs</depend> <!-- For std_msgs::msg::Int32, String, etc. -->
<depend>example_interfaces</depend> <!-- For service types -->
Step 2: Build
cd my_ws
source /opt/ros/humble/setup.bash # the first build of the workspace only
nros build
Before it builds, this will:
- Parse
package.xmlto find dependencies - Resolve transitive dependencies through the ament index
- Filter to interface packages (those with msg/srv/action)
- Generate bindings into the workspace’s
generated/directory - Resolve the bringup’s launch file into the system model
- Write each image’s build settings under
build/, carrying the board’s triple and flags, the resolved pool budgets, and the[patch.crates-io]rows for the nano-ros runtime crates
Each step is redone only when its inputs changed. To run them without building
(before opening the workspace in an IDE, for instance), use nros configure.
nros interface generate rust is the
codegen-only step underneath — steps 1–4 and nothing else — and is useful when
you want bindings without a workspace.
Step 3: there is no step 3 — package.xml is the dependency list
A generated message crate is a path dependency on its own directory, and
nros build writes that row for you, before it plans: one
per <depend> / <build_depend> / <exec_depend> that names a message
package, tagged # nros-managed:
[dependencies]
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
example_interfaces = { path = "../../generated/example_interfaces", default-features = false } # nros-managed
So adding a message package is one edit — the <depend> — exactly as on the
C/C++ road, and deleting the <depend> deletes the row on the next build. A
row without the tag is yours: the build never edits or removes it, so deleting the
tag is how you take a row over. The nros row is not
managed — it carries a feature choice that is yours to make.
Never name a message crate by registry version (std_msgs = "*"): those
crates are generated per host from your ament install, they are not
published, and a bare name resolves against the public crates.io instead.
The nano-ros runtime crates are the other case — nros,
nros-board-<board>, the backends — and they are registry-named with
version = "*", because the build writes the [patch.crates-io] rows
that redirect them into the nano-ros SDK root.
Why msg crates are RMW-agnostic
Generated msg crates carry only the wire-format data type — no backend-specific code, no per-RMW Cargo feature. A user manifest is the plain pair:
[dependencies]
std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed
nros = { version = "*" }
Transport choice and message schema are orthogonal concerns and the
manifest reflects that. (Which backend you actually get is
[system] rmw in the bringup’s system.toml, not a feature you pick here — see
Switching RMW in Config.) This matches upstream rclcpp + rclrs, which
both ship msg packages RMW-agnostic and let the RMW pick which
descriptor representation it wants at runtime.
For DDS-based backends that need a per-type descriptor on the wire
(Cyclone DDS today), the nros-rmw-cyclonedds shim builds those
descriptors lazily on first pub/sub for a given message type,
walks the static field schema exposed by nros-serdes (the
Message trait with const TYPE_NAME + const FIELDS), and caches
the result in a bounded no_std registry. No per-msg-pkg backend
code is required.
The registry is bounded by the NROS_CYCLONEDDS_MAX_TYPES sizing knob.
Where the nano-ros sources come from
A project uses the SDK root the nros binary belongs to — a release’s
own share/nano-ros — so a plain nros build
works anywhere. To name a different SDK root, pass it:
nros build --nano-ros-path <sdk-root>
The build then writes the [patch.crates-io] rows into each image’s
generated settings, pointing each nros* requirement at a directory
in the SDK root. Nothing about the location is baked into a tracked file,
so moving the workspace, or installing another release, needs no edit: the
next build re-points it.
Use in code
#![allow(unused)]
fn main() {
use std_msgs::msg::Int32;
use example_interfaces::srv::{AddTwoInts, AddTwoIntsRequest, AddTwoIntsResponse};
let msg = Int32 { data: 42 };
}
Command Options
nros interface generate rust is the codegen step alone — it writes
bindings and nothing else:
nros interface generate rust [OPTIONS]
Options:
--manifest <PATH> Path to package.xml [default: package.xml]
-o, --output <DIR> Output directory [default: generated]
--ros-edition <EDITION> humble | iron | jazzy [default: humble]
--codegen-config <PATH> Explicit per-field capacity config (nros-codegen.toml)
--rename <OLD=NEW> Rename a generated package
--force Overwrite existing bindings
-v, --verbose Enable verbose output
It also still accepts --generate-config / --nano-ros-path /
--nano-ros-git, which write a .cargo/config.toml beside a package by hand.
Those are the old shape and are not the path to use: nros build writes the
patches into the generated settings instead, where they are regenerated
rather than committed.
Generated Output Structure
my_ws/
├── src/
│ ├── my_node/
│ │ ├── package.xml # Your dependency declarations
│ │ ├── Cargo.toml # Your package manifest (a library)
│ │ └── src/lib.rs # Your code using generated types
│ └── my_node_bringup/
│ ├── package.xml
│ └── system.toml # Board, RMW, components
├── generated/ # Generated bindings (do not edit)
│ ├── std_msgs/
│ │ ├── Cargo.toml
│ │ └── src/
│ │ ├── lib.rs # #![no_std]
│ │ └── msg/
│ │ ├── mod.rs
│ │ └── int32.rs
│ └── builtin_interfaces/ # Transitive dependency
│ └── ...
└── build/ # Build output (do not commit)
└── …/<image>_entry/ # each image's generated entry and its cargo settings, incl. the patches
Generated Code Features
no_std by default:
#![allow(unused)]
#![no_std]
fn main() {
pub mod msg;
}
std feature for optional std support:
[features]
default = []
std = ["nros-core/std", "nros-serdes/std"]
heapless types for embedded:
#![allow(unused)]
fn main() {
pub struct String {
pub data: heapless::String<256>,
}
pub struct Arrays {
pub data: heapless::Vec<i32, 64>,
}
}
Service types with Request/Response:
#![allow(unused)]
fn main() {
pub struct AddTwoInts;
pub struct AddTwoIntsRequest { pub a: i64, pub b: i64 }
pub struct AddTwoIntsResponse { pub sum: i64 }
impl RosService for AddTwoInts {
type Request = AddTwoIntsRequest;
type Reply = AddTwoIntsResponse;
}
}
A timestamp message gets SecNanosecMsg, so Time::to_ros_msg() can return
it:
#![allow(unused)]
fn main() {
impl nros_core::SecNanosecMsg for Time {
fn from_sec_nanosec(sec: i32, nanosec: u32) -> Self { Self { sec, nanosec } }
}
}
Emitted for every message whose field set is exactly { int32 sec, uint32 nanosec } — builtin_interfaces/msg/Time, its Duration twin, and any message
of yours with that shape. It is what makes the call a publisher writes work:
#![allow(unused)]
fn main() {
msg.header.stamp = node.now().to_ros_msg();
}
nros-core cannot name a message type — every generated crate depends on it, and
your message crates are generated from your packages — so the conversion is
implemented on the generated side and the trait is the seam. The C++ side spells
the same thing as a template (node.now().to_msg(msg.header.stamp)); Rust has no
structural bound, so it needs the named trait. There is no Result: nros’s
Time already is { sec, nanosec }, so nothing can fail. (rclrs returns
Result<_, TryFromIntError> because its Time is i64 nanoseconds; a ported
t.to_ros_msg()? will not compile here, and deleting the ? is the fix.)
Examples
Each example on GitHub is a workspace that carries its own bringup and resolves on its own, so it builds wherever it lives. Build each from its own root:
cd my_talker_ws && nros build
cd my_service_client_ws && nros build
Regenerating Bindings
To regenerate after ROS package updates or dependency changes:
nros interface generate rust --force
Where the definitions come from
nros build reads message definitions from the ROS 2 install you sourced
(or the one the project’s first configure recorded), through
AMENT_PREFIX_PATH. The release carries copies of a few standard packages,
but they only fill gaps under a ROS 2 install, never stand in for one: with
no prefix set and none recorded, configure refuses rather than build from
them, because they would silently fix a ROS 2 distro and the types would
stop matching a peer on another one. A message package in your own
workspace shadows the installed one of the same name.
Troubleshooting
“AMENT_PREFIX_PATH is unset and no configure of … has recorded one to reuse”
- This shell has not sourced ROS 2, and this project has never been
configured from one that had. Run
source /opt/ros/humble/setup.bash(or your overlay’sinstall/setup.bash), then build again.
“Package ‘X’ not found in ament index or bundled interfaces”
- Source the ROS 2 environment:
source /opt/ros/humble/setup.bash - Check package is installed:
ros2 pkg list | grep X - Install if missing:
sudo apt install ros-humble-X
Build errors with generated code
- Regenerate with
--forceflag - Check nros crate compatibility
Which CMake spelling (one table)
Five codegen entry points exist in the CMake surface. They are NOT interchangeable spellings of one thing — each serves one audience, and two are back-compat only. Pick by what you are doing:
| You are… | Use | Notes |
|---|---|---|
| Consuming an existing msg package from an app/node pkg | find_package(<pkg> REQUIRED) | The recommended default. The smart Find-stub resolves the package through the 3-layer search (workspace → ament → bundled) and generates bindings on the fly. Zero codegen calls in your file. |
| Defining msgs in a message package | rosidl_generate_interfaces(${PROJECT_NAME} msg/… DEPENDENCIES …) | Recommended — the stock ROS shape, intercepted by nano-ros. Your msg pkg carries zero nano-ros-specific lines and cross-builds under colcon unchanged. |
| Defining msgs and you want the nano-ros verb explicitly (or C bindings) | nano_ros_generate_interfaces(<name> <files…> [LANGUAGE C|CPP] [DEPENDENCIES …]) | The ament-shape alias. Defaults to LANGUAGE CPP like rosidl — pass LANGUAGE C for C. |
| Building a workspace root with several msg pkgs | nros_workspace_interfaces() after set(NROS_INTERFACE_SEARCH_PATH …/src) | Bulk-builds every workspace msg pkg in topological order; one line, no per-pkg add_subdirectory. |
Generating this pkg’s package.xml dependencies (no find_package flow) | nros_find_interfaces([LANGUAGE C|CPP] [SKIP_INSTALL]) | Consume-by-manifest: resolves the transitive interface closure of your own package.xml. Used by the workspace node pkgs. Defaults LANGUAGE CPP. |
| — | nros_generate_interfaces(<target> <files…>) | Deprecated for new code. The low-level generator everything above routes through; direct calls are back-compat only. |
C Code Generation (CMake)
nano_ros_generate_interfaces(… LANGUAGE C) generates C bindings for .msg, .srv,
and .action files (the function defaults to C++ — the LANGUAGE C
argument is what makes it the C generator). It uses a bundled codegen library — no external nros binary needed.
Prerequisites
nano_ros_generate_interfaces() becomes available automatically once
the consumer’s CMakeLists.txt invokes add_subdirectory(nano-ros).
The codegen tool ships inside the in-tree nros CLI binary
(packages/cli/target/release/nros; the old nros-codegen
submodule is retired); cmake auto-resolves it from
PATH / packages/cli/target/release/ / the transitional
${NROS_HOME:-~/.nros}/bin/. No separate build step.
Usage
For a complete example, see the features workspace,
examples/workspaces/features:
its custom_msgs package defines a message, and c_reading_talker_pkg and
c_reading_listener_pkg use the generated C bindings. The custom-message
feature demo is a node package there, with its own image row.
Design background
- RFC-0067: environment-invariant message dependency identity
- RFC-0048: CMake ament consumption
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. Feature demos as node packages of the features workspace.
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.tomlper-package/workspace file (rejected by the CLI), the oldconfig.toml[network]/[zenoh]/[scheduling]schema, every[package.metadata.nros.{node,entry,component,deploy.*}]table, and thepackage.xml<nano_ros deploy=… board=… rmw=…/>tuple. The last two moved intosystem.toml; readers refuse them rather than reading them. The design records are listed under Design background.
One home per concern
| File | Owns | Per |
|---|---|---|
system.toml | Everything 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, the board network file holding its address, os_config = ["board-net.toml"], and how it runs, is flashed and is watched, [image.<id>.run], [image.<id>.flash], [image.<id>.console]). 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.toml | Rust 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.txt | C/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 (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.toml | GENERATED, 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 build (or nros configure alone), passed to cargo with --config. | image |
package.xml | ROS package identity + msg <depend>s (codegen input for nros interface generate) | all |
config.toml (standalone) | Hand-written no_std direct-mode apps only — [node] / [eth0] / [network.<name>], 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
This table has one answer in all four cells, which is the point of it:
| One node | Several nodes | |
|---|---|---|
| Rust | system.toml in the bringup pkg | system.toml in the bringup pkg |
| C / C++ | system.toml in the bringup pkg | system.toml in the bringup pkg |
A system.toml beside a Cargo.toml or CMakeLists.txt is refused, 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.
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 = "mps2-an385-baremetal"
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
An image’s address is a layer-1 fact: the operating system owns the
network device. On Linux that is the kernel; on Zephyr and NuttX it is their
own configuration (Kconfig, devicetree, defconfig); on a board where nano-ros
is the OS (the reference FreeRTOS, ThreadX and bare-metal boards) it is the
board package, through a small board network file the image names with
os_config — one table per interface:
[image.talker]
board = "mps2-an385-baremetal"
os_config = ["board-net.toml"]
# board-net.toml, beside system.toml
[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 defined in the
board network file beside the interface on it, and the image states its
router as its own locator:
# board-net-sensor.toml
[eth0]
network = "lab"
address = "192.168.50.20"
[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"
[image.sensor]
board = "mps2-an385-baremetal"
os_config = ["board-net-sensor.toml"]
locator = "tcp/192.168.50.1:7447"
Two images on one network each define it in their own file, and the files must agree: one network has one subnet, gateway and lease policy.
Per interface:
networkdefaults to the board’s (qemuon every QEMU board), so[eth0] host = 10is enough there.hostis the last octet within the network’s subnet;addressis the full IPv4, or"dhcp"on a network withdhcp = true(and a board whose interface runs a DHCP client). An interface onqemuthat states no address takes10.0.2.15, the address a plain-nic userguest picks.macis optional. Left out, it is derived from the board interface’smac_prefixand 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 interface never states them.
- An image dials its own
locator, else[system] locator. On a network other thanqemuone of the two must be stated.
A board may have several interfaces. An image addresses each on its own
network, one table each: [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 no file defines;
- two images with the same static address, or the same MAC, on one network
other than
qemu, or two files defining one network differently; - a board network file 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 retired spellings, each refused naming its new home:
[network.<name>]and[image.<id>.net.<if>]insystem.toml, and the flatip/gateway/netmaskkeys on[image.<id>].
The board’s half is [board.net] in its descriptor: stack (who owns the
network: host, os or board), links, 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 (under the workspace) and reaches the firmware as
NROS_IMAGE_NET.
How an emulated image joins its network is a fact about how it is RUN, not built, so it has its own table, read only by the QEMU launchers and the test harness:
[image.sensor.run]
nic = { bridge = "qemu-br" } # QEMU: -nic bridge,br=qemu-br,…
nic is "slirp" (QEMU’s own user-mode network, the default on qemu),
{ bridge = "<dev>" } or { tap = "<dev>" } on the host, and applies to the
image’s first interface. An image on any other network with no nic is
physical only, and launching it under QEMU is refused. nros run passes the
-nic value to QEMU when it boots the image.
The same goes for a board that is flashed and watched on a serial port: the
board’s descriptor states its flasher and console ([board.flash],
[board.console], see
the descriptor section),
and an image overrides any key of them in its own tables:
[image.lab.flash]
command = "pyocd flash -t stm32f767zi {hex}" # replaces the board's method
prepare = [{ say = "Set BOOT0 to 1, then press RESET", wait_for = "usb:0483:df11" }]
[image.lab.console]
baud = 921600 # kind stays the board's
Both are checked when system.toml is read (an unknown key, wait_for event
or {placeholder} is refused) and are read by nros flash and nros monitor.
The middleware’s own file
How a session finds its peers — which router to dial, which endpoints to
listen on — is the middleware’s own configuration, written in its own
language. A bringup ships it beside its system.toml, and
it applies to every image of that backend:
| Backend | File | The same document ROS 2 reads as |
|---|---|---|
| zenoh | rmw/zenoh.json5 | ZENOH_SESSION_CONFIG_URI |
| CycloneDDS | rmw/cyclonedds.xml | CYCLONEDDS_URI |
// rmw/zenoh.json5
{
mode: "client",
connect: { endpoints: ["tcp/192.168.50.1:7447"] },
}
zenoh-pico has no json5 parser, so the build reads the file on the host,
lowers each key to a zenoh-pico session property and bakes the result into the
image. A key zenoh-pico cannot take is refused, naming the key — never
dropped. With a file, the file IS the wiring: the image’s connect list
replaces the default router, and an image that also states a locator is
refused (a [system] locator is simply not used). On a hosted build
ZENOH_SESSION_CONFIG_URI replaces the baked file at run time, whole, as it
does for rmw_zenoh.
The flat rmw/zenoh.conf list (key = value) is retired and refused, naming
the json5 that replaced it.
The names above are the default. To ship a different file — one per site, one for a QEMU run and one for the lab — name it:
[system]
rmw_config = "rmw/zenoh-lab.json5" # every image's default session
[image.qemu]
board = "mps2-an385-freertos"
rmw_config = "rmw/zenoh-qemu.json5" # this image's own
A named file that does not exist is refused. XRCE has no file format; its
endpoint is the image’s locator.
A bridge opens one session per [[domain]], and each domain names its own
file, so one image can speak to a CAN zenoh network and an Ethernet DDS one
with two documents:
[[domain]]
name = "zen"
rmw = "zenoh"
config = "rmw/zenoh-zen.json5"
[[domain]]
name = "dds"
rmw = "cyclonedds"
id = 5
config = "rmw/cyclonedds-dds.xml"
[[bridge]]
name = "gw"
from = "zenoh:zen"
to = "cyclonedds:dds"
A domain with a config states no locator (the file is its wiring), and a
domain config that no [[bridge]] opens is refused, since nothing would
read it. An image overrides one domain’s file with a table,
rmw_config = { dds = "rmw/cyclonedds-lab.xml" }: the build then writes that
image its own bridge configuration (nros-bridge.<image>.toml) beside the
bringup’s, and the other images keep the shared one. At run time
ZENOH_SESSION_CONFIG_URI and CYCLONEDDS_URI still replace a hosted
bridge’s files, and NROS_BRIDGE_<SESSION>_LOCATOR replaces that session’s
file whole.
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 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 ([tasks.<name>.<rtos>] +
[[node_overrides]]).
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. The rationale and the measurements are in the execution-model
design record (see Design background).
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, compile-baked with include_str! and parsed by the
board crate’s Config::from_toml. It is the board package’s network file
— the same [<interface>] / [network.<name>] shape an image’s
os_config file has — plus a [node] table that stands in for the session a
system.toml would declare.
# config.toml
[node]
domain_id = 0 # ROS 2 domain ID (0–232)
locator = "tcp/10.0.2.2:7447" # the router the firmware dials
[eth0]
network = "qemu" # built in: QEMU user-mode, 10.0.2.0/24, gateway 10.0.2.2
host = 10 # the address within that network → 10.0.2.10
# address = "10.0.2.10" # or the whole address
mac = "02:00:00:00:00:00"
# A network of your own: name it on the interface instead of `qemu`.
# [network.lab]
# subnet = "192.168.50.0/24"
# gateway = "192.168.50.1"
[node.rt.freertos] # FreeRTOS boards: raw task priorities; omit for defaults
app_priority = 3
zenoh_read_priority = 4
A board reads its own facts from the interface table: a serial link’s
[uart0] baud, ThreadX Linux’s host veth as [eth0] device. One interface is
brought up; a direct-mode board takes no DHCP lease, so state the address.
A file in the retired shape ([[transport]] with ip, gateway and
locator) panics at boot naming the new spelling, rather than booting on
the board’s defaults: ip = "10.0.2.10/24" + gateway = "10.0.2.2" is
[eth0] network = "qemu" + host = 10, and the locator moves to [node].
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.
Files that used to carry configuration and no longer do are listed in Retired configuration files.
Build-time environment variables
Read during cargo build (by build.rs). Export them 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: 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:
| Variable | Tree |
|---|---|
FREERTOS_DIR / LWIP_DIR | FreeRTOS kernel / lwIP |
THREADX_DIR / NETX_DIR | ThreadX / NetX Duo |
NUTTX_DIR / NUTTX_APPS_DIR | NuttX RTOS / apps |
CYCLONEDDS_SOURCE_DIR | Cyclone 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:
| Variable | Default | Effect |
|---|---|---|
NROS_LINK_IP | on | NROS_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_SOCKETS | 4 / 2 | Sized 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_SIZE | per-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):
| Backend | Peak working set | Recommended heap | Notes |
|---|---|---|---|
| zenoh-pico (TCP) | ~16 KB | 24–32 KB (≈2× for fragmentation) | peer middleware; alloc-based session/buffers |
| zenoh-pico (serial) | lighter than TCP | 16–24 KB | no TCP link buffers; verified running at 16 KB |
| XRCE (Micro-XRCE-DDS) | ~3 KB (micro-ROS figure) | ~8 KB | static 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.
| platform | transport | backend | profile | text (flash code) | data | bss | RAM total |
|---|---|---|---|---|---|---|---|
| mps2-an385-baremetal (cortex-m3) | ethernet (smoltcp) | zenoh-pico | release | 177.4 KB | 67.0 KB | 91.7 KB | 158.7 KB |
| mps2-an385-baremetal | ethernet | zenoh-pico | size | 158.3 KB | 67.0 KB | 91.7 KB | 158.7 KB |
| mps2-an385-baremetal | serial (no IP stack) | zenoh-pico | release | 128.6 KB | 25.2 KB | 75.8 KB | 101.0 KB |
| mps2-an385-baremetal | serial | zenoh-pico | size + recipe | 116.1 KB | 25.2 KB | 75.8 KB | 101.0 KB |
| stm32f4 (thumbv7em-eabihf, cortex-m4) ¹ | ethernet | zenoh-pico | release | 186.9 KB | 13.7 KB | 123.0 KB | 136.7 KB |
| stm32f4 ¹ | ethernet | zenoh-pico | size | 138.1 KB | 13.7 KB | 123.0 KB | 136.7 KB |
| mps2-an385-freertos (cortex-m3 + lwIP, RTOS-reused stack) | ethernet (lwIP) | zenoh-pico | release | 240.6 KB | 10.7 KB | 3.3 MB | 3.3 MB |
| mps2-an385-baremetal (serial) | serial (custom XRCE transport) | XRCE | size, heap 24 KB, tight XRCE pools | 60.3 KB | 25.2 KB (heap 24 KB) | 8.8 KB | ~34 KB |
| micro-ROS reference (XRCE) | serial | XRCE-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.textby ~10–26 % with.bss/.dataunchanged (opt-level doesn’t touch static buffers — those are the env knobs above).-Ozis 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
.bssis 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 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 board default) — the older 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.
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):
| Variable | Description | Default |
|---|---|---|
ROS_DOMAIN_ID | ROS 2 domain ID | 0 |
NROS_LOCATOR | Router address | tcp/127.0.0.1:7447 |
NROS_SESSION_MODE | client / peer | client |
ZENOH_TLS_ROOT_CA_CERTIFICATE* | TLS CA cert (path / base64) | (none) |
By deployment scenario
| Scenario | Config source | Cargo features | Notes |
|---|---|---|---|
| Desktop (POSIX) | env (ExecutorConfig::from_env()) | rmw-cffi, platform-posix, std + zenoh dep | run 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-metal | system.toml [image.<id>] ip/mac/gateway/locator | rmw-cffi, platform-bare-metal, ros-humble + zenoh | TAP/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 module | Kconfig overlay (prj-<rmw>.conf) | (Kconfig → features) | |
Hand-written no_std (no codegen) | standalone config.toml → Config::from_toml | per-board | net config in a file, not in code |
| Minimal RAM (XRCE serial) | config.toml [uart0] baud + [node] locator or [image.<id>] | … + xrce dep | XRCE_* buffer tuning |
Design background
- RFC-0004: configuration and transports
- RFC-0098: generated leaf build config
- RFC-0031: RMW selection and lowering
- RFC-0109: network config layers
- RFC-0015: RTOS orchestration (multi-node RT tiers)
- RFC-0002: RT execution model (timer overrun policy, § 4.4a)
- RFC-0103: one owner per fact (source lookup ladder)
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 = "mps2-an385-baremetal"
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: the build 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 configure (from the CMakePresets.json that
nros setup presets writes, which names the SDK root; nros build passes it
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_TYPE | cargo profile | what it means |
|---|---|---|
Debug | dev | debuggable, minimal optimization |
RelWithDebInfo | nros-relwithdebinfo | opt-level 2, debug info, no LTO — the default |
MinSizeRel | nros-minsizerel | opt-level "s", fat LTO — smallest images |
Release | release | opt-level 3, fat LTO — fastest code |
| (unset) | nros-relwithdebinfo | the development default |
cmake --preset linux -DCMAKE_BUILD_TYPE=MinSizeRel
cmake --build build/linux
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 --preset linux \
-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 --preset linux \
-DCMAKE_BUILD_TYPE=Release -DNROS_CARGO_PROFILE=prod
Which profile is active?
A CMake configure prints it, with the target/ subdirectory it builds into:
-- nano-ros: cargo profile `nros-minsizerel` (CMAKE_BUILD_TYPE=MinSizeRel) → target/nros-minsizerel
Notes for embedded targets
- Images grow at the default.
nros-relwithdebinfotrades size for build speed. For flashing to a constrained board, buildMinSizeRel. - 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 or west. 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?
Running it: nros run and nros flash
nros run starts an image whose board is a host process or an emulator: it
builds the image if it is stale, starts the router the image’s link needs,
streams the console and stops everything when it ends (--expect <text>
grades the console, --dry-run --why prints the plan it would execute). How a
board is launched is stated once, in its descriptor’s [board.run], so the
command nros build prints as its next step is the one nros run executes.
Real silicon is flashed, not run: nros run refuses a hardware image and
names nros flash --monitor. nros flash builds the image if stale, runs the
board’s human steps ([board.flash] prepare), flashes through the method its
descriptor’s [board.flash] names (west, or a command template), and
with --monitor watches the console on the same port; --expect <text>
grades it as nros run does. nros monitor opens the console of a board
that is already running. The port comes from --port, NROS_PORT, the bench
file (nros bench scan) or the one attached device that matches the board.
The commands quoted below are the board tooling’s, reproduced here as a reference for a board no flash method covers yet.
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 / shape | driver | the artifact |
|---|---|---|
| Rust-only workspace, any non-Zephyr board | cargo | <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 board | west | <ws>/build/<coordinate>/<bringup>__<image>_west/zephyr/zephyr.{elf,bin,exe} |
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. 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/.
After a successful build nros build prints the artifact itself, and the
board’s own command that runs or flashes it — never running either:
nros build: next for `c_talker_node_bringup:linux`:
artifact: /home/you/c_talker_ws/build/posix-zenoh-linux/cmake/linux_entry
run:
/home/you/c_talker_ws/build/posix-zenoh-linux/cmake/linux_entry
What the run: (or flash) line is depends on the board: the binary for a
host board, the board’s QEMU runner with the provisioned qemu-system-* for an
emulated one, west build -d <dir> -t run / west flash -d <dir> for Zephyr.
--dry-run and --check build nothing, so they print no such block.
One path the build also prints 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/<coordinate>/<entry>/nros-cargo.toml, and nros build points cargo at
it:
cargo build --manifest-path build/posix-zenoh/linux_entry/Cargo.toml \
--config build/posix-zenoh/linux_entry/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 build — read it, never edit it.
The artifact is the generated entry’s binary, inside that entry’s own target dir:
<ws>/build/<coordinate>/<image>_entry/target/[<triple>/]<profile-dir>/<image>_entry
The triple level appears whenever the board pins one — which it does in the
generated [build] target, not on your command line.
A Linux image on Cyclone DDS, for instance, 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
(cargo’s rule: a profile builds into the directory of its own name, and dev
into debug).
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
-kerneltakes, 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 (west’s default,
build/ under the cwd, would put every image of a workspace in one tree and
refuse 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, against the image’s build directory
(nros build prints whichever the board supports):
west build -d build/zephyr-zenoh/demo_bringup__zephyr_west -t run # native_sim and emulated boards
west flash -d build/zephyr-zenoh/demo_bringup__zephyr_west # hardware: the board's flash runner
./build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.exe # native_sim: the same as -t run
The -d is not optional here: without it west uses build/ under the
current directory, which is not where nros build put the image. west flash
picks its runner from the board’s own board.cmake; nano-ros adds nothing to
it.
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
What nros build generates.
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 be part of 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 distributed as a release: one archive holding the nros
CLI and the library source it builds against (the SDK root). Nothing
is on crates.io and there is no prebuilt library. So “vendoring” nano-ros
means pinning and mirroring a release, not copying its repository into
your tree: your tree holds your packages and the bringup’s system.toml,
and the release supplies everything else.
Pinning the release
Install the exact release your tree is tested against:
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh \
| sh -s -- --version 0.6.0-nros4
export PATH="$HOME/.nros/bin:$PATH"
nros pin 0.6.0-nros4 # record it in the project's nros-toolchain.toml
Rules that keep the pin trustworthy:
- Pin a version, never “latest”. A moving target unships the pairing you tested.
- Move the pin forward only.
nros pin <newer>reports the codegen delta before it writes anything, so an upgrade that invalidates generated bindings warns first. - A release’s toolchain pins are decisions, not lags. Its
nros-sdk-index.tomlfixes the cross-compilers, the Cyclone DDS that ROS ships, a wire-stable zenoh-pico. Take what the release pinned.
What a fresh machine needs, in order
Three things, and the order matters:
- The
nrosCLI, installed as above. Nothing else works without it. ~/.nros/binonPATHfor interactive use. CI can skip this and call$NROS_HOME/bin/nrosby absolute path.- 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-runprints the plan first. (The zenoh router is deliberately not provisioned — it comes from a ROS 2 install,ros2 run rmw_zenoh_cpp rmw_zenohd.)
Then nros build from the workspace root. It produces the message bindings
and the build settings each [image.*] implies before it builds — see the
next section.
The Rust side: everything the build reads is generated, outside your source tree
Two things a Rust node package needs are generated, not authored: the
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
release’s SDK root. You state the target once, as a board name in the
bringup’s system.toml, and the build derives the rest:
[system]
name = "my_app"
rmw = "cyclonedds"
[image.linux]
board = "linux"
nros build writes those settings under build/, one set per [image.*]
row, beside the entry it generates for that image and never beside your
package, then hands them to cargo:
nros build # or: nros build <image-id> for one image
A CI job that drives cargo itself prepares the same files first, without
building, and runs the line nros build --dry-run prints:
nros configure # prepares every image, builds nothing
nros build <image-id> --dry-run # prints the cargo line, with its --config
nros configure is also the step to cache on: a run with nothing changed is a
no-op, and its stamp under build/nros/configured/<image>/ holds the digest of
every input that image read, which makes a CI cache key.
This is what makes a tree cheap to move: there is no .cargo/config.toml
to copy, no include line whose relative path cannot resolve from your
directory, and no table of absolute paths under version control to
invalidate. After relocating the workspace or installing another
release, the next build rewrites what is under build/ and touches nothing
you track. Switching an image to another board is editing its one board =
line.
Message dependencies are path deps that the build writes for you from
package.xml’s <depend>s (std_msgs = { path = "../../generated/std_msgs", default-features = false } # nros-managed); 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.cmakeat the SDK root). Located via-Dnano_ros_ROOT=<sdk-root>, whichnros buildand the CMakePresetsnros setup presetswrites pass; a rawcmakenames it (no shell exports it). The SDK root is the installed release’sshare/nano-ros(nros locate nano-rosprints it, for the version the project is pinned to).add_subdirectory(<sdk-root> nano_ros)— the lower-level shape. See Build as a CMake subdirectory for the cache-variable table.
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:
- Mirror the release asset.
install.shtakesNROS_INSTALL_URL=<url>— a mirror, an air-gapped copy, a test server. The asset’s.sha256must sit beside it and is still required. - Mirror or pin the SDK index.
nros setupreads the index the release ships;NROS_INDEX_URL=<url>fetches a newer one from a mirror,nros setup --index <path>uses a copy you already have, andNROS_OFFLINE=1forbids the fetch, and also runs every cargo thatnrosstarts withCARGO_NET_OFFLINE=true, so dependencies come from cargo’s own cache (item 5). The index names every asset’s upstream ([tool.*],[source.*]sections); for a full mirror, rehost those URLs in your copy. - Pre-install Corrosion:
nros setup --tool corrosion(or thenros setup <board>you already run) stages it into the SDK store, where the configure finds it without the network. Verify the configure line printsnano-ros: Corrosion <ver> via <origin>— never infer the version from having run an installer. - Cache
NROS_HOMEand the Rust toolchain. The CLI, the SDK root and every SDKnros setupfetches land under${NROS_HOME:-~/.nros}; pointNROS_HOMEat a shared or cached location and it is reusable across jobs and machines of the same host triple. The Rust toolchain is the exception:nros setupinstalls it with rustup, which keeps it in${RUSTUP_HOME:-~/.rustup}and${CARGO_HOME:-~/.cargo}(the crate registry cache too). Cache those two directories besideNROS_HOME, or the offline host’snros setuptries to download rustup again. - Prepare each workspace while online.
nros configurein the workspace fetches the crates its images need into cargo’s cache and writes everything generated, so the offline build has nothing left to download.
What lives in your tree, and what does not
An application, a message package, a board overlay crate, and a
custom transport all live entirely in your tree. A board the
tooling can see does too: point NROS_EXTRA_BOARD_PATH (env, or CMake
cache var for nano_ros_use_board()) at a directory of board crates in
your tree, laid out like the SDK’s packages/boards/. Board keys stay
global (a name under two roots errors).
A new platform (your own RTOS port) is the one extension that still
edits nano-ros itself (nros-platform’s feature list and type alias,
plus a cmake/platform/ module). That is a fork of nano-ros, built and
maintained the way Contributing describes
— see Custom Platform for the edits.
Upgrading the pin
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh \
| sh -s -- --version <newer>
nros pin <newer> # reports the codegen delta first
nros setup <board> # reconcile the SDK store with the new index
nros build # in each consuming workspace
What nros build generates is keyed to the CLI that wrote it, so the first
build after a pin move regenerates it; there is no separate step to remember.
Checklist
- One release version, installed with
--version, recorded withnros pin, moved forward only - CI drives
nros build(or a rawcmake --presetfrom theCMakePresets.jsonthatnros setup presetswrites) — no reliance on an interactive shell - CI runs
nros configurebefore any cargo line it drives itself (nros buildneeds nothing before it); no build setting hand-written into a package - Air-gapped: release asset mirrored (
NROS_INSTALL_URL), index pinned (--index/NROS_OFFLINE), Corrosion staged,NROS_HOMEcached - Your own boards found through
NROS_EXTRA_BOARD_PATH, not copied into the SDK - Router comes from the ROS 2 install on the host that runs it — nothing in the release provides one
Related
- Build as a CMake subdirectory — cache variables, link targets
- How Integration Works — per-RTOS build hosting
- Vendor Overlay Board Crate — board glue that lives in your tree
- Worked Example — STM32F4 Out of Tree
- Install — the interactive first-time flow this page assumes you are automating
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
the bringup’s system.toml and let 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. Start at
First Project and
Growing your workspace 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. nros build
sets exactly these variables in the root it generates.
The SDK root’s top-level CMakeLists.txt (the SDK root is the
share/nano-ros directory of the installed release,
nros locate nano-ros prints it) exposes everything via add_subdirectory(...). The
higher-level, ament-shaped alternative is
find_package(nano_ros REQUIRED) — nano_rosConfig.cmake at the
SDK root, located via -Dnano_ros_ROOT=<root>, which
nros build and the CMakePresets nros setup presets 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: there is no install prefix and no
find_package(NanoRos) (capital) config pipeline. Pinning a release
for a company tree (offline CI, upgrade workflow) has its own page:
Integrating into a Vendored Tree.
Layout
my_app/
├── CMakeLists.txt
└── main.c
nano-ros itself is the installed SDK root; the configure names it:
cmake -B build -DNANO_ROS_SDK="$(nros locate nano-ros)"
cmake --build build
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("${NANO_ROS_SDK}" 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
| Variable | Default | Values |
|---|---|---|
NROS_PLATFORM | posix | posix, freertos, nuttx, threadx (legacy spellings: freertos_armcm3, nuttx_armv7a, threadx_linux, threadx_riscv64). esp_idf is no longer accepted and is 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_BOOTSTRAP | OFF | ON skips the configure-time submodule bootstrap — which only a source checkout has; an installed SDK root takes its sources from nros setup. 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_RMW | zenoh | zenoh, dds, xrce, cyclonedds |
NROS_ROS_EDITION | humble | humble, iron, jazzy |
NROS_BUILD_CODEGEN | ON | ON / 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.
What about installing?
nano-ros has no install(...) rules. It 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>/ in the SDK root — they translate NuttX / PX4
manifests into the same root CMake. Each shell is a
~20-line wrapper around add_subdirectory(<sdk root>).
What the in-tree examples do instead
Worth reading as the contrast, because it is the shape most projects
want. The C node package
nros new c_talker_ws --board linux --template talker --lang c scaffolds is
a dozen lines and sets no cache variable at all:
cmake_minimum_required(VERSION 3.22)
project(c_talker_node LANGUAGES C)
find_package(nano_ros REQUIRED)
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_node::Talker
EXECUTABLE c_talker_node
SHAPE configure
TYPED)
The board, the RMW, the domain and the network identity are not in
that file — they are in the bringup’s system.toml, and the build reads
them from there. nros build generates the CMake root that adds the
package and the image’s entry, under build/, and passes the board’s
toolchain file; its message bindings are a CMake-time output.
All the C/C++ examples follow that shape. The add_subdirectory
mechanism on this page sits underneath it.
The Target Model
Every place an image can run is described the same way: a launcher or a
flasher (a host process, an emulator, or a tool that programs a board), a
console (where the image’s output appears), and a link (how its
middleware reaches the rest of the system). nros reads these from the
board’s descriptor, so the same commands work on every board.
nros runlaunches an image on the host or in an emulator.nros flashprograms a real board, andnros monitorattaches to its console.nros benchrecords which board on your machine is on which port.
This page is coming soon. Until then, see the
nros CLI reference.
Design background
Running an Image: nros run
nros run builds the image if it is stale, starts what its link needs, and
streams the console, for host images and emulators. nros run --expect '<line>' exits successfully when that line appears, which makes it a
one-line smoke test. On a real board it refuses and names the flashing step
instead.
This page is coming soon. Until then, see nros run in the
nros CLI reference and
Deployment Workflow.
Design background
Flashing and Monitoring a Board
nros flash programs a real board with a built image, and nros monitor
attaches to its console. Both read how to do it from the board’s descriptor.
This page is coming soon. Until those verbs are available for your board, flash with the board vendor’s own tools; see Deployment Workflow.
Design background
Your Bench: nros bench
nros bench records which board on this machine is connected to which
serial port and debug probe, so nros flash and nros monitor find it
without being told each time.
This page is coming soon.
Design background
Run Plans
Before it runs anything, nros run resolves a run plan: which launcher
or flasher, which console and which link, and where each fact came from. The
facts come from five levels, highest first: the command line, the image, your
machine’s bench file, the board’s descriptor, and the runner’s defaults.
--plan prints the plan without running it.
This page is coming soon.
Design background
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 build | How nano-ros plugs in | Shell in the repo | Start here |
|---|---|---|---|
Zephyr (west) | west module | zephyr/ (module.yml, Kconfig, CMakeLists.txt) | Zephyr (west module) |
NuttX (make + Kconfig) | apps/external/ app | integrations/nuttx/ (Make.defs, Kconfig, Makefile) | NuttX (apps/external) |
ESP-IDF (idf.py) | retired — integrations/nano-ros/ is gone | none ships today | ESP32 (ESP-IDF component) — retired; for an ESP32-C3 use ESP32 (esp-hal) |
| FreeRTOS, CMake project | add_subdirectory(nano-ros) | cmake/platform/nano-ros-freertos.cmake | Build as a CMake subdirectory, FreeRTOS (QEMU) |
| ThreadX, CMake project | add_subdirectory(nano-ros) | cmake/platform/nano-ros-threadx.cmake | Build as a CMake subdirectory, ThreadX |
PX4 (make px4_…) | EXTERNAL_MODULES_LOCATION copy-out | integrations/px4/module-template/ | PX4 (integration shell) |
PlatformIO (pio run) | library + pre-build codegen script | library.json (repo root) + integrations/platformio/ | integrations/platformio/README.md in the repo |
| IDE project (no CMake) | emitted drop-in sources | planned — no shell ships today | see note below |
Notes on the rows that need them:
- Vendored FreeRTOS — STM32Cube, MCUXpresso, Pico SDK, and upstream
FreeRTOS-Kernelall count. Your SDK builds the kernel and network stack; your CMake project doesadd_subdirectory(nano-ros)and linksNanoRos::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.
When nros build drives the build
The rows above are for a project whose build already exists. A nano-ros
workspace (Growing your workspace) has
no build of its own to integrate into: nros build generates each image’s
entry and the build root that compiles it under build/, then runs the
platform’s own tool on it. Which tool follows from the image:
| The image | nros build runs |
|---|---|
| a Zephyr board | west build, on a generated Zephyr application |
| any C or C++ node package | CMake (Rust packages join it through corrosion) |
| every node package Rust | cargo, on the generated entry package |
The tool is not hidden behind nano-ros. Compiler errors are the compiler’s,
unchanged. nros build <image> --dry-run prints the exact command for an
image, and running it yourself works. No command line names a --target:
the board’s triple, linker flags and QEMU runner are in the settings
generated for that image. On Zephyr, nros build prints Zephyr’s next
command for the image’s own build directory, west build -d <dir> -t run
for an emulated board or west flash -d <dir> for one with a flash runner.
Per platform
The nros setup name to provision each platform, where its copy-out
talkers live, and its chapter:
| platform | nros setup <board> | copy-out talkers under | chapter |
|---|---|---|---|
| Linux host | linux | examples/linux | Native host build |
| FreeRTOS (QEMU MPS2-AN385) | mps2-an385-freertos | examples/mps2-an385-freertos | FreeRTOS |
| NuttX (Arm) | qemu-armv7a-nuttx | examples/qemu-armv7a-nuttx | NuttX |
| NuttX (RISC-V) | rv-virt-nuttx | examples/rv-virt-nuttx | NuttX |
| ThreadX (Linux sim) | threadx-linux | examples/threadx-linux | ThreadX |
| ThreadX (QEMU RISC-V 64) | rv-virt-threadx | examples/rv-virt-threadx | ThreadX |
| Zephyr | zephyr | examples/zephyr | Zephyr |
| Bare-metal Cortex-M3 | mps2-an385-baremetal | examples/mps2-an385-baremetal | Bare-metal |
| Arm FVP (Cortex-A SMP) | zephyr + the Arm FVP (nros setup --tool arm-fvp, x86_64 only) | — | ARM FVP |
Each platform directory holds one copy-out talker per language, at
<lang>/talker/: a workspace with one node package and a bringup with no
launch/, whose launch file is generated. Copy its directory anywhere and
build it there; it is the example to start from.
The paired examples are not separate directories. Talker and listener,
service server and client, and action server and client are image rows of
the language workspaces examples/workspaces/{rust,c,cpp}, under one bringup
per behaviour: pubsub_bringup, service_bringup and action_bringup. An
image id is <platform>_<role>, plus _<rmw> for any RMW other than zenoh,
so one role on one platform is one command:
nros run pubsub_bringup:freertos_listener
nros run service_bringup:zephyr_service_server_xrce
Variants with different code are node packages of their own, configurations
such as a serial link are image rows that state the knob, and the Linux
feature demos (logging, parameters, lifecycle, custom messages) live in
examples/workspaces/features. The multi-node workspace examples all live
under
examples/workspaces.
Coverage Matrix
shows which platform, language and RMW combinations have examples.
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
- Zephyr workspace: Zephyr (west module)
- NuttX board: NuttX (apps/external)
- ESP32-C3: ESP32 (esp-hal) — bare-metal. The ESP-IDF component shell is retired
- PX4 firmware: PX4 (integration shell)
- Plain CMake host (FreeRTOS, ThreadX, POSIX): Build as a CMake subdirectory
- Deploy targets and
system.toml: Deployment Workflow - A project with several nodes or boards: Growing your workspace
- If the first build fails: Troubleshooting — First 10 Minutes
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples as image rows of behaviour bringups.
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 project 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. Neither native (every general-purpose host) nor
posix (the platform) names a board: board = "…", nros setup,
and nros new --board 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
Install the nros CLI (Installation),
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). Rather not run a router? Use
--rmw cyclonedds — it needs no daemon. Either way the first build
generates the messages from your ROS 2 install, so it needs ROS 2 sourced.
With the nros CLI on PATH (Installation —
install.sh puts it in ~/.nros/bin):
# Provision the Linux host (zenoh RMW is the default):
nros setup linux --rmw zenoh # or: --rmw xrce / --rmw cyclonedds
linux is this board’s one name; posix names the platform layer underneath
it, and native names no board at all
(Retired names).
For a colcon consumer workspace that already has nano-ros under
src/:
colcon build && source install/setup.bash
Workspace Layout
A host project is a workspace, like every other board’s:
nros new host_ws --board linux --template talker --lang rust # or --lang c / cpp
cd host_ws
host_ws/
└── src/
├── host_node/ # node package: package.xml + Cargo.toml (or CMakeLists.txt), the node
└── host_node_bringup/ # package.xml and system.toml: board, rmw, domain, components
The node package is a library with no main(); the bringup’s
[image.linux] block (board = "linux") is what makes it a host program.
The examples on GitHub are copy-out ready: nothing in one names a path into
the SDK. nros finds its own SDK; --nano-ros-path names another.
Code Example
A Rust node package’s src/lib.rs declares what the node creates and what
its callbacks do, and exports the node; the entry that opens the executor and
spins it is generated:
pub struct Talker;
impl Component for Talker {
const NAME: &'static str = "talker";
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::<StringMsg>("/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 { … the timer callback publishes "Hello World: N" … }
nros::node!(Talker);
Use the full First Node — Rust
walkthrough for generated messages and runnable commands, and
Growing your workspace for each
line of lib.rs.
Build and Run
Start the RMW host daemon (the router comes from ROS 2). For zenoh:
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd
Then build and run the node from the workspace root, in a shell that has sourced ROS 2. The first build generates the messages from it and records the prefix, so later runs need no sourced shell:
source /opt/ros/humble/setup.bash
nros run host_node
nros run builds first if anything changed. nros build alone prints the
binary’s path under build/, which runs directly too. For a workspace that
lives in a colcon consumer workspace, colcon build && source install/setup.bash && ros2 run … works as well.
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.
Design background
FreeRTOS (QEMU MPS2-AN385)
Support level:
mps2-an385-freertos,freertos-posix,s32z270-freertos,mps3-an536-freertos. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
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 are on GitHub as reading material; this page scaffolds your own.
Prereqs.
nros setup mps2-an385-freertosis the single command that prepares your machine for this board. It fetches a prebuilt toolchain set into the shared store at~/.nros/sdk— thearm-none-eabi-gcccross-compiler, the patchedqemu-system-armemulator, 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, whichnros setupinstalls.
Setup
With the nros CLI on PATH (Installation —
install.sh puts it in ~/.nros/bin), 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.
Project layout
nros new scaffolds a workspace for the board, in any of the three languages:
nros new fr_ws --board mps2-an385-freertos --template talker --lang c # or --lang cpp
cd fr_ws
nros new fr_ws --board mps2-an385-freertos --template talker --lang rust
cd fr_ws
fr_ws/
└── src/
├── fr_node/ # node package: package.xml + CMakeLists.txt (or Cargo.toml), the talker
└── fr_node_bringup/ # package.xml and system.toml: the board, the RMW, the node
fr_node is a library: no main(), no board, no RMW. The bringup’s
system.toml holds one image, [image.freertos], with board = "mps2-an385-freertos". The program’s entry, the FreeRTOS startup task that
runs your node, is generated from that image at build time.
Nothing else states the deployment. The Rust side 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 build under build/. A C or C++ node package
has no <nano_ros deploy=… board=… rmw=…/> tuple in its package.xml and
names no board in its CMakeLists.txt.
The board crate (nros-board-mps2-an385-freertos) wraps the kernel +
lwIP + LAN9118 driver build; the generated entry depends on it, so
nothing you write names it.
Configure
Board, RMW, domain and network identity are declared in the bringup’s
system.toml and baked at compile time — there is no config file on the
device. With a locator and an address added,
src/fr_node_bringup/system.toml reads:
[system]
name = "fr_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "fr_node"
class = "fr_node::Talker"
name = "fr_node"
[image.freertos]
board = "mps2-an385-freertos"
locator = "tcp/10.0.2.2:7800"
os_config = ["board-net.toml"] # [eth0] network = "qemu", host = 15
locator is where the router is (the QEMU guest reaches the host’s router
through the Slirp gateway 10.0.2.2); os_config names the board network
file, board-net.toml beside system.toml in the bringup. The file is the
same whatever language the node is written in. Nothing about the deployment
lives in Cargo.toml, in CMakeLists.txt, or in the package.xml
<export> block.
Switching board is editing [image.<id>] board and rebuilding: nros build picks the cross-toolchain file the board names, and the generated entry
brings the board crate, its RMW and nros-platform’s FreeRTOS feature.
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 image above dials host port
7800; with no locator, as scaffolded, an image dials the board’s default
(tcp/10.0.2.2:7447).
Changing this in the tree: see Internals → per-platform lanes.
Build
The build generates the message bindings from your ROS 2 install, so source it in this shell first. This first build records the ROS 2 prefix in the workspace, so later builds and runs of it need no sourced shell.
In the workspace root (fr_ws/, where nros new left you), whatever the
language:
source /opt/ros/humble/setup.bash
nros build
nros build prepares the build first (message bindings, the resolved launch
model, the generated entry for [image.freertos]), then hands off to cargo or
CMake with the board’s cross-toolchain file and the SDK root. There is no step
to run before it.
The first build cross-compiles nano-ros’s dependencies and the FreeRTOS kernel
- lwIP (~3 min for C / C++, ~5 min for Rust). The ELF lands under
build/.nros build --dry-runprints the native commands without building.
After a successful build nros build prints what it built and the
command that runs it — a block like
nros build: next for `fr_node_bringup:freertos`:
artifact: /home/you/fr_ws/build/…/freertos_entry
run:
nros run fr_node
Driving the native tool yourself is the escape hatch, not the path.
nros build --dry-run prints the exact cargo build … or cmake lines, run
against the generated entry under build/. To write that entry without
building (an IDE, a CI cache step), run:
nros configure
For a CMake tree that is not a nano-ros workspace at all, see Build as a CMake subdirectory.
See
When nros build drives the build
for which tool builds each image.
Run on QEMU
Three terminals: the router, the board, and a stock ROS 2 peer.
1. The router — ROS’s rmw_zenohd, on the port your image’s locator
names (7447 when it names none, as a scaffolded image; 7800 in the
example shown above). It must listen on 0.0.0.0 for the Slirp gateway:
Slirp forwards the guest’s 10.0.2.2:<p> to the host’s <p>.
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
2. The board — from the workspace root:
nros run fr_node
nros run rebuilds if anything changed, then boots the image in QEMU with the
board’s runner: the store’s patched qemu-system-arm (nros setup mps2-an385-freertos installed it), the LAN9118 on Slirp, and -icount shift=auto, which keeps the board’s timer in step with Slirp’s I/O. It streams
the semihosting console until you press Ctrl-C.
The scaffolded talker publishes std_msgs/String once a second, in every
language:
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
3. A stock ROS 2 peer. The talker publishes best-effort; stock ros2 topic echo defaults to RELIABLE, so the QoS-mismatched echo silently delivers
nothing. Force best-effort to receive:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
It prints a data: line per message:
data:
Ctrl-C stops nros run and QEMU with it.
Readiness signal. Within ~20 seconds of QEMU boot, the image
prints Publishing: 'Hello World: 1' on its semihosting stdout — a
std_msgs/String counted from 1, like 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:
- Confirm the router is running on the host on the port the image
dials — the
locatorin itssystem.toml(Slirp forwards10.0.2.2:<p>→ host:). Without it the talker makes one connection attempt and stops with
Failed to open session; a board that should wait for a late router sets[knobs.zenoh.limits] connect_forever = true(orconnect_timeout_msfor a bounded wait). - Check the talker’s early log for
lwIP DHCP timeoutorFailed to open session. - Bridge tip:
ros2 topic echo /chatterfrom a stock ROS 2 install (withRMW_IMPLEMENTATION=rmw_zenoh_cpp) confirms end-to-end interop. - See Troubleshooting — First 10 Minutes.
Run on the POSIX simulator
The freertos-posix board runs the FreeRTOS kernel as an ordinary
Linux process (FreeRTOS’s POSIX port) — no QEMU, host networking. It is
a C / C++ board (it has no Rust entry shape). Provision it, point the
image of a C or C++ workspace at it, and run:
nros setup freertos-posix --rmw zenoh
# in src/fr_node_bringup/system.toml: [image.freertos] board = "freertos-posix"
nros run fr_node
The image dials the router on the host directly (no Slirp gateway), so
start rmw_zenohd on the port its locator names, as in
Run on QEMU step 1.
Flash a real board
No in-tree FreeRTOS board is flashed by a documented flow, and none is claimed: the MPS2-AN385 boards above are QEMU machines.
-
NXP S32Z270 (
s32z270-freertos; its status is on its board page). No emulator models its Cortex-R52 RTU, so nothing here boots it. It builds like the other boards — the licensed pieces (NXP’sGCC/ARM_CR52_GICFreeRTOS port, the RTD NETC Ethernet driver, the S32 Config ToolsPBcfg) are yours to supply, as its descriptor explains — and the ELF is flashed with your own S32 tooling (S32 Design Studio, a Lauterbach/PEmicro probe); nano-ros adds no flash step:nros setup s32z270-freertos --rmw zenoh # in src/fr_node_bringup/system.toml, a second image: # [image.s32z] board = "s32z270-freertos" nros build s32z # → nros build prints the ELF path; flash it with your S32 tooling -
Your own FreeRTOS board is a board package of your own — see Adding a FreeRTOS Board — and is flashed with whatever that board’s vendor uses (probe-rs, OpenOCD, a vendor IDE).
Neither flash path is run by any probe (a CI runner has no board): both stand unverified until run against a named board. How often the QEMU line above runs is on each board’s page under Supported Boards.
GitHub source
The copy-out talkers for this board, one per language. Each is the same project this page scaffolds: one node package and a bringup with no launch file of its own.
- Rust:
examples/mps2-an385-freertos/rust/talker/ - C:
examples/mps2-an385-freertos/c/talker/ - C++:
examples/mps2-an385-freertos/cpp/talker/
Next
- Subscriber, services and actions: the
freertos_*image rows of the language workspaces,examples/workspaces/{rust,c,cpp}, one bringup per behaviour (pubsub_bringup,service_bringup,action_bringup).nros run pubsub_bringup:freertos_listenerruns the listener;freertos_listener_xrceis the same role over XRCE. - No RTOS: the Bare-metal Cortex-M3 page is the same board without FreeRTOS.
- 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.
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples as image rows of behaviour bringups.
Zephyr (west module)
Support level:
native_sim,mps2_an385, the S32K344 safety island. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
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.
Changing this in the tree: see Internals → in-tree examples.
Just want a working starter? Clone the
nano-ros-zephyr-examplerepo (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.nros buildgenerates the messages from your ROS 2 install, so its first build of a workspace needs ROS 2 sourced. Python: 3.10+ on Zephyr 3.7 LTS, but ≥ 3.12 on Zephyr 4.x (4.x’sfind_package(Python3)requires 3.12 — see the version matrix below). nano-ros’s imported west fragmentzephyr/west.ymlis 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:
| Capability | Zephyr 3.7 LTS | Zephyr 4.x |
|---|---|---|
| Min Python | 3.10 | 3.12 (find_package(Python3)) |
| RMW selection | the image’s rmw in system.toml | the image’s rmw in system.toml (an app with no system.toml: the -S nros-<rmw> snippet) |
| nano-ros patches | applied during nros setup zephyr provisioning | applied 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; the lanes are in Internals → Zephyr in-tree examples.
Project layout
A nano-ros project on Zephyr is a workspace like on every other platform,
beside the west workspace that holds Zephyr. Your code is node packages; the
bringup’s system.toml names the board. The Zephyr application is generated:
nros build writes it under build/ and runs west build on it.
my_zephyr_ws/ # the west workspace: Zephyr and its modules
├── .west/
├── manifest/west.yml
├── zephyr/ # cloned by `west update`
└── modules/
c_ws/ # the nano-ros workspace
├── src/
│ ├── c_node/ # your node package
│ └── c_node_bringup/
│ ├── package.xml
│ └── system.toml # [image.zephyr] board = "zephyr"
└── build/ # generated, the Zephyr application among it
Quick start builds exactly
this. An application that owns its west build and has no bringup (its own
CMakeLists.txt and prj.conf, with nano-ros added as one more module) is
the subject of its own chapter,
A Zephyr App You Build with West.
Prerequisites
nros setup zephyr provisions what the index declares for the board — the
Zephyr SDK ([board.zephyr] packages = ["zephyr-sdk"]) and the RMW
transport sources (zenoh-pico + mbedtls for zenoh, the cyclonedds fork) —
from a pinned index into ${NROS_HOME:-~/.nros}/sdk. The Zephyr sources and
modules themselves are a west workspace, which you keep the standard Zephyr
way. It provides no zenoh router: that is ROS’s rmw_zenohd
(see the RMW design); Cyclone DDS needs no daemon at all.
- Get the
nrosCLI — install a release withscripts/install.sh(Installation); it lands in~/.nros/bin. - Provision the board + RMW:
nros setup zephyr --rmw zenoh # Zephyr SDK + zenoh-pico + mbedtls nros setup --source px4-rs # workspace cargo-load dep - Point Zephyr at the SDK if your shell does not already find it —
export ZEPHYR_SDK_INSTALL_DIR="$(nros locate tool zephyr-sdk)", which prints wherenros setupinstalled it (or register it via the SDK’ssetup.sh -c). - Source ROS 2 for the first build.
nros buildgenerates the message bindings from your ROS 2 install’s definitions,std_msgsincluded, so the types match your ROS 2 peers:source /opt/ros/humble/setup.bashbefore the firstnros build,nros runornros configureof a workspace. That configure records the prefix, so later shells need not source ROS.
The zenoh router must be running before an example connects
(ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd); for xrce, the Micro-XRCE-DDS agent
nros setup … --rmw xrce installed; for cyclonedds, nothing.
Quick start: a talker on native_sim, from nothing
The sections after this one explain each piece; this is the whole path in
order, for a C talker on native_sim (a host executable, so no emulator).
Zephyr’s own host tools come first — west and the build tools
Zephyr’s Getting Started
lists; nros setup installs no system packages. On Debian/Ubuntu:
sudo apt-get install -y --no-install-recommends git cmake ninja-build gperf \
device-tree-compiler python3-pip python3-venv xz-utils file wget
pip3 install --user west
export PATH="$HOME/.local/bin:$PATH"
Provision the board and RMW (the Zephyr SDK, zenoh-pico, mbedtls):
nros setup zephyr --rmw zenoh
export ZEPHYR_SDK_INSTALL_DIR="$(nros locate tool zephyr-sdk)"
A west workspace that pins Zephyr 3.7 and fetches only the modules nano-ros’s
boards build with (nros setup zephyr lists them; import: true would fetch
every vendor HAL, ~2.5 GB more):
mkdir -p my_zephyr_ws/manifest && cd my_zephyr_ws
cat > manifest/west.yml <<'WEST_YML'
manifest:
remotes:
- name: zephyrproject-rtos
url-base: https://github.com/zephyrproject-rtos
projects:
- name: zephyr
remote: zephyrproject-rtos
revision: v3.7.0
import:
name-allowlist: [cmsis, picolibc, mbedtls, tinycrypt, mcuboot, net-tools]
# Rust support: only a Rust app builds with it. Pinned to the revision
# nano-ros's own west.yml pins for the 3.7 line (it has no release tags).
- name: zephyr-lang-rust
remote: zephyrproject-rtos
revision: 404fcefdbab0f2711cd342a1fab9d2eda52a1ba9
path: modules/lang/rust
self:
path: manifest
WEST_YML
west init -l manifest
west update --narrow -o=--depth=1
pip3 install --user -r zephyr/scripts/requirements-base.txt
export ZEPHYR_BASE="$PWD/zephyr"
Scaffold a workspace beside the west workspace: a C talker node package and
its bringup, whose image runs on board zephyr (Zephyr’s native_sim/native/64).
nros build writes the Zephyr application for that image under build/ and
runs west build on it, from the west workspace NROS_ZEPHYR_WORKSPACE names.
You write no CMakeLists.txt and no prj.conf.
The board package also applies nano-ros’s nros-nsos snippet, which routes the
image’s sockets to the host’s (Zephyr’s NSOS) instead of a TAP interface.
Without it native_sim tries to create one (eth_posix: Cannot create zeth),
which needs privileges a container or an ordinary user does not have, and the
image never reaches the router.
The first build generates the message bindings from your ROS 2 install, so source it in this shell first. It records the ROS 2 prefix in the workspace, so later builds and runs of it need no sourced shell.
source /opt/ros/humble/setup.bash
export NROS_ZEPHYR_WORKSPACE="$PWD"
cd ..
nros new c_ws --board zephyr --template talker --lang c --rmw zenoh
cd c_ws
nros build
That wrote two packages, the same shape every platform’s scaffold has:
c_ws/
└── src/
├── c_node/ # the talker: package.xml, CMakeLists.txt, its C source
└── c_node_bringup/ # package.xml, and system.toml: [image.zephyr] board = "zephyr"
Run it against a router on the host’s loopback — native_sim uses the host’s
sockets, and the image dials tcp/127.0.0.1:7447 unless the bringup’s
system.toml states a locator:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
With the router running, run the talker:
nros run c_node
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
An application you already build with west. A Zephyr application that
keeps its own CMakeLists.txt and prj.conf and has no bringup is not a
workspace image: it adds the nano-ros module to a plain west build, as
A Zephyr App You Build with West shows.
The same talker in Rust. The scaffold is the same with --lang rust, and
so is everything nros build does with it. Make it beside the west workspace
above.
zephyr-lang-rust needs three things the C app does not: the Rust target the
Zephyr board implies (x86_64-unknown-none for native_sim/64 on an x86_64
host), rustup’s clippy (it lints every Rust app as part of the build), and
clang and libclang, for the bindings it generates from Zephyr’s headers.
nros setup in the workspace installs the target and clippy, and with
--sudo the two packages (without it, it prints the install command).
nros build refuses a Rust Zephyr image that lacks any of them, naming the
command.
source /opt/ros/humble/setup.bash
. "$HOME/.cargo/env" # the cargo `nros setup` installed (Installation)
export NROS_ZEPHYR_WORKSPACE="$PWD"
cd ..
nros new rs_ws --board zephyr --template talker --lang rust --rmw zenoh
cd rs_ws
nros setup --sudo
nros build
With the router above running, run it:
nros run rs_node
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
Configure
nano-ros’s Zephyr module ships in the installed SDK — the SDK root (the
release’s share/nano-ros; nros locate nano-ros prints it) holds its
zephyr/module.yml. You do not add
nano-ros to your west.yml: nros build names the module on the
west build line it runs (-DZEPHYR_EXTRA_MODULES=<sdk root>), and a
west build you run yourself passes the same:
west build -b native_sim/native/64 apps/my_app -- \
-DZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)"
Your west.yml pins Zephyr itself (and whatever modules your
application uses) the standard Zephyr way:
manifest:
remotes:
- 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
The transport sources the module compiles (zenoh-pico, mbedtls, the
Cyclone DDS fork) come from nros setup zephyr --rmw <rmw>, not from
west.
Then nano-ros’s half of the configuration goes in the bringup’s
system.toml, and Zephyr’s own half in prj.conf fragments (in a workspace,
under the bringup’s boards/<board>/, as
Growing your workspace shows):
# src/c_node_bringup/system.toml
[system]
name = "c_node_bringup"
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 in its own
prj.conf (A Zephyr App You Build with West).
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.
Configuring an image: system.toml, not prj.conf
An image’s nano-ros configuration lives in ONE place, the bringup’s
system.toml. 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.toml | The 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 = 12 | CONFIG_NROS_MAX_QUERYABLES=12 — a registry knob, spelled as a board’s [knobs] is (see Configuration) |
[image.<id>.knobs.xrce] max_subscribers = 0 | CONFIG_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_ |
os_config = ["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. 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
A bringup 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"
os_config = ["prj-zenoh.conf"]
[image.zephyr_xrce]
board = "zephyr"
rmw = "xrce"
os_config = ["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"
os_config = ["prj-cyclonedds.conf"]
nros build # the default_images: zephyr_zenoh
nros build zephyr_xrce # another image — its own west build dir
Each image builds into its own directory,
build/<platform>-<rmw>/<package>__<image>_west, so switching images never
reuses another image’s cache.
To run west yourself on one image, nros build zephyr_xrce --dry-run prints
the west build line for that image’s generated application and its own
build directory.
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 os_config 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.confusesCONFIG_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 sharedprj.confenables. The agent endpoint is the image’slocator. - Cyclone DDS: brokerless RTPS, wire-compatible with stock ROS 2
(
rmw_cyclonedds_cpp). Its source is C++, soCONFIG_CPP=yeven for Rust callers; it needs a large stack (CONFIG_MAIN_STACK_SIZE=524288), pthread pools, andCONFIG_NET_IPV4_IGMP=yfor SPDP multicast. The bool prerequisites (thread-local storage, dynamic threads,NET_TCP, …) areselected byCONFIG_NROS_RMW_CYCLONEDDS. Itsddsrt_mallocdraws 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.
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 Zephyr + its modules 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
On every platform nros build generates the image’s entry under build/ and
runs the platform’s tool on it. On Zephyr the entry is a Zephyr application
and the tool is west build. What stays yours is the west workspace:
west init / west update populate it, it pins your Zephyr, and nano-ros never
writes into it.
So for a Zephyr image nros build:
- writes the application for that image under
build/, from the image’s board and the bringup’s nodes (or usessrc/<image>_entry/, if you have taken it over withnros materialize); - resolves your Zephyr —
$ZEPHYR_BASE, else$NROS_ZEPHYR_WORKSPACE, else an in-treezephyr-workspace/, else a sibling../nano-ros-workspace. Note it is a Zephyr that is needed, not a.west/directory: withZEPHYR_BASEset,west buildruns from anywhere, which is what lets the application live under your workspace’sbuild/, outside the west workspace; - runs
west buildfrom 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 …/build/…/demo_bringup__zephyr_west
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.
nros build is the flow this chapter teaches: it applies the image’s overlays, picks the build
directory and, when it finishes, prints the artifact and the Zephyr command
that runs or flashes it. It never becomes a required layer between you and
west — a Zephyr app you build with west stays the escape hatch,
for an application that owns its west build and has no bringup.
Starting from nothing: nros new
nros new rs_ws --board zephyr --template talker writes a workspace whose
bringup already has a Zephyr image, as in the quick start. Inside a workspace,
a further 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 talker_pkg --template talker --to demo_bringup
It writes the package and, with --to, adds the [[component]] row to that
bringup. There is no Zephyr application to scaffold: nros build and
nros run generate it from the image.
Owning the application: nros materialize
The generated application takes the image’s keys (board, os_config, the
RMW) and the board package’s Kconfig and devicetree overlay, and most changes
are one of those. When a change is none of them, and the application’s own
CMakeLists.txt or prj.conf has to change, take it over:
nros materialize zephyr
That writes the generated application into src/zephyr_entry/ as a package of
yours, and from then on nros build builds that copy and does not regenerate
it. Deleting src/zephyr_entry/ returns the image to generation. It is the
last resort, for the reasons
Customising an entry
gives.
The whole flow, end to end
Four 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. build. This generates the message bindings the workspace's packages
# depend on, resolves the application, applies the image's overlays and
# runs `west build` for you.
nros build demo_bringup:zephyr
# 2. 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
# 3. run the image — `nros build` printed this, with its own build dir
# (build/<coordinate>/<bringup>__<image>_west):
west build -d build/zephyr-zenoh/demo_bringup__zephyr_west -t run
# (on native_sim that runs ./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: Publishing: 'Hello World: 1'
<inf> rust: talker_pkg: Publishing: 'Hello World: 2'
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: 'Hello World: 6'
---
Step 2 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"
conf = ["prj-zenoh.conf"] # the RMW overlay this app requires
None of them sets entry: nros build generates each image’s application.
os_confignames 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 itsprj-<rmw>.confnext to theCMakeLists.txtwest builds. These entriesFATAL_ERRORwithout an RMW overlay, so the image has to say which one.boardis a nano-ros board id, which nano-ros resolves to the Zephyr board target. See below.entrynames the application package. Normally leave it out: an entry declares the deploy target it serves — its ownsystem.tomlin Rust,nano_ros_add_executable(... DEPLOY zephyr)inCMakeLists.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 buildlists the candidates rather than picking one. Reachable for C/C++ entries, which are matched by theirDEPLOYtoken; - none does —
nros buildfalls 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 withnano_ros_use_board(...)inside its ownCMakeLists.txtis this case: theDEPLOYscan does not read that call. Usually the better fix is to delete the application —nros buildgenerates one and hands it the board package’s own Kconfig and devicetree overlay, which is allnano_ros_use_boardadded.
- several packages claim the board — deriving would be a coin flip, so
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 —
a checkout of this repository, or a mirror of a release you vendor (see
Pin and mirror a release). With the installed SDK alone
there is no west nros: west reads extension commands from a manifest
project, and the installed SDK root is a versioned directory under ~/.nros
that no west.yml can name. Name the Zephyr workspace to nros instead,
which is what west nros does for you:
nros build --zephyr-workspace ~/zephyr-workspace demo_bringup:zephyr
west nros is the only nano-ros west extension; to run an image, use 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_BASEyou 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.
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.
nros build runs a plain west build, so the extension points are untouched.
The first row edits the application’s CMakeLists.txt, so it needs an
application you own (nros materialize);
the rest do not:
| you want | you do | nros build involvement |
|---|---|---|
| an out-of-tree module | list(APPEND ZEPHYR_EXTRA_MODULES <dir>) before find_package(Zephyr) | none |
| a driver in that module | zephyr_library() + zephyr_library_sources(), gated on your own Kconfig | none |
| its devicetree binding | dts/bindings/ + settings: {dts_root: .} in the module’s zephyr/module.yml | none |
| an out-of-tree board / SoC / arch / snippet | the 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 image | a os_config fragment on [image.*] | passed as EXTRA_CONF_FILE |
| a devicetree overlay for one image | an .overlay in the same os_config list | passed 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]
os_config = ["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: Publishing: 'Hello World: 1'
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.
Driving west yourself
An application with no bringup, built with a plain west build and configured
in its own prj.conf, is covered in
A Zephyr App You Build with West: adding the module,
the Kconfig settings that replace system.toml, messages, and running it.
Rust applications
Two things differ for a Rust app (C/C++ apps skip this section):
-
The Rust crate’s
[lib]must be namedrustapp(crate-type = ["staticlib"]) — azephyr-lang-rustcontract: itsrust_cargo_application()linkslibrustapp.a. The Cargo package name is free. -
Generate the interface crates and the dependency wiring for YOUR layout before the first
west build, which does not run nano-ros’s preparation for a Rust app. From your app dir, run (after the Prerequisitesnros setup, which provides the codegen toolchain), in a shell that has sourced ROS 2 (the message definitions come from it; this configure records the prefix for later runs):source /opt/ros/humble/setup.bash nros configureThis writes
generated/<pkg>/(the message crates) and a.cargo/config.tomlwhose[patch.crates-io]points thenros-*crates into the nano-ros SDK root and the generated interfaces atgenerated/*.--nano-ros-path <dir>names another root (the SDK ROOT, the directory holdingnano_rosConfig.cmake).
Run
nros build ends by printing what to type next, read from the finished build
itself — the emulators Zephyr’s board declares (run_<emu> targets in
build.ninja) and its flash runner (zephyr/runners.yaml). For an image in
build directory <dir> that is one or both of
west build -d <dir> -t run # an emulated board: native_sim, QEMU
west flash -d <dir> # a board with a flash runner
Both are Zephyr’s own commands; nano-ros adds nothing to them. The router comes first either way:
# 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 &
Run on native_sim
board = "zephyr" is Zephyr’s native_sim/native/64: the image is a host
executable, and its sockets are the host’s (NSOS), so it reaches the router on
loopback with no network setup.
nros build demo_bringup:zephyr
west build -d build/zephyr-zenoh/demo_bringup__zephyr_west -t run
# — the same as running the executable directly:
./build/zephyr-zenoh/demo_bringup__zephyr_west/zephyr/zephyr.exe
(A build you drove with plain west build, into west’s default build/:
west build -t run, or ./build/zephyr/zephyr.exe.)
Run on QEMU (mps2-an385)
The Cortex-M3 witness is Zephyr’s mps2/an385 under QEMU, with Zephyr’s own
IP stack on the emulated SMSC911x. An image names it board = "mps2-an385-zephyr"; west build -t run boots it under the QEMU the Zephyr
SDK ships (nros setup zephyr installed it). In the Rust workspace above, a
second image is that board. nros setup board comes first: it applies
nano-ros’s patch set to your west workspace, without which zephyr-lang-rust’s
cargo command carries none of nano-ros’s settings for the board (among them
the C cross compiler the zenoh-pico crate builds with), and adds the board’s
Rust target. The image is one more block in the bringup:
nros setup board mps2-an385-zephyr --zephyr-workspace "$NROS_ZEPHYR_WORKSPACE"
cat >> src/rs_node_bringup/system.toml <<'TOML'
[image.zephyr_m3]
board = "mps2-an385-zephyr"
TOML
nros setup --sudo
nros build zephyr_m3
The guest sits on QEMU’s user-mode network, and the board package states it:
its prj.conf gives the image 10.0.2.15 (gateway 10.0.2.2) and the locator
tcp/10.0.2.2:7447, and nros build applies it, so there is nothing to set.
The Slirp gateway 10.0.2.2 is the host, so the router above is reachable.
An image whose router listens elsewhere states its own locator in
system.toml.
With two images in the bringup, name the one to run:
nros run zephyr_m3
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
Verify from stock ROS 2
# The scaffolded talker publishes std_msgs/String (the talker in
# "A Zephyr App You Build with West" 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
The Zephyr boot banner runs first, then the talker fires: it prints
Publishing: 'Hello World: 1', Publishing: 'Hello World: 2', …
(Rust + C + C++ all count from 1, matching the official ROS 2 demo talker).
Readiness signal. On native_sim, expect the first publish line
(Publishing: 'Hello World: 1') within 5 seconds of boot; on a QEMU
board (mps2-an385-zephyr, or a qemu_cortex_a9 you built yourself)
expect it within ~15 seconds (QEMU cold boot + Zephyr init). If no publish line
in 30 seconds:
- Confirm
CONFIG_NROS=ylit up viawest build -t menuconfig; without it the module shell neveradd_subdirectory’s nano-ros. - Check
CONFIG_NETWORKING=y,CONFIG_NET_IPV4=y,CONFIG_NET_TCP=yinprj.conf— Zephyr networking is opt-in. - Confirm
zenohdreachable from the simulated network (Slirp needs10.0.2.2:7447on QEMU; native_sim uses host loopback). - 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 renamedETH_NATIVE_TAPin 4.x; the version-aware NSOS overlay handles it (contributors: the in-treejust zephyr build-onerecipe applies it automatically).
Flash a real board
The boards above are native_sim and QEMU machines, and the ARM FVP is a
model (its chapter). The hardware boards nano-ros ships a
package for are the Nucleo-F767ZI (board = "nucleo-f767zi-zephyr"), whose
Renode twin boots the same image (its chapter), and the
Raspberry Pi Pico W (board = "rpi-pico-w-zephyr"), whose Wokwi twin does;
the Pico W’s own chapter is still to come. Any board Zephyr supports is reached with a
board package that names it (west_board, see
Which boards you can name), and flashed with the
runner Zephyr’s own board definition declares:
# src/my_board/nros-board.toml names west_board = "nucleo_f767zi" (say);
# in system.toml: [image.nucleo] board = "my-board"
nros build demo_bringup:nucleo
west flash -d build/zephyr-zenoh/demo_bringup__nucleo_west
Not replayed. No probe runs this flash line (a CI runner has no board), so it is written from the board’s runner, not measured (tracking issue).
nros build prints that west flash line when the build’s
zephyr/runners.yaml names a flash runner. The runner (J-Link, nrfjprog,
OpenOCD, pyOCD, …) and its probe are Zephyr’s and your bench’s; nano-ros adds
nothing to them. The board must also have a network nano-ros can use.
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. 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. Both lines now use the provisioner. Cyclone-DDS-on-Zephyr patches stay baked into the pinned
cyclonedds submodule, not delivered through patches.yml.)
Start from an example
The copy-out talkers under
examples/zephyr/<lang>/talker/
on GitHub, one per language, are copy-out clean, with no reference back
into the nano-ros repository. Each is the same project as the
quick start’s: one node
package and a bringup with no launch file of its own, whose launch file is
generated. Their node sources are a starting point to copy into your own node
packages.
The other roles are image rows of the language workspaces,
examples/workspaces/{rust,c,cpp}, one bringup per behaviour:
pubsub_bringup, service_bringup and action_bringup. A Zephyr image id is
zephyr_<role>, plus _<rmw> for any RMW other than zenoh, so
nros run pubsub_bringup:zephyr_listener_xrce
builds and runs the XRCE listener. The release does not ship examples/;
nros new <ws> --board zephyr --template <role> --lang <c|cpp|rust> writes
any role as a workspace of your own.
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
- Zephyr module shell:
zephyr/ - Copy-out talkers:
examples/zephyr/rust/talker/,examples/zephyr/c/talker/,examples/zephyr/cpp/talker/ - The other roles: the
zephyr_*image rows of the language workspaces’pubsub_bringup,service_bringupandaction_bringup - Module manifest:
zephyr/module.yml - Kconfig surface (canonical — every
CONFIG_NROS*symbol the doc cites lives here):zephyr/Kconfig - Patches applied by
nros setup zephyr(thewest patchflow is retired):zephyr/patches.yml
Next
- Pick a real board (Nordic, NXP, STM32, …): a board package naming its
west_board, then Flash a real board. - 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 in-tree examples (contributor).
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples as image rows of behaviour bringups.
A Zephyr App You Build with West
Support level:
native_sim. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
In this chapter you add a ROS 2 talker to a Zephyr application that you
already build with west build, run it on native_sim against a ROS 2
router, and then flash the same application to a real board. The application
keeps its own CMakeLists.txt and prj.conf; nano-ros joins it as one more
Zephyr module.
What this assumes: a Linux host with ROS 2 Humble and its Zenoh
middleware (ros-humble-rmw-zenoh-cpp), the install
done so nros is on your PATH, and some familiarity with west: a manifest,
west update, west build -b <board>.
What it leaves for later: a project with several nodes, several boards, or a launch file. That is a nano-ros workspace, which the Zephyr chapter builds.
When to choose this
There are two ways to put a ROS 2 node on Zephyr.
- A nano-ros workspace (
nros new my_ws --board zephyr). Your code is node packages; a bringup package says which board each image runs on.nros buildwrites the Zephyr application for you and runswest buildon it, andnros runstarts it. You write noCMakeLists.txtfor Zephyr and noprj.conf. Start there if you are starting fresh, or if the project will grow past one node. The Zephyr chapter covers it. - Your own west application (this chapter). The Zephyr application
already exists, owns its build, and has its own drivers, devicetree and
prj.conf. You want a ROS 2 node inside it, and you wantwest buildandwest flashto stay the commands you type.
The second road has no bringup and no system.toml. Everything nano-ros needs
to know about the image comes from prj.conf, the way any Zephyr module is
configured.
Set up the host and the west workspace
Zephyr’s host tools come first: west and the build tools
Zephyr’s Getting Started
lists. nros setup installs no system packages. On Debian or Ubuntu:
sudo apt-get install -y --no-install-recommends git cmake ninja-build gperf \
device-tree-compiler python3-pip python3-venv xz-utils file wget
pip3 install --user west
export PATH="$HOME/.local/bin:$PATH"
Provision the Zephyr SDK and the zenoh transport sources nano-ros compiles, then point Zephyr at that SDK:
nros setup zephyr --rmw zenoh
export ZEPHYR_SDK_INSTALL_DIR="$(nros locate tool zephyr-sdk)"
nros locate tool zephyr-sdk prints where nros setup installed the Zephyr
SDK version this release of nano-ros pins, so the line keeps working when that
version changes. If you already have a Zephyr SDK that your workspace builds
with, keep using it: set ZEPHYR_SDK_INSTALL_DIR to that one instead, or
register it with the SDK’s own setup.sh -c.
If you already have a west workspace, skip the next block. This one pins Zephyr 3.7 and fetches only the modules nano-ros’s boards use:
mkdir -p my_zephyr_ws/manifest && cd my_zephyr_ws
cat > manifest/west.yml <<'WEST_YML'
manifest:
remotes:
- name: zephyrproject-rtos
url-base: https://github.com/zephyrproject-rtos
projects:
- name: zephyr
remote: zephyrproject-rtos
revision: v3.7.0
import:
name-allowlist: [cmsis, picolibc, mbedtls, tinycrypt, mcuboot, net-tools]
- name: zephyr-lang-rust
remote: zephyrproject-rtos
revision: 404fcefdbab0f2711cd342a1fab9d2eda52a1ba9
path: modules/lang/rust
self:
path: manifest
WEST_YML
west init -l manifest
west update --narrow -o=--depth=1
pip3 install --user -r zephyr/scripts/requirements-base.txt
export ZEPHYR_BASE="$PWD/zephyr"
The last project, zephyr-lang-rust, is Zephyr’s Rust support, at the
revision nano-ros builds with on Zephyr 3.7. Only a
Rust application uses it; leave it out if you write
none.
nano-ros supports both the Zephyr 3.7 LTS line and the 4.x line; the version table lists what differs between them.
Add nano-ros to the build
nano-ros’s Zephyr module ships inside your nano-ros install. nros locate nano-ros prints the directory that holds it, and you hand that directory to
west as an extra module:
west build -b native_sim/native/64 apps/my_app -- \
-DZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)"
You do not add nano-ros to west.yml, and nothing is cloned. The command
needs no network, so it is also safe inside your own scripts and CI.
If you would rather not type the option on every build, ask for the path once
in the application’s CMakeLists.txt, before find_package(Zephyr):
execute_process(COMMAND nros locate nano-ros
OUTPUT_VARIABLE NROS_MODULE_DIR
OUTPUT_STRIP_TRAILING_WHITESPACE
COMMAND_ERROR_IS_FATAL ANY)
list(APPEND ZEPHYR_EXTRA_MODULES "${NROS_MODULE_DIR}")
With either form, a plain west build picks the module up.
Write the application
The application is a stock Zephyr application with four files:
my_zephyr_ws/
├── zephyr/
├── modules/
└── apps/
└── my_app/
├── CMakeLists.txt # find_package(Zephyr), then the node
├── package.xml # the message packages the node uses
├── prj.conf # Zephyr's settings and nano-ros's
└── src/
└── Talker.c # the node
The node, in C
The node is a component: a struct that holds its state and a configure
function that creates its publishers, subscriptions and timers. You do not
write main(). The module generates the application’s entry point, which
initialises the RMW session, creates the node and executor, calls your
configure, and then spins the executor.
mkdir -p apps/my_app/src
cat > apps/my_app/src/Talker.c <<'EOF'
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <rclc/rclc.h>
#include <std_msgs/std_msgs.h>
typedef struct {
rcl_publisher_t pub;
rcl_timer_t timer;
int32_t count;
} talker_t;
static void on_tick(rcl_timer_t* timer, int64_t last_call_time) {
talker_t* self = RCLC_CONTAINER_OF(timer, talker_t, timer);
(void)last_call_time;
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) {
setvbuf(stdout, NULL, _IONBF, 0);
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_default(&self->timer, NULL, 1000ULL * 1000000ULL, on_tick);
if (rc != RCL_RET_OK) {
return rc;
}
return rclc_executor_add_timer(executor, &self->timer);
}
RCLC_C_COMPONENT(talker_t, talker_configure)
EOF
It publishes a counter as std_msgs/Int32 on /chatter once a second.
RCLC_C_COMPONENT registers the struct and its configure function with the
generated entry point. The rest is the ROS 2 rclc API.
The build file
cat > apps/my_app/CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app)
find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)
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)
EOF
find_package(nano_ros) and find_package(std_msgs) are provided by the
module. nano_ros_auto_add_library compiles your node source.
nros_components_register_node registers the component, and DEPLOY zephyr
is what makes the module generate the entry point into Zephyr’s app target.
Your application’s other sources still go into app with target_sources,
as before; nothing else about the file changes.
The C++ form
The same node in C++ is a class with a configure member. In place of
Talker.c, write its header and its source:
mkdir -p apps/my_app/src
cat > apps/my_app/src/Talker.hpp <<'EOF'
#pragma once
#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"
namespace my_app {
class Talker {
::rclcpp::Publisher<std_msgs::msg::Int32> pub_;
::rclcpp::Timer timer_;
int count_ = 0;
void on_tick();
public:
::rclcpp::Result configure(::rclcpp::Node& node);
};
} // namespace my_app
EOF
cat > apps/my_app/src/Talker.cpp <<'EOF'
#include "Talker.hpp"
#include <cstdio>
namespace my_app {
void Talker::on_tick() {
std_msgs::msg::Int32 m;
m.data = count_;
if (pub_.publish(m).ok()) {
std::printf("Published: %d\n", count_);
}
++count_;
}
::rclcpp::Result Talker::configure(::rclcpp::Node& node) {
::setvbuf(stdout, nullptr, _IONBF, 0);
::rclcpp::Result r = node.create_publisher(pub_, "/chatter");
if (!r.ok()) return r;
return node.create_wall_timer<Talker, &Talker::on_tick>(timer_, 1000, this);
}
} // namespace my_app
EOF
::setvbuf turns off stdout’s buffering, so each line shows as soon as it is
printed. It is the global one because Zephyr’s C++ library declares it only
there.
The build file differs from the C one in two places: the project declares C++, and the registration names the class’s header.
cat > apps/my_app/CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app LANGUAGES C CXX)
find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)
nano_ros_auto_add_library(talker_lib STATIC src/Talker.cpp)
nros_components_register_node(talker_lib
HEADER Talker.hpp
PLUGIN my_app::Talker
EXECUTABLE talker
SHAPE configure
TYPED
DEPLOY zephyr)
EOF
Everything after this point is the same for the C and the C++ node.
Configure
nano-ros reads its settings from Kconfig, so they go in prj.conf beside
Zephyr’s own, and west build -t menuconfig shows them under
nros ROS 2 client library:
cat > apps/my_app/prj.conf <<'EOF'
# Zephyr: console and networking
CONFIG_LOG=y
CONFIG_PRINTK=y
CONFIG_NETWORKING=y
CONFIG_NET_IPV4=y
CONFIG_NET_UDP=y
CONFIG_NET_TCP=y
CONFIG_NET_SOCKETS=y
CONFIG_NET_L2_ETHERNET=y
CONFIG_NET_MAX_CONTEXTS=16
CONFIG_DNS_RESOLVER=n
CONFIG_SYS_CLOCK_TICKS_PER_SEC=1000
# zenoh-pico runs on POSIX threads and wants room
CONFIG_POSIX_API=y
CONFIG_MAX_PTHREAD_MUTEX_COUNT=64
CONFIG_MAX_PTHREAD_COND_COUNT=16
CONFIG_POSIX_THREAD_THREADS_MAX=16
CONFIG_MAIN_STACK_SIZE=16384
CONFIG_HEAP_MEM_POOL_SIZE=65536
CONFIG_SYSTEM_WORKQUEUE_STACK_SIZE=4096
CONFIG_NET_PKT_RX_COUNT=32
CONFIG_NET_PKT_TX_COUNT=32
CONFIG_NET_BUF_RX_COUNT=64
CONFIG_NET_BUF_TX_COUNT=64
# nano-ros
CONFIG_NROS=y
CONFIG_NROS_CPP_API=y
CONFIG_STD_CPP17=y
CONFIG_NROS_RMW_ZENOH=y
CONFIG_NROS_ZENOH_LOCATOR="tcp/127.0.0.1:7447"
CONFIG_NROS_DOMAIN_ID=0
EOF
The nano-ros lines, one by one:
| Setting | What it chooses |
|---|---|
CONFIG_NROS=y | Turns the module on. Without it nano-ros adds nothing to the build. |
CONFIG_NROS_CPP_API=y | The runtime library the generated entry point links. The entry point is C++ even when your node is C, so a C or C++ node both set this. CONFIG_NROS_RUST_API is for a Rust application (below). |
CONFIG_STD_CPP17=y | The C++ standard the entry point compiles with. |
CONFIG_NROS_RMW_ZENOH=y | The middleware: CONFIG_NROS_RMW_ZENOH, CONFIG_NROS_RMW_XRCE or CONFIG_NROS_RMW_CYCLONEDDS. |
CONFIG_NROS_ZENOH_LOCATOR | Where the zenoh router is. tcp/127.0.0.1:7447 is the default and suits native_sim, which uses the host’s sockets. |
CONFIG_NROS_DOMAIN_ID | The ROS 2 domain. It must match the ROS_DOMAIN_ID of the nodes you talk to. |
CONFIG_NROS_C_API is for an application that calls the C API
(rclc/*.h) from its own main and registers no component. An application
that registers its node with nros_components_register_node(... DEPLOY zephyr)
is driven by the generated entry point, which is C++, so it sets
CONFIG_NROS_CPP_API=y even when the node is C.
For XRCE-DDS, set CONFIG_NROS_RMW_XRCE=y and name the agent with
CONFIG_NROS_XRCE_AGENT_ADDR and CONFIG_NROS_XRCE_AGENT_PORT instead of a
locator. Each middleware also needs some Zephyr settings of its own;
What each RMW’s Zephyr-native fragment needs
lists them.
Every other nano-ros setting (executor slots, buffer sizes, the zenoh
session’s limits) is a Kconfig symbol too, set the same way. The
Kconfig reference
lists them with their defaults. Do not set CONFIG_NROS_ROS_EDITION: there
is no such symbol, and Zephyr stops the build on a symbol it does not know.
On Zephyr 4.x you can also select the middleware on the build line with one of
the module’s snippets, -S nros-zenoh, -S nros-xrce or -S nros-cyclonedds,
instead of the CONFIG_NROS_RMW_* line
(Zephyr 4.x: select the RMW with a snippet).
Messages
You do not run a generator. The build generates the C or C++ types for every
message package the application depends on, and package.xml is where you
list those packages, the same file a ROS 2 package uses:
cat > apps/my_app/package.xml <<'EOF'
<?xml version="1.0"?>
<package format="3">
<name>my_app</name>
<version>0.1.0</version>
<description>A Zephyr application with a ROS 2 talker</description>
<maintainer email="you@example.com">You</maintainer>
<license>Apache-2.0</license>
<depend>std_msgs</depend>
<export>
<build_type>nros_cmake</build_type>
</export>
</package>
EOF
Each <depend> also gets a find_package(<pkg> REQUIRED) line in
CMakeLists.txt. The C node then includes <std_msgs/std_msgs.h> and the C++
node "std_msgs.hpp".
The message definitions come from the first of these that has the package:
the directories in NROS_INTERFACE_SEARCH_PATH, then a sourced ROS 2
install (AMENT_PREFIX_PATH), then copies nano-ros carries of a few
standard packages. Those copies are one ROS 2 distro’s, so build from a shell
that has sourced your ROS 2 install (source /opt/ros/humble/setup.bash):
the types then match your ROS 2 peers. A Rust application’s nros configure
(below) does not fall back to the copies at all. For
your own messages, keep them in a ROS 2 interface
package (its package.xml and msg/ directory) anywhere on disk, add the
directory that holds it to NROS_INTERFACE_SEARCH_PATH, and depend on it like
any other.
Build
From the top of the west workspace, build for native_sim:
west build -b native_sim/native/64 -S nros-nsos apps/my_app -- \
-DZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)"
native_sim builds the application as a program for your machine, so there
is no emulator. The nros-nsos snippet routes its sockets to the host’s
(Zephyr’s NSOS). Without it, native_sim tries to create a TAP interface
(eth_posix: Cannot create zeth), which needs privileges an ordinary user or
a container does not have, and the talker never reaches the router.
Run on native_sim
ROS 2 provides the router. Start it on the port the talker dials:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
With the router running, in another terminal, start the talker:
./build/zephyr/zephyr.exe
Published: 1
Published: 2
west build -t run does the same. To see the messages from ROS 2, in a third
terminal:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/Int32 --qos-reliability best_effort
The talker publishes best-effort. ros2 topic echo asks for reliable delivery
unless told otherwise, and then receives nothing.
Flash a real board
The board here is ST’s Nucleo-F767ZI (Zephyr’s nucleo_f767zi). Its
ST-LINK debugger is also a USB serial port, so the talker can reach the router
over that cable, with no network on the board at all. Zenoh calls this a
serial link.
Provision the board, and add its HAL, hal_stm32, to the west workspace.
nros setup board applies nano-ros’s changes for the board to the workspace:
nros setup nucleo-f767zi-zephyr --rmw zenoh
sed -i 's/net-tools]/net-tools, hal_stm32]/' manifest/west.yml
west update --narrow -o=--depth=1
nros setup board nucleo-f767zi-zephyr --zephyr-workspace "$PWD"
Zephyr reads boards/<board>.conf and boards/<board>.overlay from the
application directory after prj.conf, for that board only, so the native_sim
build stays as it is. The settings turn the IP stack off, carry zenoh over
USART3 (the ST-LINK port), move the console to USART6 (pins D0 and D1, for a
USB-to-UART adapter), and size the image for the board’s 384 KiB of RAM:
mkdir -p apps/my_app/boards
cat > apps/my_app/boards/nucleo_f767zi.conf <<'EOF'
# zenoh over USART3, the ST-LINK USB serial port; no IP stack
CONFIG_NETWORKING=n
CONFIG_NROS_TRANSPORT_SERIAL=y
CONFIG_NROS_ZENOH_LOCATOR="serial/serial@40004800#baudrate=115200"
CONFIG_SERIAL=y
CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_RING_BUFFER=y
CONFIG_MAIN_THREAD_PRIORITY=5
# no network driver brings up the RNG, so zenoh's session id uses this one
CONFIG_TEST_RANDOM_GENERATOR=y
# 384 KiB of RAM
CONFIG_HEAP_MEM_POOL_SIZE=16384
CONFIG_NROS_MAX_LARGE_SUBSCRIBERS=0
EOF
cat > apps/my_app/boards/nucleo_f767zi.overlay <<'EOF'
/ {
chosen {
zephyr,console = &usart6;
zephyr,shell-uart = &usart6;
};
};
EOF
Build for the board, into its own build directory:
west build -p always -b nucleo_f767zi -d build-board apps/my_app -- \
-DZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)"
Zephyr warns that prj.conf’s networking settings (NET_IPV4, NET_TCP
and the rest) were assigned y but got n. That is expected: the board file
turned networking off, and the warnings do not stop the build.
Plug the board in, start the router on its serial port (the ST-LINK port is
usually /dev/ttyACM0), and flash it with Zephyr’s own runner:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["serial//dev/ttyACM0#baudrate=115200"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
west flash -d build-board
ros2 topic echo, as on native_sim, then shows the counter:
data: 1
---
data: 2
---
Without the board, the same image runs in the board’s emulator: the probe
for this chapter boots build-board/zephyr/zephyr.elf in Renode’s
nucleo_f767zi machine, with USART3 on a host serial port, instead of
flashing it, and checks that ros2 topic echo hears it. The flash runner
(OpenOCD, pyOCD, J-Link and so on) and the probe are Zephyr’s and your
bench’s; nano-ros adds nothing to them.
To use the board’s Ethernet instead, leave out the serial lines: keep
CONFIG_NETWORKING=y with the board’s Ethernet driver and an address, and set
CONFIG_NROS_ZENOH_LOCATOR to the router’s address on that network, for
example "tcp/192.0.2.10:7447", with the router listening on
tcp/0.0.0.0:7447.
A Rust application
A Rust application is built by Zephyr’s Rust support (zephyr-lang-rust,
the last project in the manifest above):
its CMakeLists.txt calls rust_cargo_application(), which builds the
application’s crate with cargo and links it into the image. The node is the
same talker, written as a nano-ros Rust component.
zephyr-lang-rust needs three things a C application does not: the Rust target
the Zephyr board implies (x86_64-unknown-none for native_sim/native/64 on
an x86_64 host), rustup’s clippy (it lints every Rust application as part of
the build), and clang with libclang, for the bindings it generates from
Zephyr’s headers. nros setup board adds the target and applies nano-ros’s
changes to the west workspace, without which zephyr-lang-rust’s cargo command
carries none of nano-ros’s settings for the board. From the top of the west
workspace:
. "$HOME/.cargo/env" # the cargo the install set up
sudo apt-get install -y --no-install-recommends clang libclang-dev
rustup component add clippy
nros setup board zephyr --zephyr-workspace "$PWD"
The application is a crate whose library is named rustapp and built as a
static library: rust_cargo_application() links librustapp.a, whatever the
package is called. In place of the C application’s source and build file,
write the crate, its build script and its node:
mkdir -p apps/my_app/src
cat > apps/my_app/CMakeLists.txt <<'EOF'
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app)
rust_cargo_application()
EOF
cat > apps/my_app/Cargo.toml <<'EOF'
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[lib]
name = "rustapp"
crate-type = ["staticlib"]
[dependencies]
zephyr = "0.1.0"
log = "0.4"
nros = { version = "*", default-features = false, features = ["alloc", "rmw-cffi", "macros"] }
nros-platform = { version = "*", default-features = false, features = ["platform-zephyr"] }
nros-rmw-zenoh = { version = "*", default-features = false, features = ["platform-zephyr"] }
std_msgs = { version = "*", default-features = false }
[build-dependencies]
zephyr-build = "0.1.0"
nros-zephyr-build = "*"
[profile.release]
opt-level = "s"
lto = true
debug = false
EOF
cat > apps/my_app/build.rs <<'EOF'
fn main() {
zephyr_build::export_kconfig_bool_options();
nros_zephyr_build::bake_nros_config();
}
EOF
cat > apps/my_app/src/lib.rs <<'EOF'
#![no_std]
extern crate zephyr;
use nros::{
Callback, CallbackCtx, Component, DeclarativeNode, ExecutableNode, NodeContext, NodeOptions,
NodeResult, TimerDuration,
};
use std_msgs::msg::Int32;
pub struct Talker;
impl Component for Talker {
const NAME: &'static str = "talker";
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::<Int32>("/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;
const HAS_TICK: bool = false;
fn init() -> Self::State {
0
}
fn on_callback(count: &mut Self::State, callback: Callback<'_>, ctx: &mut CallbackCtx<'_>) {
if callback.as_str() == "on_tick" {
let msg = Int32 { data: *count };
if ctx.publish_to_topic::<Int32, 16>("/chatter", &msg).is_ok() {
log::info!("Published: {}", *count);
}
*count += 1;
}
}
}
nros::node!(Talker);
// Keeps the zenoh backend in the static library, then generates the
// application's entry point around the component.
nros::force_link_backend!(nros_rmw_zenoh);
nros::zephyr_component_main!(crate::Talker);
EOF
Write prj.conf and package.xml as for the C application
(Configure, Messages), then change two lines of
prj.conf: a Rust application selects CONFIG_NROS_RUST_API (which needs
CONFIG_RUST, Zephyr’s switch for zephyr-lang-rust) instead of
CONFIG_NROS_CPP_API, and needs no C++ standard:
sed -i -e '/^CONFIG_NROS_CPP_API=y$/d' -e '/^CONFIG_STD_CPP17=y$/d' apps/my_app/prj.conf
cat >> apps/my_app/prj.conf <<'EOF'
CONFIG_RUST=y
CONFIG_NROS_RUST_API=y
EOF
``` The other settings stay as they are. `build.rs` hands the Kconfig
values to the crate, among them the locator and the domain, so they are still
set in `prj.conf` alone.
The crate names the nano-ros crates and `std_msgs` but does not say where they
come from. `nros configure`, run in the application directory, generates the
Rust types for the message packages `package.xml` lists, under `generated/`,
and writes `.cargo/config.toml`, which points the nano-ros crates at your
nano-ros install and the message crates at `generated/`. Run it before the
first `west build`, and again when `package.xml` changes. It generates the
types from your ROS 2 install, so source it first; the configure records the
prefix, and a later `nros configure` of the application needs no sourced
shell:
```bash probe=49 track=installed variant=west-app-rust
source /opt/ros/humble/setup.bash
cd apps/my_app && nros configure && cd ../..
From there the application builds and runs as the C one does: the same
west build line, and the same
run on native_sim. The log line reads
<inf> nros: rustapp: Published: 1, from Zephyr’s logger.
If it doesn’t work
Could not find a package configuration file provided by "nano_ros".
The module is not in the build. Check that nros locate nano-ros prints a
directory, and that the build line passes it as -DZEPHYR_EXTRA_MODULES
after the --.
attempt to assign the value ... to the undefined symbol NROS_.... The
symbol is misspelled or does not exist. Search for it in
west build -t menuconfig (/ searches). CONFIG_NROS_ROS_EDITION is a
common one: it does not exist.
No Published: line within 30 seconds.
- Check that the router is running and listening on the port in
CONFIG_NROS_ZENOH_LOCATOR. - On
native_sim, check that the build line has-S nros-nsos. A lineeth_posix: Cannot create zethin the output means it did not. - Check
CONFIG_NETWORKING=y,CONFIG_NET_IPV4=yandCONFIG_NET_TCP=yinprj.conf. Zephyr’s networking is opt-in.
A message with an unbounded string or sequence field fails to generate,
asking for a bound. nano-ros sizes its buffers at build time, so a field
with no stated maximum needs one. Put it in an nros-codegen.toml beside
package.xml:
[fields]
"std_msgs/String.data" = 256
The Zephyr 4.x line says Could NOT find Python3 ... required is at least "3.12". Zephyr 4.x needs Python 3.12 or newer for west and its build
scripts.
For anything else, Troubleshooting: First 10 Minutes walks through the common failures.
Design background
A nano-ros project is normally a workspace, and its bringup’s system.toml
says what runs where. An application that owns its own build stays outside
that model on purpose (RFC-0121 D6): nano-ros joins the build the way any
library would, and asks the build for nothing back. For Zephyr, that
application is a guest (RFC-0085 D14): it loads nano-ros as a module,
states the middleware, locator and domain in Kconfig, and builds with west
alone. With no system.toml beside it, the module gives every nano-ros
setting its Kconfig prompt, so prj.conf, menuconfig and -DCONFIG_* are
the one source of its configuration.
Nucleo-F767ZI (Zephyr, Renode twin)
Support level:
nucleo-f767zi-zephyr. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
One talker on an ST Nucleo-F767ZI (STM32F767ZI, Cortex-M7) running Zephyr 3.7, talking to ROS 2 over the board’s USB serial port. You do not need the board: Renode emulates it on your machine, and its USART3, the UART the Nucleo’s ST-LINK carries to your USB port, is wired to your host the way the cable would be. No network is configured anywhere in this chapter; ethernet comes after it.
This chapter assumes a Linux x86_64 host (or the
container the install page
describes) with a C/C++ compiler, ROS 2 Humble with
ros-humble-rmw-zenoh-cpp, and the install done, so
nros is on your PATH. It uses Zephyr’s own tools the way
the Zephyr chapter does; read that one for what each
piece is.
WiFi is deferred to the Zephyr 4.4 line, where the Pico W’s WiFi driver
exists, so no chapter here opens over WiFi yet.
Provision the board
Zephyr’s host tools first: west and the build tools
Zephyr’s Getting Started
lists (nros setup installs no system packages). On Debian/Ubuntu:
sudo apt-get install -y --no-install-recommends git cmake ninja-build gperf \
device-tree-compiler python3-pip python3-venv xz-utils file wget
pip3 install --user west
export PATH="$HOME/.local/bin:$PATH"
Then the board:
nros setup nucleo-f767zi-zephyr --rmw zenoh
export ZEPHYR_SDK_INSTALL_DIR="$(nros locate tool zephyr-sdk)"
That fetches what the board needs into a shared store under ~/.nros: the
Zephyr SDK, the zenoh-pico sources, the Rust target, and Renode, the
emulator the board runs in here. It installs no router: the router is ROS’s
rmw_zenohd, from the ROS 2 install above.
A west workspace that pins Zephyr 3.7 and fetches only the modules this
board builds with, hal_stm32 among them (import: true would fetch every
vendor HAL):
mkdir -p nucleo_ws/manifest && cd nucleo_ws
cat > manifest/west.yml <<'WEST_YML'
manifest:
remotes:
- name: zephyrproject-rtos
url-base: https://github.com/zephyrproject-rtos
projects:
- name: zephyr
remote: zephyrproject-rtos
revision: v3.7.0
import:
name-allowlist: [cmsis, picolibc, mbedtls, tinycrypt, mcuboot, net-tools, hal_stm32]
self:
path: manifest
WEST_YML
west init -l manifest
west update --narrow -o=--depth=1
pip3 install --user -r zephyr/scripts/requirements-base.txt
export ZEPHYR_BASE="$PWD/zephyr"
Last, give the workspace the patches nano-ros carries for Zephyr 3.7:
nros setup board nucleo-f767zi-zephyr --zephyr-workspace .
Scaffold the talker
nros new nucleo_ws --board nucleo-f767zi-zephyr --template talker --lang c
cd nucleo_ws
That wrote a workspace with two packages: src/nucleo_node/, the C talker,
and src/nucleo_node_bringup/, whose system.toml ends in the
[image.zephyr] block naming the board. The Zephyr application itself is
generated under build/ when you build. The board reaches the zenoh router
on your host through USART3, which Zephyr calls serial@40004800, at 115200
baud. Say so with one line at the end of the bringup’s system.toml:
echo 'locator = "serial/serial@40004800#baudrate=115200"' >> src/nucleo_node_bringup/system.toml
The line lands in the [image.zephyr] block, under the
board = "nucleo-f767zi-zephyr" line the scaffold wrote. The board’s own configuration already turns Zephyr’s IP stack off and
gives USART3 to zenoh; its console is USART6.
Run in the twin
Source ROS 2, then build and run. ROS 2 provides the router, and its message
definitions are what the build generates the talker’s std_msgs code from.
This first run records the ROS 2 prefix in the workspace, so later runs of it
need no sourced shell:
source /opt/ros/humble/setup.bash
nros run nucleo_node --expect 'Publishing:'
Publishing: 'Hello World: 1'
nros run: matched after
nros run generates what the build reads (the message code and the board’s
build settings, under build/), builds the firmware through west (the first
build takes a few minutes; later ones take seconds), boots it in Renode’s nucleo_f767zi
machine with USART3 on a terminal device of your host, starts rmw_zenohd
on that device, and then lets the board run. What it prints is the board’s
console, USART6: *** Booting Zephyr OS build v3.7.0 ***, then the talker’s
Publishing: 'Hello World: 1' and on. With --expect it stops everything
and exits 0 at the first console line containing that text, and exits 1 if
the board stops first.
Without --expect it keeps running until you press Ctrl-C. Start it that
way in one terminal:
nros run nucleo_node
Publishing: 'Hello World: 2'
See it from ROS 2
In a second terminal, ask ROS 2 for the topic. The router nros run started
also listens where a ROS 2 node on this machine looks for one, so nothing
needs to be configured:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
data: 'Hello World:
The talker publishes best-effort, so the echo asks for best-effort too.
Ctrl-C in the first terminal stops Renode and the router together.
Ethernet
The Nucleo’s on-chip ethernet MAC works in Renode: the talker reached a
router over tcp/ with Zephyr’s eth_stm32_hal driver in an early
spike. That run needed a host tap device bridged to Renode’s virtual switch,
which takes CAP_NET_ADMIN (the spike made one inside a rootless network
namespace), and nros run does not set one up yet. So this chapter stops at
serial; the ethernet chapter comes with that support.
Flash a real board
On the bench, nros flash programs the same image through Zephyr’s own
runner for the board, OpenOCD over the Nucleo’s ST-LINK:
nros flash
Not replayed. No probe runs this flash line (a CI runner has no board), so it is written from the board’s runner, not measured (tracking issue).
On the real board USART3 is the ST-LINK’s virtual COM port, so the zenoh
link arrives on your host as a USB serial device (/dev/ttyACM0, say), and
the router listens there instead of on Renode’s terminal device. The
console, USART6, is on the board’s pins rather than on the USB cable.
If it didn’t work
nros runnames what failed and the log it wrote.no runner package drivesorthe plan's tools name no renode: run thenros setup nucleo-f767zi-zephyrstep again, which installs Renode.- A router that would not start usually means
rmw_zenohdwas not found (source your ROS 2 setup in that terminal) or another router already holds port 7447 on this machine. - A build that cannot find
hal_stm32: the west workspace predates thehal_stm32line in its allowlist; add it and runwest updateagain. - Everything else: Troubleshooting - First 10 Minutes.
Where to go next
- What each Zephyr piece is - Zephyr (west module).
- The same talker on QEMU, with no Zephyr - First Project.
- Talk to a real ROS 2 system - ROS 2 Interoperability.
NuttX (apps/external)
Support level:
qemu-armv7a-nuttx,rv-virt-nuttx. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
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.
Changing this in the tree: see Internals → in-tree examples.
Prereqs. Install the
nrosCLI once per machine, then runnros setup qemu-armv7a-nuttx --rmw <zenoh|xrce|cyclonedds>(--rmwdefaults tozenoh). This fetches a mostly-prebuilt toolchain set into${NROS_HOME:-~/.nros}/sdk— the NuttX cross-compiler, the emulator and the NuttX sources — so you do not hand-install a cross-toolchain. The zenoh router is ROS’srmw_zenohdand comes from a ROS 2 install; the xrce path needs only the Micro-XRCE-DDS agent, whichnros setup … --rmw xrceinstalls:nros setup qemu-armv7a-nuttx --rmw zenoh(
nrosis the installed CLI — Installation.)The NuttX-managed road below (
apps/external, NuttX’s ownmake) also needs a NuttX ≥ nuttx-12 checkout of your own with anapps/sibling; thenros buildroad does not.
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 to the SDK root's
├── Make.defs # integrations/nuttx/
├── Makefile
├── CMakeLists.txt # (cmake-driven NuttX builds)
└── Kconfig
nx_ws/ # your application: a workspace
└── src/
├── nx_node/ # node package: package.xml, Cargo.toml | CMakeLists.txt, the node
└── nx_node_bringup/ # package.xml and system.toml: the board, the RMW, the node
Wire the shell into your NuttX apps tree. Two commands, both run once:
nros setup qemu-armv7a-nuttx --rmw zenoh # toolchain + NuttX sources + SDK
sdk="$(nros locate nano-ros)" # this project's nano-ros SDK root
"$sdk/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. (The SDK
root carries integrations/ in releases cut after 0.6.0-nros4.) Menuconfig
surfaces it under Application Configuration → External Modules.
The script stages the integration shell only. A bringup with several
components is not built through the apps tree: give it an [image.<id>] with
board = "qemu-armv7a-nuttx" and run nros build — see [image.nuttx] in
examples/workspaces/rust. The script’s old --bringup <dir> / --image <id> flags
staged a per-bringup app that could not build an image, and now refuse.
If you’d rather wire it yourself (e.g. into a vendored apps tree):
ln -s "$sdk/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 the bringup’s system.toml and baked at compile
time. With a locator added, src/nx_node_bringup/system.toml reads:
[system]
name = "nx_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "nx_node"
class = "nx_node::Talker"
name = "nx_node"
[image.nuttx]
board = "qemu-armv7a-nuttx"
locator = "tcp/10.0.2.2:8200"
The file is the same whatever language the node is written in — a C or C++
node’s package.xml has no <nano_ros …/> tuple and its 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. The next build picks an edit to this file up, regenerating the
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
Two roads, and they differ in who owns the NuttX kernel build.
nros build — the path for a nano-ros workspace. The board in the
bringup (qemu-armv7a-nuttx for Arm virt, rv-virt-nuttx for RISC-V
rv-virt) picks the cross toolchain and the NuttX board config; nros build
links your node into a bootable NuttX kernel ELF. Provision the board (no NuttX
checkout of your own is needed on this road — nros setup fetches the kernel
and apps sources), then scaffold a workspace for it and build:
nros setup qemu-armv7a-nuttx --rmw zenoh --sudo
The NuttX kernel build needs two host packages a fresh Debian/Ubuntu host may
lack — the Python venv module and unzip. nros setup names any that are
missing before fetching anything; --sudo lets it run the one install command
it composed for your package manager (sudo apt-get install -y python3-venv unzip here) before the download starts. Leave --sudo off and it only prints
that command, and never runs sudo for you.
The build generates the message bindings from your ROS 2 install, so source it in this shell first. This first build records the ROS 2 prefix in the workspace, so later builds and runs of it need no sourced shell.
source /opt/ros/humble/setup.bash
nros new nx_ws --board qemu-armv7a-nuttx --template talker --lang c # or --lang cpp
cd nx_ws
nros build
A Rust node is the same scaffold with --lang rust. For it, nros build
configures and exports the NuttX kernel from the board’s defconfig first (the
same step the C/C++ road runs inside its configure; a current export is a
no-op), then builds the entry with the board’s pinned nightly:
source /opt/ros/humble/setup.bash
nros new nx_ws --board qemu-armv7a-nuttx --template talker --lang rust
cd nx_ws
nros build
Either way nros build prepares the build itself (message bindings, the
generated entry), and the kernel ELF lands under build/; nros build prints
its path with the QEMU line that boots it. nros build --dry-run shows the
native commands.
NuttX’s own build — the integration shell in a NuttX-managed tree. NuttX
owns the build verb there, make from the configured kernel, the way Zephyr
owns west build. NuttX’s make does not prepare a nano-ros workspace, so
prepare it first, in the workspace, because the generated message bindings must
exist before anything compiles against them:
cd nx_ws
nros configure # bindings + settings from the bringup
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. The kernel ELF is $NUTTX_DIR/nuttx.
For a CMake-driven NuttX build, NuttX’s own CMake takes one
BOARD_CONFIG=<board>:<config> and finds apps/ beside the kernel tree
(-DNUTTX_APPS_DIR=<dir> names another):
cmake -S "$NUTTX_DIR" -B build -DBOARD_CONFIG=qemu-armv7a:nsh -GNinja
cmake --build build # the ELF is build/nuttx
Stock qemu-armv7a:nsh has no networking; merge the nano-ros requirements
into it as described under
Auto-configure glue below.
Run on QEMU (Arm virt)
QEMU is the store’s build (nros setup qemu-armv7a-nuttx installed it), on
QEMU’s virt machine with virtio-net on Slirp — the line nros build
prints. Two terminals: the router, then the board.
1. 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; match whatever locator your
app bakes — 7447 when it states none, as a scaffolded image; 8200 in the
example above:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
2. The board — from the workspace root:
nros run nx_node
nros run boots the kernel ELF with the board’s runner: the store’s
qemu-system-arm on the virt machine, virtio-net on Slirp, and -icount shift=auto. (A make-built kernel is $NUTTX_DIR/nuttx; boot it with the same
QEMU line, which nros build --dry-run prints.)
An image nros build produced starts its application itself. The scaffolded
talker publishes Hello World: N once a second, in every language:
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
A stock ROS 2 peer can echo it:
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
A make-built kernel boots to the NSH prompt instead; run your app’s PROGNAME
there — the make-driven build registers every nano-ros example as a built-in
command via Application.mk’s -Dmain=<PROGNAME>_main rename, e.g.
nsh> nuttx_c_talker (C) or nsh> nuttx_cpp_talker (C++).
Ctrl-C stops nros run and QEMU with it (Ctrl-A x for a QEMU you started yourself).
Readiness signal. Once the image boots (a make-built kernel: once
you type the app’s NSH command, e.g. nuttx_c_talker), expect its first
line on the NSH console within ~6 seconds — Publishing: 'Hello World: 1'
from the scaffolded and in-tree Rust, C and C++ talkers, which start the
count at 1 like the official ROS 2 demo talker. If no
Publishing: line:
- Confirm the app actually ran —
psshould show your task. - Confirm networking —
ifconfigshows a configured interface. With the virtio-net + Slirp wiring above,eth0comes up at10.0.2.15(the board’s default on QEMU’s network; an image’snetaddress overrides it). - Confirm
zenohdreachable; the image’slocatormust match the router’s listen port. - See Troubleshooting — First 10 Minutes.
Run on QEMU (RISC-V rv-virt)
The rv-virt-nuttx board (rv32imac) boots on QEMU’s RISC-V virt machine.
The store’s QEMU is built for Arm and RISC-V 64 only, so this board needs
a qemu-system-riscv32 from your distribution (Debian/Ubuntu:
qemu-system-misc):
nros setup rv-virt-nuttx --rmw zenoh --sudo
sudo apt-get install -y qemu-system-misc
Name it with QEMU_SYSTEM_RISCV32 and nros build prints a line that uses
it; without it the printed line asks nros locate tool qemu-system-riscv32,
which then has nothing to answer. Scaffold for this board and build:
source /opt/ros/humble/setup.bash
nros new nx_ws --board rv-virt-nuttx --template talker --lang c
cd nx_ws
export QEMU_SYSTEM_RISCV32="$(command -v qemu-system-riscv32)"
nros build
A Rust node builds for this board the same way as for Arm. The kernel step
uses rv-virt’s own defconfig, and the target is the board’s
riscv32imac-unknown-nuttx-elf spec, built with build-std:
source /opt/ros/humble/setup.bash
export QEMU_SYSTEM_RISCV32="$(command -v qemu-system-riscv32)"
nros new nx_ws --board rv-virt-nuttx --template talker --lang rust
cd nx_ws
nros build
Start the router as above (on the port your image’s locator names), then
boot it; nros run uses the qemu-system-riscv32 that QEMU_SYSTEM_RISCV32
names:
nros run nx_node
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
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:
- Bring the required symbols into your config. Stock NuttX
qemu-armv7a/nshships withoutCONFIG_NET=y, virtio-net, orTLS_NELEM. If you have no config of your own, copy the board defconfigpackages/boards/nros-board-nuttx-qemu/nuttx-config/arm/defconfigto$NUTTX_DIR/.configand runmake 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 againstnshfor the authoritative list) and re-runmake olddefconfig. - Stage the integration shell. Run
scripts/nuttx/stage-external-apps.sh "$NUTTX_APPS_DIR"to symlinkintegrations/nuttx/into$NUTTX_APPS_DIR/external/. Remove$NUTTX_APPS_DIR/Kconfigso NuttX’smkkconfig.shrediscovers the newapps/external/Kconfig. - Flip the nano-ros Kconfig knobs via
kconfig-tweak. The recipe enablesNROS,NROS_C_API,NROS_CPP_API, everyNROS_EXAMPLE_<EX>_<LANG>, setsTLS_NELEM=8, disablesLIBCXXNONE+ enablesLIBCXXTOOLCHAIN, and disablesALLSYMSfor the bootstrap link. Re-runmake olddefconfigso the newly-visible dependencies settle, thenmake.
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.
Flash a real board
nano-ros has no NuttX hardware board of its own: both boards above are QEMU
machines. On real hardware the image is an ordinary NuttX application, so it
goes to the board the way NuttX’s documentation for that board says — build
the board’s own BOARD_CONFIG with the integration shell staged as above,
then flash nuttx (or nuttx.bin / nuttx.hex) with the board’s tool:
cmake -S "$NUTTX_DIR" -B build -DBOARD_CONFIG=<board>:<config> -GNinja
cmake --build build
openocd -f board/<your-board>.cfg -c "program build/nuttx verify reset exit"
Not replayed. No probe runs this flash line (a CI runner has no board), so it is written from the board’s runner, not measured (tracking issue).
(OpenOCD is an example; NuttX boards also flash with J-Link, dfu-util,
esptool or a vendor tool, as the board’s page says.)
GitHub source
- NuttX integration shell:
integrations/nuttx/ - Copy-out NuttX QEMU talkers, one per language, each the same project as
the one this page scaffolds:
examples/qemu-armv7a-nuttx/rust/talker/,examples/qemu-armv7a-nuttx/c/talker/,examples/qemu-armv7a-nuttx/cpp/talker/ - The listener, service and action examples: the
nuttx_*andriscv_nuttx_*image rows of the language workspaces,examples/workspaces/{rust,c,cpp}, underpubsub_bringup,service_bringupandaction_bringup(nros run pubsub_bringup:nuttx_listener) - Kconfig schema:
integrations/nuttx/Kconfig
Next
- Multiple apps: each app declares its own
prognameinApplication Configuration → External Modules; they share the onelibnros_c.abuild 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 in-tree examples (contributor).
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples as image rows of behaviour bringups.
ThreadX (Linux sim / RISC-V64 QEMU)
Support level:
threadx-linux,rv-virt-threadx. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
Single-node starter on Microsoft Azure RTOS ThreadX + NetX Duo (BSD socket layer). Two flavours:
- threadx-linux — ThreadX user-space simulator on Linux. Fast build, host network stack, ideal for development.
- threadx-riscv64 — QEMU
virtmachine with the RISC-V64 GCC toolchain. Full kernel + NetX Duo TCP/IP stack.
Rust, C, and C++ are supported on both flavours.
Changing this in the tree: see Internals → per-platform lanes.
See the coverage matrix for the per-RMW cell status.
Prereqs. Install the
nrosCLI once, then runnros setup <board> --rmw <rmw>for the flavour you need (see Setup). It provisions the cross-compiler, emulator, and ThreadX/NetX sources — no hand-installedriscv64cross toolchain orqemu-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, whichnros setupinstalls.
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; 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 Details for the full explanation.
With the nros CLI on PATH (Installation —
install.sh puts it in ~/.nros/bin), provision the ThreadX flavour you
need (+ the RMW; --rmw defaults to zenoh). The POSIX-sim flavour:
nros setup threadx-linux --rmw zenoh
and, if you need the RISC-V64 QEMU flow:
nros setup rv-virt-threadx --rmw zenoh
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
nros new scaffolds a workspace for one board, in any of the three languages.
threadx names two boards, so name the one you want with --board:
nros new tx_ws --board threadx-linux --template talker --lang c # or rv-virt-threadx; --lang cpp
cd tx_ws
nros new tx_ws --board threadx-linux --template talker --lang rust
cd tx_ws
nros new tx_ws --board rv-virt-threadx --template talker --lang rust
cd tx_ws
tx_ws/
└── src/
├── tx_node/ # node package: package.xml + CMakeLists.txt (or Cargo.toml), the talker
└── tx_node_bringup/ # package.xml and system.toml: the board, the RMW, the node
tx_node is a library with no main() that names no board; the ThreadX
startup that runs it is generated from the bringup’s image at build time.
The two flavours differ in the board = line of their image — nothing
else. Everything else follows the board. threadx-linux builds for the
host triple (its Rust entry is still #![no_std]), 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 your package. nros build resolves the choice
into the generated entry and its settings under build/, and the entry is
what depends on the board crate, so nothing you write names it.
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 three layers:
| Package | Layer | What it owns |
|---|---|---|
nros-board-threadx | family driver | the kernel/NetX build and the generic run_entry boot path both boards call |
nros-board-threadx-port-riscv64 | arch port | a fork of upstream’s ports/risc-v64/gnu: tx_port.h + five context-switch .S files |
nros-board-threadx-{linux,qemu-riscv64} | board overlay | defaults, 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 in the bringup’s system.toml, one image per flavour,
and baked at compile time. The scaffold names its image after the platform,
[image.threadx]. For the hosted flavour, with a locator added:
[system]
name = "tx_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "tx_node"
class = "tx_node::Talker"
name = "tx_node"
[image.threadx]
board = "threadx-linux"
locator = "tcp/127.0.0.1:9000"
The QEMU flavour adds the guest’s network identity, because it boots behind QEMU Slirp. Added beside the first, it takes a name of its own:
[image.threadx_rv]
board = "rv-virt-threadx"
locator = "tcp/10.0.2.2:9400"
os_config = ["board-net.toml"] # [eth0] network = "qemu", host = 15
Both blocks can live in one bringup; the node package does not change. With
two images, nros run tx_node no longer picks one, so name it
(nros run threadx, nros run threadx_rv). Whatever the node’s language,
the bringup carries the same keys; a C node’s CMakeLists.txt declares targets
and nothing about the deployment, and its 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
The build generates the message bindings from your ROS 2 install, so source it in this shell first. This first build records the ROS 2 prefix in the workspace, so later builds and runs of it need no sourced shell.
In the workspace root (tx_ws/, where nros new left you), whatever the
language and flavour:
source /opt/ros/humble/setup.bash
nros build
nros build prepares the build first (message bindings, the generated entry
for the image, its cargo or CMake settings), then hands off. There is no step
to run before it.
First setup builds ThreadX + NetX Duo (~3 min). Subsequent builds finish
in seconds. The binary lands under build/; nros build --dry-run prints the
exact paths and the cargo or cmake lines, for driving the tool yourself
(run nros configure first to write the generated entry without
building). See
When nros build drives the build.
After a successful build, nros build prints the artifact and the command
that runs it.
Run on the Linux simulator
threadx-linux is an ordinary host process — no QEMU. First the router
(ROS’s rmw_zenohd), on the port your image’s locator names — 7447 when
it names none, as a scaffolded image; 9000 in the example above:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
Then the image, from the workspace root:
nros run tx_node
The scaffolded talker publishes std_msgs/String once a second, in every
language:
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
Run on QEMU (RISC-V 64)
rv-virt-threadx boots on QEMU’s RISC-V virt machine, UART on stdio,
virtio-net on Slirp. Three terminals: the router, the board, and a stock
ROS 2 peer.
1. The router, on the image’s port (7447 for a scaffolded image; 9400 in
the example above). The guest dials 10.0.2.2:<p> through QEMU Slirp, so it
listens on 0.0.0.0:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
2. The board — from the workspace root:
nros run tx_node
nros run boots the image with the board’s runner: the store’s
qemu-system-riscv64 (nros setup rv-virt-threadx installed it) on the
virt machine, with virtio-net on Slirp. A C or C++ node on this board runs
the same way.
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
3. A stock ROS 2 peer (either flavour). The talker publishes
best-effort; stock ros2 topic echo defaults to RELIABLE, so the
QoS-mismatched echo silently delivers nothing. Force best-effort to receive:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
data:
Ctrl-C stops nros run and QEMU with it.
Readiness signal. threadx-linux: Publishing: 'Hello World: 1'
within a few seconds of the program starting on a warm
cache; a cold first nros build compiles the Rust image (~80 s)
before the first publish lands. threadx-riscv64
(QEMU): within ~15 seconds of QEMU boot. If no Publishing: line:
- Confirm the router is reachable on the image’s
locator(threadx-linux uses127.0.0.1; riscv64 QEMU uses10.0.2.2). - threadx-linux: if you brought up
tap-tx0by hand, confirm it is up; without it the fixtures use the loopback fallback, which is fine for this tutorial (nros setup threadx-linuxdoes not create the interface). - See Troubleshooting — First 10 Minutes.
Flash a real board
No real ThreadX board is supported. Both boards here are simulated — a
Linux process and a QEMU machine — and nano-ros ships no ThreadX board
package for physical hardware. A board of your own starts from the
porting guide, with the
nros-board-threadx family crate and the riscv64 arch port as the
pieces to reuse; it is then flashed with that board vendor’s tooling.
Multi-tier scheduling
ThreadX supports the multi-tier workspace scheduling model: declare per-tier ThreadX priorities in the bringup’s
system.toml with [tasks.<name>.threadx] priority tables, and
the generated entry 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
The copy-out talkers, each the same project as the one this page scaffolds:
- ThreadX-Linux Rust:
examples/threadx-linux/rust/talker/ - ThreadX-Linux C:
examples/threadx-linux/c/talker/ - ThreadX-RISC-V64 Rust:
examples/rv-virt-threadx/rust/talker/ - Board crates:
packages/boards/nros-board-threadx-linux/,packages/boards/nros-board-threadx-qemu-riscv64/
Next
- Subscriber, service and action: the
threadx_*andriscv_threadx_*image rows of the language workspaces,examples/workspaces/{rust,c,cpp}, underpubsub_bringup,service_bringupandaction_bringup(nros run pubsub_bringup:threadx_listener). - DDS on ThreadX: Cyclone DDS is the surviving DDS backend
(
nros-rmw-cyclonedds) — select it with[system] rmw = "cyclonedds"in the bringup’ssystem.tomland build again; 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.
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples as image rows of behaviour bringups.
Bare-metal Cortex-M3 (QEMU)
Support level:
mps2-an385-baremetal. Each page states what nano-ros can show for the board (where it runs, how often, and its breadth), generated from the board support registry; Board Support Levels explains the words.
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
nrosCLI once per machine (scripts/install.sh, see Installation), then provision this board.nros setupfetches a prebuilt bare-metal toolchain (arm-none-eabi-gcc,qemu-system-arm) plus the Rustthumbv7m-none-eabitarget into a shared store — no manual cross-compiler install. It provides no zenoh router: that is ROS’srmw_zenohd(see the RMW design), so running a zenoh image needs it from a ROS 2 install.
# 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-baremetalis a retired spelling of the same entry. 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
Scaffold a workspace for the board:
nros new bm_ws --board mps2-an385-baremetal --template talker --lang rust --rmw zenoh
cd bm_ws
bm_ws/
└── src/
├── bm_node/ # node package: package.xml, Cargo.toml (a library), src/lib.rs
└── bm_node_bringup/ # package.xml and system.toml: the board, the RMW, the node
bm_node is the talker, a library with no main() that names no board. The
bringup’s system.toml holds one image, [image.baremetal]. 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 build writes them under build/
together with the generated entry it compiles.
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
The entry shape
The image’s board line names the board, and the generated entry depends on
the board crate with the feature that shape needs. Pick it there — never a
different board crate, and never an edit to the node package:
| Entry shape | [image.<id>] board = | board crate feature the entry enables |
|---|---|---|
| direct-exec (inline spin loop) | "mps2-an385-baremetal" | board-entry |
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 a second address (10.0.2.10, router port 7450). Either way, an image that sets its locator in its [image.<id>]
and its address in a board network file ([eth0] network = "qemu", host = N)
table overrides the default — which every in-tree example
does, and which you should too. The address goes in the image’s board network
file, named by os_config (below).
Configure
The whole deployment statement is the bringup’s system.toml. It is baked at
compile time: the generated entry folds the image’s network identity into a
DeployOverlay the board’s boot Config applies. With a locator and an
address added, src/bm_node_bringup/system.toml reads:
[system]
name = "bm_node"
rmw = "zenoh"
domain_id = 0
[[component]]
pkg = "bm_node"
class = "bm_node::Talker"
name = "bm_node"
dispatch = "deferred"
[image.baremetal]
board = "mps2-an385-baremetal"
locator = "tcp/10.0.2.2:10500"
os_config = ["board-net.toml"]
and the image’s address sits in the board network file it names,
board-net.toml beside system.toml in the bringup (the board package owns
the device, so its address lives in the board’s own file):
[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], the board crate and its features — follows the
board = line on the next nros build, so no linker flag and no triple ever
moves by hand. The node package’s Cargo.toml names no board crate, RMW
backend or panic handler: those are the generated entry’s dependencies. So
changing board = to "mps2-an385-freertos" and building again builds the
FreeRTOS image with no other 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
The build generates the message bindings from your ROS 2 install, so source it in this shell first. This first build records the ROS 2 prefix in the workspace, so later builds and runs of it need no sourced shell.
In the workspace root (where the scaffold step left you):
source /opt/ros/humble/setup.bash
nros build # builds the image `[image.baremetal]` declares
nros build prepares the build first (message bindings, the generated entry
for [image.baremetal], the cargo settings the board implies), then runs
cargo. There is no step to run before it.
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 under
build/, never beside your sources; nros build prints its path and a run:
line when it finishes.
Driving cargo yourself (an IDE, a CI step, --release) is supported:
nros build --dry-run prints the exact cargo line, against the generated
entry under build/. To write that entry and its settings without building,
before you open the workspace in an IDE, run:
nros configure
What it writes is
regenerated on every build; never edit it, and never add a .cargo/config.toml
of your own to carry board facts. Your own Rust-toolchain preferences go where
cargo already looks for them — a parent directory or $CARGO_HOME. See
When nros build drives the build.
Run on QEMU
Three terminals: the router, the board, and a stock ROS 2 peer.
1. The router — ROS’s rmw_zenohd, on the port your image’s locator
names (Slirp forwards the guest’s 10.0.2.2:<p> to the host’s <p>). The
scaffold states no locator, so the board dials 10.0.2.2:7447; the example
above dials 10500. It must listen on 0.0.0.0 for the Slirp gateway:
source /opt/ros/humble/setup.bash
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' \
ros2 run rmw_zenoh_cpp rmw_zenohd
Started Zenoh router with id
2. The board — from the workspace root:
nros run bm_node
nros run rebuilds if anything changed, then boots the talker in QEMU with the
board’s runner: the patched qemu-system-arm that nros setup mps2-an385-baremetal provisioned into the SDK store, the LAN9118 on Slirp, and
-icount shift=auto, which keeps the board’s timer in step with Slirp’s I/O.
It streams the semihosting console until you press Ctrl-C.
Expected serial-over-semihosting output:
Publishing: 'Hello World: 1'
Publishing: 'Hello World: 2'
...
3. A stock ROS 2 peer. The talker publishes best-effort; stock ros2 topic echo defaults to RELIABLE, so the QoS-mismatched echo silently delivers
nothing. Force best-effort to receive:
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort
data: 'Hello World:
Ctrl-C stops nros run and QEMU with it.
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:
- Router not running — talker spins on smoltcp poll until killed.
- No network on the guest — if you boot the ELF with your own QEMU
line, an explicit
-nic/-netdevthat does not reach the LAN9118 (or-nic none,-nodefaults) leaves it unwired. The board’s runner passes-nic user,model=lan9118. - Cooperative spin starvation — if you added a long-running callback, the entire executor stalls; bare-metal has no preemption.
- See Troubleshooting — First 10 Minutes.
Flash a real board
Nothing in-tree is flashed: mps2-an385 is a QEMU machine, and nros setup
provisions no probe or flash tooling for it. A real Cortex-M board is a board
crate of your own — Worked Example — STM32F4 Out of Tree
takes one through the whole ladder — and its ELF goes to the board with the
probe tool its vendor supports. Add an image for it to the bringup
([image.stm32f4] board = "<your board id>"); once that board package declares
its flash runner, nros flash names the image:
nros flash bm_node_bringup:stm32f4
# or by hand, on the ELF path `nros build` printed:
# probe-rs run --chip STM32F407VGTx <elf>
# openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
# -c "program <elf> verify reset exit"
Not replayed. No probe runs this flash line (a CI runner has no board), so it is written from the board’s runner, not measured (tracking issue).
The chip, interface and triple above are the STM32F4-Discovery’s; yours come from your board.
GitHub source
- Bare-metal talker, the copy-out example for this board and the same
project as the one this page builds:
examples/mps2-an385-baremetal/rust/talker/ - Board crate:
packages/boards/nros-board-mps2-an385/
Constraints to be aware of
- No
allocby default. Pureno_std+heaplessfor bounded collections. If you needalloc, opt in via theallocfeature on your board crate and supply a#[global_allocator]. - No wake primitive. Cooperative single-thread spin only; the
executor’s
nros_platform_wake_*slots returnUnsupported. - No preemption. A long-running user callback blocks every other dispatchable handle until it returns.
nros-c/nros-cppNOT 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: the bare-metal image rows of the Rust
language workspace,
examples/workspaces/rust, underpubsub_bringup,service_bringupandaction_bringup. The serial talker and listener are image rows there too, statingtransport = "serial"over the same packages. - Wake-callback opt-in: the
wake-callback(latency-probe) bench underpackages/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.
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker per platform and language, and the paired examples, variants and configurations as image rows of behaviour bringups.
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-eabifor Pixhawk targets), andpython3with the PX4 development requirements installed (bash ./Tools/setup/ubuntu.shonce).
Project layout
PX4 external modules live outside the PX4 source tree and are
hooked in at configure time. The template — shipped in the SDK root at
integrations/px4/module-template/ (the SDK root is the installed release’s
share/nano-ros, nros locate nano-ros prints it) — 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 your own checkout — the firmware tree you already build. Clone it (or use the one you have) and initialize its submodules; nano-ros is tested against PX4 v1.17.0.
git clone --branch v1.17.0 https://github.com/PX4/PX4-Autopilot.git
git -C 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.
Copy the template into your firmware repo; PX4 is pointed at the copy at build time:
mkdir -p px4-modules
sdk="$(nros locate nano-ros)" # this project's nano-ros SDK root
cp -r "$sdk/integrations/px4/module-template" px4-modules/nano-ros
(Releases after 0.6.0-nros4 ship the template; with an older one, copy it from GitHub.)
PX4 enforces the src/modules/<name>/ layout exactly. To make the module
your own, rename nano_ros_app in all three places at once: the directory,
the module list in src/CMakeLists.txt, and the MAIN name in the module’s
own CMakeLists.txt. PX4 has no central module registry, so copying the
template into your tree is how it is distributed.
PX4’s own make <target> is the build verb, and it configures and builds ONE
tree, PX4-Autopilot/build/<target>/. The module joins it through
EXTERNAL_MODULES_LOCATION, passed on that same make line — PX4’s
Makefile forwards it to its CMake configure — and pointing at the dir that
contains 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" …)).
The template finds nano-ros itself: an explicit -Dnano_ros_ROOT=<path>
cache variable wins, otherwise it asks the nros on PATH,
which answers with the SDK that binary belongs to. No environment variable is
read. To name the root anyway, pass it through PX4’s
CMAKE_ARGS:
make -C PX4-Autopilot px4_sitl_default \
EXTERNAL_MODULES_LOCATION="$PWD/px4-modules/nano-ros" \
CMAKE_ARGS="-Dnano_ros_ROOT=$sdk" # optional
Not replayed. No probe track runs the PX4 SITL build yet, so this line is unmeasured on the installed track (tracking issue).
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. Which RMW backends the module registers is the
BACKENDS list of nros_px4_add_module(...) in
src/modules/nano_ros_app/CMakeLists.txt — the template ships BACKENDS uorb
— not a cache variable. (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
One make, one tree. For the SITL simulator (POSIX target — easier to
develop against), or for a Pixhawk (px4_fmu-v5_default here; use your
board’s PX4 target):
make -C PX4-Autopilot px4_sitl_default \
EXTERNAL_MODULES_LOCATION="$PWD/px4-modules/nano-ros"
make -C PX4-Autopilot px4_fmu-v5_default \
EXTERNAL_MODULES_LOCATION="$PWD/px4-modules/nano-ros"
The firmware lands in PX4’s own tree, PX4-Autopilot/build/<target>/
(px4_fmu-v5_default.px4 for the Pixhawk). The first build cross-compiles
nano-ros’s Rust staticlibs alongside PX4’s NuttX kernel + apps (~10 min on a
fresh checkout). Note that the module links prebuilt nano-ros archives
(nros_px4_add_module resolves them at configure time and, when one is
missing, stops naming the command that builds it).
Run on the SITL simulator
PX4’s SITL runs the autopilot as a host process against a simulator. With Gazebo (Harmonic) installed per PX4’s documentation:
make -C PX4-Autopilot px4_sitl_default gz_x500 \
EXTERNAL_MODULES_LOCATION="$PWD/px4-modules/nano-ros"
# In the PX4 console:
pxh> nano_ros_app start
# 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
Flash a real board
PX4’s own upload target flashes over the bootloader USB, with the same
EXTERNAL_MODULES_LOCATION as the build (it rebuilds if anything changed):
make -C PX4-Autopilot px4_fmu-v5_default upload \
EXTERNAL_MODULES_LOCATION="$PWD/px4-modules/nano-ros"
Not replayed. No probe runs this flash line (a CI runner has no board), so it is written from the board’s runner, not measured (tracking issue).
QGroundControl can flash the same build/px4_fmu-v5_default/px4_fmu-v5_default.px4
instead. Start the module from the NSH console (nano_ros_app start) or the
board’s startup script.
No probe runs the upload line (a CI runner has no board): it stands unverified until run against a named board. PX4 SITL is the replay that can run in CI, and it is not yet run there either.
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:
- Module didn’t register — the template logs
nros_rmw_uorb_register() -> <rc>on startup (search the PX4 boot log fornros_rmw_uorb_register). A non-zero<rc>usually means a NuttX kernel-config / feature-gate mismatch. - Once you’ve added forwarders,
zenohdmust be reachable from the autopilot’s network (Pixhawk: configured via QGroundControl orparam set). - uORB topic not advertised yet — start the upstream PX4 module
that publishes it (
commander startetc.) first. - See Troubleshooting — First 10 Minutes.
GitHub source
- PX4 external-module template:
integrations/px4/module-template/ - Worked PX4 example, the source of truth for the register-check shape
(including the weak
nros-rmw-cffiregister stub on the Rust side):packages/testing/nros-px4-register-check/ - PX4 integration roadmap notes:
integrations/px4/README.md
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.cpptemplate’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.
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_AEMv8Rand piping UART 0–3 to stdout — is landed.The two standalone talker lanes were retired: 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 the archived FVP runtime roadmap.
Verified where? The run half is maintainer-run: a maintainer boots the image by hand (the recipe is in the contributor lanes linked below), and no installed probe track replays this chapter.
fvp-aemv8r-smp’s status and the date of its last boot are on its evidence page; a probe track follows an automated run, not the other way round. Read the output below as what a maintainer last saw, not as a gate.
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
hwv2safety-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 zephyronce if you haven’t (see the Zephyr starter). -
The Arm
Base_RevC AEMv8RFast Models binary. nano-ros fetches it:nros setup --tool arm-fvpnros setup board fvp-aemv8r-smpdoes 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]innros-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-islandhad 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
For a model you extracted yourself, rather than the one nros setup --tool arm-fvp fetched, 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 downloads nothing: nros setup --tool arm-fvp
is the fetch.
Doctor check
nano-ros checks that the model resolves via ARMFVP_BIN_PATH, ARM_FVP_DIR,
PATH, or the SDK store (where nros setup --tool arm-fvp installs it), 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.
Changing this in the tree: see Internals → per-platform lanes.
Build
The FVP is a Zephyr board, fvp-aemv8r-smp, so an image names it in its
bringup’s system.toml and nros build hands it to west like any Zephyr
image (the Zephyr chapter
has the workspace flow). The worked example is
examples/workspaces/realtime-cpp’s
[image.fvp], a Cyclone DDS image:
[image.fvp]
board = "fvp-aemv8r-smp"
rmw = "cyclonedds"
The build generates the messages from your ROS 2 install, so the first one runs in a shell that has sourced it; it records the prefix, so later builds need no sourced shell:
nros setup zephyr --rmw cyclonedds # the Zephyr workspace + SDK
nros setup fvp-aemv8r-smp # the model (x86_64 Linux only)
source /opt/ros/humble/setup.bash
nros build demo_bringup:fvp
The model must be resolvable when the build configures: Zephyr caches the
FVP_BaseR_AEMv8R path then, so an image configured before the model was
installed (or before ARMFVP_BIN_PATH was set) has to be reconfigured
(nros build demo_bringup:fvp -- --pristine). nros build says so in the note
it prints after the build.
Run on the FVP
nros build ends by printing Zephyr’s run target for the image’s build
directory; it invokes FVP_BaseR_AEMv8R with the board’s cluster and UART
settings and pipes the UARTs to your terminal:
west build -d build/zephyr-cyclonedds/demo_bringup__fvp_west -t run
Not replayed. No probe track builds or boots the FVP image yet, so this line is unmeasured on the installed track (tracking issue).
The model is a free download, not licence-gated (see Prereqs); what keeps it off a CI lane is cost and its x86_64-only host build. It runs until you stop it (Ctrl-C).
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
- Environment Variables —
ARM_FVP_DIR/ARMFVP_BIN_PATH— the discovery contract. - Zephyr (west module) — the parent Zephyr starter; the FVP is a board-target slice of it.
examples/workspaces/realtime-cpp/src/demo_bringup/— the workspace image ([image.fvp]) this chapter builds and boots. Its west application is generated bynros build; its Kconfig lives inboards/fvp_baser_aemv8r_fvp_aemv8r_aarch64_smp/.
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 and explains how each backend connects your device to a ROS 2 network. The mechanics of changing your pick are in Switching 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, 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. Which status events and other features it wires is
listed in the Per-RMW Feature Matrix;
the remaining known limitations are 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.
What each backend supports
The Per-RMW Feature Matrix is the one capability table in this book. It is generated from the backend sources, so it is current: services, status events, graph introspection, zero-copy loans and the rest, per backend. In short, all three backends do publish/subscribe, service servers and clients, and status events; they differ in the less common features (for example, XRCE-DDS has no graph introspection, and only Zenoh has the zero-copy loan API). The version of each backend’s upstream library is listed on Support Status.
How each backend works
The three backends differ less in what they can do than in the shape of the network around your device: what else has to run, who handles discovery, and what happens when a process goes away.
Zenoh
The Zenoh backend uses zenoh-pico, a lightweight C client for the Zenoh protocol. The MCU takes part in the Zenoh network directly; there is no protocol translation layer.
- The MCU runs zenoh-pico in client mode and connects to a zenoh
router (
rmw_zenohd, ROS 2’s own) over TCP, UDP or TLS. - zenoh-pico creates publishers and subscribers directly on the Zenoh network.
- ROS 2 nodes running
rmw_zenoh_cppconnect to the same router. The router forwards messages without interpreting them; it is not a protocol translator. - The device takes part in ROS 2 graph discovery through liveliness
tokens, so
ros2 topic listandros2 node listsee it.
Zenoh itself defines a peer mode in which two zenoh-pico devices talk
directly with no router. nano-ros does not ship it: zenoh-pico is
built without its multicast transport and scouting, because every
nano-ros deployment reaches its peers through a router or an agent and
those code paths cost flash on an MCU. Setting NROS_SESSION_MODE=peer
is therefore refused at session open, with a message saying so.
If the router stops, the device loses routing but keeps running. Footprint: roughly 16 KB+ of RAM (heap required) and 100 KB+ of flash.
XRCE-DDS
The XRCE-DDS backend uses Micro-XRCE-DDS-Client, the same client library micro-ROS uses. It follows an agent-based model: a small client on the MCU delegates entity creation to an agent process.
- The MCU runs the XRCE-DDS client and connects to an Agent process over UDP or serial (UART, HDLC framing).
- The client asks the Agent to create entities (“a publisher on topic X with type Y”).
- The Agent creates full DDS entities on the client’s behalf and bridges data between the XRCE protocol and the DDS data space.
- ROS 2 nodes using any DDS-based RMW (Fast DDS, Cyclone DDS) communicate through the Agent.
The Agent is mandatory and handles discovery; the client does none of its own. If the Agent stops, the device loses all connectivity.
Heap use on the MCU is small and bounded: the backend allocates when a
session or entity is created, never per message. Each
XRCE_MAX_SUBSCRIBERS slot is heap, so size it to the subscriptions you
declare. Footprint: roughly 3 KB of client RAM and 75 KB of flash. The
upstream client also defines a CAN FD transport, but nano-ros builds do
not enable it.
Cyclone DDS
The Cyclone DDS backend uses
Eclipse Cyclone DDS,
the same DDS implementation ROS 2 uses through rmw_cyclonedds_cpp. It is
a C++ library that registers itself with the nano-ros runtime through the
RMW C ABI.
- Selecting
cycloneddsfor the image links the backend and registers it before the executor opens; you do not call a register function yourself. - The executor’s session, publisher, subscriber and service operations dispatch to Cyclone, which creates the DDS domain, participant, topics, writers and readers. Cyclone runs its own receive threads.
- ROS 2 nodes using stock
rmw_cyclonedds_cppinteroperate directly: same wire protocol, same discovery, and no key rewriting (unlikermw_zenoh’s<domain>/<topic>/<type>/…key expressions).
Discovery is SPDP / SEDP over UDP multicast, or a static unicast peer
list. The domain is configured from a built-in configuration rather than
an XML file, so no XML parser is linked. Cyclone allocates with
malloc, so a heap is required: plan for roughly 32 KB+ of RAM and
150 KB+ of flash on an embedded link (the POSIX shared library,
libddsc.so, is about 1.4 MB). When a peer goes offline, only its
samples stop arriving; there is no central process whose loss takes the
network down.
Getting the Cyclone library. Selecting cyclonedds for an image is
all you have to do; the build provides Cyclone itself. It uses a prebuilt
install if you point at one (-DCMAKE_PREFIX_PATH=<install> or
-DCycloneDDS_DIR=); otherwise it builds Cyclone from source, by default
from the pinned sources that ship with nano-ros, or from your own copy
with -DCYCLONEDDS_SOURCE_DIR=<path>. A plain nros build therefore
needs no separate Cyclone step. The idlc code generator is a host tool,
found on PATH (for example from a ROS 2 install) or through
-DIDLC_EXECUTABLE=.
Zephyr on Cortex-A / Cortex-R needs the aarch64-zephyr-elf
toolchain in your Zephyr SDK install; toolchain setup for these targets
is in
docs/reference/zephyr-armv8r-setup.md.
The footprint figures in this section are order-of-magnitude planning
numbers, not measurements; they vary with features, message set and
optimisation level. Size your own image with arm-none-eabi-size; the
RAM knobs are listed in the
Static Pool Inventory.
Cyclone DDS network configuration per platform
Cyclone DDS speaks RTPS over UDP multicast and unicast. On every RTOS the network stack needs IGMP enabled, enough network buffers for the SPDP / SEDP discovery burst, and a way to join a multicast group. The settings per 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.
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, also use a board overlay to enlarge the GEM
driver’s RX / TX descriptor rings (default 32), so a brief pause in
draining does not immediately drop frames:
&gem0 {
promiscuous-mode;
rx-buffer-descriptors = <128>;
tx-buffer-descriptors = <64>;
};
On native_sim, Zephyr’s offloaded-sockets driver does not forward
IP_ADD_MEMBERSHIP to the host kernel, so multicast discovery does not
work there. The native_sim configuration discovers peers by unicast to
127.0.0.1 instead (see
Troubleshooting);
use qemu_cortex_a9 to exercise multicast DDS on Zephyr.
FreeRTOS + lwIP
// 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
CONFIG_NET_IGMP=y
CONFIG_NET_BROADCAST=y
CONFIG_NET_UDP_NRECVS=4
CONFIG_NET_RECV_TIMEO=y
ThreadX + NetX Duo
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
#![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
No configuration is needed: the kernel handles IGMP and setsockopt.
Make sure the loopback interface is up (it is by default).
Decision summary
- Default / getting started / DDS fleet → Cyclone DDS. Nothing to
run beside your node; direct
rmw_cyclonedds_cppinterop. Needs ~32 KB RAM and a network stack with IGMP. rmw_zenoh_cppfleet, 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 and no per-message allocation, but the agent is mandatory.
Any of the three handles standard publish/subscribe and services, integration with ROS 2 desktop nodes, and development under QEMU.
You do not have to pick exactly one per system: a single binary can link multiple backends and bridge between them, for example zenoh devices to a Cyclone DDS stack, or XRCE devices to a Zenoh network — see Cross-backend Bridges.
Once you have picked, Switching RMW Backends shows the exact edits per build system.
Design background
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 build
A workspace has no root build file to edit: nros build
writes the CMake root under build/ with BACKEND taken from the
image’s rmw, and for a Rust image it generates the entry that gives the
board crate the backend feature. Each image builds
into its own directory, so no object files from another backend linger.
A CMake tree that is not a workspace
A CMake project with no system.toml that consumes nano-ros in source
form — either find_package(nano_ros REQUIRED) (the ament-shape entry
point, nano_rosConfig.cmake at the SDK root, located via
-Dnano_ros_ROOT) or a raw add_subdirectory
(Build as a CMake subdirectory) — picks
its backend on the configure line. nros setup presets writes the
package a CMakePresets.json that already carries the SDK root and the
board’s toolchain (from the presets nros setup <board> wrote), so the
configure line names only the backend:
nros setup presets # once per package
cmake --preset linux -DNROS_RMW=cyclonedds # zenoh | xrce | cyclonedds
cmake --build build/linux
Use one build directory per backend (-B build-zenoh,
-B build-cyclonedds, …) if you switch back and forth.
A Rust node package
A Rust node package needs no edit at all. Its Cargo.toml names no
rmw-<x> or ros-<edition> feature and no board crate, and no code calls a
backend’s register(): the generated entry boots through the board crate,
which links the backend the image selects and registers it. The workspace
section above is the whole change.
Several backends from one bringup
A bringup whose nodes run on more than one backend declares one image per RMW
in its system.toml and builds the image it names:
# src/talker_node_bringup/system.toml
[system]
name = "talker_node"
rmw = "zenoh"
default_images = ["linux"]
[image_defaults]
board = "linux"
[image.linux]
[image.linux_cyclonedds]
rmw = "cyclonedds"
nros build linux_cyclonedds
The full-system declaration model — how the one value in system.toml
is lowered to cargo features and CMake cache variables — is specified in
the RMW selection-and-lowering design (see Design background below).
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. One image per backend, each with the Zephyr
settings that backend needs:
[image.zephyr_zenoh]
board = "zephyr"
rmw = "zenoh"
os_config = ["prj-zenoh.conf"]
[image.zephyr_cyclonedds] # Cyclone: Cortex-A/R and native_sim targets
board = "zephyr"
rmw = "cyclonedds"
os_config = ["prj-cyclonedds.conf"]
A Cyclone DDS image also needs multicast-capable networking on the target; the per-platform settings are in Choosing an RMW.
See the platform guides for the concrete per-platform workflow:
and When nros build drives the build for
which builder applies where.
Design background
- RFC-0031: RMW selection and lowering
- RFC-0048: CMake / ament consumption
- RFC-0098: generated leaf build config
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 core 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 event kinds
Nine event kinds: the five core kinds below, plus Iron’s matched and incompatible-type pairs (below). Three core kinds on subscribers, two on publishers.
Subscriber events
| Kind | Fires when | Use |
|---|---|---|
LivelinessChanged | A tracked publisher’s liveliness state changes (publisher started asserting / stopped) | Safety-island fail-over: trigger MRM when remote control node goes silent |
RequestedDeadlineMissed | An expected sample didn’t arrive within the configured deadline | Periodic-sensor pattern: 100 Hz topic with 15 ms deadline; fire alarm when late |
MessageLost | The backend dropped one or more samples (typically: ring-buffer overflow, slow consumer) | Diagnostic + adaptive: log, drop more aggressively, coalesce |
Publisher events
| Kind | Fires when | Use |
|---|---|---|
LivelinessLost | This publisher missed its own liveliness assertion deadline | Self-monitoring: detect own task starvation |
OfferedDeadlineMissed | This publisher promised X Hz, fell behind | Self-monitoring: detect overrunning compute |
Matched and incompatible type (Iron+)
Both pairs have one kind per side.
| Kind | Side | Fires when | Payload |
|---|---|---|---|
PublicationMatched | publisher | a subscription starts or stops matching it | MatchedStatus |
SubscriptionMatched | subscription | a publisher starts or stops matching it | MatchedStatus |
PublisherIncompatibleType | publisher | a subscription on the same topic name with another type appears | IncompatibleTypeStatus |
SubscriptionIncompatibleType | subscription | a publisher on the same topic name with another type appears | IncompatibleTypeStatus |
MatchedStatus is upstream’s rmw_matched_status_t — total_count,
total_count_change, current_count, current_count_change (signed: an
endpoint leaving is -1). The typical use is “start publishing once somebody
listens”:
#![allow(unused)]
fn main() {
pub_.on_matched(|s| {
if s.current_count > 0 { LISTENER_PRESENT.store(true, Ordering::Release); }
})?;
// Publisher-side events are observed on the publisher's poll points, so a
// publisher that is not publishing yet gives it one:
while !LISTENER_PRESENT.load(Ordering::Acquire) {
let _ = pub_.assert_liveliness();
executor.spin_once(Duration::from_millis(50));
}
}
C: rclc_publisher_set_matched_callback,
rclc_publisher_set_incompatible_type_callback,
rclc_subscription_set_matched_callback and
rclc_subscription_set_incompatible_type_callback (the subscription must
be added to an executor first), payloads rclc_matched_status_t /
rclc_incompatible_type_status_t. C++: Publisher::on_matched /
on_incompatible_type and the same pair on PollSubscription, taking
rclcpp’s MatchedInfo / IncompatibleTypeInfo through a function pointer
plus user_context. Like the five core C/C++ setters below, they keep
the registration in one process-wide 16-slot table shared by C and C++.
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);
})?;
}
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.
Every setter returns RCL_RET_OK once the backend holds the
registration, and RCL_RET_UNSUPPORTED when the backend does not generate
that event (XRCE-DDS and uORB carry none; zenoh-pico has no
LivelinessChanged on bare metal). That answer comes from the backend, never
from the C layer. The other returns:
RCL_RET_NOT_INIT— for the three subscription setters, the subscription has not been added to an executor yet (rclc_executor_add_subscription*). The executor owns the subscriber, so before that there is nothing to register on. Register after the add.RCLC_RET_FULL— the process-wide event-slot table is full. It holds 16 registrations across every C and C++ entity; one publisher with all four of its kinds plus one subscription with all five take nine.
deadline_ms on the two deadline setters reaches the backend: zenoh uses it
when the entity’s QoS states no deadline of its own, Cyclone DDS takes the
deadline from the QoS profile and ignores the argument. Registering the same
kind again replaces the earlier callback, and a callback never runs after
rcl_publisher_fini / rcl_subscription_fini (or
rclc_executor_remove_subscription), even if the backend fires late.
C++
static void on_liveliness_changed(void* storage,
nros_cpp_liveliness_changed_status_t status,
void* user_context) {
if (status.alive_count == 0) trigger_failover();
}
// On `rclcpp::PollingSubscription` / `rclcpp::Publisher`.
sub.on_liveliness_changed(on_liveliness_changed, nullptr);
sub.on_requested_deadline_missed(15 /* ms */, on_deadline_missed, nullptr);
C function pointers + user_context only; there are no lambda or
std::function overloads. The subscription-side setters exist on
PollingSubscription alone. The first callback argument is the entity’s
storage, and it follows the object through a C++ move. Same answers as C:
ErrorCode::Unsupported where the backend does not generate the event,
ErrorCode::Full when the shared 16-slot table is full.
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 core 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:
| Backend | LivelinessChanged / Lost | DeadlineMissed | MessageLost |
|---|---|---|---|
| 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) | ✅ Client-side clock check against the last takeable arrival / last publish, fired from drive_io. The Agent’s DDS entity keeps an infinite deadline — uxrQoS_t has no field for one — so a peer matching on deadline still sees infinite | ✗ 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 | ✗ Not wired: the C++ backend (nros-rmw-uorb) fills no event slot. The retired Rust uORB backend’s publish-counter MessageLost did not carry over |
Matched / incompatible type: Cyclone DDS (its own matched status;
builtin-topic type comparison) and zenoh-pico (the graph cache’s
liveliness tokens, as rmw_zenoh_cpp does) carry both pairs; XRCE-DDS
and uORB answer Unsupported. On zenoh the counts are snapshots of the
graph cache, so total_count counts increases seen between polls, and an
image built with graph discovery off (ZPICO_GRAPH_DISCOVERY=0) refuses the
registration.
✅ = wired. ✗ = not wired (registration answers Unsupported). The
slot-level view is generated from the backend sources in the
per-RMW feature matrix.
Cyclone DDS carries the whole core set, as zenoh-pico does, but
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 subscription side has no such limit. Its statuses are also
polled from has_data, and a subscription with an arrival waker is normally
asked only after something arrives. Registering any status callback on a
subscription changes that: the executor then asks it on every spin, arrival or
not. So once the last publisher has gone, the matched count falls, the
liveliness count drops, and RequestedDeadlineMissed fires, on the next spin
after the backend notices. On zenoh that is within one liveliness query and one
graph walk (at most every 50 ms). The interop cell
native-qos-event-c-zenoh-bidir measures both falls against stock ROS 2. A
subscription with no status callback is still asked only after an arrival, so
it costs an idle executor nothing. On Cyclone DDS, whose has_data always
answers “maybe”, a subscription with a status callback is dispatched on every
spin; when nothing is waiting, the take finds nothing and the callback does not
run.
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
One upstream event type is intentionally absent from the API (*_MATCHED
and *_INCOMPATIBLE_TYPE are supported; see above):
*_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.
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
- RMW API: Differences from upstream
rmw.hSection 8 — the design comparison this page expands on. - RTOS Cooperation —
max_callbacks_per_spintreatment of event callbacks. - Differences from ROS 2 — broader surface comparison.
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 normw_zenoh_cppapt package — on those, either buildrmw_zenohfrom source as a colcon overlay, or use the Cyclone DDS backend instead (directrmw_cyclonedds_cppinterop, 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: Scaffold, build and run a nano-ros talker
nros new talker_ws --board linux --template talker --lang rust --rmw zenoh
cd talker_ws
RUST_LOG=info nros run talker_node # builds first, then runs it
# 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:
Scaffold a listener node instead, the same node as
listener_pkg
in the Rust language workspace (nros run pubsub_bringup:linux_listener
there runs it); it subscribes to std_msgs/String on /chatter, the demo talker’s topic:
# Terminal 2: a nano-ros listener
nros new listener_ws --board linux --template listener --lang rust --rmw zenoh
cd listener_ws
RUST_LOG=info nros run listener_node
# 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
The listener logs each message ROS 2 publishes.
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
Add an image):
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 speaks the same wire format as rmw_zenoh_cpp: the same key
expressions, liveliness tokens, CDR encoding and per-message attachment.
You do not configure any of it. How each piece is encoded is described in
Internals → Zenoh protocol.
ROS 2 Editions
Three editions are supported: 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 build <id> generates the messages for that
edition and builds the image; the
ros-<edition> cargo features are what the build lowers it to, not
something you pass. A workspace is scaffolded for one edition —
nros new talker_ws --board linux --template talker --ros-edition jazzy —
and a bringup that builds for several declares one image per edition,
as the bringup of the copy-out talker,
examples/linux/rust/talker/system.toml,
does (linux_jazzy, linux_xrce_jazzy, …), then picks one:
nros build linux_jazzy
Troubleshooting
These are the interop-specific failures. For problems that are not about the ROS 2 side (build failures, sessions that never open, transport errors), see Troubleshooting; for a first run, see Troubleshooting — First 10 Minutes.
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. Also check
that both sides use the same ROS 2 edition (see
ROS 2 Editions).
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
- Architecture — understand the layer model
- Internals → Zenoh protocol — how the wire format is encoded
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The copy-out talker and the language workspaces that hold the paired examples.
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:
- Looks up
namein the registry of linked backends. - If
name == primary_rmwand the locator matches → the Node binds to the primary session (slot 0). No second session opened. - Else → opens a fresh session through that backend’s
open_with_rmwand stores it inextra_sessions[N-1]. The new Node’ssession_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); });
A bridged domain’s RMW file, from C / C++
A Rust bridge entry gets each [[domain]]’s config file through
nros-bridge.toml. A hand-written C or C++ multi-session caller
(nros_cpp_init_multi, rclc_init_multi) asks for the same table at
configure time and hands it to the session spec:
nros_session_properties_header(
BRINGUP ${CMAKE_CURRENT_SOURCE_DIR}/../demo_bringup
DOMAIN zen
SYMBOL zen_props
OUT ${CMAKE_BINARY_DIR}/gen/zen_props.h) # ROW_TYPE rclc_session_property_t for rclc
#include "zen_props.h" // static const nros_cpp_session_property_t zen_props[N]; zen_props_LEN
NrosCppSessionSpec specs[2] = {
{"uorb", "", 0, "bridge_in", nullptr, nullptr, 0},
{"zenoh", "", 0, "bridge_out", nullptr, zen_props, zen_props_LEN},
};
nros_cpp_init_multi(specs, 2, storage);
The file is lowered on the host, exactly as for the Rust road:
a zenoh JSON5 file becomes its session keys, a Cyclone
XML file travels whole under config. Editing the file re-runs the
configure. rclcpp::SessionSpec::with_properties(rows) sets it on the C++
MultiExecutor wrapper.
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_extrawake-callback slot (stdonly; bare-metal targets share the primary wake). - One round-trip through
spin_onceper 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()?;
}
Each Node’s callbacks run in its sched context’s rank order inside the executor. For the slow backend not to delay the fast one at all, put the two Nodes in different tiers (a tier is one thread at its own priority; executor preemption levels generalise it), so the fast Node’s tier preempts the slow one’s.
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. The build 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/chatteronto Cyclone DDS, where a stockrmw_cyclonedds_cpppeer receives it.bridge-xrce/is the XRCE variant: the egress[[domain]]carries the Micro-XRCE-DDS Agent’slocator, 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
A bridge can run under an ARINC-653-style time-triggered cyclic
schedule (ingress and egress in non-overlapping windows of a major frame,
via Executor::apply_time_triggered_schedule), but no shipped example
combines a bridge with time-triggered windows, and none demonstrates TT
windows at all; the window gate is covered only by nros-node executor
unit tests. 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
Bridge-specific failures are below. For a backend that will not open a session at all, oversized messages and other general problems, see Troubleshooting.
| Symptom | Likely 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_init | Stale build — the C-side multi-Session dispatch predates nros-v0.5.0; rebuild nros-c after updating. |
Bridge spinning marker never reaches piped test harness | Add 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
- Choosing an RMW Backend — single- backend selection criteria.
- RMW Backends — Host-Language Policy — registry + per-backend sizing.
Design background
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:
| Transport | Crate | Use Case |
|---|---|---|
| TCP/UDP (Ethernet/WiFi) | zpico-smoltcp | MCUs with Ethernet MAC or WiFi radio |
| Serial (UART) | zpico-serial | MCUs 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:
| Platform | Serial Implementation | Extra Crate Needed |
|---|---|---|
| Bare-metal (MPS2-AN385) | zpico-serial + UART driver | Yes |
| ESP32-QEMU (esp-hal, no IDF) | Not wired — see ESP32 Serial — not wired | — |
| Zephyr | zenoh-pico built-in; interrupt-driven RX ring (CONFIG_UART_INTERRUPT_DRIVEN, selected by the serial link). The polled path is retired | No |
| FreeRTOS / NuttX | zenoh-pico built-in (POSIX /dev/ttyXXX) | No |
| ThreadX | zenoh-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. These are the rows the generated entry carries for the image; you do not write them:
# Board crates are registry-style names resolved by the generated
# patch table, 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 Crate | Default | Alternative | Both |
|---|---|---|---|
nros-board-mps2-an385 | ethernet | serial | ethernet,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 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: the
serial/ locator selects it.
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. Scaffold and run
The mps2-an385 bare-metal scaffold already talks over its serial port:
nros new serial_ws --board mps2-an385-baremetal --template talker --lang rust --rmw zenoh
cd serial_ws
source /opt/ros/humble/setup.bash # nros run starts ROS's router on the serial link
nros run serial_node
nros run builds the image, creates the PTY pair, starts the router on one
end, boots QEMU with UART0 on the other, and streams the console. It needs
rmw_zenoh_cpp findable — ROS 2 sourced, or NROS_RMW_ZENOHD naming the
router; otherwise it exits with status 2 and names the router it could not
find. The steps below are what it does, for doing them by hand.
The serial locator is part of the image’s identity, so it is stated in the
bringup’s system.toml (src/serial_node_bringup/system.toml) rather than
passed to the build:
[image.baremetal]
board = "mps2-an385-baremetal"
locator = "serial/UART_0#baudrate=115200"
The serial/ scheme is also the image’s LINK: nros build
derives the serial-only build from it (ZPICO_NO_SMOLTCP=1, NROS_LINK_IP=0,
the board’s serial feature), so nothing else names it. The transport = "serial" key that used to restate it is refused.
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(nano-ros ships nozenohdof its own). It takes no command-line configuration — argv is not parsed, so a--connectflag is not rejected, it is simply unread. Configuration travels inZENOH_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, on the ELF path
nros build printed:
"$(nros locate tool 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 <elf>
(-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:
| Format | Example | Use Case |
|---|---|---|
serial/<dev>#baudrate=<baud> | serial/UART_0#baudrate=115200 | Device name (Zephyr, bare-metal) |
serial/<tx>.<rx>#baudrate=<baud> | serial/0.1#baudrate=115200 | Pin 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: the board descriptor states
it once as [board.run] / [board.run.qemu], and the build renders it into
the image’s generated settings file (nros-cargo.toml under build/), where
it reads:
[target.thumbv7m-none-eabi]
runner = "qemu-system-arm -cpu cortex-m3 -machine mps2-an385 -nographic -icount shift=auto -semihosting-config enable=on,target=native -nic user,model=lan9118 -kernel"
Key flags:
-nographic— No display window-semihosting-config enable=on,target=native— Debug output via semihosting (separate from UART)-icount shift=auto— keeps the guest clock on wall-clock time (below)-nic user,model=lan9118— the board’s Ethernet on QEMU’s user network; a serial-transport image does not use it
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:
- Build the serial-talker example (prebuilt fixture)
- Create socat PTY pairs (both ends exist before either process)
- Start the router with
listen/endpoints=["serial/<pty>#baudrate=115200"] - Boot QEMU via
start_mps2_an385_with_serial(UART0 → the pair;-display none -monitor none) - Subscribe and verify message delivery
Changing this in the tree: see Internals → per-platform lanes.
Baud Rate Tuning
Recommended Rates
| Baud Rate | Use Case |
|---|---|
| 115200 | Default, safe for all hardware |
| 460800 | Higher throughput, most USB-serial adapters |
| 921600 | Maximum 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 argument is required: 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.
Zephyr: a board that joins over a UART
Two settings decide whether a Zephyr board can join a domain whose peers are already sending, and neither default is right for a serial link yet. The safety island found both by measurement:
CONFIG_MAIN_THREAD_PRIORITY=5
CONFIG_NROS_ZENOH_SERIAL_RX_RING_BYTES=4096
- Main thread below the read task. Zephyr’s default main priority, 0,
outranks zenoh-pico’s read task while
mainregisters entities. The UART ISR then fills the receive ring while the read task cannot run, and the frames lost to the overflow carry the router’s replies to the publishers’ interests. The island’s joins into a live domain failed 3 of 3 at 0 and passed at 5. On a serial link the configure REFUSES an image whose main thread is not below the read task, and the error names the number to use (5 on the default 15 preempt priorities). - Receive ring. The default 1024 bytes is 11 ms of line at 921600 baud.
The island measured a high water of 815–1024 bytes at a join, and one join
in five overflowed. Read
_z_zephyr_serial_stats.ring_high_waterandring_overflowson your board and size the ring from them; the RAM is static.
A board that only joins an idle domain stays well inside the ring default.
The ring default may change once a serial join has been measured;
the main priority stays a stated value, because Kconfig cannot derive
read task + 1 from two symbol values.
Troubleshooting
Serial-specific failures are below. For general problems (build failures, oversized messages, zenoh-pico error codes), see 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
-chardevuses - Baud rate mismatch — MCU and router must use the same baud rate
- QEMU timing — Add
-icount shift=autoto slow down QEMU’s CPU clock
No Messages Received
- Locator mismatch — Ensure the MCU’s
zenoh_locatormatches what zenohd expects - Domain ID — Both sides must use the same ROS 2 domain ID (see ROS 2 Interop for the other ROS 2-side checks)
- 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. 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; serial on this board is not planned.
TX Throughput & Latency Tuning
(zenoh-pico RMW — Zephyr, FreeRTOS, NuttX, ThreadX, POSIX)
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
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.
| platform | batch | split_lock | flush_ms | rationale |
|---|---|---|---|---|
| Zephyr | on | on | 50 | the per-fd tx ceiling above; measured 15–20× streaming. Relies on nano-ros’s zenoh-pico fork, in which 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 else | off | off | 50 | no 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):
| configuration | talker completes? | throughput | vs baseline |
|---|---|---|---|
| defaults | no (~33 s window) | ~9 msg/s | 1× |
TX_BATCH | no | ~136 msg/s | ~15× |
TX_BATCH + TX_SPLIT_LOCK | yes (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
| knob | Zephyr (Kconfig) | cargo lanes (env at build) | C/C++ cmake lanes | default |
|---|---|---|---|---|
| socket recv window | CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS | — (posix doesn’t serialize per-fd) | — | 100 ms |
| tx batching | CONFIG_NROS_ZENOH_TX_BATCH=y | NROS_ZENOH_TX_BATCH=1 | -DZPICO_TX_BATCH=1 via NROS_CMAKE_EXTRA_DEFS | off |
| flush cadence | CONFIG_NROS_ZENOH_TX_BATCH_FLUSH_MS | NROS_ZENOH_TX_BATCH_FLUSH_MS | -DZPICO_TX_BATCH_FLUSH_MS=<ms> | 50 ms |
| split tx locking | CONFIG_NROS_ZENOH_TX_SPLIT_LOCK=y | NROS_ZENOH_TX_SPLIT_LOCK=1 | -DZ_FEATURE_TX_SPLIT_LOCK=1 (shared config) | off |
| per-publisher express | tx_express in the QoS profile (all languages, below) | same | same | off |
On Zephyr the Kconfig column names the symbol the build ends up with; you
state it in system.toml, never in prj.conf: 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). A full ring keeps the newest samples and evicts older ones, counted bynros_rmw_zenoh::ring_full_drops_total()— build measurement sinks with e.g.NROS_ZENOH_SUBSCRIBER_RING_DEPTH=1024or 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).
The benchmarks behind these defaults live in the tree: see Internals → zpico-sys build architecture.
Transport buffers and limits
Beyond throughput, every transport has fixed-size buffers that are sized at build time. They are static memory, not heap: an embedded image has no runtime configuration, and a buffer that is too small shows up as a dropped or truncated message rather than as an allocation failure.
Most entity counts and receive slots are derived for each image from what its components declare, so you do not set them; which ones, and how the derivation works, is in Static Memory → Sizing the pools. The wire buffers in this section are not derived. They depend on your network and your largest message, so they stay yours.
Set a knob in one of three places, highest precedence first:
-
the environment of one build — a value you export wins over the build’s generated
[env]table, which never usesforce:NROS_ZENOH_FRAG_MAX_SIZE=1400 \ NROS_ZENOH_BATCH_UNICAST_SIZE=1024 \ nros build -
the image, in the workspace’s
system.toml, keyed as the knob registry keys it:[image.<id>.knobs.zenoh.wire] frag_max_size = 1400 batch_unicast_size = 1024 get_reply_buf_size = 1024 -
the board descriptor’s
[knobs], for a budget that belongs to the hardware and every image built for it.
nros config explain reports where each resolved value came from. The full
list of names and defaults is the
Environment Variables Reference.
Zenoh-pico wire buffers
These control fragmentation and reassembly. The defaults depend on the platform: POSIX hosts, NuttX (POSIX-compatible) and ThreadX (NetX Duo BSD sockets) get the large defaults; bare-metal, Zephyr and FreeRTOS get the small ones, and a serial-only image gets a 1,500-byte unicast batch.
| Variable | Image key ([image.<id>.knobs.zenoh.wire]) | POSIX-like | Embedded | Serial |
|---|---|---|---|---|
NROS_ZENOH_FRAG_MAX_SIZE | frag_max_size | 65535 | 2048 | 2048 |
NROS_ZENOH_BATCH_UNICAST_SIZE | batch_unicast_size | 65535 | 1024 | 1500 |
NROS_ZENOH_BATCH_MULTICAST_SIZE | batch_multicast_size | 8192 | 1024 | 1024 |
NROS_ZENOH_FRAG_MAX_SIZElimits the largest message your node can receive. A message larger than this is dropped silently.NROS_ZENOH_BATCH_UNICAST_SIZElimits the largest message your node can send without fragmentation; a larger one is split into fragments.- All batch sizes are capped at 65535 (the protocol’s 16-bit limit).
- On an MTU-constrained network (Ethernet 1500, Zephyr’s default of about 1400), keep the batch sizes below the network MTU to avoid IP fragmentation.
Zenoh-pico service buffers
| Variable | Default | What it holds |
|---|---|---|
NROS_ZENOH_SERVICE_BUFFER_SIZE | 1024 | per-service-server receive buffer |
NROS_ZENOH_GET_REPLY_BUF_SIZE | 4096 | service-client reply buffer |
NROS_ZENOH_GET_POLL_INTERVAL_MS | 10 | service-call polling interval (ms) |
NROS_ZENOH_MAX_PENDING_GETS | 4 | concurrent in-flight service calls; each holds one reply buffer |
Size a buffer to the CDR-serialized size of the largest message the entity receives. CDR adds alignment padding per field, typically 4–8 bytes; variable-length fields are often smaller on the wire than their in-memory bound. The per-subscriber receive slots are derived from the message types the image subscribes to; see Static Memory.
Zenoh session constants
These are fixed in the generated zenoh-pico configuration and have no knob:
| Constant | Value | Meaning |
|---|---|---|
Z_TRANSPORT_LEASE | 60,000 ms | transport lease; matches the lease the ROS 2 zenoh router announces |
Z_TRANSPORT_LEASE_EXPIRE_FACTOR | 3 | lease expiry multiplier |
A peer that dies with its socket closed is detected at once; the lease only bounds detection of a peer that vanishes without closing its connection.
smoltcp (bare metal)
A bare-metal image on the smoltcp TCP/IP stack sizes its socket pools at
build time. The defaults suit a brokered client (zenoh or XRCE), which
multiplexes everything over one session: one TCP and one UDP socket, or four
UDP sockets with the nros-smoltcp/rtps feature. The knobs are
NROS_SMOLTCP_MAX_SOCKETS, NROS_SMOLTCP_MAX_UDP_SOCKETS,
NROS_SMOLTCP_BUFFER_SIZE (per-socket staging buffer, 2048),
NROS_SMOLTCP_CONNECT_TIMEOUT_MS (30,000) and
NROS_SMOLTCP_SOCKET_TIMEOUT_MS (10,000), keyed net.* in an image’s or
board’s [knobs] ([image.<id>.knobs.net] max_sockets = 1).
XRCE-DDS
The XRCE backend’s transport buffers are set by NROS_XRCE_* knobs. The
plain XRCE_* names are the C #defines those knobs produce; exporting one
has no effect and reports no error.
NROS_XRCE_TRANSPORT_MTUis the UDP/TCP transport MTU (4096; 512 on Zephyr) and the default ofNROS_XRCE_CUSTOM_TRANSPORT_MTU.- The per-session stream buffers are
MTU × NROS_XRCE_STREAM_HISTORY. The history depth defaults to 16 and must be at least 4. NROS_XRCE_BUFFER_SIZE(1024) is the receive ceiling: a sample larger than it is refused on take, whatever buffer the subscription asked for.
The entity caps derive from the image’s declarations. The session timeouts (entity creation, service reply) are compile-time constants with no knob. Both are listed in the Environment Variables Reference.
When a transport limit is hit
| Symptom | Knob |
|---|---|
| Large messages never arrive | raise NROS_ZENOH_FRAG_MAX_SIZE: the message exceeds the reassembly limit |
| A subscription receives truncated data, or a large-type subscription fails to create | the receive slot is too small for the serialized message; see Static Memory for NROS_SUBSCRIBER_BUFFER_SIZE and the large class |
| Service calls time out with large replies | raise NROS_ZENOH_GET_REPLY_BUF_SIZE |
| Service-call latency is too high | lower NROS_ZENOH_GET_POLL_INTERVAL_MS, at a higher CPU cost |
| Several concurrent service calls fail | raise NROS_ZENOH_MAX_PENDING_GETS |
| An entity create fails at run time | a pool is full; the Static Pool Inventory names the knob that sizes it |
| smoltcp connect times out | raise NROS_SMOLTCP_CONNECT_TIMEOUT_MS on a slow network, and check the router is reachable at the image’s locator (NROS_LOCATOR on a native host) |
| XRCE entity creation times out | check NROS_XRCE_STREAM_HISTORY is at least 4, and that the agent is running and reachable |
Compared with Cyclone DDS on Zephyr
For reference, a published port of Autoware actuation to Zephyr with Cyclone DDS (actuation_porting) uses these transport settings:
| Cyclone DDS setting | Value | zenoh-pico counterpart | Notes |
|---|---|---|---|
ReceiveBufferSize | 16 KB | none | zenoh-pico uses per-entity buffers instead of a shared pool |
ReceiveBufferChunkSize | 2 KB | NROS_SUBSCRIBER_BUFFER_SIZE | per entity in zenoh-pico, shared chunks in Cyclone DDS |
MaxMessageSize | 1400 B | NROS_ZENOH_BATCH_UNICAST_SIZE | an MTU-aware limit to avoid IP fragmentation |
AllowMulticast | SPDP only | the link-udp-multicast feature | off by default in both |
That port sizes Zephyr’s network stack for Cyclone DDS’s dynamic allocation
(CONFIG_NET_PKT_RX_COUNT=32, CONFIG_NET_PKT_TX_COUNT=32,
CONFIG_NET_BUF_RX_COUNT=256, CONFIG_NET_BUF_TX_COUNT=256,
CONFIG_NET_BUF_DATA_SIZE=1600, CONFIG_NET_MAX_CONN=200) and gives Cyclone
DDS a 1 MB heap. The zenoh-pico wire and entity buffers on this page are sized
at build time instead. The zenoh-pico session’s own working set still comes
from a heap; on bare metal that heap is sized by NROS_HEAP_SIZE (see
Configuration).
Design background
- RFC-0049: hierarchical platform and board config
- RFC-0103: one owner per fact — the
NROS_<ID>knob spelling and the knob registry
RTOS Cooperation
nano-ros runs on platforms that span pure cooperative bare-metal through multi-task preemptive RTOS. The executor’s spin loop has to cooperate with each of these without imposing a single execution model on all of them.
Every shape on this page keeps nano-ros’s real-time promise: nano-ros decides which callback runs and at what priority. A loop owned by a foreign async runtime or event loop (tokio, Embassy, Qt, GLib) cannot keep it, so it is not supported.
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
| Model | Description | Example targets |
|---|---|---|
| Cooperative single-task | One 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 priority | ROS 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-time | Each “task” has a provable worst-case execution time. Tasks are dispatched directly from interrupts; no spin loops in the hot path. | Avionics WCET-validated code |
| Time-triggered cyclic | Fixed 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 |
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, which want throughput and do not benefit 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:
| Knob | Default | When 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_next | off | One 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.
Per-model recommended configuration
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.
Preemptive priority RTOS — recommended
#![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
Don’t hand-write the spin loop. Give each callback that needs its own bound a
tier of its own in the launch contracts: nros::main! runs every tier as a
task at the priority nano-ros derives for it. WCET analysis then applies to
each tier’s callbacks, not to one spin loop that serves them all.
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.)
Trade-offs at a glance
| Configuration | Throughput | Per-callback latency | Timer-callback fairness | Code-size cost |
|---|---|---|---|---|
| drain-all (default) | High | Bounded by ALL ready callbacks’ total WCET | Poor under load | Smallest |
max_callbacks(1) | Slightly lower (more spin loop iterations) | Bounded by ONE callback’s WCET | Good | Same — the cap is just an integer |
timeout_ms(N) per cycle | Lower | Bounded by N ms wall clock + one callback’s WCET | Good if N tight | One clock read per iteration |
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:
| Platform | Sleep primitive in drive_io | When CPU is sleeping |
|---|---|---|
| POSIX | select / epoll_wait with deadline | Inside drive_io |
| Zephyr | k_poll / condvar with deadline | Inside drive_io |
| FreeRTOS | xSemaphoreTake(g_spin_sem, ticks) | Inside drive_io |
| NuttX | sem_timedwait with absolute deadline | Inside drive_io |
| ThreadX | tx_event_flags_get(..., TX_OR, ..., ticks) | Inside drive_io |
Bare-metal smoltcp + BoardIdle | smoltcp poll + wfi() between iterations | Outside drive_io (in the spin loop’s idle hook) |
Bare-metal smoltcp without BoardIdle | smoltcp poll, busy loop | Nowhere — CPU spins |
In all cases the user-visible API is Executor::spin_once(timeout);
the platform-correct sleep happens transparently underneath.
See also
- RMW API Design — the architectural reasons the runtime / RMW boundary is shaped the way it is.
- RMW API: Differences from upstream
rmw.hSection 4 — thedrive_iovsrmw_waitcomparison this page expands on. - no_std Support — heap and threading constraints that shape the cooperative model.
Design background
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
| Type | Guarantee | Consequence of missed deadline |
|---|---|---|
| Hard | Deadline must never be missed | System failure (safety hazard) |
| Soft | Deadline should rarely be missed | Degraded quality (dropped frame, late message) |
nano-ros targets soft real-time by default (bounded message latency, bounded memory). Hard real-time is achievable 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).
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: 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
FreeRTOS
The most widely deployed RTOS. Uses fixed-priority preemptive scheduling with optional round-robin time-slicing for same-priority tasks.
| Property | Value |
|---|---|
| Model | Fixed-priority preemptive (FPP) |
| Preemption | Yes — configUSE_PREEMPTION=1 (default) |
| Priority levels | configMAX_PRIORITIES (typically 8–32) |
| Priority direction | Higher number = higher priority |
| Context switch | ~80 cycles on Cortex-M (PendSV handler) |
| Mutual exclusion | Mutexes with optional priority inheritance |
| Time slicing | Optional — 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:
| Task | Default Priority | Stack | Role |
|---|---|---|---|
| nros_app | 3 (Normal) | 64 KB | Executor, callbacks, spin |
| net_poll | 4 (AboveNormal) | 1 KB | Poll LAN9118 RX FIFO |
| zenoh read | 4 (AboveNormal) | 5 KB | Socket read, message decode |
| zenoh lease | 4 (AboveNormal) | 5 KB | Keep-alive, lease monitor |
| tcpip_thread | 4 (AboveNormal) | 4 KB | lwIP protocol processing |
ThreadX (Azure RTOS)
ThreadX is designed for deeply embedded systems with a unique preemption-threshold feature not found in other RTOSes.
| Property | Value |
|---|---|
| Model | Fixed-priority preemptive (FPP) with preemption-threshold |
| Preemption | Yes — with configurable threshold per thread |
| Priority levels | 32 (0–31) |
| Priority direction | Lower number = higher priority |
| Context switch | ~60 cycles (optimized assembly for each architecture) |
| Mutual exclusion | Mutexes with priority inheritance |
| Time slicing | Per-thread configurable (time_slice parameter) |
The preemption-threshold is ThreadX’s distinguishing feature. Each thread has two priority values:
- Priority: determines scheduling order (which thread runs next)
- 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 — 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.
| Property | Value |
|---|---|
| Model | POSIX SCHED_FIFO (FPP), SCHED_RR, or SCHED_SPORADIC |
| Preemption | Yes (FIFO/RR), configurable |
| Priority levels | 1–255 (POSIX sched_param.sched_priority) |
| Priority direction | Higher number = higher priority |
| Context switch | Kernel-managed, architecture-dependent |
| Mutual exclusion | POSIX mutexes with PTHREAD_PRIO_INHERIT or PTHREAD_PRIO_PROTECT |
| Scheduling policy | Per-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):
| Protocol | How it works | Trade-off |
|---|---|---|
| Priority inheritance | Holder inherits waiter’s priority | Dynamic — may chain through multiple locks |
| Priority ceiling | Mutex has fixed ceiling priority; holder runs at ceiling | Static — 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.
| Property | Value |
|---|---|
| Model | Cooperative + preemptive + optional EDF |
| Preemption | Configurable per-thread |
| Priority levels | Cooperative: K_PRIO_COOP(0) to K_PRIO_COOP(N) (negative values); Preemptive: K_PRIO_PREEMPT(0) to K_PRIO_PREEMPT(N) (positive values) |
| Priority direction | Lower number = higher priority |
| Context switch | Architecture-dependent (~100 cycles on Cortex-M) |
| Mutual exclusion | k_mutex with priority inheritance |
| Meta-IRQ | Ultra-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
| Solution | Approach | Platforms |
|---|---|---|
| Priority inheritance | Mutex holder inherits highest waiter’s priority | FreeRTOS, ThreadX, NuttX, Zephyr |
| Priority ceiling | Mutex has fixed ceiling; holder runs at ceiling | NuttX (PTHREAD_PRIO_PROTECT) |
| Preemption-threshold | Limit which tasks can preempt | ThreadX |
| Lock-free design | Avoid shared resources entirely | nano-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
| Criterion | FreeRTOS | ThreadX | NuttX | Zephyr |
|---|---|---|---|---|
| Determinism | Good (FPP) | Good (FPP+threshold) | Good (POSIX FPP) | Good (FPP+coop+EDF) |
| Worst-case latency | ~80 cycles | ~60 cycles | Kernel-dependent | ~100 cycles |
| Priority inversion | Inheritance | Inheritance + threshold | Inheritance + ceiling | Inheritance |
| Analysis tools | RMA/RTA | RMA/RTA + threshold | POSIX standard RMA | RMA + EDF analysis |
| Flexibility | Medium | Medium-High | High (POSIX) | Highest |
| RAM overhead | Low | Low (threshold reduces stacks) | Medium (kernel) | Medium (kernel) |
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.
- 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.
Design background
- RFC-0053: ThreadX multi-tier execution
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. Example variants as node packages with their own image rows.
The Real-Time Execution Model
This chapter is about one question: when does a nano-ros callback meet its deadline, and what does the runtime do to make it? The design records are listed under Design background at the end of this page. Scheduling Models covers the RTOS schedulers underneath.
One level does not preempt
An executor runs a callback to completion. Within one OS thread nothing can interrupt user code mid-callback, so a callback that becomes ready while another runs waits for it to return. Its worst-case blocking is the longest callback that can be running when it wakes:
response time >= own execution time + longest other callback on the thread
A 5 ms telemetry callback on the same thread as a 1 kHz control loop makes that loop miss its 1 ms deadline every time the two meet, whatever priority bucket or scheduling class either has. The buckets and classes below change which READY callback runs next. They cannot stop one that is already running.
Scheduling classes
A callback runs in a scheduling context (SchedContext), and its class picks
how the executor orders ready work:
| Class | Orders ready work by | Use it for |
|---|---|---|
Fifo (default) | registration order within a priority bucket | most callbacks |
Edf | absolute deadline (release + relative deadline) | periodic work with stated deadlines on one level |
Sporadic | Fifo, plus a budget per replenishment period | a callback whose CPU share must be capped |
BestEffort | last, after every other bucket | logging, telemetry |
A tier or level’s class = "real_time" is the deployment-side spelling. On
Linux it also selects SCHED_DEADLINE for a level that states a budget (see
below). Time-triggered windows are a gate on a context
(tt_window_offset_us / tt_window_duration_us), not a class.
Levels: preemption from the kernel
The fix for the bound above is a second thread. nano-ros places callbacks on executor levels: one OS thread per level, all over one executor per core. A more urgent level’s thread has a higher kernel priority, so the kernel preempts a less urgent level mid-callback when the urgent one wakes.
- The number of levels is derived. Each node’s rank comes from its
contract (
paths.<p>.trigger.timer.rate_hz, rate-monotonic), and each distinct rank is one level, up to the image’smax_levels(CONFIG_NROS_EXECUTOR_MAX_LEVELS, default 8). - Priorities are allocated, not authored. A derived level takes a
priority from the board’s application band, below the transport tasks.
An authored
[tasks.<name>]bound withgroup_tasksis a pin: its members share one level at its stated priority. - The boot level is the least urgent one; it runs on
main()and owns the session. The others are spawned. - On a multi-core host, levels run in parallel. Preemption only shows when
they compete for one CPU, which is why the Linux tests pin the process with
taskset -c 0.
Who enforces a budget
A level whose tier states a budget and a period has exactly one enforcer. The kernel takes it where the kernel can; otherwise the executor enforces the same numbers at callback boundaries. The runner prints which:
nros: level budget tier=`capped` 2000us/10000us enforced by the KERNEL
| Platform | Kernel mechanism | When the kernel enforces it |
|---|---|---|
| Linux | SCHED_DEADLINE (runtime, deadline, period) | a spawned level with class = "real_time", when the process holds CAP_SYS_NICE and the level is not pinned to fewer CPUs than its root domain |
| NuttX | SCHED_SPORADIC (CONFIG_SCHED_SPORADIC) | every budgeted level thread on the C runner; spawned levels on the Rust runner |
| Zephyr, FreeRTOS, ThreadX | none | never: the executor enforces it |
Without CAP_SYS_NICE a Linux image keeps running. The kernel refuses both
SCHED_FIFO and SCHED_DEADLINE, the image prints the refusal once with the
setcap command that fixes it, and every level runs at the default policy.
That image has levels but no preemption between them.
Scenarios and what measured them
The execution-model design names twelve scenarios. Each row names the test that measured it, or says that none has.
| Scenario | Status |
|---|---|
| S1, drone flight controller: 1 kHz critical loop beside 5 ms telemetry | Linux, on levels: rt_sched_levels_e2e::s1_critical_loop_meets_every_deadline_on_its_own_level (60 s, zero misses on a PREEMPT_RT kernel and the host’s own floor on a generic one, plus a one-level negative control). It runs only on a lane that claims rt-sched. |
| S2, sense, plan and act at 10 Hz on one thread | No dedicated test. |
| S3, watchdog liveness | Levels feed the watchdog only when every level is alive. Measured on the S32K344 safety island. |
| S4, Autoware-style mixed rates on Linux | No dedicated test. |
| S5, Cortex-M0+ sensor node | No dedicated test. |
S6, Trigger::AllOf fusion | trigger_conditions (nros-tests) and the executor’s unit tests. |
| S7, a 1 kHz subscription drained once a cycle | Idempotent ready-set insertion, unit-tested in nros-node. |
| S8, a long-running action goal | The action server is a cooperative state machine; covered by the action e2e tests, not by a timing test. |
| S9, a service handler that triggers a callback | No dedicated test. |
| S10, cross-level priority inversion | Priority-inheritance mutexes in the POSIX port (c_port_posix). No scenario test. |
| S11, several nodes on one executor | The default deployment; every multi-node example runs it. |
| S12, a cycle wall-time budget | Not built: there is no cycle budget option. |
Running the real-time tests in the tree: see Internals → Running the RT tests.
Design background
- RFC-0002: real-time execution model — the executor’s scheduling contexts and the twelve scenarios (§11)
- RFC-0108: executor preemption levels — levels and the watchdog rule (D8)
- RFC-0079: priority is allocated, not authored
Dispatch Strategy
Design rationale. This chapter explains why
DispatchStrategyis 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. Embassy and other foreign async runtimes are not supported (RTOS Cooperation says why).
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 (a Node that does not name a strategy keeps working unchanged). Served by every runtime: POSIX, FreeRTOS, NuttX, Zephyr, ThreadX and bare-metal. -
Deferred— Callbacks land in a queue, and a dispatch task drains it and drivesExecutableNode::on_callbackfrom its own task context, decoupling callback latency from network polling. The entrynros::main!generates for a supported board has no such task: each tier’s loop is the only task that runs its callbacks, so aDeferredcomponent is registered inline there (a queue nothing drains would lose callbacks). The node still has to use the tag API below, so it builds unchanged for an entry that does drain the queue. -
FromIsr— Callbacks fire directly from an ISR handler. Design slot only — not implemented yet. Reserved so the lint matrix has a stable discriminant to reject against; current builds error out at thenros checklayer.
Framework × Strategy matrix
The matrix nros check enforces:
┌──────────────────────────────────────────────────────┐
│ DispatchStrategy │
│ Inline │ Deferred │ FromIsr │
┌─────────────┼──────────────┼─────────────────┼─────────────────────┤
│ posix │ OK │ OK │ ERR: no ISRs │
│ rtos │ OK │ OK │ ERR: no ISRs │
│ bare-metal │ OK │ OK │ ERR: no ISRs │
└─────────────┴──────────────┴─────────────────┴─────────────────────┘
- Inline and Deferred: both accepted on every supported board. Inline is the default because the executor’s spin loop is the natural place to dispatch callbacks when the entry owns that loop.
FromIsr: rejected — the entries nano-ros generates dispatch from a task or loop, never from an ISR handler, so there is no ISR context to dispatch from. It stays rejected until the reentrancy audit, the SPSC rework and the per-Node#[isr_safe]contract described below exist.
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:
- 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. - Implementation simplicity. The dispatch task drains a single
queue and routes each entry by
CallbackIdto a single Node’son_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 — per-callback strategies can be reconsidered. 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 = 2are wire-frozen — the__nros_node_<pkg>_dispatch_strategy()ABI symbol returns au8thatnros checkreads without linking the Node crate. AddingFromIsrlater 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 checkmatrix would have to special-case “user wroteFromIsrbut 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::Queueis 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_callbackmust 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 executor’s atomic_waker cross-task notification is the building
block for the SPSC variant. 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() {
// Closure form — 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
'staticcapture —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 toextern "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 deferred dispatch uses 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: 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 existing Node pkgs (all default to Inline)
and a forced-correct API for the Deferred path.
Deferring callbacks without the macros
Node::DISPATCH belongs to nros::node! nodes. The queue under it is the
executor’s own, with three calls and no macro-private type. A loop that drains
it must be a task whose priority nano-ros owns, such as a tier’s; a foreign
host’s loop would decide when the callbacks run, which nano-ros does not
support:
#![allow(unused)]
fn main() {
use nros::{Executor, HandleId};
// 1. Mark the callbacks the host wants to run in its own task(s).
let sub: HandleId = node.create_subscription::<Int32, _>("/chatter", on_chatter)?;
executor.set_deferred(sub, true);
// 2. Learn when one is queued. A plain fn and a context pointer; it runs on
// the spinning thread, inside the pass, and must not block.
fn on_queued(ctx: *mut core::ffi::c_void, handle: HandleId) {
let _ = (ctx, handle); // pend / wake the task that owns `handle`
}
executor.set_defer_notifier(on_queued, core::ptr::null_mut());
// 3. In the spin task: spin as usual. A ready deferred callback is QUEUED
// and notified, never run here.
executor.spin_once(core::time::Duration::from_millis(10));
// In the host's task: run what is queued, all of it or one callback.
executor.run_deferred(); // every queued callback, slot order
executor.run_deferred_handle(sub); // just this one (per-priority tasks)
}
The rules:
- The queue is one bit per callback slot, so it allocates nothing. A callback that is already queued is not queued or notified again; a drain runs it once, and that dispatch takes what an inline dispatch would have. Data left behind is found by the next spin, which queues it again.
- A deferred callback takes no part in the spin’s
Trigger. - Timers cannot be deferred (
set_deferredreturnsfalse): a timer’s readiness is decided inside its own dispatch. - The defer notifier fires during the spin and says which callback to run.
- The drain needs
&mut Executor, likespin_once. On a single task that is the same task; across tasks, the executor is the shared resource already guarded for spinning.
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).
#[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:
-
nros check. Statically inspects the Node crate’s.rmetaor links the staticlib + reads the symbol viadlsym/GetProcAddress(host-side check; embedded targets only read it from.rmeta). Compares against the Entry pkg’s boardframeworkmetadata using the matrix above; rejects mismatches atnros checktime, before the user runscargo build. -
The
nros::main!()proc-macro. When expanding the Entry pkg’smain.rsit walks the registered Node list and reads each pkg’s strategy. An entry with a dispatch task adds it only when some Node is Deferred, so Inline-only workspaces pay nothing for it; the entries generated for the supported boards have none and register every Node inline. -
Future runtime diagnostic tools. A
nros doctorornros topologystyle 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 board-side dispatch trait
The board-side dispatch sink is NodeDispatchRuntime (the user-facing
sink is NodeRuntime, in packages/api/nros/src/node.rs). It carries
the two dispatch methods:
#![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() {
// A sketch: a board runtime with a dispatch task of its own.
impl NodeDispatchRuntime for DeferredRuntime {
fn dispatch_strategy(&self) -> DispatchStrategy {
DispatchStrategy::Deferred
}
fn signal_callback(&mut self, cb_id: CallbackId<'_>, ctx: &mut CallbackCtx<'_>) {
// Single producer: only the spin task enqueues.
self.queue_producer.enqueue((cb_id, ctx.snapshot())).unwrap();
// Wake the dispatch task; it runs at its own declared priority.
self.wake_dispatch_task();
}
}
}
signal_callback’s default panic is the right behavior: the Inline
path never calls it (callbacks flow through the existing inline
trampoline). If a callback is signalled to an Inline runtime anyway —
for example from a hand-written entry — the panic is louder than
silently dropping the callback. A generated entry never reaches it:
nros::main! registers a Deferred Node inline when the runtime has no
dispatch task, so the panic is a belt-and-braces backstop.
Backward compatibility
Two contracts:
- Defaulted associated const.
Node::DISPATCHisconst DISPATCH: DispatchStrategy = DispatchStrategy::Inline;in the trait definition. Edition 2024 supports defaulted associated consts as stable, so everyimpl Component for ...block that doesn’t mentionDISPATCHcontinues to compile and is treated asInline. - 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 whenDISPATCH = Deferred. Every Node pkg using closures stays valid without changes.
The migration shape for an Inline Node that wants to move to Deferred is:
#![allow(unused)]
fn main() {
// Before — Inline (the 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 — 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
- RTOS Cooperation — the supported execution shapes, and why Embassy is not one.
- Scheduling Models — the real-time scheduling backdrop against which dispatch strategy choices are made.
packages/platform/nros-platform/src/board/dispatch.rs— theDispatchStrategyenum.packages/api/nros/src/dispatch_tag.rs— the tag types.- Changing this in the tree: see Internals → Dispatch ABI history.
Design background
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-Pattern | Problem | Detection Method |
|---|---|---|
| Unbounded loops | Infinite execution time | Clippy + custom lints |
| Recursion | Stack overflow, unbounded depth | cargo-call-stack |
| Heap allocation | Non-deterministic timing, fragmentation | no_std + forbid patterns |
| Blocking I/O | Unbounded wait times | Custom lints |
| Missing timeouts | Operations can hang forever | Custom lints |
| Large stack frames | Stack overflow | cargo-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
| Lint | Detects |
|---|---|
infinite_iter | Iterator chains guaranteed to be infinite |
while_immutable_condition | Loop conditions that can never change |
never_loop | Loops that exit on first iteration |
empty_loop | loop { } 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
Recommended Clippy Configuration
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 (from your firmware crate's directory)
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_stdprograms
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)?;
}
Custom Lint Approach
A Dylint lint can flag an I/O call that has no timeout configured before it in the same scope:
#![allow(unused)]
fn main() {
// Conceptual lint logic (simplified)
fn check_io_without_timeout(expr: &Expr) {
if is_io_operation(expr) && !has_timeout_in_scope(expr) {
emit_warning("I/O operation without timeout may block indefinitely");
}
}
}
Manual Audit Checklist
For code review, check that these operations have timeouts:
TcpStream::connect()– useconnect_timeout()socket.read()/socket.write()– set socket timeoutchannel.recv()– userecv_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;
}
}
}
Custom Lint for Loop Bounds
Clippy cannot prove a loop terminates. A custom Dylint lint can check that:
- every
loopblock has a reachablebreakorreturn; - every
whilecondition involves a variable that the loop body modifies; - a loop inside a task function has an explicit iteration limit.
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
Example: Detect Blocking in Async
A lint is a late lint pass over the compiler’s typed AST. The skeleton below
shows the shape; the two checks in check_expr are the part you write:
#![allow(unused)]
fn main() {
// src/lib.rs
use clippy_utils::diagnostics::span_lint;
use rustc_lint::{LateContext, LateLintPass};
use rustc_session::declare_lint_pass;
declare_lint! {
pub BLOCKING_IN_ASYNC,
Warn,
"blocking operations in async context"
}
declare_lint_pass!(BlockingInAsync => [BLOCKING_IN_ASYNC]);
impl<'tcx> LateLintPass<'tcx> for BlockingInAsync {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
// Check if we're in an async function
// Check if expr is a blocking call (std::thread::sleep, std::fs::*, etc.)
// Emit lint if both conditions are true
}
}
}
Recommended Custom Lints for Real-Time
| Lint | Purpose |
|---|---|
blocking_in_async | Detect std::thread::sleep, blocking I/O in async |
unbounded_loop_in_task | Flag loops without bounds in task functions |
heap_in_interrupt | Detect allocation in #[interrupt] handlers |
mutex_in_interrupt | Flag std::sync::Mutex in interrupt context |
float_in_isr | Warn about FP operations in ISRs (soft-float targets) |
missing_timeout | I/O operations without timeout |
Running the Checks in CI
The checks above run unattended. A GitHub Actions workflow for your own
project, with the firmware crate in firmware/ and the host-testable crates
named my_core and my_msgs (substitute your own):
name: Real-Time Static Analysis
on: [push, pull_request]
jobs:
clippy-realtime:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Clippy with RT lints
run: |
cargo clippy --all-targets -- \
-D warnings \
-D clippy::infinite_iter \
-D clippy::while_immutable_condition \
-D clippy::unconditional_recursion \
-W clippy::large_stack_arrays
stack-analysis:
runs-on: ubuntu-latest
defaults:
run:
working-directory: firmware
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
- name: Install cargo-call-stack
run: cargo +nightly install cargo-call-stack
- name: Analyze the firmware
run: |
RUSTFLAGS="-Z emit-stack-sizes" cargo +nightly build --release
cargo +nightly call-stack --release > stack_analysis.txt
cat stack_analysis.txt
- name: Check for recursion
run: |
if grep -q "cycle" stack_analysis.txt; then
echo "ERROR: Recursion detected in call graph"
exit 1
fi
miri-safety:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- name: Run Miri
run: cargo +nightly miri test -p my_core -p my_msgs
Miri runs host tests, so point it at the crates whose logic does not depend on the target: serialization, state machines, protocol code.
Tool Summary
| Tool | Detects | Effort | CI-Ready |
|---|---|---|---|
| Clippy | Infinite iters, recursion, large arrays | Low | Yes |
| cargo-call-stack | Stack usage, recursion cycles | Medium | Yes |
| Miri | Undefined behavior, memory errors | Low | Yes |
| Dylint | Custom patterns (blocking, heap, etc.) | High | Yes |
| MIRAI | Abstract interpretation, all paths | High | Experimental |
| no_std | Prevents std heap allocation | Low | Automatic |
| heapless | Static collections | Low | N/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
| Property | Value |
|---|---|
| Target | ARM Cortex-M3 (thumbv7m-none-eabi) |
| Machine | QEMU lm3s6965evb (for infrastructure validation) |
| Clock | DWT CYCCNT (cycle-accurate on real hardware) |
| Iterations | 100 per benchmark (10 warmup) |
| Optimization | release 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:
| Function | Notes |
|---|---|
serialize Int32 | Single i32 field |
deserialize Int32 | Single i32 field |
serialize Time | Two fields (i32 + u32) |
roundtrip Int32 | Serialize + deserialize |
| serialize w/ header | CDR encapsulation header + Int32 |
Node API:
| Function | Notes |
|---|---|
Node::new() | standalone node creation (nros_node::StandaloneNode) |
create_publisher() | Register Int32 publisher |
serialize_message() | Node-level serialize to buffer |
Safety E2E:
| Function | Notes |
|---|---|
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):
- 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 harness in the tree: see Internals → Running the RT tests.
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:
| Tool | Type | License | Notes |
|---|---|---|---|
| Platin | Static (IPET) | Open source | Analyzes LLVM IR + machine code |
| aiT | Static (abstract interpretation) | Commercial (AbsInt) | Industry standard for DO-178C / ISO 26262 |
| SWEET | Static (flow analysis) | Academic | Research tool; good for Cortex-M |
| Chronos | Static (IPET) | Open source | Academic; 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
- Clippy Lints Reference
- cargo-call-stack
- Dylint - Custom Lints
- MIRAI Abstract Interpreter
- heapless Crate
- Embedded Rust Book
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 with the memory report, which reads the linked ELF. The report ships in the nano-ros source tree, not in a release; running it: see Internals → The memory report.
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 storageNode::GlobalStorageHolder<0>::storage, and the RustEXECUTOR_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_Nfor C++,__NROS_COMPONENT_<pkg>_SLOT_STOREfor 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. Turning the corresponding knobs down is the current remedy (see Sizing the pools for which knobs the build already derives).
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:
| symbol | holds | one ring is | knobs |
|---|---|---|---|
nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX | your services | depth × (slot + 12 B) | NROS_SERVICE_INBOX_BYTES, NROS_SERVICE_INBOX_DEPTH (4) |
nros_rmw_zenoh::shim::service::ACTION_INBOX | the four queryables of each action server | depth × (slot + 12 B) | NROS_ACTION_INBOX_BYTES, NROS_ACTION_INBOX_DEPTH (4) |
nros_rmw_zenoh::shim::service::BUILTIN_INBOX | the runtime’s own parameter and lifecycle services | depth × (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_parametersrequest 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 the memory 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 (the report’s --json output), change something, rebuild,
and compare against it (--baseline; the commands are on the
Internals page).
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.
Sizing the pools
Before you turn a knob down, know which pools you size and which the build
sizes for you. The pools that dominate static RAM are derived per image
from what the image’s components declare, and the build writes the answers
into its generated cargo [env] table (on Zephyr, into the Kconfig defaults).
Stating one of them by hand is an override, not a requirement.
| Knob | Derived from |
|---|---|
NROS_ZENOH_MAX_PUBLISHERS, NROS_ZENOH_MAX_SUBSCRIBERS | the declared entity inventory, floored at 1: each sizes a fixed C array, where zero is not a smaller pool |
NROS_ZENOH_MAX_QUERYABLES | the declared service servers, plus the parameter and lifecycle services the image’s nodes register; with no readable declaration it stays at its undeclared budget (8 embedded, 32 hosted) |
NROS_ZENOH_MAX_LIVELINESS | one per node name plus the session’s own, one per publisher, subscriber, service server and client, and the parameter and lifecycle families’ entities |
NROS_EXECUTOR_MAX_CBS, NROS_EXECUTOR_ACTION_CLIENTS, NROS_RMW_SUBSCRIBER_SLOTS | the declared entity inventory |
NROS_SUBSCRIBER_BUFFER_SIZE | the largest small-class message type the image subscribes to |
NROS_ZENOH_SUBSCRIBER_LARGE_SIZE | the largest large-class type; stated only when the large count is non-zero |
NROS_ZENOH_MAX_LARGE_SUBSCRIBERS | how many subscribed types fall in the large class. Zero is an answer, not a floor: the pool becomes zero bytes |
NROS_SUBSCRIPTION_BUFFER_SIZE | the executor’s take buffer, from the same message bounds |
NROS_EXECUTOR_ARENA_SIZE | the same inventory, in the executor’s own build; the variable is the override |
The transport’s wire buffers (NROS_ZENOH_FRAG_MAX_SIZE, the batch sizes,
NROS_ZENOH_SERVICE_BUFFER_SIZE, NROS_ZENOH_GET_REPLY_BUF_SIZE,
NROS_ZENOH_MAX_PENDING_GETS), the smoltcp socket pools and the parameter
store limits are not derived: they are yours. They are covered in
TX Throughput & Latency Tuning.
Precedence. A value you state wins over a derived one: an exported
environment variable first, then the image’s [image.<id>.knobs], then the
board descriptor’s [knobs]. The derived value comes next, and the crate’s
own default applies only when nothing is stated and nothing can be derived.
nros config explain shows which rung answered for each knob.
Two properties of the message-bound sizes (NROS_SUBSCRIBER_BUFFER_SIZE, the
large class, NROS_SUBSCRIPTION_BUFFER_SIZE) are worth knowing:
- The derived number is an upper bound. The bounds come from every type in
the interface closure the image links, not only the types it subscribes to,
so the size is never too small but can be larger than you need. The build
names the type that set it. When that type is not one of yours, state your
own value or trim the package’s
<depend>list. - One unbounded type refuses the whole derivation. Nothing is derived, the
stated or default values stand, and the build names every offending type
and the member that makes it unbounded. Bound the field in the
.msg(string<=64) or cap itinlineinnros-codegen.toml; one cap on a declaring type such as"std_msgs/Header.frame_id"reaches every message that nests it.
Every slot of a fixed pool costs its bytes whether or not an entity uses it, so an over-provisioned count is wasted RAM and an under-provisioned one is a create that fails at run time. The Static Pool Inventory gives each pool’s cost at its defaults; the memory report above gives what your image actually reserved.
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.
E2E Safety Protocol
The
safety-e2efeature described in this document has been implemented. It is available innros-rmw,nros-node, and the top-levelnroscrate.
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:
| Offset | Size | Field | Purpose |
|---|---|---|---|
| 0-7 | 8 | sequence_number (i64 LE) | Monotonically increasing per publisher |
| 8-15 | 8 | timestamp (i64 LE, nanos) | Publication time |
| 16 | 1 | VLE length (always 16) | GID size prefix |
| 17-32 | 16 | rmw_gid | Random 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
- Sequence numbers exist but are only checked by subscribers when
safety-e2eis enabled. - Timestamps exist but freshness validation requires a clock source (deferred).
- Publisher GID exists but source authentication requires a registration mechanism.
- CRC is added by the
safety-e2efeature. It is computed over the CDR payload and appended to the attachment. - 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:
| Threat | EN 50159 Defense | Integration Approach |
|---|---|---|
| Corruption | CRC | CRC-32 covering CDR payload, stored in extended attachment |
| Repetition | Sequence number | Subscriber tracks expected sequence, flags duplicates |
| Deletion | Seq + timeout | Subscriber detects sequence gaps |
| Insertion | Seq + auth | Sequence validation rejects unexpected messages |
| Resequencing | Sequence number | Subscriber validates monotonic sequence |
| Delay | Timestamp/timeout | Subscriber compares message timestamp to current time |
| Masquerade | Authentication | Subscriber 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-e2ereads 33 bytes – succeeds normally - nano-ros with
safety-e2ereads 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_seqtomessage_seq + 1 - Contiguous:
message_seq == expected_seq– normal delivery - Duplicate:
message_seq < expected_seq– flagged inIntegrityStatus - Gap:
message_seq > expected_seq– gap count reported inIntegrityStatus
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):
safetymodule: CRC-32 function,IntegrityStatustype,SafetyValidatorstate trackerShimPublisher::publish_raw(): computes CRC and extends attachment to 37 bytesSubscriberBuffer: attachment buffer sized to 37 bytesShimSubscriber:SafetyValidatorfield,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
| Component | Without safety-e2e | With safety-e2e |
|---|---|---|
| CRC-32 lookup table | 0 | +1024 bytes (.rodata) |
| Subscriber attachment buffers (8x) | 8 x 33 = 264 bytes | 8 x 37 = 296 bytes (+32) |
| SafetyValidator per subscriber (8x) | 0 | 8 x ~24 = 192 bytes |
| Total | – | +1248 bytes |
ROS 2 Interoperability
| Sender | Receiver | CRC | Sequence | Result |
|---|---|---|---|---|
| nano-ros (safety) | nano-ros (safety) | Validated | Tracked | Full E2E protection |
| nano-ros (safety) | nano-ros (no safety) | Ignored | Not tracked | Works, no protection |
| nano-ros (safety) | ROS 2 (rmw_zenoh) | Ignored | Not tracked | Works, no protection |
| nano-ros (no safety) | nano-ros (safety) | None | Tracked | Partial (seq only) |
| ROS 2 (rmw_zenoh) | nano-ros (safety) | None | Tracked | Partial (seq only) |
No interoperability is broken. Safety degrades gracefully when one side doesn’t support it.
AUTOSAR E2E Comparison
| Feature | AUTOSAR E2E P01 | AUTOSAR E2E P02 | nano-ros E2E |
|---|---|---|---|
| CRC | CRC-8 (8-bit) | CRC-8 (8-bit) | CRC-32 (32-bit, stronger) |
| Counter | 4-bit (0-14) | 4-bit (0-14) | 64-bit (i64, no rollover in practice) |
| Data ID | 16-bit | 16-bit | 128-bit GID (stronger) |
| Alive counter | Yes | Yes | Yes (via sequence) |
| Timeout | Configurable | Configurable | Deferred (needs clock abstraction) |
| State machine | E2E_SM | Same | Simpler (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
MonotonicClocktrait and platform implementations. The existingtimestampfield 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-m3bench atpackages/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
mallinfoor 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_nsmust 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
stdPOSIX. 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.8.0 (wire-compatible with
rmw_zenoh_cppacross zenoh 1.x). Cyclone DDS 0.10.5 (matchesros-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
- Kani harnesses clean for the nano-ros release you ship. 148 bounded harnesses; non-trivial coverage of CDR + scheduling + RMW glue.
- Verus proofs clean for that release. 83 deductive proofs. Running both in the tree: see Internals → Formal Verification.
- CRC32 attached if using
safety-e2efeature. 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_oncetimeout,Promise::wait_for(timeout),recv_timeout. NoWAIT_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, NuttXCONFIG_DEBUG_STACK).
5. Interop testing
- Publish from nano-ros, subscribe with stock ROS 2.
RMW_IMPLEMENTATION=rmw_zenoh_cppfor Zenoh,rmw_cyclonedds_cppfor Cyclone,rmw_fastrtps_cppfor DDS (interop tier). - Message type compatibility for any custom
.msgyou’ve added. Round-trip a sample message through ROS 2’srosbag2to 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) orMicroXRCEAgent(XRCE) mid-run. Confirm reconnection. For DDS / Cyclone this is N/A (no central process). - Network partition → reconnection. Block the talker’s
egress with
iptablesfor 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-logprovides the logging surface; pick a sink (UART, RTT, semihosting, or ROS 2/rosoutover the wire — the last is 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_periodreports 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 -lover the last 90 days). Re-check quarterly. - Escalation path: identify the primary maintainer
contact (from
Cargo.tomlauthors+ 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 section | Status |
|---|---|
| All boxes | Production-ready for that axis |
| Missing one | Pilot deployment OK; close the gap before scale |
| Missing two | Lab / 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
- Real-Time Analysis — RT scheduling background + response-time formulas.
- Formal Verification — Kani + Verus harnesses.
- Safety Protocol — E2E CRC + sequence tracking.
- Choosing an RMW Backend — backend capability matrix.
- Supported Boards — per-board status + caveats.
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.
| Severity | u8 | When to use |
|---|---|---|
Trace | 0 | Per-instruction granularity; off by default. |
Debug | 1 | Diagnostic information useful while developing. |
Info | 2 | Normal operation events worth surfacing once. |
Warn | 3 | Unexpected but recoverable conditions. |
Error | 4 | Errors the caller should surface; system continues. |
Fatal | 5 | Unrecoverable; 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);
}
rclrs’s modifiers chain off the logger, and each applies per call site:
#![allow(unused)]
fn main() {
use nros_log::ToLogParams;
log_info!(node.logger().once(), "link up");
log_warn!(node.logger().skip_first(), "late sample {}", seq);
log_debug!(node.logger().only_if(verbose), "state {:?}", state);
// needs the platform clock; a clockless image refuses it at compile time
log_warn!(node.logger().throttle(Duration::from_millis(500)), "lag {} ms", lag);
}
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
| Target | Backend |
|---|---|
| POSIX | fprintf(stderr, "[<LEVEL>] <name>: <msg>\n") |
| Zephyr | LOG_INF / LOG_WRN / etc. (or printk if CONFIG_LOG=n); module nros |
| NuttX | syslog(priority, "%s", buf) |
| FreeRTOS | board-registered UART writer fn-ptr |
| ThreadX | board-registered UART writer fn-ptr |
| Bare-metal (mps2-an385) | QEMU semihosting via cortex_m_semihosting::hio::hstderr |
| Bare-metal (stm32f4) | defmt::info!("{=str}", msg) |
Boards on platforms with no native logger (FreeRTOS / ThreadX) 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:
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. 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_childreturnsErr(ChildError::{NameTooLong, EmptyName, ArenaFull}), as rclrs’s is fallible too; - C’s
rclc_logger_get_childreturnsNULLand warns once; - C++’s
get_child, whose upstream shape cannot fail, emits through the parent (records carry the parent’s name and level) and refusesset_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>):
| Knob | Key | Default | What it sizes |
|---|---|---|---|
NROS_LOG_BUFFER_SIZE | buffer_size | 256 (128–4096) | the per-call formatting buffer, and the message in each early//rosout slot |
NROS_LOG_EARLY_RECORDS | early_records | 4 (0 = drop, counted) | records held before init installs the sinks |
NROS_LOG_ROSOUT_RECORDS | rosout_records | 16 | the /rosout queue (below) |
NROS_LOG_DYNAMIC_LOGGERS | dynamic_loggers | 16, 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
The logging demos live in the features workspace,
examples/workspaces/features,
with the other Linux feature demos: one node package per language (Rust, C
and C++), each built by its own linux image row in the workspace’s
bringup. 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_writerABI.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 legacyNROS_INFO/ etc. file:line printf surface stays alongside the newNROS_LOG_*macros).
/rosout
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, 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.
The C and C++ publishers above pump the same queue, so the same counters and the same rules apply.
Design background
- RFC-0102: logger hierarchy
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. Feature demos as node packages of the features workspace.
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 write | it advances with | affected by /clock |
|---|---|---|
create_wall_timer(period, cb) | the platform’s monotonic clock | no |
create_timer(clock, period, cb) | whatever clock is | yes, 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. nano-ros has no 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. The
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.
Design background
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)
One command, whatever the workspace holds:
# One node (`nros new host_ws --board linux --template talker`):
cd host_ws
nros run host_node
# A bigger workspace — one entry generated per [image.*]:
cd my_robot
nros build # every default image
nros run linux # or run the binary `nros build` printed
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 does not bundle a router. 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:
nros build # or: nros build <image-id>, in any language
After a successful build nros build prints the artifact and the board’s own
next command (a QEMU line, west build -d … -t run, west flash -d …); each
platform chapter’s Run on QEMU / simulator and Flash a real board
sections say the same. Driving CMake yourself is the escape hatch —
nros build --dry-run prints the line it would run, and
Build as a CMake subdirectory
covers a tree with no system.toml.
Building and testing the in-tree fixtures: see Internals → Per-Platform Contributor Lanes.
For real hardware, deployment step becomes flash/load/monitor. For QEMU, deployment is launching simulator with correct network setup.
For a host image or an emulator, nros run builds the image if stale, starts
the router its link needs and streams the console; nros run --expect '<line>' is the one-line smoke test. Real hardware is flashed:
nros flash --monitor --expect '<line>' builds, runs the board’s human steps,
flashes through the board’s flash method and watches the console on the same
port, and nros monitor attaches to a board that is already running. A board
with no flash method stays 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. 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:
- Configure —
nros buildfirst reads the bringup’ssystem.toml+[image.<id>]+ its launch file (authored or generated) and emits, per image, the generated message crates, the resolved system model, the entry and the image’s build settings underbuild/. Keyed on the image, not on the board: several images can name one board and still differ in what they contain.nros configureruns this step alone. - Build —
nros buildthen hands off to the vendor tool —cargo/cmake/west— which you can equally drive yourself against the generated settings. - Flash + monitor — the vendor tool again:
probe-rs run/west flash, 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 builds with west, and nros build runs it for you — the board comes
from system.toml like everywhere else; the descriptor states the -b west
receives:
nros setup zephyr --rmw zenoh # Zephyr SDK + a west workspace in the store
cd my_robot
nros build demo_bringup:zephyr
west build -d build/zephyr-zenoh/demo_bringup__zephyr_west -t run # native_sim / QEMU
west flash -d build/zephyr-zenoh/demo_bringup__zephyr_west # a board with a flash runner
nros build generates the west application around the entry (Rust workspaces
included), passes the image’s overlays, and builds
into the image’s own directory, which is why the follow-up west commands
name it with -d. When it finishes it prints whichever of the two lines
the board supports. Bringing your own west workspace, or driving west build
by hand: Zephyr Integration.
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.
Design background
- RFC-0003: RTOS integration pattern — the three-step embedded deploy contract (§4)
- RFC-0065: colcon-like workspace builder —
nros build - RFC-0075: zenoh router provenance — why the router comes from ROS 2
Observing a Board With No Console
Some boards have no console a human can read. On the NXP MR-CANHUBK344 the console UART is not wired, and the one UART that is carries the zenoh serial link. A log line written there either goes nowhere or corrupts the transport’s framing. This page lists the channels that still work, in the order to reach for them.
The order matters. A debug probe is not a passive instrument on such a board: reading memory halts the core, a serial zenoh session misses its keepalives, and the session does not come back until the router restarts. So read what the image has already recorded first, and attach a live reader last.
1. The boot record: what the image says about itself
CONFIG_NROS_BOOT_REPORT=y keeps one fixed-size record in RAM, the
NROS_BOOT_REPORT symbol. The image writes it as it boots, so it survives the
failures that stop a log from ever printing, such as an executor arena that
runs out during entity creation, before the first spin.
It carries:
- the knobs the image was compiled with (arena size, callback slots, scheduling contexts, nodes, receive buffer);
- the last boot stage it reached;
- the allocation that did not fit, by how much, and which allocator refused it (the executor arena or the platform heap);
- the platform heap’s peak (at
FirstSpinand overall) and its capacity; - samples dropped for being bigger than their subscription buffer;
- the ROS domain and same-session-query setting, each with the rung of the configuration ladder that decided it;
- every
ERROR/FATALlog record, counted, with the first one’s source file and line. This includes errors raised while no console exists, such as a full liveliness pool.
Read it after halting the core. Take the record’s address and size from the image’s own symbol table, so a relinked image is never read at a stale address, and save those bytes with your probe:
read ADDR LEN <<< "$(arm-none-eabi-nm -S build/zephyr/zephyr.elf \
| awk '$4=="NROS_BOOT_REPORT"{print "0x"$1, "0x"$2}')"
pyocd commander -t s32k344 -c halt -c "savemem $ADDR $LEN report.bin"
The nros CLI has no command that decodes the saved bytes yet. Until it
does, the decoder lives with nano-ros’s own tools; the fields it prints are the
ones listed above.
Halting the core ends a serial zenoh session (the warning above). That does not matter here, because you read the record after the run you care about. The record is OFF by default on a real board. An image without it is byte-identical to one built before the feature existed.
2. The fault record: what killed it
CONFIG_NROS_FAULT_LOG=y replaces Zephyr’s fatal-error handler with one that
writes the fault reason, the faulting PC/LR/PSR and the thread name into a
.noinit record, which survives a functional reset. Set
CONFIG_THREAD_NAME=y too, or the thread reads ?; for a stack overflow, the
thread is the whole question. CONFIG_NROS_FAULT_LOG_FLASH=y also copies the
record to the storage partition at the next boot. On the S32K344 a destructive
reset (power-on, pin reset) zeroes the RAM the record lives in, so only a
functional reset preserves it. See the Kconfig help for the limits.
3. RTT: a live log, last
When you need the running log, move the console to an unwired UART in a devicetree overlay, so its bytes cannot reach the transport. Then read it over SEGGER RTT on the SWD link you already have:
script -qec "pyocd rtt -t s32k344 -a $(arm-none-eabi-nm build/zephyr/zephyr.elf \
| awk '$3==\"_SEGGER_RTT\"{print \"0x\"$1}')" /dev/null
None of the four things this needs is mentioned by any error message:
| Symptom | Fix |
|---|---|
| the SEGGER module is not in the workspace | clone zephyrproject-rtos/segger (e.g. into modules/debug/segger) |
| west does not register it | pass -DZEPHYR_EXTRA_MODULES=<that path> |
pyocd rtt needs a TTY | run it under script -qec "…" /dev/null, as above |
| it does not find the control block | pass -a <address of _SEGGER_RTT>, as above |
The default 1 KiB up-buffer wraps before a reader that attaches after the
event sees anything, so a failing run reads back empty. Set
CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=16384.
Everything you observe this way, you observed with the core being halted underneath it. One crash on the reference island did not reproduce once the probe was detached. Check a finding made over RTT again with the probe detached, using the two records above, before filing it.
See also
- Serial Transport, including the two settings a Zephyr board joining over a UART needs.
- Logging, for the log levels the boot record counts.
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, 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.
Installing it
install.sh puts it on your PATH beside nros (~/.nros/bin/nros-build-profile),
so there is nothing to install:
nros-build-profile --help
Release 0.6.0-nros4 and older do not carry it; install a newer release
to get it.
How it works
Every backend already emits timing data:
| Backend | Artifact | Opt-in |
|---|---|---|
west, cmake | build*/.ninja_log | none — ninja always writes it |
cargo (native, 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 (no opt-in)
$ west build -b qemu_cortex_a53 my_app # build/.ninja_log written
$ nros-build-profile my_app
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:
$ cargo build --timings --manifest-path my_talker/Cargo.toml \
--config my_talker/build/linux/nros-cargo.toml # build/linux/target/cargo-timings/ written
$ nros-build-profile my_talker/build/linux --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)
$ nros-build-profile my_app --json
wrote my_app/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 any other project, run the installed bin the same way, on the
directory holding its build*/ or target*/:
$ 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.
Troubleshooting
Common issues and solutions when working with nros. This page owns troubleshooting; a few chapters keep a short section for failures that only happen in their topic:
- First build and first run: Troubleshooting — First 10 Minutes
- ROS 2 side sees nothing (domain ID, QoS,
rmw_zenoh_cppclient mode, edition): ROS 2 Interop - Serial links (handshake timeouts, baud rate, UART pins): Serial Transport
- Multi-backend binaries: Cross-backend Bridges
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:
- The router tracks “interest” for each client
- When creating a publisher, zenoh-pico creates a write filter context
- The filter creation calls
_z_session_rc_clone_as_weak()which can fail - 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.
(The in-tree Zephyr workspace gets this patch automatically: see
Internals → Zephyr in-tree examples.)
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:
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::MessageTooLargewhen 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:
| Layer | Posix Default | Embedded Default | Env Var |
|---|---|---|---|
Defragmentation (Z_FRAG_MAX_SIZE) | 65536 | 2048 | NROS_ZENOH_FRAG_MAX_SIZE |
Batch size (Z_BATCH_UNICAST_SIZE) | 65536 | 1024 | NROS_ZENOH_BATCH_UNICAST_SIZE |
| Per-entity shim buffer | 1024 | 1024 | – (named constant in code) |
User receive buffer (RX_BUF) | 1024 | 1024 | – (const generic) |
XRCE-DDS backend layers:
| Layer | Posix Default | Embedded Default | Env Var |
|---|---|---|---|
| Transport MTU | 4096 | 512 | XRCE_TRANSPORT_MTU |
| Per-entity buffer | 1024 | 1024 | – (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: let provisioning fetch 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
cargo build
For Zephyr builds, clean the west build directory:
rm -rf build/
west build -b native_sim/native/64 <app>
Test Failures
The repository’s own test suites (hanging integration tests, skipped Zephyr tests): see Internals → Test failures.
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()(requiresalloc) for heap allocation with stable address - Use
Pinto 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
| Code | Name | Description |
|---|---|---|
| -3 | _Z_ERR_TRANSPORT_OPEN_FAILED | Could not connect to router |
| -78 | _Z_ERR_SYSTEM_OUT_OF_MEMORY | Memory allocation failed (or write filter issue) |
| -128 | _Z_ERR_GENERIC | Generic 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
tools (the XRCE agent, Cyclone’s idlc) 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. The one thing it does not
provide is a zenoh router: that is ROS’s rmw_zenohd, so the
zenoh backend needs a ROS 2 install on the router host, while Cyclone DDS
needs no daemon at all and xrce uses the agent nros setup installs.
# 1. Get the nros CLI (analogous to installing a ROS distro):
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh
export PATH="$HOME/.nros/bin:$PATH"
# 2. Provision a board + RMW (analogous to `rosdep install`):
nros setup linux --rmw zenoh
# 3. Scaffold, build and run something (analogous to `colcon build`
# then `ros2 run`): a workspace with a node package and its bringup.
nros new talker_ws --board linux --template talker --lang rust
cd talker_ws
RUST_LOG=info nros run talker_node
Step 3 is the one that maps most directly onto ROS 2, and it maps
better than it used to. nros build (which nros run calls) 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 the bringup’s system.toml — the board, the RMW,
the domain, the components — and nothing in Cargo.toml or
CMakeLists.txt mentions a target. Changing target is editing the
board = line and building again.
For embedded targets, name the board instead of linux; 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> setupcallsnros 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.freertos]
board = "mps2-an385-freertos" # the platform follows from the board
Then 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.
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: a
src/of packages withpackage.xmlmanifests. - 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 buildstill works as a consumer-side build for POSIX C++ workspaces that already use it;nros buildis the equivalent that also reaches embedded targets, drivingcmake,cargoorwestfor you.- Interop: POSIX nano-ros nodes can communicate with standard ROS 2 nodes through compatible RMW backends (Zenoh, Cyclone DDS, XRCE).
What Changes
- One release, no package registry. The
nrosCLI is released on GitHub and installed withscripts/install.sh; the library ships as source inside that release (its SDK root, the release’sshare/nano-rosdirectory) and is compiled into your build. Nothing is published to crates.io, and there is no Arduino zip or ESP-IDF binary component. - 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-runsays 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), so the zenoh path needs ROS 2 on the router host; xrce and cyclonedds do not. - Compile-time RMW + platform. Embedded targets can’t
dlopen, so the combination is locked in at build time — stated once as[system] rmwand[image.<id>] boardinsystem.toml, and derived from there into the build. - No install prefix. There
is no
cmake --installstep for nano-ros itself — consumers pull it into their build viafind_package(nano_ros)against the SDK root (a release’s or a checkout’s) oradd_subdirectory(<sdk-root>). (Example packages still carry their owninstall(TARGETS …)for colcon compatibility.) The integration shells underintegrations/<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 toolchain listprints every entry with its size and when it was last read;nros toolchain gc --older-than 90dproposes what to reclaim and removes nothing unless you add--delete. It never removes an entry a pin names — it readsnros-sdk-index.toml/nros-sdk.lockfrom the directory you run it in and its ancestors, and says which files it consulted.nros toolchain remove <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/(orOUT_DIRfor 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 insystem.toml(locator, and the address innet) 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.
Design background
- RFC-0075: zenoh router provenance — why the router comes from ROS 2
- RFC-0098: generated leaf build config — why no build file mentions a target
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. 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::Nodedoes 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: 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_stringand<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=yandCONFIG_GLIBCXX_LIBCPP=y.
- On FreeRTOS the toolchain compiles C++
- 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.
A whole workspace on a board
A ported workspace keeps each package’s own CMakeLists.txt, unchanged. Only
the root that configures the packages is new.
examples/templates/ament-workspace-port
has three roots over one stock package:
- FreeRTOS:
find_package(nano_ros), thenadd_subdirectoryfor each package, withsystem.tomlnaming the board. Build withnros build. - Zephyr:
find_package(Zephyr)first, then the same two lines, plus apackage.xmland the Kconfig fragments beside them. Build withnros build. - posix: the same root you already use.
On a board, nano-ros reads the stock CMake verbs this way:
add_executablebuilds an image, asnano_ros_add_executable(... ROS2_MAIN)does. The board’s entry calls yourmain, and the image gets its locator and domain fromsystem.toml.add_library(... SHARED)buildsSTATIC, because an image is one binary.- A package that finds
rclcppgets the C++ standard library. Its directory is compiled without-ffreestanding. ament_target_dependencies(<target> std_msgs)on a C++ target generates the message package if nothing has yet.- A plain
target_link_libraries(<exe> <lib>)keeps working beside nano-ros’s own links. - A package that calls
find_package(ament_cmake)is a ported package: its C++ is hosted even if it never findsrclcpp, and a message package it finds is generated in C++, sotarget_link_libraries(<lib> PUBLIC ${std_msgs_TARGETS})reaches the headers. find_package(<sibling>)reaches a library package of the same workspace when the root setsNROS_INTERFACE_SEARCH_PATHand callsnros_workspace_interfaces()before adding the packages. Zephyr has the same call.-Wpedantic -Werrorand-fPICon a package’s directory are fine. On Zephyr the image’s kernel link is never position-independent, whatever the package adds.std::mutexandstd::recursive_mutexexist although the RTOS toolchains’ libstdc++ has no threads: nano-ros defines them over the platform’s mutex for the ported package.std::condition_variable,std::threadandstd::promiseare still absent there.
What porting Nav2 packages to FreeRTOS and Zephyr needed edited in the source:
-
Exceptions. An RTOS image is built
-fno-exceptions, so athrowdoes not compile. Guard it with#if defined(__cpp_exceptions)and abort in the other arm. Atry/catcharound a call needs the same guard. -
Linux scheduling. Nav2’s
setSoftRealTimePriority()callssched_setscheduler. On an RTOS a thread’s priority is its tier’s (system.toml[tasks.*]), so make the call a no-op there. -
Per-request client callbacks. No
std::promise, so noClient<S>::SharedFuture: read the future on a later tick (the client row in the table below). -
Parameters with no default. A board has no command line. State the launch file’s parameters in the image instead, and leave the source alone:
[image.<id>] params_files = ["config/x.param.yaml"] ros_args = ["--ros-args", "-p", "update_rate:=10.0"]The build bakes both into the image, and
rclcpp::initapplies them ahead of whatever command line there is, so a host’s real arguments still win. A C (rclc) program does not read them.
What a workspace image still has to state:
-
Pool sizes: let the census answer. Say that the image’s entities are what its program creates, and state no size:
[[component]] pkg = "my_pkg" class = "my_pkg::MyNode" name = "my_node" # the executable, when the workspace builds several entities = "census"The configure then builds the same sources for the host, runs the program once in census mode, and sizes the publisher, subscriber, queryable, liveliness and executor-callback pools from what it created. Nothing has to be built twice by hand, and a current census is reused. This works on the
add_subdirectoryandfind_packageroads, for a host image and a FreeRTOS one, and for a Zephyr application. On Zephyr the census also decides whether an in-image client reaches an in-image server (local_queryable) and, when it records a parameter, the parameter store and the nros heap’s default. A program that creates several nodes is counted node by node. The census run constructs the node, so it needs the node’s parameters: state them withparams_files(above). One Zephyr pool stays yours to state, because Zephyr sizes it in Kconfig: zenoh-pico takes a POSIX condition variable per subscriber and queryable, and the configure refuses aCONFIG_MAX_PTHREAD_COND_COUNTbelow what the census needs, naming the number to set in yourprj-zenoh.conf. -
Message bounds. An unbounded sequence or string in a message the node publishes gets the default bound, which can make one message megabytes. Bound it in
nros-codegen.toml, for example[packages."diagnostic_msgs"] sequence = 4andstring = 64. -
Parameter capacities and the app stack. The default capacities fit: both ported Nav2 nodes boot on FreeRTOS’s default 64 KiB app stack, at peaks of 12.5 and 16.8 KiB.
[knobs.params](max_string_value_len,max_array_len,max_byte_array_len) still shrinks the parameter store’s RAM. If a node needs more stack, raise it withset(NROS_FREERTOS_APP_STACK_KB <KiB>)in the root. The boot lineapp task stack peak <used> of <total>says what it needed. -
The heap. On FreeRTOS,
NROS_FREERTOS_HEAP_KBonfreertos_config; on Zephyr,[image.<id>.knobs.zephyr] heap_size. A Zephyr heap that runs out while a subscription is created reportsConnectionFailed, not an out-of-memory. -
The Zephyr board. Name the board package,
board = "mps2-an385-zephyr", not the genericzephyr(which lowers tonative_sim). The package carries the board’s QEMU network, sizing and entropy Kconfig, so the leaf names only its ownos_configfragments.
What “just works” without source edits
nano-ros covers the patterns a typical ROS 2 C++ node uses:
| rclcpp surface | nano-ros mapping | Notes |
|---|---|---|
class MyNode : public rclcpp::Node | rclcpp::Node — ours, and the same name. | Ctor takes (name), (name, options) or (name, ns, options). |
std::make_shared<MyNode>() | works | auto 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. |
rclcpp::Node::make_shared("name") | std::make_shared<rclcpp::Node>(…), the owning std::shared_ptr | Hosted builds. auto node = rclcpp::Node::make_shared("x"); is unchanged; assigning the temporary straight to a global rclcpp::Node::SharedPtr is not — keep it in a local first (auto node = …; g_node = node;). |
create_publisher<M>(topic, qos) | shared_ptr-returning wrapper | qos can be rclcpp::QoS(10) or an int. |
create_subscription<M>(topic, qos, callback) | registered on the executor arena; dispatched by any spin verb | Capturing lambdas + std::function + std::bind all work. The callback may take const M& (no allocation, every target) or, on hosted builds, upstream’s M::UniquePtr / M::SharedPtr / M::ConstSharedPtr / std::shared_ptr<const M>: each delivery is then copied into a fresh heap object the callback owns and may keep. A freestanding build refuses the pointer shapes at compile time, naming const M&. |
create_wall_timer(period, callback) | registered on the executor arena; dispatched by any spin verb | std::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 /clock | Humble’s only form. create_wall_timer stays on the steady clock. |
create_service<S>(name, handler) with upstream’s std::shared_ptr<Request> / std::shared_ptr<Response> handler (optionally with std::shared_ptr<rmw_request_id_t> first) | lent from the dispatch: the handler gets pointers to the request and response the dispatch decoded | Hosted builds; a plain function, a lambda with or without captures, or std::bind(&Node::on_request, this, _1, _2) (a handler’s state is copied into the service’s executor entry, and whatever it returns, such as Autoware’s bool, is ignored). The handler must not KEEP a pointer past its call: that aborts, naming why. The request header is zero-filled. |
rclcpp::executors::SingleThreadedExecutor + add_node + spin | the process executor every node already lives on | add_node checks rather than attaches. MultiThreadedExecutor is a compile error naming the alternative: an executor dispatches on one task, and parallelism is a tier, not a callback group. |
executor.spin_some() / rclcpp::spin_some(node) | drain what is ready, without waiting | The bound is milliseconds (spin_some(5)), not a std::chrono duration. |
client->wait_for_service(1s) / wait_for_action_server(10s) | the millisecond wait, converted | Hosted builds. The budget is still required; a negative duration is refused at the call. |
rclcpp::spin_until_future_complete(node, client->send_request(req)) | spins until the reply arrives | The timeout is milliseconds. |
auto fut = client->async_send_request(std::make_shared<S::Request>(…)) / fut.get() / fut.wait_for(1s) / client->remove_pending_request(fut) | the poll client’s rclcpp::Future, under upstream’s verbs | Hosted builds, except remove_pending_request. get() returns the reply as an owning std::shared_ptr<Response> but never blocks: call it after spin_until_future_complete returned SUCCESS (before the reply it logs a refusal and returns nullptr). wait_for returns std::future_status and spins the executor while it waits. Each future is keyed by its request’s sequence number, as upstream’s is: several may be in flight on one client, each gets its own reply, and a reply that arrives after remove_pending_request (or prune_pending_requests) is dropped. How many is capped by the transport’s own outstanding-request limit (NROS_ZENOH_MAX_PENDING_GETS on zenoh); a send past it returns an empty future (valid() false). What it returns is upstream’s FutureAndRequestId: the future as .future, the request’s sequence id as .request_id (auto [future, id] = … works), and share() hands back a SharedFutureAndRequestId whose future is a std::shared_future<SharedResponse> every copy of which gets the same response; remove_pending_request takes the id too. .future is an rclcpp::Future, not a std::future. A freestanding build has send_request(req) + fut.try_take(resp); get() and share() there are compile errors naming them. |
#include "rclcpp_action/rclcpp_action.hpp" | an umbrella over Server<A> / Client<A> and the two free factories | See Actions below. |
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 / FATAL | dispatched through the NROS_LOG_* family into nros_log | Carries 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. _THROTTLE variants are REFUSE-LOUD. |
get_logger().get_child("sub") | a real child logger, <parent>.sub | 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 required | Upstream 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) ctor | Chainable setters inherited. |
diagnostic_updater::Updater + DiagnosticStatusWrapper | packages/api/nros-diagnostic-updater | Publishes /diagnostics. |
rclcpp_action::Server<A> / Client<A> | ours, under upstream’s names | Hosted builds take upstream’s goal-handle shapes as written; freestanding builds key goals by id. See Actions below. |
class MyNode : public rclcpp_lifecycle::LifecycleNode | ours, 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) + rclcpp_components_register_node(lib PLUGIN "ns::Class" EXECUTABLE exe) | the macro defines the component’s factory in its own source file; the cmake verb emits a thin int main() per registration that declares the class and calls that factory | Single binary, no class_loader. The PLUGIN’s qualifiers must be namespaces (a class nested in a class cannot be forward-declared). A component library built SHARED is built static. |
plain CMake components: add_library(<c> SHARED …), ament_target_dependencies(<c> rclcpp …), rclcpp_components_register_nodes(<c> "ns::Class"), a hand-written main (add_executable + target_link_libraries) | each SHARED or MODULE library builds static, and the configure prints a line saying so; the plural verb and ament_environment_hooks are accepted and do nothing | One image is one binary with one runtime. ament_target_dependencies works beside a target_link_libraries in either signature (plain or PRIVATE). A hand-written main gets the link pass the ament verbs give theirs. IMPORTED libraries keep their type. A project that overrides add_library itself replaces this. |
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. |
ament_auto_find_build_dependencies() + ament_auto_add_library / ament_auto_add_executable | reads package.xml; each target gets the package’s include/ and every dependency nano-ros has a module for | A dependency nano-ros does not provide (a project helper such as autoware_utils) is listed at configure and not looked up; a source that includes it fails on the include. |
a project’s own message packages (autoware_vehicle_msgs, tier4_control_msgs, …) installed under AMENT_PREFIX_PATH | generated by nano-ros codegen from the installed .msg / .srv | Never the installed package’s upstream C++. Source the project’s install first (below). |
create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive), SubscriptionOptions::callback_group, create_subscription<M>(topic, qos, cb, options) | accepted; an unnamed group runs on the node’s default context | One executor task already keeps every callback mutually exclusive, so Reentrant runs them one at a time too. A NAMED group (create_callback_group("name")) is the tier-mapped one. |
create_service<S>(name, cb, rmw_qos_profile_services_default, group) | upstream’s trailing QoS and group | rmw_qos_profile_t has upstream’s fields and enum values. A profile converts to rclcpp::QoS, QoS(QoSInitialization::from_rmw(p), p) builds one, and qos.get_rmw_qos_profile() reads it back. rmw_qos_profile_system_default is absent, because every field of it is a placeholder for the RMW to fill in, and the RMWs fill it in differently. A file that also includes <nros/rmw_entity.h> is refused, because that header defines the RMW backend’s own struct of the same name. |
pub_->publish(msg); as a statement | allowed on a ported (ament-built) target, even under -Wall -Werror; a failure is logged | Code written for nano-ros keeps the [[nodiscard]] Result. |
several packages in one workspace (a library package such as Nav2’s nav2_util, a cmake-only one such as nav2_common, and the node that finds them) | find_package(<sibling>) resolves to the sibling configured earlier in the same run: <pkg>::<pkg> over what it exported with ament_export_libraries / _targets / _dependencies, its include/, and its ament_package(CONFIG_EXTRAS …) files run | Add each package before the packages that find it; the configure refuses an out-of-order find by name. Every package configures in ONE CMake run, so ${CMAKE_SOURCE_DIR} is the workspace root, not the package: write ${PROJECT_SOURCE_DIR} (rare: 0 of 417 Autoware CMakeLists name it). |
find_package(<pkg>) for an upstream C++ library nano-ros does not ship (bondcpp, tf2_ros, …) | refused at configure, naming the package and the installed file CMake fell back to | Remove the dependency, or port the library into your workspace as a package of its own. |
rclcpp::Time(msg->header.stamp) | implicit, from any {sec, nanosec} message, on the ROS clock | |
msg.header.stamp = now();, stamp + rclcpp::Duration::from_seconds(0.1) | a {sec, nanosec} message converts from rclcpp::Time, and + / - a Duration | |
last_cmd_.twist == geometry_msgs::msg::Twist() | a generated message has field-wise operator== / operator!= | Messages only; a service’s or action’s own Request / Goal structs have none. |
rclcpp::Node::WeakPtr parent + parent.lock() | the same non-owning handle; lock() hands it back | lock() cannot tell a destroyed node from a live one, because the handle owns nothing; it answers null only for an unset handle. |
rclcpp::Clock::SharedPtr clock_ = get_clock(); | a pointer to the node’s clock | std::make_shared<rclcpp::Clock>(...) does not convert; hold a rclcpp::Clock by value. |
timer_->cancel(); / timer_->reset(); as a statement | allowed on a ported target under -Werror | As publish(): the result is still readable. |
descriptor.floating_point_range.resize(1) | a bounded sequence resizes within its bound | Past the bound it is refused and returns false, where rosidl throws. |
rclcpp::spin(node->get_node_base_interface()) | spins the node | |
get_node_base_interface()->get_context()->add_pre_shutdown_callback(f) | rclcpp::Context views the executor’s shutdown hooks; f runs on rclcpp::shutdown() | The hooks share the executor’s table (NROS_EXECUTOR_MAX_SHUTDOWN_CBS, default 2). A full table returns an invalid handle. |
a package that depends on rclcpp_lifecycle | its runtime carries lifecycle (with param_services on a host), and ros2 lifecycle get /<node> finds it | On a board the cmake road gives it lifecycle alone. A Zephyr application states it in system.toml: [system] features = ["lifecycle"]. |
std::static_pointer_cast<Derived>(shared_from_this()) (Nav2’s nav2_util::LifecycleNode) | does not compile | The handle owns nothing, so there is no std::shared_ptr to cast. Drop the override; rclcpp_lifecycle::LifecycleNode::shared_from_this() returns its own type. |
pub_->get_subscription_count() | takes the executor and an out-parameter | See Publisher::get_subscription_count. Nav2 calls it only in its tests. |
declare_parameter(name, rclcpp::ParameterValue(...)[, descriptor]), get_parameter(name) as an rclcpp::Parameter, set_parameter(rclcpp::Parameter(...)) | the same store the typed declare_parameter<T> uses; the descriptor may be an rcl_interfaces::msg::ParameterDescriptor | Hosted builds. A byte or string array can be built but not declared. A wrong-type as_double() answers 0.0, not an exception. |
add_on_set_parameters_callback(std::bind(&C::cb, this, _1)) with cb(std::vector<rclcpp::Parameter>) returning SetParametersResult | runs on every write, local or ros2 param set; successful = false refuses it; the returned handle unregisters on destruction | One parameter per call, because writes are validated one at a time. The reason does not reach the remote caller. Keep the handle as a member of your node class, so it goes before the node does. |
node->get_node_parameters_interface() | has_parameter, declare_parameter, get_parameter and get_parameter_overrides() through -> | get_parameter_overrides() lists this node’s -p / --params-file values as a std::map<std::string, rclcpp::ParameterValue>, one copy per node, so Nav2’s declare_or_get_parameter compiles. |
pub_->publish(std::move(msg)) with a std::unique_ptr<M> | publishes *msg; the message is freed when the call returns | Hosted builds. Every publish serializes, so there is no intra-process hand-over to save a copy. A null pointer is refused and logged. |
rclcpp::Client<S>::SharedPtr client_ = create_client<S>(name); + client_->async_send_request(req, callback) with a void(Client<S>::SharedFuture) callback | the handle holds the poll client; the callback runs from the executor when the reply arrives, with a SharedFuture whose get() returns it | Hosted builds with std::promise. The client registers one 1 ms timer for its callbacks, cancelled while none is waiting. A removed request’s callback never runs. wait_for_service(1s) inside a callback waits without spinning, so on a polled backend it runs out its budget. On an RTOS toolchain (no std::promise) store the client as rclcpp::Owned<rclcpp::PollClient<S>>, keep the FutureAndRequestId that async_send_request(req) returns, and read it on a later callback with future.is_ready() and get(). |
diagnostic_updater::Updater updater_{this}; | publishes /diagnostics on its own timer, every period (setPeriod), as upstream’s does | update() still works and is no longer required. |
DiagnosticTask, FrequencyStatus, TimeStampStatus, Heartbeat, HeaderlessTopicDiagnostic, TopicDiagnostic | upstream’s classes, levels and messages; updater.add(task) | A clock argument is a const rclcpp::Clock*, so node->get_clock() still passes. The tasks take no lock, so a node that calls tick() from its own thread must keep that call from overlapping an update. DiagnosedPublisher is absent, because a nano-ros publisher handle can be moved but not copied. Keep the publisher and a TopicDiagnostic, and call tick(stamp) next to each publish. |
the six parameter services (ros2 param get/set/list) | on for an ament package built on a host: every node serves them, as upstream’s does | Comes from the package’s package.xml naming ament_cmake; set NROS_FEATURES to declare the capabilities yourself. Values come from --ros-args --params-file <yaml> or -p. On a board they are off unless declared (param_services in NROS_FEATURES, or [system] features on Zephyr): they cost six queryables and their RAM. |
Measured: the stock rclcpp examples
packages/api/nros-cpp/tests/compile/rclcpp-examples/ holds 13 files from
ros2/examples (humble and jazzy) verbatim,
each beside its port. The repository’s C++ check compiles and links every port
against nros-cpp, boots it on a stub middleware, and fails if the number of edited
lines changes in either direction (edits.json). Today:
| example | edited lines | what the edits are |
|---|---|---|
minimal_publisher lambda / member_function | 0 | — |
minimal_subscriber member_function | 0 | — |
minimal_subscriber lambda (jazzy) | 0 | — |
minimal_timer lambda / member_function | 0 | — |
minimal_client | 0 | — |
minimal_service | 3 | global Node::SharedPtr from a temporary |
minimal_subscriber not_composable | 3 | global Node::SharedPtr from a temporary |
minimal_publisher not_composable | 11 | the try/catch goes: no exceptions |
multithreaded_executor | 18 | MultiThreadedExecutor, callback groups |
minimal_action_client member_functions | 2 | seq.back() on a generated sequence |
minimal_action_server member_functions | 6 | the goal runs in the accepted callback, not on a thread of its own |
The remaining causes, largest first:
- Callback groups and the multi-threaded executor (18 lines).
- Exceptions (11 lines). There is nothing to catch.
- The action server’s thread per goal (6 lines). The stock
handle_accepteddetaches astd::threadthat drives the goal handle; a goal handle used from a thread you started races the executor (nano-ros starts no threads behind it and a one-level image has no executor lock). The port callsexecute(goal_handle)in the accepted callback and drops therclcpp::Rate::sleep, which would re-enter the executor from that callback. - A global
Node::SharedPtrassigned frommake_shared(2 lines each). seq.back()on a generated sequence (2 lines).FixedSequence/HeapSequenceare notstd::vector; writeseq[seq.length() - 1].
Upstream’s goal-handle shapes are drop-ins, so the two action examples need 6 and 2 edited lines; the total is 43.
Actions: goal handles on a hosted build
The action types keep upstream’s names and concepts, and GoalResponse /
CancelResponse / ResultCode keep upstream’s enumerators and values.
On a hosted build (-DNROS_CPP_STD, what the porting flow sets) the stock
spellings are drop-ins:
rclcpp_action::create_server<A>(node->get_node_base_interface(), …clock, …logging, …waitables, name, handle_goal, handle_cancel, handle_accepted)andrclcpp_action::create_client<A>(…base, …graph, …logging, …waitables, name), or the(node, name, …)shape. Each returnsX<A>::SharedPtr, which isrclcpp::Owned<X<A>>: the entity by value, so a memberServer<A>::SharedPtr action_server_;works and nothing is shared. Theget_node_*_interface()getters return a handle naming the node, whose only use is these two calls.- The callbacks take upstream’s shapes,
std::bind-ed members included:handle_goal(const GoalUUID&, std::shared_ptr<const Goal>),handle_cancel(std::shared_ptr<ServerGoalHandle<A>>),handle_accepted(std::shared_ptr<ServerGoalHandle<A>>). ServerGoalHandle<A>answersget_goal_id,get_goal,is_active,is_executing,is_canceling, andpublish_feedback/succeed/abort/canceledtake upstream’sstd::shared_ptrs. Each call forwards to the server, keyed by the goal id; the goal itself lives in the server’s static arena, and the handle is a token over it.execute()moves a goal accepted withACCEPT_AND_DEFERto EXECUTING and publishes the status; forACCEPT_AND_EXECUTEthe server does that itself beforehandle_accepted, as upstream’s does.Client<A>::SendGoalOptionscarries upstream’sgoal_response_callback(aClientGoalHandle<A>::SharedPtr, null when rejected),feedback_callback(std::shared_ptr<const Feedback>) andresult_callback(WrappedResult{goal_id, code, result}), andasync_send_goal(goal, options)binds them to that goal. Giving a result callback requests the result once the goal is accepted, as upstream does.async_get_result(goal_handle, result_callback)andasync_cancel_goal(goal_handle, cancel_callback), plusasync_cancel_all_goals/async_cancel_goals_before(stamp), return upstream’sstd::shared_future(of aWrappedResult, or aCancelResponse::SharedPtrwithreturn_codeandgoals_canceling), sorclcpp::spin_until_future_complete(node, f)thenf.get()work unchanged. The future drives the client while it is polled or waited on.
What stays different, each a compile error or written below:
async_send_goalreturnsrclcpp::Future<GoalAccept>, the goal-keyed futuresend_goal_futurereturns, not astd::shared_futureof a goal handle.auto f = client->async_send_goal(goal, opts);compiles; a caller that waits onfgets the acceptance (the future never blocks without spinning).- A cancel reply is taken by the executor’s spin, as upstream’s
is: the spin settles the
async_cancel_*future and runs itscancel_callback, so code that keeps only the callback ports as written. A nano-ros server acts on the zero-id cancel-all / cancel-before forms as an rclcpp server does. - A goal handle is driven from the executor’s task. Do not hand it to a
thread you start (see the 6 lines above). It names its server by address, so
the server must not move while a handle is in use; a
SharedPtrdoes not move it. - No
get_goal_stamp: the arena keeps no acceptance time. - It is not a
Waitable(noexecute/is_ready/set_on_ready_callback): the executor drives the server directly. wait_for_action_serverreturnsResultand the budget is required (see the table above).
On a freestanding build there is no allocator for a goal handle, so
ClientGoalHandle::SharedPtr, ServerGoalHandle::SharedPtr,
rclcpp_action::create_server, async_send_goal and an assignment to
SendGoalOptions’ upstream fields are each a compile error naming the
goal-id-keyed form:
- Server. Declare the
Server<A>, initialise it withnode.create_action_server(srv, name), and installset_goal_callback_with_ctx/set_cancel_callback_with_ctx/set_accepted_callback_with_ctx(function pointers with avoid*context). The callbacks receive the 16-byte goal id; feedback and termination are calls on the server keyed by it —srv.publish_feedback(id, fb),srv.succeed(id, result),abort,canceled— taking the message by const reference. - Client.
client.set_callbacks(SendGoalOptions{goal_response, feedback, result, context})installs raw callbacks once for every goal the client sends;send_goal_async(goal, goal_id)sends.send_goal_future/get_result_futureare the future-shaped spellings, each keyed by its goal.rclcpp_action::create_clientworks here too (no allocator involved).
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, 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 raisebad_weak_ptr. nano-ros cannot derive fromstd::enable_shared_from_this<Node>: itsstd::weak_ptrmember 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. Theshared_ptrthere is a temporary, and a handle to it would dangle at the end of the line; the compiler names the deleted conversion. Writeauto node = std::make_shared<MyNode>();instead. Ashared_ptryou keep converts toNode::SharedPtrfreely, andrclcpp::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::SharedPtris a small handle, not astd::shared_ptr. Its callback lives in the executor, so the handle does not own the timer. Dropping or overwritingtimer_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 asstd::shared_ptr<rclcpp::TimerBase>does not compile: spell itrclcpp::TimerBase::SharedPtr.- The same holds for
Subscription<M>::SharedPtr,Service<S>::SharedPtrandClient<S>::SharedPtr: dropping the handle leaves the entity running, andsub_.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<...>andrclcpp::GenericTimer<...>do not exist. The clock axis is the second verbrclcpp::create_timer(node, clock, period, cb)plus a runtime field, not a type parameter.- DERIVING from
rclcpp::TimerBasegets 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, andTimerBaseis a name that promisesWallTimerandGenericTimersiblings we deliberately do not have. The clock axis is a runtime field plus the second verbrclcpp::create_timer, not a type parameter.
A real package: Autoware’s autoware_external_cmd_selector
This node was ported (Autoware 0.45.0; 12 subscriptions, 7 publishers,
a service, two parameters, diagnostic_updater, a component registered with
rclcpp_components_register_node(... EXECUTABLE ...)) through a workspace
umbrella like the one above. Its package needed no source edit and no
CMakeLists edit, and it runs against a stock ROS 2 Humble rmw_zenoh_cpp
peer: topics, the service, ros2 param, /diagnostics. Three things it
needed from the person porting it:
-
Source the project’s install before configuring. Its CMakeLists calls
find_package(autoware_cmake)and its message packages come from Autoware’s own install; both reach a machine throughsource <autoware>/install/setup.bash. nano-ros reads what that puts onAMENT_PREFIX_PATHandCMAKE_PREFIX_PATH:autoware_cmake’s own macros run over nano-ros’s ament verbs, and each message package is generated by nano-ros codegen, never taken from the installed upstream C++. -
Raise the pool sizes. A package built this way declares no entity counts, so every pool sits at its embedded default, and start-up stops at the first one that fills, naming its knob. Unless the census derives them from the program (see “Pool sizes” above), set them in the environment of the configure and build:
export NROS_EXECUTOR_MAX_CBS=40 NROS_ZENOH_MAX_LIVELINESS=128 \ NROS_ZENOH_MAX_PUBLISHERS=32 NROS_ZENOH_MAX_SUBSCRIBERS=32 \ NROS_ZENOH_MAX_QUERYABLES=32Those are what this node needed with headroom; a smaller node needs less.
-
Name the router.
NROS_LOCATOR=tcp/<host>:7447at run time, the same router the ROS 2 side’srmw_zenoh_cppuses.
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 emitsstd::string. Assigning astd::stringneeds a one-token adapter:message.data = s.c_str(). The reverse(std::string{}.c_str())is whatRCLCPP_INFOalready 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)
-
Persisting parameters. A
--params-file <yaml>on the command line is read (scalars), as is-p, anddeclare_parameter<T>(name)with no default takes its value from there (the Autoware port below declares both of its parameters that way). Parameters then live in a volatile store the standard parameter services can update until the next boot; nothing writes them back. -
A message with an unbounded string or sequence (
std_msgs/String’sdata) — a subscription to it stops the compile atNROS_UNBOUNDED__<pkg>_msg_<type>__field_<field>. The receive buffer is sized from a bound, and an unbounded field has none. Give it one in anros-codegen.tomlat the workspace root or beside the package’sCMakeLists.txt:[fields] "std_msgs/String.data" = 256The wire format does not change. The other levels (
[defaults],[packages.*],[types.*]) and the storage modes are in the message field capacity design. -
create_generic_publisher(topic, type, qos)/create_generic_subscription(topic, type, qos, callback)by type name — a compile error naming the alternative. rclcpp loads the type’s typesupport at run time; nano-ros links its types at build time, and the wire key needs the type’s RIHS01 hash, which nothing derives from a name. Pass the hash:create_generic_publisher(topic, type, type_hash, qos)returns aGenericPublisherwithpublish_serialized(data, len), andcreate_generic_subscription(topic, type, type_hash, qos, callback)callscallback(const uint8_t* data, size_t len)with each message’s CDR bytes. A generated type states both asM::TYPE_NAMEandM::TYPE_HASH. -
class_loader,dlopencomposition, a component that is not anrclcpp::Node(get_node_base_interface()) — nano-ros has no runtime loader and no node interfaces. Build the components into one image with a hand-written main, as ros2/demosmanual_compositiondoes. -
tf2,image_transport,pluginlib— out of nano-ros scope. Project- specific helpers (autowareuniverse_utils, PX4 uORB shims) are not nano-ros’s to ship; the porting user vendors them or replaces the call sites with rawnros-cppones.
When the port hits a gap
Known open follow-ups: pool sizing on the ament road,
the per-request client callback, listing parameter overrides, and the in-tree migration of legacy
nros_generate_interfaces(<pkg>) call sites. If your port surfaces a new
gap, file an issue with a reduced case — a fix lands either tree-side (in
packages/api/nros-cpp/, cmake/NanoRosAmentSurface.cmake or cmake/find/)
or as a codegen change, each with its own in-tree test.
In-tree regression fixtures:
local-msg-package— mixed workspace (workspace + AMENT msg sources) C++ + Rust consumers.cpp-port-minimal-publisher— ROS 2 tutorialminimal_publisher.cppverbatim.rclcpp-compat-smoke— minimal source-compat regression test.topic-state-monitor-port— multi-sub / wall-timer / diagnostic_updater exercise.
Your ported code stays yours: nano-ros does not take a copy of it as a
fixture. What lands is the fix, tested by code written for the test: the
Autoware port above left behind tests/cmake-ament-surface-tests.sh and
packages/api/nros-cpp/tests/compile/ament_port_surface_runtime.cpp, neither
of which contains a line of Autoware’s.
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 upstreamrmw_zenohROS 2 RMW. A nano-ros publisher and anrclcppsubscriber 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.
A C++ callback may also take upstream’s const rclcpp::MessageInfo& as its
second argument (hosted builds). A C++ poll subscription has
upstream’s polling form, sub.take(msg, info), on hosted and freestanding
builds. The fields are what the backend reports per sample, so on
XRCE and uORB they are all zero. info.is_reported() (a nano-ros extension)
says whether the backend reported metadata for that sample at all.
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. No foreign async runtime, on purpose
rclrs 0.7+ added spin_async beside its synchronous executor. nano-ros
does not offer one. A future polled by tokio or Embassy runs when THAT
scheduler decides, so nano-ros could not state when a callback runs or at
what priority, and the real-time promise is the first thing nano-ros offers.
Concurrent waits are written as callbacks on tiers instead, each tier an OS
thread at a priority nano-ros derives.
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:
| Mode | Cargo features | What works |
|---|---|---|
std | std (default on POSIX) | Everything. POSIX threading, full async runtime. |
no_std + alloc | alloc + a #[global_allocator] | Everything except features that need std::sync::Mutex. Used by FreeRTOS / NuttX / ThreadX / Zephyr. |
no_std cooperative | no std/alloc platform threading (bare-metal board crates) | Cooperative single-task — no threading at all. Used by bare-metal MPS2-AN385. |
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_IMPLEMENTATIONbaked 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 build (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. An embedded cross-build has no ROS 2 runtime to link against, so
the types are generated into the build instead of installed as libraries.
The message definitions still come from your ROS 2 install: nros build
reads them through AMENT_PREFIX_PATH, which you source once per project
(the first configure records it), so the generated layouts and type hashes
are the ones your ROS 2 peers use.
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. 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,
but under a manual kind
zenoh-pico, XRCE-DDS and uORB 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 the Node (node.declare_parameter::<T>(name), rclrs’s
builder, plus get_parameter / set_parameter and the rest), as rclrs does;
the executor’s own parameter methods are deprecated or internal.
If you have older nano-ros code:
rclcpp::ParameterServer<Cap>is gone 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, soros2 param listnever saw what you declared. The C caller-storage family and thenros_executor_*_param*functions are gone too: C uses rclc’srclc_parameter_server_t, which declares into the same executor-owned store.
What we keep
- Same five scalar types:
bool,int64_t,double, string, plus thebool/int64_t/double/byte/stringarray 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 theparam-servicesfeature is enabled. This pulls in ROS 2 wire compat: declared parameters are visible toros2 param list /<node>andros2 param set. The services use thercl_interfacesmessage bounds: alist_parametersreply 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). Aset_parametersrequest is answered per parameter, so a value or name past those bounds getssuccessful = falsewith a reason while the rest are applied. /parameter_events, from every language. A node whose parameters are served publishesrcl_interfaces/msg/ParameterEventwithrmw_qos_profile_parameter_events(RELIABLE, VOLATILE; depth 1000 hosted,NROS_PARAM_EVENTS_DEPTHon an RTOS): one start-up event naming what the node already holds, then one per declare, set and undeclare, withnodeset to the node’s fully qualified name. C++ gets it fromNode::start_parameter_services(), Rust from[param_services](orExecutor::register_parameter_services), C from rclc’snotify_changed_over_dds(default on;falseopts that node out; C++‘sNodeOptions().start_parameter_event_publisher(false)does the same). One publisher per executor serves every node, soparam_servicescosts one publisher slot and one liveliness token on top of the six services per node, and no queryable. An event too big forNROS_PARAM_SERVICE_BUFFER_SIZEis not published (logged as an error), never truncated. To subscribe to OTHER nodes’ events, a hosted C++ build hasrclcpp::ParameterEventHandler: per-parameter callbacks takingrclcpp::Parameter, and whole-event callbacks taking the image’s generatedrcl_interfaces::msg::ParameterEvent. A freestanding build, C and Rust use a plain/parameter_eventssubscription.
What we drop, and why
| Upstream feature | nano-ros status | Why dropped |
|---|---|---|
ParameterDescriptor (description, ranges, read-only, dynamic_typing) | not exposed | descriptor 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_callback | one rclc_parameter_callback_t per C server — rclc’s (old, new, context), fired before the write, false refuses | three 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_t | string reason would force heap or fixed-buffer; ret code captures the binary outcome |
set_parameters_atomically | not exposed | atomic 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 / yaml | not exposed | embedded apps configure via Kconfig / Config struct; runtime overrides come over the wire via ~/set_parameters (when param-services is on) |
| Storage allocation policy | one executor-owned table, NROS_MAX_PARAMETERS slots, keyed by node | no per-node heap; capacity is a build knob, sized once like the executor arena rather than multiplied by the node count |
Storage shape difference
| rclcpp | nano-ros | |
|---|---|---|
| Container | std::map<string, ParameterValue> per node (heap) | one fixed table on the executor, keyed by (node, name) |
| String value | std::string (heap) | fixed slot, copy semantics |
| Array params | std::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 cost | unbounded | NROS_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-zenohfor ROS 2 interop; everything else is a different trade-off.
If you are coming from rclrs:
- The umbrella crate is
nros, not split intorclrs_*.nros::preludegives you everything. Executor::open(&config)is the equivalent ofContext::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
- API surface, type by type → per-language references at Rust / C / C++.
- Why the executor / RMW / platform layers split this way → Architecture Overview.
- Cooperative
no_stdmodel → no_std Support.
Design background
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_zenohis 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
| Axis | nano-ros | micro-ROS |
|---|---|---|
| Project home | NEWSLab NTU | ROS 2 ecosystem (community + Bosch + eProsima) |
| First release | 2024 | 2019 |
| Primary language | Rust (with C + C++ bindings) | C (rclc) |
| User-facing APIs | Rust + C + C++ | C only (rclc); experimental C++ in rclcpp_lite |
| RMW backend choice | Zenoh, XRCE-DDS, Cyclone DDS — pick at compile time | XRCE-DDS only |
| Network model | Peer-to-peer (Zenoh / Cyclone DDS) or agent-based (XRCE) | Agent-based only |
| Bridge process required? | No for Zenoh / Cyclone DDS; yes for XRCE | Yes (Micro-XRCE-DDS Agent) |
| Supported RTOSes | FreeRTOS, NuttX, ThreadX, Zephyr, PX4 (NuttX), POSIX, bare-metal. No ESP32 — that port is retired | FreeRTOS, NuttX, Zephyr, ESP-IDF, POSIX; PX4 is the canonical deployment |
no_std core | Yes — entire client stack compiles no_std + heapless | No — rclc requires libc + a heap |
| Heap usage | Optional on bare-metal (XRCE backend is fully static); required for Zenoh / Cyclone DDS | Required (malloc-based DDS-XRCE client) |
| RT scheduling story | SchedContext classes (Fifo/Edf/Sporadic live in dispatch; TT deprecated-cooperating) + per-platform kernel capabilities — the generated Scheduling Wiring Matrix is the per-cell truth | rclc executor with priority callbacks; no SchedContext / EDF / TT story |
| Multi-executor preemption | Executor::open_threaded per-RTOS via PlatformScheduler trait | Single executor per process |
| Multi-backend bridge in one binary | Yes — Executor::open_with_rmw + multi-Node | No (single XRCE session per process) |
| Discovery | Zenoh liveliness, RTPS SPDP, XRCE-via-Agent | XRCE-via-Agent |
| QoS support | Backend-dependent — per-policy table in RMW vs upstream §7 | Subset of XRCE QoS |
| Formal verification | 148 Kani harnesses + 83 Verus proofs (CDR, scheduling, RMW glue) | None published |
| E2E safety protocol | CRC-32/ISO-HDLC + sequence tracking, EN 50159-mapped (safety-e2e feature) | None |
| ROS 2 distro coverage | Humble, Iron, Jazzy ([image.<id>] ros_edition, lowered to ros-<edition> features) | Humble, Iron, Jazzy |
| Build system | Cargo + CMake + platform tools (west, probe-rs); C/C++ consume via add_subdirectory(<repo>) | colcon + CMake; per-RTOS meta-build (create/configure/build/flash_firmware.sh) |
| Deploy/config model | Entry packages select board/RMW/deploy shape; Bringup packages own launch topology; platform tools build and flash | colcon.meta (hand-tuned static sizing) + configure_firmware.sh -t <transport> flags + hand-coded rclc app |
| Host-side broker | none (Zenoh P2P / Cyclone DDS brokerless); Agent only for XRCE | Micro-XRCE-DDS Agent always required |
| Release model | Source-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) |
| License | MIT OR Apache-2.0 (dual) | Apache-2.0 |
| Governance | Single-academic-lab maintainership today | ROS 2 community + corporate stewards |
| Commercial support | None as of writing | Bosch + 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
SchedContextAPI. - 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: nano-ros’s C API uses 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-spelledrclc_publisher_init_default(C).- micro-ROS’s
rmw_uros_set_custom_transport→ nano-ros’s custom transport pattern vianros_platform_*C ABI (see Custom Transport). - XRCE Agent deployment stays the same — point nano-ros’s XRCE backend at the same Agent.
See also
- Build / config / deploy workflow comparison — historical comparison of the three workflow axes vs micro-ROS, Zenoh-pico, embedded DDS, and Arduino-ROS.
- Choosing an RMW Backend — the backend capability matrix.
- Production Readiness Checklist — pilot-deployment validation.
- Supported Boards — per-board status.
- Migration Guide — for porters coming from rclcpp / rclrs / rclc.
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 written | Growing your workspace |
package.xml | kept, verbatim. What it deploys to is not in it — that is the bringup’s system.toml | Anatomy |
.launch.xml / .launch.py | kept — nros configure resolves them into a SystemModel the image bakes in | Add a launch file |
ros2 launch pkg file | nros build <image>, then run its binary — the launch product is compiled in | Add an image |
colcon build | nros build <image> — it generates the root and hands off to cmake / cargo / west. Running those yourself still works when you point them at the generated build root, and colcon build still works for POSIX C++ workspaces | C/C++ workspaces |
rosdep install | nros setup <board> --rmw <rmw> | Install |
RMW_IMPLEMENTATION=… at runtime | compile-time backend, one line: [system] rmw = "…" in the bringup’s system.toml, then nros build | Switching RMW |
| generated msg packages (ament index) | nros build writes generated/ crates; the cargo patch table goes in the generated settings under build/, not in your package | Message Generation |
find_package(rclcpp) | find_package(nano_ros) — ament-shaped, source-backed | Porting 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 an installed release of the nros CLI + a
compile-time target tuple:
curl -fsSL https://raw.githubusercontent.com/NEWSLabNTU/nano-ros/main/scripts/install.sh | sh
export PATH="$HOME/.nros/bin:$PATH"
nros setup linux --rmw zenoh # Linux host, 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 tight RT loops and a task per entity. 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:
zenohfor ROS 2 interop throughrmw_zenoh_cpp(default).xrcefor agent-based micro-ROS-style deployments.cycloneddsfor direct DDS/RTPS interop withrmw_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_stdtargets withoutalloc. - 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 byspin_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
Configtype loaded fromconfig.tomlor 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
- New user: Setup Compared to Standard ROS 2.
- Application author: Configuration.
- Platform porter: Porting Overview.
- Contributor changing internals: Design Overview.
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 polls | Layer 2 — executor dispatches | |
|---|---|---|
| Verb | Node::create_polling_* (receive side), Node::create_publisher / create_client | Executor::register_*, or rclrs’s Node::create_subscription(topic, callback) / create_service(name, callback) |
| Returns | Owned handle | Handle ID + dispatched closure |
| Receive shape | take() / call() → Promise<T> / try_accept_goal(...) | Closure runs on rx / reply / timer fire |
| Scheduler | Caller (app loop, RTOS task) | Executor::spin_once |
| Typical use | Single-task FreeRTOS, RT-bounded loops | Callback-shaped apps, multi-handler servers |
Rationale
Embedded callers want both. A bare-metal loop that owns its scheduling will not hand that loop to a library; 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 spellingnode.create_subscription::<M, _>("/topic", |m| { ... })— executor dispatches.
The polled receive-side constructors are named create_polling_*
because 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 (a FreeRTOS task per entity, a bare-metal loop).
- The flow is fundamentally request-response — service-client
Promise<Reply>and action-clientsend_goal→get_resultwork 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_onceto dispatch all handlers from one site (less state to thread through main). - The application is callback-shaped (matches
rclcpp/rclpymental 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_initwrites aRawSubscriptioninline inrclc_polling_subscription_t._opaque, andrclc_polling_subscription_take_serializedreads from the inline buffer. Like C++, C splits the two layers by TYPE: a dispatchedrcl_subscription_thas no inline storage at all. Services and clients have the same split:rclc_polling_service_t/rclc_polling_client_twithrclc_polling_service_take_request_raw/rclc_polling_client_take_response_raw, beside the dispatchedrcl_service_t/rcl_client_t. Action servers and clients keep both roads in one type for now. - C, L2 callback —
rclc_subscription_init_defaultcreates 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_rawfor 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_requestandrclcpp::PollClient<S>+send_requestfor L1,rclcpp::PollingActionServer<A>for the L1 action path, the L2 executor-registered callback model via the existingrclcpp::ActionServer<A>API. The L1/L2 split is a TYPE split in C++: one class serving both owners would offer 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.
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 crate | Transport | Description |
|---|---|---|
nros-rmw-zenoh | zenoh-pico | Peer-to-peer via Zenoh protocol; default, ROS-2-interop. |
nros-rmw-xrce-cffi | Micro-XRCE-DDS-Client | Agent-based via DDS-XRCE protocol. |
nros-rmw-cyclonedds | Cyclone DDS | C++ 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.
| Feature | Target | Threading | Network |
|---|---|---|---|
platform-posix | Linux (host board), NuttX | pthreads | BSD sockets |
platform-zephyr | Zephyr RTOS | k_thread_create | Zephyr sockets |
platform-bare-metal | Cortex-M, RISC-V | Single-threaded | smoltcp |
platform-freertos | FreeRTOS | xTaskCreate | lwIP |
platform-nuttx | NuttX RTOS | pthreads | BSD sockets |
platform-threadx | Azure RTOS / ThreadX | tx_thread_create | NetX Duo |
ROS Edition (pick one)
The ROS edition controls wire-format compatibility with specific ROS 2 releases.
| Feature | Description |
|---|---|
ros-humble | Humble Hawksbill wire format (no type hash) |
ros-iron | Iron 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.
| Feature | Description |
|---|---|
std | Enables std-dependent APIs: spin(), spin_period(), system clock |
alloc | Enables heap-dependent APIs: boxed callbacks, the parameter store heap fallback |
safety-e2e | CRC-32 integrity + sequence tracking (AUTOSAR E2E / EN 50159) |
param-services | ROS 2 parameter service handlers (~/get_parameters, etc.). Uses alloc only for its fallback. |
ffi-sync | Wraps transport FFI calls in critical_section::with() for RTOS reentrancy |
sync-spin | Use spin-lock mutex (default) |
sync-critical-section | Use critical-section mutex (for interrupt-driven code) |
unstable-zenoh-api | Zero-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 crate | Underlying platform | CPU | Notes |
|---|---|---|---|
nros-board-mps2-an385 | bare-metal | Cortex-M3 | QEMU MPS2-AN385 + LAN9118 + smoltcp |
The board-over-RTOS pattern — a board that pulls in an RTOS row rather than
being a platform itself — is what NVIDIA’s Jetson Orin SPE would be: a
Cortex-R5F running NVIDIA’s FreeRTOS FSP, i.e. platform = "freertos", never a
platform of its own. nano-ros ships no board crate for it, and the SPE is
not supported for now.
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"] }
How the pieces link
nros carries no RMW crate dependency. Instead:
- RMW. The consuming crate’s
nros-rmw-*dep ships a#[ctor]that callsnros_rmw_<name>_register()at lib load (POSIX.init_array) or the caller invokes it frommain(bare-metal). The backend installs anros_rmw_vtable_tintonros-rmw-cffi’s named registry.Executor::openresolves the registered backend; the concrete session type is alwaysCffiSession(vtable-backed). - Platform.
nros’splatform-*feature resolves tonros-platform-cffi. The canonicalnros_platform_*C symbols ship frompackages/platform/nros-platform-<plat>/src/*.c, linked at the consumer’s build site —posix-c-portfor pure-cargo POSIX builds, the standalonelib<…>.afor 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 canonicalnros_platform_*symbols:zpico-sys/c/zpico/platform_aliases.c(default-onplatform-aliasesfeature) for zenoh-pico, andnros-rmw-xrce/src/platform_aliases.c(always compiled intonros-rmw-xrce-cffi) for XRCE-DDS. (The formerzpico-platform-shim/xrce-platform-shimcrates no longer exist.)
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.
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
| Crate | no_std base | alloc additions | std additions |
|---|---|---|---|
| nros-serdes | CDR ser/de for primitives, heapless types, &str, [T; N] | String and Vec<T> ser/de | (none) |
| nros-core | Time, Duration, Clock (atomic fallback), lifecycle, logger, error types, action types | (none) | Clock::now() via SystemTime, std::error::Error impls |
| nros-rmw | All traits, QoS, sync primitives, safety/E2E protocol | handle_request_boxed() (Box<Reply>) | (none) |
| nros-params | ParameterServer, ParameterValue, all parameter types (heapless) | (none) | ParameterVariant impls for std::string::String, std::vec::Vec |
| nros-node | Executor::open(), create_node(), spin_once(), Promise, pub/sub/service/action, timers (fn pointer callbacks) | Boxed timer callbacks, handle_request_boxed(), parameter services | spin(), spin_period(), ExecutorConfig::from_env(), halt flag |
| nros | Re-exports from above | (same as above) | SpinPeriodResult re-export |
RMW Backend Crates
| Crate | no_std base | alloc additions | std additions |
|---|---|---|---|
| nros-rmw-zenoh | Zenoh-pico RMW implementation, all pub/sub/service/action ops | (none) | (none) |
| zpico-sys | FFI bindings to zenoh-pico C library | (none) | (none) |
| nros-rmw-xrce | XRCE-DDS RMW implementation, all pub/sub/service/action ops | (none) | (none) |
| xrce-sys | FFI 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 sessionExecutorConfig::new(locator)– manual configurationexecutor.create_node(name)– create a nodeexecutor.spin_once(timeout_ms)– single spin iterationexecutor.spin_period(period)– periodic spin withoutstd::thread::sleep
Two-layer API. Every entity kind follows one verb discipline:
- Layer 1 (caller polls) –
Node::create_*returns an owned handle. Caller drivestake/call/try_accept_goal/take_request_rawitself. Good for tight RT loops and task-per-entity FreeRTOS. - Layer 2 (executor dispatches) –
Executor::register_*takes a closure;spin_oncefires 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 withtake()) - L2 —
executor.register_timer(period, || publisher.publish(...)),executor.register_subscription::<M, _>(topic, |msg| { ... }) publisher.publish(&msg)/publish_serialized(&bytes)— publish messages
Services:
- L1 —
node.create_service::<S>(name)(poll withhandle_request()),node.create_client::<S>(name)+client.call(&request)→Promise<Reply>(poll withpromise.take(), or block on it withpromise.wait(&mut executor, timeout)). - L2 —
executor.register_service::<S, _>(name, |req| reply). Service clients keep the L1Promiseshape; the typed callback API isn’t surfaced (onlyregister_service_client_rawexists for byte-level use).
Actions:
- L1 —
node.create_action_server::<A>(name)+try_accept_goal/complete_goal, ornode.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 L1Promiseshape for the same reason as service clients.
Promises:
Promise<'a, T, Cli>– allocation-free promise, borrows client’s reply slotPromise::take()– non-blocking poll for reply, while the executor spins- No async runtime drives the executor:
spin_asyncis retired, because a foreign scheduler, not nano-ros, would decide when callbacks run
Timers:
TimerHandlewith function pointer callbacks (fn())TimerDuration,TimerMode,TimerState
Serialization:
CdrWriter/CdrReader– CDR serialization to/from byte buffersSerialize/Deserializetraits- 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()Clockwith atomic counter fallback (no wall-clock time)
Parameters (local only):
Node::declare_parameter/get_parameter/set_parameter– the node’s parameters, in the executor’s one storeParameterServer– the store itself; its operations are behindnros_params::ParameterServerOps, for a caller that holds a standalone storeParameterValueenum with heapless collectionsParameterDescriptor,ParameterType,ParameterParameterBuilderfor declaring parameters with constraints
Other:
LifecycleState,LifecycleTransition,LifecyclePollingNodeLogger(usescore::sync::atomic)GoalId,GoalStatus,GoalResponse,CancelResponse,CancelReturnCodeQoSProfile,TopicInfo,ServiceInfoSafetyValidator,IntegrityStatus(withsafety-e2efeature)- Sync primitives:
spin::Mutexorcritical-section(feature-selected)
Requires alloc
| API | Location | Why |
|---|---|---|
Serialize/Deserialize for String, Vec<T> | nros-serdes | Heap-allocated containers |
TimerCallback (Box<dyn FnMut() + Send>) | nros-node/timer.rs | Boxed closure for timer callbacks |
Timer::new_with_box(), set_callback_box() | nros-node/timer.rs | Construct/update boxed timer callbacks |
EmbeddedServiceServer::handle_request_boxed() | nros-node/handles.rs | Returns Box<Reply> for large response types |
param-services heap fallback | nros-node/executor/spin.rs | Store + service state when the backing carves none |
Argv parameter overrides (-p, --params-file) | nros-node/ros_args.rs | Owned override strings from the hosted edge |
Parameter services detail: 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 (its operations are
reached through nros_params::ParameterServerOps).
Requires std
| API | Location | Why |
|---|---|---|
Clock::now() (system/steady clock) | nros-core/clock.rs | Uses std::time::SystemTime / UNIX_EPOCH |
std::error::Error for NanoRosError, RclReturnCode | nros-core/error.rs | Trait requires std |
ExecutorConfig::from_env() | nros-node/types.rs | Uses std::env::var() + Box::leak() |
Executor::spin(options) | nros-node/spin.rs | Uses std::thread::sleep(), Arc<AtomicBool> |
Executor::spin_period(duration) | nros-node/spin.rs | Uses std::time::Instant, std::thread::sleep() |
Executor::halt_flag() | nros-node/spin.rs | Returns Arc<AtomicBool> for cross-thread cancellation |
SpinPeriodResult | nros-node/types.rs | Contains std::time::Duration |
ParameterVariant for std::string::String, std::vec::Vec | nros-params/types.rs | Convenience conversions for std types |
Typical Configurations
The nros umbrella does not depend on 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).
Promises (Promise, take()) are available without std or alloc.
Use spin_once() or spin_period() in your main loop.
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 = ["alloc", "env", "log", "rmw-cffi"] }
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.
nros has no std feature: your program is a hosted binary and has std
itself, env reads the process environment (through the C library’s
getenv), and log routes nano-ros’s own diagnostics to whatever log
logger you install.
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 crate | C Library | Allocator by Platform |
|---|---|---|
nros-rmw-zenoh | zenoh-pico 1.10.1 | POSIX: malloc; Zephyr: k_malloc; FreeRTOS: pvPortMalloc; bare-metal: custom bump allocator |
nros-rmw-xrce-cffi | Micro-XRCE-DDS 3.0.1 | POSIX: 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.
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.
Design background
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, so a backend can be written in C or C++ as well as Rust. Graph discovery is exposed through ABI slots that report through visitor callbacks rather than caller-owned tables.
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.
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?
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.
Design background
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
| Concern | rclc | rclcpp | rclrs | nros |
|---|---|---|---|---|
| Target | MCU (Micro-XRCE-DDS) | Desktop / robot | Desktop (alpha) | MCU + RTOS + desktop |
| Language | C99 | C++17 | Rust (std) | Rust (no_std) + C + C++14 |
| Node ownership | App owns node + executor | shared_ptr<Node> + Executor | Arc<Node> + executor | Executor owns the session; node borrows from it |
| Executor model | Single-threaded, polled | Single-/multi-threaded, owned thread | Single-threaded, polled | Single-threaded, polled |
| Spin entry point | rclc_executor_spin_some(&exec, timeout) | rclcpp::spin(node) (blocks, owns thread) | executor.spin() (blocks current thread) | executor.spin_once(timeout_ms) |
| Blocking primitive | None (callback-only) | std::shared_future::wait_for(timeout) | Promise::wait(timeout) (Tokio-style) | Promise::wait(&mut executor, timeout_ms) |
| Async primitive | None | std::shared_future + spin_until_future_complete | impl Future | Promise<T> (manual poll + executor-driven blocking wait; no Future impl) |
| Heap requirement | Optional (static allocator) | Required | Required | Optional (caller buffers; only zenoh-pico transport heap) |
| Threading requirement | None | Required (std::thread) | Required (std::thread) | None (single-threaded valid) |
Promise as the unifying primitive
The same Promise<T> value serves two 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
There is deliberately no third, .await-on-a-foreign-runtime pattern: a
Future impl completes only while something else spins the executor, and then
that runtime’s scheduler, not nano-ros, decides when the reply is handled.
Contrast this with the alternatives:
- rclcpp uses
std::shared_future<Response>, which is bound tostd::threadandstd::condition_variable. There is no way to drive it on a cooperative single-threaded MCU. The blocking helperrclcpp::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 Futurebut no callback-poll path; you must.awaitfrom 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, and wait() polls it in a loop while spinning the executor.
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).
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:
- Single executor type that owns the session – no separate context object, no executor variants.
- No internal spin – the user always owns the spin loop; blocking helpers exist but are wrappers.
- Explicit
&mut Executoron every blocking op – the API enforces the I/O dependency at the call site. - Promise as the unifying primitive – one type for callback-drive and blocking-with-timeout.
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.
Design background
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: a board of yours can live in your own tree (NROS_EXTRA_BOARD_PATH), but registering a new platform still touches a handful of files inside nano-ros itself (the registration steps below) — that is a fork, built the way Contributing describes.
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 target | Custom Platform |
| Bring up nano-ros on a new MCU board | Custom 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
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 what any vendored C build on the platform needs (top-levelarch_profiles,compile,required_env). How zenoh-pico builds there is a[port.<platform>]row in the zenoh backend’s ownnros-rmw.toml, not part of the platform file. An empty file is valid: built-in defaults always produce a working build.nros-board.toml(board package) — the 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 explicit0/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.
| Package | Role |
|---|---|
nros | Facade crate: re-exports and feature-axis enforcement |
nros-core | Message, service, and action type traits |
nros-serdes | CDR serialization |
nros-node | Executor, Node, pub/sub/service/action handles |
nros-rmw | RMW trait definitions (Session, Publisher, Subscriber, etc.) |
nros-platform | Platform trait definitions and ConcretePlatform type alias |
zpico-sys alias TU | Maps zenoh-pico z_* C symbols to nros_platform_* (default-on platform-aliases) |
nros-rmw-xrce alias TU | Maps XRCE-DDS uxr_* C symbols to nros_platform_* |
nros-c, nros-cpp | C 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.
| Trait | zenoh-pico | XRCE-DDS |
|---|---|---|
PlatformClock | Required | Required |
PlatformAlloc | Required (~64 KB heap) | Not needed |
PlatformSleep | Required | Not needed |
PlatformRandom | Required | Not needed |
PlatformTime | Required | Not needed |
PlatformThreading | Required (multi-threaded platforms) | Not needed |
PlatformTcp | Required | Not needed |
PlatformUdp | Required | Not needed |
PlatformSocketHelpers | Required | Not needed |
PlatformNetworkPoll | Bare-metal only | Not needed |
PlatformUdpMulticast | Desktop platforms only | Not needed |
PlatformLibc | Bare-metal only | Not 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:
- Add a
platform-<name>feature tonros-platform/Cargo.tomlthat pulls in your crate as an optional dependency. - Add a
ConcretePlatformtype alias innros-platform/src/resolve.rsgated 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 crate | Target | Threading | Networking |
|---|---|---|---|
nros-platform-posix | Linux, *BSD | pthreads | libc BSD sockets |
nros-platform-freertos | FreeRTOS | FreeRTOS tasks | lwIP |
nros-platform-nuttx | NuttX | pthreads | POSIX sockets |
nros-platform-threadx | ThreadX | ThreadX threads | NetX Duo |
nros-platform-zephyr | Zephyr | Zephyr POSIX | Zephyr sockets |
nros-platform-mps2-an385 | Cortex-M3 bare-metal | Single-threaded | smoltcp |
nros-platform-stm32f4 | STM32F4 bare-metal | Single-threaded | smoltcp |
Further reading
- Custom RMW Backend – implementing a new transport protocol
- Custom Platform – porting to a new RTOS or bare-metal target
- Custom Board Package – bringing up a new MCU board
- Platform API Reference – complete trait signatures and method documentation
- RMW API Reference – RMW trait hierarchy and backend details
- Architecture Overview – concise layer map
- Platform Model – conceptual overview of the three feature axes
Design background
- RFC-0049: hierarchical platform and board config — the two-crate, two-toml layout
- RFC-0042: platform build determinism — the
nros-board.tomlhardware descriptor
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::*; that legacy trait family no longer exists.
Board crates implement
nros-platform::board. The porting surface is theBoardfamily described on this page (Board=BoardInit+BoardPrint+BoardExit, driven byBoardEntry::run). The oldnros-board-common::board_inittrait family has been deleted; every in-tree board usesnros-platform::board. A C, C++ or other-language board uses the flat<nros/board.h>ABI instead.
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 fromfn 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, …), orprintk.BoardExit::{exit_success, exit_failure}() -> !— terminate cleanly (or with failure). QEMU boards callcortex_m_semihosting::debug::exit; real hardware resets or halts; POSIX shellsstd::process::exit.BoardEntry::run(setup)— the boot driver. Implementations live in the family driver crates (nros-board-{posix,freertos,threadx,…}); user Entry pkgmain.rscalls 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:
BoardInit::init_hardware()— clocks, pinmux, MMIO setup.- 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. - Open the executor, build a
RuntimeCtxwith overlay knobs from the launch file / CLI, and invokesetup(&mut runtime). The codegen-emittedrun_plan(runtime)body is whatsetupultimately calls. - Spin the executor to completion (or termination signal).
BoardExit::exit_success()onOk,BoardExit::exit_failure()onError 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/NetworkWaitmixinEarlier 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 have been removed. Two reasons, both structural:
- The blanket impl overlaps the direct
BoardEntryimpls 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:
TransportBringuphad zero implementations and zero call sites;NetworkWaithad one implementation and no callers, because the one place that would have called it — thenros::main!Zephyr arm — routed around it deliberately (ZephyrBoard::wait_link_upcallsstatic inlineZephyr 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 class | Implement | Notes |
|---|---|---|
| Ethernet (smoltcp / lwIP / NetX BSD) | driver up, then DHCP/link gate | both, in run |
| WiFi | same shape — association is L2, DHCP is L3 | both, in run |
| Serial UART only | open at baud | no IP, so no link gate |
| CAN / USB CDC / IVC | link layer only | no IP |
| Bridged-net (threadx-linux veth) | host kernel owns IP | probe the bridge in run |
| Native (host) | None | Host 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 islinux, notposix—apply_tier_affinitycallssched_setaffinitywithcpu_set_t, which libc does not define for macOS.init_transport/wait_link_upno-ops.nros-board-freertos— FreeRTOS-Kernel + lwIP;runspawns 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_transportshellsifup-style logic.nros-board-zephyr— carve-out: Kconfig + DTS own BSP; the crate exposes an inherentwait_link_upover<zephyr/net/net_if.h>. The Rust staticlib cannot take overmainon Zephyr.- Direct-exec (Cortex-M / RV32, no RTOS) has no family crate: each board
implements
BoardEntry::runitself with a single-threadzp_readloop. Anros-board-bare-metalfamily 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-an385is 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 underpackages/boards/nros-board-*still ride the legacynros-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: anros-platform-*impl for the platform traits and anros-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:
Configstruct – network settings (IP, MAC, gateway), transport selection (Ethernet, serial, or WiFi via Cargo features), and a zenoh locator string.run()entry point – initializes hardware, starts the network stack, and calls a user-provided closure with&Config.- 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 innros-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 smoltcpDevicetrait 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_*`.
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.
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:
- Clock – initialize the hardware timer (must happen before any clock reads)
- Cycle counter – enable DWT or equivalent for timing measurements
- RNG seed – seed PRNG with entropy (IP-based hash, semihosting time, etc.)
- Transport – Ethernet driver + smoltcp, or UART + zpico-serial
- 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 andnros-smoltcp.serial– UART link viazpico-serial. Requires a UART driver crate.wifi– WiFi via the platform’s native stack. 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 crate | Transport | Platform | Location |
|---|---|---|---|
nros-board-mps2-an385 | Ethernet + Serial | Bare-metal | packages/boards/nros-board-mps2-an385/ |
nros-board-mps2-an385-freertos | Ethernet (lwIP) | FreeRTOS | packages/boards/nros-board-mps2-an385-freertos/ |
nros-board-nuttx-qemu | BSD sockets | NuttX | packages/boards/nros-board-nuttx-qemu/ (arm virt and rv-virt) |
nros-board-threadx-qemu-riscv64 | NetX Duo | ThreadX | packages/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, its status is on its board page). 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.
| File | Lines | What it is |
|---|---|---|
config/lwipopts.h | 1 | #include the shared defaults |
config/arch/cc.h | 1 | #include the shared defaults |
config/FreeRTOSConfig.h | 3 | CPU clock + NVIC priority bits, then the shared file |
config/s32k344.ld | 7 | memory map + _estack, then the shared section layout |
c/board_s32k344.c | 64 | vector table, Reset_Handler, netif registration |
| per-board delta | 76 | |
src/lib.rs | 57 | the board ZST + four trait impls |
build.rs | 45 | driver + board C + NROS_APP_CONFIG + linker scripts |
Cargo.toml | 27 | dependencies + the rmw-zenoh feature |
| total | 205 |
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 the C and config half
was templated 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-freertoscompile FreeRTOSConfig.h,lwipopts.h,arch/cc.h— shared defaults, inpackages/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) andConfig
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.rsreaches sibling crates with../../walks (manifest.parent().join("nros-board-freertos/config"), drivers,nros-cincludes) — those only resolve insidepackages/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 markerpackages/core/nros-core/Cargo.toml— and derives every include from it (no environment variable names it any more):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: read the
S32DS integration shell on GitHub
for the pattern. 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/BoardExitare pure Cortex-M semihosting andBoardEntryis 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 innros-board-freertoswould take this to one line.Cargo.toml(27) andbuild.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.
The C and config half is already templated (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.
Design background
- RFC-0064: board support organization — integration shells versus board crates
- RFC-0103: one owner per fact — why the SDK root is found, not named by an environment variable (D6)
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_*, STMHAL_*, 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.
Contract
A generic per-kernel board crate (taking nros-board-freertos as the
worked case) exposes:
| Item | Type | Purpose |
|---|---|---|
Config / BaseConfig | structs | Network + transport config; overlay can extend. |
run_bare / run_entry / run_tiers_entry | generic functions | Entry points, generic over a board marker type. Initialise kernel + network, then call your closure inside the app task. |
nros_platform::BoardInit | trait | One 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_netif | weak 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:
- Inherits the generic crate’s
FREERTOS_DIR/THREADX_DIR/ etc. env-var contract (overlay doesn’t override unless needed). - Adds vendor HAL
.csources via its owncc::Build. - 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]
# nano-ros is source-only: these names are NOT on crates.io. The build
# writes the [patch.crates-io] rows that resolve them into the SDK root.
nros-board-freertos = { version = "*" }
nros-platform = { version = "*", 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+Configfromnros-board-freertos. - Defines the
Mps2An385marker and implements thenros_platformboard traits on it. build.rsadds the LAN9118 driver C sources + per-board linker script.- Provides the strong
nros_board_register_netif/nros_board_poll_netifdefinitions 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-freertosnros-board-stm32h7-threadxnros-board-nxp-mimxrt1064-freertosnros-board-renesas-synergy-s7g2-threadxnros-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 extendConfigwith vendor- specific fields and re-implementrunif 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 onnros, 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 # upstream FreeRTOS-Kernel's port enum
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, or point NROS_EXTRA_BOARD_PATH at it. Tag
releases against the nano-ros release you developed against
(nros version), and state that pairing in your README — the
board-trait surface is versioned with the release, not
semver-independent.
Related
docs/design/0012-board-bsp-integration-architecture.md— the layered model + the consumption matrix.- 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 two STM32F4 board crates (nros-board-stm32f4 and a framework variant)
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 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:
| Fact | Value |
|---|---|
| Target | thumbv7em-none-eabihf |
| Link | -C link-arg=-Tlink.x, plus a memory map for the chip |
| Net stack ownership | nanoros-owned — nano-ros supplies both the MAC driver and the IP stack |
| Transports | Ethernet (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: nano-ros’s board-support analysis puts 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 want | Rung | What you write |
|---|---|---|
| A different chip in the F4 family (F407 vs F429) | 1 — declare | one chip value + its memory map |
| A different PHY, same MAC | 2 — hook | the board’s Ethernet init function |
| A proprietary link instead of Ethernet | 1 + 2 | link.custom + the transport vtable |
| An IP stack that is not smoltcp | 3 — escape | the 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"
cargo_config = '''
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]
'''
# 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. (A `[board.*]` table ends the `[[board]]` keys
# above it, so `cargo_config` goes before it.)
[board.entry]
crate_name = "my_stm32f4_board"
signature = "#[my_stm32f4_board::entry]\nfn main() -> !"
supported_netstacks names who brings up the link (here your crate’s
smoltcp glue — nothing outside it). Unknown keys are refused when the
catalog loads, naming the key and the file, so a typo fails loudly rather
than doing nothing.
How the board runs: [board.run] and [board.console]
A board states its launch once, in [board.run]. Real silicon
with no emulator twin leaves it out. Otherwise it holds either
emulator = "<kind>:<machine>" (kind qemu, fvp, renode or wokwi) or
host = true for a host process, never both. A runner-specific sub-table
holds what only that runner reads; for QEMU it is [board.run.qemu]:
[board.run]
emulator = "qemu:mps2-an385"
[board.run.qemu]
system = "arm" # qemu-system-<system>
cpu = "cortex-m3" # -cpu
# machine = "…" # -machine, when it differs from the emulator's word
icount = "shift=auto" # -icount
semihosting = true # -semihosting-config enable=on,target=native
nic_model = "lan9118" # -nic user,model=<m>
[board.console]
kind = "semihosting" # stdio | serial | rtt | semihosting
# usb = "0483:374b" # serial only
# baud = 115200 # serial only
Three things read it, so none of them states it again. nros build prints the
QEMU line (program from the SDK store, then the image) as its next step. The
generated cargo settings file gives the same line to cargo as the triple’s
runner, so cargo run --config <that file> boots the image. The board-support
registry reads emulator from it rather than typing its own copy. A runner in
cargo_config, or [board.zephyr] runner, is refused naming these tables.
How the board is flashed: [board.flash]
Real silicon states how it is programmed in [board.flash]: a
flasher method (west, espflash, probe-rs, openocd, picotool, uf2,
dfu-util, …), the chip in that flasher’s own spelling, and the prepare
steps a person or a relay performs first, in order:
[board.flash]
method = "probe-rs"
chip = "STM32F429ZITx"
prepare = [
{ say = "Set BOOT0 to 1, then press RESET", wait_for = "usb:0483:df11" },
{ run = "relay-ctl on 1" },
]
[board.console]
kind = "serial"
usb = "0483:374b" # the USB serial adapter, <vid>:<pid> hex
baud = 115200
A step is one of say (print an instruction), wait_for (block on an event:
usb:<vid>:<pid>, port:<path>, volume:<label> for a UF2 drive, or
line:<text> on the console) and run (a host command); only say and
wait_for share a step. For a tool no runner covers, command replaces
method with a template, filled from the plan’s own placeholders only
({elf} {bin} {hex} {uf2} {build_dir} {port} {baud} {probe}
{nic}, and {tool:<name>} from the SDK store):
[board.flash]
command = "{tool:pyocd} flash -t stm32f429zi {hex}"
An unknown key, an unknown event kind or an unknown placeholder fails when the
catalog loads. The method word is checked for shape only: flashers are
runner packages, so which words exist is the installed runners’ answer. A
Zephyr board says method = "west" and no more, because which runner and
probe west flash uses is Zephyr’s own runners.yaml. A board may state
both [board.run] and [board.flash] when it has an emulator twin: nros run takes the twin and nros flash the silicon.
An image overrides any of this in its system.toml with
[image.<id>.flash] and [image.<id>.console], which take the same keys
(see Configuration). nros flash runs
these steps.
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 read Verified (in emulator, on host or on
hardware) — scripts/check-board-tiers.py enforces exactly that, which is what moved these
crates out in the first place.
Design background
- RFC-0064: board support organization — why an unwitnessed board leaves the tree, and the MAC-driver estimate
- RFC-0118: target execution model —
[board.run]and[board.flash](D4)
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_FILElisting the board crate’sprj.confand 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:
- A Zephyr 3.7+ workspace managed by west. Zephyr 3.7 is the floor (the official in-tree
zephyr-lang-rustmodule did not exist below it, so nothing earlier can link the nano-ros Rust staticlib); newer LTS lines work too. - All gated SDK packages for the target board. Run
nros doctor --board <name>to check; missing items can be installed withnros setup <board>(ornros setup --tool <t>for a single dependency). Seenros setupin the build commands reference. - nano-ros’s Zephyr module exported on
ZEPHYR_EXTRA_MODULESso Zephyr picks up its Kconfig + DTS roots. The module is the installed SDK root itself (theshare/nano-rosdirectory of the release, which holdszephyr/module.yml);nros buildpasses it for you, and awest buildyou run yourself names it:
export ZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)"
The release you installed is the pin (Pinning a version); nothing in your west.yml names nano-ros.
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 the SDK root; no extra include() is required.
Out-of-tree boards. The lookup searches
<nano-ros>/packages/boards/plus every root named byNROS_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 aspackages/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
| Source | Effect |
|---|---|
BOARD | Set to NROS_BOARD_ZEPHYR_ID (from the board’s descriptor) if the user did not pass -DBOARD=... on the command line. |
EXTRA_CONF_FILE | The 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_FILE | The board crate’s per-board DTS overlay is appended. Consumer overlays are preserved and layered after. |
NROS_RMW | Defaulted to NROS_BOARD_DEFAULT_RMW (from the board’s descriptor) when the consumer did not pass -DNROS_RMW=.... |
NROS_BOARD_RUNNER | The Zephyr emulator name derived from the descriptor’s [board.run] emulator (fvp: gives armfvp), cached. Zephyr does not read it; nano_ros_entry() reads it as “an embedded board was imported”. |
NROS_BOARD_RUST_SUPPORT_MODULE | Appended 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() asks the nros CLI 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 buildcommand 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, orcyclonedds– see Choosing an RMW). The board crate’s default is used only when nothing is set. - Layer extra Kconfig / DTS. Set
EXTRA_CONF_FILEandDTC_OVERLAY_FILEafter the call tonano_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 states its emulator once, in [board.run] emulator (for the FVP,
fvp:FVP_BaseR_AEMv8R), which reaches cmake as NROS_BOARD_RUNNER. Zephyr’s own
board definition selects the emulator, and its run 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) into the
SDK store, under ~/.nros/sdk/arm-fvp/<version>/. When the app is an image of
an nros workspace, nros run <image> finds the model and boots it. A
west build -t run you drive yourself needs Zephyr’s armfvp runner pointed
at the directory that holds FVP_BaseR_AEMv8R:
export ARMFVP_BIN_PATH=<directory holding FVP_BaseR_AEMv8R>
west build -d build -t run
There used to be a
west fvp runverb here. It readNROS_BOARD_RUNNERfromCMakeCache.txt, ran that same resolver, exportedARMFVP_BIN_PATH, and then exec’dwest build -t run— so it was env wiring in front of the stock verb, and env wiring belongs in your environment. It has been retired.
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.confinEXTRA_CONF_FILE. It is already there; doing it again either duplicates or fights the layering order. - Don’t hardcode
BOARD=<id>in abuild.sh. The call sets it; hardcoding it short-circuits thenros board infoinspection and the drift audit. - Don’t carry your own copy of
boards/<id>.conforboards/<id>.overlaymirroring the board crate’s. Vendor a delta only – the base ships in the crate. - Don’t reimplement the FVP launch in a
build.sh. ExportARMFVP_BIN_PATHand usewest build -t run; Zephyr’s ownarmfvprunner coversFVP_BaseR_AEMv8Rand the other supported simulators with the right CLI flags, the same waywest buildcovers compilation. - Don’t
include()files from the SDK root’scmake/directly. The public surface isnano_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:
- Find the per-board Kconfig and overlay entries in the current
CMakeLists.txt/build.sh. Anything listing the board crate’sprj.conf, board overlay, or HWv2 snippet – delete it. Keep only entries that point at the consumer’s own deltas. - Find any hardcoded
BOARD=<id>string (in CMakeset(BOARD ...), inwest build -b <id>, or inbuild.sh). Delete it.nano_ros_use_board()will set it fromboard.cmake. - Replace
find_package(Zephyr) + ... + manual FVP launchwith the canonical pattern above – onenano_ros_use_board()call, thenfind_package(Zephyr), thenproject(), thentarget_sources(app ...). Replace the FVP shell script call withwest fvp run -d build. - 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
- The
BoardTrait Family – for implementers of a new board crate. This chapter is the consumer-side dual. - Custom Board Package – the full board-crate authoring guide.
- Vendor Overlay Board Crate – the lighter “I just want to override one field” path.
- Build commands reference –
nros setup,nros doctor,nros board info. - RMW backends – the menu of values for
-DNROS_RMW=....
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
| Profile | Workflow | Recommended 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. |
| NuttX user (any board) | NuttX apps/external/ + Kconfig. | NuttX integration shell — symlink under apps/external/. |
| PX4 user | PX4 build pipeline. | PX4 integration shell — EXTERNAL_MODULES_LOCATION. |
| Niche RTOS / vendor fork | Stock 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:
| Kernel | Crate | SDK env vars you set |
|---|---|---|
| FreeRTOS + lwIP | nros-board-freertos | FREERTOS_DIR, FREERTOS_PORT, LWIP_DIR, FREERTOS_CONFIG_DIR |
| ThreadX + NetX-Duo | nros-board-threadx | THREADX_DIR, THREADX_CONFIG_DIR, NETX_DIR, NETX_CONFIG_DIR |
| NuttX | nros-board-nuttx | NUTTX_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
# build-generated [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"] }
# written by the build from package.xml's <depend>std_msgs</depend>:
std_msgs = { path = "generated/std_msgs", default-features = false } # nros-managed
#![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 # upstream FreeRTOS-Kernel's port enum
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:
- Depends on the matching generic board crate.
- Defines a board marker type and implements the
nros_platformboard traits on it (BoardInit::init_hardware()and siblings); re-exportsConfig. - Provides strong definitions of the generic crate’s weak C network
hooks (
nros_board_register_netif,nros_board_poll_netif). - Pulls vendor HAL
.csources via its ownbuild.rscc-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_*, NXPfsl_*, Espressifesp_*, RenesasR_*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, NuttXdefconfig, ESP-IDFsdkconfig). - 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.
Related reading
- Vendor Overlay Board Crate — the overlay cookbook.
- Custom Board Package — older guide covering monolithic board crates (legacy pattern).
add_subdirectory(third_party/nano-ros)— root CMake entry for vendor-IDE consumers.- Platform Model — Boards vs Platforms;
Layer 1’s
<nros/platform_*.h>contract overlays sit on top of. - RTOS Cooperation — what the runtime expects from the OS underneath the platform layer.
Adding a Board to the Registry
Every support claim the book makes about a board (its status, breadth,
emulator and run line) is generated from one row of the board-support
registry and the board’s own descriptor. A board you add out of tree needs no
row: it runs from its descriptor’s [board.run], [board.flash] and
[board.console] tables. A board contributed to nano-ros gets a row, so its
status appears on Supported Boards.
How to add a new board
-
Pick the matching RTOS path. Cortex-M3, M4 or M7 with an RTOS: start from the FreeRTOS or Zephyr starter. Cortex-M0+: the bare-metal starter, which is limited (see the Cortex-M0+ caveat on Supported Boards). Cortex-A or RISC-V64: NuttX or Zephyr. Xtensa has no supported path.
-
Start from an existing board package, or scaffold one. The board packages nano-ros ships cover the QEMU machines and the reference development kits;
nros board listnames them andnros board info <board>prints one’s resolved descriptor. For a board of your own, scaffold a package outside nano-ros:nros board new my-board --platform freertos --out-of-tree ./boardsIt prints the
NROS_EXTRA_BOARD_PATHvalue through which nano-ros finds the package. A real-hardware board needs a thin package that supplies startup code, a linker script andBoardIdle::wfi()(bare-metal), or that wraps the RTOS’s BSP (FreeRTOS, Zephyr). The Custom Board Package page describes its parts, and the STM32F4 out-of-tree worked example is a complete one. -
Say how the board runs. The descriptor,
nros-board.toml, states it once:[board.run]for a host process or an emulator,[board.flash]and[board.console]for real silicon.nros runand the generated board pages read those tables; nothing else repeats them. -
Build and run the talker and listener. Use your platform chapter’s example, and check the pair against a stock ROS 2 peer (for zenoh,
RMW_IMPLEMENTATION=rmw_zenoh_cpp). -
Contribute it, if you want a status. A board that stays out of tree works without a registry row; it simply has no status on the generated pages. To have nano-ros carry a status for it, open an issue or a pull request on GitHub with the board package and the commands that build, flash and run it. The maintainers add its registry row, and the status word is derived from the evidence the row records.
What a registry row records
A row records where the board’s image runs in the evidence nano-ros holds (nowhere, on the host, in an emulator, or on hardware), how often that run happens (on every merge, daily, weekly, or by hand, with the date of the last manual run), and, for a board that inherits its breadth from its platform family, which family’s evidence it inherits. The status word is generated from those facts and is never typed by hand. Board Support Levels lists the words and what each means.
Design background
- RFC-0114: board support registry primitives
- RFC-0118: target execution model — the
[board.run],[board.flash]and[board.console]tables
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
| Trait | Methods | Purpose |
|---|---|---|
PlatformClock | clock_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)
| Trait | Methods | Purpose |
|---|---|---|
PlatformAlloc | alloc(), realloc(), dealloc() | Heap allocation. zenoh-pico needs ~64 KB. |
PlatformSleep | sleep_us(), sleep_ms(), sleep_s() | Delay. On bare-metal with smoltcp, poll the network during busy-wait. |
PlatformRandom | random_u8() through random_u64(), random_fill() | PRNG for session IDs and protocol nonces. |
PlatformTime | time_now_ms(), time_since_epoch() | Wall-clock time for logging. Return monotonic time if no RTC. |
PlatformThreading | Tasks, mutexes, recursive mutexes, condvars (19 methods) | OS threading primitives. Single-threaded platforms provide no-op stubs. |
Networking
| Trait | Methods | Purpose |
|---|---|---|
PlatformTcp | open(), read(), send(), close(), … | TCP client and server sockets. |
PlatformUdp | open(), read(), send(), close(), … | UDP unicast sockets. |
PlatformSocketHelpers | set_non_blocking(), accept(), close(), wait_event() | Socket utility operations. |
Optional
| Trait | When needed |
|---|---|
PlatformUdpMulticast | Desktop platforms using zenoh scouting. Not needed for embedded client mode. |
PlatformNetworkPoll | Bare-metal platforms using smoltcp. Called during sleep to process packets. |
PlatformLibc | Bare-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
There are no platform shim crates. 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:
nros-rmw-xrce-cffi has no platform-<rtos> features; 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 no longer exists and its
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 intraits.rsdocument the contract, but the ZST uses inherentimplblocks. c_voidpointers for handles. Mutex, condvar, and task handles are opaque#[repr(C)]structs sized to hold your RTOS handle. Cast the*mut c_voidto 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> ... */
2. Link
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-zenohfeature is the lowering of the declared RMW: the backend is declared once insystem.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 Design background).
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_tcp_open, _z_tcp_read, 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_tcp_open, _z_tcp_read, 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
Executorvalue 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.bssstatic 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=autofor QEMU targets so the virtual clock tracks wall time during WFI.
Next steps
- Custom Board Package – create a board crate that ties your platform to specific hardware
- Platform API Reference – complete method signatures for all traits
- Platform Porting Pitfalls – QEMU networking and timing issues
Design background
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:
| Symbol | Kind | Purpose |
|---|---|---|
NanoRos::Platform | ALIAS library | Aliased to the platform-specific INTERFACE target; linked into NanoRos::NanoRos umbrella by the root CMakeLists. |
nros_platform_<plat>_iface | INTERFACE library | Carries the platform staticlib + host-system libs + transitive deps. |
nros_platform_link_app(target) | function | Per-app fixups: linker script, startup objects, ISR vectors, RTOS-specific final-link tweaks. Empty for POSIX. |
NROS_PLATFORM_LINK_FEATURES | cache variable | Default 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 Platform — Rust-side trait implementations (clock, alloc, threading, networking)
- Custom Board Package — the Rust board crate that carries the linker script and board-specific drivers
- Build as a CMake subdirectory
— user-facing intro to
add_subdirectory(nano-ros)
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 — 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/:
| Header | Purpose | Symbol count |
|---|---|---|
platform.h | Core kernel surface: clock, sleep, alloc + heap stats, threading, scheduler, time, yield, random, critical section, opaque wake primitive. | 59 |
platform_net.h | Network surface: TCP/UDP/multicast socket helpers, endpoint resolution, IVC. | 29 |
platform_timer.h | Periodic 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. CI runs the gate on every
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)
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
- 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.
- 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 (
-1fortask_initon single-task RTOS,0formutex_*on single-core no-preempt hardware) rather than skipping the symbol. - 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 intojust <port> test-c-port. The smoke tests are the runtime parity layer that the drift gate cannot enforce. - For platforms that emit
critical_section::Impl(i.e. anything that consumes acritical-section-using crate), make sure the binary pullsnros-platform-critical-sectiononce — it does thecritical_section::set_impl!(PlatformCs)registration against the canonical externs. Binaries that don’t need the global registration don’t pay for it. - Update the drift gate’s smoke list (the
HEADERS_REQUIRE_MACROarray inscripts/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.
| Capability | Rust trait | C section | Symbols |
|---|---|---|---|
| Clock | PlatformClock | clock_ns | nros_platform_clock_ns |
| Sleep | PlatformSleep | sleep_ms | nros_platform_sleep_ms |
| Alloc | PlatformAlloc | malloc/realloc/free | nros_platform_alloc{,_realloc,_free} |
| Threading | PlatformThreading | mutex/condvar/task | nros_platform_{mutex,condvar,task}_* |
| Critical section | PlatformCriticalSection | per-CPU interrupt mask | nros_platform_critical_section_{acquire,release} |
| Scheduler | PlatformScheduler | task hints | nros_platform_scheduler_* |
| Time | PlatformTime | wall-clock | nros_platform_time_now_ns |
| Yield | PlatformYield | cooperative yield | nros_platform_yield_now |
| Random | PlatformRandom | best-effort RNG | nros_platform_random_* |
| Wake | PlatformThreading (wake methods) | opaque binary-semaphore | nros_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. (An ESP-IDF port was once a sixth; it has been retired.) critical_sectionpromoted to a canonical platform capability owned by every port’s C body;nros-platform-critical-sectionis 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
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 allocation gate (check-no-direct-kernel-alloc.sh) enforces this for allocation; the same pattern extends to the other scalars (the vendoredz_*/uxr_*funnel is checked by a CI relink lane). -
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’stx_byte_allocatethread-stack / NetX-pool sites are not allocation-gate 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 (out of scope for the platform split)
until there is a consumer that needs portable socket structs. Threads
and sync are the least likely to move (deepest struct coupling).
Design background
- RFC-0034: platform layer split — the scalar / opaque-struct boundary (D2) and the allocation gate (D8)
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-apimirrors 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_nsaccurately. Synthesizing nanoseconds from a 1 ms tick is a lie that hides clock resolution from the caller. u64nanoseconds 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_usreturns the raw counter,clock_msdivides by 1000. Platforms with a slow tick (1 ms) implementclock_usasclock_ms * 1000and 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: 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 callnetwork_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/closebecause the backend opens different socket types for each. PlatformSocketHelperscarries the cross-cutting operations –set_non_blocking,accept, genericclose, andwait_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:
| API | Pros | Cons | Verdict |
|---|---|---|---|
clock_ns only | Single function, finest resolution | 64-bit math on every call; lies on 1 ms-tick MCUs | Rejected |
clock_ms only | Cheap, fits zenoh-pico’s lease math | Insufficient resolution for sub-millisecond protocol timing | Insufficient |
Both clock_ms and clock_us | Each call is cheap and honest about its resolution | Two functions to implement | Chosen |
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
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
clock_ms | No | No | Required for any backend | Monotonic; wraparound-free for system lifetime |
clock_us | No | No | Required for any backend | Same monotonic base as clock_ms |
PlatformAlloc
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
alloc | No | Yes (null) | Required for zenoh-pico | Caller checks for null and propagates as RMW error |
realloc | No | Yes (null) | Required for zenoh-pico | Existing block must be preserved on failure |
dealloc | No | No | Required for zenoh-pico | dealloc(null) is a no-op |
PlatformSleep
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
sleep_us | Yes | No | Required for zenoh-pico | Bare-metal must call network_poll() during busy-wait |
sleep_ms | Yes | No | Required for zenoh-pico | Same |
sleep_s | Yes | No | Required for zenoh-pico | Same; typically implemented as sleep_ms(s * 1000) |
PlatformRandom
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
random_u8 / _u16 / _u32 / _u64 | No | No | Required for zenoh-pico | xorshift32 is sufficient; not cryptographic |
random_fill | No | No | Required for zenoh-pico | Fills len bytes; no upper bound check |
PlatformTime (wall-clock)
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
time_now_ms | No | No | Required for zenoh-pico | Return clock_ms if no RTC |
time_since_epoch | No | No | Required for zenoh-pico | Return (monotonic_s, 0) if no RTC |
PlatformThreading – tasks
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
task_init | No | Yes (-1) | Return -1 on single-threaded | zenoh-pico’s lease task must degrade gracefully |
task_join | Yes | Yes | Return 0 (success) | Single-threaded never spawned a task to join |
task_detach | No | Yes | Return 0 | Same |
task_cancel | No | Yes | Return 0 | Same |
task_exit | No | No | No-op | Caller is the only thread |
task_free | No | No | No-op | No allocation to free |
PlatformThreading – mutexes (regular and recursive)
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
mutex_init / mutex_rec_init | No | Yes | Return 0 | Single-threaded has no mutex state |
mutex_drop / mutex_rec_drop | No | Yes | Return 0 | Same |
mutex_lock / mutex_rec_lock | Yes | Yes | Return 0 (success) | Single-threaded: no contention possible |
mutex_try_lock / mutex_rec_try_lock | No | Yes | Return 0 | Always “succeeds” on single-threaded |
mutex_unlock / mutex_rec_unlock | No | Yes | Return 0 | Same |
PlatformThreading – condition variables
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
condvar_init | No | Yes | Return 0 | No state on single-threaded |
condvar_drop | No | Yes | Return 0 | Same |
condvar_signal | No | Yes | Return 0 | No waiter to wake |
condvar_signal_all | No | Yes | Return 0 | Same |
condvar_wait_until | Yes | Yes (timeout) | Return 0 immediately | Same; blocking C++ Future::wait deadlocks on single-threaded (use non-blocking polling instead) |
PlatformTcp
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
create_endpoint | Yes (DNS) | Yes | Required for zenoh-pico | Backed by getaddrinfo or platform equivalent |
free_endpoint | No | No | Required | Mirrors freeaddrinfo |
open | Yes | Yes | Required | Connect with timeout in ms |
listen | No | Yes | Optional (server mode) | Bare-metal client typically returns -1 |
close | No | No | Required | Shutdown + close |
read | No (after set_non_blocking) | Yes (usize::MAX) | Required | Returns 0 if no data; must be non-blocking for zenoh-pico’s poll loop |
read_exact | Yes | Yes | Required | Used for length-prefixed framing |
send | Yes | Yes | Required | May block on socket buffer full |
PlatformUdp
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
create_endpoint | Yes (DNS) | Yes | Required for zenoh-pico | Same as TCP but SOCK_DGRAM |
free_endpoint | No | No | Required | |
open | No | Yes | Required | UDP socket open is non-blocking |
close | No | No | Required | |
read | No | Yes | Required | recvfrom; returns 0 if no datagram |
read_exact | Yes | Yes | Required | Rarely used (UDP is message-oriented) |
send | Yes | Yes | Required | sendto |
PlatformSocketHelpers
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
set_non_blocking | No | Yes | Required | Critical: enables non-blocking read |
accept | No (after set_non_blocking) | Yes | Optional | Server-mode only |
close | No | No | Required | Generic socket close |
wait_event | Yes | Yes | Required | Multi-threaded: yields to scheduler. Single-threaded: spins + network_poll |
PlatformUdpMulticast (optional)
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
mcast_open | Yes | Yes | Stub returns -1 | Skipped on embedded targets without scouting |
mcast_listen | Yes | Yes | Stub returns -1 | Same |
mcast_close | No | No | No-op | |
mcast_read / mcast_read_exact | Varies | Yes | Stub returns usize::MAX | |
mcast_send | Yes | Yes | Stub returns usize::MAX |
PlatformNetworkPoll (bare-metal only)
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
network_poll | No | No | OS-driven platforms: no-op | Advances smoltcp once; called by sleep_* and wait_event |
PlatformLibc (bare-metal without libc)
| Method | Blocking? | May fail? | Unsupported fallback | Notes |
|---|---|---|---|---|
strlen, strcmp, strncmp, strchr, strncpy | No | No | Linker provides if libc present | Same semantics as the C standard |
memcpy, memmove, memset, memcmp, memchr | No | No | Same | Same |
strtoul | No | Yes (errno) | Same | Used by zenoh-pico to parse locator strings |
errno_ptr | No | No | Same | Returns pointer to thread-local (or static) errno |
Cross-cutting rules
Two contract rules apply to every trait method:
-
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.
-
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::MAXfor byte counts) instead of panicking. The exception isPlatformClock– if the clock cannot be read, the system is fundamentally broken and there is nothing useful to return.
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, orwifi. - 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:
| Layer | Owns | Crates / files |
|---|---|---|
| L0 — canonical C ABI | #[repr(C)] struct + abi_version: u32 field + four unsafe extern "C" fn pointers + user_data: *mut c_void | nros-rmw::custom_transport (Rust source); <nros/transport.h> is the cbindgen-emitted C header |
| L1 — language wrappers | mechanical glue, no new design decisions | nros-rmw::set_custom_transport (Rust); rclc_set_custom_transport (C); rclcpp::set_custom_transport (C++) |
| L2 — typed app API | n/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
_reservedregion. 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 throughuser_data.
Threading contract
| Constraint | Rationale |
|---|---|
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 (there is no ALREADY_INIT return code). |
Return-code conventions
open/writereturn0(NROS_RMW_RET_OK) on success, a negativercl_ret_t(e.g.NROS_RMW_RET_TIMEOUT,NROS_RMW_RET_ERROR) on failure.readreturns the non-negative byte count on success (may be less thanlen); a negativercl_ret_ton error / timeout.closereturns 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=trueto the backend’sinit_transport_from_custom_ops(framing)for byte-stream transports (UART, USB-CDC). Passframing=falsefor packet-oriented transports (UDP, BLE GATT). - zenoh-pico: framing is built into the wire protocol — always
framing=falseregardless of the underlying medium.
Backend coverage
| Backend | Status |
|---|---|
| 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
<nros/transport.h>— C header<rclcpp/transport.hpp>— C++ headernros_rmw::custom_transport— Rust source- Custom platform — when you need more than just transport
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-rmwtraits directly. - C/C++ path – fill in
nros_rmw_vtable_tand register it at startup vianros-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
(The vocabulary follows upstream rmw: Subscription, ServiceTrait,
ClientTrait, and the create methods below match. The older names
Subscriber, ServiceServerTrait and ServiceClientTrait no longer exist.)
Most methods have default implementations. The required methods per trait are:
| Trait | Required methods |
|---|---|
Rmw | open() |
Session | create_publisher(), create_subscription(), create_service(), create_client(), close() |
Publisher | publish_raw(), buffer_error(), serialization_error() |
Subscription | take_serialized(), deserialization_error() |
ServiceTrait | take_request(), send_response() |
ClientTrait | send_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”:
| Variant | When to use |
|---|---|
Timeout | Bounded wait elapsed (drive_io). |
WouldBlock | Resource momentarily unavailable; caller should retry. Distinct from NoData — NoData means the queue is empty, WouldBlock means it’s contended. |
NoData | Non-blocking receive found the queue empty. |
BufferTooSmall | Caller’s &mut [u8] smaller than the next message. |
MessageTooLarge | Incoming message exceeds the backend’s static capacity. |
InvalidArgument | NULL pointer, out-of-range value, missing required config. |
Unsupported | Backend genuinely cannot perform this operation (e.g., uORB has no service surface). |
IncompatibleQos | Endpoints’ QoS profiles differ in a way the backend cannot reconcile. |
TopicNameInvalid | Topic / service name failed validation (empty, too long, illegal characters). |
BadAlloc | Allocation failed on a heap-equipped backend. |
LoanNotSupported | Lending requested on an entity that doesn’t support it. |
ConnectionFailed / Disconnected | Transport-level failures. |
PublisherCreationFailed / SubscriberCreationFailed / ServiceServerCreationFailed / ServiceClientCreationFailed | Catch-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 intoRmwConfig— 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 shippedExecutorConfig::from_env()only reads the middleware-agnosticNROS_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 instatic mut. Theopen(self, …)signature makes it natural to move the configured transport into theSessionreturn value, which then owns the connection for the rest of its lifetime. A backend that still usesstatic mutsession-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. 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_iomay block up totimeout_ms; it must not hold application-visible locks across the wait.publish,take, andsend_responsemay run concurrently from different executor threads — your backend handles serialisation.take,take_request, andtake_responseare non-blocking: return0/NROS_RMW_RET_NO_DATAif nothing is ready. The executor will retry afterdrive_io.- There is no blocking client slot (
call_rawno longer exists):send_request+take_responseis 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:
| Field | Size | Meaning |
|---|---|---|
sequence_number | 8 bytes | int64 LE — monotonic per publisher |
timestamp | 8 bytes | int64 LE — source nanoseconds |
gid | 16 bytes | random 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-encodedgid_lengthprefix. - 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-entity | Kind | Name suffix |
|---|---|---|
send_goal | Service | _action/send_goal |
cancel_goal | Service | _action/cancel_goal |
get_result | Service | _action/get_result |
feedback | Topic (pub) | _action/feedback |
status | Topic (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 distro | Type hash |
|---|---|
| Humble | literal string "TypeHashNotSupported" |
| Iron / Jazzy / Rolling | RIHS01_<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
by nano-ros itself, not read out of an ament index. 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_ALLbuffering 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_effortpublisher matched with areliablesubscriber 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).
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):
| backend | steady-state | create / init |
|---|---|---|
| Cyclone DDS | 1 | 8 |
| XRCE-DDS | 0 | 7 |
| uORB | 0 | 3 |
| zenoh-pico | 0 | 0 |
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.
The ABI now has graph-enumeration slots —
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. Which backends fill each slot varies; 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) 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.
The C headers are the single source of truth for this ABI, so a backend can 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
| Aspect | ROS 2 rmw | nros-rmw |
|---|---|---|
| Language | C API (rmw/rmw.h) | Rust traits |
| Dispatch | Runtime plugin loading (shared library via rmw_implementation) | Link-time selection; calls cross a C ABI vtable registered at startup |
no_std | No (requires libc, heap, POSIX) | Yes — but “no heap” is a property of the BACKEND, not of the abstraction (see below) |
| Error model | rmw_ret_t integer codes | rmw_ret_t at the ABI, using upstream rmw’s VALUES; 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: “no heap” is not true of every backend, and the per-backend allocation-site count answers it precisely. 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 count — 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:
| Field | ROS 2 | nros-rmw |
|---|---|---|
| History (keep last/all) | Yes | Yes |
| Depth | Yes | Yes |
| Reliability (reliable/best-effort) | Yes | Yes |
| Durability (volatile/transient local) | Yes | Yes |
| Deadline | Yes | No |
| Lifespan | Yes | No |
| Liveliness (automatic/manual) | Yes | No |
avoid_ros_namespace_conventions | Yes | No |
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 (like upstream, there is no blocking call at the RMW level):
pub trait ClientTrait {
// Async: send request (returns its sequence id), poll for reply separately
fn send_request_raw(&mut self, request: &[u8]) -> Result<i64, Self::Error>;
// A reply carries the sequence id of the request it answers
fn take_response_raw(&mut self, reply_buf: &mut [u8]) -> Result<Option<(usize, i64)>, Self::Error>;
}
Blocking waits are composed above the RMW by the executor (which keeps driving drive_io between polls).
Every client surface matches a reply to its request by that sequence id
(nros_rmw::RequestCorrelator): a reply is delivered only to the
request it names, and a reply whose request was cancelled, abandoned or timed
out is dropped rather than handed to the next request. A backend that cannot
assign ids leaves NROS_RMW_SEQUENCE_ID_UNKNOWN, and the client then keeps one
request in flight.
APIs Present in ROS 2 rmw but Absent in nros-rmw
Several rows below have gained ABI slots since this table was first written, 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 API | Purpose | Why absent |
|---|---|---|
rmw_node_t / rmw_create_node() | Node lifecycle at RMW level | No longer absent: 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 waiting | Replaced by drive_io() + per-entity has_data() |
rmw_guard_condition_t | Wake wait set from application code | Replaced by register_waker(&Waker) |
rmw_event_t | QoS 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 introspection | Slot declared as a VISITOR callback — the backend walks its own discovery data, the caller owns no table. Per-backend wiring is in the feature matrix. |
rmw_get_node_names() | Node discovery | Same — declared as a visitor. |
rmw_count_publishers() / rmw_count_subscribers() | Graph statistics | Same — declared as a visitor; per-backend wiring is in the feature matrix. |
rosidl_message_type_support_t | C type support tables for serialization | Replaced by TopicInfo string metadata |
rmw_serialize() / rmw_deserialize() | Standalone serialization | CDR handled by nros-serdes |
rmw_borrow_loaned_message() | Zero-copy shared memory publish | Not supported (smoltcp/zenoh-pico don’t use shared memory) |
| Content-filtered topics | Server-side topic filtering | Not supported |
APIs Present in nros-rmw but Absent in ROS 2 rmw
| nros-rmw API | Purpose |
|---|---|
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
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.
| side | component | observed |
|---|---|---|
| host | rmw_zenoh_cpp router + RMW | zenoh-c 1.6.2 (ROS 2 Humble, measured 2026-08-16) |
| firmware | zenoh-pico | 1.10.1 (the pin’s version.txt) |
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: the same package has been
measured moving its vendored zenoh 1.2.0 → 1.8.0 in a patch-level bump, so the
package version is not a reliable way to state which zenoh you run.
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.
Design background
- RFC-0054: C headers as the ABI single source of truth
- RFC-0075: zenoh router provenance and the unstable seam
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.
Vocabulary. The vtable uses rmw’s names: the entity types are
rmw_subscription_t/rmw_service_t/rmw_client_t, the session slots arecreate_session/destroy_session, and there is no blockingcall_rawslot (send_request+take_responseis the one request/reply path). Transport hints (tx_express,rx_buffer_hint) live in per-create options structs, not inrmw_qos_profile_t. The older names (subscriber,service_server,service_client,open/close) no longer exist.
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
| Concern | upstream rmw.h | nros-rmw-cffi |
|---|---|---|
| Plugin loading | dlopen("librmw_*.so") at runtime | Single vtable registered at init |
| Init sequence | rmw_init_options_t → rmw_context_t → entities | One create_session() call, returns the session |
| Entity types | rmw_publisher_t / rmw_subscription_t / rmw_service_t / rmw_client_t | Typed-with-opaque-tail: rmw_publisher_t / _subscription_t / _service_t / _client_t (visible metadata + opaque backend_data) |
| Wait | rmw_wait(waitset, timeout) blocks the caller | drive_io(session, timeout_ms) drives I/O once |
| Serialization | typesupport-driven (rosidl) | Pre-serialized CDR bytes only |
| Graph queries | rmw_get_topic_names_and_types, … | None |
| QoS profiles | Full DDS profile match between endpoints | Same field set; per-backend support advertised; synchronous IncompatibleQos on create instead of runtime mismatch event |
| DDS events | rmw_event_t (rmw_take_event) | None |
| Loaned messages | Optional rmw_borrow_loaned_message | First-class loan_publish / loan_recv |
| Error returns | rmw_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);
}
Asking which backend you are on. Upstream answers
rmw_get_implementation_identifier() once per process. Here the answer
is per node, because an image may register several backends:
Node::rmw_implementation_identifier() (Rust),
rclc_node_get_rmw_implementation_identifier(node) (C) and
rclcpp::Node::rmw_implementation_identifier() (C++) read the
backend’s get_implementation_identifier slot, or the name the vtable
was registered under when the slot is NULL. The answer is the nano-ros
backend name — "zenoh", "cyclonedds", "xrce", "uorb" — never a
stock rmw_zenoh_cpp-style name: upstream’s identifier is a
process-local tag that never crosses the wire, so a stock name would buy
no interop and would mislead code that branches on it.
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_namepoints to a backend-allocated string copied atcreate_publishertime. Ours points to caller (runtime) storage that outlives the publisher — no allocation per entity. - No
implementation_identifierfield. 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_messagesmatches upstream in shape, but the runtime DERIVES it. Same bool, same name, same meaning —trueif the backend exposes the loan primitive (the CDR-byte zero-copy loan path) — except that a backend does not write it. Its value is exactlyvtable->borrow_loaned_message != NULL, because that is the same fact stated twice, and a separately written flag can disagree with the slot. The flag does not steer dispatch: the publish path 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 embeddedrmw_publisher_options_tstruct 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:
| Phase | Upstream rmw_wait | nano-ros drive_io |
|---|---|---|
| Build the wait set | Executor rebuilds a waitset every spin_once, adds every entity | Executor registers entities once at construction; no per-spin rebuild |
| Block | rmw_wait blocks the thread on a kernel waitable (DDS WaitSet, condvar, kqueue) until any entity is ready or timeout | drive_io blocks (sleep-model backends) or polls (poll-model backends) for up to timeout_ms |
| Signal readiness | Wait primitive raises per-entity ready flags; backend writes flags into the waitset’s status arrays | Backend’s RX worker pulls bytes; the data is “ready” by virtue of being received |
| Dispatch user callback | Executor’s spin_once picks one ready entity, calls rmw_take, fires its user callback | Backend’s RX worker / drive_io loop fires user callbacks while it has work to do |
Per spin_once | Exactly 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_setallocates 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_taskinvokes user callbacks during its read. nano-ros’sdrive_iodrains it; upstream’srmw_zenohwould still have to round-trip throughrmw_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 model | Fits 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) | Not supported — the runtime’s scheduler, not nano-ros, would decide when callbacks run, so no real-time bound could be stated |
| 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 (DO-178C) | No — drive_io has unbounded execution time; callers needing per-callback WCET give each callback a tier of its own, whose task nros::main! generates |
| Time-triggered cyclic | No — 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.
-
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 capsdrive_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. -
Per-call user-callback cap.
drive_ioacceptsmax_callbacks: an upper bound on user callbacks fired per call. Setting it to1reproduces upstream’s “one callback perspin_once” pattern. The runtime spin loop callsdrive_ioagain to drain pending work, with timer / GC checks between iterations. Closes the priority-inversion footgun for preemptive priority RTOS. -
Wall-clock budget per call.
drive_ioacceptstime_budget_ms: a wall-clock cap that bounds total time spent firing callbacks. Time-triggered cyclic apps configure a fixed slot per cycle;drive_ioyields when the slot expires even ifmax_callbacksisn’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.
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: a backend that has written no QoS code claims no policy. A bit is earned — each one is backed by 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. 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.
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():
| Backend | Reliability + Durability + History/Depth | Deadline | Lifespan | Liveliness Automatic | Liveliness Manual | Liveliness Lease | avoid_ros_namespace_conventions |
|---|---|---|---|---|---|---|---|
| Cyclone DDS | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ✅ Native | ✅ honoured |
| XRCE-DDS | ✅ Native: binary uxrQoS_t on pub/sub, an XML create on a requester/replier (the BIN create drops the QoS, so a stream too small for the XML falls back to the Agent’s fixed profile, which *_get_actual_qos reports) | ✅ Client-side clock check (sub: RequestedDeadlineMissed; pub: OfferedDeadlineMissed); the Agent’s entity keeps an infinite deadline (uxrQoS_t has no field), so peers do not see it | ❌ Refused at create | ✅ Fast-DDS default on the Agent’s entity | ❌ Refused at create | ❌ Refused at create | ✅ 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 | ✅ Honoured | n/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 creates every entity from a binary profile, and an Agent-side XML profile does NOT reach it (measured), so extended policies are refused at create. 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, core 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 core subset (liveliness
changes, deadline misses, message lost) plus Iron’s matched and
incompatible-type kinds. Skips the waitset. Skips
QOS_INCOMPATIBLE — 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
| Event | Producer | Subscriber callback / Publisher callback |
|---|---|---|
| Liveliness changed | sub | on_liveliness_changed(LivelinessChangedStatus) |
| Liveliness lost | pub | on_liveliness_lost(DeadlineMissedStatus) |
| Requested deadline missed | sub | on_requested_deadline_missed(deadline, DeadlineMissedStatus) |
| Offered deadline missed | pub | on_offered_deadline_missed(deadline, DeadlineMissedStatus) |
| Message lost | sub | on_message_lost(MessageLostStatus) |
| Publication / subscription matched (Iron+) | pub / sub | on_matched(MatchedStatus) |
| Publisher / subscription incompatible type (Iron+) | pub / sub | on_incompatible_type(IncompatibleTypeStatus) |
The matched pair and the incompatible-type pair are values 5..8 of
rmw_event_type_t, APPENDED: upstream inserted them mid-enum, and ours was
never value-compatible with upstream’s, so appending keeps every existing
value. The payloads are upstream’s rmw_matched_status_t (four counts, in
upstream’s order) and rmw_incompatible_type_status_t, at fixed uint32_t
widths.
What’s skipped
QOS_INCOMPATIBLE— surfaces at create time, not as a runtime event:NROS_RMW_RET_INCOMPATIBLE_QOScarries the diagnostic synchronously fromcreate_publisher/create_subscription.
MATCHED is supported because “start publishing once somebody listens”
is the event an embedded node most often wants; without it the node would
poll the matched count. INCOMPATIBLE_TYPE
came with it — the same graph machinery, and a peer with another type on
the same name is a runtime condition no create-time code can report.
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 core 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:
| Backend | Liveliness | Deadline | Message 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 | ✅ Client-side clock check: sub RequestedDeadlineMissed against the last takeable arrival, pub OfferedDeadlineMissed against the last publish, counted per whole period and fired from drive_io through *_event_init. The deadline is the on_* argument, else the QoS profile’s. The Agent’s DDS entity keeps an infinite deadline (uxrQoS_t has no field), so peers see none | ❌ 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 | ❌ Not wired: the C++ backend fills no event slot (the retired Rust backend’s publish-counter delta did not carry over) |
Matched / incompatible type:
| Backend | Matched | Incompatible type |
|---|---|---|
| Cyclone DDS | ✅ Polled: dds_get_{publication,subscription}_matched_status — QoS-compatible endpoints only, as upstream counts them. The change fields are computed against what the event last reported, because other readers of the same status reset Cyclone’s own change counters | ✅ Polled: endpoints on the same DDS topic name with another type name, read from the builtin DCPSPublication / DCPSSubscription topics. NOT DDS_INCONSISTENT_TOPIC_STATUS (what rmw_cyclonedds_cpp maps it to): the pinned 0.10.5 never raises it |
| zenoh-pico | ✅ From the session’s graph cache: liveliness tokens of the opposite kind with the same topic name and type, as rmw_zenoh_cpp derives it. Snapshot-based, so total_count counts increases seen between polls; QoS is not consulted | ✅ Same walk: same name, another type |
| XRCE-DDS | ❌ No event slot | ❌ |
| uORB | ❌ No event slot | ❌ |
Both producers are POLLED from the entity’s data path, so a publisher
observes a new match on its next publish or assert_liveliness — the
latter is the poll point for a publisher that waits for a listener before
publishing.
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. An unsupported backend never reports an
assertion that did not happen. 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.
9b. XRCE-DDS: what each NULL slot means
The XRCE backend fills fewer than half of the vtable slots.
A NULL slot answers NROS_RMW_RET_UNSUPPORTED (or its documented fallback), so
every gap below is a declared “cannot tell you”, never a wrong answer. They
fall into two kinds, and the difference decides whether a gap is permanent:
- Protocol cannot — micro-XRCE-DDS-Client cannot learn the answer. The
Agent owns the DDS participant and every entity on it; the client gets back
per-request status codes (
uxrOnStatusFunc) and the payloads it asked for (uxrOnTopicFunc,uxrOnRequestFunc,uxrOnReplyFunc), and nothing else — no discovery state, no builtin topics, no DDS status, noSampleInfoon a topic sample. - Unwritten — the client has, or can get, what the slot asks for, and nobody has written the body yet.
What stock rmw_microxrcedds
(Humble) does is in the last column; it is the same protocol, so its choices
are evidence for what the protocol allows.
| family | slots | ruling | why | stock rmw_microxrcedds |
|---|---|---|---|---|
| graph: names, types, endpoint info, counts | get_topic_names_and_types, get_service_names_and_types, the four *_names_and_types_by_node, get_{publishers,subscriptions}_info_by_topic, count_{publishers,subscribers,clients,services} | protocol cannot | discovery lives on the Agent’s participant; the client has no builtin-topic readers and no enumeration request | UNSUPPORTED unless built with RMW_UXRCE_GRAPH, which reads the micro-ROS Agent’s own ros_to_microros_graph topic — not published by the stock Micro XRCE-DDS Agent nano-ros uses |
| graph: node names + change edge | get_node_names, node_get_graph_guard_condition | filled | node names are DATA on ros_discovery_info, an ordinary topic the client subscribes to through the Agent (created on first use): the answer is this session’s nodes plus every DDS-RMW participant’s, the edge fires on a changed sample or a local node change. Bounds: DDS peers only (a bridged zenoh peer publishes no such sample); a participant that dies without a clean shutdown stays listed (no discovery-loss reaches the client); a sample past XRCE_GRAPH_MAX_PEERS / XRCE_GRAPH_MAX_PEER_NODES is refused and the query answers ERROR. Measured live against a stock rmw_fastrtps_cpp talker | as above |
| matched counts | publisher_count_matched_subscriptions, subscription_count_matched_publishers | protocol cannot | matching happens between the Agent’s entities and remote ones; nothing reports it to the client | RMW_UXRCE_GRAPH only, from the graph topic |
| granted QoS | the six *_get_actual_qos | filled | no read-back request exists, so the answer is derived — and MEASURED against the pinned Agent with a stock peer reading each endpoint’s discovered QoS. A BIN-created DataWriter/DataReader holds exactly the four uxrQoS_t fields sent plus Fast-DDS defaults (deadline/lifespan infinite, liveliness AUTOMATIC); an Agent-side XML default profile does not reach it (the Agent copies DATAWRITER_QOS_DEFAULT into a local, and Fast-DDS substitutes the profile only for the sentinel’s address). A requester/replier created from BIN does NOT hold what was asked (the client library drops its QoS and the Agent builds both endpoints from fixed attributes: writers RELIABLE + TRANSIENT_LOCAL, readers RELIABLE + VOLATILE), so it is created from XML, which the Agent applies; the four slots report what the XML carried, or that fixed profile after a BIN fallback. Depth reaches no peer: reported only when sent, else UNKNOWN | returns the stored creation profile — for a service, not what the Agent built |
| status events: liveliness, incompatible QoS, matched; manual liveliness | subscription_event_init, publisher_event_init, subscription_take_event, publisher_take_event, publisher_assert_liveliness | protocol cannot | DDS status is on the Agent’s entities; no XRCE submessage carries it; the Agent’s writer asserts its own liveliness | rmw_*_event_init UNSUPPORTED |
| status events: deadline | the same slots for the two DEADLINE kinds, plus next_deadline_ms, event_type_is_supported | filled | both deadlines are local facts (time since the last takeable arrival / last publish), counted client-side and fired from drive_io; next_deadline_ms hands the executor the earliest expiry; event_type_is_supported is true for the two DEADLINE kinds only; this earned the DEADLINE QoS bit. The Agent’s entity still offers/requests an infinite deadline, so a peer matching on deadline sees none. Measured live through the Agent | UNSUPPORTED |
| session wake | set_wake_callback | protocol cannot | the client has no background thread; nothing arrives outside drive_io, which the executor itself calls | no equivalent |
| per-entity new-data callbacks | subscription_set_on_new_message_callback, service_set_on_new_request_callback, client_set_on_new_response_callback | filled | every arrival callback names its entity (uxrObjectId), so the registered callback fires from inside drive_io, once per takeable arrival with 1, under the session lock (a replaced, cleared or destroyed registration never fires again). Measured live: an arrival through the Agent marks its own subscription’s ready bit and no other | UNSUPPORTED |
| take with info | take_with_info | filled, receive time only | uxrOnTopicFunc carries no writer GUID and no source timestamp, so those stay “not reported”; received_timestamp is a local fact upstream also stamps locally (system_clock::now() in rmw_zenoh_cpp), so the topic callback stamps it on the platform wall clock | ignores message_info |
| request info | take_request_with_info | filled | uxrOnRequestFunc DOES carry the request’s SampleIdentity (measured live) | fills the request id |
| response info | take_response_with_info | filled, receive time only | uxrOnReplyFunc names the reply’s request by the client’s own request id, not by a SampleIdentity: the request writer is the Agent’s, so its GUID never reaches the client and the request id stays “not reported” (its sequence number is reported, and is what the runtime matched the reply on); no source timestamp; the reply callback stamps received_timestamp | fills the sequence number only |
| identity | get_gid_for_publisher, get_gid_for_client | filled | the protocol hands back no GUID, but src/graph.c derives each tracked endpoint’s DDS GID (probe + Fast-DDS entity counter) and lists it in ros_discovery_info; the slots answer that GID (a client’s is its request writer, the GUID a server reads in the request id), UNSUPPORTED for an endpoint it could not key. Measured live against ros2 topic info --verbose | copies the XRCE object id into the gid (matches no DDS GID) |
| server availability | service_server_is_available | protocol cannot | needs matched-replier state the Agent keeps; a create_requester ack says only that the client’s own endpoints exist (see vtable.c) | UNSUPPORTED |
| loans | borrow_loaned_message, publish_loaned_message, return_loaned_message_from_publisher, take_loaned_message, return_loaned_message_from_subscription, take_loaned_message_with_info | protocol cannot (in the loan’s shape) | a stream buffer is reused by the next uxr_run_session_*, so it cannot back a view that outlives the call; the zero-copy capability ships as publish_streamed and process_raw_in_place instead | UNSUPPORTED |
| batch take | take_sequence | not needed | one sample per arrival into a single-slot inbox; the runtime’s take loop is the same work | loops take_with_info |
| acks | publisher_wait_for_all_acked | protocol cannot | reliable-stream acks are client↔Agent only (uxr_run_session_until_confirm_delivery), not from DDS readers; also inert ABI-wide | UNSUPPORTED |
| log level | set_log_severity | protocol cannot | the client library has no runtime log control | UNSUPPORTED |
| capability probe | feature_supported | n/a | inert ABI-wide (no reader); XRCE would answer false for both features, as upstream does | false for both |
For *_get_actual_qos the deciding fact was measured: an Agent-side profile
cannot override a BIN-created entity’s QoS, and a BIN-created requester/replier
ignores its requested QoS altogether.
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:
| Returns | Success | Failure |
|---|---|---|
rmw_ret_t + entity-struct out-param (create_session, create_publisher, create_subscription, …) | NROS_RMW_RET_OK, out->backend_data non-NULL | negative named constant |
rmw_ret_t (destroy_session, drive_io, publish, send_response, …) | NROS_RMW_RET_OK | negative 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 sameint32_treturn. - No thread-local error string. No
rmw_set_error_string, normw_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’sprintkequivalent — never buffered, never thread-local. - Smaller code-set. 13 codes total (vs upstream’s ~25). The
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 & service info: functional-parity, different shape
Three 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.
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:
C mirrors this with#![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()); } })?; }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 arcl_subscription_tthatrclc_executor_remove_subscriptiontakes back out. - Two backend channels. The hot
takevtable slot returns bytes only. A backend that fills the optionaltake_with_infoslot reportsrmw_message_info_tin caller-owned storage, and the runtime dispatches it whenever it is present — that is how a C/C++ backend (Cyclone) reports metadata. A Rust backend behind the adapter writes aMessageInfoSlotside-channel instead, which the runtime pairs with the payload. Either way themessage_info()builder sees the sameMessageInfo.
received_timestamp follows upstream’s meaning: the subscriber’s own wall
clock (nros_platform_time_now_ns, ns since the Unix epoch) when the sample
ARRIVED, the clock and the moment rmw_zenoh_cpp stamps
(system_clock::now() in its sample callback). Zero means “not stamped”:
| backend | received_timestamp on a sample |
|---|---|
| zenoh-pico | stamped by the ring producer (the sample callback) |
| XRCE-DDS | stamped by the topic callback, inside drive_io |
| Cyclone DDS | 0 — Cyclone 0.10’s sample info has no reception time, and a stamp at take time is a different fact. Humble rmw_cyclonedds_cpp writes 0 for the same reason; Jazzy’s stamps the take time |
| uORB | 0 |
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 the two answers are the same bytes:
| backend | get_gid() and MessageInfo::publisher_gid |
|---|---|
| zenoh-pico | XXH3-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 |
| cyclonedds | the DDS writer GUID, 16 bytes zero-extended; the take path reports it through the take_with_info slot |
| XRCE-DDS | get_gid(): the DDS GUID graph.c derives for the endpoint (probed participant prefix + the Agent’s endpoint counter), 16 bytes zero-extended — the GID ros_discovery_info lists and a stock ros2 topic info --verbose prints. No GID on a take: a topic sample carries no writer GUID through the Agent (the take reports only its receive time) |
| uORB | UNSUPPORTED; 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.
Service info: a sibling slot on each side
Upstream’s rmw_take_request fills an rmw_service_info_t beside the
request: the client’s request_id (writer GUID + the sequence number that
client assigned) and the source_timestamp / received_timestamp pair.
nano-ros splits that take across two vtable slots:
take_requestreturns the payload bytes andint64_t *seq_out, the backend’s reply-correlation token thatsend_responsetakes back. That is everything a server needs to answer, and it is all this slot returns.take_request_with_info(optional, appended) is the same take plus a caller-ownedrmw_service_info_t *, declared in<nros/rmw_entity.h>field for field as upstream’s.seq_outstays beside the struct instead of becoming itsrequest_id.sequence_number: a backend’s token (a reply-slot index on Cyclone, XRCE and zenoh) is not the client’s sequence number, and the runtime needs both. When a backend fills this slot the runtime takes every request through it; when it does not, the runtime falls back totake_request, and only a Rust backend’s side table can then report anything.
A field the backend cannot report is zero (all-zero writer_guid = no
request id, timestamp 0 = not stamped), and the runtime turns each zero into
None, never into a value:
| backend | channel | request_id | source_timestamp | received_timestamp |
|---|---|---|---|---|
| zenoh-pico | Rust adapter side table | from the query attachment (rmw_zenoh_cpp layout) | from the attachment | stamped by the queryable callback |
| Cyclone DDS | take_request_with_info | request-header GUID + publication handle, header sequence (stock rmw_cyclonedds_cpp layout) | the DDS sample’s source timestamp | not reported — Cyclone 0.10 has no reception time (Humble upstream writes 0 too) |
| XRCE-DDS | take_request_with_info | the Agent’s SampleIdentity (client request-writer GUID + sequence), as rmw_microxrcedds fills it | not reported — no timestamp reaches the client library | stamped by the request callback |
| uORB | — | uORB has no services | — | — |
received_timestamp is the server’s own wall clock
(nros_platform_time_now_ns) when the request arrived, which is what
rmw_zenoh_cpp stamps (system_clock::now() when the query arrives); it
is zero on a platform with no wall clock.
What an application sees:
- Rust.
create_service_with_info(or the builder’s.service_info()) hands the callback anros::ServiceInfo— rclrs’s shape, except every field is anOption:#![allow(unused)] fn main() { executor.node_mut(nid).create_service_with_info::<AddTwoInts, _>( "/add_two_ints", |req: &AddTwoIntsRequest, info: &nros::ServiceInfo| { if let Some(id) = info.request_id { log::info!("request #{} from {:?}", id.sequence_number, id.writer_guid); } AddTwoIntsResponse { sum: req.a + req.b } }, )?; } - C.
rclc_executor_add_service_with_request_id(typed) andrclc_executor_add_service_raw_with_request_id(CDR bytes) pass the callback anrmw_request_id_t *, rclc’s shape. The timestamps are not surfaced in C. - C++. An rclcpp service handler taking three arguments —
(std::shared_ptr<rmw_request_id_t>, request, response)— receives the request header; the two-argument handler does not. As in C, the timestamps are not surfaced.
The client side has the same split. take_response
returns the reply bytes and the sequence_id that send_request handed
out, which is what the runtime matches a reply against.
take_response_with_info (optional, appended) is the same take plus a
caller-owned rmw_service_info_t * — upstream rmw_take_response’s
request_header. Its fields mean what a client’s mean upstream: the
request_id the reply answers (this client’s request-writer GUID and the
sequence number of its request), the SERVER’s clock when it sent the reply,
and this client’s clock when the reply arrived.
| backend | request_id | source_timestamp | received_timestamp |
|---|---|---|---|
| zenoh-pico | from the reply attachment (rmw_zenoh_cpp layout: the client’s gid and sequence number) | from the attachment — the replier’s send time; our server restamps the request attachment it echoes | stamped by the reply callback, as rmw_zenoh_cpp’s ZenohReply is |
| Cyclone DDS | the echoed request header’s GUID + the reply sample’s publication handle, header sequence (stock rmw_cyclonedds_cpp, which shares one take for both sides) | the reply sample’s source timestamp | not reported — Cyclone 0.10 has no reception time |
| XRCE-DDS | not reported — the Agent owns the request writer, so its GUID never reaches the client (the sequence number is still matched on) | not reported | stamped by the reply callback |
| uORB | — | — | — |
What an application sees on the client side:
- Rust.
Promise::take_with_info()/wait_with_info()return the reply with itsnros::ServiceInfo— rclrs’sclient.call::<_, (Response, ServiceInfo)>(..), every field anOption:
The callback client has the same read:#![allow(unused)] fn main() { let mut promise = client.call(&request)?; let (reply, info) = promise.wait_with_info(&mut executor, timeout)?; if let (Some(sent), Some(got)) = (info.source_timestamp, info.received_timestamp) { log::info!("reply took {} ns on the wire", got.to_nanos() - sent.to_nanos()); } }Node::create_client_with_callback_with_infohands its closure(&Reply, &ServiceInfo), the client twin ofcreate_service_with_info. - C.
rclc_executor_add_client_with_request_idpasses the callback anrmw_request_id_t *, rclc’s shape: the sequence number the reply was matched on, and the writer GUID where the backend reports it. The timestamps are not surfaced in C. - C++.
PollClient<S>::take_response(response, request_header)— upstreamrclcpp::Client::take_response— takes the next reply with itsrmw_request_id_t, as C’s callback receives it: the sequence number always, the writer GUID where the backend reports it. As upstream’s, it takes whatever reply is next and touches no pending future, so the header is what a caller correlates on. It returns aResult(TryAgainwhen nothing is waiting) where upstream returnsbooland throws. The timestamps are not surfaced in C++, as upstream rclcpp surfaces none on a client.
See also
- RMW API Design — deeper architectural rationale (heap, threading, dispatch model) shared across all the points above.
- Custom RMW Backend — step-by-step guide to writing a backend in C against this vtable.
<nros/rmw_vtable.h>Doxygen — full C reference for every function pointer above.packages/rmw/zenoh/nros-rmw-zenoh— canonical reference port. Read the source for a worked example.- Changing a divergence in the tree: see Internals → RMW vs upstream: the decision ledger for the decision behind each one.
Design background
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.
There is no separate
zpico-platform-shimcrate: the forwarders live in thezpico-sysalias 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:
- Identify a hardware timer (SysTick, GPT, DWT) or use the OS tick API
(
xTaskGetTickCount,k_uptime_get,clock_gettime) - Handle 32-bit timer wraps — track a wrap count in an atomic or use a 64-bit counter
- Never advance the clock inside
smoltcp_network_poll()— read the hardware timer directly - Verify with QEMU: use
-icount shift=autoto synchronize virtual time with wall-clock time
Reference implementations:
| Platform | Clock source | File |
|---|---|---|
| MPS2-AN385 | CMSDK APB Timer0 (25 MHz) | nros-platform-mps2-an385/src/clock.rs |
| STM32F4 | ARM DWT cycle counter | nros-platform-stm32f4/src/clock.rs |
| FreeRTOS | xTaskGetTickCount() | Use OS tick directly |
| NuttX | clock_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-allocFreeListHeap(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), 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
- Create the platform crate (
nros-platform-<name>) – see Custom Platform for the full guide - Implement and verify the clock — this is the #1 cause of porting
failures. Print
clock_ms()in a loop and verify monotonic advance - Implement remaining primitives — memory, random, sleep, time, threading, sockets. Each module is independent
- Wire into
nros-platform— add a feature andConcretePlatformalias - Create the board crate — see Custom Board Package
- Add the platform feature to
nroswith mutual exclusivity checks - Write an example — see Creating Examples
- Add test infrastructure — for an in-tree platform, see Internals → Per-platform contributor lanes
Platform capability summary
| Capability | Bare-metal | RTOS (FreeRTOS/ThreadX) | POSIX-like (NuttX/Zephyr) |
|---|---|---|---|
| Clock | Hardware timer | OS tick API | clock_gettime |
| Memory | embedded-alloc | RTOS heap | System malloc |
| Sleep | Busy-wait + poll | vTaskDelay | nanosleep |
| Threading | No-op stubs | Real tasks + mutexes | pthreads |
| Sockets | smoltcp shims | lwIP or NetX sockets | BSD sockets |
| Random | Seeded xorshift | RTOS RNG or xorshift | /dev/urandom |
| libc | Hand-written stubs | RTOS libc | System libc |
| Network poll | smoltcp_network_poll | Stack-specific poll | Not 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.
There is no separate
xrce-platform-shimcrate: theuxr_*forwarders live in thenros-rmw-xrcealias 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:
| Platform | Clock source | File |
|---|---|---|
| MPS2-AN385 | CMSDK APB Timer0 (25 MHz) | nros-platform-mps2-an385/src/clock.rs |
| Zephyr | k_uptime_get() | xrce-zephyr/src/xrce_zephyr.c |
| POSIX | clock_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:
| Transport | Crate | Description |
|---|---|---|
| smoltcp UDP | xrce-smoltcp | Bare-metal networking via smoltcp |
| Zephyr UDP | xrce-zephyr | Zephyr BSD sockets |
| POSIX UDP | Built-in | Uses 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:
| Feature | Effect |
|---|---|
posix | Compiles built-in time.c (uses clock_gettime, BSD sockets) |
bare-metal | Skips time.c; expects uxr_millis()/uxr_nanos() from platform crate |
zephyr | Compiles xrce_zephyr.c (Zephyr kernel APIs) |
freertos | RTOS-specific; skips time.c |
nuttx | RTOS-specific; skips time.c |
threadx | RTOS-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
- Create the platform crate —
nros-platform-<name>/(see Custom Platform) - Implement the canonical clock primitive —
nros_platform_clock_ns(); the alias TU (nros-rmw-xrce/src/platform_aliases.c) derivesuxr_millis()/uxr_nanos()from it for you - Implement
smoltcp_clock_now_ms()if using smoltcp transport - Add a feature to
xrce-sysfor the new platform if needed - Choose or implement a transport crate — reuse
xrce-smoltcpfor bare-metal, or implement a new transport if the platform has its own networking stack - Create the board crate — see Custom Board Package
- Add the platform feature to
nroswith mutual exclusivity checks - 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_HISTORYmust be >= 2 (recommend 4). History=1 fails to recycle slots between separateuxr_run_session_until_all_statuscalls. - Flush after
request_data—uxr_buffer_request_datamust be flushed withuxr_run_session_timeimmediately after being called. Unflushed requests in the reliable output stream cause intermittent timeouts.
Platform Porting Pitfalls
Hard-won lessons from porting nros to bare-metal ARM (QEMU MPS2-AN385), bare-metal RISC-V, 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 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:
| Context | Minimum stack |
|---|---|
| Bare-metal main stack | 8 KB |
| FreeRTOS zenoh read task | 2 KB |
| FreeRTOS zenoh lease task | 1 KB |
| Zephyr zpico work queue thread | 2 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:
| Platform | Minimum heap |
|---|---|
| MPS2-AN385 (bare-metal) | 64 KB |
| STM32F4 | 64 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 idle waits: a loop that sleeps between spins (
wfi(), a timer delay) gets no clock advance from a poll-driven clock during the sleep, making all timeouts effectively infinite.
Platform clock status
| Platform | Clock source | Notes |
|---|---|---|
| MPS2-AN385 (zpico) | CMSDK APB Timer0 | Hardware 25 MHz free-running counter. Wraps at ~171s, extended via WRAP_COUNT. |
| STM32F4 | ARM DWT cycle counter | Hardware-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. |
| FreeRTOS | FreeRTOS kernel tick | OS-managed via xTaskGetTickCount(). |
| NuttX | POSIX clock_gettime() | OS-managed. |
| Zephyr | Zephyr kernel tick | OS-managed via k_uptime_get(). |
| POSIX (native) | std::time::Instant | OS-managed. |
| XRCE MPS2-AN385 | CMSDK APB Timer0 | Same hardware timer as zpico MPS2-AN385. Call init_hardware_timer() before use. |
Porting checklist for new platforms
- Identify a hardware timer — SysTick, a general-purpose timer, or DWT cycle counter. On RTOS platforms, use the OS tick API.
- Export
smoltcp_clock_now_ms() -> u64— called byzpico-smoltcpfor TCP/IP timestamping. - 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. - 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. - 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: reach WFI between spin_once() calls:
#![allow(unused)]
fn main() {
// CORRECT — idles between spins → WFI → QEMU processes TAP
loop {
executor.spin_once(core::time::Duration::from_millis(0));
cortex_m::asm::wfi();
}
// WRONG — busy-loops, starving QEMU I/O
loop {
executor.spin_once(core::time::Duration::from_millis(0));
}
}
A board that implements BoardIdle does this for you in the generated 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 a cooperative scheduler (a 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, so a service or action
call from a cooperative loop can still time out this way until the TCP read path
is fully non-blocking.
Version pinning
The zenoh router is ROS 2’s own (ros2 run rmw_zenoh_cpp rmw_zenohd)
— 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.10.1 — read its version.txt); its wire protocol is stable across
zenoh 1.x, so it interoperates with a newer ROS-shipped router.
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.
Bare-metal 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.
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.
Design background
Runner packages
A runner package teaches nros run, nros flash and nros monitor to drive
a launcher, flasher or console that nano-ros does not ship a runner for. It is
an ordinary package in your workspace, with a package.xml and a small
descriptor, built for the host by nros build.
This page is coming soon.
Design background
Command packages
A command package adds a verb to nros. It is an ordinary package in your
workspace, with a package.xml and a small descriptor, built for the host by
nros build. nros help --all lists each extension verb with the package
that provides it.
This page is coming soon.
Design background
nros CLI reference
The nros binary is the user entry point for nano-ros: it provisions
toolchains, scaffolds workspaces, generates message bindings, builds and runs
images, and inspects boards, configuration and plans.
nros --help prints the verbs below, in the same groups. This page documents
those verbs: the user tier. The verbs that build systems call (the build-system tier)
and the older spellings that still parse are not part of it; nros help --all
lists the first and nros help --aliases the second.
Install
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. --version <v> installs a
named release instead of the newest; the repository’s GitHub Releases page
lists them. The release carries the CLI and its SDK root, the library source
your build compiles (share/nano-ros in the install). There is no crates.io,
Arduino zip or ESP-IDF binary channel. See
Installation.
Start
nros new
Scaffold a workspace, a package, an image or a host. Outside a workspace it writes a new one, for one board:
nros new sensor_ws --board mps2-an385-baremetal --template talker [--lang c|cpp] [--rmw <rmw>]
sensor_ws/
└── src/
├── sensor_node/ # node package: a library, no main(), names no board
└── sensor_node_bringup/ # package.xml and system.toml: one component row, one image
The node package is named from the workspace (<x>_ws gives <x>_node), and
the bringup is <node>_bringup. Its system.toml names the board, the RMW, the
one component and one image, so nros run <node> runs it with nothing edited
(First Project).
Inside a workspace, nros new <name> adds a node package under src/ instead:
nros new monitor_node --template listener --to sensor_node_bringup
--to <bringup> also adds the node’s [[component]] row and its
<exec_depend> to that bringup. Without it the package is written and no
bringup runs it yet.
The node is the --template role, the in-tree example of that role under the
package’s names:
--template | Node | Interface |
|---|---|---|
talker (default) | publishes Hello World: N once a second | std_msgs/String on /chatter |
listener | logs what /chatter carries | std_msgs/String on /chatter |
server | adds two integers | example_interfaces/srv/AddTwoInts on /add_two_ints |
client | calls the server once it is up | example_interfaces/srv/AddTwoInts on /add_two_ints |
action-server | computes a Fibonacci sequence, with feedback | example_interfaces/action/Fibonacci on /fibonacci |
action-client | sends one goal, logs feedback and the result | example_interfaces/action/Fibonacci on /fibonacci |
The roles and interfaces are the ROS 2 demos’ (demo_nodes_cpp’s talker and
listener, add_two_ints_server / _client, the Fibonacci action pair), so a
scaffolded node talks to the demo it is named after.
The package flags follow ros2 pkg create:
| Flag | Writes | Default |
|---|---|---|
--node-name <name> | the node’s ROS name | the package name (- spelled _) |
--dependencies <pkg>... | extra <depend> rows, after the role’s interface package | none |
--license <id> | <license> | Apache-2.0 |
--description <text> | <description> | a one-line summary |
--maintainer-name / --maintainer-email | <maintainer> | git config user.name / user.email |
--destination-directory <dir> | where the package directory is created | the current directory (src/ inside a workspace) |
ros2 pkg create --build-type has no counterpart: --lang decides the
package’s build type (cargo for Rust, CMake for C/C++). --library-name has
none either: every node package is a library. --use-case is retired and
refused, naming --template.
| Flag | Values | Default |
|---|---|---|
--board | a board id (Supported Boards) | (required for a new workspace) |
--lang | rust, c, cpp | rust |
--rmw | zenoh, xrce, cyclonedds | zenoh |
--ros-edition | humble, iron, jazzy | humble |
--template | talker, listener, server, client, action-server, action-client | talker |
--to | a bringup package in this workspace | none |
--force | overwrite an existing directory | off |
The scaffold names the image after the board’s platform: [image.baremetal],
[image.linux], [image.freertos], [image.zephyr], each with
board = "<board>".
--board native and --board posix are refused with a hint naming
linux — the host board’s one name. There is no standalone package shape: a
system.toml lives only in a bringup, and nros build refuses one beside a
Cargo.toml or CMakeLists.txt, naming where to move it. Zephyr is no
exception: nros build and nros run generate the Zephyr application under
build/ from the image (the Zephyr chapter), and
nros materialize turns it into a package of yours when
you need to edit it.
Image / host mode: nros new --image <name> [--board <b>] scaffolds an [image.<name>] — a buildable image — 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: [deploy.*] split into those two tables, and a system.toml that still carries one is refused by name. 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 setup
Provision the toolchain/SDK for a board. nros setup is the single
canonical provisioning command. Inside a workspace, bare nros setup
provisions what the workspace’s images declare. Most components are prebuilt per platform
per RMW (cross-compiler, emulator, RMW host tools, SDK sources), fetched
from a pinned index into a shared store (${NROS_HOME:-~/.nros}/sdk). No
hand-installed cross-toolchains.
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: the router is
ROS’s rmw_zenoh_cpp/rmw_zenohd, so a zenoh run needs a ROS 2 install;
Cyclone DDS needs no daemon. 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 # the Zephyr SDK + RMW transport sources
| Flag | Effect |
|---|---|
<board> | resolve + fetch this board’s package set (see Supported Boards) |
--rmw <zenoh|xrce|cyclonedds> | also provision the RMW’s host tools — the XRCE agent, Cyclone’s idlc; zenoh has none here (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 |
--list | list every package in the index + its version |
--licenses | show license-gated packages + how to install them |
--system | resolve the OS-package closure for this host’s package manager and PRINT the one install command (it never runs it without --sudo) |
--workspace [<path>] | provision what the workspace at <path> (default .) declares: every [image.*]’s board and RMW, then the OS packages its build types and <depend>s name (installed with --sudo, otherwise printed). An unresolvable <depend> stops it before any fetch. Bare nros setup at a workspace root is the same; --check only reports |
--role <role> | narrow --system to entries of this role (repeatable) |
--check | run presence probes instead of installing; exits 1 when anything is missing. With --system, the OS packages only; alone, every declared class |
--sudo | EXECUTE the plan’s composed OS-package install command instead of printing it — with a <board> (its tools’ and sources’ system = [..], before any download), --workspace, --system, or a --tool that declares OS build deps. Any other selector refuses it, naming these |
--full / --shallow | full git history / force --depth 1 for provisioned sources |
-j, --jobs <n> | packages installed at once (default: CPU count; NROS_SETUP_JOBS) |
--index <path> | the SDK index to read (default nros-sdk-index.toml) |
--dry-run | resolve + print the plan without fetching anything |
nros setup board <name> --zephyr-workspace <dir> [--workspace <nano-ros>]
provisions a downstream Zephyr consumer’s tree for a nano-ros board.
--tool and --source combine: nros setup --tool arm-none-eabi-gcc --source freertos-kernel --source lwip installs the tool, then the
sources, in one command. Selectors that do not combine — a <board>
beside --tool/--source, anything beside --list/--licenses/
--system, --source with --check, --rmw without a <board> — are
refused, naming the flag, never silently ignored.
Board names (the full set — the index’s [board.*] keys; each board is named for the reach of
its build):
freertos-posix, fvp-aemv8r-smp, linux, mps2-an385-baremetal, mps2-an385-freertos, mps2-an385-zephyr, mps3-an536-freertos, nucleo-f767zi-zephyr, qemu-armv7a-nuttx, rpi-pico-w-zephyr, rv-virt-nuttx, rv-virt-threadx, s32z270-freertos, threadx-linux, zephyr.
A wrong one lists the rest; nros setup <board> --dry-run resolves without
fetching. native and posix are refused with a hint naming linux
(Retired names); older retired
board names are in the
retired board names
ledger.
nros setup presets
Write a project CMakePresets.json (into [<dir>], default the current
directory) that includes the per-board preset fragments nros setup <board> wrote under ~/.nros/presets/. Each preset names the SDK root, the
board’s toolchain file (for a cross board) and the build directory
build/<board>, so cmake --preset <board> configures a nano-ros CMake
package with no hand-set -DCMAKE_TOOLCHAIN_FILE or -Dnano_ros_ROOT.
Idempotent: re-run it after provisioning another board. Zephyr boards have no
preset, because west is their build tool.
nros setup qemu-armv7a-nuttx
nros setup presets # writes ./CMakePresets.json
cmake --preset qemu-armv7a-nuttx
Build
nros build
Build the workspace. 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. An image is named <bringup>:<image>,
or <image> when exactly one bringup declares it.
Each build first prepares what the image reads, as nros configure does
(below): message bindings, the resolved launch model, the
generated entry and the tool’s settings. A part whose inputs did not change is
not redone, so a build with nothing changed costs a digest comparison. There is
no step to run before it.
cd <workspace>
nros build # every default image
nros build linux # one image by id
nros build linux -- --release # arguments after `--` go to the native tool
--board <board> builds the image whose [image.*] board is that board —
the authored word in any case, another alias of the same board, or the
nros setup key it provisions through (--board mps2-an385-baremetal finds a
mps2-an385-baremetal image). One match is the same as naming the image; several
or none is refused with the image ids listed, so name one. It cannot be
combined with image arguments or --all.
After a successful build (not --dry-run, not --check) nros build prints,
per image, where the artifact is and the platform’s own next command — it runs
nothing:
nros build: next for `demo:qemu`:
artifact: <ws>/build/…/qemu_entry/target/thumbv7m-none-eabi/debug/qemu_entry
run:
<store>/sdk/qemu/<ver>/bin/qemu-system-arm -cpu cortex-m3 -machine mps2-an385 … -kernel <artifact>
A host board prints the binary; a cargo board with a descriptor runner prints
that runner; NuttX and ThreadX riscv64 print the QEMU line the tests use; a
Zephyr image prints west build -d <dir> -t run and/or west flash -d <dir>,
read from what its build supports. QEMU is the store’s patched build
(nros locate tool qemu-system-arm), never whatever $PATH holds. A board
with no derivable command (S32Z270) gets a pointer to its platform chapter.
colcon’s flags work with colcon’s names: --packages-select,
--packages-up-to, --packages-above, --packages-skip and --base-paths
choose the packages (nros list -n with the same flags prints the set),
and --continue-on-error, --cmake-args and --build-base act as
colcon’s do. The differences are refusals rather than
different answers: a package name that matches nothing is an error, and a
selection that leaves out a package a selected one depends on is refused,
because nano-ros has no install prefix for it to come from. In a workspace
with images the unit of --continue-on-error is the image, and
--build-base is refused there.
-j N / --jobs N (default: the number of CPUs, as cargo) caps the TOTAL
number of jobs. nros build runs one GNU make jobserver of N tokens and
every cargo, make and ninja below it draws from it, so independent packages
building side by side never run more than N compilers between them. Run
under a make -jN that already has a jobserver, it joins that one. Ninja
older than 1.13 cannot read a jobserver; when one would run, nros build
says so. colcon’s --parallel-workers is refused with a pointer to -j:
colcon counts packages because it has no shared job pool, and nros build
does.
nros build --packages-up-to talker_pkg # it and its dependencies
nros build --packages-above msgs_pkg # it and what depends on it
nros list -t -n --packages-skip other_pkg # the set, in build order
A runner or command package is a host tool. nros build
builds it for the host triple, in release, into
build/nros/host/<package>/, whatever board the images beside it target. It
has its own target directory and never joins an image’s generated root, so
editing it rebuilds only it, and editing an image does not rebuild it.
--build-base does not move it, because nros run and extension verbs look
for its executable there. An image depends on the workspace package providing
its runner, so nros build --packages-up-to <bringup> builds that runner too.
With no selection every host tool in the workspace builds, and a selection
that keeps only host tools builds no image:
nros build --packages-select acme_tools # one command package, alone
A workspace package may replace one of nano-ros’s own providers by announcing
the same kind and name: the workspace overlays the nano-ros packages, as a
colcon overlay does its underlay. For a runner or a command, which run host
code and flash hardware, the replacement must be declared in the workspace’s
nros.toml, pinned to the package’s path; otherwise the build or run that
resolves the name refuses it and prints the line to add.
--allow-overriding <name> approves it for one invocation, as colcon’s flag
does. Other kinds (an rmw, a board) only warn when undeclared. The
replacement must speak the runner protocol of the one it replaces, and an
entry whose path no longer provides the name is an error rather than a quiet
return to ours.
The workspace’s own source trees, src/ and every package_paths entry, are
peers, as colcon’s base paths are: the same provider in two of them is an
error for every kind, and the message names both paths. Declare the one you
mean, or pass --allow-overriding <name> to let the later root win for one
run. NROS_PACKAGE_PATH overlays everything, with a warning.
--base-paths <path>… names those source trees for one invocation, and means
the same on nros build, nros list, nros configure and
nros run/flash/monitor (whose build and runner lookup get it). It
replaces the workspace (default .) and its package_paths, relative to the
workspace, and nothing else: nano-ros’s own packages and NROS_PACKAGE_PATH
stay on the search path, so a runner nano-ros provides still resolves. As in
colcon, the paths are peers, their order does not matter, a path repeated or
inside another counts once, and a package name found in two of them is an
error naming both. A path that does not exist is an error; one holding no
package is a warning. A path outside the workspace is allowed, and nros doctor and
nros run --dry-run --why say so.
nros build --base-paths src ../vendor # src/ and a vendor tree beside the workspace
nros list -n --base-paths src # what that build would see
# nros.toml, at the workspace root
[workspace]
package_paths = ["../vendor"]
[workspace.overrides]
qemu = "src/my_qemu" # this workspace's package answers the qemu runner
Overrides are the workspace’s to declare: a system.toml under src/ that
carries [overrides] is refused. nros doctor lists every override, whether it was declared, and which file the
declarations came from.
-v / --verbose shows the configure’s detail notes: why a re-configure
happened, which message bounds were refused and why. Without it each is one
line. A raw cmake run gets the same with --log-level=VERBOSE.
--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 prepares the image first.
nros build linux --check # cargo check
nros build linux --check=clippy -- -- -D warnings # cargo clippy, warnings denied
Zephyr keeps west build as its build verb: nros build prepares the image,
then runs west build on the application it generated.
Driving the native tool yourself is supported: nros build <image> --dry-run
prints the exact cargo / cmake / west line, against the generated entry
under build/. Run nros configure first to write that entry without
building.
nros configure [<workspace>] [--image <id>] [--generated-base <d>] [--ros-edition <e>] [--dry-run] [--check] [--force] [--offline] [--no-metadata] [--no-provider-index] [--model-dir <d>] [--nano-ros-path <p>] [--nano-ros-root <p>] [--base-paths <p>…] [-v]
Configure a project without building it: codegen every *.msg package the
declared deps name, write the generated build settings for each declared image,
then resolve and generate the image’s build inputs (discover, preflight, census,
resolve, generate). It works on a standalone package or a colcon-style
workspace (it picks single-pkg vs workspace mode by layout) and runs no target
build tool.
You rarely type it. nros build and nros run run it first when it is stale,
the way cargo build fetches what it needs. Run it yourself when you want the
files without a build (an IDE, a CI step), after editing *.msg,
package.xml or system.toml.
nros configure # generated/ msg crates + build/<image>/nros-cargo.toml
nros build # or: nros build <image-id>
The interface search path is computed inside configure, so no environment step is needed before it.
What it writes into build/<image>/nros-cargo.toml: 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, configure also still maintains the [patch.crates-io] block in
each Rust consumer’s patch-authority .cargo/config.toml (never in
Cargo.toml). That road is being removed, and it is what the ws clean /
ws doctor subcommands below still talk about.
The stamp. Each image gets a stamp at
<project>/build/nros/configured/<image>/stamp.toml, holding a digest of its
inputs: package.xml, Cargo.toml, system.toml, the launch files, the
environment variables configure reads (such as AMENT_PREFIX_PATH), the
nros-launch-resolve path, and the content of the ROS install the generated
message crates came from. A second run with nothing changed reads the stamps and
exits without doing any work. An image this host cannot build is recorded as
skipped, and is configured only when you name it. The flags that change what
configure writes (--ros-edition, --generated-base, --model-dir,
--no-metadata, --base-paths, --nano-ros-path, --nano-ros-root) and
NROS_MODEL_DIR are inputs too, and a deleted output (generated/, a model,
the selection package) makes the stamp stale. --force configures even when
every stamp is current. --dry-run records no stamp.
Named images. An image you did not name that fails to configure is reported
on stderr (configure: <image>: not configured — …) and the configure still
succeeds. An image you name with --image, in a workspace as in a
single-package leaf, must configure: its failure, a missing toolchain included,
is an error.
The lock. A per-project file lock serialises writers, so several configures reaching one workspace at once (a parallel fixture build) queue instead of racing.
| Flag | Meaning |
|---|---|
[<workspace>] | Workspace root (the dir containing src/); defaults to the cwd |
--image <id> | Single-package leaf only: configure this [image.<id>] (its selection package, build/<id>/nros-cargo.toml, message codegen for its edition) |
--generated-base <d> | Output dir for the generated message crates, relative to the workspace (default generated) |
--ros-edition <e> | humble | iron | jazzy; inherits [system].ros_edition, else humble |
--dry-run | Don’t write; print what would happen |
--check | Exit non-zero if any patch block is missing or stale (CI hook; also used by nros ws status) |
--force | Configure even when every image’s stamp is current |
--offline | Never touch the network. Reaches the cargo update --workspace that prunes a single-package leaf’s freshly seeded Cargo.lock, the one step that resolves crates |
--no-metadata | Skip the per-Node-package host probe; bakes fall back to the model’s entity lower bound |
--no-provider-index | Skip the provider index (build/nros/providers.json) |
--model-dir <d> | Write resolved SystemModels here instead of each bringup’s config/ |
--nano-ros-path <p> | The SDK root the probes and board lookups read |
--nano-ros-root <p> | nano-ros source tree scanned as the first provider search root |
--base-paths <p>… | Provider search roots, replacing the default (as colcon’s --base-paths) |
-v, --verbose | Verbose codegen output |
--check exits non-zero when anything is missing or stale — the CI hook, and
what nros ws status reports non-fatally.
nros clean
Remove the generated message bindings (generated/, or --build-dir <dir>)
and anything nros configure wrote for them under build/. Sections you wrote are left alone. --dry-run prints
what it would remove.
nros clean --dry-run
nros materialize
Take ownership of a generated entry package: the last resort, after which
nros build stops regenerating it. It writes the entry into
src/<image>_entry/, for a Zephyr image the whole west application. panic, profile and before_boot 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. --workspace <dir> names the workspace root.
nros materialize linux # or <bringup>:linux when two bringups share the id
Run on a target
nros run
Run an image on its target: a host process or an emulator. It builds the
image if it is stale, starts the router or agent its link needs, streams the
console and stops everything on exit. Arguments after -- go to the target.
The target argument is tried in this order:
| You type | It means |
|---|---|
<bringup>:<image> | that bringup’s image; always works |
<image> | the image of that name, when exactly one bringup declares it |
<node> | the one bringup whose [[component]] rows name only that node package, with its only image |
| nothing | the workspace’s only image |
Anything else refuses and lists the <bringup>:<image> spellings that would
resolve; a shortcut never picks one of several. --why names the rung that
resolved. The <node> rung is nros run’s alone; nros build, nros flash
and nros monitor take the other spellings.
| Flag | Effect |
|---|---|
--expect <text> / --expect-regex <re> | exit 0 as soon as a console line matches; exit 1 if the target ends, or --timeout passes, first |
--timeout <seconds> | stop the target after that long |
--router <auto|none|LOCATOR> | auto starts the zenoh router or XRCE agent when the link needs one on this host; none starts nothing; a locator names one already running |
--port <device> | the console device |
--prefix <cmd> | run a host image under a command (--prefix 'valgrind -q') |
--dry-run [--why] [--json] | print the resolved run plan instead of running it; --why adds the rung that supplied each field |
--plan <file> | execute a plan file exactly as written: resolve nothing, build nothing |
--workspace <dir> | the workspace root (default: found from the current directory) |
nros run sensor_node # the node rung
nros run linux --expect 'Received: 2' --timeout 30
A board with no host process or emulator is real silicon; nros run refuses
it and names the flashing step. See Running an Image.
nros flash
Flash an image onto a board. Reserved: not available in this version.
It exits with an error that says so. Until it ships, flash with the command
nros build prints after a successful build. See
Flash and Monitor.
nros monitor
Attach to a running board’s console. Reserved: not available in this
version, with the same error and the same advice as nros flash.
nros bench
This machine’s boards. nros bench scan records which attached USB device is
which board, matching each device against the USB id a board descriptor’s
[board.console] usb states, in $NROS_HOME/bench.toml (--dry-run prints
what it would record). nros bench list prints that file and whether each
device is attached now. The bench file supplies ports and probes; it never
decides what is tested. See Your Bench.
nros bench scan --dry-run
nros bench list
nros debug
Debug an image on its target. Reserved: not available in this version.
Workspace
nros list
List the workspace’s packages: kind, name and directory per row. Any of
-t (build order, as colcon list -t), -n (names only), -p (paths only)
or the colcon selection flags (--packages-select, --packages-up-to,
--packages-above, --packages-skip, --base-paths) switches to
colcon list’s format, one name<TAB>path<TAB>(type) line per package,
found the way nros build finds them.
nros list -t -n --packages-up-to talker_pkg
nros graph
Print the workspace’s packages in dependency order, each with the workspace
packages it depends on. --workspace <dir> names the workspace (default: the
current directory).
nros graph
Interfaces
nros interface
Message interfaces, with three subcommands:
| Subcommand | Effect |
|---|---|
generate <rust|c|cpp|all> | generate bindings from a package.xml (--manifest, default package.xml) into --output (default generated), for --ros-edition (default humble); --codegen-config names an explicit per-field capacity file, --rename old=new renames a generated Rust package, --force overwrites |
list | list the interface types the search path reaches; -m, -s, -a keep only messages, services or actions |
show <package>/<msg|srv|action>/<Name> | show one type’s definition with nested types expanded |
nros build generates the bindings a workspace needs (for Rust together with
the image’s build settings; for C and C++ during the CMake configure). interface generate is the codegen step alone.
nros interface show std_msgs/msg/Header
nros px4 msgs
Generate CDR-serializable px4_msgs::msg::* bindings from a PX4-Autopilot
.msg tree (--px4 <dir>, default $PX4_AUTOPILOT_DIR) into --output.
--lang rust (the default) writes a px4_msgs crate for an XRCE companion;
--lang cpp writes headers and FFI glue for an in-firmware PX4 module.
--topics keeps only the messages a bridge carries (by message name or uORB
topic name), pulling in nested types. See the
PX4 starter.
nros px4 msgs --output generated/px4_msgs --topics vehicle_status
nros px4 is a command package (nros_px4_tools), not a built-in: nros
runs its executable with the arguments unchanged. In a checkout just setup-cli builds it. The old spelling, nros generate-px4-msgs, still works
and runs the same package.
nros router config --model <model>
Print the zenoh router downsampling rule nros configure wrote beside a
resolved model, as the ZENOH_CONFIG_OVERRIDE entry rmw_zenohd reads
(--format json5 prints the artifact itself):
ZENOH_CONFIG_OVERRIDE="$(nros router config --model build/nros/models/<bringup>/<stem>.yaml)" rmw_zenohd
It fails when there is no rule: no subscriber declares an ingress, no image
links zenoh, or the model was never configured. Like nros px4, it is a
command package (nros_zenoh_tools); the old spelling nros ws router-config
runs the same package.
Extension commands
A verb nros does not know is looked up as an announced command package
(<nano_ros_provides kind="command" name="<verb>"/>). nros help --all lists
every one this workspace reaches, with its summary and package. The command runs with
NROS_WS_ROOT, NROS_TOOLCHAIN and NROS_CLI set, and its exit status is
nros’s. A package announcing a built-in verb is refused.
Inspect
nros doctor
Health-check the install and the project. With no workspace it verifies the
install: the launcher, the SDK store, and that the pin in this directory
resolves. In a workspace it also checks the bringup’s [image.*] and
[host.*] blocks (--config <system.toml> names another) and the
license-gated SDKs the images’ boards need (--board <board> narrows that to
one board). --platform <name> restricts the check to one platform.
--report prints the support status of every board the bringup’s images
name, from the board support registry, and stops: a read-only report you can
attach to a bug or a review.
nros doctor --report
nros board
The boards this nros knows: the board packages in its SDK root, plus any
found through NROS_EXTRA_BOARD_PATH.
| Subcommand | Effect |
|---|---|
list | every board package, with its support status and cadence |
show <board> | a board’s support evidence: status, where it runs, cadence, breadth and the book chapters that run it |
info <board> | the board’s resolved descriptor, as JSON |
new <name> --platform <platform> | scaffold a board package; --out-of-tree <dir> writes it outside nano-ros and prints the NROS_EXTRA_BOARD_PATH that reaches it; --west-board for Zephyr; --dry-run |
The status and cadence come from the board support registry, the same source as Supported Boards.
nros board show mps2-an385-freertos
nros config
The resolved configuration, with where each value came from.
nros config show [--system [<bringup>]] [--workspace <dir>] prints the
effective config of a bringup system (rmw, domain_id, locator, and the
safety, param_services and lifecycle capability axes) with a
provenance column: system.toml [section] or the built-in default. Without a
bringup it uses the workspace’s default_system, or its only bringup. A
legacy per-package nros.toml overlay that still declares one of those blocks
is flagged as deprecated.
nros config explain --platform <name> [--board-toml <file>] prints the
build-time knob ladder for a platform: every knob’s final value and the rung
that set it (builtin, platform, board or env). When a pool size or a
hardware budget is not what you expected, it says which rung decided.
nros config show
nros config explain --platform freertos
nros plan
Resolve a bringup’s launch file, its manifests and the components’ source
metadata into a typed nros-plan.json under build/<system_pkg>/nros/:
the component graph, topic wiring, parameter remaps and scheduling-context
bindings. The arguments are the system package, then a launch file or a
package directory, then name:=value launch arguments.
| Flag | Use |
|---|---|
--model <system_model.yaml> | plan from a resolved SystemModel |
--record <file> | use a precomputed record.json instead of parsing the launch file |
--image <id> | resolve per-image values (RMW, build tuning, target triple) against this [image.<id>] |
--rmw <rmw> | override the RMW: --rmw beats [image.<id>].rmw, which beats [system].rmw |
--workspace <dir> / --out-dir <dir> | the workspace root / the output root |
nros plan demo_bringup src/demo_bringup
nros check
Validate a generated nros-plan.json (default build/nros/nros-plan.json)
before the platform build runs: unconnected required topics, conflicting QoS,
missing parameters and scheduling-context binding errors. A bringup’s
system.toml (or its directory) is validated as a deployment instead.
--bringup lints a bringup package directory (it must be purely declarative),
and --workspace [<dir>] runs the workspace layout checks.
nros check
nros explain
Render a generated nros-plan.json (default build/nros/nros-plan.json) in
human-readable form: the component graph, topic wiring, parameter bindings and
scheduling-context assignments.
nros explain path/to/nros-plan.json
Toolchain
nros toolchain
The installed toolchains and SDK packages in the store
(${NROS_HOME:-~/.nros}/sdk), which only grows: provisioning never
overwrites, so a new version lands beside the old one.
| Subcommand | Effect |
|---|---|
list [--root <dir>] | every entry with its version, size and when it was last read |
gc --older-than <dur> [--delete] [--pin-file <f>…] [--ignore-pins] [--root <dir>] | propose removing entries not read for <dur> (90d, 12h, 2w); removes nothing without --delete, and never an entry a pin names |
remove <version> [--dry-run] [--pin-file <f>…] [--ignore-pins] [--root <dir>] | remove one installed nano-ros toolchain; refuses while a pin names it, or when no pin file could be consulted |
nros toolchain gc --older-than 90d
nros toolchain <uninstall|manifest|stage-packages>
| Subcommand | Effect |
|---|---|
uninstall <version> [--dry-run] [--pin-file <f>…] [--ignore-pins] [--root <dir>] | remove one installed nano-ros toolchain; refuses while a pin names it, or when no pin file could be consulted |
manifest [--store-version <v>] [--index <date>] [--nano-ros <commit>] [--write <path>] | read (or, with --write, stamp) the share/nros/manifest.toml a release declares it is made of |
stage-packages <prefix> [--checkout <dir>] | release-only: stage every runner and command package of <prefix>/share/nano-ros prebuilt into <prefix>/share/nros/packages/<package>/ (package.xml, descriptor, bin/), the underlay a released nros resolves runners from |
nros pin [<version>] [--dir <dir>] [--root <dir>] [--dry-run]
Report this project’s nros-toolchain.toml pin, or move it to <version> (the
store version, e.g. 0.6.0-nros3, not the crate version nros --version
prints). A move reports the codegen delta between the two toolchains first, so
an upgrade that would invalidate already-generated code warns before anything
is written.
nros pin 0.6.0-nros4 --dry-run
nros update
Move one pin of a pin set and leave one diff to review: a registry crate
(--crate <name> [--precise <v>]), a vendored source
(--source <name> --ref <sha>, forward only unless --allow-rewind), a host
tool (--tool <name> --to-version <v>) or a Zephyr west project
(--west <module> --revision <rev>). --pins [--prune] regenerates the
registry pin set from the locked roots. It edits the pin set of a nano-ros
source tree (--nano-ros-path); an installed release’s pin set is read-only,
so a project that only consumes a release never runs it. To move your
project to another release, use nros pin.
nros update --crate heapless --precise 0.8.0
nros locate
Print where a resource is, and with --why, every rung of the lookup ladder
(argument, environment, the project’s system.toml [sources], a local
edit, the store) and what each saw. A name is an index [source.<name>], or
store for the store root. nros locate tool <name> locates a host binary
(qemu-system-<arch> through the same QEMU lookup nros build uses,
renode, or nros-launch-resolve) or an SDK tool’s directory (below). --all locates every [source.*] row, and
--format sh|cmake prints shell or CMake assignments instead of bare paths.
nros locate zenoh-pico --why
nros locate tool qemu-system-arm
The SDK root. nros locate nano-ros prints the nano-ros SDK root the
project in the current directory builds against: the directory that holds
nano-ros’s CMake modules, its Zephyr module and its board descriptors. Use it
wherever a build tool needs that path, for example
-DZEPHYR_EXTRA_MODULES="$(nros locate nano-ros)". It needs no network and no
index, so it is safe in a CMake execute_process or a script.
Each project can pin its own nano-ros version (nros-toolchain.toml, written by
nros build and changed with nros pin). Inside a pinned project, nros runs
the pinned toolchain, so nros locate nano-ros prints that version’s SDK
root. Two projects pinned to two versions get two different answers, and a
script that asks in each project gets each one right. --why shows which
version answered and which pin file chose it:
cd ~/robots/arm-controller && nros locate nano-ros --why
cd ~/robots/old-gateway && nros locate nano-ros # a different pinned version
--project <dir> asks about another project. If that project is pinned to a
different version from the nros answering, the command refuses rather than
print the wrong version’s root, and tells you to run it inside that project.
An SDK tool. nros locate tool <name> also prints the install directory
of a tool that nros setup <board> provisioned, such as the Zephyr SDK or a
cross compiler. The answer is the version this release of nano-ros pins,
never another version that happens to sit beside it, so a build that asks
keeps working when the pin moves:
nros setup zephyr --rmw zenoh
export ZEPHYR_SDK_INSTALL_DIR="$(nros locate tool zephyr-sdk)"
A tool that is not installed, or that nano-ros does not know, makes the
command fail with a message that names nros setup <board>. A project’s
system.toml can point a tool somewhere else with a [tools] entry, and that
entry wins.
Other
nros version
Print the CLI version, its codegen version and the versions of the libraries it was built with.
nros version
nros completions
Emit shell completion scripts to stdout.
| Shell | Install snippet |
|---|---|
bash | nros completions bash > ~/.local/share/bash-completion/completions/nros |
zsh | nros completions zsh > "${fpath[1]}/_nros" |
fish | nros completions fish > ~/.config/fish/completions/nros.fish |
powershell | nros completions powershell > $PROFILE.parent\nros.ps1 |
nros help
Print the grouped verb list, or one verb’s help (nros help build, the same as
nros build --help). nros help --all adds the build-system tier and
nros help --aliases lists the older spellings that still parse.
Design background
- RFC-0117: CLI command tiers — the user tier and its groups
- RFC-0118: target execution model —
nros run,flash,monitorandbench - RFC-0065: colcon-like workspace builder — images, hosts and
nros materialize - RFC-0075: zenoh router provenance — why
nros setupdoes not ship a router - RFC-0093: board naming states reach
- RFC-0098: generated leaf build config — the generated
nros-cargo.toml - RFC-0120: one resolver for every build road —
nros configure, and whynros buildandnros runconfigure themselves - RFC-0121: workspace-first project layout — the scaffold, the bringup-only
system.tomland the target rungs - RFC-0119: CLI extension packages —
runnerandcommandpackages
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
nrosrustdoc — 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
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.
nros_rmw— RMW trait surface (porters)nros_platform_api— platform abstraction traits (porters)nros-rmw-zenoh— zenoh-pico backend internals
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
rclc/rclc.h— convenience umbrella includerclc/init.h—rclc_support_init,rclc_support_finirclc/node.h—rclc_node_init_default,rcl_node_finirclc/executor.h— spin loop, executor lifecyclerclc/publisher.h/subscription.h— pub/subrclc/service.h/client.h— servicesrclc/action.h— actionsrclc_parameter/rclc_parameter.h— parameter servicesrclc_lifecycle/rclc_lifecycle.h— REP-2002 lifecycle states
Two-layer API. Every entity exposes two parallel C entry-point sets:
rclc_*_init_default(Layer 1, caller polls) andrclc_executor_add_*(Layer 2, executor callback). Layer 1 grew an_init_polling+take_*_raw/send_*_rawfamily for inline-storage callers (no executor arena). Layer 2 pairs the samerclc_*_init_defaultwith 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-ownedrclc_wake_state_tPOD; the backend firescb(ctx)on rx / reply / request arrival. Per-channel for actions (set_{goal,cancel,get_result}_wake_callbackon the server,set_{goal_response,cancel_response,result,feedback}_wake_callbackon the client). See the Doxygen for signatures.
Action server
<rclc/action_server.h> follows rclc’s model:
example_interfaces__action__Fibonacci_SendGoal_Request goal_requests[2];
rclc_action_server_init_default(&server, &node, &support,
ROSIDL_GET_ACTION_TYPE_SUPPORT(example_interfaces, action, Fibonacci), "/fibonacci");
rclc_executor_add_action_server(&executor, &server, 2, goal_requests,
sizeof(goal_requests[0]), goal_callback, cancel_callback, &ctx);
Each incoming goal takes one of the handles_number goal handles
(rclc_action_goal_handle_t, at most NROS_MAX_CONCURRENT_GOALS); its
request is deserialised into the matching element of the array, and
goal_callback returns RCL_RET_ACTION_GOAL_ACCEPTED or
RCL_RET_ACTION_GOAL_REJECTED. Do the work OUTSIDE the callback — between
rclc_executor_spin_some() calls, or from a timer in a C component — and
answer with rclc_action_publish_feedback(handle, &feedback_message) and
rclc_action_send_result(handle, GOAL_STATE_SUCCEEDED, &get_result_response).
Two bounds differ from rclc: a goal arriving with every handle in use is
rejected (rclc queues it), and an accepted goal executes at once, so a result
may be sent before the client asks for it. A component, which has no
rclc_support_t, passes NULL for support. See action_server_pkg in the C language
workspace, examples/workspaces/c; nros run action_bringup:linux_action_server
runs it on Linux.
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 withrclc_action_client_set_goal_response_callback(),rclc_action_client_set_feedback_callback(),rclc_action_client_set_result_callback(), and (for a cancel’s reply: its return code and how many goals are canceling)rclc_action_client_set_cancel_response_callback(). The callbacks fire fromrclc_executor_spin_some()— your spin loop is the only place callbacks run. - Blocking convenience:
rclc_action_send_goal()andrclc_action_get_result(). These call the async variant and then driverclc_executor_spin_some()internally on a wall-clock budget until the reply lands (or 15 s / 30 s timeout). They take therclc_executor_t*explicitly because the action client stores its handle as an opaque slot inside the executor (registered viarclc_executor_attach_action_client()). The same applies torclc_action_client_wait_for_action_server()andrclc_action_client_action_server_is_ready(). Calling any of them from inside a dispatch callback returnsRCLC_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, callrclc_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 bringrclc_executor_get_handle_count()back down. A program that tears the executor down withrclc_executor_fini()needs neither — that drops every entry. - The same holds for every other entity the executor registers:
rclc_executor_remove_subscription(),rclc_executor_remove_timer(),rclc_executor_remove_service()andrclc_executor_remove_client()— rclc’srclc_executor_remove_*shape — each drop the arena entry (receive ring included), free the slot and bringhandle_countback down. Call one before the entity’sfiniwhen the executor keeps running; a timer or client that isfini’d without it is still dispatched into. A removed client returns to INITIALIZED and can be re-added. All returnRCLC_RET_NOT_FOUNDfor an entity not registered on that executor andRCLC_RET_REENTRANTfrom inside a callback. A subscription whose callback also receives the sample’s attachment is registered withrclc_executor_add_subscription_with_info()into arcl_subscription_tand removed the same way; the older direct-argrclc_executor_add_subscription_raw_with_info()takes no subscription object, so its entry lives untilrclc_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
action_client_pkg in the C language workspace, examples/workspaces/c.
The same package runs on Linux and on FreeRTOS: its linux_action_client
and freertos_action_client image rows in action_bringup differ only in
the board.
A separate L1 polling family
(rclc_polling_action_client_init + the _raw send/recv siblings)
stores ActionClientCore inline in the separate type
rclc_polling_action_client_t 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.
Design background
- RFC-0122: behaviour bringups, single-node witnesses, and cadence-backed CI. The paired examples as image rows of behaviour bringups in the language workspaces.
C++ API
The nano-ros C++ API is a freestanding C++14 interface for ROS 2 nodes on embedded and host targets. It needs no standard library, no exceptions and no RTTI, so the same code builds for Zephyr, FreeRTOS, NuttX, ThreadX, bare metal and Linux.
- Upstream’s names, in upstream’s namespaces.
rclcpp::Node,Publisher<M>,Subscription<M>,Service<S>,Client<S>,Timer,GuardCondition,Executor, andrclcpp_action::Server<A>/Client<A>. Every user-facing name is inrclcpp::,rclcpp_action::orrclcpp_lifecycle::; there is nonros::namespace. The extensions with no upstream counterpart (thePoll*types, the*Storagetypes,FixedString, …) are inrclcpp::too. - Errors are values. Every fallible call returns
rclcpp::Result, andRCLCPP_TRYreturns early on failure. Nothing throws. - Generated message types.
std_msgs::msg::Int32,example_interfaces::srv::AddTwoIntsand so on, generated by the build from the packages your node depends on (see Message Generation). - An opt-in std surface for ported code. Defining
NROS_CPP_STDadds thestd::string/std::function/std::chronooverloads and thestd::shared_ptr-returningrclcpp::Nodefactories. The build requests it; it is never detected from the toolchain. See Two surfaces.
The headers live in the SDK’s C++ include directory at upstream’s paths:
rclcpp/, rclcpp_action/ and rclcpp_lifecycle/. rclcpp/detail/ is
internal.
To build and run a first C++ node, start with First Node — C++; to grow it into a workspace, see C++ in a workspace. To bring an existing ROS 2 node across, see Porting a ROS 2 C++ node. This page is the API itself.
API reference
C++ API Doxygen — start here.
The Doxygen site is generated from the current main branch. The umbrella
header rclcpp/rclcpp.hpp pulls in every
public surface a user application needs.
Where to start
rclcpp/rclcpp.hpp— convenience umbrella includerclcpp::init_in/rclcpp::shutdown— session lifetimerclcpp::Node— node + create_publisher/subscription/service/client/action_*rclcpp::Publisher<M>/Subscription<M>. Hosted, a subscription callback may also take upstream’sconst rclcpp::MessageInfo&as its second argument (see Subscription)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>::SharedPtrisrclcpp::ServiceHandle<S>, a two-word keep-alive with nothing to dereference;Client<S>::SharedPtrisrclcpp::ClientHandle<S>, the same two words with one verb,async_send_request. Neither has anoperator->.rclcpp::PollService<S>— L1 polling service server. The caller owns the server and drains it withtake_request/send_responsefrom 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. The caller owns the client and drains the reply itself:send_requestreturns arclcpp::Future<T>(on a hosted buildasync_send_request(shared_ptr)returns upstream’sFutureAndRequestId—.future,.request_id,share()),callblocks while driving the executor,call_pollingblocks without driving it, andwait_for_service/service_is_readyprobe 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. Hosted, upstream’s goal handles too (see Actions with upstream’s goal handles).rclcpp::PollingActionServer<A>/PollingActionClient<A>— L1 polling model. Caller drivestake_*/accept_goal/complete_goalfrom a spin loop.rclcpp::Future<T>— async result handlerclcpp::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 thercl_interfacesservices read.rclcpp::Seq<T, N>is the array VALUE a freestanding node declares with (std::vector<T>underNROS_CPP_STD). There is no standalonerclcpp::ParameterServer: a second store would be invisible toros2 param get. See Differences from ROS 2 §9 for what is intentionally smaller thanrclcpp, and Parameters below.
Two-layer API. The
rclcpp::ActionServer<A>/ActionClient<A>templates are L2 callback handles, dispatched by the executor. Therclcpp::PollingActionServer<A>/PollingActionClient<A>are L1 polling templates with the same typed serialization glue but caller-driven control flow. Subscriptions, services and service clients have both shapes too (Subscription/PollSubscription, and so on). See Two-Layer API for the per-layer rationale.
Event-driven wake callbacks.
PollingActionServer<A>andPollingActionClient<A>expose a nestedWakeStatePOD and typedset_{goal,cancel,get_result,goal_response, cancel_response,result,feedback}_wake_callback(state, cb, ctx)methods. Pair annros_cpp_wake_state_twith each entity / channel to wake on rx instead of polling.
Core API
The snippets below use the freestanding surface. Each create_* writes the
entity through an out-reference and returns a Result.
Initialization
#include <rclcpp/rclcpp.hpp>
// Global init (simple applications). `init_in` is the Result-returning form:
// upstream's `rclcpp::init(argc, argv)` returns `void`, so the name differs
// where the contract does.
rclcpp::Result ret = rclcpp::init_in("tcp/127.0.0.1:7447", 0);
// Or an explicit executor (multi-executor patterns)
rclcpp::Executor executor;
RCLCPP_TRY(rclcpp::Executor::create(executor, "tcp/127.0.0.1:7447"));
Node
rclcpp::Node node;
RCLCPP_TRY(rclcpp::create_node(node, "my_node"));
// Or with an explicit executor:
RCLCPP_TRY(executor.create_node(node, "my_node", "/namespace"));
Publisher
#include "std_msgs.hpp"
rclcpp::Publisher<std_msgs::msg::Int32> pub;
RCLCPP_TRY(node.create_publisher(pub, "/chatter"));
std_msgs::msg::Int32 msg;
msg.data = 42;
RCLCPP_TRY(pub.publish(msg));
A publisher can be marked express (it bypasses transmit batching) through its QoS profile; see TX Throughput & Latency Tuning.
Subscription
A poll-style subscription is rclcpp::PollSubscription<M>: the caller owns the
subscriber, so the caller is the one that takes from it. Call spin_once() to
drive I/O, then take() to check for a message. rclcpp::Subscription<M> is
the DISPATCH subscription, whose samples the executor arena delivers to a
callback.
rclcpp::PollSubscription<std_msgs::msg::Int32> sub;
RCLCPP_TRY(node.create_subscription(sub, "/chatter"));
rclcpp::spin_once(100);
std_msgs::msg::Int32 msg;
if (sub.take(msg).ok()) {
printf("Received: %d\n", msg.data);
}
On a hosted build (NROS_CPP_STD) a dispatch subscription’s callback may take
upstream’s const rclcpp::MessageInfo& as a second argument, after the message
in any of its four forms (const M&, std::shared_ptr<const M>,
std::shared_ptr<M>, std::unique_ptr<M>):
auto sub = node->create_subscription<std_msgs::msg::Int32>(
"/chatter", 10, [](const std_msgs::msg::Int32& msg, const rclcpp::MessageInfo& info) {
const auto& r = info.get_rmw_message_info();
printf("%d from seq %llu at %lld ns\n", msg.data,
(unsigned long long)r.publication_sequence_number, (long long)r.source_timestamp);
});
get_rmw_message_info() carries source_timestamp, received_timestamp,
publication_sequence_number, reception_sequence_number and the 24-byte
publisher_gid, as the backend reported them. A backend that reports none
(XRCE, uORB) leaves every field zero. from_intra_process is always false.
A freestanding build refuses the shape at compile time: the metadata path keeps
the callable on the heap. SubscriptionOptions::message_info = true with a
callback that takes no MessageInfo aborts the create, because it would
otherwise configure nothing.
Service server
rclcpp::PollService<S> is the polled server: the caller owns it and drains
it. rclcpp::Service<S> is the DISPATCH server, which takes a handler.
#include "example_interfaces.hpp"
using AddTwoInts = example_interfaces::srv::AddTwoInts;
rclcpp::PollService<AddTwoInts> srv;
RCLCPP_TRY(node.create_service(srv, "/add_two_ints"));
// In your main loop:
rclcpp::spin_once(10);
AddTwoInts::Request req;
int64_t seq;
if (srv.take_request(req, seq).ok()) {
AddTwoInts::Response resp;
resp.sum = req.a + req.b;
srv.send_response(seq, resp);
}
Service client
rclcpp::PollClient<S> is the polled client: the caller owns it and drains
the reply. rclcpp::Client<S> is the DISPATCH client, whose one verb is
async_send_request and whose reply reaches a handler during spin_once.
rclcpp::PollClient<AddTwoInts> client;
RCLCPP_TRY(node.create_client(client, "/add_two_ints"));
AddTwoInts::Request req;
req.a = 1; req.b = 2;
AddTwoInts::Response resp;
RCLCPP_TRY(client.call(req, resp));
// resp.sum == 3
Action server
rclcpp_action::Server<A> is a DISPATCH server: the executor answers goal and
cancel requests through callbacks you install, and the goal itself is driven
through the server by its 16-byte id. Upstream hands handle_accepted an owning
std::shared_ptr<ServerGoalHandle>; here the goals live in the server’s static
arena, so the callback receives the id instead. A polled form, where the caller
takes each goal request itself, is rclcpp::PollingActionServer<A>.
#include "example_interfaces.hpp"
using Fibonacci = example_interfaces::action::Fibonacci;
rclcpp_action::Server<Fibonacci> srv;
RCLCPP_TRY(node.create_action_server(srv, "/fibonacci"));
// Stateless callables: an empty-capture lambda or a plain function pointer.
// The `*_with_ctx` forms add a `void*` for state.
RCLCPP_TRY(srv.set_goal_callback([](const uint8_t id[16], const Fibonacci::Goal& goal) {
return goal.order > 0 ? rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE
: rclcpp_action::GoalResponse::REJECT;
}));
RCLCPP_TRY(srv.set_accepted_callback([](const uint8_t id[16]) {
// Record `id`; the work happens outside the callback, which must return
// promptly.
}));
// Later, from the main loop, for an accepted goal `goal_id`:
Fibonacci::Feedback fb;
// ... fill feedback ...
srv.publish_feedback(goal_id, fb);
Fibonacci::Result result;
// ... fill result ...
srv.complete_goal(goal_id, result); // GoalStatus::Succeeded; another overload takes a status
Action client
rclcpp_action::Client<A> keeps its goal, feedback and result buffers (and
the four transport channels underneath) in fixed-size storage inside the
Client<A> instance itself. Nothing is heap-allocated per send_goal call,
and the type is move-only.
Blocking convenience. send_goal() and get_result() spin the executor
internally until the server replies or the per-call timeout expires:
rclcpp_action::Client<Fibonacci> client;
RCLCPP_TRY(node.create_action_client(client, "/fibonacci"));
Fibonacci::Goal goal;
goal.order = 10;
uint8_t goal_id[16];
RCLCPP_TRY(client.send_goal(goal, goal_id));
Fibonacci::Result result;
RCLCPP_TRY(client.get_result(goal_id, result));
These helpers are sugar over send_goal_async() + spin_once(). They cannot
be called from inside a dispatch callback; a blocking call made there fails
with ErrorCode::Reentrant.
Future-based async. send_goal_future() / get_result_future() return a
Future<T> keyed by its goal: each future receives only its own goal’s
acceptance or result, whether the executor dispatched it during spin_once or
the future’s own poll took it, and a result for a goal nobody waits on is
dropped. At most MAX_CONCURRENT_GOALS of each may be pending per client;
past that the send is refused and the future is empty (valid() false):
auto fut = client.send_goal_future(goal);
typename decltype(client)::GoalAccept accept;
RCLCPP_TRY(fut.wait(executor.handle(), 5000, accept));
if (accept.accepted) {
auto rfut = client.get_result_future(accept.goal_id);
Fibonacci::Result result;
RCLCPP_TRY(rfut.wait(executor.handle(), 10000, result));
}
GoalAccept is a nested type on rclcpp_action::Client<A> (16-byte UUID +
bool accepted).
Feedback. Feedback is not goal-scoped at the stream layer:
feedback_stream() yields FeedbackType across every active goal for this
client.
auto& fb_stream = client.feedback_stream();
Fibonacci::Feedback fb;
while (fb_stream.try_next(fb).ok()) { /* ... */ }
// Or blocking:
RCLCPP_TRY(fb_stream.wait_next(executor.handle(), 500, fb));
For per-goal feedback dispatch, use the callback API.
Callback API: SendGoalOptions + set_callbacks(). This is the
rclcpp-style entry point. SendGoalOptions is a nested POD on
rclcpp_action::Client<A>; populate the three function-pointer fields
(goal_response, feedback, result) plus an optional context pointer,
then install once. Callbacks fire from spin_once():
typename decltype(client)::SendGoalOptions opts;
opts.goal_response = [](bool accepted, const uint8_t id[16], void* ctx) {
auto* state = static_cast<MyState*>(ctx);
state->accepted = accepted;
std::memcpy(state->goal_id, id, 16);
};
opts.feedback = [](const uint8_t id[16], const uint8_t* data, size_t len, void* ctx) {
Fibonacci::Feedback fb;
if (Fibonacci::Feedback::ffi_deserialize(data, len, &fb) == 0) {
// dispatch on `id`
}
};
opts.result = [](const uint8_t id[16], int32_t status, const uint8_t* data, size_t len, void* ctx) {
// status: 4=Succeeded, 5=Canceled, 6=Aborted
};
opts.context = &my_state;
RCLCPP_TRY(client.set_callbacks(opts));
RCLCPP_TRY(client.send_goal_async(goal, goal_id)); // fire-and-forget
while (!my_state.done) { rclcpp::spin_once(10); }
set_callbacks() may be called before or after send_goal_async(): the
executor reads the latest callbacks on each dispatch. The client also keeps the
most recent feedback and result bytes, so the same Client can drive
feedback_stream().try_next() and get_result_future().wait() even with
callbacks installed.
Actions with upstream’s goal handles (hosted)
On a hosted build (NROS_CPP_STD) the stock rclcpp_action spellings are
drop-ins over the two classes above:
using Fibonacci = example_interfaces::action::Fibonacci;
using GoalHandle = rclcpp_action::ServerGoalHandle<Fibonacci>;
using namespace std::placeholders;
// Server: free factory over the node interfaces, std::bind-ed members.
rclcpp_action::Server<Fibonacci>::SharedPtr server = rclcpp_action::create_server<Fibonacci>(
get_node_base_interface(), get_node_clock_interface(), get_node_logging_interface(),
get_node_waitables_interface(), "fibonacci",
std::bind(&MyNode::handle_goal, this, _1, _2), // (const GoalUUID&, shared_ptr<const Goal>)
std::bind(&MyNode::handle_cancel, this, _1), // (shared_ptr<GoalHandle>)
std::bind(&MyNode::handle_accepted, this, _1)); // (shared_ptr<GoalHandle>)
// in handle_accepted: goal_handle->get_goal(), ->publish_feedback(fb),
// ->is_canceling(), ->succeed(result) / ->abort(result) / ->canceled(result)
// Client: SendGoalOptions' upstream fields, bound per goal by async_send_goal.
auto client = rclcpp_action::create_client<Fibonacci>(this, "fibonacci");
auto opts = rclcpp_action::Client<Fibonacci>::SendGoalOptions();
opts.goal_response_callback = [](rclcpp_action::ClientGoalHandle<Fibonacci>::SharedPtr h) {};
opts.feedback_callback = [](auto h, const std::shared_ptr<const Fibonacci::Feedback> fb) {};
opts.result_callback = [](const rclcpp_action::Client<Fibonacci>::WrappedResult& r) {
// r.code: rclcpp_action::ResultCode::SUCCEEDED / ABORTED / CANCELED; r.result
};
auto fut = client->async_send_goal(goal, opts); // rclcpp::Future<GoalAccept>
// The handle-taking calls return upstream's std::shared_future.
auto result = client->async_get_result(goal_handle); // shared_future<WrappedResult>
auto cancel = client->async_cancel_goal(goal_handle); // shared_future<CancelResponse::SharedPtr>
if (rclcpp::spin_until_future_complete(node, result) == rclcpp::FutureReturnCode::SUCCESS) {
const auto& wrapped = result.get();
}
// server, after handle_goal returned ACCEPT_AND_DEFER:
goal_handle->execute(); // ACCEPTED -> EXECUTING, status array published
X<A>::SharedPtr is rclcpp::Owned<X<A>> (the entity by value), and the
get_node_*_interface() handles name the node and do nothing else. A goal
handle is a heap token over the goal id: every call forwards to the server or
client keyed by it, and the goal’s state stays in the arena. Drive it from the
executor’s task only — not from a thread you started — and do not move the
server while a handle is in use. The callbacks run from the executor’s
dispatch, beside the client-wide set_callbacks ones. async_send_goal
returns the goal-keyed rclcpp::Future<GoalAccept>, not a
std::shared_future; get_goal_stamp is absent.
async_get_result / async_cancel_goal / async_cancel_all_goals /
async_cancel_goals_before return a
rclcpp_action::detail::DrivenSharedFuture<T>, which IS-A
std::shared_future<T>: its is_ready() (what spin_until_future_complete
polls), wait_for and get drive the client’s dispatch and executor while
they wait, since no other thread fulfils it. Converted to a bare
std::shared_future it still completes, but only when something else drives
the client. Every async_get_result for one goal returns the handle’s own
future; a result-aware goal’s handle is kept by the client until its result
arrives, as upstream’s pending request keeps it. Cancel replies are taken by
the executor’s spin, as upstream’s executor delivers them: the spin settles
the waiting async_cancel_* future and runs its cancel_callback, so a
caller that keeps only the callback needs no poll(). A reply no future
claims is kept for try_recv_cancel_response, oldest first, up to
rclcpp_action::CANCEL_STASH_CAP (4) per client. These need std::promise,
so they are hosted-only twice over: a libstdc++ built without threads
(arm-none-eabi) refuses them as a freestanding build does.
ServerGoalHandle::execute() and Server<A>::execute(goal_id) move an
ACCEPTED goal to EXECUTING and publish the status array; the latter reports
NotFound / NotAllowed where upstream raises. create_server executes an
ACCEPT_AND_EXECUTE goal before handle_accepted; the goal-id API leaves it
ACCEPTED.
Goal slots. Each action sub-client holds at most MAX_CONCURRENT_GOALS (4)
requests in flight. A future dropped (or cancel()ed) before its reply frees
its request’s slot, unless something else still waits on that reply — a
client-wide set_callbacks hook, or an upstream goal (async_send_goal with
callbacks, async_get_result). A late reply for a freed slot is dropped. On a
freestanding build each of these shapes is a compile error
(NROS_RCLCPP_REFUSE_ACTION_GOAL_HANDLE) naming the goal-id-keyed form above;
create_client alone works there.
Timer
Timers fire during spin_once():
void on_timer(void* ctx) {
// periodic work
}
rclcpp::Timer timer;
RCLCPP_TRY(node.create_wall_timer(timer, 1000, on_timer)); // 1000 ms period
RCLCPP_TRY(node.create_timer_oneshot(timer, 5000, on_timer)); // one-shot after 5 s
timer.cancel();
timer.reset(); // restart from zero
GuardCondition
Guard conditions allow cross-thread signalling:
void on_signal(void* ctx) {
// handle event
}
rclcpp::GuardCondition guard;
RCLCPP_TRY(node.create_guard_condition(guard, on_signal));
// From another thread:
guard.trigger();
// Callback fires on the next spin_once()
Executor
rclcpp::Executor executor;
RCLCPP_TRY(rclcpp::Executor::create(executor));
rclcpp::Node node;
RCLCPP_TRY(executor.create_node(node, "my_node"));
// Create publishers, subscriptions, etc. on node...
while (executor.ok()) {
executor.spin_once(10);
}
executor.shutdown();
Spinning
// Global spin (after rclcpp::init_in())
rclcpp::spin_once(10); // single poll, 10 ms timeout
rclcpp::spin_in(5000); // spin for 5 seconds
rclcpp::spin_in(5000, 50); // spin for 5 s, 50 ms poll interval
rclcpp::spin_in(); // until rclcpp::shutdown() flips rclcpp::ok()
// Explicit executor
executor.spin_once(10);
executor.spin_for(5000, 50); // bounded; executor.spin() blocks until shutdown
How spinning maps onto RTOS tasks and priorities is the subject of Execution Model.
Parameters and /parameter_events
Parameters are methods on the node (declare_parameter<T>, get_parameter<T>,
set_parameter<T>, undeclare_parameter), over the executor’s one store.
node.start_parameter_services() serves them on the six rcl_interfaces
services, so ros2 param reaches them, and publishes /parameter_events
(rcl_interfaces/msg/ParameterEvent, rmw_qos_profile_parameter_events) as
rclcpp’s node does:
rclcpp::Node node;
RCLCPP_TRY(executor.create_node(node, "ctrl"));
node.declare_parameter<int64_t>("gain", 3); // before or after: both reported
RCLCPP_TRY(node.start_parameter_services()); // start-up event: new_parameters = [gain]
node.set_parameter<int64_t>("gain", 4); // changed_parameters = [gain]
node.undeclare_parameter("gain"); // deleted_parameters = [gain]
Each event names the node’s fully qualified name in node; the events are
published on the next spin. The image needs the param_services capability.
One publisher per executor serves every node. It is on for every served node
unless the node was built with
rclcpp::NodeOptions().start_parameter_event_publisher(false), which opts
that one node out; the other nodes keep publishing.
To react to OTHER nodes’ changes, a hosted build has
rclcpp::ParameterEventHandler. It subscribes to /parameter_events once, on
the node you give it:
auto handler = std::make_shared<rclcpp::ParameterEventHandler>(node);
auto h = handler->add_parameter_callback(
"gain", [](const rclcpp::Parameter& p) { use(p.as_int()); }, "/controller");
auto e = handler->add_parameter_event_callback(
[](const rcl_interfaces::msg::ParameterEvent& ev) { /* every event */ });
Keep the handles: dropping one unregisters it, as upstream’s do. An empty node
name means this node, and a relative one resolves against this node’s
namespace. A parameter callback sees new and changed values, never deletions.
An event callback receives the type it takes, so rcl_interfaces must be
generated for the image. The per-parameter handle is
rclcpp::ParameterCallbackHandle, as upstream (the handler’s nested
ParameterEventHandler::ParameterCallbackHandle names the same type). A
freestanding build refuses the handler at compile time; there, subscribe to
/parameter_events with create_subscription yourself.
Error handling
All fallible operations return rclcpp::Result. Use RCLCPP_TRY for early
return:
rclcpp::Result setup() {
RCLCPP_TRY(rclcpp::init_in());
RCLCPP_TRY(rclcpp::create_node(node, "my_node"));
RCLCPP_TRY(node.create_publisher(pub, "/topic"));
return rclcpp::Result::success();
}
Check results manually when needed:
rclcpp::Result ret = pub.publish(msg);
if (!ret.ok()) {
printf("Error: %d\n", ret.raw());
}
ret.raw() is an rclcpp::ErrorCode. The numbering matches rcl_ret_t:
| Code | Value | Meaning |
|---|---|---|
Ok | 0 | success |
Error | -1 | generic failure not covered by a more specific code |
Timeout | -2 | the deadline elapsed before completion |
InvalidArgument | -3 | null pointer, empty topic name, or out-of-range value |
NotFound | -4 | entity not found (topic, parameter, service, goal) |
AlreadyExists | -5 | duplicate declare or register |
Full | -6 | a static pool is exhausted (executor slots, subscription buffers, …) |
NotInitialized | -7 | init was never called, or the entity is in its default state |
BadSequence | -8 | the call is invalid in the current state |
ServiceFailed | -9 | a service request or reply failed |
PublishFailed | -10 | publish failed |
SubscriptionFailed | -11 | subscription create or take failed |
NotAllowed | -12 | not allowed for this entity or backend |
Rejected | -13 | the peer considered the request and declined (a rejected goal, a QoS mismatch) |
TryAgain | -14 | no data ready yet (non-blocking take); retry later |
Reentrant | -15 | a blocking call was made from inside a callback |
Unsupported | -16 | not implemented by the active backend |
TransportError | -100 | the transport rejected the operation |
A Full from a create_* usually means a build-time pool is too small; see
Static Pool Inventory for the knob that sizes it.
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.
It is the surface for every node written for nano-ros, native host builds
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’s upstream-shaped
factories, which return std::shared_ptr and take const std::string&
names.
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… | Flag | Why |
|---|---|---|
writing a new node against rclcpp::Node / rclcpp::Publisher<M> / rclcpp::Executor | no | The 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 SharedPtr | yes | Those 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. |
passing a std::string topic name or a std::chrono period into an otherwise nano-ros-native file | yes | Those are the std_compat.hpp overloads. |
building for Zephyr, FreeRTOS, NuttX, ThreadX, or any -ffreestanding toolchain | no | And 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 that
disagree about it are an ODR/layout break rather than a missing function. An
image may set the flag on one module and not another, so keep the definition
at target granularity and give every translation unit of a target the same
answer.
A ported package usually needs none of this. nano-ros’s ament surface 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.
The ported shape
With the flag set, an upstream node’s own spelling compiles: std::make_shared,
SharedPtr returns, a std::string topic, a QoS depth, a capturing lambda and
a std::chrono period.
#include <rclcpp/rclcpp.hpp>
#include "std_msgs/std_msgs.hpp"
using namespace std::chrono_literals;
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("talker");
auto pub = node->create_publisher<std_msgs::msg::Int32>("chatter", 10);
int count = 0;
auto timer = node->create_wall_timer(1s, [&]() {
std_msgs::msg::Int32 msg;
msg.data = ++count;
pub->publish(msg);
});
rclcpp::spin(node);
rclcpp::shutdown();
}
A failed create_* here ABORTS, naming the call, because upstream throws and
nano-ros has no exceptions. To handle the failure instead, use the
Result-returning forms above.
Why it is not detected for you
Earlier releases discovered the capability macros 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 toolchains:
__STDC_HOSTED__ | __has_include(<string>) | #include <string> | |
|---|---|---|---|
arm-none-eabi 13.2, -ffreestanding (FreeRTOS) | 0 | TRUE | hard #error |
Zephyr -nostdinc++, minimal libcpp | 1 | FALSE | absent |
| hosted g++ 12.3 | 1 | TRUE | works |
Each probe is right on exactly one embedded toolchain 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 is in
the comments of rclcpp/detail/std_detect.hpp.
Zephyr
A Zephyr application reaches the C++ API through the nano-ros Zephyr module.
The module, the image’s system.toml and the prj.conf split are covered in
Zephyr integration; the RMW,
locator and domain come from the image, never from CONFIG_NROS_* lines in
prj.conf. The C++ side needs CONFIG_CPP=y and CONFIG_STD_CPP14=y in
prj.conf (Zephyr’s own settings), and the application’s CMakeLists.txt
generates the message bindings:
cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_app LANGUAGES CXX)
nros_generate_interfaces(std_msgs LANGUAGE CPP)
target_sources(app PRIVATE src/main.cpp)
A minimal main() waits for the network, opens the session with the locator
and domain the image rendered into Kconfig, and spins:
#include <zephyr/kernel.h>
#include <zephyr/logging/log.h>
#include <nros/platform_zephyr.h>
#include <rclcpp/rclcpp.hpp>
#include "std_msgs.hpp"
LOG_MODULE_REGISTER(my_app, LOG_LEVEL_INF);
int main(void)
{
nros_platform_zephyr_wait_network(CONFIG_NROS_INIT_DELAY_MS);
rclcpp::Result ret = rclcpp::init_in(CONFIG_NROS_ZENOH_LOCATOR, CONFIG_NROS_DOMAIN_ID);
if (!ret.ok()) return 1;
rclcpp::Node node;
if (!rclcpp::create_node(node, "my_node").ok()) return 1;
// ... create publishers, subscriptions, etc.
while (true) {
rclcpp::spin_once(100);
}
}
nros_platform_zephyr_wait_network (<nros/platform_zephyr.h>) is defined
in every Zephyr image. On an image with no IP stack (CONFIG_NET_SOCKETS off,
for example a serial-only zenoh image) it returns 0 at once, so the call needs
no guard. CONFIG_NROS_INIT_DELAY_MS is the module’s start-up delay; your
main() is its only reader.
Building the API docs from source
The Doxygen site linked above is the published copy. Building it from the nano-ros source tree: see Internals → Build Commands.
Design background
- RFC-0018: C++ API design — freestanding C++14, no exceptions, the
Resultchannel - RFC-0089: ROS 2 API adoption, and the compile-or-conform rule — upstream’s namespaces and names, the
_inrule, the hosted surface - RFC-0021: blocking API rules — why blocking calls drive the executor and refuse re-entry
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.
Regenerating the Doxygen in the tree: see Internals → Quick Reference § API documentation.
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.
| Backend | Source | Notes |
|---|---|---|
| zenoh-pico | packages/rmw/zenoh/nros-rmw-zenoh | Default. C transport via zenoh-pico. Native zero-copy publish via z_bytes_from_static_buf. |
| micro-XRCE-DDS-Client | packages/rmw/xrce/nros-rmw-xrce | C-only shim; agent-based. |
| Cyclone DDS | packages/rmw/cyclonedds/nros-rmw-cyclonedds | C++ shim; standalone CMake project. |
| PX4 uORB | packages/rmw/uorb/nros-rmw-uorb | Typed-trampoline registry over PX4 uORB. |
The zenoh-pico shim is the canonical reference port.
Writing a custom backend
- Conceptual walkthrough: Custom RMW Backend.
- Coming from upstream
rmw.h? → RMW API: Differences from upstreamrmw.h. - Coverage status vs upstream
rmw.h:docs/research/rmw-c-abi-coverage.md.
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; 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.
Regenerating the Doxygen in the tree: see Internals → Build Commands.
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.
| Crate | Target | Source |
|---|---|---|
nros-platform-posix | Linux / *BSD | packages/platform/nros-platform-posix |
nros-platform-nuttx | NuttX RTOS | packages/platform/nros-platform-nuttx |
nros-platform-freertos | FreeRTOS | packages/platform/nros-platform-freertos |
nros-platform-threadx | Azure RTOS / ThreadX | packages/platform/nros-platform-threadx |
nros-platform-zephyr | Zephyr RTOS | packages/platform/nros-platform-zephyr |
nros-platform-mps2-an385 | Cortex-M3 (QEMU) | packages/platform/nros-platform-mps2-an385 |
nros-platform-stm32f4 | STM32F4 | packages/platform/nros-platform-stm32f4 |
The POSIX implementation is the canonical reference port.
Writing a custom platform
- Conceptual walkthrough: Custom Platform.
- Per-platform behaviour matrix: Platform Differences.
Design background
- RFC-0054: C header ABI single source of truth — why the C headers are canonical and Rust uses generated bindings
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
| Platform | Clock source | Allocator | Threading | Networking | UDP multicast | Wall-clock | Notes |
|---|---|---|---|---|---|---|---|
| POSIX | clock_gettime(CLOCK_MONOTONIC) | libc malloc | pthreads | libc BSD sockets (Rust) | Yes | clock_gettime(CLOCK_REALTIME) | Canonical reference port. |
| NuttX | POSIX alias | libc malloc | pthreads | zenoh-pico unix/network.c (C) | Yes | POSIX | Most paths inherit POSIX behaviour. |
| FreeRTOS | xTaskGetTickCount | pvPortMalloc | FreeRTOS tasks | lwIP via freertos-lwip-sys (Rust) | — | Tick-based, no RTC | Multicast gated on lwIP LWIP_IGMP=1 (untested). |
| Zephyr | k_uptime_get | k_malloc | Zephyr POSIX pthreads | Zephyr POSIX sockets (Rust) | Yes (native_sim via NSOS) | clock_gettime(CLOCK_REALTIME) | native_sim multicast routes through the NSOS IPPROTO_IP patch. |
| ThreadX | tx_time_get | tx_byte_allocate | ThreadX threads | NetX Duo BSD network.c (C) | — | tx_time_get fallback | Multicast gated on NetX Duo nx_igmp_* (untested). |
| Bare-metal (MPS2-AN385) | CMSDK Timer0 | bump allocator | single-threaded | nros-smoltcp (Rust) | Yes (smoltcp 0.12 IGMP) | monotonic fallback | Cortex-M3 QEMU. |
| Bare-metal (STM32F4) | DWT cycle counter | bump allocator | single-threaded | nros-smoltcp (Rust) | Yes | monotonic fallback | Cortex-M4F. |
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:
xTaskGetTickCountticks (typically 1 ms).clock_us=clock_ms × 1000— the resolution lie is documented. - Zephyr:
k_uptime_getms +k_cyc_to_us_floor64(k_cycle_get_64())µs. - ThreadX:
tx_time_getticks (default 100 Hz; bump viaTX_TIMER_TICKS_PER_SECONDfor finer resolution). - Bare-metal (Cortex-M): hardware timer counter (CMSDK Timer0, DWT cycle counter). µs is the native resolution; ms is
µs / 1000.
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 throughnros-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
| Tier | Lane | Runs | Guarantee |
|---|---|---|---|
| 1 | ci (every change) | Host-native builds + native fixtures, all gates | The logic and the seams are sound. Says nothing about embedded targets. |
| 2 | ci-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-nightly | ci-matrix-nightly | Pairwise cover (~70 %) | Platform×language and RMW×language interactions surface here, within a day. |
| 3 | ci-full (pre-release) | The whole matrix | Everything the tree claims, verified together. |
Per-board support levels are generated on their own page: Board Support Levels. The procurement view, including parts with no board package, is Supported Boards.
RTOS / platform source pins
nros setup <board> fetches exactly these (shared store
${NROS_HOME:-~/.nros}/sdk):
| Component | Pinned version | Used by |
|---|---|---|
| FreeRTOS kernel | 10.6.2 | FreeRTOS boards (QEMU MPS2-AN385, overlays) |
| lwIP | 2.2.0 | FreeRTOS networking |
| ThreadX | 6.4.1 | ThreadX (Linux sim + QEMU RISC-V64) |
| NetX Duo | 6.4.x | ThreadX networking |
| NuttX kernel | 12.13.0 | NuttX (QEMU armv7a / rv32) |
| Zephyr SDK | 0.16.8 (+ 1.0.1 for the 4.4 rolling line) | Zephyr module builds |
| mbedTLS | 3.x | zenoh 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
| Backend | Client side (pinned) | Host side | Pairing rule |
|---|---|---|---|
| Zenoh | zenoh-pico 1.10.1 (vendored submodule) | Router = the one ROS ships: ros2 run rmw_zenoh_cpp rmw_zenohd — nano-ros deliberately vendors no router | zenoh-pico’s wire protocol is stable across zenoh 1.x, so the pinned client interops with a newer ROS-shipped router |
| XRCE-DDS | Micro-XRCE-DDS client 3.0.1 + Micro-CDR 2.0.2 | Agent 2.4.3-nros1 (installed by nros setup … --rmw xrce) | Client and agent are pinned independently and are NOT the same upstream release; they meet at the DDS-XRCE wire protocol |
| Cyclone DDS | fork 0.10.5-nros1 — upstream 0.10.5 + carried patches (fork delta) | Talks RTPS directly to rmw_cyclonedds_cpp | Pinned to the Cyclone ROS ships (ros-humble-cyclonedds 0.10.5) — upstream 11.x is deliberately NOT a rebase target until a distro migration |
Per-feature coverage across the three backends: Per-RMW Feature Matrix.
ROS 2 editions
| Edition | Feature / flag | Status | Notes |
|---|---|---|---|
| Humble | ros-humble (default) | Supported | TypeHashNotSupported key expressions, XCDR1. No rmw_zenoh_cpp apt package — zenoh interop on Humble needs a source overlay, or use Cyclone DDS. |
| Iron | ros-iron | Supported | RIHS01 type hashes. Same zenoh-apt caveat as Humble. |
| Jazzy | ros-jazzy | Supported | RIHS01; ships ros-jazzy-rmw-zenoh-cpp — the zenoh-interop reference edition. A default Jazzy peer encodes XCDR1/FINAL, which nano-ros speaks; per-type XCDR2 (@appendable) is not enabled. |
Pick the edition of the ROS system you interop with — mismatched editions discover nothing (different key expressions).
Supported means WIRE interop, not API parity. nano-ros’s API is
compared against Humble and Jazzy — Jazzy from pinned upstream
sources, since it cannot be installed beside Humble
(how the comparison runs). Iron is not compared on its own; what it added and Jazzy kept is
covered there. Jazzy’s additions over Humble are each RULED, not each implemented:
matched/incompatible-type events, timer call info,
the small rclcpp accessors,
count_clients/count_services, the client GID and
event_type_is_supported are implemented;
BEST_AVAILABLE QoS is implemented,
resolved at creation against the discovered graph;
dynamic typesupport, service introspection and the
~/get_type_description service are declined on RAM/flash grounds;
rmw_discovery_options_t is answered by the session locator and
localhost_only. Type hashes (RIHS01) are on the wire today.
Toolchains
| Tool | Pinned version |
|---|---|
| Rust | 1.99.0 channel (rust-toolchain.toml); pinned cross targets: thumbv7m-none-eabi, thumbv7em-none-eabihf, armv7r-none-eabi, armv7r-none-eabihf, armv8r-none-eabihf, riscv32imc-unknown-none-elf, riscv64gc-unknown-none-elf, aarch64-unknown-none, armv7a-none-eabi, armv7a-none-eabihf, thumbv7em-none-eabi, thumbv8m.main-none-eabi, thumbv8m.main-none-eabihf, i686-unknown-linux-gnu, x86_64-unknown-none, thumbv6m-none-eabi. rustfmt runs on nightly (nightly-only options). |
Arm GCC (arm-none-eabi) | 13.2-nros5 |
RISC-V GCC (riscv-none-elf) | 14.2-nros1 |
| QEMU (patched, Arm) | 11.0.0-nros8 |
| 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) |
| OpenOCD | 0.12.0-nros2 |
| sccache | 0.17.0-nros1 |
-nros<N> suffixes mark builds re-packaged (or patched) for the SDK
store; the base version is the upstream release.
Related
- Install — how these get onto
a machine (
nros setup, prebuilt-vs-source rules) - Per-RMW Feature Matrix
- Board Support Levels (generated) · Supported Boards (procurement view)
- Integrating into a Vendored Tree — pinning and upgrading these from a company tree
Per-RMW Feature Matrix
One table per question the capability pages used to answer in
contradictory prose. Derived from the backend sources — the C
vtables (vtable.c (XRCE-DDS), vtable.cpp (Cyclone DDS), vtable.cpp (uORB)) 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). A slot filled with a function that only
returns UNSUPPORTED (uORB’s service stubs) is —, not wired.
Status events count as wired whether the backend calls back
(*_event_init) or the runtime polls it (*_take_event). The rows
below the parity marker are slots declared in the ABI whose backend
wiring is still landing; all-dash
rows there mean declared, not yet wired anywhere, and they flip
automatically as backends land implementations.
Session / entity capabilities
| Capability | Zenoh | XRCE-DDS | Cyclone DDS | uORB |
|---|---|---|---|---|
| Publish / subscribe | wired | wired | wired | wired |
| Services (server side) | wired | wired | wired | — |
| Service clients | wired | wired | wired | — |
| Server-availability probe | wired | — | wired | — |
| Status events (deadline / liveliness / lost) | wired | wired | wired | — |
| Matched / incompatible-type events (Iron+) | wired | — | wired | — |
| Manual liveliness assert | — | — | wired | — |
Event-driven wake (set_wake_callback) | wired | wired | wired | wired |
Deadline hint (next_deadline_ms) | wired | wired | — | — |
| Zero-copy loan API | wired | — | — | — |
Batch receive (take_sequence) | wired | — | wired | — |
| Streamed publish | wired | wired | — | — |
| Connectivity ping | wired | wired | — | — |
| Identity / feature probe | — | — | — | — |
| Publisher GID | wired | wired | wired | — |
| Matched-endpoint counts | — | — | wired | — |
| Actual-QoS read-back | wired | wired | wired | wired |
| Wait-for-acked | — | — | — | — |
| Take-with-info | wired | wired | wired | — |
| Entity new-data callbacks | wired | wired | wired | — |
| Graph introspection (names/types/counts) | wired | — | wired | — |
Service-graph counts (count_clients / count_services) | wired | — | wired | — |
| Client GID | wired | wired | wired | — |
Event-kind probe (event_type_is_supported) | wired | wired | wired | — |
Node-layer features
These live in nros-node on top of pub/sub + services — they are
not backend slots, so a backend has them exactly when it has those.
tested = at least one Runtime cell for that backend and workload
in matrix::CELLS or interop::CELLS (packages/testing/nros-tests/src/);
untested = available, but no runtime cell runs it; — = the
backend lacks pub/sub or services, so the feature cannot exist there.
| Feature | Zenoh | XRCE-DDS | Cyclone DDS | uORB | Rule |
|---|---|---|---|---|---|
| Actions | tested | tested | tested | — | Built in nros-node on pub/sub + services (goal / cancel / result services + feedback / status topics). |
| Parameters (+ param services) | tested | tested | tested | — | Node-layer services; available wherever services are. |
| Lifecycle (REP-2002) | tested | untested | untested | — | Node-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. Each column is that backend’s OWN answer: the zenoh
shim’s Session::supported_qos_policies, and for a C backend the
function its vtable puts in the supported_qos_policies slot.
Per-policy semantics: RMW vs upstream §7.
| Policy | Zenoh | XRCE-DDS | Cyclone DDS | uORB |
|---|---|---|---|---|
RELIABILITY | ✓ | ✓ | ✓ | ✓ |
DURABILITY_VOLATILE | ✓ | ✓ | ✓ | ✓ |
DURABILITY_TRANSIENT_LOCAL | ✓ | ✓ | ✓ | — |
HISTORY | ✓ | ✓ | ✓ | ✓ |
DEPTH | ✓ | ✓ | ✓ | ✓ |
DEADLINE | ✓ | ✓ | ✓ | — |
LIFESPAN | ✓ | — | ✓ | — |
LIVELINESS_AUTOMATIC | ✓ | — | ✓ | — |
LIVELINESS_MANUAL_BY_TOPIC | ✓ | — | ✓ | — |
LIVELINESS_MANUAL_BY_NODE | — | — | — | — |
LIVELINESS_LEASE | ✓ | — | ✓ | — |
AVOID_ROS_NAMESPACE_CONVENTIONS | — | ✓ | — | — |
Related
- Choosing an RMW Backend — the decision tree, and how each backend works
- Support Status — versions, pins, and CI tiers
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.
Kernel capabilities per platform
| Platform | Kernel EDF | Reservation / sporadic server | Preemption threshold | SMP affinity | Priority levels | Low number = high priority |
|---|---|---|---|---|---|---|
| Linux host | — | — | — | ✓ | 99 | — |
| Zephyr | ✓ | — | — | ✓ | 32 | ✓ |
| FreeRTOS | — | — | — | ✓ | 16 | — |
| ThreadX | — | — | ✓ | ✓ | 32 | ✓ |
| NuttX | — | ✓ | — | ✓ | 255 | — |
| Bare-metal (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,
single core. These are PLATFORM DEFAULTS and there is no per-image
override today. ([deploy.<board>] edf / cores, documented in older
releases, never took effect and is retired.)
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.
| Class | Dispatch sites in spin.rs | Status |
|---|---|---|
Fifo | 3 | Live — the default class; bucketed FIFO ready-set. |
Edf | 2 | Live — earliest-deadline-first ready-set ordering. |
Sporadic | 6 | Live — budget/period enforcement in dispatch. |
Related
- Scheduling Models — the narrative: how tiers, transport priorities, and these classes land on each kernel
- RTOS Cooperation — spin-loop shapes per execution model
- Support Status · Per-RMW Feature Matrix
Supported Boards
Procurement-grade compatibility matrix, in two parts.
Board packages nano-ros ships come first. Those rows are generated from the board support registry, so each status there is derived from evidence (where the board runs, how often, and when it last did) and links to the board’s evidence page. Board Support Levels explains each status word.
| Board package | Status | Runs on | Evidence |
|---|---|---|---|
nros-board-linux (Linux) | Verified on host (merge) | host executable | evidence page |
nros-board-zephyr (ZephyrNativeSim) | Verified on host (merge) | host executable | evidence page |
nros-board-mps3-an536-freertos | Builds | qemu:mps3-an536 (twin) | evidence page |
nros-board-mps2-an385-freertos (FreertosMps2) | Verified in emulator (daily) | qemu:mps2-an385 | evidence page |
nros-board-s32z270-freertos | Builds | s32z270 (target) | evidence page |
nros-board-nuttx-qemu (NuttxArm) | Verified in emulator (daily) | qemu:virt-armv7a | evidence page |
nros-board-threadx-linux (ThreadxLinux) | Verified on host (merge) | host executable | evidence page |
nros-board-freertos-posix (FreertosPosix) | Verified on host (daily) | host executable | evidence page |
nros-board-threadx-qemu-riscv64 (ThreadxRiscv64) | Verified in emulator (daily) | qemu:rv-virt | evidence page |
nros-board-mps2-an385 (QemuBaremetal) | Verified in emulator (daily) | qemu:mps2-an385 | evidence page |
nros-board-mps2-an385-zephyr (ZephyrQemuCortexM) | Verified in emulator (daily) | qemu:mps2-an385 | evidence page |
nros-board-nuttx-qemu (NuttxRiscv) | Verified in emulator (daily) | qemu:rv-virt | evidence page |
nros-board-zephyr (Fvp) | Boots, manual (2026-09-06); reads Builds from 2027-01-05 | fvp:FVP_BaseR_AEMv8R | evidence page |
s32k344-safety-island | Boots on hardware, manual (2026-10-26); reads Builds from 2027-02-24 | mr-canhubk344 | evidence page |
nros-board-nucleo-f767zi-zephyr (ZephyrNucleoF767zi) | Boots, manual (2026-11-01); reads Builds from 2027-03-02 | renode:nucleo_f767zi, nucleo-f767zi (target) | evidence page |
nros-board-rpi-pico-w-zephyr | Boots, manual (2026-11-01); reads Builds from 2027-03-02 | wokwi:pi-pico-w, rpi-pico-w (target) | evidence page |
Other boards and how you would approach them
The parts below have no board package in nano-ros, so the registry says nothing about them and this page claims no status for them. Each row names the path you would take to bring nano-ros up on it.
| Vendor | Board | MCU / SoC | Arch | RTOS to start from | How to approach it |
|---|---|---|---|---|---|
| STMicro | STM32F4-Discovery | STM32F407 | Cortex-M4F | bare | Worked example, an out-of-tree board package |
| STMicro | STM32H7-Nucleo | STM32H743 | Cortex-M7F | FreeRTOS / Zephyr | FreeRTOS or Zephyr starter, with an overlay on nros-board-freertos |
| STMicro | Pixhawk 4 (FMUv5) | STM32F765 | Cortex-M7F | NuttX (PX4) | The PX4 module template: PX4 Autopilot |
| STMicro | Pixhawk 6X / 6C | STM32H753 | Cortex-M7F | NuttX (PX4) | The PX4 module template: PX4 Autopilot |
| Nordic | nRF52840-DK | Cortex-M4F | Armv7E-M | Zephyr | Zephyr starter with -b nrf52840dk_nrf52840 |
| Nordic | nRF5340-DK | Cortex-M33 (dual) | Armv8-M | Zephyr | Zephyr starter with -b nrf5340dk_nrf5340_cpuapp |
| NXP | LPC55S69-EVK | Cortex-M33 | Armv8-M | Zephyr | Zephyr starter with -b lpcxpresso55s69_cpu0 |
| NXP | MIMXRT1170-EVK | Cortex-M7 + M4 | Armv7-M | FreeRTOS / Zephyr | FreeRTOS starter plus the vendor BSP |
| TI | LP-CC1352P7 | Cortex-M4F | Armv7E-M | FreeRTOS / TI-RTOS | FreeRTOS starter plus a TI driver overlay |
| RP2040 | Raspberry Pi Pico | Cortex-M0+ | Armv6-M | bare / FreeRTOS | The bare-metal Cortex-M path; see the Cortex-M0+ caveat below |
| QEMU | Cortex-A9 (Versatile) | Cortex-A9 | Armv7-A | Zephyr | Zephyr starter with -b qemu_cortex_a9 |
| NVIDIA | Jetson Orin SPE | Cortex-R5 | Armv7-R | FreeRTOS (NVIDIA FSP) | No board package, and no work on one is planned. The IVC driver (nvidia-ivc) and the zenoh-pico IVC link remain in nano-ros |
The ESP32 boards (ESP-IDF component and esp-hal bare-metal) are retired: ESP32 — retired.
To add a board, as a package of your own or contributed to nano-ros, see Adding a Board to the Registry.
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): not supported. nano-ros has no Xtensa board, and the RISC-V ESP32 boards are retired (ESP32 — retired).
- 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 Levels — what each status word means.
- Embedded Starters — per-RTOS walkthrough.
- Platform Differences — per-platform capability deltas.
Environment Variables Reference
Set a runtime variable in the shell that starts the program. Set a
build-time knob in the shell before nros build, or per image in
system.toml (an [image.<id>] env row, or the knob-registry table where
the section below says so).
The checkout’s .env file for in-tree builds: see
Internals → Build Commands.
Runtime Configuration
Examples use ExecutorConfig::from_env() for configuration:
| Variable | Description | Default |
|---|---|---|
ROS_DOMAIN_ID | ROS 2 domain ID | 0 |
NROS_LOCATOR | RMW locator (tcp/…, udp/…, serial/…, or tls/…) | tcp/127.0.0.1:7447 |
NROS_SESSION_MODE | Session mode: client or peer | client |
ZENOH_TLS_ROOT_CA_CERTIFICATE | Path to CA certificate (PEM) for TLS | (none) |
ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64 | Base64-encoded CA certificate for TLS | (none) |
ZENOH_TLS_VERIFY_NAME_ON_CONNECT | Verify server hostname in TLS (true/false) | (none) |
Retired legacy names:
ZENOH_LOCATORandZENOH_MODEare no longer read. Setting either is a startup ERROR naming its replacement (NROS_LOCATOR/NROS_SESSION_MODE) — Rust panics inresolve_hosted/ returnsInitError::RetiredEnvVarfromnros::init, C / C++ init returnsRCL_RET_INVALID_ARGUMENT/NROS_CPP_RET_INVALID_ARGUMENTand 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(install it with your host’s package manager). File-path and base64 cert loading are both supported. - Bare-metal: only
ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64is supported (no filesystem). The certificate is embedded at build time. - The
link-tlsCargo feature must be enabled on both the example and thenroscrate.
Build-Time Configuration
| Variable | Description | Required |
|---|---|---|
ZENOH_PICO_DIR | CMake install prefix for pre-built zenoh-pico (use with system-zenohpico feature on zpico-sys) | Only with system-zenohpico |
NROS_EXTRA_BOARD_PATH | Extra 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.
| Variable | Description | Default |
|---|---|---|
ARMFVP_BIN_PATH | Directory containing FVP_BaseR_AEMv8R (Zephyr-canonical, highest priority). | (unset) |
ARM_FVP_DIR | Install root; resolver scans models/Linux64_GCC-*/ underneath. | (unset) |
The model is found in this order: ARMFVP_BIN_PATH, ARM_FVP_DIR,
FVP_BaseR_AEMv8R on PATH, then the store copy that
nros setup --tool arm-fvp installs. Set ARMFVP_BIN_PATH at BUILD time as
well as run time, because Zephyr’s armfvp.cmake caches the location at
configure. For a model you downloaded by hand, set ARM_FVP_DIR to the
extraction root. nros doctor --board fvp-aemv8r-smp reports whether the
model resolves.
Changing how the FVP is resolved or run in the tree: see Internals → Zephyr in-tree examples.
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: 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.
| Variable | Tree |
|---|---|
FREERTOS_DIR | FreeRTOS kernel source |
LWIP_DIR | lwIP source |
NUTTX_DIR | NuttX RTOS source |
NUTTX_APPS_DIR | NuttX apps source |
THREADX_DIR | ThreadX kernel source |
NETX_DIR | NetX Duo source |
CYCLONEDDS_SOURCE_DIR | Cyclone 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 used to be spelled ZPICO_<X>. That old spelling is
retired: 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.
| Variable | Description | Default | Crate |
|---|---|---|---|
NROS_ZENOH_FRAG_MAX_SIZE | Max reassembled message size after defragmentation | 65536 / 2048 | zpico-sys |
NROS_ZENOH_BATCH_UNICAST_SIZE | Max unicast batch size before fragmentation | 65536 / 1024 | zpico-sys |
NROS_ZENOH_BATCH_MULTICAST_SIZE | Max multicast batch size | 8192 / 1024 | zpico-sys |
NROS_ZENOH_MAX_PUBLISHERS | Max concurrent publishers in zenoh shim | 8 | zpico-sys |
NROS_ZENOH_MAX_SUBSCRIBERS | Max concurrent subscribers in zenoh shim | 8 | zpico-sys |
NROS_ZENOH_MAX_QUERYABLES | Max concurrent queryables in zenoh shim | 8 | zpico-sys |
NROS_ZENOH_MAX_LIVELINESS | Max 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. A stated value wins. | 16 when nothing is declared | zpico-sys |
NROS_ZENOH_MAX_PENDING_GETS | Max concurrent in-flight service calls | 4 | zpico-sys |
NROS_ZENOH_MAX_TL_PUBLISHERS | Retention 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. | 2 | nros-rmw-zenoh |
NROS_ZENOH_TL_RETAIN_BYTES | Bytes one retained TRANSIENT_LOCAL sample may hold. A larger publish is SENT but not retained, and says so once at WARN. | 1024 | nros-rmw-zenoh |
NROS_ZENOH_TL_RETAIN_DEPTH | Samples 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; the publisher is granted and advertises min(asked, this). Must be >= 1. | 1 when nothing is declared | nros-rmw-zenoh |
NROS_SUBSCRIBER_BUFFER_SIZE | small 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.) | 1024 | nros-rmw-zenoh |
NROS_ZENOH_SUBSCRIBER_LARGE_SIZE | large size-class slot, for subscriptions whose type does not fit the small block | 16384 | nros-rmw-zenoh |
NROS_ZENOH_SUBSCRIBER_SIZE_THRESHOLD | The 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. | 2048 | nros-rmw-zenoh |
NROS_ZENOH_PUBLISHER_TX_BUFFER_SIZE | Per-publisher TX arena for the zero-copy loan path; costs NROS_ZENOH_MAX_PUBLISHERS x this. | 1024 | nros-rmw-zenoh |
NROS_ZENOH_MAX_LARGE_SUBSCRIBERS | How many large-class blocks this image reserves. 0 is legal: 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 a cargo image gets it from the build settings nros build generates under build/, joined over the types the image’s components subscribe to. Setting it here still wins. | 2 | nros-rmw-zenoh |
NROS_ZENOH_SERVICE_BUFFER_SIZE | Per-service-server static buffer in zenoh shim | 1024 | nros-rmw-zenoh |
NROS_PARAM_SERVICE_INBOX_BYTES | Bytes 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. 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_BYTES | nros-rmw-zenoh |
NROS_PARAM_SERVICE_INBOX_DEPTH | Requests 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. | 1 | nros-rmw-zenoh |
NROS_ZENOH_GET_REPLY_BUF_SIZE | Stack buffer for service client replies | 4096 | zpico-sys |
NROS_ZENOH_GET_POLL_INTERVAL_MS | Single-threaded polling interval in zenoh_shim_get() | 10 | zpico-sys |
NROS_SMOLTCP_MAX_SOCKETS | Max concurrent TCP sockets (smoltcp); brokered default. (ZPICO_SMOLTCP_MAX_SOCKETS is retired.) | 1 (brokered) | nros-smoltcp |
NROS_SMOLTCP_MAX_UDP_SOCKETS | Max concurrent UDP sockets (smoltcp); 1 by default, 4 with the nros-smoltcp/rtps feature. (ZPICO_SMOLTCP_MAX_UDP_SOCKETS is retired.) | 1 (brokered) | nros-smoltcp |
NROS_SMOLTCP_BUFFER_SIZE | Per-socket staging buffer (smoltcp). (ZPICO_SMOLTCP_BUFFER_SIZE is retired.) | 2048 | nros-smoltcp |
NROS_SMOLTCP_CONNECT_TIMEOUT_MS | TCP connection timeout (smoltcp). (ZPICO_SMOLTCP_CONNECT_TIMEOUT_MS is retired.) | 30000 | nros-smoltcp |
NROS_SMOLTCP_SOCKET_TIMEOUT_MS | TCP read/write timeout (smoltcp). (ZPICO_SMOLTCP_SOCKET_TIMEOUT_MS is retired.) | 10000 | nros-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.
| Variable | Description | Default | Min | Crate |
|---|---|---|---|---|
NROS_XRCE_BUFFER_SIZE | Per-slot receive buffer. See the note below — this is the receive ceiling. | 1024 | 64 | nros-rmw-xrce |
NROS_XRCE_SUBSCRIBER_RING_DEPTH | Queued samples per subscriber | 32 | 1 | nros-rmw-xrce |
NROS_XRCE_MAX_SUBSCRIBERS | Max concurrent subscribers. On Zephyr, DERIVED — see below. | 8 | 0 | nros-rmw-xrce |
NROS_XRCE_MAX_SERVICE_SERVERS | Max concurrent service servers. On Zephyr, DERIVED — see below. | 4 | 0 | nros-rmw-xrce |
NROS_XRCE_MAX_SERVICE_CLIENTS | Max concurrent service clients. DERIVED from the sizing descriptor — see below. | 4 | 0 | nros-rmw-xrce |
NROS_XRCE_STREAM_HISTORY | Reliable stream history depth; sizes the per-session output buffer | 16 | 4 | nros-rmw-xrce |
NROS_XRCE_CUSTOM_TRANSPORT_MTU | Custom transport MTU; stream buffers are MTU x STREAM_HISTORY | 4096 | 128 | nros-rmw-xrce |
NROS_XRCE_TRANSPORT_MTU | UDP/TCP transport MTU, and the Zephyr default of CUSTOM_TRANSPORT_MTU | 4096 (Zephyr 512) | 128 | nros-rmw-xrce |
NROS_XRCE_SUBSCRIBER_BUFFER_SIZE | Subscriber ring entry size; derived from the declared subscriptions when unstated (Zephyr: -1) | BUFFER_SIZE | 1 | nros-rmw-xrce |
NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE | Application service-server request entry size. DERIVED from the declared servers’ requests beside the builtin split | BUFFER_SIZE | 1 | nros-rmw-xrce |
NROS_XRCE_SERVICE_REPLY_BUFFER_SIZE | Service-client reply slot size. DERIVED from the declared clients’ replies | BUFFER_SIZE | 1 | nros-rmw-xrce |
NROS_XRCE_BUILTIN_SERVICE_SERVERS | How many service-server slots are the parameter / lifecycle services’. DERIVED beside the total | 0 (no split) | 0 | nros-rmw-xrce |
NROS_XRCE_BUILTIN_SERVICE_REQUEST_BUFFER_SIZE | Request entry size of a builtin-family slot | BUFFER_SIZE, raised to the parameter request | 1 | nros-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.
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 beside the
bringup’s system.toml when it has no launch/). 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.
Stating a number in Kconfig or the environment still wins over the derivation,
in both directions.
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTS also defaults to -1, and its number
comes from the image’s sizing descriptor on every road that writes one: the
declared service clients plus three per action client (send_goal,
cancel_goal, get_result). With no descriptor, or one that does not state
both counts, the crate default of 4 stands.
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 have a
Kconfig symbol, and on Zephyr that is the way to set it: 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.
A payload at or above the transport MTU (4096) arrives fragmented; the receive path reads the fragment chain and delivers it whole.
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.
| Define | Description | Default |
|---|---|---|
XRCE_ENTITY_CREATION_TIMEOUT_MS | Timeout for entity creation | 1000 |
XRCE_SERVICE_REPLY_TIMEOUT_MS | Per-attempt service reply timeout | 50 |
XRCE_SERVICE_REPLY_TOTAL_MS | Total service reply budget | 5000 |
XRCE_SESSION_FLUSH_TIMEOUT_MS | Session flush timeout | 100 |
XRCE_SESSION_CREATION_RETRIES | Session creation retries | 3 |
XRCE_MAX_PENDING_REPLIES | In-flight service replies | 4 |
XRCE_SERVICE_REQUEST_RING_DEPTH | Queued service requests per server | 4 |
XRCE_DEFAULT_AGENT_PORT | Default agent port | 2018 |
Core (NROS_*)
| Variable | Description | Default | Crate |
|---|---|---|---|
NROS_EXECUTOR_MAX_CBS | Max executor callback slots (compile-time fixed array size) | 4 | nros-node |
NROS_EXECUTOR_ARENA_SIZE | Executor arena size in bytes (compile-time fixed array size) | 4096 | nros-node |
NROS_SUBSCRIPTION_BUFFER_SIZE | Default subscription/service buffer size (bytes) | 1024 | nros-node |
NROS_LET_BUFFER_SIZE | Buffer size for LET semantics per handle | 512 | nros-c |
NROS_MAX_CONCURRENT_GOALS | Max concurrent goals per action server (compile-time constant, not env-var configurable) | 4 | nros-c |
NROS_MAX_PARAMETERS | Max parameters in parameter server | 32 | nros-params |
NROS_MAX_PARAM_NAME_LEN | Max parameter name length | 64 | nros-params |
NROS_MAX_STRING_VALUE_LEN | Max string parameter value length | 256 | nros-params |
NROS_MAX_ARRAY_LEN | Max parameter array length | 32 | nros-params |
NROS_MAX_BYTE_ARRAY_LEN | Max byte array parameter length | 256 | nros-params |
NROS_MAX_PARAM_DESCRIPTION_LEN | Max parameter description length; longer is truncated and logged, 0 = none (max 256) | 256 | nros-params |
Design background
- RFC-0103: one owner per fact — the one source-location ladder, the
NROS_<ID>knob spelling, and knob-registry rows
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 namednros-bridge.tomlto avoid colliding with the orchestrationnros.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:
- Which RMW backends to open, under what locator / domain.
- 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.
| Key | Type | Default | Notes |
|---|---|---|---|
name | string | required | Logical name. Bridge entries reference this. |
rmw | string | required | Canonical backend name: "zenoh", "xrce", "cyclonedds". Must match a backend the binary linked in. |
locator | string | "" | Backend-specific locator. Zenoh uses tcp/..., udp4/..., serial/...; DDS uses domain=<n>; XRCE uses udp4://...:port. |
domain_id | u32 | 0 | ROS domain id passed to the backend. |
namespace | string | "/" | Default namespace applied to handles created on this node. |
Locator scheme grammar (zenoh)
| Scheme | Example | Meaning |
|---|---|---|
tcp/<host>:<p> | tcp/10.0.0.1:7447 | TCP unicast |
udp/<host>:<p> | udp/10.0.0.1:7447 | UDP unicast |
serial/<dev> | serial//dev/ttyUSB0 | UART (host-side) or board UART (bare-metal) |
tls/<host>:<p> | tls/router.example.org:7447 | TLS 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.
| Key | Type | Default | Notes |
|---|---|---|---|
type | string | required | ROS type name ("std_msgs/Int32"). Backends use it for liveliness / discovery. |
type_hash | string | "" | ROS 2 RIHS type hash for the typed binding. May be left empty if both sides ignore it. |
from | endpoint | required | { node = "<name>", topic = "<topic>" }. The source node + topic. |
to | endpoint | required | { 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:
| Variant | Cause |
|---|---|
Io | File read failed (missing / unreadable). |
Parse | TOML malformed or a required field missing. |
UnknownNode | A [[bridge]] references a node name no [[node]] declared. |
OpenSession | Executor::open_multi rejected a spec — usually a backend name no RMW_INIT_ENTRIES entry registered under. |
BuildNode | create_node_on failed (registry exhausted, name too long, …). |
BuildEntity | Creating 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 name | Cargo dep that contributes the RMW_INIT_ENTRIES entry |
|---|---|
zenoh | nros-rmw-zenoh = { ... } |
xrce | nros-rmw-xrce-cffi = { ... } |
cyclonedds | nros-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).
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. An audit of a 256 KB image found it 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.
| pool | bytes at default | formula | declared in |
|---|---|---|---|
LARGE_PAYLOADS | 131,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_SIZE | packages/rmw/zenoh/nros-rmw-zenoh/src/shim/subscriber.rs:231 |
SLOTS | 8,192 (builtin — an image that declares its entities derives its own) | NROS_RMW_SUBSCRIBER_SLOTS * NROS_RMW_SUBSCRIBER_SLOT_BYTES | packages/rmw/cffi/src/rust_adapter.rs:128 |
SMALL_PAYLOADS | — (knob NROS_ZENOH_MAX_SUBSCRIBERS has a computed default) | NROS_ZENOH_MAX_SUBSCRIBERS * NROS_ZENOH_SUBSCRIBER_RING_DEPTH * NROS_SUBSCRIBER_BUFFER_SIZE | packages/rmw/zenoh/nros-rmw-zenoh/src/shim/subscriber.rs:230 |
SOCKET_RX_BUFFERS | — (knob NROS_SMOLTCP_MAX_SOCKETS has a computed default) | NROS_SMOLTCP_MAX_SOCKETS * NROS_SMOLTCP_BUFFER_SIZE | packages/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_SIZE | packages/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_SIZE | packages/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_SIZE | packages/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. The memory report (Static memory) gives each one’s exact bytes in the image you built, beside the knobs listed here.
| symbol | kind | sized by |
|---|---|---|
g_sessions | pool | NROS_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::SLOTS | pool | NROS_LOG_EARLY_RECORDS |
nros_log::pool::NAMES | pool | NROS_LOG_DYNAMIC_LOGGERS |
nros_log::pool::POOL | pool | NROS_LOG_DYNAMIC_LOGGERS |
nros_log::rosout::SLOTS | pool | NROS_LOG_ROSOUT_RECORDS |
nros_node::boot_report::NROS_BOOT_REPORT | pool | NROS_BOOT_REPORT |
nros_node::parameter_services::PARAM_INBOX | pool | NROS_PARAM_SERVICE_INBOX_BYTES, NROS_PARAM_SERVICE_INBOX_DEPTH |
nros_platform::zephyr_heap::HEAP | heap | NROS_ZEPHYR_HEAP_SIZE |
nros_rmw_cffi::MESSAGE_INFO_TABLE | pool | NROS_RMW_MESSAGE_INFO_SLOTS |
nros_rmw_cffi::NODE_TABLE | pool | NROS_RMW_MAX_NODES |
nros_rmw_cffi::REGISTRY | pool | NROS_RMW_MAX_BACKENDS |
nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS | pool | NROS_ZENOH_MAX_TL_PUBLISHERS, NROS_ZENOH_TL_RETAIN_BYTES, NROS_ZENOH_TL_RETAIN_DEPTH |
nros_rmw_zenoh::shim::service::ACTION_INBOX | pool | NROS_ACTION_INBOX_BYTES, NROS_ACTION_INBOX_DEPTH |
nros_rmw_zenoh::shim::service::SERVICE_BUFFERS | pool | NROS_ZENOH_MAX_QUERYABLES, NROS_ZENOH_MAX_SESSIONS, NROS_ZENOH_SERVICE_BUFFER_SIZE |
nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX | pool | NROS_SERVICE_INBOX_BYTES, NROS_SERVICE_INBOX_DEPTH, NROS_ZENOH_MAX_QUERYABLES, NROS_ZENOH_MAX_SESSIONS |
nros_rmw_zenoh::shim::subscriber::SMALL_PAYLOADS | pool | NROS_SUBSCRIBER_BUFFER_SIZE |
nros_rmw_zenoh::shim::subscriber::SUBSCRIBER_BUFFERS | pool | NROS_ZENOH_MAX_SUBSCRIBERS |
nros_smoltcp::bridge::SOCKET_RX_BUFFERS | pool | NROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_SOCKETS |
nros_smoltcp::bridge::SOCKET_TABLE | pool | NROS_SMOLTCP_MAX_SOCKETS |
nros_smoltcp::bridge::SOCKET_TX_BUFFERS | pool | NROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_SOCKETS |
nros_smoltcp::bridge::UDP_SOCKET_RX_BUFFERS | pool | NROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_UDP_SOCKETS |
nros_smoltcp::bridge::UDP_SOCKET_TABLE | pool | NROS_SMOLTCP_MAX_UDP_SOCKETS |
nros_smoltcp::bridge::UDP_SOCKET_TX_BUFFERS | pool | NROS_SMOLTCP_BUFFER_SIZE, NROS_SMOLTCP_MAX_UDP_SOCKETS |
ucHeap | heap | NROS_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.
| knob | default | read by | cost |
|---|---|---|---|
NROS_ACTION_INBOX_BYTES | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:165 | packages/rmw/zenoh/nros-rmw-zenoh | pool 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_DEPTH | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:171 | packages/rmw/zenoh/nros-rmw-zenoh | pool nros_rmw_zenoh::shim::service::ACTION_INBOX: ring depth of each action-family inbox; same InboxStorage element |
NROS_BOOT_REPORT | computed — see packages/core/nros-node/build.rs:397 | packages/core/nros-node | pool nros_node::boot_report::NROS_BOOT_REPORT: a switch, not a count: on (the native_sim default, 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_SERVICES | computed — see packages/core/nros-params/build.rs:276 | packages/core/nros-params | executor: a switch: the bringup declares param_services (Zephyr Kconfig); like NROS_PARAM_STORE it makes the executor’s storage carve the parameter store; whether the six services’ sets and request/reply pair are carved too is the param-services cargo feature |
NROS_EXECUTOR_ACTION_CLIENTS | computed — see packages/tooling/nros-zephyr-build/knobs.toml:77 | packages/tooling/nros-zephyr-build | executor: action-client entries the executor arena is sized for |
NROS_EXECUTOR_ARENA_SIZE | computed — see packages/core/nros-node/build.rs:1207 | packages/core/nros-node | executor: the executor arena itself; the backing is EXECUTOR_BACKING (Rust) or the entry’s __nros_executor_storage / tier table (C/C++) |
NROS_EXECUTOR_BACKING_U64S | computed — see packages/tooling/nros-zephyr-build/knobs.toml:143 | packages/tooling/nros-zephyr-build | executor: STATES the boot executor’s backing size in words; EXECUTOR_BACKING is exactly 8x this |
NROS_EXECUTOR_EXTRA_SESSIONS | computed — see packages/core/nros-node/build.rs:677 | packages/core/nros-node | executor: extra-session slots (a session plus its id, 696 B each on thumbv7em with zenoh) carved from the executor backing, at most one per node: backend_count - 1 from the sizing descriptor, else one per node |
NROS_EXECUTOR_MAX_CBS | computed — see packages/tooling/nros-zephyr-build/src/lib.rs:745 | packages/tooling/nros-zephyr-build | executor: callback slots inside the executor |
NROS_EXECUTOR_MAX_NODES | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:379 | packages/rmw/zenoh/nros-rmw-zenoh | executor: node slots inside the executor; the zenoh backend also sizes its per-node liveliness cap from it |
NROS_EXECUTOR_MAX_SC | computed — see packages/tooling/nros-zephyr-build/src/lib.rs:756 | packages/tooling/nros-zephyr-build | executor: sched-context slots inside the executor |
NROS_EXECUTOR_MAX_SHUTDOWN_CBS | 2 | packages/core/nros-node | executor: shutdown-callback slots inside the executor |
NROS_EXECUTOR_MAX_VIOLATIONS | 8 | packages/core/nros-node | executor: 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 |
NROS_EXECUTOR_RUNTIME_REMAPS | computed — see packages/core/nros-node/build.rs:692 | packages/core/nros-node | executor: runtime remap-table slots carved from the executor backing (404 B each on a 32-bit target): --ros-args -r and hand-declared rules; 16 with process arguments, 0 on bare metal; the launch’s rules are a flash table |
NROS_FREERTOS_APP_STACK_KB | computed — see packages/tooling/nros-platform-config/src/platform_config.rs:1590 | packages/tooling/nros-platform-config | stack: 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_KB | computed — see packages/tooling/nros-platform-config/src/platform_config.rs:1589 | packages/tooling/nros-platform-config | heap ucHeap: FreeRTOS heap_4’s ucHeap[configTOTAL_HEAP_SIZE]; the symbol IS the region, measured per image |
NROS_GRAPH_MAX_ENTITIES | 0 | packages/rmw/zenoh/nros-zpico-build | input: the entity count ZPICO_GRAPH_CACHE_SIZE is derived from; storage is that knob’s |
NROS_KEYEXPR_STRING_SIZE | 256 | packages/rmw/zenoh/nros-rmw-zenoh | element: the key-expression buffer inside each subscriber/service shim entry; lands in SUBSCRIBER_BUFFERS/SERVICE_BUFFERS |
NROS_LET_BUFFER_SIZE | computed — see packages/tooling/nros-build-helpers/src/c.rs:119 | packages/tooling/nros-build-helpers | element: the per-handle LET buffer inside each C/C++ API handle, which lives in the CALLER’s storage |
NROS_LOG_BUFFER_SIZE | computed — see packages/core/nros-log/build.rs:106 | packages/core/nros-log | stack: the per-call Rust FormatBuffer is a stack frame in the logging caller; it is also the message element of nros_log::early::SLOTS and nros_log::rosout::SLOTS |
NROS_LOG_DYNAMIC_LOGGERS | computed — see packages/core/nros-log/build.rs:100 | packages/core/nros-log | pool nros_log::pool::POOL, nros_log::pool::NAMES: runtime-logger slots: 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_RECORDS | computed — see packages/core/nros-log/build.rs:113 | packages/core/nros-log | pool nros_log::early::SLOTS: records held before init: 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_LEVEL | computed — see packages/core/nros-log/build.rs:129 | packages/core/nros-log | not-a-size: the compile-time severity CEILING: 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_RECORDS | computed — see packages/core/nros-log/build.rs:120 | packages/core/nros-log | pool nros_log::rosout::SLOTS: the /rosout queue, linked only with nros-node/rosout: each slot holds a message of NROS_LOG_BUFFER_SIZE bytes plus a logger name |
NROS_MAX_ARRAY_LEN | computed — see packages/tooling/nros-zephyr-build/knobs.toml:557 | packages/tooling/nros-zephyr-build | element: bounds an array parameter value; paid by every ParameterValue, i.e. every slot |
NROS_MAX_BYTE_ARRAY_LEN | computed — see packages/tooling/nros-zephyr-build/knobs.toml:575 | packages/tooling/nros-zephyr-build | element: bounds a byte-array parameter value; paid by every ParameterValue, i.e. every slot |
NROS_MAX_PARAMETERS | computed — see packages/core/nros-params/build.rs:51 | packages/core/nros-params | executor: 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:), so mem-report’s [executor storage] prices it; otherwise boxed on the heap once per executor that declares |
NROS_MAX_PARAM_CONSTRAINTS_LEN | computed — see packages/core/nros-params/build.rs:131 | packages/core/nros-params | element: bounds a parameter descriptor’s constraints text inside every ParameterStorage slot |
NROS_MAX_PARAM_DESCRIPTION_LEN | computed — see packages/core/nros-params/build.rs:105 | packages/core/nros-params | element: bounds a parameter descriptor’s description inside every ParameterStorage slot |
NROS_MAX_PARAM_NAME_LEN | computed — see packages/core/nros-params/build.rs:59 | packages/core/nros-params | element: bounds a parameter NAME inside every parameter-store slot (executor storage or heap, see NROS_MAX_PARAMETERS) |
NROS_MAX_STRING_VALUE_LEN | computed — see packages/tooling/nros-zephyr-build/knobs.toml:676 | packages/tooling/nros-zephyr-build | element: bounds a string parameter VALUE — ParameterValue is sized by its largest variant, so this is paid by every slot |
NROS_MONITOR_ARM_GRACE_MS | 0 | packages/core/nros-node | not-a-size: a time: arm the contract monitors this long after the first spin |
NROS_MONITOR_ARM_ON_CALL | computed — see packages/core/nros-node/build.rs:719 | packages/core/nros-node | not-a-size: a switch: whether contract monitors wait for the application’s arm call |
NROS_MULTIGOAL_GOALS | computed — see packages/testing/nros-tests/bins/action-client-multigoal/src/main.rs:132 | packages/testing/nros-tests/bins/action-client-multigoal | test-only: action-client-multigoal test binary |
NROS_MULTIGOAL_SOAK_MS | 0 | packages/testing/nros-tests/bins/action-client-multigoal | test-only: action-client-multigoal test binary |
NROS_PARAM_EVENTS_DEPTH | computed — see packages/core/nros-params/build.rs:147 | packages/core/nros-params | not-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_SIZE | computed — see packages/core/nros-node/build.rs:543 | packages/core/nros-node | executor: request/reply buffers of the builtin parameter and lifecycle services, held in the executor’s value |
NROS_PARAM_SERVICE_INBOX_BYTES | 0 | packages/rmw/xrce | pool nros_node::parameter_services::PARAM_INBOX: slot bytes of the parameter-service inbox ring (CallerInboxStorage element) |
NROS_PARAM_SERVICE_INBOX_DEPTH | 1 | packages/core/nros-node | pool nros_node::parameter_services::PARAM_INBOX: ring depth of the parameter-service inbox (CallerInboxStorage element) |
NROS_PARAM_STORE | computed — see packages/core/nros-params/build.rs:277 | packages/core/nros-params | executor: a switch: the image builds a parameter store, so the executor’s storage carves NROS_MAX_PARAMETERS slots of it; off, the store costs the backing nothing |
NROS_PUBSUB_QOS_DEPTH | computed — see packages/core/nros-node/build.rs:985 | packages/core/nros-node | executor: the per-subscription receive queue depth the arena budgets for; derived from the declared QoS where an image states one |
NROS_RMW_MAX_BACKENDS | 8 | packages/rmw/cffi | pool nros_rmw_cffi::REGISTRY: backend slots in the RMW registry; the element is a BackendSlot struct |
NROS_RMW_MAX_NODES | 4 | packages/rmw/cffi | pool nros_rmw_cffi::NODE_TABLE: node slots in the RMW node table; the element is a NodeSlot struct |
NROS_RMW_MESSAGE_INFO_SLOTS | 64 | packages/rmw/cffi | pool 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 |
NROS_RMW_SUBSCRIBER_SLOTS | 8 | packages/rmw/cffi | prices SLOTS (8,192 B at defaults); default DERIVED per image when it declares its entities |
NROS_RMW_SUBSCRIBER_SLOT_BYTES | 1024 | packages/rmw/cffi | prices SLOTS (8,192 B at defaults) |
NROS_RT_SCHED_CPU | 0 | packages/testing/nros-tests | test-only: rt_sched_levels_e2e: the CPU its pinned runs use (default 0) |
NROS_RUNTIME_COMPONENT_SLOT_BYTES | 512 | packages/api/nros | component: byte budget of each component slot (TypedSlot<C>) |
NROS_RUNTIME_MAX_CELL_ENTITIES | 8 | packages/api/nros | component: entity-registry slots per component cell |
NROS_RUNTIME_MAX_CLASS_INSTANCES | 2 | packages/api/nros | component: instances one component class’s macro-emitted store (__NROS_COMPONENT_<pkg>_SLOT_STORE) holds |
NROS_RUNTIME_MAX_COMPONENTS | 4 | packages/api/nros | component: component slots of the runtime’s type-erased pool |
NROS_SERVICE_INBOX_BYTES | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:158 | packages/rmw/zenoh/nros-rmw-zenoh | pool 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_DEPTH | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:164 | packages/rmw/zenoh/nros-rmw-zenoh | pool nros_rmw_zenoh::shim::service::USER_SERVICE_INBOX: ring depth of each user-service inbox (InboxStorage element) |
NROS_SERVICE_TIMEOUT_MS | computed — see packages/tooling/nros-platform-config/src/platform_config.rs:1805 | packages/tooling/nros-platform-config | not-a-size: milliseconds |
NROS_SMOLTCP_BUFFER_SIZE | computed — see packages/drivers/net/nros-smoltcp/build.rs:54 | packages/drivers/net/nros-smoltcp | sizes 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_MS | computed — see packages/drivers/net/nros-smoltcp/build.rs:57 | packages/drivers/net/nros-smoltcp | not-a-size: milliseconds |
NROS_SMOLTCP_MAX_SOCKETS | computed — see packages/drivers/net/nros-smoltcp/build.rs:45 | packages/drivers/net/nros-smoltcp | sizes 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_SOCKETS | computed — see packages/drivers/net/nros-smoltcp/build.rs:51 | packages/drivers/net/nros-smoltcp | 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::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_MS | computed — see packages/drivers/net/nros-smoltcp/build.rs:60 | packages/drivers/net/nros-smoltcp | not-a-size: milliseconds |
NROS_SUBSCRIBER_BUFFER_SIZE | computed — see packages/core/nros-node/build.rs:915 | packages/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_SIZE | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1033 | packages/tooling/nros-zephyr-build | executor: each subscription’s receive buffer inside the executor arena (DEFAULT_RX_BUF_SIZE) |
NROS_TRACE_TAKES | computed — see packages/core/nros-node/build.rs:728 | packages/core/nros-node | not-a-size: a switch: whether the callback trace emits a take event per subscription sample |
NROS_TRACE_TIMER_EVERY | 1 | packages/core/nros-node | not-a-size: a ratio: one timer tick in N keeps its callback-trace start/end pair |
NROS_VIOLATION_DRAIN_REPORT | computed — see packages/core/nros-node/build.rs:710 | packages/core/nros-node | not-a-size: a switch: whether a new executor drains its violation ring every spin and logs each entry |
NROS_XRCE_BUFFER_SIZE | 1024 | packages/rmw/xrce | heap: the default the three XRCE buffer sizes above alias to |
NROS_XRCE_BUILTIN_SERVICE_REQUEST_BUFFER_SIZE | 1024 | packages/rmw/xrce | heap: request buffer of each builtin-family server slot inside xrce_session_state_t |
NROS_XRCE_BUILTIN_SERVICE_SERVERS | 0 | packages/rmw/xrce | heap: how many service-server slots inside xrce_session_state_t are the parameter / lifecycle family’s, each with its own request ring |
NROS_XRCE_CUSTOM_TRANSPORT_MTU | 4096 | packages/rmw/xrce | heap: the custom-transport twin of NROS_XRCE_TRANSPORT_MTU |
NROS_XRCE_MAX_SERVICE_CLIENTS | 4 | packages/rmw/xrce | heap: service-client slots inside xrce_session_state_t |
NROS_XRCE_MAX_SERVICE_SERVERS | 4 | packages/rmw/xrce | heap: service-server slots inside xrce_session_state_t |
NROS_XRCE_MAX_SUBSCRIBERS | 8 | packages/rmw/xrce | heap: 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_SIZE | 1024 | packages/rmw/xrce | heap: per-client reply buffer inside xrce_session_state_t |
NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE | 1024 | packages/rmw/xrce | heap: per-APPLICATION-server request buffer inside xrce_session_state_t |
NROS_XRCE_STREAM_HISTORY | 16 | packages/rmw/xrce | heap: reliable-stream history depth; multiplies the stream buffers inside the session state |
NROS_XRCE_SUBSCRIBER_BUFFER_SIZE | 1024 | packages/rmw/xrce | heap: bytes per XRCE subscriber ring entry inside xrce_session_state_t |
NROS_XRCE_SUBSCRIBER_RING_DEPTH | 32 | packages/rmw/xrce | heap: ring depth of each XRCE subscriber slot inside xrce_session_state_t |
NROS_XRCE_TRANSPORT_MTU | 4096 | packages/rmw/xrce | heap: Micro-XRCE-DDS-Client transport MTU (UDP/TCP): sizes the transport’s buffer inside its transport struct |
NROS_ZENOH_BATCH_MULTICAST_SIZE | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1365 | packages/tooling/nros-zephyr-build | heap: the multicast twin of the unicast batch buffers — heap at session open, no symbol |
NROS_ZENOH_BATCH_UNICAST_SIZE | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1370 | packages/tooling/nros-zephyr-build | heap: 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_CONNECT_FOREVER | computed — see packages/tooling/nros-platform-config/src/platform_config.rs:1774 | packages/tooling/nros-platform-config | not-a-size: a switch |
NROS_ZENOH_CONNECT_TIMEOUT_MS | computed — see packages/tooling/nros-platform-config/src/platform_config.rs:1780 | packages/tooling/nros-platform-config | not-a-size: milliseconds |
NROS_ZENOH_FRAG_MAX_SIZE | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1394 | packages/tooling/nros-zephyr-build | heap: zenoh-pico’s defragmentation buffer, _z_wbuf_make(Z_FRAG_MAX_SIZE) on first fragment — heap, no symbol |
NROS_ZENOH_GET_POLL_INTERVAL_MS | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1410 | packages/tooling/nros-zephyr-build | not-a-size: milliseconds |
NROS_ZENOH_GET_REPLY_BUF_SIZE | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1426 | packages/tooling/nros-zephyr-build | stack: 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_SIZE | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1339 | packages/rmw/zenoh/nros-zpico-build | pool g_sessions: the graph cache’s byte buffer, embedded in each struct zpico_session beside its mutex and subscriber handle |
NROS_ZENOH_GRAPH_DISCOVERY | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1354 | packages/rmw/zenoh/nros-zpico-build | not-a-size: switches the graph cache on; its bytes are ZPICO_GRAPH_CACHE_SIZE |
NROS_ZENOH_MAX_LARGE_SUBSCRIBERS | 2 | packages/rmw/zenoh/nros-rmw-zenoh | prices LARGE_PAYLOADS (131,072 B at defaults) |
NROS_ZENOH_MAX_LIVELINESS | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1155 | packages/rmw/zenoh/nros-zpico-build | pool g_sessions: liveliness-token entries inside each struct zpico_session |
NROS_ZENOH_MAX_PENDING_GETS | 4 | packages/rmw/zenoh/nros-zpico-build | pool g_sessions: pending-get slots inside each struct zpico_session |
NROS_ZENOH_MAX_PUBLISHERS | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1142 | packages/rmw/zenoh/nros-zpico-build | pool g_sessions: publisher entries inside each struct zpico_session; default derived per image from the entity inventory |
NROS_ZENOH_MAX_QUERYABLES | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1126 | packages/rmw/zenoh/nros-zpico-build | pool 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_SESSIONS | 1 | packages/rmw/zenoh/nros-zpico-build | pool 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_SUBSCRIBERS | computed — see packages/rmw/zenoh/nros-zpico-build/src/runner.rs:1146 | packages/rmw/zenoh/nros-zpico-build | sizes 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_PUBLISHERS | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:981 | packages/rmw/zenoh/nros-rmw-zenoh | pool 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_SIZE | 1024 | packages/rmw/zenoh/nros-rmw-zenoh | element: each publisher’s zero-copy lend arena (ZENOH_TX_BUF), inside the publisher entry wherever that is stored |
NROS_ZENOH_READ_PRIORITY | 16 | packages/rmw/zenoh/nros-zpico-build | not-a-size: a task priority |
NROS_ZENOH_RX_TASK_BUDGET_FRAMES | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1759 | packages/tooling/nros-zephyr-build | not-a-size: a frame COUNT the read task processes before it rests; sizes no storage |
NROS_ZENOH_RX_TASK_BUDGET_REST_US | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1787 | packages/tooling/nros-zephyr-build | not-a-size: microseconds |
NROS_ZENOH_SERVICE_BUFFER_SIZE | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:133 | packages/rmw/zenoh/nros-rmw-zenoh | pool 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_SIZE | 16384 | packages/rmw/zenoh/nros-rmw-zenoh | prices LARGE_PAYLOADS (131,072 B at defaults) |
NROS_ZENOH_SUBSCRIBER_RING_DEPTH | 4 | packages/rmw/zenoh/nros-rmw-zenoh | prices 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_THRESHOLD | 2048 | packages/rmw/zenoh/nros-rmw-zenoh | not-a-size: the hint above which a subscription routes to the LARGE payload class — a routing threshold, not storage |
NROS_ZENOH_TL_RETAIN_BYTES | computed — see packages/rmw/zenoh/nros-rmw-zenoh/build.rs:401 | packages/rmw/zenoh/nros-rmw-zenoh | pool nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS: bytes retained inside each RetainSlot |
NROS_ZENOH_TL_RETAIN_DEPTH | 1 | packages/rmw/zenoh/nros-rmw-zenoh | pool nros_rmw_zenoh::shim::publisher::transient_local::TL_SLOTS: samples each RetainSlot retains: 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_BATCH | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1905 | packages/tooling/nros-zephyr-build | not-a-size: a switch: whether zenoh-pico batches outgoing messages |
NROS_ZENOH_TX_BATCH_FLUSH_MS | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1935 | packages/tooling/nros-zephyr-build | not-a-size: a time: the longest a batched message waits before it is flushed |
NROS_ZENOH_TX_SPLIT_LOCK | computed — see packages/tooling/nros-zephyr-build/knobs.toml:1957 | packages/tooling/nros-zephyr-build | not-a-size: a switch: whether the TX path takes its own lock instead of the session’s |
NROS_ZEPHYR_HEAP_SIZE | computed — see packages/platform/nros-platform/build.rs:66 | packages/platform/nros-platform | heap 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_MS | 250 | packages/testing/nros-tests/bins/rosout-talker | test-only: rosout-talker test binary |
ROSOUT_PROBE_RECORDS | 60 | packages/testing/nros-tests/bins/rosout-talker | test-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: one setting, two values in the same image.
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.
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.
139 Kconfig symbols — 66 settable, 1 provided for application code, 72 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
| symbol | default | class | evidence |
|---|---|---|---|
CONFIG_NROS_RMW_ZENOH | — | public | cmake, rust-source, kconfig select/depends |
CONFIG_NROS_RMW_XRCE | — | public | cmake, rust-source, kconfig select/depends |
CONFIG_NROS_RMW_CYCLONEDDS | — | public | cmake, 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
| symbol | default | class | evidence |
|---|---|---|---|
CONFIG_NROS_DOMAIN_ID | 0 | public | forwarded-to-cargo, c-source, cmake, rust-source, kconfig select/depends |
CONFIG_NROS_CYCLONE_DOMAIN_ID | NROS_DOMAIN_ID | public | c-define, c-source, cmake, rust-source |
CONFIG_NROS_CYCLONE_DOMAIN_ID defaults FROM NROS_DOMAIN_ID — the one derivation edge in this cluster. Do not pin it to a literal: a Cyclone DDS image would then run on that domain whatever domain_id says.
Router / agent endpoint
Set it here: [system].locator in system.toml
| symbol | default | class | evidence |
|---|---|---|---|
CONFIG_NROS_ZENOH_LOCATOR | tcp/127.0.0.1:7447 | public | c-source, cmake, rust-source |
CONFIG_NROS_XRCE_AGENT_ADDR | 127.0.0.1 | public | cmake, rust-source |
CONFIG_NROS_XRCE_AGENT_PORT | 2018 | public | cmake, 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)
| symbol | default | class | evidence |
|---|---|---|---|
CONFIG_NROS_EXECUTOR_MAX_CBS | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_SC | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_NODES | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_ARENA_SIZE | 0 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_MONITORS | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_AGE_MONITORS | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_SHUTDOWN_CBS | 2 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_ACTION_CLIENTS | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SUBSCRIPTION_BUFFER_SIZE | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_PARAM_SERVICE_BUFFER_SIZE | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PARAMETERS | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PARAM_NAME_LEN | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_STRING_VALUE_LEN | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_ARRAY_LEN | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_BYTE_ARRAY_LEN | -1 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PARAM_DESCRIPTION_LEN | 256 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PARAM_CONSTRAINTS_LEN | 0 | derived | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_PARAM_EVENTS_DEPTH | 1 | derived | forwarded-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, so the Kconfig value must reach the build environment.
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.
| symbol | default | evidence |
|---|---|---|
CONFIG_NROS_ARENA_EXHAUSTION_IS_FATAL | y if NROS_BOOT_REPORT | cmake, rust-source |
CONFIG_NROS_BOOT_REPORT | y if ARCH_POSIX | c-source, cmake, rust-source, kconfig select/depends |
CONFIG_NROS_CAPABILITY_LIFECYCLE | — | cmake, 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_EPOCH_FROM_ZENOH | — | c-source, kconfig select/depends |
CONFIG_NROS_EPOCH_FROM_ZENOH_INTERVAL_MS | 1000 | c-source |
CONFIG_NROS_EXECUTOR_MAX_LEVELS | 8 | rust-source |
CONFIG_NROS_FAULT_LOG | n | cmake, kconfig select/depends |
CONFIG_NROS_FAULT_LOG_FLASH | n | c-source |
CONFIG_NROS_HEAP_EXHAUSTION_IS_FATAL | y if NROS_BOOT_REPORT | c-source, rust-source |
CONFIG_NROS_MONITOR_ARM_GRACE_MS | 0 | forwarded-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_API | NROS_RMW_ZENOH | cmake, rust-source, kconfig select/depends |
CONFIG_NROS_SNTP_EPOCH | — | c-source, kconfig select/depends |
CONFIG_NROS_SNTP_SERVER | 10.0.2.2 | c-source |
CONFIG_NROS_SNTP_TIMEOUT_MS | 3000 | c-source |
CONFIG_NROS_TRACE_CALLBACKS | — | cmake, kconfig select/depends |
CONFIG_NROS_TRACE_TAKES | — | c-source, cmake, rust-source |
CONFIG_NROS_TRACE_TIMER_EVERY | 1 | forwarded-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_RECONNECT | y | cmake |
CONFIG_NROS_ZENOH_LEASE_FACTOR | 3 | c-define, cmake |
CONFIG_NROS_ZENOH_LEASE_MS | 60000 | c-define, cmake |
CONFIG_NROS_ZENOH_LINK_CAN | n | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_LINK_ISOTP | n | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_LINK_SERIAL | n | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_LINK_TCP | y | cmake |
CONFIG_NROS_ZENOH_LINK_UDP_MULTICAST | n | cmake |
CONFIG_NROS_ZENOH_LINK_UDP_UNICAST | n | cmake |
CONFIG_NROS_ZENOH_LINK_WS | n | cmake |
CONFIG_NROS_ZENOH_MULTI_THREAD | y | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_PUBLICATION | y | cmake |
CONFIG_NROS_ZENOH_QUERY | y | cmake |
CONFIG_NROS_ZENOH_QUERYABLE | y | cmake |
CONFIG_NROS_ZENOH_RAWETH_TRANSPORT | n | cmake |
CONFIG_NROS_ZENOH_SCOUTING | n | cmake |
CONFIG_NROS_ZENOH_SERIAL_FIFO_RXWATER | 1 | c-define |
CONFIG_NROS_ZENOH_SERIAL_FIFO_WATERMARK | — | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_SERIAL_ISR_STATS | — | cmake |
CONFIG_NROS_ZENOH_SERIAL_RX_RING_BYTES | 1024 | cmake |
CONFIG_NROS_ZENOH_SERIAL_TCM | — | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_SERIAL_TX_RING_BYTES | 2048 | cmake |
CONFIG_NROS_ZENOH_SERIAL_UART_ASYNC | — | cmake, kconfig select/depends |
CONFIG_NROS_ZENOH_SERIAL_UART_ASYNC_RX_TIMEOUT_US | 25 | cmake |
CONFIG_NROS_ZENOH_SUBSCRIPTION | y | cmake |
CONFIG_NROS_ZEPHYR_LEVEL_WORKER_FRAME | 13312 | c-source, rust-source |
CONFIG_NROS_ZEPHYR_MAX_TIERS | 4 | c-define, c-source, cmake, rust-source |
CONFIG_NROS_ZEPHYR_STACKS_IN_DTCM | n | c-source, cmake, rust-source, kconfig select/depends |
CONFIG_NROS_ZEPHYR_TASK_SLOTS | 2 if NROS_RMW_ZENOH && NROS_ZENOH_TX_BATCH | c-define, c-source, cmake |
CONFIG_NROS_ZEPHYR_TASK_STACK_SIZE | 0 | c-define, c-source, cmake |
CONFIG_NROS_ZEPHYR_TIER_STACKS_IN_DTCM | NROS_ZEPHYR_STACKS_IN_DTCM | c-source, cmake |
CONFIG_NROS_ZEPHYR_TIER_STACK_SIZE | 16384 | c-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.
| symbol | default | evidence |
|---|---|---|
CONFIG_NROS_INIT_DELAY_MS | 2000 | book/src/reference/cpp-api.md (its Zephyr section) shows an application calling nros_platform_zephyr_wait_network(CONFIG_NROS_INIT_DELAY_MS) from its own main(); book-internals/src/testing/zephyr-dev-setup.md:220 documents it as a knob. |
Derived — selected by another symbol
No Kconfig prompt, so not asked about directly.
| symbol | default | evidence |
|---|---|---|
CONFIG_NROS_ACTION_INBOX_BYTES | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_ACTION_INBOX_DEPTH | 4 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_BATCH_UNICAST_SIZE | 1024 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_BACKING_U64S | -1 | rust-source |
CONFIG_NROS_EXECUTOR_EXTRA_SESSIONS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_MAX_VIOLATIONS | 8 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_EXECUTOR_RUNTIME_REMAPS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_FRAG_MAX_SIZE | 2048 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_GET_POLL_INTERVAL_MS | 10 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_GET_REPLY_BUF_SIZE | 4096 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_GRAPH_CACHE_SIZE | 65536 | cmake, rust-source |
CONFIG_NROS_GRAPH_MAX_ENTITIES | 0 | rust-source |
CONFIG_NROS_LOG_BUFFER_SIZE | 256 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_LOG_DYNAMIC_LOGGERS | 16 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_LOG_EARLY_RECORDS | 4 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_LOG_MAX_LEVEL | 0 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_LOG_ROSOUT_RECORDS | 16 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_LARGE_SUBSCRIBERS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_LIVELINESS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PENDING_GETS | 4 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_PUBLISHERS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_QUERYABLES | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_MAX_SUBSCRIBERS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_PARAM_SERVICE_INBOX_BYTES | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_PARAM_SERVICE_INBOX_DEPTH | 1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_PUBSUB_QOS_DEPTH | 10 | rust-source |
CONFIG_NROS_RMW_LOCAL_QUERYABLE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_RMW_SUBSCRIBER_SLOTS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SERVICE_BUFFER_SIZE | 1024 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SERVICE_INBOX_BYTES | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SERVICE_INBOX_DEPTH | 4 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SUBSCRIBER_LARGE_SIZE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_SUBSCRIBER_RING_DEPTH | 4 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_BUFFER_SIZE | 1024 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_CUSTOM_TRANSPORT_MTU | NROS_XRCE_TRANSPORT_MTU | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_MAX_SUBSCRIBERS | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_SERVICE_REPLY_BUFFER_SIZE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_STREAM_HISTORY | 16 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_SUBSCRIBER_BUFFER_SIZE | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTH | 32 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_XRCE_TRANSPORT_MTU | 512 | forwarded-to-cargo, cmake, rust-source, kconfig select/depends |
CONFIG_NROS_ZENOH_GRAPH_DISCOVERY | `n if (NROS_ZENOH_LINK_SERIAL | |
CONFIG_NROS_ZENOH_READ_PRIORITY | 200 | c-define, cmake, rust-source |
CONFIG_NROS_ZENOH_RX_TASK_BUDGET_FRAMES | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_ZENOH_RX_TASK_BUDGET_REST_US | -1 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS | 100 | c-define, cmake, rust-source |
CONFIG_NROS_ZENOH_TX_BATCH | y | cmake, rust-source, kconfig select/depends |
CONFIG_NROS_ZENOH_TX_BATCH_FLUSH_MS | 50 | forwarded-to-cargo, cmake, rust-source |
CONFIG_NROS_ZENOH_TX_SPLIT_LOCK | y | cmake, rust-source |
CONFIG_NROS_ZEPHYR_HEAP_SIZE | 1048576 if NROS_RMW_CYCLONEDDS | forwarded-to-cargo, c-source, cmake, rust-source |
Board Support Levels
What nano-ros can show for each board it knows about. Each level is derived from evidence, never typed: where the board’s image runs (on a host, in an emulator, on hardware, or nowhere yet), how often that run happens (every merge, daily, weekly, or by hand on a date) and which board’s test matrix stands for its breadth.
| Status | What it means |
|---|---|
| Verified on host / in emulator / on hardware (cadence) | an automated lane boots it and asserts a message, at that cadence |
| Boots, manual (date) | a maintainer booted it by hand on that date; after 120 days it reads Builds until booted again |
| Builds | a lane builds it; nothing boots it |
| Not supported | nothing builds or boots it |
A witness board runs its platform family’s test matrix; a board that inherits shares the witness’s code path and adds its own smoke evidence. Each board links to its evidence page.
| Board | Status | Runs on | Breadth |
|---|---|---|---|
nros-board-linux (Linux) | Verified on host (merge) | host executable | witness — Runtime: Pubsub, ExecutorBoundNode, Service, Action, Port, PortSmoke, PortMonitor, PortWorkspace, Params, Qos, CustomMsg, Safety, Logging, EntryPubsub, Lifecycle, Remap, BeforeBoot, RealtimeTiers, Multihost in Rust, C, C++, Mixed over zenoh, cyclonedds, XRCE-DDS |
nros-board-zephyr (ZephyrNativeSim) | Verified on host (merge) | host executable | witness — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, Params, Lifecycle, Qos, Safety, RealtimeTiers, Multihost in Rust, C, C++, Mixed over zenoh, cyclonedds, XRCE-DDS |
nros-board-mps3-an536-freertos | Builds | qemu:mps3-an536 (twin) | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
nros-board-mps2-an385-freertos (FreertosMps2) | Verified in emulator (daily) | qemu:mps2-an385 | witness — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
nros-board-s32z270-freertos | Builds | s32z270 (target) | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
nros-board-nuttx-qemu (NuttxArm) | Verified in emulator (daily) | qemu:virt-armv7a | witness — Runtime: Pubsub, TypedCarrier, Service, Action, Params, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++ over zenoh |
nros-board-threadx-linux (ThreadxLinux) | Verified on host (merge) | host executable | witness — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
nros-board-freertos-posix (FreertosPosix) | Verified on host (daily) | host executable | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds. Board-specific: Runtime: EntryPubsub in C, C++ over cyclonedds |
nros-board-threadx-qemu-riscv64 (ThreadxRiscv64) | Verified in emulator (daily) | qemu:rv-virt | inherited from ThreadxLinux — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds. Board-specific: Runtime: Pubsub, Service, Errno, TypedCarrier, Params, Logging, EntryPubsub in Rust, C, C++ over zenoh, cyclonedds; build-only: Action |
nros-board-mps2-an385 (QemuBaremetal) | Verified in emulator (daily) | qemu:mps2-an385 | witness — Runtime: Pubsub, Service, Action, Logging, BoardEntryBoot in Rust, C, C++ over zenoh |
nros-board-mps2-an385-zephyr (ZephyrQemuCortexM) | Verified in emulator (daily) | qemu:mps2-an385 | witness — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
nros-board-nuttx-qemu (NuttxRiscv) | Verified in emulator (daily) | qemu:rv-virt | inherited from NuttxArm — Runtime: Pubsub, TypedCarrier, Service, Action, Params, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++ over zenoh. Board-specific: Runtime: Pubsub, RealtimeTiers in C, C++, Rust over zenoh; build-only: EntryPubsub |
nros-board-zephyr (Fvp) | Boots, manual (2026-09-06); reads Builds from 2027-01-05 | fvp:FVP_BaseR_AEMv8R | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action. Board-specific: build-only: Pubsub |
s32k344-safety-island | Boots on hardware, manual (2026-10-26); reads Builds from 2027-02-24 | mr-canhubk344 | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
nros-board-nucleo-f767zi-zephyr (ZephyrNucleoF767zi) | Boots, manual (2026-11-01); reads Builds from 2027-03-02 | renode:nucleo_f767zi, nucleo-f767zi (target) | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action. Board-specific: Runtime: Pubsub in C over zenoh |
nros-board-rpi-pico-w-zephyr | Boots, manual (2026-11-01); reads Builds from 2027-03-02 | wokwi:pi-pico-w, rpi-pico-w (target) | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
Infrastructure
Packages under packages/boards/ that are not boards: shared traits, family drivers, ABI mirrors, PACs and descriptor directories.
nros-board-common, nros-board-cffi, nros-board-freertos, nros-board-nuttx, nros-board-threadx, nros-board-threadx-port-riscv64, mps2-an385-pac, linux, zephyr, nros-board-qemu-cortex-a53
nros-board-linux (Linux)
Status: Verified on host (merge)
| Package | nros-board-linux |
| Test-matrix platform | Linux |
| Runs on | host executable |
| Cadence | merge |
| Breadth | witness — Runtime: Pubsub, ExecutorBoundNode, Service, Action, Port, PortSmoke, PortMonitor, PortWorkspace, Params, Qos, CustomMsg, Safety, Logging, EntryPubsub, Lifecycle, Remap, BeforeBoot, RealtimeTiers, Multihost in Rust, C, C++, Mixed over zenoh, cyclonedds, XRCE-DDS |
| Book | Run: first project |
| Book probe tracks | linux (weekly) |
Notes
The reference platform: the densest Runtime coverage of any target, all three RMWs. One board crate covers both the Linux host and the POSIX family driver.
See Board Support Levels for what each status means.
nros-board-zephyr (ZephyrNativeSim)
Status: Verified on host (merge)
| Package | nros-board-zephyr |
| Test-matrix platform | ZephyrNativeSim |
| Runs on | host executable |
| Cadence | merge |
| Breadth | witness — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, Params, Lifecycle, Qos, Safety, RealtimeTiers, Multihost in Rust, C, C++, Mixed over zenoh, cyclonedds, XRCE-DDS |
| Book | Run: zephyr |
| Book probe tracks | zephyr (weekly), zephyr-rust (weekly), zephyr-west-app (weekly), zephyr-west-app-cpp (weekly), zephyr-west-app-rust (weekly) |
Notes
CAVEAT: only ever built for native_sim/native/64. No real Zephyr hardware board is built by anything.
See Board Support Levels for what each status means.
nros-board-mps3-an536-freertos
Status: Builds
| Package | nros-board-mps3-an536-freertos |
| Test-matrix platform | — |
| Runs on | qemu:mps3-an536 (twin) |
| Cadence | — |
| Breadth | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
| Book | — |
| Book probe tracks | — |
See Board Support Levels for what each status means.
nros-board-mps2-an385-freertos (FreertosMps2)
Status: Verified in emulator (daily)
| Package | nros-board-mps2-an385-freertos |
| Test-matrix platform | FreertosMps2 |
| Runs on | qemu:mps2-an385 |
| Cadence | daily |
| Breadth | witness — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
| Book | Run: freertos |
| Book probe tracks | freertos (weekly), freertos-c (weekly) |
See Board Support Levels for what each status means.
nros-board-s32z270-freertos
Status: Builds
| Package | nros-board-s32z270-freertos |
| Test-matrix platform | — |
| Runs on | s32z270 (target) |
| Cadence | — |
| Breadth | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
| Book | Flash: freertos |
| Book probe tracks | — |
Notes
S32Z270 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.
See Board Support Levels for what each status means.
nros-board-nuttx-qemu (NuttxArm)
Status: Verified in emulator (daily)
| Package | nros-board-nuttx-qemu |
| Test-matrix platform | NuttxArm |
| Runs on | qemu:virt-armv7a |
| Cadence | daily |
| Breadth | witness — Runtime: Pubsub, TypedCarrier, Service, Action, Params, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++ over zenoh |
| Book | Run: nuttx |
| Book probe tracks | nuttx-arm (weekly), nuttx-arm-rust (weekly) |
See Board Support Levels for what each status means.
nros-board-threadx-linux (ThreadxLinux)
Status: Verified on host (merge)
| Package | nros-board-threadx-linux |
| Test-matrix platform | ThreadxLinux |
| Runs on | host executable |
| Cadence | merge |
| Breadth | witness — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds |
| Book | Run: threadx |
| Book probe tracks | threadx-linux (weekly), threadx-linux-c (weekly) |
See Board Support Levels for what each status means.
nros-board-freertos-posix (FreertosPosix)
Status: Verified on host (daily)
| Package | nros-board-freertos-posix |
| Test-matrix platform | FreertosPosix |
| Runs on | host executable |
| Cadence | daily |
| Breadth | inherited from FreertosMps2 — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds. Board-specific: Runtime: EntryPubsub in C, C++ over cyclonedds |
| Book | Run: freertos |
| Book probe tracks | — |
See Board Support Levels for what each status means.
nros-board-threadx-qemu-riscv64 (ThreadxRiscv64)
Status: Verified in emulator (daily)
| Package | nros-board-threadx-qemu-riscv64 |
| Test-matrix platform | ThreadxRiscv64 |
| Runs on | qemu:rv-virt |
| Cadence | daily |
| Breadth | inherited from ThreadxLinux — Runtime: Pubsub, Service, Action, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++, Mixed over zenoh, cyclonedds. Board-specific: Runtime: Pubsub, Service, Errno, TypedCarrier, Params, Logging, EntryPubsub in Rust, C, C++ over zenoh, cyclonedds; build-only: Action |
| Book | Run: threadx |
| Book probe tracks | threadx-riscv64 (weekly) |
Notes
All three Action rows are BuildOnly by a deliberate wall-clock choice (182.5), not by breakage.
See Board Support Levels for what each status means.
nros-board-mps2-an385 (QemuBaremetal)
Status: Verified in emulator (daily)
| Package | nros-board-mps2-an385 |
| Test-matrix platform | QemuBaremetal |
| Runs on | qemu:mps2-an385 |
| Cadence | daily |
| Breadth | witness — Runtime: Pubsub, Service, Action, Logging, BoardEntryBoot in Rust, C, C++ over zenoh |
| Book | Run: bare metal |
| Book probe tracks | first-project, baremetal (weekly), offline (weekly) |
Notes
One crate; the book’s entry shape is direct execution (the board-entry feature).
See Board Support Levels for what each status means.
nros-board-mps2-an385-zephyr (ZephyrQemuCortexM)
Status: Verified in emulator (daily)
| Package | nros-board-mps2-an385-zephyr |
| Test-matrix platform | ZephyrQemuCortexM |
| Runs on | qemu:mps2-an385 |
| Cadence | daily |
| Breadth | witness — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
| Book | Run: zephyr |
| Book probe tracks | — |
Notes
C, C++ and Rust pubsub are Runtime; service and action are BuildOnly until the runner’s peer half lands. It carries a live-peer interop cell against a host peer over zenoh. Daily rather than per merge, because the merge lane is host-only and this board needs QEMU.
See Board Support Levels for what each status means.
nros-board-nuttx-qemu (NuttxRiscv)
Status: Verified in emulator (daily)
| Package | nros-board-nuttx-qemu |
| Test-matrix platform | NuttxRiscv |
| Runs on | qemu:rv-virt |
| Cadence | daily |
| Breadth | inherited from NuttxArm — Runtime: Pubsub, TypedCarrier, Service, Action, Params, Logging, EntryPubsub, RealtimeTiers in Rust, C, C++ over zenoh. Board-specific: Runtime: Pubsub, RealtimeTiers in C, C++, Rust over zenoh; build-only: EntryPubsub |
| Book | Run: nuttx |
| Book probe tracks | nuttx-riscv (weekly), nuttx-riscv-rust (weekly) |
Notes
HONEST LABEL: C runtime-proven; Rust and C++ Pubsub are explicit CarveOuts and every EntryPubsub row is BuildOnly.
See Board Support Levels for what each status means.
nros-board-zephyr (Fvp)
Status: Boots, manual (2026-09-06); reads Builds from 2027-01-05
| Package | nros-board-zephyr |
| Test-matrix platform | Fvp |
| Runs on | fvp:FVP_BaseR_AEMv8R |
| Cadence | manual, last run 2026-09-06 |
| Breadth | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action. Board-specific: build-only: Pubsub |
| Book | Run: arm fvp |
| Book probe tracks | — |
Notes
Runtime is run by a maintainer, hence cadence = "manual". The model is not licence-gated: nros setup --tool arm-fvp fetches it from a pinned Arm download. CI does not run it because of cost and x86_64-only hosting. It is the target of the Autoware safety-island reference consumer.
See Board Support Levels for what each status means.
s32k344-safety-island
Status: Boots on hardware, manual (2026-10-26); reads Builds from 2027-02-24
| Package | s32k344-safety-island (in simple-autoware-safety-island, outside this tree) |
| Test-matrix platform | — |
| Runs on | mr-canhubk344 |
| Cadence | manual, last run 2026-10-26 |
| Breadth | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
| Book | Flash: zephyr |
| Book probe tracks | — |
Notes
NXP S32K344 (Cortex-M7) on the MR-CANHUBK344, Zephyr, behind a 921,600-baud UART zenoh link to an island gateway router. Verified on hardware on 2026-10-26. The board package belongs to the downstream island project.
See Board Support Levels for what each status means.
nros-board-nucleo-f767zi-zephyr (ZephyrNucleoF767zi)
Status: Boots, manual (2026-11-01); reads Builds from 2027-03-02
| Package | nros-board-nucleo-f767zi-zephyr |
| Test-matrix platform | ZephyrNucleoF767zi |
| Runs on | renode:nucleo_f767zi, nucleo-f767zi (target) |
| Cadence | manual, last run 2026-11-01 |
| Breadth | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action. Board-specific: Runtime: Pubsub in C over zenoh |
| Book | Run: nucleo f767zi; Flash: nucleo f767zi |
| Book probe tracks | zephyr-west-app-flash (weekly), renode-nucleo (weekly) |
Notes
ST Nucleo-F767ZI (STM32F767ZI, Cortex-M7F, 384 KiB sram0) on Zephyr 3.7. Its Renode twin boots the C talker over serial (USART3, the ST-LINK VCP) and over ethernet. Its RAM budget, max_large_subscribers = 0, is the board descriptor’s [knobs.zenoh], which every image inherits; the ethernet image adds its own knobs (see the descriptor header).
See Board Support Levels for what each status means.
nros-board-rpi-pico-w-zephyr
Status: Boots, manual (2026-11-01); reads Builds from 2027-03-02
| Package | nros-board-rpi-pico-w-zephyr |
| Test-matrix platform | — |
| Runs on | wokwi:pi-pico-w, rpi-pico-w (target) |
| Cadence | manual, last run 2026-11-01 |
| Breadth | inherited from ZephyrQemuCortexM — Runtime: Pubsub, Port, PortSmoke, PortMonitor, PortWorkspace, BootReport, IdleWake in C, C++, Rust over zenoh, XRCE-DDS; build-only: Service, Action |
| Book | Flash: zephyr |
| Book probe tracks | wokwi-pico |
Notes
Raspberry Pi Pico W (RP2040, Cortex-M0+, 264 KiB SRAM) on Zephyr 3.7, serial only. Its Wokwi twin boots the C talker over UART0 (the Wokwi serial monitor), with the console on UART1. The image needs CONFIG_TICKLESS_KERNEL=n (in the board’s prj.conf) and five RAM knobs, which are the descriptor’s [knobs] and every image inherits. Each twin run needs your own WOKWI_CLI_TOKEN and spends that account’s simulation minutes, so the twin is run by users and not in nano-ros CI.
See Board Support Levels for what each status means.
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.
| Retired | Use instead |
|---|---|
NANO_ROS_PLATFORM | NROS_PLATFORM |
NANO_ROS_FEATURES | NROS_FEATURES |
NANO_ROS_SAFETY_E2E | NROS_SAFETY_E2E |
NANO_ROS_ROS_EDITION | NROS_ROS_EDITION |
NANO_ROS_GEN_CACHE_DIR | NROS_GEN_CACHE_DIR |
NANO_ROS_SKIP_BOOTSTRAP | NROS_SKIP_BOOTSTRAP |
NANO_ROS_BUILD_CODEGEN | NROS_BUILD_CODEGEN |
NANO_ROS_CMAKE_DIR | NROS_CMAKE_DIR |
NANO_ROS_RMW | NROS_RMW |
NANO_ROS_BOARD | NROS_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 board names
The host board is linux. Two other words used to name it and are now
refused wherever a board is named — nros setup <board>, nros new --platform, nros new --image … --board, an [image.<id>] board row —
with an error that names linux. One more word named the
bare-metal MPS2-AN385 board by its emulator, and another the RISC-V ThreadX
board; both are refused the same way, naming the board’s one name:
| Retired | Use instead | Why |
|---|---|---|
native | linux | native names every general-purpose host, and the host board builds on Linux only |
posix | linux | posix is the platform (nros-platform-posix), not a board |
qemu-mps2-an385 | mps2-an385-baremetal | names the emulator, not the board: QEMU is how the AN385 image is run |
threadx-qemu-riscv64 | rv-virt-threadx | names the emulator, not the board: QEMU is how the RISC-V virt image is run |
posix stays correct as a PLATFORM name and in [tasks.*.posix]. This
page is the only place in the book that spells any of these words as a board.
Retired knob spellings
Build-time knobs have one env spelling, NROS_<ID>, 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.
| Retired | Use instead |
|---|---|
ZPICO_BATCH_MULTICAST_SIZE | NROS_ZENOH_BATCH_MULTICAST_SIZE |
ZPICO_BATCH_UNICAST_SIZE | NROS_ZENOH_BATCH_UNICAST_SIZE |
ZPICO_FRAG_MAX_SIZE | NROS_ZENOH_FRAG_MAX_SIZE |
ZPICO_GET_POLL_INTERVAL_MS | NROS_ZENOH_GET_POLL_INTERVAL_MS |
ZPICO_GET_REPLY_BUF_SIZE | NROS_ZENOH_GET_REPLY_BUF_SIZE |
ZPICO_GRAPH_CACHE_SIZE | NROS_ZENOH_GRAPH_CACHE_SIZE |
ZPICO_GRAPH_DISCOVERY | NROS_ZENOH_GRAPH_DISCOVERY |
ZPICO_MAX_LARGE_SUBSCRIBERS | NROS_ZENOH_MAX_LARGE_SUBSCRIBERS |
ZPICO_MAX_LIVELINESS | NROS_ZENOH_MAX_LIVELINESS |
ZPICO_MAX_PENDING_GETS | NROS_ZENOH_MAX_PENDING_GETS |
ZPICO_MAX_PUBLISHERS | NROS_ZENOH_MAX_PUBLISHERS |
ZPICO_MAX_QUERYABLES | NROS_ZENOH_MAX_QUERYABLES |
ZPICO_MAX_SESSIONS | NROS_ZENOH_MAX_SESSIONS |
ZPICO_MAX_SUBSCRIBERS | NROS_ZENOH_MAX_SUBSCRIBERS |
ZPICO_MAX_TL_PUBLISHERS | NROS_ZENOH_MAX_TL_PUBLISHERS |
ZPICO_PUBLISHER_TX_BUFFER_SIZE | NROS_ZENOH_PUBLISHER_TX_BUFFER_SIZE |
ZPICO_READ_TASK_PRIORITY | NROS_ZENOH_READ_PRIORITY |
ZPICO_SERVICE_BUFFER_SIZE | NROS_ZENOH_SERVICE_BUFFER_SIZE |
ZPICO_SMOLTCP_BUFFER_SIZE | NROS_SMOLTCP_BUFFER_SIZE |
ZPICO_SMOLTCP_CONNECT_TIMEOUT_MS | NROS_SMOLTCP_CONNECT_TIMEOUT_MS |
ZPICO_SMOLTCP_MAX_SOCKETS | NROS_SMOLTCP_MAX_SOCKETS |
ZPICO_SMOLTCP_MAX_UDP_SOCKETS | NROS_SMOLTCP_MAX_UDP_SOCKETS |
ZPICO_SMOLTCP_SOCKET_TIMEOUT_MS | NROS_SMOLTCP_SOCKET_TIMEOUT_MS |
ZPICO_SUBSCRIBER_LARGE_SIZE | NROS_ZENOH_SUBSCRIBER_LARGE_SIZE |
ZPICO_SUBSCRIBER_RING_DEPTH | NROS_ZENOH_SUBSCRIBER_RING_DEPTH |
ZPICO_SUBSCRIBER_SIZE_THRESHOLD | NROS_ZENOH_SUBSCRIBER_SIZE_THRESHOLD |
ZPICO_TL_RETAIN_BYTES | NROS_ZENOH_TL_RETAIN_BYTES |
ZPICO_TL_RETAIN_DEPTH | NROS_ZENOH_TL_RETAIN_DEPTH |
ZPICO_TX_BATCH | NROS_ZENOH_TX_BATCH |
ZPICO_TX_BATCH_FLUSH_MS | NROS_ZENOH_TX_BATCH_FLUSH_MS |
ZPICO_TX_SPLIT_LOCK | NROS_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.
Retired knobs
Some knobs are gone, not renamed: what they configured no longer exists. Every spelling of one is refused with an error that says why and what to set instead.
zenoh-pico 1.10 runs the lease and keep-alive on its executor thread, which is the read task, so the separate lease task and its settings are retired:
| Retired | Where it was set | What to do |
|---|---|---|
NROS_ZENOH_LEASE_PRIORITY, ZPICO_LEASE_TASK_PRIORITY | environment, cargo [env], [image.<id>] env | remove it; NROS_ZENOH_READ_PRIORITY prioritises the thread that now runs the lease |
[image.<id>.knobs.zenoh] lease_priority | system.toml | remove it; use read_priority |
CONFIG_NROS_ZENOH_LEASE_PRIORITY | prj.conf / Kconfig fragments | remove it; Kconfig reports it as an undefined symbol. Use CONFIG_NROS_ZENOH_READ_PRIORITY |
zenoh_lease_priority, zenoh_lease_stack_bytes | [node.rt] / [node.rt.freertos] | remove them; zenoh_read_priority / zenoh_read_stack_bytes configure that task |
.keepalive_priority, .keepalive_stack_bytes | nros_app_config_t.scheduling | remove them; the C compiler rejects an initializer that names either |
ZPICO_LEASE_TASK_PRIORITY (C define) | -D to the zenoh shim | remove it; the shim #errors on it |
zpico_set_task_config takes two arguments now, the executor task’s priority
and stack size.
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”.
| Retired | Use instead | What happens now |
|---|---|---|
ZENOH_LOCATOR (env) | NROS_LOCATOR | startup error (Rust panic / InitError::RetiredEnvVar; C/C++ init returns invalid-argument and logs it) |
ZENOH_MODE (env) | NROS_SESSION_MODE | same |
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 companions | nothing — the xrce backend is unconditional | the feature no longer exists (cargo: unknown feature) |
Retired commands
| Retired | Use instead | What happens now |
|---|---|---|
nros new entry <name> --platform zephyr | nros materialize <bringup>:<image>, which writes the image’s generated entry into your workspace as a package you own under src/; or, for an application that owns its west build, A Zephyr App You Build with West | the book no longer teaches it: a workspace generates the entry for each image, so there is nothing to scaffold by hand |
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.
Design background
- RFC-0093: board naming states reach — why a board is not named for its emulator
- RFC-0103: one owner per fact — the one
NROS_<ID>knob spelling - RFC-0110: zenoh-pico executor driving — why the lease task is gone
Migrating off the install-local prefix
TL;DR. nano-ros no longer has a CMake install step: the install-local
recipe, the build/install/
prefix, every install(...) rule, every Config.cmake.in template,
and the find_package(NanoRos) consumption path are gone. nano-ros is
consumed exclusively via add_subdirectory(<repo-root>) from the
user’s CMakeLists.txt.
If your project still consumes nano-ros through an installed prefix, this page is the one-page rewrite that gets you onto the supported shape.
Before (installed prefix)
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: the install-local recipe populated build/install/
# In the user project:
cmake -S . -B build -DCMAKE_PREFIX_PATH=<nano-ros>/build/install
cmake --build build
After (no install step)
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
| Before | After |
|---|---|
find_package(NanoRos REQUIRED CONFIG) | add_subdirectory(<path-to-nano-ros>) after set(NROS_PLATFORM ...) + set(NROS_RMW ...) |
the install-local recipe | (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 — dropnano-rosinto yourwest.yml, setCONFIG_NROS=yinprj.conf, and state the RMW as the image’srmwin asystem.tomlbeside it. - ESP-IDF. No longer supported: the component was removed too (ESP32 — retired).
- NuttX.
integrations/nuttx/is aapps/external/shim — symlink (or copy) and enable viamake menuconfig. - PX4.
integrations/px4/module-template/is aEXTERNAL_MODULES_LOCATIONtemplate — 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.ayou could link from arbitrary external projects. Now 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 frompackages/cli/interfaces/inside the nano-ros checkout. - The
cmake --install buildstep. 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,
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.
ESP32 — retired
nano-ros no longer supports the ESP32. Two paths existed, and both have been
removed: the ESP32-C3 bare-metal board over esp-hal, and the ESP-IDF
component with its idf.py build driver. This page says what each was, why it
went, and what an ESP32 port would need today.
The ESP32-C3 bare-metal board (esp-hal), retired 2026-11-02
esp32 was the ESP32-C3 under Espressif’s QEMU fork, over esp-hal: the
nros-board-esp32-qemu board crate, the nros-platform-esp32-qemu platform
crate and the examples/esp32-c3-baremetal/ projects. It had already been
dormant: nothing built or ran it.
nano-ros supports a way of running only where it keeps its real-time promise:
an executor parks until an arrival or its next deadline, and wakes for nothing
else. On a bare-metal board that needs a receive interrupt and a one-shot timer
behind the port’s wake object, as mps2-an385-baremetal has. The ESP32-C3
board had neither, so its executor could only poll the network. nano-ros
refuses such a board rather than serve it by polling, so the board was retired
instead of ported.
Every name the board answered to (esp32-c3-baremetal, esp32-qemu,
esp32-c3, esp32c3, esp32) is refused with that reason wherever a board is
read: system.toml, nros build, nros setup, nros::main!. The esp32
framework name is refused the same way.
The ESP-IDF component and idf.py driver, retired 2026-09
integrations/nano-ros/ was an ESP-IDF component shim. Nothing built it, it
had no platform descriptor or package.xml, and it was never published to the
Espressif Component Registry, so it was removed rather than kept as an
untested promise.
nros build also had an idf.py driver for ESP32 boards whose package graph
crossed languages. With the component gone there was nothing for a project to
depend on, no ESP-IDF project for idf.py to find, and no way to carry a
resolved nano-ros setting into that build, so the driver was deleted. That
combination now refuses with those reasons instead of running a tool with
no project to build. C and C++ on an ESP32 have no build road.
If you need an ESP32
The platform ABI and the C API are unchanged, and nothing forbids an out-of-tree port. A port has to bring what every supported board has:
- for bare metal, the network driver’s receive interrupt and a one-shot timer signalling the port’s wake object, so the session reports arrivals instead of being polled;
- for ESP-IDF, a component, a way to carry nano-ros’s resolved settings into the ESP-IDF build, and a build that something runs.
Extending nano-ros describes what a port provides.
Retired Configuration Files
These files used to carry configuration and no longer do. If a guide, an old
project or a blog post tells you to write one of them, its content now
belongs in system.toml (an image’s settings, under [image.<id>]) or, for a
hand-written no_std app with no codegen, in the standalone config.toml
that Configuration describes.
nros.toml— retired in full. A workspace-rootnros.tomlis rejected by the CLI (NrosTomlNotSupported; migrate with thenros-v0.5.0tag’snros migrate workspace— the one-shot verb is retired on newer trees); the legacy per-package overlay is a deprecated fallback thatnros checkflags; the embedded-runtime role never shipped (no example ever declared one). If a doc tells you to writenros.toml, it predates the migration — the content belongs insystem.toml.- Old
config.tomlschemas —[network]/[zenoh]/[scheduling], and the[[transport]]table — retired; the direct-modeconfig.tomlabove is the kept, supported shape.