Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Start Here

This is nano-ros Internals, the book for people who change nano-ros itself. If you want to use nano-ros (write nodes, bring up a board, add a board or an RMW backend from outside the tree), read the nano-ros Book instead. Everything there works from an installed release, while everything here assumes a checkout.

The two books and the records

You want…Read
To use nano-ros, or to port it to a board, platform, transport or RMWthe nano-ros Book
How the tree is built, tested, generated and releasedthis book
Why a decision was madean RFC in docs/design/
What is being worked on, and its acceptancea phase doc in docs/roadmap/
A known bug, limitation or piece of tech debtan issue in docs/issues/

The three numbered series are records. This book is narrative: it explains how the parts work and links to the record that decided each one, without copying it.

The books are split by reader, not by topic (RFC-0123). When a subject has a user side and a contributor side, each book has its own page and the two link to each other. For example, the Book’s ROS 2 interop page says what to do, and RMW Internals → Zenoh protocol says how it works.

First hour

  1. Set up a checkout: Contributing.
  2. Learn the shape of the system: Architecture, then the repo map.
  3. Learn the verbs. just with no arguments lists about eight, grouped by when you need them, and just check <name> runs one gate. Build Commands has the full list.
  4. Before you open a pull request, run just check fast.

Where a new fact goes

When you learn something that will still be true later, file it where the next person will look:

  • a design decision goes in an RFC;
  • planned work goes in a phase doc;
  • a bug or limitation goes in an issue;
  • how something works, for contributors, goes on a page of this book;
  • how to use something goes on a page of the nano-ros Book.

CLAUDE.md and AGENTS.md at the repo root are routers for coding agents. They point here and into docs/, and they deliberately hold no design detail.

Architecture

The whole-system design is written once, in docs/design/ARCHITECTURE.md. That document narrates the system end to end and links into the RFC behind each part. This page is a reading order for it, not a second copy.

Read in this order

  1. The layers. Application, nano-ros core, RMW backend, platform. Each layer can be swapped on its own. RFC-0001 has the crate stack and the directory map.
  2. The feature axes. RMW × platform × ROS edition (ARCHITECTURE §2, RFC-0005, RFC-0006).
  3. How an image is built. There are three roads (cargo, CMake, west), and each carries knobs to the compiler differently: book-internals/src/pipeline/canonical-build-path.md. nros configure is the single resolver every road calls (RFC-0120).
  4. How an image runs. A target is a launcher or flasher, a console and a link, executed from one RunPlan (RFC-0118).
  5. What a board is. A board is a descriptor plus a registry row. Every support claim is generated from the registry (RFC-0114).
  6. The CLI’s shape. A user tier and an nros dev tier (RFC-0117), extended by runner and command packages (RFC-0119).

The user-facing view of the same system, with fewer moving parts, is the Book’s Architecture Overview.

What backs the Book’s claims

The Book’s introduction states its benefits without evidence, for readers. This is the evidence, and the place to update when a claim changes.

  • “One node, any board.” The example_portability test normalizes every platform’s copy of each example and fails if any two differ. Two execution models are declared exceptions, with written reasons: bare-metal deferred dispatch, and Zephyr’s component shape. The test names them rather than hiding them.
  • “Fits a microcontroller.” The core is no_std; alloc and std are opt-in features. Whether an image needs an allocator is the backend’s call: XRCE allocates only at entity setup, zenoh-pico wants an allocator (a bump allocator suffices on bare-metal), and Cyclone DDS allocates per message. scripts/rmw-alloc-sites.py reports every allocation site.
  • “Checked, not only tested.” Kani harnesses and Verus proofs cover CDR serialization, scheduling and protocol correctness. The current counts and how to run them are in Formal Verification.

Repo Map

What each top-level directory holds. The packages/ breakdown is kept in one place, the “Directory map” in RFC-0001, and check-package-directories checks it against the tree, so it is not repeated here.

DirectoryHolds
packages/Every crate and C port: language APIs, core, RMW backends, platforms, boards, drivers, CLI, runners, tooling, tests, verification. See RFC-0001’s directory map.
examples/Standalone copy-out projects, shaped examples/<platform>/<language>/<example>/. The RMW is chosen at build time.
book/The nano-ros Book (users and porters), plus page-map.toml, which records which book and part every page belongs to.
book-internals/This book.
docs/The records: design/ (RFCs), roadmap/ (phases), issues/, plus development/, reference/ and research/.
just/, justfileBuild orchestration. just/check/*.just holds the gates, and the fast lane is derived from those recipes.
scripts/Generators (gen-*), gates (check-*, scripts/check/), probes (scripts/probe/) and shared helpers (scripts/lib/).
cmake/The CMake road: find_package(NanoRos) support, board CMake and ament-surface shims.
zephyr/The Zephyr module (west road), with its Kconfig generated from the knob registry.
integrations/Glue for host tools that are not nano-ros’s own build: FreeRTOS, NuttX, PlatformIO, PX4, S32DS.
config/Toolchain and target lists, and per-family defaults.
templates/Scaffolds for out-of-tree boards and cargo-zephyr projects.
tests/Shell and CMake fixture tests. The Rust integration tests live in packages/testing/nros-tests.
docker/, ci/Container images for development and CI.
locks/Registry lockfile pins.
third-party/Provisioned SDKs and RTOS sources (gitignored; nros setup fills it).
tools/The host environment and the pinned Rust toolchain for tools.
changelog.d/towncrier fragments, one per change.

Contributing

This chapter covers the development workflow for nano-ros.

Development Setup

This is the contributor setup: a checkout of the repository and the CLI built from it. If you only want to use nano-ros, install a release instead — Installation; every user chapter of this book assumes the installed CLI and no checkout.

Inside a nano-ros checkout the tree’s own build is the only correct nros: a released nros run against a checkout is refused outright, because it emits its own generated code and this tree’s runtime is the one that has to compile it (RFC-0090). One checkout = one CLI version = one runtime ABI — no global install, no ~/.nros/bin PATH skew across worktrees.

The host prerequisites are the ones in Installation § Host prerequisites (python3-dev is needed here: bootstrap.sh builds the launch resolver, which embeds CPython to parse ROS launch files).

From a fresh checkout

# 1. Pull the source (`main`, or a release tag `nros-<version>`):
git clone https://github.com/NEWSLabNTU/nano-ros.git
cd nano-ros

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

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

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

bootstrap.sh builds the in-tree nros CLI from source at packages/cli/target/release/nros and leaves it on PATH for that shell (just is NOT a prerequisite; rustup is installed on demand). Already have cargo? The equivalent by hand:

git submodule update --init packages/cli/third-party/play_launch
cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros
cargo build --release --manifest-path packages/cli/nros-launch-resolve/Cargo.toml
export PATH="$PWD/packages/cli/target/release:$PATH"

Both builds are needed for a workspace flow: nros configure and nros build call nros-launch-resolve to resolve launch files, so a bare cargo build --bin nros alone is not equivalent.

Every subsequent shell

Every new shell needs the workspace env wired in. All three wire the same env exports + PATH entries from activate.sh:

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

activate.sh is the after-install step, not a prerequisite: it presumes the nros binary already exists at packages/cli/target/release/nros. It also sources /opt/ros/humble/setup.bash if present (used by the rmw_zenoh interop tests, and it lets codegen resolve message packages from the ament index). It exports no checkout root: the in-tree nros knows the checkout it was built in. Without ROS 2 it says so:

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

On a host without just it also prints one line about RTOS SDK path defaults (FREERTOS_DIR, NUTTX_DIR, THREADX_DIR, …) not being loaded — only the in-tree just recipes (test lanes, fixture builds) read them (cargo install just).

Provisioning through just

The just setup recipes are thin wrappers over the same nros setup index — just setup <module> calls nros setup <board> under the hood, so the toolchains a contributor gets are identical to a user’s:

just setup freertos           # → nros setup mps2-an385-freertos
just setup nuttx              # → nros setup qemu-armv7a-nuttx
just setup threadx_linux      # → nros setup threadx-linux
just doctor tier=all          # diagnose missing tools (read-only)

A checkout’s version is its tag: the CLI built from a nros-<version> tree prints the same number (scripts/check-version-lockstep.sh and bootstrap.sh shell-doctor enforce binary↔tree agreement), and the tree’s committed nros-sdk-index.toml pins the toolchains. Upgrading = checking out a newer tag, re-running ./scripts/bootstrap.sh, then nros setup.

Docker environment

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

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

Setup tiers

Choose the setup tier. base is enough for most code review and native development; all prepares the full test-all matrix:

just setup               # print setup choices
just setup base          # workspace tools + zenohd
just setup all           # all supported SDKs/services
source ./activate.sh

The test and fixture stages need the vendored-source UNION, not one board. nros setup <board> --rmw <x> provisions one board+rmw’s sources — right for building an app for that board. But just test links every RMW’s -sys crate and just build-test-fixtures resolves component graphs that path-dep platform sources (e.g. nuttx/libc) even for a native component, so the contributor build needs the whole set. Both recipes preflight it and, if a source is missing, name the exact remedy — nros setup --source <name>, or nros setup --build-sources to provision the union at once (issue 0390).

Build the entire workspace including examples:

just build

Use groups to inspect the command surface:

just --group main --list
just --group full-matrix --list
just --group full-matrix --list zephyr

Quality Checks

Always run just ci after completing a task that needs the full maintainer gate. This runs the normal checks plus the full test matrix entry points:

just ci

For a narrower local iteration, run just check and just test.

For platform-specific CI, use just <platform> ci (e.g., just freertos ci).

Testing

nano-ros has several test tiers, each with its own just recipe:

RecipeWhat it testsExternal deps
just test-unitUnit tests (no external deps)None
just test-integrationRust integration tests, no heavy QEMU/Zephyr (builds zenohd automatically)None
just testtest-unit + test-integration (default dev tier)None
just test-docrustdoc doctests for the nros umbrella crateNone
just test-miriUndefined behavior detection (standalone)None
just qemu testQEMU bare-metal examplesqemu-system-arm
just freertos testFreeRTOS QEMU E2Eqemu-system-arm + arm-none-eabi-gcc
just nuttx testNuttX QEMU E2Enightly + qemu-system-arm
just threadx_linux testThreadX Linux user-space E2Egcc
just threadx_riscv64 testThreadX RISC-V QEMU E2Eqemu-system-riscv64
just zephyr testZephyr E2E (native_sim + NSOS)west
just test-allEverything: test + heavy QEMU/Zephyr/ROS-interop + test-doc + test-miri + C codegenAll of the above

All test recipes accept a verbose argument for live output.

Test organization

  • Reusable Rust tests go in packages/testing/nros-tests/tests/
  • Shell-based test scripts go in tests/ with corresponding justfile entries
  • Temporary tests can be run directly in the shell, then converted to proper tests once validated

Build isolation

Nextest runs each test file as a separate process in parallel. When multiple tests build the same example with different features, use --target-dir to isolate output directories (e.g., target-safety/, target-zero-copy/). Add new target dirs to the example’s per-directory .gitignore.

Code Style

Rust Edition 2024

nano-ros uses Rust edition 2024, which requires:

  • unsafe extern "C" { ... } – extern blocks require the unsafe keyword
  • #[unsafe(no_mangle)] – no_mangle requires the unsafe attribute
  • Unsafe operations inside unsafe fn need explicit unsafe { ... } blocks

The nros-c crate keeps #![allow(unsafe_op_in_unsafe_fn)] due to 420+ FFI operations.

Unsafe conventions

  • Minimize unsafe usage; prefer safe abstractions
  • Document safety invariants with // SAFETY: comments
  • Use wrapper types (like SubscriberBufferRef) to encapsulate unsafe access patterns

no_std patterns

All core crates support #![no_std] with optional std/alloc features:

  • #[cfg(feature = "alloc")] gates Vec/Box usage
  • #[cfg(feature = "std")] gates std-only features
  • heapless::Vec is used in no_std contexts

Unused variables

  • Rename to _name with a comment explaining why
  • Use #[allow(dead_code)] for test struct fields

Message Types

Message types are always generated, never hand-written. Use the codegen tool:

nros dev codegen rust

Bundled interface definitions live in packages/cli/interfaces/. Example generated/ directories are gitignored and recreated by just generate-bindings. Only the core pre-generated set checks its generated/ into git: ONE driver package at packages/interfaces/, one output tree at packages/interfaces/generated/humble/, six crates, regenerated with just generate-interfaces on a ROS 2 ament host.

System Packages

Never install system packages or run sudo directly. If a system dependency is needed, document what the user should install.

Temporary Files

Create temporary files in the project’s tmp/ directory (git-ignored), not in /tmp.

PR Workflow

  1. Create a branch from main
  2. Make changes
  3. Run just ci – all checks must pass
  4. Submit for review

Verification

For changes to core crates, run the formal verification suite:

just verify          # Kani + Verus
just verify-kani     # Kani bounded model checking (~3 min)
just verify-verus    # Verus deductive proofs (~1 sec)

Writing the Book

This page is for anyone who writes or reorganizes a page of the nano-ros Book, the user book under book/src/. It is the charter of RFC-0124 put as a checklist, followed by one template for each kind of page. Read the checklist before you open a template, and check your page against it before you ask for review.

check-book-audience reads the gated rules. It reports them today (phase-537 W0) and refuses them once phase-537 W13 lands:

python3 scripts/check-book-audience.py --charter

The checklist

Who and why

  1. Three purposes, stated budgets. Every page serves at least one of education, field solutions and industrial, within its budget: a 15-minute install and labs under two hours; 30 minutes from box to ros2 topic echo; reproducible, auditable, offline builds (RFC-0116 D1).
  2. Two books by reader. Application developers and porters read the Book. Contributors read this book, Internals. Contributor material in a user page moves here.

What may be in the Book

  1. A board enters the Book only with an emulator twin, or a track that replays its chapter (RFC-0115 D4). QEMU mps2-an385 is the concepts board. Gated: a board page whose registry rows are neither Verified nor an emulator twin of a named board is reported.
  2. Claims follow the registry. Board facts and status words are generated, never typed. A board, feature or integration with no registry row gets no user page. Gated (rule E refuses a typed status word).
  3. Only the user tier. No nros dev, no checkout steps, no internal ids in prose. RFC numbers appear only in a closing “Design background”. Gated (rules D and check-user-surface-ids refuse).

How it is organized

  1. Sections follow what the reader is doing: Start here, Get started on your board, Tutorial, User guide, Background, Reference, Extending, Releases. RFC-0124 Appendix A names every page and its sources. A page not in Appendix A needs an RFC amendment first.
  2. One topic, one page per type. Tutorial, how-to, explanation and reference are separate pages that link to each other. A topic has at most one page of each type. Gated: the declared type must fit the section, and two pages may not declare the same type and topic.
  3. Starters before depth. Every admitted board has a starter that needs only Install, twin-and-hardware boards first.
  4. Reference is generated from its owner: the contract format, the CLI, the APIs, system.toml, boards. A hand-written table that copies a generated one is deleted.

How it is written

  1. Procedure first. A task page is steps. Each step is a command or an edit, followed by its expected output. The page ends with a complete working example.

  2. Concepts at the point of need. Explain a concept only if it is uncommon outside nano-ros and central inside it: the bringup, the image, the contract, static pools, the execution tiers. Start from the example in front of the reader, not from the model. For what a ROS 2 user knows, link to the ROS 2 documentation.

  3. The simplest working path first. Serial before ethernet, with WiFi stated as deferred. The first chapter names no launch file, image or entry. One run verb, nros run, with nros flash --monitor on hardware.

  4. Say plainly where nano-ros differs from ROS 2. Never imply a symmetry that is not there.

  5. Every runnable step is probed on its row’s evidence. A step that cannot run yet carries an awaits line on its track and fails loudly. Nothing is skipped. Gated: a shell fence with no probe= is reported.

  6. Budgets. Gated:

    TypeWords of prose
    how-to300–1,500
    tutorial chapter1,500–3,000
    explanation800–2,500
    referenceno limit, but tables and lookups only

    A page under 300 words is merged into its parent. Rust, C and C++ variants of one step are tabs on one page, not parallel pages.

Voice

Follow the Google developer documentation style guide: second person, present tense, active voice, sentence-case headings, and no “simply”, “just” or “easy”. Name a thing once, then use that name throughout.

Declare the type and level

Every hand-written user page carries one declaration on the line after its title. Generated pages are reference pages and carry none.

# Configure zenoh

<!-- type: how-to · level: 2 -->

Add · topic: <slug> when a topic has pages of several types, so the gate can tell that each type appears once:

<!-- type: tutorial · level: 2 · topic: contract -->

Topic levels

A page may assume only topics at its own level or below. It lists them on a “needs first” line, and the gate reports a link to a higher level.

LevelTopicsWhere
1. First contactinstall; a board starter; run a talker; what you built; add a node; pick and configure the RMWStart, starters, Tutorial 1–4
2. Building real applicationsyour own message; services and actions; parameters and launch files; the contract and sizing; another target; ROS 2 interop and QoS; a serial link; logging; workspace layout; mixing languagesTutorial 5–10, User guide
3. Production and tuningconfiguring an image; build profiles; offline and vendored builds; deploying; static memory; scheduling and timing; throughput; bridges; porting a ROS 2 nodeUser guide, Background
4. Extending nano-rosa board, platform or transport; a custom RMW backend; runner and command packages; API designExtending, then Internals

A reference page carries the level of the most advanced topic it describes. Anyone may look it up.

Templates

Each template gives the headings in order. Replace the comments. Keep the headings a reader scans for, even when a section is one line.

Tutorial chapter

One chapter of the one Tutorial project, run on a Verified row, small enough for a two-hour lab.

# <What the reader does, as a verb phrase>

<!-- type: tutorial · level: <1|2> -->

**Needs first:** [<previous chapter>](<link>)

<!-- One paragraph: what the reader has at the end, stated as output they will see. -->

## <Step 1, a verb phrase>

<!-- The command or edit. Then the output they should see. -->

## <Step n>

## What just happened

<!-- The ONE nano-ros concept this chapter introduces, explained from the
     example the reader just ran. Link to Background for the full model. -->

## Next

<!-- The next chapter, and the User guide pages that go deeper. -->

How-to

One task, for a reader who already knows what they want.

# <Task, as a verb phrase: "Use a serial link">

<!-- type: how-to · level: <2|3|4> -->

**Needs first:** [<page>](<link>)

<!-- One sentence: when you would do this. -->

## Before you start

<!-- What must already be true: board, packages, a working project. -->

## Steps

1. <!-- A command or edit. -->

   <!-- Its expected output. -->

## Example

<!-- One complete, working example the reader can copy whole. -->

## If it doesn't work

<!-- The two or three failures a reader actually meets, each with its fix. -->

## See also

<!-- The reference page for the options; the Background page for the why. -->

Board starter

The Tutorial’s chapter 1 done on one board, needing only Install. About 1,500 words. The status header and the list of starters are generated from the registry; do not type them.

# Get started on <board>

<!-- type: how-to · level: 1 -->

**Needs first:** [Install](<link>)

## What you need

## Provision

## Scaffold the project

## Run it in the <emulator or twin>

## Flash the board

<!-- Only when the row names `hardware`. -->

## See it in ROS 2

## If it doesn't work

## Next

Platform page

What the platform’s starter leaves out: configuration, board notes, deeper tasks. Needs only Tutorial 1–2 and the starter. It sits in the User guide group its row’s generated status word names.

# <Platform>

<!-- type: how-to · level: <2|3> -->

**Needs first:** [Get started on <board>](<link>)

## Configure the platform

## Board notes

## <Deeper task, one heading each>

## Reference

<!-- Links to the generated board pages. -->

Explanation

A Background page: the nano-ros “why” a reader needs before production work.

# <The thing explained, as a noun phrase>

<!-- type: explanation · level: 3 -->

<!-- Open with a concrete example the reader has already run or will run:
     a build, a log line, a measured number. Then explain the model behind
     it. No steps. -->

## <Part of the model>

## Where nano-ros differs from ROS 2

<!-- Stated plainly, when it differs. -->

## Design background

<!-- The only place an RFC number appears. -->

Reference

Lookup only: tables, keys, options. If the page’s content has an owner (the CLI’s help, a schema, a generator), generate the page from it instead.

# <Thing> reference

<!-- type: reference · level: <level of its most advanced topic> -->

<!-- One sentence: what this lists and where it is generated from. -->

| Name | Type | Default | Meaning |
| --- | --- | --- | --- |

Before you ask for review

State in the PR description, for every page you add or rewrite:

  • its type, level and section in RFC-0124 Appendix A;
  • the principles you checked it against, and any you could not meet yet, with the reason (for example, a step waiting on a CLI change, with its awaits line);
  • the output of python3 scripts/check-book-audience.py --charter for that page.

Moves go through book/page-map.toml and scripts/book-move.py, so every moved page keeps a redirect. Land the work in section-sized PRs, rebased onto main rather than merging main in.

Quick Reference

The .env file

All environment variables can be set in a .env file at the checkout root:

cp .env.example .env
# Edit .env — uncomment and adjust values as needed
  • justfile — .env is auto-loaded. Missing file silently ignored.
  • direnv — .envrc sources .env if present.
  • Manual — set -a; source .env; set +a before cargo build.

Variables in .env take precedence over justfile defaults but are overridden by explicit shell exports. The variables themselves are listed in the user book’s Environment Variables.

Finding Commands

Root commands are grouped by audience:

just --list                         # grouped root command overview
just --groups                       # group names
just --group main --list            # normal development workflows
just --group full-matrix --list     # build-all / fixtures / test-all / ci
just --group setup --list           # setup and doctor entry points
just --group maintenance --list     # clean and generated-binding commands
just --group docs --list            # book and API docs

Platform and backend commands stay namespaced:

just --group full-matrix --list zephyr
just zephyr build-fixtures
just zephyr build-all
just qemu setup-network             # QEMU TAP networking only
just zephyr help                    # Zephyr-specific help

Install the local book tooling before previewing docs:

just setup-docs                     # mdbook + mdbook-mermaid
just book-serve                     # serve book/src with live reload
just book                           # full deployed preview with API docs

API documentation

The API reference the user book links (book/src/api/…) is Doxygen and rustdoc output built from the tree:

just doc                 # all of the below
just doc-rust            # rustdoc
just doc-c               # target/doxygen/c/
just doc-cpp             # C++ API
just doc-rmw-cffi        # RMW C ABI
just doc-platform-cffi   # target/doxygen/platform-cffi/

The Doxygen recipes need doxygen and skip with a warning when it is not installed (nros setup --system composes the install command).

Test profiling & slow-test reporting

just test and just test-all always print the slowest tests after the run summary — the top 20 by duration (binary, test name, time, status), parsed from the active profile’s target/nextest/<profile>/junit.xml. No flag needed; it is part of the normal output.

Deeper profiling is opt-in and adds nextest’s experimental event/output recording plus artifact export. It preserves the normal nextest execution model (same filters, cargo profile, parallelism) — it only records and exports, so leave it off for routine runs:

NROS_NEXTEST_RECORD=1 just test
NROS_NEXTEST_RECORD=1 just test-all

Each profiled run writes a timestamped directory under tmp/ with a stable -latest symlink:

tmp/nextest-profile-test-YYYYMMDD-HHMMSS/      tmp/nextest-profile-test-latest -> …
tmp/nextest-profile-test-all-YYYYMMDD-HHMMSS/  tmp/nextest-profile-test-all-latest -> …

Artifacts in that directory:

  • nextest-run.zip — portable recording archive; replay with full captured output (incl. successful tests) via cargo nextest replay.
  • nextest-trace.json — Chrome/Perfetto timeline (slot/group occupancy, idle slots, retries, long-pole tests). Canonical concurrency artifact.
  • junit.xml — copy of the run’s JUnit report.
  • env.txt, command.txt — the knobs and exact command used.

Knobs:

VariableEffect
NROS_NEXTEST_RECORD=1Enable recording + artifact export.
NROS_NEXTEST_RECORD_DIR=<path>Override the output dir (and its -latest link).
NROS_NEXTEST_TRACE_GROUP_BY=slot|binaryPerfetto grouping; default slot (wall-clock/concurrency view).
NROS_NEXTEST_REPLAY_LOG=1Also write nextest-replay.log (full captured stdout/stderr — can be large on chatty tests). Off by default; rely on the portable archive otherwise.
NROS_NEXTEST_RECORD_KEEP_STATE=1Keep the temp NEXTEST_STATE_DIR (<dir>/state); removed after export otherwise.
NROS_NEXTEST_RUN_PROFILE=fail-fastStop at the first failure instead of the default --no-fail-fast full report (uses target/nextest/fail-fast/junit.xml).

Overhead and retention: recording adds event/output-store writes during the run, and nextest-run.zip (plus an enabled nextest-replay.log) can get sizable on output-heavy suites — keep it opt-in for local runs and prune old tmp/nextest-profile-* directories. Recording uses a profile-local NEXTEST_STATE_DIR, so it never pollutes the user’s global nextest record store. Do not reach for --no-capture to inspect output: it serializes execution and skews every timing. Use the replay archive instead.

Manual Testing

# Base setup builds/checks the in-tree zenoh router.
just setup base
source ./activate.sh

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

# Terminal 2: Talker
cd examples/linux/rust/talker && nros configure && nros build
RUST_LOG=info ./build/linux/target/debug/talker

# Terminal 3: Listener
cd examples/linux/rust/listener && nros configure && nros build
RUST_LOG=info ./build/linux/target/debug/listener

Each leaf’s RMW comes from its system.toml [system] rmw, and its artifact path is build/<image-id>/target/[<triple>/]<profile>/<bin>.

UDP Transport

On the native (host) build, zenoh-pico has built-in UDP support via OS sockets:

# Use UDP instead of TCP for the zenoh locator
NROS_LOCATOR=udp/127.0.0.1:7447 ./build/linux/target/debug/talker

On bare-metal, enable the link-udp-unicast feature to use UDP over smoltcp:

nros = { features = ["rmw-zenoh", "platform-bare-metal", "link-tcp", "link-udp-unicast"] }

The rmw-zenoh feature here is the lowering of the declared RMW — you declare the backend once in system.toml ([system].rmw / [image.<id>].rmw) and the toolchain sets the cargo feature; the feature is what the build uses, not the user-facing selector. See RFC-0031.

TLS Transport

TLS layers on top of TCP using mbedTLS. Requires a self-signed certificate (or real CA cert) and the link-tls Cargo feature.

Generate a test certificate:

openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
  -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

Native (host) (requires libmbedtls-dev – installed by just setup base):

# Terminal 1: Router with TLS
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tls/localhost:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd \
  --cfg 'transport/link/tls/listen_certificate:"cert.pem"' \
  --cfg 'transport/link/tls/listen_private_key:"key.pem"'

# Terminal 2: Talker with TLS
cd examples/linux/rust/talker && nros configure
cd .. && cargo build --manifest-path talker/Cargo.toml \
      --config talker/build/linux/nros-cargo.toml --features link-tls
NROS_LOCATOR=tls/localhost:7447 \
  ZENOH_TLS_ROOT_CA_CERTIFICATE=cert.pem \
  ./talker/build/linux/target/debug/talker

link-tls is a transport feature of the leaf’s own crate, so it stays a --features flag; the RMW is not — that is [system] rmw in system.toml.

Bare-metal (QEMU ARM):

On bare-metal, only base64-encoded certificates are supported (no filesystem). Build with --features link-tls:

# Build TLS-enabled examples
cd examples/mps2-an385-baremetal/rust/talker && nros configure
cd .. && cargo build --release --features link-tls \
      --manifest-path talker/Cargo.toml \
      --config talker/build/mps2-an385-baremetal/nros-cargo.toml

The CA certificate must be passed via ZENOH_TLS_ROOT_CA_CERTIFICATE_BASE64 at runtime (through the zenoh config), or embedded in the binary at build time.

ROS 2 Interop

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

# Terminal 2: nros talker
cd examples/linux/rust/talker && nros configure && nros build
RUST_LOG=info ./build/linux/target/debug/talker

# Terminal 3: ROS 2 listener
source /opt/ros/humble/setup.bash
export RMW_IMPLEMENTATION=rmw_zenoh_cpp
ros2 topic echo /chatter std_msgs/msg/String --qos-reliability best_effort

Actions

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

# Terminal 2: Action server (Fibonacci example)
cd examples/linux/rust/action-server && nros configure && nros build
./build/linux/target/debug/action-server

# Terminal 3: Action client
cd examples/linux/rust/action-client && nros configure && nros build
./build/linux/target/debug/action-client

Zephyr action tests:

just zephyr build-all          # Build all Zephyr examples (Rust + C + C++ + XRCE)
just zephyr test               # Run zenoh E2E tests (covers actions)

Docker Development Environment

Docker provides QEMU 7.2 (from Debian bookworm) which fixes TAP networking issues present in Ubuntu 22.04’s QEMU 6.2.

# One-time setup: add yourself to docker group
sudo usermod -aG docker $USER
# Log out and back in, or run: newgrp docker

# Build and use Docker environment
just docker build              # Build nano-ros-qemu image
just docker shell              # Interactive shell
just docker test-qemu          # Run QEMU tests in container
just docker help               # Show all Docker commands

QEMU Bare-Metal Testing

Run bare-metal Cortex-M3 examples on QEMU (MPS2-AN385 machine with LAN9118 Ethernet).

# Build prerequisites
just qemu build-zenoh-pico    # Build zenoh-pico for ARM Cortex-M3
just qemu build                # Build all QEMU examples

# Non-networked tests (no setup required)
just qemu test                # Bare-metal QEMU integration tests

# Networked talker/listener test (Docker Compose - recommended)
just docker test-qemu         # Runs zenohd, talker, listener in separate containers

Docker Compose Architecture:

┌─────────────────────────────────────────────────────────────┐
│              Docker Network: 172.20.0.0/24                  │
│                                                             │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
│  │   zenohd    │  │   talker    │  │      listener       │  │
│  │ 172.20.0.2  │  │ 172.20.0.10 │  │    172.20.0.11      │  │
│  │             │  │  ┌───────┐  │  │  ┌───────────────┐  │  │
│  │             │  │  │ QEMU  │  │  │  │     QEMU      │  │  │
│  │             │  │  │ ARM   │──┼──┼──│     ARM       │  │  │
│  │             │  │  │ TAP   │  │  │  │     TAP       │  │  │
│  │             │  │  └───────┘  │  │  └───────────────┘  │  │
│  └──────▲──────┘  └──────┼──────┘  └─────────┼───────────┘  │
│         └────────────────┴───────────────────┘              │
│                    NAT to zenohd                            │
└─────────────────────────────────────────────────────────────┘

Manual networked test (3 terminals, requires host TAP setup):

# Terminal 1: Setup network + start router
just qemu setup-network                    # Requires sudo
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Talker (192.0.2.10)
bin=build/cargo-fixtures/baremetal/thumbv7m-none-eabi/nros-relwithdebinfo
./scripts/qemu/launch-mps2-an385.sh --tap tap-qemu0 --binary "$bin/qemu-bsp-talker"

# Terminal 3: Listener (192.0.2.11)
./scripts/qemu/launch-mps2-an385.sh --tap tap-qemu1 --binary "$bin/qemu-bsp-listener"

Build those first with just qemu build-fixtures (or just build-test-fixtures lane=linux).

The artifact directory is computed, not fixed. Since phase-340 P2 a platform’s cargo fixtures build into a shared group directory under build/cargo-fixtures/<group>/ rather than each leaf’s own target/, and the profile is nros-relwithdebinfo, not release. The group name can carry a hash suffix when a row builds with distinct env, so treat the path above as what it is on a default build, not as a guarantee — scripts/build/fixtures-target-dir.sh (nros_fixture_row_artifact_dir) is the single source of truth, and it is what the test side uses to find the same file. That helper takes the row’s cargo args and env as arguments, so a script that did not itself run the build should ask for the row by id — nros_fixture_row_artifact_dir_by_id <row-id> reads all three key fields from the manifest, and cannot disagree with the builder about which group a row landed in (issue 1025).

Run just qemu help for more options.

Zephyr Setup

just setup zephyr       # Initialize workspace at $repo/zephyr-workspace/
just zephyr test        # Run zenoh tests (native_sim uses NSOS on host loopback)
just zephyr test-xrce   # Run XRCE tests

The workspace lives at $repo/zephyr-workspace/ by default (gitignored). Set $NROS_ZEPHYR_WORKSPACE to install elsewhere. Legacy sibling installs at ../nano-ros-workspace/ are auto-detected; run ./scripts/zephyr/migrate-workspace.sh to consolidate.

See Zephyr for full details.

CLI lives in the monorepo (Phase 218)

The nros CLI lives in-tree at packages/cli/ as a sub-workspace — own Cargo.toml, own Cargo.lock, own member crates (nros-cli, nros-cli-core, nros-build, cargo-nano-ros, rosidl-*, colcon-cargo-ros2, nros-msg-to-idl). It is built per checkout via just setup-cli → packages/cli/target/release/nros, then put on PATH by the activate file. See also:

Why sub-workspace, not root member

The runtime workspace’s no_std feature-unification view (Phase 214.F.3) is the project’s most fragile invariant — a single workspace member adding a std-activating dep without target-gating turns the embedded build red on a path the test matrix doesn’t always exercise. The CLI’s deps (clap, askama, syn, ureq, …) are aggressively std-only; making them workspace siblings of the runtime crates would either (a) require target-gating every single one or (b) effectively disable the F.3 guard.

The sub-workspace shape keeps the two surfaces categorically separate. The pattern mirrors packages/testing/nros-{tests,bench,smoke}/, which carved out the same way for the same reason.

Why per-checkout, not ~/.nros/bin

Confirmed during the Phase 218 UX walkthrough: contributors with multiple nano-ros worktrees (phase branches, ASI integration trees, downstream forks) need each tree to point at its own CLI. A global install would silently version-skew across trees the moment the user cds — and because the CLI’s codegen format is structurally bound to the nros-core / nros-c / nros-cpp crates in the same checkout, a global CLI is a known footgun.

The per-checkout shape makes the activate file the single switch — no which nros surprise across cwd boundaries. One checkout = one CLI version = one runtime ABI, by construction; no version-pin matrix to police.

Resolution chain

scripts/build/cargo.sh::nros_cli_bin resolves the active binary:

  1. $NROS_CLI (explicit override)
  2. nros on $PATH (activate file puts the repo-local one here)
  3. packages/cli/target/release/nros (per-checkout)
  4. ${NROS_HOME:-~/.nros}/bin/nros (transitional, removed once every active branch lands on 218)
  5. error

The Phase 218.E ABI guard checks the in-tree CLI build matches the runtime crates’ codegen ABI hash; opt out via NROS_SKIP_VERSION_CHECK=1 when intentionally bisecting a CLI mismatch.

See also

The CLI’s developer tier

The user-facing nros verbs are documented in the user book’s CLI reference. This page holds the rest: building the CLI from a checkout, the hidden nros dev … verbs that cmake, west, build.rs and just call, the contributor-only nros update, and how nros relates to the just recipes. nros help --all and nros dev --help list the dev tier (RFC-0117 D1, D3).

Building the CLI from the checkout

Inside a nano-ros checkout, build the CLI from the checkout. That is not a fallback: in a checkout the tree’s own build is the only binary this tree accepts, because a released nros emits its own generated code and this runtime is what compiles it (RFC-0090, phase-431 W1). just doctor fails when another nros shadows it. The CLI ships from the in-tree sub-workspace at packages/cli/; bootstrap builds it (installing rustup if needed), then activate the workspace to put it on PATH:

./scripts/bootstrap.sh
source ./activate.sh        # OR: direnv allow / source ./activate.fish

Equivalent, if you already have cargo (same build, same binaries): git submodule update --init packages/cli/third-party/play_launch, then cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros and cargo build --release --manifest-path packages/cli/nros-launch-resolve/Cargo.toml — the launch resolver the workspace flow needs (bootstrap runs all three for you; just setup-cli + just setup-launch-resolve are the internal aliases).

The resulting binary lives at packages/cli/target/release/nros. One checkout = one CLI version = one runtime ABI; contributors with multiple nano-ros worktrees get per-tree CLIs with no global PATH skew. Once on PATH, nros --help lists every verb. ${NROS_HOME:-~/.nros}/bin/nros is where install.sh puts a release; inside a checkout just doctor fails when that one shadows the tree’s own build.

nros doctor run inside a checkout also forwards to just doctor (or just <platform> doctor with --platform) after its own native checks; when just is not on PATH it says it skipped those contributor checks and still exits on the native result.

Dev-tier verbs

nros dev codegen rust [--manifest <path>] [--output <dir>] [--ros-edition <e>] [--codegen-config <f>] [--rename <old=new>] [--force] [--verbose]

The Rust-only codegen primitive. Same binding generation as nros interface generate rust, and with no side-effects: it writes generated/ and nothing else. Use it when you want bindings and only bindings; use nros sync for an actual project, because a leaf also needs its generated build settings.

--generate-config (alias --config), --nano-ros-path and --nano-ros-git still parse and are legacy. They wrote a leaf .cargo/config.toml — the file RFC-0098 retired. The patch rows now live in the generated build/<image>/nros-cargo.toml that nros sync writes, alongside the board’s cargo settings and the image’s derived [env], and cargo reads that through --config. (nros sync’s own --nano-ros-path is documented in its help as a no-op kept for back-compat.)

nros dev codegen <resolve-deps|cyclonedds-descriptors|entry> …

Internal build-tool interface — invoked by the CMake integration and build.rs scripts, not meant for direct use. Subcommands resolve message-package dependency closures, emit CycloneDDS type descriptors, and generate C/C++ entry glue. (nros dev codegen system below is the user-facing system bake.)

nros dev metadata <system_pkg> [--workspace <path>] [--out-dir <dir>] [--metadata <existing.json>] [--build [--nano-ros-workspace <path>]] — walk a colcon-style workspace under <workspace>/src/

collecting component source metadata into build/<system_pkg>/nros/source-metadata.json. The result feeds nros plan. With --build, any declared component (component_nros.toml) missing its source-metadata is produced by the metadata-mode build: nano-ros compiles + runs the component against an in-memory recorder to emit the JSON. --build needs the nano-ros workspace (--nano-ros-workspace or NROS_WORKSPACE).

nros ws <subcommand>

Workspace-level message-package utilities (the env / status / list / clean / doctor helpers; sync was promoted to top-level nros sync).

eval "$(nros dev ws env)"   # add src/ to NROS_INTERFACE_SEARCH_PATH
nros ws status          # freshness check (non-fatal): n up-to-date / n stale / n missing
nros list            # list discovered msg + Rust-consumer pkgs
nros clean           # remove generated/ + auto-managed patch entries
nros ws doctor          # lint workspace pkgs (package.xml markers, stale patches, …)
nros dev lock managed-deps    # write the `# nros-managed` message rows from package.xml
SubcommandDescription
envPrint shell export adding <dir> (default ./src) to NROS_INTERFACE_SEARCH_PATH
statusNon-fatal freshness check — sibling of nros sync --check
listList discovered msg + Rust-consumer pkgs (kind, name, dir per row)
cleanRemove generated/ + auto-managed [patch.crates-io] entries from each .cargo/config.toml; leaves user-written keys + sections alone
managed-deps [--check]The one step nros sync and nros build both run: write each Rust package’s # nros-managed [dependencies] rows — one per <depend> on a message package — without codegen. --check writes nothing and exits non-zero if a manifest would change
doctorLint: warn on missing <member_of_group> rosidl_interface_packages</member_of_group>, malformed package.xml, missing nros-managed [patch.crates-io] entries in the authority .cargo/config.toml

nros dev codegen system [--bringup <pkg>] [--target <id> | --for-entry <pkg>] [--out <dir>] [--launch <file>] [--ahead-of-vendor <pio|px4>]

Host-time system bake: reads <bringup>/system.toml + <bringup>/launch/system.launch.xml and emits the baked compile-time C config + component registration glue consumed by every embedded RTOS adapter.

nros dev codegen system --bringup demo_bringup
nros dev codegen system --bringup demo_bringup --target zephyr_native_sim  # an [image.<id>]
nros dev codegen system --bringup demo_bringup --for-entry zephyr_app      # the image claiming it
nros dev codegen system --bringup demo_bringup --ahead-of-vendor pio   # + PlatformIO library.json
nros dev codegen system --bringup demo_bringup --ahead-of-vendor px4   # + PX4 module dirs
FlagDescription
--workspace <path>Workspace root (default: cwd)
--bringup <pkg>Bringup pkg name or path. Defaults to [workspace.metadata.nros].default_system
--target <id>The [image.<id>] this bake is for — it selects the domain, RMW, locator, launch and tier sub-table. A triple or platform name that matches no block selects nothing and every one of those falls back to the [system] default (issue 1312)
--for-entry <pkg>Name the ENTRY PACKAGE instead (a name, or a path to its directory) and the [image.*] that claims it supplies the target. What a framework configure can answer: west/ESP-IDF know the application directory, never the image id. Mutually exclusive with --target
--nano-ros-path <dir>nano-ros checkout holding packages/boards, for the board catalog a resolved target needs. Default: a walk up from the workspace, then this nros binary’s own SDK root (a release’s share/nano-ros, or the checkout an in-tree nros was built in)
--out <dir>Output directory; nros-system/ subdir created inside. Default: <workspace>/build/<bringup>/
--launch <file>Multi-launch disambiguation: pick <bringup>/launch/<file> (--file is an alias)
--exec <exec><node exec="…"> override for synthesised launches
--ahead-of-vendorpio: emit PlatformIO library.json; px4: emit one PX4-native nros_<component>/ module dir per component

nros dev sdk root [--workspace <dir>] [--nano-ros-path <dir>] [--explain]

Print the nano-ros SDK root this nros resolves — the directory holding the runtime sources and nano_rosConfig.cmake that a build compiles against: an explicit --nano-ros-path, else a nano-ros checkout found by walking up from --workspace (default: the current directory), else this binary’s own SDK (a release’s share/nano-ros, or the checkout an in-tree nros was built in). --explain names the rung that answered on stderr; stdout stays the bare path.

nros dev sdk root --explain

Dev-tier verbs the user book no longer names

The user book teaches only the user tier (RFC-0117 D2, RFC-0123 D9). These dev-tier verbs used to appear in user chapters; the user step that replaced each is named beside it.

Dev-tier verbWhat it doesWhat the user book says instead
nros dev sdk root [--explain]print the SDK root this nros resolves (section above)nros locate nano-ros, the same ladder; a raw cmake can also use nros setup presets + cmake --preset <board>
nros dev sdk path <tool> [--require]print a store tool’s prefix (qemu, zephyr-sdk, arm-fvp)nros locate tool qemu-system-<arch> for QEMU; the store path ~/.nros/sdk/<tool>/<version> for the rest
nros dev sdk manifest (old nros toolchain manifest)read, or with --write stamp, the share/nros/manifest.toml a release declares; the release workflow calls itnothing: a release-pipeline verb
nros dev codegen rustRust bindings only, no side effects (section above)nros interface generate rust, or nros sync
nros dev rmw session-properties <bringup> --domain <name> --symbol <ident> --out <header>lower a bridge’s session config file to a C/C++ header without CMakethe CMake road only
nros dev lock managed-deps [--check]write the # nros-managed message rows into each Rust package, alonenros sync (which runs it)
nros dev image entity-inventory --metadata <json> --model <yaml>print the composed entity inventory an image is sized fromnothing
nros dev image entity-census take --image <bringup>:<image>take a cross image’s census before a hand-run west build / cmakenros build takes it; a hand-run configure refuses and prints this command
nros dev image factsper-image facts, including cmake_build_dirthe command nros build --dry-run prints
nros dev image model-pathwhere a resolved SystemModel landedbuild/nros/models/<bringup>/ (default)
nros dev profile resolve --build-type <T> / nros dev profile dir <name>the cargo profile for a CMake build type, and its target/ subdirectorythe configure’s own -- nano-ros: cargo profile … line
nros dev board cmake-varsproject a board descriptor into cmake variables (nano_ros_use_board() runs it)“asks the nros CLI”
nros dev metadata [--build]collect component source metadata for nros plan (section above)nothing
nros ws qemu-nic <leaf> --model <nic-model> (hidden ws subcommand)print the QEMU -nic value for a leaf’s imagenros run passes it to QEMU

Old spellings

nros help --aliases prints every older spelling that still parses and the spelling it stands for. The table lives in packages/cli/nros-cli-core/src/cmd/user.rs (ALIASES, user tier) and dev.rs (dev tier). An old user spelling prints one note on stderr naming the new one, only when stderr is a terminal; a dev-tier alias prints nothing.

Old spellingNow
nros initnros setup presets
nros store list / nros store gcnros toolchain list / nros toolchain gc
nros toolchain uninstallnros toolchain remove
nros toolchain manifestnros dev sdk manifest
nros generatenros interface generate
nros generate-rustnros dev codegen rust
nros ws list / nros ws cleannros list / nros clean
nros sdk-root, nros sdk-path, nros sdk-frontnros dev sdk root, nros dev sdk path, nros dev sdk front
nros codegen, nros codegen-systemnros dev codegen, nros dev codegen system
nros image-facts, nros model-pathnros dev image facts, nros dev image model-path
nros ws leaf-system, board-facts, sizing-descriptor, west-leaf-sizing, entity-inventory, entity-census, model-dims, message-bound-knobsnros dev image <same>
nros ws rmw-dispatch, rmw-config, session-propertiesnros dev rmw dispatch, config, session-properties
nros ws seed-lock, check-lock, managed-deps, central-patchnros dev lock seed, check, managed-deps, central-patch
nros ws env, order, providersnros dev ws env, order, providers
nros profilenros dev profile
nros board cmake-vars, nros board netnros dev board cmake-vars, nros dev board net
nros metadatanros dev metadata

nros toolchain manifest: with no arguments it reads the manifest of the toolchain the binary belongs to; --write <path> (with --store-version, --index <date>, --nano-ros <commit>) stamps one. The release workflow calls the binary it just built, so a manifest cannot claim a codegen version its own binary does not emit.

Front-door gaps

A user step with no user-tier nros spelling. The user book either works around each (named below) or says no command exists yet. Each wants a front-door verb or an explicit decision that it stays out of the user tier.

  • The SDK root — closed (phase-532 W9). nros locate nano-ros is the user spelling: it answers through the nano_ros_root ladder anywhere, and in a pinned project it is the pinned version’s root. The user book uses "$(nros locate nano-ros)" wherever a host tool needs the path. The scaffolds nros new writes and the probe fences still spell nros dev sdk root, which gives the same answer.
  • A store tool’s directory. nros locate tool covers qemu-system-* only. The Zephyr SDK (ZEPHYR_SDK_INSTALL_DIR) and the Arm FVP model directory (ARMFVP_BIN_PATH) have no user-tier spelling.
  • The boot-report reader (below).
  • The mem-report tool (mem-report).
  • A QEMU -nic value for a script that starts QEMU itself (nros ws qemu-nic).
  • The census, for a hand-run west/cmake build (nros dev image entity-census take); the configure prints the command when it refuses.
  • The NuttX apps-tree staging is a script in the SDK root (scripts/nuttx/stage-external-apps.sh), not a verb.

Decoding a boot report

The user book’s Observing a Board With No Console page saves the NROS_BOOT_REPORT record with a debug probe; decoding it needs the repository’s reader:

read ADDR LEN <<< "$(python3 scripts/read-boot-report.py --addr-only build/zephyr/zephyr.elf)"
pyocd commander -t s32k344 -c halt -c "savemem $ADDR $LEN report.bin"
python3 scripts/read-boot-report.py build/zephyr/zephyr.elf report.bin

--addr-only prints the address and length from the ELF’s own symbol table, so a relinked image is never read at a stale address. --heap-headroom <dump> is the heap-headroom gate; --help lists the fields.

Contributor verbs

nros update (--pins [--prune] | --crate <n> [--precise <v>] | --source <n> [--ref <sha>] [--allow-rewind] | --tool <n> [--to-version <v>] | --west <m> [--revision <r>]) [--nano-ros-path <dir>]

Move ONE pin of a nano-ros checkout’s pin set — a registry crate, a vendored source, a host tool or a west project — and leave one diff to review. --pins regenerates the registry pin set from the locked roots. A contributor verb: an installed SDK’s pin set is read-only.

nros and just

nros vs. just

Want to …Use
Scaffold a projectnros new …
Generate bindings + the image’s build settingsnros sync (primitive: nros dev codegen rust)
Buildnros build — or the platform tool pointed at the generated settings (cargo --config …, cmake --build, west build)
Plan + check a multi-component system from a ROS 2 launch filenros dev metadata → nros plan → nros check
Flash / runPlatform tools: espflash, probe-rs, qemu-system-*, idf.py
Orchestrate the checkout (contributor: setup, doctor, CI, multi-platform sweeps)just …

nros routes through the nros-cli-core library. just recipes that wrap user-flow operations call into nros for consistency; internal recipes (build matrices, CI orchestration) keep their current shape.

Release pipeline status

The nros CLI is released as a GitHub Releases asset (nros-<host>.tar.zst, carrying the CLI and its SDK root) and installed with scripts/install.sh — Installation. There is no crates.io, Arduino zip or ESP-IDF binary channel. (A hidden, maintainer-only nros release verb exists behind the release cargo feature; the default build does not carry it.) Downstream RTOS package managers (Zephyr west, ESP-IDF idf_component_yml, NuttX Kconfig) consume the same source tree via the integration shells under integrations/<rtos>/.

SDK Tiers for Setup

Setup is split by customer workflow. scripts/bootstrap.sh is the entrypoint for a fresh checkout with no just installed yet; once just is available, all paths delegate to the same recipes.

scripts/bootstrap.sh                 # install/check just, then show setup choices
scripts/bootstrap.sh base            # first-time quick start
scripts/bootstrap.sh platform zephyr # focused platform developer
scripts/bootstrap.sh all             # contributor / full test-all setup

just setup                           # print setup choices
just setup base                      # base quick-start tier
just setup all                       # full contributor / test-all tier
just <platform> setup                # focused platform setup
just doctor                          # diagnose base tier
just doctor tier=all                 # diagnose full tier

just setup with no argument is a menu and does not fetch/install. NROS_SETUP_TIER still overrides the default no-argument just doctor tier. Valid tiers are base and all. Legacy aliases minimal and default map to base; extended and everything map to all.

Tiers

TierModulesUse case
basecli (in-tree CLI build → packages/cli/target/release/nros, Phase 218), workspace (incl. Corrosion → ~/.nros/sdk/corrosion/, ninja, make, rust pinned toolchains), zenohdFirst-time users who want native nano-ros examples and standard ROS/zenoh workflows without every RTOS SDK
platform-specificone module, e.g. zephyr, nuttx, esp_idf, px4Developers focused on one target platform
allworkspace, verification, zenohd, qemu, freertos, nuttx, threadx_{linux,riscv64}, esp32, zephyr, xrce, rmw_zenoh, orin_spe, cyclonedds, platformio, esp_idf, px4Contributors preparing for just build-test-fixtures and just test-all

cli (Phase 218 monorepo merge) is the first step of the base tier — just setup-cli runs cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros, producing the per-checkout packages/cli/target/release/nros. Every downstream just <module> setup recipe assumes this binary is present (it is the canonical provisioner). packages/cli/ is a sub-workspace (own Cargo.toml, own Cargo.lock) so its host-only deps (clap, askama, syn, ureq) stay outside the runtime no_std feature-unification view (Phase 214.F.3). Per-checkout, not ~/.nros/bin — contributors with multiple worktrees get tree-isolated CLIs with no global PATH skew. A source ./activate.sh / direnv allow puts the resulting binary on PATH.

Corrosion (the CMake ↔ Rust/Cargo bridge corrosion_import_crate() uses) is installed by just workspace install-corrosion to ~/.nros/sdk/corrosion/ whenever the workspace module runs — i.e. on every tier (base and all), since workspace is the first module in both branches of justfile::_orchestrate. The pin is in just/workspace.just::CORROSION_VERSION; the SDK-index counterpart [tool.corrosion] in nros-sdk-index.toml pins the same tag so nros setup --tool corrosion --prefix $HOME/.nros/sdk/corrosion lands an equivalent install. Header/config-only at install time, well under the 500 MB / 5 min default tier policy, idempotent (stamp-file gated). Phase 212.D + H.4 mixed Rust+C++ workspace tests (phase212_d_workspace_metadata::cmake_mixed_corrosion_bridge_builds) green-path here once the workspace module runs.

The launch parser is not a provisioned tool. It is linked into nros-launch-resolve, built from the packages/cli/third-party/play_launch submodule by just setup-launch-resolve (and shipped beside nros in a release). The standalone play_launch_parser store tool was retired by issue 1413: no build path ran it, and it carried a second, lagging pin.

all is intentionally explicit because it pulls many submodules and installs large SDKs. A module never moves between tiers without bumping this document and the orchestrator switch in justfile::_orchestrate.

Policy: when does a module join base?

A module joins base when all of:

  1. Expected by first-time users. It supports native examples, standard ROS/zenoh interop, or core workspace development.
  2. Moderate surprise. It does not fetch large platform SDKs such as PX4-Autopilot, ESP-IDF, Zephyr, or full RTOS trees.
  3. Idempotent on re-run. No destructive ops, no sudo, no network-only stages that flap.

A module joins all when it is needed by full-matrix development or just test-all, but is too heavy or platform-specific for the default quick start.

A module stays opt-in entirely (neither tier) when it pulls in private SDKs, requires sudo, or carries license-restricted bits. ARM FVP, NVIDIA SDK Manager, Cadence Tensilica toolchain, proprietary vendor BSPs all fall here. Contributors needing them run just <module> setup explicitly out-of-band.

CycloneDDS — self-provisioned in CMake (Phase 186)

nros-rmw-cyclonedds consumes Cyclone via find_package(CycloneDDS) and never compiled it itself. Phase 186 moved provisioning into the build system: the backend’s nros_provide_cyclonedds() resolves Cyclone in order —

  1. an already-defined CycloneDDS::ddsc target,
  2. find_package(CycloneDDS CONFIG) — a prebuilt install on CMAKE_PREFIX_PATH / CycloneDDS_DIR (a user install),
  3. self-provision from source — add_subdirectory(${CYCLONEDDS_SOURCE_DIR}) (defaults to the pinned third-party/dds/cyclonedds submodule; a user points it at their own checkout), with the per-platform flags staged in cmake/platform/nano-ros-<plat>.cmake and sccache as the compiler launcher.

So a bare cmake/cargo build self-provisions Cyclone with no just cyclonedds pre-step — freertos / threadx-rv64 / native all build it on demand (just <plat> build-fixtures), gated on the relevant cross toolchain. There is no longer a build/cyclonedds-<rtos>-install artifact (the old cross-probe scripts are deleted); the targets link a static ddsc (no runtime libddsc.so, so no rpath and no risk of ld.so substituting a mismatched system /opt/ros Cyclone).

Availability still follows the tier that ships the cross toolchains:

  • all tier (ships arm-none-eabi-gcc via freertos, RV64 GCC via threadx_riscv64): the embedded-Cyclone test-all cases build + pass.
  • base tier (no cross toolchains): the embedded-Cyclone tests are filtered out of test-all (gated on command -v <cross-cc>, Phase 185.2/ 186.4) so they report skipped, not failed. ThreadX-RV64 Cyclone fixtures stay behind the experimental NROS_THREADX_RV64_CYCLONEDDS_FIXTURES=1.

Host build/install (the cyclonedds setup module) is still built — for the backend standalone CI (just cyclonedds build-rmw/test/ci), the Zephyr-Cyclone host idlc, and the not-yet-migrated threadx-linux host-Cyclone path. Example builds no longer depend on it (Phase 186 “host build.sh” follow-up tracks full removal).

Run test-all in the all tier for full Cyclone coverage.

Adding a new module

When introducing a new RTOS / SDK module:

  1. Land just/<module>.just with setup (idempotent) and doctor (read-only) recipes.
  2. Add mod <module> 'just/<module>.just' to justfile.
  3. Decide whether it belongs in base, all, or platform-only setup.
  4. Add run <module> to the matching tier branch of _orchestrate in justfile.
  5. Update the table in §Tiers in this doc.
  6. Update AGENTS.md’s “Build, Test, and Development Commands” if the change is user-facing.

If the module is opt-in entirely, skip steps 3–5 and add a note to its own README explaining why.

Cold-start verification

For full-matrix cold start, run:

rm -rf build target*
scripts/bootstrap.sh all
source ./setup.bash
just build-test-fixtures
just test-all

For quick-start cold start, use scripts/bootstrap.sh without all.

Relation to Cargo features

Tiers describe what gets installed (host SDKs, toolchains, clones). Cargo features describe what gets compiled inside the nano-ros workspace. The two axes are orthogonal: a contributor may install tier=extended (every SDK) but build with --no-default-features (minimum nros feature set).

Relation to packaged releases (Phase 139.8)

Downstream consumers of a packaged nano-ros release (ESP-IDF component registry, PlatformIO library, Zephyr west.yml, NuttX-apps, PX4 external module) never invoke just setup. They pull a pinned release through their RTOS’s package manager and the host-SDK install is handled by the RTOS toolchain. Tier policy applies to local development of nano-ros itself, not to consumers. See registry-publishing.md.

nano-ros versioning

Model: JetPack-style bundle. One version number covers the whole project — runtime crates, the nros CLI, the C/C++ surfaces, the example trees, the board-glue cmake. A release bundles the artifacts behind that number; consumers either:

  • clone the repo at a nros-v<X.Y.Z> tag (runtime + examples + cmake),
  • AND/OR fetch the matching CLI binary from the GitHub release page (the Phase 218.G nros-<triple>.tar.gz assets).

The model is named after NVIDIA’s JetPack: a single SDK version (JetPack 5.1.2) covers CUDA + cuDNN + TensorRT + the L4T kernel + the rest — distributed as one bundle, not as N independently-versioned crates on PyPI.

Why not crates.io publish?

The runtime crates carry deep C/C++ deps via *-sys shims — zenoh-pico, Cyclone DDS, micro-XRCE-DDS, mbedtls, FreeRTOS/Zephyr/ NuttX/ThreadX RTOS surfaces. crates.io can’t carry those cleanly: build scripts assume a configured workspace with SDK paths exported (see .envrc / activate.sh), and the SDK source itself is gitignored

  • index-driven (nros-sdk-index.toml).

A user who tries cargo add nros-core from a third-party project gets a crate that won’t link in any meaningful environment. Better to ship the whole thing as a bundle, where the SDK provisioning, the codegen toolchain, the runtime, and the examples all move together.

No nano-ros crate is published to crates.io. Every workspace member should carry publish = false (defensive; prevents accidental publish on a stray cargo publish).

Consumers pin nano-ros runtime crates through their project’s [patch.crates-io] table, redirected to a local path (or to a git submodule of the tagged release). The Phase 212.M-F.21 nros sync verb writes those entries automatically.

Single workspace.package.version

Two Cargo.toml files carry a [workspace.package].version field:

FileRole
Cargo.tomlruntime workspace (nros-core, nros-c, nros-cpp, boards, RMW shims, …)
packages/cli/Cargo.tomlCLI sub-workspace (nros, nros-cli-core, nros-build, rosidl-*, codegen)

These two MUST stay in lockstep. scripts/check-version-lockstep.sh (run as just check version-lockstep, on the fast lane) errors if they diverge. just release-bump <X.Y.Z> updates both atomically.

Inside each workspace, every member crate inherits the version via version.workspace = true. New crates added to either workspace should follow this pattern.

Bumping the bundle

Three rules:

  1. Patch bump (0.4.0 → 0.4.1) — any non-ABI-breaking change to ANY subsystem (CLI bug fix, runtime perf fix, board-glue cleanup, docs-only changes that ship with release notes).
  2. Minor bump (0.4.x → 0.5.0) — any ABI-affecting change to nros-core, nros-c, nros-cpp, or any of the RMW vtable surfaces. The Phase 218.E ABI guard will reject pairings that straddle a minor boundary, forcing consumers to update.
  3. Major bump (0.x.y → 1.0.0) — declared stability commitment; reserved for the post-rclrs-0.7-parity moment.

Patches are cheap (no crates.io republish). Resist the temptation to batch unrelated changes into a single bump just to “save a version number” — the bundle model treats version numbers as low-cost labels.

Bump workflow

just release-bump 0.4.1                # sed both files + lockstep check
git diff Cargo.toml packages/cli/Cargo.toml
git commit -am 'release: nros-v0.4.1'
git tag nros-v0.4.1
git push origin main nros-v0.4.1

The tag publishes no artifacts — nano-ros is a source distribution (phase-288 D1/D2; the pre-288 release.yml prebuilt pipeline is deleted). A tag is the consumer’s pin: it fixes the runtime source, the CLI the tree builds (nros version lockstep), and the toolchain versions in the committed nros-sdk-index.toml. See the book’s Pinning a version section.

ABI guard (Phase 218.E)

nros dev codegen / nros dev codegen rust reads the consumer’s Cargo.lock, finds the resolved nros-core version, and compares it to the CLI binary’s compile-time env!("CARGO_PKG_VERSION"). Strict equality. Mismatch → exit non-zero with both versions printed.

NROS_SKIP_VERSION_CHECK=1 opts out (warned to stderr for CI visibility).

The strict comparison is the bundle model’s enforcement mechanism: if a consumer has nros-core 0.4.0 in their lockfile and runs the nros 0.5.0 binary, the codegen / runtime ABI assumption is broken and the build SHOULD fail loud. The maintainer’s job (via just release-bump) is to keep the CLI and the runtime moving as one unit so the guard stays quiet on the tracked tag.

When the guard fires within the nano-ros tree itself (during local development on a branch where the version hasn’t been bumped yet), the NROS_SKIP_VERSION_CHECK=1 env in your shell session is the documented relief valve.

Baseline

Bundle version 0.4.0 is the Phase 218 monorepo-merge baseline. It was chosen so that:

  • It signals a discontinuity from nros-v0.3.7 (the last standalone nros-cli tag — see the archived github.com/NEWSLabNTU/nros-cli history).
  • It doesn’t claim post-1.0 stability the project hasn’t yet established.
  • 0.x.y SemVer convention (everything pre-1.0 is “API may change”) matches the current development pace.
  • Phase 218 doc: docs/roadmap/phase-218-merge-cli-into-monorepo.md
  • Phase 218 design spec: docs/superpowers/specs/2026-06-04-cli-monorepo-merge-design.md
  • ABI guard impl: packages/cli/nros-cli-core/src/abi_guard.rs
  • Lockstep check: scripts/check-version-lockstep.sh
  • Bump recipe: just release-bump in the root justfile
  • (The pre-288 release.yml prebuilt workflow is deleted — tags publish no artifacts.)

CI conventions for nano-ros

A new GitHub Actions workflow should work on its first push. The Phase 196 bring-up of zephyr-dual-line.yml cost seven stacked CI rounds because each workflow re-discovered the same fresh-runner gaps (no submodules, no ROS, no Python 3.12, wrong runner OS). This page is the checklist + copy-paste snippets that close that gap class. Pair it with the live workflows in .github/workflows/ — they are the worked examples.

Some workflow names on this page are HISTORICAL. The fresh-runner discipline below is current and still load-bearing; the files it was learned from were consolidated by the phase-253 CI reorg and again by phase-395. Every such name is listed in the “Historical workflow names” table at the foot of this page, with what replaced it. A name NOT in that table must name a file that exists — check-ci-doc-workflow-refs enforces exactly that, because a doc citing a workflow nobody can open sends a reader looking for a file rather than for the rule it was illustrating.

The mental model: the runner is a fresh clone, nothing else

A GitHub runner has the repo at the recorded commit and the runner image’s default tools. It has no submodules, no ROS, no SDKs, and an OS-pinned Python. Every assumption your laptop satisfies silently is a CI failure. Walk the job as if you just cloned into an empty container.

Conventions

1. Init only the submodules you need — never recursive-all

actions/checkout@v4 initializes no submodules. Init the specific paths the job touches; never submodules: recursive on checkout (the platform/RTOS submodules are large and fork-pinned — a full recursive init is slow and pulls trees the job will never read).

- uses: actions/checkout@v4
- name: Init needed submodules
  run: git submodule update --init --recursive packages/codegen
  # add others a job actually builds, e.g.:
  #   third-party/dds/cyclonedds packages/rmw/zenoh/zpico-sys/zenoh-pico

The rule — build the in-tree nros, then provision via it; never hand git submodule update. A user builds the nros CLI from source (scripts/bootstrap.sh — nano-ros is a source distribution, phase-288 D1/D2) and runs nros setup to provision sources from nros-sdk-index.toml — they don’t git submodule update their libc / zenoh-pico / cyclonedds fork. CI simulates exactly that. There is no hand-init step:

- name: Build the nros CLI (source, per-checkout)
  run: |
    git submodule update --init packages/cli/third-party/play_launch
    cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros
    echo "$PWD/packages/cli/target/release" >> "$GITHUB_PATH"
# board's whole toolchain + source set:
- run: nros setup <board> --rmw <rmw>
# or a specific source (submodule) by name:
- run: nros setup --source <name> [--source <name>…]

The build’s own interface codegen resolves this same per-checkout nros (via $NROS_CLI / PATH / scripts/build/cargo.sh::nros_cli_bin) as its codegen tool. Caching the built binary is safe (see the source-cache caveat below); caching nros setup --source dirs is NOT (phase-253).

If a build needs a source the index doesn’t provision, that is an index/nros bug to fix (add the [source.*] entry / teach nros), never a git submodule line in the workflow. Examples: dep-chain.yml (per-board), ci.yml (px4-rs via --source), zephyr-dual-line.yml (zenoh-pico + cyclonedds-src + px4-rs via --source).

2. ROS 2 for the interface codegen

nros dev codegen rust / nros dev codegen resolve a message package’s msg/*.msg via AMENT_PREFIX_PATH from a sourced ROS 2. Provide it and source before any build that codegens interfaces:

- name: Install ROS 2 Humble
  uses: ros-tooling/setup-ros@v0.7
  with:
    required-ros-distributions: humble
# ... then in the build step:
- run: source /opt/ros/humble/setup.bash && <build command>

3. Runner OS follows the ROS distro

ros-tooling/setup-ros keys off the runner OS. Humble’s baseline is jammy, so any job that sources ROS 2 Humble must runs-on: ubuntu-22.04 (not the floating ubuntu-latest, which has moved to noble). Pure-lint jobs that need no ROS can stay on ubuntu-latest.

4. Python 3.12 via uv for the Zephyr line

Zephyr 4.4 needs Python ≥3.12; scripts/zephyr/provision-py312-venv.sh requires uv with no fallback. Add it before the Zephyr setup:

- uses: astral-sh/setup-uv@v5

5. Build the in-tree nros CLI — there is no released binary

nano-ros is a source distribution (phase-288 D1/D2): the CLI is built per-checkout from packages/cli/ (one checkout = one CLI = one runtime ABI). Every lane that needs nros runs the same cargo build --release --manifest-path packages/cli/Cargo.toml --bin nros (see §1). The pre-288 prebuilt route (NEWSLabNTU/nros-cli Releases + install.sh, Phase 195) is retired.

6. Canonical codegen invocation: nros dev codegen --args-file

The low-level codegen entrypoint is the codegen subcommand (nros dev codegen --args-file …); the old top-level nros --args-file … was removed in Phase 195. Build glue (*.cmake, *.just, *.sh) that drops the subcommand silently breaks interface generation. The codegen-convention.yml lint (scripts/ci/codegen-invocation-check.sh) enforces this — don’t reintroduce the bare form.

7. Concurrency: cancel-in-progress per ref

Push-storms (a maintainer landing several commits) otherwise queue redundant runs. Cancel the in-flight run for the same ref:

concurrency:
  group: <workflow-name>-${{ github.ref }}
  cancel-in-progress: true

Note the side effect: rapid pushes show cancelled runs for the superseded commits — that is the dedup working, not a failure. Judge a commit by the run on its own SHA.

8. Path-filter triggers to what the workflow proves

Scope on.push/on.pull_request paths: to the inputs the job actually validates, plus the workflow file itself. Keeps the matrix off unrelated commits. Always include workflow_dispatch: so a workflow can be re-run by hand.

Cost discipline

  • Validate the dep chain, don’t rebuild the world. Full per-platform builds are expensive; dep-chain.yml proves every (board, rmw) cell resolves (nros setup + codegen + cargo tree, no compile) in one cheap job. Reserve full builds for the sparse lanes (zephyr-dual-line, just build-all).
  • Cache the heavy SDKs. A job that re-installs the ~1 GB Zephyr SDK and west-updates from scratch every run should cache scripts/zephyr/sdk + the workspace (Phase 196.3 follow-up).

Preconditions must fail loud

Mirroring the repo-wide test rule (CLAUDE.md): a CI step whose precondition is unmet must exit non-zero, never warn-and-pass. scripts/ci/* check AMENT_PREFIX_PATH, the nros binary, etc. up front and exit 1 with a fix hint. A green check must mean the thing was actually validated.

Split CI into a core lane + per-platform lanes

CI is split so each workflow provisions only what it validates — keeping per-workflow minutes low and failures isolated to one platform:

  • Core-libraries lane (ci.yml, job core-libs) — the portable no_std core crates cross-checked on bare embedded targets. No SDKs, no submodules; the only setup is a rustup target add. Split by target (one job per target, parallel), each a single cargo check over the crates compatible with it (e.g. nros-rmw-cffi needs atomic CAS, so it’s checked on thumbv7m but not riscv32imc).
  • Cross-platform resolution lane (dep-chain.yml) — proves every (board, rmw) dep chain resolves via one cheap job (nros setup per board pulls only that board’s tools; ROS installed once). No compiles.
  • Per-platform build lanes — one workflow per platform, each pulling only that platform’s SDK + submodules (zephyr-dual-line.yml is the template). The heavy lanes; add a new one per platform rather than fattening a shared job.

The worked examples

WorkflowWhat it shows
gate.ymlthe fast gate: path-filtered jobs, container image + credentials, build-CLI-from-source, nros setup --source provisioning
host-tests.ymlROS on the runner, and installing a package rather than assuming it
nightly.ymlthe broad matrix, and lanes too expensive to gate a merge
queue.ymlmerge_group, cancel-in-progress: false, and a self-hosted job interlocked on a repo variable
post-submit.ymlpush-to-main scope, and why cancel-in-progress is right here and wrong in the queue
docs.ymla cheap lane with no toolchain at all

Historical workflow names

These files no longer exist. They are named above and in the prose because the lessons were learned from them; do not go looking for them.

HistoricalReplaced by
ci.ymlgate.yml (fast gate) + nightly.yml (matrix) — phase-253
dep-chain.ymlnightly.yml; the check itself is scripts/ci/dep-chain-check.sh
zephyr-dual-line.ymlnightly.yml
codegen-convention.ymlgate.yml; the lint is scripts/ci/codegen-invocation-check.sh
sdk-index-gate.ymlgate.yml, job sdk-index
host-integration-tests.ymlhost-tests.yml
build-wide.ymlqueue.yml’s self-hosted L3 job (just ci matrix build per merge group) — deleted 2026-10-03, dispatch-only since phase-413 W1

ROS 2 on a non-Ubuntu host (Arch, Fedora, NixOS …)

nano-ros itself needs no ROS 2 (see installation.md): setup, nros sync, codegen and the first-node flows all work without it. ROS 2 is required only for the interop side — ros2 CLI verification, the rmw_zenoh_cpp / cyclone / fastrtps interop cells, and the ROS-bridging lanes of just test-all.

The problem: ROS 2 ships apt packages for one Ubuntu LTS per edition (humble → 22.04, jazzy → 24.04) and nothing else. On Arch the AUR ros2-humble package source-builds the whole tree against the rolling python/boost/OpenCV and breaks whenever those move — not worth the time. The repo also hardcodes /opt/ros/<distro>/setup.bash in activate.sh and in packages/testing/nros-tests/src/ros2.rs, so a relocated prefix (RoboStack, nix-ros-overlay) does not drop in either.

The route that works: an Ubuntu distrobox sharing your home and network. /opt/ros/humble exists inside it, the checkout keeps ONE absolute path, and every documented lane runs unchanged.

Setup

sudo pacman -S --needed distrobox        # or dnf/apt equivalent

# podman is not required if you already use docker:
DBX_CONTAINER_MANAGER=docker distrobox create -n ros2 -i ubuntu:22.04 \
    --volume /path/to/your/checkout/parent:/path/to/your/checkout/parent -Y

# ROS 2 Humble + the packages the interop lanes use:
distrobox enter ros2 -- bash scripts/dev/ros2-distrobox-setup.sh

Mount the checkout at the same absolute path it has on the host. Different paths on the two sides is the issue-0375 hazard: nros sync writes absolute paths, and every path-keyed cache then splits in two.

ros-humble-desktop is ~2 GB of apt; the script also installs the book’s host prerequisites, rmw_cyclonedds_cpp, rmw_fastrtps_cpp, rmw_zenoh_cpp, domain_bridge, example_interfaces, rosidl_adapter, colcon and rosdep, then verifies each.

rmw_zenoh_cpp is what the zenoh e2e lanes run as their router (phase-362 W1). Without it they report [SKIPPED:capability], which reads as green — three issues were closed “unverifiable” that way on 2026-08-18. just doctor reports which router it can see.

Where the box’s storage lands — check / before you create it

ros-humble-desktop plus the three RMWs is roughly 3–5 GB of image layers and apt content, and BOTH docker and rootless podman keep that under / by default (/var/lib/docker, ~/.local/share/containers). On a workstation whose root filesystem is nearly full, the documented distrobox create above will either fail partway through the apt step or leave the system with no headroom.

Measured on the maintainer host, 2026-08-18: / at 97 % (9.1 GB free) while /mnt/wd — the disk holding the checkout — had 218 GB. Three ways out, in the order worth trying:

  1. Put rootless podman’s storage on the big disk. A user-level ~/.config/containers/storage.conf with graphroot pointing at a directory on that disk; no sudo, reversible by deleting the file, and / is untouched. Create the box with the default manager afterwards (drop the DBX_CONTAINER_MANAGER=docker prefix).

  2. Use docker exactly as documented and accept the space on /. Simplest and matches the Setup section verbatim — fine when / has ≥ 15 GB free.

  3. Install ros-humble-ros-base instead of desktop, keeping rmw-{zenoh,cyclonedds,fastrtps}-cpp. Roughly half the size, and a divergence from scripts/dev/ros2-distrobox-setup.sh — so when a lane later wants a package desktop would have pulled, that divergence is what you will be chasing. Prefer 1 or 2 unless disk is genuinely scarce.

Whichever you pick, the checkout still has to be mounted at the SAME absolute path it has on the host (issue 0375) — see “Two paths to the same checkout”.

What a host without the box gives up

Not a nicety. On a host with no ROS install, just ci on 2026-08-18 reported 176 [SKIPPED:capability] tests — the whole zenoh-router-dependent surface, including every linux_api interop case — and they are counted as skips only because test-all rewrites them. The check-required-features-tests lane (issue 0652) runs a BARE cargo nextest, where the same skips are FAILURES, so tier 1 cannot go green on such a host at all. The box is what turns that surface from “unverified, quietly” into coverage.

Build where you run — clone INSIDE the box (issue 1248)

The box is an ordinary Linux that happens to have ROS. Treat it as one:

distrobox enter ros2
git clone https://github.com/NEWSLabNTU/nano-ros      # NOT --recurse-submodules
cd nano-ros
. scripts/dev/ros2-box-env.sh      # gives this toolchain its own store
just setup-cli && just ci gate

Never --recurse-submodules, here or anywhere (RFC-0097 D10). The submodules are large and most are irrelevant to any one task, and a recursive clone also drags in play_launch’s layer-3 runtime submodules, which nano-ros never builds. scripts/bootstrap.sh initialises the one submodule the CLI build needs (packages/cli/third-party/play_launch), scoped — run it once if you use just setup-cli, which builds but does not init. Everything else comes from nros setup --source <name>, which fetches shallow (--depth 1) rather than deepening to reach a pin.

Never build on the host and run in the box, and never mirror one tree into the other. Host and box differ in compiler and libc, nothing checks that shared artifacts agree, and the failures are loader errors naming a symbol version — which read like code bugs (issues 0400, 0401, 0759).

scripts/dev/ros2-box-sync.sh used to rsync the host tree into a <checkout>-box sibling, and it is retired. Its rules excluded build output by directory NAME, the tree has tracked SOURCE with those names, and the two cannot be told apart by name because provisioned source and build output share directories. That collision ate tracked files five times — packages/cli/ build-support/ (the box could not compile nros at all), Zephyr’s drivers/i2c/target/ (could not configure any Zephyr image), then #1229, #0925 and #1243. Every one of them was a bug in the COPY, not in nano-ros. A git clone has none of them.

The one thing the box still needs from us

A distrobox shares $HOME with the host by design, so ~/.nros and ~/.cargo are the same directory in both — two toolchains, one store. That is not a box concept; it is the general rule a store belongs to one toolchain meeting an environment where a different machine does not imply a different $HOME. ros2-box-env.sh sets NROS_HOME and CARGO_INSTALL_ROOT for the inner toolchain and does nothing else.

The build system asks exactly one question about an environment: are ROS 2 ament packages discoverable here, so message packages can be found? Whether this Linux is bare metal, a container or a VM is the operator’s business and is not encoded anywhere.

Known rough edges

  • zstd is not in a stock Ubuntu 22.04, and the prebuilt dists are .tar.zst, so the first prebuilt install fails with tar (child): zstd: Cannot exec. sudo apt-get install zstd inside the box. → issue 0385
  • rmw_zenoh_cpp is an apt package on humble+ (ros-<distro>-rmw-zenoh-cpp; this note used to say humble had none). Install it like the others — there is no source overlay to build any more (RFC-0075, amended 2026-08-19).
  • If the box is in play, EVERY job runs in the box — against a tree cloned there. Not a style preference: the two sides have different compilers and a different libc, and nothing in the build system checks that the toolchains agree. Do not half-share — a host-side just setup-cli or a host-side fixture build reaches into the same directories. Issue 0759 refused sharing one checkout; issue 1248 retired the rsync mirror that was the other way to get a second tree, because git clone does the same thing without a rule set that cannot distinguish source from build output by name.
  • A box created BEFORE that note has no router, and nothing says so loudly. The setup script installs rmw_zenoh_cpp today; a box built earlier does not gain it, and the only symptom is cells reporting [SKIPPED] zenohd failed to start … no rmw_zenoh_cpp/rmw_zenohd found — a skip, so the run stays green (2026-08-22: every router-needing sched cell skipped here, and a measurement script that keys on its cell’s own output line read those skips as FAILURES, which is the opposite misread and the reason it was noticed at all). Check with just doctor, fix with sudo apt-get install ros-<distro>-rmw-zenoh-cpp inside the box.
  • The vendored make and nros-launch-resolve are host binaries in a shared tree, exactly like the CLI. The pinned make used to link the build host’s guile and died here as libguile-3.0.so.1: cannot open shared object file (fixed: the index’s source recipe configures --without-guile, so one binary serves both sides — rebuild it once in the box with nros setup --tool make). nros-launch-resolve is per-environment by design (issue 0409): a stale one fails nros sync with a TOML unknown field error naming a field the current schema does have, which reads as a broken system.toml rather than a stale binary. just setup-launch-resolve in the box.
  • ROS’s setup.bash dies under set -u (AMENT_TRACE_SETUP_FILES: unbound variable). Any script sourcing it needs set +u around the source.
  • The stale-CLI guard fires after any pull that touched CLI sources (in-tree nros CLI is STALE — its sources changed since it was built). Rebuild it in the box and re-run nros_box_publish.
  • source in a pipeline runs in a subshell — source env.sh | tail leaves your environment untouched and looks exactly like a broken activation.

Verified on this route

just test-unit in the box: 817 tests, 816 passed, 2 skipped, plus one [SKIPPED] second session refused — shim built with ZPICO_MAX_SESSIONS=1 reported as a failure by the bare-nextest quirk above. No box-specific failures. (Since issue 1704 that test is compiled #[ignore] in a single-session build, so it counts among the skipped rather than the failed.)

Arch host (glibc 2.44) + Ubuntu 22.04.5 box (glibc 2.35), 2026-08-01: nros setup for zenoh and cyclonedds, nros sync, a cyclone-backed examples/linux/rust/talker build, and ros2 topic echo /chatter receiving its messages over rmw_cyclonedds_cpp with ros2 topic list showing /chatter.

Build System & Caching

Audience. Contributors working on the repo build/test matrix. End users never need this — they consume nano-ros with a single add_subdirectory(<repo-root>), no install step, no find_package. This handbook documents the other build world: the in-repo just orchestration that builds + tests every platform × RMW, and the caching that keeps it incremental and correct.

Canonical sources this page summarises: CLAUDE.md (“Build”, “Build tiers”, “Build parallelism”), Phase 176 (unified jobserver), Phase 181 (fixture SSOT

  • Ninja), Phase 177.9 (staleness probes).

TL;DR cheat-sheet

just setup                 # one-time SDK/toolchain install (tiered; see CLAUDE.md)
direnv allow               # once after clone — else zpico-sys/build.rs panics

just build                 # workspace + transports (fast inner loop)
just build-examples        # + every example
just build-all             # + test fixtures (everything test-all consumes)
just build-test-fixtures   # just the fixtures + writes the test-all stamp
just build-test-fixtures lane=linux   # only what the tier-1 lane (`just ci`) runs

just test-unit             # ~5s          ⊂
just test-integration      # ~30s         ⊂
just test                  #              ⊂
just test-all              # heavy QEMU/Zephyr/ROS-interop/miri/codegen

just <plat> build|build-all|test|ci   # narrow to one platform first
NROS_BUILD_JOBS=8 just build-all       # cap parallelism

Rule of thumb: a platform-specific failure → run the narrow just <plat> build-all (or closest build/build-examples/build-fixtures) before the root just build-all. Always just ci after a task. Never sudo — if a step needs it, tell the user.

Generators — Ninja by default

The repo’s CMake builds use Ninja (-G Ninja), falling back to Make only when ninja is absent. Ninja gives reliable incremental rebuilds and fits the Phase 176 fifo jobserver; Make was dropped from the staleness path because make -q mis-reported up-to-dateness.

  • Configure-once: scripts/build/cmake-incremental.sh::nros_cmake_configure_if_needed configures a build dir only when its CMakeCache.txt / generated build system is missing; cmake --build then handles reconfigure on CMakeLists changes.
  • Generator-mismatch wipe: a dir configured with the other generator is rm -rf’d and reconfigured (you can’t switch generators in place).
  • Pinned tools: the unified jobserver needs make ≥ 4.4 + ninja ≥ 1.13 (apt’s 4.3/1.10 lack the fifo jobserver). Both are SDK-store tools — nros setup --tool make (source tarball; upstream ships no binary) and nros setup --tool ninja (upstream prebuilt zip) — and scripts/sdk-path-tools.txt puts them on PATH (incl. a gmake → make-4.4 alias). Resolve an exact path with nros dev sdk path <tool>.

Per-RMW build dirs = cache isolation

Each example builds into a per-RMW directory with its own cargo target dir:

examples/<plat>/<lang>/<example>/
  build-zenoh/       cargo/   …   # -DNROS_RMW=zenoh
  build-xrce/        cargo/   …   # -DNROS_RMW=xrce
  build-cyclonedds/  cargo/   …   # -DNROS_RMW=cyclonedds

Because each RMW (and its Corrosion cargo target dir) is physically separate, selecting a different RMW cannot collide with another RMW’s cache — there is no shared target dir to invalidate. Platform is fixed per build dir (-DNROS_PLATFORM=<plat> at configure). This layout is why the old manual cache-invalidation idea (archived Phase 145) was retired: the directory shape makes it unnecessary. (Rust-only examples build via plain cargo into target-<rmw>/; the per-RMW principle is the same.)

Parallelism — NROS_BUILD_JOBS + the unified jobserver

One knob scales every parallel recipe:

NROS_BUILD_JOBS=8 just build-all     # default: nproc

Unified jobserver (Phase 176). just build-all auto-routes to build-all-jobserver when the pinned make 4.4 + ninja 1.13 are present: a single GNU-make fifo jobserver spans cargo + build-script cc + ninja-via-west

  • cmake, allocating tokens dynamically instead of a static per-tool split. Under the jobserver (NROS_JOBSERVER=1) recipes drop their explicit -j/--parallel/CMAKE_BUILD_PARALLEL_LEVEL so the tools inherit the pool.
  • Same artifacts either way; without the pinned tools it falls back to a static split.
  • NROS_NO_JOBSERVER=1 forces the static path.
  • Never re-introduce a hardcoded parallel --jobs <n> without threading ${NROS_BUILD_JOBS:-N} through.

Stall watchdog (issue 1403). Every fixture/jobserver make (the fixture make drivers, fixtures-build.sh, workspace-fixtures-build.sh, the jobserver-pool, build-all-jobserver) runs under scripts/build/make-stall-watchdog.py. If make sits with no live child and no CPU for NROS_JOBSERVER_STALL_SECS (default 600; 0 disables; sampled every NROS_JOBSERVER_STALL_POLL_SECS, default 15), the watchdog writes jobserver-stall-<pid>-<time>.txt into that build’s log dir (make’s wchan/syscall/status or stack, its fds, the jobserver FIFO and its FIONREAD token count, every other process holding a jobserver, a gdb backtrace when ptrace allows), kills make and its descendants — by pid, nothing else — and exits 75 with NO VERDICT: jobserver stall — <file>. That is a lane that never answered, not a build failure; just nightly-triage counts it as NO VERDICT. Attach the file to issue 1403. A normal build is untouched: same exit status, no output.

See docs/roadmap/archived/phase-176-unified-jobserver-build-orchestration.md.

Build & test tiers

Each tier is a strict superset of the previous:

Buildcontains
buildworkspace + transports
build-examples⊃ build + every example
build-all⊃ build-examples + test fixtures
Testwall-clockcontains
test-unit~5 sunit
test-integration~30 s⊃ + integration
test—⊃
test-allminutes⊃ + heavy QEMU/Zephyr/ROS-interop + doc + miri + C codegen

Per-platform: just <plat> build ⊂ build-examples ⊂ build-fixtures ⊂ build-all and just <plat> test|test-all|ci. <plat> = target families (qemu, zephyr, board groups). Support services (zenohd, cyclonedds) are not platform scopes. Orchestration lives in justfile + just/*.just.

Fixture SSOT — examples/fixtures.toml

Per-fixture build options (features, --target-dir, env, per-RMW variants, cmake -D defs, cross target) live in one manifest, examples/fixtures.toml, consumed by both the build recipes and the test-all staleness probe — so the build and the probe use identical options (no feature-thrash).

  • Reader: scripts/build/fixtures-manifest.py list --platform <p> --lang <l> [--rmw <r>] [--for-probe]. Emits \x1f-separated records (unit-separator, not tab — tab is IFS-whitespace).
  • Builder: scripts/build/fixtures-build.sh <plat> [lang] [rmw] — one shared loop; rust cells go through cargo build, C/C++ cells through cmake --build (configure-once + per-RMW dir). Platform-wide -D defs (toolchain, codegen tool, SDK dirs) are injected by the recipe via NROS_CMAKE_EXTRA_DEFS.
  • Cross-toolchain platforms inject RUSTUP_TOOLCHAIN / SDK env via the recipe (e.g. ESP32-C3 = riscv32imc on the workspace nightly + build-std).

Staleness discipline

A prebuilt fixture that’s silently stale would let test-all run against the wrong binary. The discipline:

  • Coverage gate. build-test-fixtures stamps target/nextest/.fixtures-built with the lane and the fixture coordinates it built, not just a timestamp; test-all’s _require-fixtures fast-fails (~1 s) if the stamp is absent or does not cover the run implied by NROS_FIXTURE_LANE (bypass: NROS_SKIP_FIXTURE_CHECK=1). So a lane=linux build satisfies just ci (the tier-1 lane) and is correctly rejected by an unscoped test-all. A pre-issue-0393 timestamp-only stamp is read as lane=all, which is what it meant.

    Note “the run”, not “the lane” (issue 0482). A lane’s coordinates are what it keeps fresh; what must exist is decided by which tests the recipe executes. The two are answered separately, and nros_lane_build_lane is the mapping between them (CiLane::run_scope is the declaration; lane-coords <lane> --build-lane prints it).

    Every lane narrows its run by fixture COORDINATE (NROS_TEST_COORDS), and each is its own build lane. The tier-1 lane used to narrow by test/binary NAME (NROS_TEST_SCOPE=linux); phase-395 W19 moved it to coordinates, and issue 1900 retired the name filter’s reader, which no runner had set since. Each test asks the lane BEFORE it probes the host, so a test of a platform the lane did not select reports [SKIPPED:lane] on every host; scripts/test/check-out-of-lane-deselected.py proves it on the just ci gate lane.

    A name filter could never have expressed the tier-2 lane anyway: it is 1-wise over platform, every platform is in the lane, so a platform-token filter excludes nothing, and the saving is in lang × rmw within a platform, which test names do not encode (issues 0357, 0482). So the narrowing happens in the fixture resolver (phase-340 W3, nros_tests::fixtures::lane): a resolved artifact is attributed back to its examples/fixtures.toml row through row_artifact_root(), and a row outside the lane reports SKIPPED instead of missing. Build and run then apply the same row_coord() predicate to the same coordinate file, so just build-test-fixtures lane=tier2 && just ci-matrix is a coherent pair. Before W3 the tier-2 lane ran the whole suite, and a lane=tier2 build satisfied the preflight for a run that then failed STALE on 34 unbuilt coordinates.

    The skip is keyed on the coordinate, never on the artifact being absent: an in-lane fixture that is missing or stale still fails hard, and a path the manifest cannot attribute (Zephyr west leaves, the compile-check lane — both built module-level, so nothing is omitted) is never skipped.

    The preflight enforces the pairing rather than trusting it: accepting a build narrower than all requires NROS_TEST_COORDS to be set and to hold exactly that lane’s coordinates, so a hand-run NROS_FIXTURE_LANE=tier2 just test-all is refused instead of reproducing issue 0482. A full build needs no narrowing, so NROS_FIXTURE_LANE=tier2 on top of lane=all still works — scope the freshness gate, keep the run wide.

  • Rust cells. scripts/test/rust-fixture-stale.sh reuses cargo’s own fingerprint — cargo build … --message-format=json reports "fresh":false for a stale unit, so the probe both detects and self-heals.

  • C/C++ cells. scripts/test/cmake-fixture-stale.sh runs the incremental cmake --build (near-no-op when fresh) and flags cells that actually rebuilt. (ninja -n is unusable here — Corrosion’s cargo step is an always-run custom command, so it always reports pending.)

  • Probe opt-out. Cells needing a recipe-injected toolchain the probe can’t supply (e.g. the ESP32 nightly + build-std) set skip_probe = true; the reader’s --for-probe omits them so they don’t toolchain-thrash.

  • Source-list / drift gates. zpico-sys (vendored zenoh-pico, Phase 136.6) and nros-rmw-xrce-cffi (vendored uxr/micro-cdr, Phase 145.4) verify each vendored source root resolves to a real dir with .c files and panic with a git submodule update --init hint on drift. cbindgen build scripts (nros-c, nros-cpp, zpico-sys) emit cargo:rerun-if-changed=cbindgen.toml

    • src/.
  • Probe runs in preflight. _check-fixtures-stale runs both probes (rust + cmake) over the manifest before the test-all nextest stage; it warns + self-heals rather than hard-failing.

Patched QEMU

QEMU networked tests use a patched qemu-system-arm (icount + MPS2 fixes) built by just qemu setup-qemu into build/qemu/bin/. The harness picks it up automatically (nros_tests::qemu::qemu_system_arm_cmd()); the system binary is the fallback. See Patched qemu-system-arm.

Cyclone DDS in the tree

nros_provide_cyclonedds() self-provisions Cyclone from the pinned third-party/dds/cyclonedds submodule when no prebuilt install is given, so a bare cmake/cargo build needs no pre-step. just cyclonedds is a contributor-only recipe that builds it ahead of time. On a tier without the cross toolchain, the embedded-Cyclone tests are filtered out of test-all (skipped, not failed). Details: book-internals/src/dev-env/sdk-tiers.md § “CycloneDDS — self-provisioned in CMake”.

See also

  • Build Commands — the user-facing quick reference.
  • zpico-sys Build Architecture — the zenoh-pico cross-compile.
  • CLAUDE.md — the authoritative build/test-tier + parallelism policy.
  • Phase docs (docs/roadmap/archived/): 176 (jobserver), 181 (fixture SSOT + Ninja), 177.9 (staleness probes).

Codegen — the pack pipeline

nano-ros generates ROS 2 message/service/action types (and their per-package scaffolding) from .msg/.srv/.action files. The generator is a four-stage pipeline (RFC-0068): parse → resolve → lower → render. This page covers the last stage — render — and how to change or add a target language.

Render = a data pack + a runtime template

Every backend renders through one minijinja environment (packages/cli/rosidl-codegen/src/render.rs) over data packs under packages/cli/rosidl-codegen/packs/:

packoutput
packs/c/C headers + sources
packs/rmw/RRR-compatible Rust message layer
packs/rust/idiomatic (rclrs-style) Rust
packs/nros/embedded (no_std) Rust
packs/cpp/C++ headers + the Rust FFI glue
packs/scaffold/per-package Cargo.toml / lib.rs / build.rs
packs/shared/partials more than one pack includes (the codegen-version stamp)

Each pack directory carries a pack.toml that declares it: its language, its registry_order, its templates as { key, file } rows, and — for a pack that names C-family artifacts — the header_extension / guard_suffix / source_extension that generator::naming derives every artifact name from. build.rs reads every manifest and generates the template registry and the Surface enum, so a pack directory is a pack because it exists and describes itself (phase-469 W1). Nothing in render.rs names a template.

A pack is just .jinja templates. The Rust side hands each template a serde-serialized view struct (the render context) and never spells a type itself — the type strings are composed in the pack by registered filters:

  • c_type / c_array_suffix, cpp_type / cpp_array_suffix, cpp_repr_c_type / cpp_view_repr_type
  • rust_type_rmw / rust_type_idiomatic, rclc_type
  • snake_case

The view struct carries only neutral facts (the parsed field_type, resolved capacity, storage mode, current_package, …); the filter maps those to the language’s syntax. This is RFC-0068’s “what vs how” seam: what a type is lives in the IR; how a language spells it lives in the pack + its filter.

Changing a template

Edit the .jinja under packs/<lang>/ and rebuild the CLI (just setup-cli). The codegen fingerprint (RFC-0061) hashes every bundled pack’s content, so any template edit marks the affected fixtures stale — no separate bookkeeping.

Overriding a pack at runtime — no rebuild

Point the renderer at an external directory of .jinja files: a file named <template-name> or <template-name>.jinja there overrides the bundled pack of that name; anything absent falls back to bundled.

export NROS_TEMPLATE_DIR=/path/to/my/pack
nros dev codegen rust …          # uses the override, no recompile

(Equivalently, rosidl_codegen::render::set_template_dir(dir) from Rust, called once before the first render.)

The stable template names are the templates keys in each packs/<dir>/pack.toml (e.g. message.h, message_nros.rs, cargo.toml, _field.jinja); nros’s own copy of the generated registry is at target/**/build/rosidl-codegen-*/out/ pack_registry.rs. tests/external_pack_smoke.rs proves the override + fallback.

Do not set NROS_TEMPLATE_DIR during fixture or CI builds. The fingerprint hashes the bundled packs; an external override would silently produce output that disagrees with the recorded fingerprint.

Adding a language

Step 0 — decide which SURFACES you need

This is the step that sizes the work, and skipping it is why “add a language” sounds bigger than it is. A pack is a (language × surface) pair, not a language: Rust has four message packs, and the cpp pack emits Rust as well as C++.

There are two independent axes. On the message side: idiomatic, embedded-idiomatic (no_std), FFI/ABI (repr(C)), bridge glue, packaging. On the entry side: the entry TU, and the component seam. A language can take an entry surface with no message surface (it consumes another language’s messages) or the reverse.

RFC-0091 §6 has the full table, including what each surface drags in — an FFI surface inherits the cross-language memory-agreement gate, a bridge surface is two spellings of one type that must move together. A language can ship with two surfaces and gain the rest later: a Zig component installed by a C or C++ entry needs the idiomatic and FFI message surfaces and the component seam, and nothing else.

Step 1 — the message pack

  1. add its .jinja templates (a new packs/<lang>/) and a pack.toml declaring them. No Rust edit: the registry is discovered from the manifest, and a pack directory with no manifest — or a .jinja no manifest claims — fails the build naming the file. If the pack names C-family artifacts, its three naming fields make it a generator::naming surface and the Surface variant is generated for it;
  2. if it needs type spelling the existing filters don’t cover, add a filter set for the language (rosidl_codegen::filters::FILTER_SETS) wrapping a *_spelling function in types.rs. The filter set is all the Rust that type SPELLING takes; the set is keyed by the pack that CALLS the filter, not by the syntax it emits — cpp_repr_c_type returns Rust;
  3. add a generator per kind (generator/{msg,srv,action}.rs, or one file like generator/cpp.rs) that builds the context, names the output files and calls render::render("<template-name>", &ctx), and its arm in nros interface generate (cmd/generate.rs).

Step 2 — the entry pack, if the language writes entries

An entry pack is DATA. Since phase-494 there is no per-language entry emitter: one renderer (codegen/entry/emit/mod.rs) lowers the plan once into nros_entry_lower::LoweredEntry and hands THAT to the pack’s template.

  1. packs/entry/<surface>/entry.<ext>.jinja over LoweredEntry. Nodes (with their component kind, resolved name and namespace, params, remaps, QoS), board family and boot shape, the C-ABI runner names, the executor layout (one executor, sched contexts, or per-tier setups), tier rows, the boot blob and the monitor tables all arrive computed and RAW — a template decides where a value goes and spells it, never how to quote it;
  2. packs/entry/<surface>/pack.toml — the extension, whether the TU is C-family, the entry template, its templates = [{ key, file }] rows, and what the renderer must know: context = "lowered-entry", the filters its templates call, the component kinds it constructs (components), and whether it calls the board’s C-ABI runners (c_abi_runners). CMake reads it through nros dev codegen entry-pack. The build discovers the directory — there is no registry row to add;
  3. one variant on Language in nros-lang. Every consumer sees it — one enumeration;
  4. only if the language needs a spelling no existing filter provides (the set is c_str, pkg_ident, cpp_board_class): one function and one row in codegen/entry/filters.rs. A spelling is a correctness property, so it is Rust; everything else about the pack is not.

testdata/entry-packs/zig/ is a whole entry pack written this way — a manifest and one template — and a unit test renders every golden C plan through it.

Step 3 — the goldens

Add the coordinate to the entry golden harness (codegen/entry/golden.rs; its Emitter enum names the entry point a row renders through), run NROS_UPDATE_GOLDEN=1 cargo test -p nros-cli-core --lib codegen::entry::golden, and read the diff. The generated source is a file, not a claim.

just check entry-pack-conformance refuses a half-wired pack in EITHER family — entry and message; the name is historical (see the recipe comment). A directory with no manifest, a language pack missing the fields its consumer needs, a registry key or registry_order claimed twice, a template file that does not exist, a .jinja no manifest claims, a Language variant nothing renders, or a language whose bytes are recorded in no golden — and, since phase-494, a new codegen/entry/emit_<x>.rs, because an entry language is a pack. Run it before you believe the pack works.

What this does NOT make cheap

The toolchain story — how CMake compiles the language, how it links libnros, how its components declare themselves. The codegen cost becomes a pack; the build-integration cost does not. Budget for it separately.

No per-language TYPE SPELLING lives in the message builders — the packs and their filters own it, and since phase-469 W1 so do the artifact-naming suffixes (file and guard names). The builders still hold the per-language Rust that is a RULE rather than a parameter — which generator builds which context, and the ROS kind word, which is identical on every surface. An entry language brings no emitter (phase-494): its pack and, at most, a spelling filter. Implemented by phase-335 (RFC-0068), phase-432 (RFC-0091), phase-469 and phase-494.

The canonical build path

What this is. A map of how a nano-ros image actually gets built: the three roads, what carries a configuration knob to the compiler on each, where a build finds a resource it did not produce, and which RFC owns each decision. It exists because the decisions are all made and all scattered — several RFCs own a slice each, and nobody owned the picture. The rule that ties them together is RFC-0103’s: every fact has one authored home, every other copy is generated from it, and every road reads the resolved answer.

What this is not. A new decision. Every rule here is already settled somewhere; this file points at the owner and explains how the pieces meet. Where it disagrees with an RFC, the RFC wins and this file is the bug.

Every number lives in a command, not in this file:

python3 scripts/nros-build-wiring.py          # roads, scripts, knob sources, readers
python3 scripts/nros-build-wiring.py --roads  # just the road table
python3 scripts/nros-build-wiring.py --scripts  # the build.rs census, by ROLE

That is deliberate. A hand-authored census is the failure mode this repository has recorded more than any other — the package-directory list that named four directories which no longer existed while omitting four that did (issue 1211), the RMW parity map that disagreed with the shape tool by 25 symbols, the sizes-header family. A table of counts reads as current and answers about the tree somebody measured once.

The five stages

nros build (RFC-0065 D1) is one pipeline, whatever the road:

1. DISCOVER   walk package.xml → pkg index → topological order
2. RESOLVE    the image: argument > [system] default_images > list and fail
3. PREFLIGHT  toolchains / SDKs / sources present?
4. GENERATE   msg bindings + system model + the ROOT BUILD FILE → build/<coord>/
5. EXEC       cmake --build / cargo build / west build — stderr untouched

The road is chosen at stage 4/5 by the board, never by the language mix (RFC-0065 D3). A workspace that crosses languages goes to cmake, because cargo can be consumed as a cmake target through Corrosion and cmake cannot be consumed as a cargo target (RFC-0024 §6.3). Choosing can also fail: one combination — an esp32 board with a graph that crosses languages — has no road and says so, rather than naming a tool that cannot build it.

The three roads, and the thing that actually differs

The stages are shared. What is not shared is how a resolved knob reaches the compiler — and that is the whole of this document, because each road carries it differently and each carrier has produced its own delivery defect:

The road names below are Driver variants (Cargo, CMake, West in packages/cli/nros-cli-core/src/builder/plan.rs); check-build-wiring-roads holds this table and the enum to each other in both directions.

roadexecemits a rootcarries a knob bythe defect it produced
cargocargo buildyes[env] rows in the generated nros-cargo.toml, read via --config0491 — a PATH-valued row has three spellings, so a rerun-if-env-changed on it rebuilds forever; watch the CONTENT
cmakecmake --buildyescorrosion_set_env_vars() on the target’s own cargo command; the APP rung as a --config [env] file on the same command (NanoRosImageEnv.cmake)0460 — set(ENV{}) touches only the configure process and carries nothing to a cargo lane; 1712 — the image’s [image.<id>] env had no carrier at all, and cmake -E env overwrites an exported variable, so it cannot carry a rung that sits below env
westwest build -b <board>no$DOTCONFIG, read per build script through nros_zephyr_build0460 — zephyr-lang-rust builds its own cargo command and inherits no environment at all

One road emits no root on purpose: a Zephyr app is already a complete cmake project and its Kconfig overlays are user intent, so stage 4 emits a root only where a root would otherwise be hand-written (RFC-0065 D3).

The road that was deleted, and the test that finds the next one

There was a fourth, idf.py, chosen for platform = "esp32" whenever the graph crossed languages. Its carrier cell was empty — and that is what ended it (RFC-0065 D3 amendment, 2026-09-28). Every other road’s cell names a mechanism that has produced a measured defect; an empty one says the road delivers no resolved knob at all, which is issue 0460’s class applied to a whole road rather than to one lane of one.

So the question this table asks of a new road is not “does the tool work” but what carries a knob on it, and how would I know if nothing did. A road that cannot answer is not a capability with a missing test; it is a claim. nros build now REFUSES that combination, naming issue 1525, because answering cmake instead would be the same claim in a tool that also has no esp32 road.

There WAS a fourth carrier that is not a road. On NuttX, <nros/nros_config_generated.h> resolved to a committed snapshot, not the per-build header, so a template edit that the generated artifacts consume was a two-file change (issue 1115) — forgetting it broke every NuttX C and C++ image from a clean clone for two days — and the snapshot’s sizes fell below the build four times (issue 1568 measured a live overrun). Issue 1569 retired it: the NuttX FFI build reads the per-build headers from the nros-c/nros-cpp links channels (DEP_NROS_{C,CPP}_CONFIG_INCLUDE), which also order it after both writers. The snapshot survives as *_buildless.h for header-only checks that compile with no build, behind NROS_CONFIG_BUILDLESS, which check-config-fallback-macros forbids any build to define.

The ladder: what may state a value

RFC-0049 orders the rungs builtin < platform < board < env. A higher rung that states a value wins; a rung that is silent is not a zero.

rungstated inowner
builtinthe reading crate’s own defaultthe crate
platformnros-platform.toml (config/*/, packages/platform/*/)RFC-0049
boardnros-board.toml [knobs.<group>]RFC-0064 R5 D4, RFC-0103 D6
app[image.<id>] env in the leaf’s system.tomlRFC-0049 / RFC-0098
envthe process environmentRFC-0049

One name per knob (RFC-0103 D6, phase-484 W5a). Every knob is one row in the registry packages/tooling/nros-zephyr-build/knobs.toml: id <group>.<name>, env NROS_<GROUP>_<NAME> (a knob of one RMW is NROS_<RMW>_<X>), Kconfig symbol CONFIG_NROS_<ID> unless the row names another. Everything else is generated from that file: the Rust KNOBS table and its derived KCONFIG_PAIRS, cmake’s NanoRosKnobRegistry.cmake, and the knob section of zephyr/Kconfig. An older spelling (ZPICO_MAX_PUBLISHERS) is a RETIRED row (phase-484 W8): every reader on every road — a build script, a configure, the CLI’s [image.<id>] env — refuses one that is set, in the registry’s one refusal sentence, naming the canonical spelling. The row stays one release as that error table; check-retired-knob-spellings keeps every env reader and writer off the old names (the same words stay as the zenoh-pico shim’s C macro names).

The app rung’s two spellings, one set of rows (phase-484 W8, RFC-0103 D6). An image states a registry knob as [image.<id>.knobs.<group>] <name> = <value>, the shape a board’s [knobs.<group>] has (the key is the row’s toml key, else its id split at the first _). cmd::leaf_settings::app_rung validates each key against the registry (knob_by_image_key: unknown key, type, range, names are errors naming it) and renders it to the knob’s env spelling, which is the row an [image.<id>] env entry states. Every carrier below is fed from those rows, so moving a row between the two spellings changes no byte of any carrier (measured on 29 rows: 575 dumped files identical, the west Kconfig.from-system-toml differing only in its provenance comment). An env row naming a registry knob, by its env name or by the Kconfig symbol west renders it to, still works and prints one IMAGE_ENV_DEPRECATED_TEMPLATE warning; stating a knob both ways is an error. The gate no_tracked_system_toml_states_a_registry_knob_as_an_env_row (check-cli-tests) keeps the tree on the knobs spelling.

The app rung’s carrier, per road (issue 1712). Cargo: the generated nros-cargo.toml [env]. CMake: --config <build>/nros/nros-image-env.toml on every cargo command the configure spawns (written by nros dev image leaf-system --image-env-out), the same unforced [env], so an exported variable still wins and a changed row re-runs the build scripts that read it. West (phase-481 W1, RFC-0098 D11, phase-484 W5b): the nano-ros module’s module_ext_root hook writes two files. Knobs go to <build>/nros/Kconfig.from-system-toml, which the module’s prompt-less knob section osources first, so the image’s [image.<id>.knobs] (or env) value is the symbol’s default and reaches $DOTCONFIG. A one-build override is -DNROS_OVERRIDE_<ID>=<v>; a -DCONFIG_<knob> or a knob line in a conf file is refused, naming system.toml. The image’s RMW choice, endpoint and domain stay in the conf fragment <build>/nros/<image>.conf, LAST in EXTRA_CONF_FILE, because a Kconfig choice cannot be a prompt-less default (W5b’s M1). A row with no Kconfig symbol rides the same unforced --config [env] file on the Zephyr cargo commands. A Zephyr leaf’s conf files hold no nano-ros knob at all (gate check-leaf-conf-nros-knobs). A workspace image whose configuration differs gets its own cmake configure (build/<coord>-cfg<hash>/, D12).

Beside the ladder sit two things that are facts, not rungs, and the distinction matters because a fact has no $DOTCONFIG counterpart:

  • Derived entity facts — NROS_DECLARED_*, NROS_ENTITY_*, computed by cmake for this image from the resolved SystemModel. Absent means “no answer”, never “zero” (issue 1122).
  • The sizing descriptor — <build>/nros/sizing/<entry>.toml, per-FIELD stated-or-refused (RFC-0100 D4). Read it through nros-sizing-descriptor, never by hand, and never re-carry one of its facts through an env knob.

A platform that resolves to no descriptor is an ERROR (phase-468 W1): every board-declared name has a descriptor or an explicit declaration that it has no rungs, and an unanswered name panics rather than warning.

Where a resource comes from: the location ladder

A build also needs things it did not produce: vendored source trees, cross toolchains, the nros CLI, generated artifacts. Before phase-484 a resource’s location was decided by 13 mechanisms, and 13 resources were found two or more ways (issue 1767, archived with a per-item table). RFC-0103 D3 gives them one ladder, nros_build_paths::locate, with these rungs, highest first:

rungstated by
argan explicit argument
envthe resource’s one env name in its index row
project[sources] / [tools] in the project’s system.toml
local-edita checkout submodule moved off its pin, or dirty
store$NROS_HOME/sources/<name>/<version>+<sha8>/, materialised by nros setup --source
checkoutthe submodule in this checkout

Every road asks the same ladder, never a copy of it:

roadfront end
a build scriptnros_build_paths::locate::source(name); it watches the answer’s CONTENT, never the variable (issue 0491)
cmakenros_locate_source(), nros_locate_var(), nros_locate_artifact() in cmake/NanoRosLocate.cmake: one nros locate --all --format cmake per configure, normal variables, never CACHE
shell / justnros_locate_source / nros_locate_artifact in scripts/build/cargo.sh
Python gatesscripts/lib/source_locate.py
a personnros locate <name> --why prints the rung that answered

The image’s resolve records the answers in resolved.toml [locations] (path, rung, and transient for an environment answer). That file is a record, not a carrier: build scripts re-ask the ladder.

Two roots, one name each (phase-484 W1/W2d): the store is $NROS_HOME (NROS_STORE and NROS_SDK_STORE are refused, naming it), and the checkout is found by --nano-ros-path (-DNROS_REPO_DIR in cmake), then a walk up from the workspace, then the nros binary’s own location (a release’s share/nano-ros, or the checkout an in-tree build sits in). No environment variable names it. activate.sh exports no location at all: it puts nros on PATH and nothing else, so a linked worktree cannot inherit another checkout’s trees (issue 1280).

The last three resources found more than one way went through the same fix, each with a gate that refuses a new hand-written path:

  • the nros and nros-launch-resolve binaries: nros_cli_bin [--checkout-only], nros locate tool, and in Rust nros_cli_in_checkout / launch_resolver_from (issue 1850, check-host-tool-path-literals);
  • QEMU: nros_qemu_bin and the nros-qemu-ladder crate (CLI + tests; issue 1851, check-qemu-one-ladder);
  • the generated-header directory names: nros locate artifact and cmake/NanoRosArtifactLayout.cmake, which read nros_build_paths::artifact (issue 1852, check-artifact-path-literals).

Where a selection is stated

The board, RMW, domain, locator, IP address and transport are stated in the image’s system.toml ([image.<id>], [system]). The board’s descriptor supplies the defaults through [board.net]: every QEMU board joins the one qemu network (10.0.2.0/24, gateway 10.0.2.2), and a router default is derived from the gateway as tcp/<gateway>:7447 (phase-484 W4b). The board crate reads generated BOARD_NET_* constants rather than restating them.

On the cargo road nros::main! bakes the image’s selections into a deploy overlay, and every board family merges it over its own defaults with BaseConfig::apply_overlay before opening the session. That merge is the only way the image’s locator reaches a board, because BootConfig::over_board_defaults lets the board’s value win. NuttX once skipped it and every NuttX Rust image dialled an empty locator (issue 1836), so check-image-locator-bake now checks that each family’s crates apply the overlay rather than trusting that they do. ROS_DOMAIN_ID and NROS_LOCATOR remain runtime overrides; NROS_BOARD, ZENOH_LOCATOR and a build-time NROS_DOMAIN_ID are not inputs.

Where a value must NOT be stated

Three of these are gated, because each was written by hand at least once:

  • Not in examples/**/.cargo/ — an example’s build configuration is generated from one board choice (RFC-0098 D1). Gate: check-example-cargo-dirs.
  • Not in a committed SystemModel — models are build artifacts. Gate: check-no-tracked-models.
  • Not in a second spelling of a derivation — one formula, and its INPUTS derived once too (issue 1025). Gate: check-fixture-artifact-dir-inputs.

Reading a knob: the two shapes

A Kconfig knob reaching the Zephyr C lane and not the Rust one is issue 0460, the hazard this whole area exists to contain. Readers come in two shapes and they are not interchangeable:

  • DERIVED — the Kconfig name is built from the env name as CONFIG_{env_name}. A knob such a reader names is a knob it resolves, so “does the file mention it” is a sound test.
  • TABULATING — an authored table, needed where the two vocabularies differ (NROS_ZENOH_SUBSCRIBER_RING_DEPTH ↔ CONFIG_NROS_SUBSCRIBER_RING_DEPTH). Here a mention proves nothing: three knobs were mentioned with no table row and silently compiled crate defaults (issue 1490).

Held by check-kconfig-knob-forwarding. Both shapes now go through one resolution function, nros_zephyr_build::knob("<ENV>") with the Kconfig and env sources as inputs (phase-468 W4). Since phase-484 W5a the TABULATING table is no longer authored: KCONFIG_PAIRS is derived from the knob registry, so a missing row is a missing registry row, which check-zephyr-kconfig-knobs holds against the rendered Kconfig.

How to prove a knob is delivered

The baseline cannot show a delivery failure. Unset, the Kconfig default and the crate default are usually the same number, so “delivered” and “fell back to the same value” are one observation. Use a non-default probe:

# append to the leaf's prj.conf, rebuild, read the generated const, then REVERT
CONFIG_NROS_SUBSCRIBER_RING_DEPTH=7

That is how issue 1490 was found and how its fix was accepted (4 → 7).

Who reads a carrier: the build scripts

A carrier delivers to a build script, and there are 66 of them. What each is FOR is a ROLE, printed by --scripts; phase-471 replaced a set of capability LETTERS with those roles because a letter was a grep for a TOOL and a tool is not a role — cc::Build put three first-party test C files in the same class as the FreeRTOS kernel, and left cyclonedds-sys (≈200k lines of vendored C through cmake::Config) reading as “re-roots paths and nothing else”.

Two things in that output are load-bearing here and not obvious:

  • source_origin — where a C-compiling script’s sources come from. Three answers exist: a path-valued SDK variable, a workspace-relative path, and a links= hand-off (DEP_*) from the crate that already resolved the tree. Only the first is exposed to issue 1280, because only the first can be inherited from another checkout. Prefer the links hand-off where one is available: it also orders the two build scripts, which a shared environment variable cannot do.
  • the PATH RESOLUTION section — issue 1280’s BUILD-SCRIPT half, which no gate covers (check-inherited-checkout-paths holds the shell half and names no build.rs). It read 5 before issue 1527 and reads 0 now. A build script that resolves one of these variables goes through nros_build_paths; a private helper that takes the name as an argument is the shape that hid two of the five, because no literal-matching probe can see inside it.

The classes, the outlier verdicts and the migration order are phase-471.

Which RFC owns what

sliceowner
the stages, the driver, what stage 4 emitsRFC-0065
the knob ladder and the descriptorsRFC-0049
board organization, nros-board.toml as the single sourceRFC-0064
a leaf’s build configuration is generatedRFC-0098
resolve before configureRFC-0094
the sizing descriptorRFC-0100
build caches: one root, one vocabularyRFC-0070
platform and build determinismRFC-0042
the backend descriptorRFC-0071
where a build script gets a vendored tree’s ROOTRFC-0101
one home per fact: the location ladder, knob names, selectionsRFC-0103

The recurring failure, stated once

Every defect named above is the same shape at a different site: a value that was resolved correctly and delivered to only some of the things that read it. 0460 (Kconfig reaches C, not Rust), 1115 (the header reaches every road but NuttX), 0491 (one directory, three spellings), 1025 (one formula, two derivations of its inputs), 1490 (the table that resolves, missing a row).

So the question to ask of any new wiring is never “is the value right” — it is which roads carry it, and how would I know if one did not. The answer is a non-default probe on each road, not a reading of the code.

Codegen Type Mapping Reference

How nros dev codegen rust maps ROS 2 message fields to Rust, C, and C++ types.

Overview

Every ROS message generates a single owned Rust struct — no lifetime parameters, no dual Msg<'a> / MsgOwned variants. Unbounded fields (string, uint8[], etc.) use fixed-capacity heapless types. All generated types implement Serialize, Deserialize, RosMessage, Default, and Clone.

For zero-copy access to large payloads (images, point clouds), use the raw subscription API and read CDR fields directly with CdrReader instead of fully deserializing into owned structs.

Rust type mapping

Primitive fields

ROS typeRust type
boolbool
byte / uint8u8
char / int8i8
int16i16
uint16u16
int32i32
uint32u32
int64i64
uint64u64
float32f32
float64f64

String fields

ROS typeRust typeNotes
string (unbounded)heapless::String<256>Capacity is the codegen limit
wstring (unbounded)heapless::String<256>Same
string<=N (bounded)heapless::String<N>Capacity matches bound
wstring<=N (bounded)heapless::String<N>Same

heapless::String<N> is re-exported from nros_core::heapless for use in generated code and in user code without a direct heapless dependency.

Sequence fields

ROS typeRust typeNotes
uint8[]heapless::Vec<u8, 64>Capacity is codegen limit
int8[] / byte[]heapless::Vec<u8, 64>
bool[]heapless::Vec<bool, 64>
int32[] / float64[] / etc.heapless::Vec<T, 64>
string[]heapless::Vec<heapless::String<256>, 64>
T[] (nested message)heapless::Vec<T, 64>
T[<=N] (bounded sequence)heapless::Vec<T, N>Capacity matches bound

The default sequence capacity is 64 elements. For large-payload messages where you need more elements at runtime, use the raw subscription API with CdrReader to read fields without materializing the whole vector.

Fixed-size arrays

ROS typeRust typeNotes
T[N][T; N]Zero-cost, no heap

Arrays with more than 32 elements use a manual Default impl instead of deriving, since Default is only derived for arrays up to [T; 32].

Nested message fields

Nested message types are embedded inline:

Field typeRust type
T (nested message)T
T[] (sequence of T)heapless::Vec<T, 64>
T[N] (array of T)[T; N]

Traits

Every generated message type implements:

TraitNotes
SerializeCDR serialization via CdrWriter
DeserializeCDR deserialization via CdrReader
RosMessageProvides TYPE_NAME and TYPE_HASH constants
DefaultAll fields zero/empty
CloneDeep copy
PartialEqField-wise equality
DebugFormatted output

RosMessage requires Serialize + Deserialize:

#![allow(unused)]
fn main() {
pub trait RosMessage: Sized + nros_serdes::Serialize + nros_serdes::Deserialize {
    const TYPE_NAME: &'static str;
    const TYPE_HASH: &'static str;
}
}

Zero-copy access via raw subscription API

For large messages, deserializing into an owned struct (e.g., Image { data: heapless::Vec<u8, 64> }) truncates at 64 elements. Use the raw API and CdrReader instead:

#![allow(unused)]
fn main() {
use nros_core::{CdrReader, RosMessage};

// Read only needed fields without full deserialization
executor.add_subscription_buffered_raw::<65536>(
    "/camera/image",
    Image::TYPE_NAME,
    Image::TYPE_HASH,
    QoSProfile::default(),
    |cdr: &[u8]| {
        let mut r = CdrReader::new_with_header(cdr).unwrap();
        // Skip header fields by reading and discarding them
        let _stamp_sec: u32 = r.read_u32().unwrap_or(0);
        let _stamp_nsec: u32 = r.read_u32().unwrap_or(0);
        let _frame_id_len: u32 = r.read_u32().unwrap_or(0);
        // ... read only what you need
        let height: u32 = r.read_u32().unwrap_or(0);
        let width: u32 = r.read_u32().unwrap_or(0);
        let pixel_data_len = r.read_u32().unwrap_or(0) as usize;
        let pixels = r.read_slice_u8(pixel_data_len).unwrap_or(&[]);
        process_pixels(width, height, pixels);
    },
)?;
}

The cdr slice borrows from the triple buffer slot and is only valid for the duration of the callback — do not store a reference to it.

C type mapping

ROS typeC type
string (unbounded)struct { const char* data; size_t size; }
string<=N (bounded)char name[N]
uint8[] (unbounded)struct { const uint8_t* data; size_t size; }
T[] (unbounded, other)struct { const T* data; size_t size; }
T[<=N] (bounded)struct { uint32_t size; T data[N]; }
T[N] (fixed)T name[N]

The C deserializer sets pointer+length fields to point into the CDR buffer (valid for the callback duration). The C serializer reads from the pointer+length fields.

C++ type mapping

ROS typeC++ type
string (unbounded)rclcpp::StringView
string<=N (bounded)rclcpp::FixedString<N>
uint8[] (unbounded)rclcpp::Span<uint8_t>
T[] (unbounded, other)rclcpp::Span<T>
T[<=N] (bounded)rclcpp::FixedSequence<T, N>
T[N] (fixed)T name[N]

rclcpp::Span<T> and rclcpp::StringView are freestanding C++14 types defined in rclcpp/span.hpp. They provide data(), size(), begin(), end(), and operator[] — same API as std::span / std::string_view.

Service types

Service request and response types are regular message structs that also implement Serialize and Deserialize. Nested message fields in service types are embedded inline (same as standalone messages).

ComponentType
Request structFooRequest
Response structFooResponse
Service markerFoo (zero-sized)

Foo implements RosService:

#![allow(unused)]
fn main() {
impl RosService for Foo {
    type Request = FooRequest;
    type Reply = FooResponse;
    const SERVICE_NAME: &'static str = "...";
    const SERVICE_HASH: &'static str = "...";
}
}

Action types

Action goal, result, and feedback types are regular message structs. The action marker type implements RosAction:

#![allow(unused)]
fn main() {
impl RosAction for MyAction {
    type Goal = MyActionGoal;
    type Result = MyActionResult;
    type Feedback = MyActionFeedback;
    const ACTION_NAME: &'static str = "...";
    const ACTION_HASH: &'static str = "...";
}
}

Goal, Result, and Feedback all implement Serialize + Deserialize (required by the RosMessage bound on RosAction associated types).

Package name remapping

nros dev codegen rust --rename old_pkg=new_crate_name renames:

  • Output directory
  • [package] name in Cargo.toml
  • Dependency names and paths in Cargo.toml
  • use old_pkg:: references in Rust source

Used by nano-ros to generate nros-rcl-interfaces from rcl_interfaces.

RMW Backends — Host-Language Policy

This page documents which language each RMW backend is implemented in, and the rule that decides it. The matrix was originally frozen 2026-05-07; the L tier (+ L.8, landed 2026-05-12) collapsed the public RMW surface so every backend now reaches the runtime via the same nros_rmw_vtable_t bridge. The underlying implementations still ship in whichever language their upstream library prefers, but consumers never see that — they only see the C vtable.

The rule (post-115.L)

Every backend installs itself via the nros_rmw_vtable_t bridge. The underlying library’s host language is an implementation detail of the per-backend -cffi shim; it does not appear on the consumer surface.

The original rule (a backend’s host language matches its underlying library’s native language unless overridden) is still how we pick the inside of each -cffi shim — but the shim itself is uniform: a small Rust or C++ TU that fills in a vtable and calls nros_rmw_cffi_register_named(name, &vtable) once at startup.

Hierarchy

nros-core (Rust) ──→ Rmw trait (internal; bridged by RustBackendAdapter<R>)
                        └──→ nros-rmw-cffi   (C ABI bridge, registry)
                                ↓ nros_rmw_vtable_t  (~17 fn ptrs)
                                ├──→ nros-rmw-zenoh    (wraps Rust nros-rmw-zenoh)
                                ├──→ nros-rmw-xrce-cffi     (links C nros-rmw-xrce)
                                ├──→ nros-rmw-cyclonedds    (C++ direct, no Rust)
                                └──→ nros-rmw-uorb      (C++ direct, no Rust)

The shims are the canonical consumer surface. Public Cargo features on nros / nros-c / nros-cpp (rmw-{zenoh,xrce}-cffi, cffi-{zenoh-cffi,xrce-c}) all route through the same nros_rmw_vtable_t runtime. The pre-L.7 direct-Rust-trait features (rmw-zenoh, rmw-xrce, rmw-uorb) are gone.

Phase 169 (2026-05-19) — dust-dds retired. The nros-rmw-dds Rust shim and the dust-dds upstream Rust DDS implementation have been removed (Phase 169.4). Cyclone DDS is the sole DDS backend; the nros-rmw-cyclonedds shim registers under its canonical name "cyclonedds" only. The previous "dds" generic slot is not aliased — callers always select Cyclone by its specific name (NROS_RMW=cyclonedds, target_link_libraries(... NanoRos::Rmw::cyclonedds), etc.).

Any language with stable C-ABI interop (C, C++, Zig, Rust, Go-via-cgo, Python-via-ctypes…) can implement a backend by filling in the vtable and calling nros_rmw_cffi_register_named(name, &vtable) once at startup.

Decision matrix (post-115.L, updated by Phase 171)

BackendUnderlying libUnderlying langShim crateVerdict
Cyclone DDSCyclone DDSC / C++nros-rmw-cyclonedds (C++ direct vtable; canonical DDS backend)keep
XRCEmicro-XRCE-DDS-ClientCnros-rmw-xrce-cffi (Rust shim over the C nros-rmw-xrce static lib; 115.K.2 ported)keep
zenoh-picozenoh-picoCnros-rmw-zenoh (Rust → vtable via RustBackendAdapter<ZenohRmw>)keep
uORBPX4 module SDKC++nros-rmw-uorb (C++ direct vtable; 115.K.4 port replaces legacy nros-rmw-uorb Rust crate)keep

Dust-DDS was retired in Phase 169 (2026-05-19) after repeated bring-up failures on embedded targets. It is intentionally absent from the active backend table; Cyclone DDS now fills the DDS slot.

Rust-backend cffi shape

For backends whose upstream library is Rust (zenoh-pico) the cffi shim ships as a tiny crate that calls RustBackendAdapter::<UnderlyingRmw>::register(). The adapter monomorphizes a static nros_rmw_vtable_t over the Rust Rmw trait impl and installs it into the C registry. Consumer code never sees the trait surface; it only sees the vtable.

The legacy direct-Rust-trait crates (nros-rmw-zenoh, nros-rmw-xrce) stay in the workspace as internal-only implementation libs of these shims. They have no public Cargo feature reaching them after.

C-/C++-backend cffi shape

For backends whose upstream library is C/C++ (Cyclone DDS, uORB, XRCE) the cffi shim is a standalone CMake project that builds a static C/C++ library and registers a nros_rmw_vtable_t at startup via nros_rmw_cffi_register_named. No RustBackendAdapter is involved. The Rust runtime sees these via the same registry; the NROS_RMW=<name> CMake selector flips a build-time macro that ensures the register call is wired into nros::init.

Cyclone DDS: runtime type introspection (Phase 212.K.7)

The Cyclone DDS shim does not require per-type backend code on the generated msg crate. Phase 212.K.7 inverted the original design where each msg crate carried an optional cyclonedds Cargo feature plus a Cyclone-specific descriptor sidecar.

In the runtime-introspection design every generated msg crate is purely the wire-format data type (#[derive]d struct + a tiny impl nros_serdes::Message exposing const TYPE_NAME + const FIELDS). On the first typed create_publisher<M> / create_subscription<M> for a given M, the nros-rmw-cyclonedds shim walks the static field schema, builds a Cyclone ddsi_sertype via Cyclone’s dynamic-type C API, and caches the pointer in a bounded heapless::FnvIndexMap<u64, NonNull<ddsi_sertype>, MAX_TYPES> guarded by a platform-selected mutex. Subsequent uses of the same M are hash-map hits.

End state: no <msg-pkg>/cyclonedds Cargo feature anywhere, no per-msg-pkg backend code, no codegen branching on the active RMW. The shape matches upstream rclcpp’s introspection typesupport + rclrs’s plain <pkg> = "*" consumer manifest.

Sizing knob: NROS_CYCLONEDDS_MAX_TYPES (default 32), wired through the existing nros-sizes build probe (same pattern as EXECUTOR_OPAQUE_U64S). See section 212.K.7 of docs/roadmap/phase-212-ux-cargo-native-and-file-consolidation.md for the work-item ledger.

When to revisit

This matrix is a snapshot. Update it when any backend’s situation changes:

  • A new backend lands → add a row + shim-crate + verdict.
  • An existing backend’s underlying library changes language (e.g. zenoh-pico ships a Rust port upstream) → swap the shim shape (Rust adapter vs. C/C++ direct) but the vtable bridge stays.

The rule stays. Only per-backend verdicts and shim shapes move.

Registry + naming

nros-rmw-cffi holds a fixed-size named registry of backend vtables. Each backend registers under a canonical name at process startup:

BackendNameRegistered by
zenoh-pico"zenoh"nros_rmw_zenoh_register() (auto-ctor on POSIX)
micro-XRCE-DDS-Client"xrce"nros_rmw_xrce_register() (C ctor on POSIX)
Cyclone DDS"cyclonedds"nros_rmw_cyclonedds_register() (Phase 169.5 — canonical DDS backend; no generic "dds" alias)
uORB"uorb" (future)TBD

Naming policy

  • Lowercase ASCII identifying the protocol / wire format. Not the transport variant — "xrce" covers both XRCE-UDP and XRCE-serial; the transport is selected via the locator (udp/... vs serial:/dev/...).
  • Stable across releases. Renaming a registered name is a breaking change for bridge code that selects backends by string.
  • No "default" for new backends. It was the implicit name of the legacy single-arg nros_rmw_cffi_register_named, which phase-482 W6 deleted; name the protocol instead.

Capacity

Registry size: NROS_RMW_MAX_BACKENDS build-time env var consumed by nros-rmw-cffi/build.rs. Default 8. Range [1, 64]. Set lower on Cortex-M0+ (where each slot’s ~40 B costs); set higher for bridge nodes with 4+ backends. Hitting the cap = subsequent nros_rmw_cffi_register_named returns NROS_RMW_RET_ERROR.

Default-backend convention

Executor::open and any create_node call without an explicit .rmw(name) selector use the first-registered backend — the nano-ros equivalent of ROS 2’s RMW_IMPLEMENTATION. Single- backend binaries with one auto-registering backend Just Work without user code mentioning the backend’s name.

The user-facing knob is a declared, language-agnostic, per-deploy value (system.toml [system].rmw / [image.<id>].rmw, or a CLI/build flag) that the toolchain lowers to each language’s native link mechanism. The Cargo feature / shim dep and the CMake cache var below are those lowering targets — what the build uses, not how a user picks a backend (see RFC-0031):

  • Cargo (Rust): the declared RMW lowers to the nros rmw-<x> feature plus the matching nros-rmw-<x> shim dep in the consumer’s [dependencies]. Linking the shim crate is what registers the backend.
  • CMake: cmake -DNROS_RMW=zenoh ... (C/C++ users). The nano_ros_link_rmw(... RMW zenoh) helper auto-generates the per-target nros_app_register_backends() strong stub.

No runtime env-var override; selection is fixed at link time (RTOS-friendly, matches our static-link world).

Symbol-survival mechanism

Backend register symbols must survive linker dead-strip. Four mechanisms, layered:

  1. linkme distributed-slice (/ 128.H.2) — each backend contributes an RMW_INIT_ENTRIES entry through the nros_rmw_register_backend! macro. rclc_support_init / Executor::open walks the slice and calls each entry. Canonical on the hosted targets whose linker sections linkme recognises — of which nano-ros builds only the linux host board. Macro expands to a no-op on RTOS targets where linkme can’t recognise the section (NuttX, Zephyr, FreeRTOS, bare-metal).
  2. Rust ctor (legacy fallback): #[unsafe(link_section = ".init_array")] #[used] static AUTO_REGISTER_CTOR. #[used] keeps rustc from dead-stripping.
  3. C ctor (legacy fallback): __attribute__((constructor)) static void nros_rmw_<name>_register_ctor. Same survival via .init_array walk by libc startup.
  4. CMake strong stub (landed): the nano_ros_link_rmw(<target> RMW <name>) helper at cmake/NanoRosLink.cmake:62-117 emits an auto-generated TU per target that defines a strong nros_app_register_backends() calling every linked RMW’s nros_rmw_<name>_register(). The weak default in libnros_c_weak_stubs.a is overridden. This is the canonical path on every RTOS where linkme can’t survive.
  5. Explicit user call (Rust no_std bridges): nros_rmw_<name>::register() from main() — drags the rlib’s CGU into the binary so the linkme entry is reachable. See the gateway fixture packages/testing/nros-tests/bins/bridge-zenoh-to-xrce-fwd/.

Bare-metal + RTOS targets that don’t run .init_array rely on (4). Pure-Rust no_std binaries with multiple backends rely on (5). POSIX builds get (1) + (2) + (3) for free.

Ctor ordering

POSIX .init_array runs ctors in link order, not in any user-controlled sequence. When multiple backends auto-register in one binary, the first to fire owns the default slot — the one selected by Executor::open() / nros::init() with no .rmw(name) argument. The order is reproducible per link graph but not portable across linkers (lld vs. mold vs. gold) or build configs (LTO can reorder via --print-icf-sections collapse) and must not be relied on for correctness.

Disambiguation is the user’s job in multi-backend binaries. Use the named entry points:

  • Rust: Executor::open_with_rmw("zenoh", &cfg) for the primary session; node_builder("name").rmw("cyclonedds").build() for additional Nodes.
  • C: rclc_node_init_ex with rcl_node_options_t.rmw_name set.
  • C++: rclcpp::Executor::open_with_rmw(...) and rclcpp::NodeBuilder::rmw(...) mirror the Rust API (Phase 104.C.9).

The packages/testing/nros-tests/bins/bridge-zenoh-to-cyclonedds-fwd/ gateway fixture (run by the mixed-RMW bridge e2e) shows the pattern end-to-end: both Zenoh and Cyclone DDS backend ctors fire at lib-load (so the registry has both "zenoh" and "cyclonedds" slots populated), and open_with_rmw("zenoh", ...) plus node_builder("egress").rmw("cyclonedds") pin each session to its intended backend without depending on link-order luck.

Single-backend builds keep the legacy ergonomics — only one ctor fires, the default-slot convention picks it up, and no user-visible name is ever required. The cost of naming is paid only when multiple backends coexist.

Real-time budget per backend

The poll loop’s worst-case execution time is dominated by the backend’s transport drain. Bridge users summing bridge_wcet = Σ poll_i + Σ dispatch_j need each backend’s contribution; this table captures the current best-effort estimates from per-backend microbenchmarks (packages/testing/nros-bench/wcet-cycles-qemu/, packages/testing/nros-bench/wake-latency-cortex-m3/) + heap-usage stats from cargo build --release symbol dumps.

Backendpoll_wcet_usBuffer-pool sizeNotes
zenoh-pico (nros-rmw-zenoh)~50–200 µs nominal on Cortex-M3 (FreeRTOS QEMU — emulated timing, host-load-dependent; no hardware measurement exists)Z_BATCH_UNICAST_SIZE (default 6500 B/peer) + 4 KB per subscription buffered ringWake-cb collapses idle wait to kernel xSemaphore post — sub-poll-period latency when transport notifies. POSIX cv-wait path same shape, ~1 µs notify-to-dispatch.
XRCE-DDS (nros-rmw-xrce-cffi)~100–500 µs per uxr_run_session_time on POSIX; agent-round-trip dominates over local poll. Bare-metal targets pay the same poll cost.STREAM_HISTORY (4) × UXR_CONFIG_UDP_TRANSPORT_MTU (512 B default) ≈ 2 KB/stream; one input + one output stream per sessionPoll-only — set_wake_callback slot is NULL; spin_once cv-wait still wakes on its deadline. Agent does the reliable-retransmit accounting; client adds ~10 µs per stream per tick.
Cyclone DDS (nros-rmw-cyclonedds)~150–600 µs on POSIX; C++ listener callback latency depends on Cyclone’s reader-cache scan.Cyclone’s RTPS history per the DDS QoS History.depth (default 10) + Cyclone’s own DDSI buffer pool (~32 KB default)Listener-side set_wake_callback wiring is follow-up — today the C++ vtable sets the slot NULL. Memory footprint dominated by Cyclone itself, not the nano-ros shim.

Bridge users: sum the poll_wcet_us for every backend the bridge process opens, then add per-callback dispatch budget (typically <10 µs for the executor’s arena dispatch + the user callback’s own work). A bridge_picas_priority regression test (blocked on the PiCAS dispatcher) will eventually pin a bar to this table.

Per-backend README.md files live at packages/{zpico/nros-rmw-zenoh,dds/nros-rmw-cyclonedds,xrce/nros-rmw-xrce-cffi}/README.md (when present); reach out to the backend’s maintainer for fresh microbench numbers on a different target class than the ones above.

See also

  • roadmap doc — Appendix D carries LOC sizing, port shapes, and risk notes.
  • Custom Transport porting guide — how the transport vtable composes with the RMW vtable.
  • packages/rmw/cyclonedds/nros-rmw-cyclonedds/ — reference layout for the C++ vtable consumer.
  • packages/rmw/uorb/nros-rmw-uorb/ — reference layout for the C++ vtable consumer with PX4 SDK integration.

zpico-sys Build Architecture

zpico-sys is nano-ros’s Rust *-sys crate for zenoh-pico. Its build script is one line; the work is in packages/rmw/zenoh/nros-zpico-build (runner::run), a library so its parsing and source selection can be unit-tested outside Cargo’s build-script harness. It compiles the vendored zenoh-pico C with a single cc-rs invocation, driven by data rather than by per-RTOS Rust code.

Zephyr is the exception: its west/cmake lane compiles the same vendored tree through zephyr/cmake/nros_rmw_zenoh.cmake, and the cargo build of zpico-sys for a Zephyr image compiles none of it (see compiled_by below).

Where the data lives

Three files, each owned by the thing it describes. None of them is the zenoh_platforms.toml older docs mention; that central manifest was split up by phase-290 and phase-349 W2b.

WhatFileOwner
The core zenoh-pico sources, every platformpackages/rmw/zenoh/zpico-sys/zenoh-sources.txtthe backend (phase-420 W9)
zenoh-pico’s per-port defines, extra sources, include paths[port.<platform>] in packages/rmw/zenoh/nros-rmw-zenoh/nros-rmw.tomlthe backend
CPU flag profiles, compile settings, required SDKseach platform’s nros-platform.tomlthe platform

A platform names no backend (RFC-0072 §9): what a platform needs from any vendored C build stays in its own file, and what zenoh-pico needs on that platform is a row in zenoh’s descriptor. A build joins the two.

zenoh-sources.txt

The one source list, read by both the cargo lane and the cmake lane. Records are group <name> <condition>, dir <group> <tree> <path> (every .c under <path>, recursively) and src <group> <tree> <path> (exactly one file). The core is nine dir records under zenoh-pico/src/, condition always; the two other groups are Zephyr-only. The file’s header explains the format and why it lists directories rather than files. Gate: just check zenoh-source-manifest — every .c under zenoh-pico/src/ must be covered by a record or sit under a system/<platform>/ tree some port row selects, so a new upstream DIRECTORY fails the gate.

The platform half — nros-platform.toml

Seven files: config/{bare-metal,generic}/ and packages/platform/nros-platform-{freertos,nuttx,posix,threadx,zephyr}/. Most of each file is the RFC-0049 knob ladder ([capabilities], [knobs.*]); the C-build keys are:

KeyTypeUse
nameslist[str]Names this platform answers to; names[0] is canonical (config/bare-metal answers bare-metal and esp32).
inheritsstringParent platform. Its C-build keys and its port row merge underneath this one (rules below).
arch_profilesstr | list[str]Which [arch.*] profiles apply, in order; the first whose target_match admits the target wins.
compiletableopt_level / warnings / cflags.
required_envlist[RequiredEnv]SDK paths the build needs. See below.
[arch.<name>]tableA flag profile (below). A profile may appear in several files only if every copy is identical.

Profiles listed today: cortex-m3, cortex-m4f, riscv32imc (bare-metal); cortex-m3, cortex-m7, cortex-r5, cortex-r52 (FreeRTOS); cortex-a7 (NuttX); riscv64gc (ThreadX). posix, zephyr and generic list none. Gate: just check arch-profile-resolution checks every supported triple against these lists.

The search path is $NROS_PLATFORMS_DIR (colon-separated, searched first), then packages/platform/, then config/. An empty tree is fatal, not a fallback to builtins.

[arch.<name>]

KeyTypeUse
target_matchstringSubstring or <prefix>* glob the target triple must match.
target_excludestringVeto when the triple contains this substring (cortex-m3 excludes thumbv7em, so a Cortex-M4 does not pick it).
cflagslist[str]Compiler flags.
needs_picolibcboolAdd the picolibc sysroot’s include/.
needs_errno_overrideboolGenerate and prepend the errno-override shadow header (RISC-V picolibc TLS-errno workaround).
needs_riscv_compilerboolProbe for a RISC-V cross cc.

The backend half — [port.<platform>]

Rows exist for freertos, nuttx, posix, threadx, zephyr, bare-metal and generic; the key is a platform name or alias. A row is deny_unknown_fields: one that names arch_profiles, compile or required_env is a platform fact in the wrong file and fails to load. The schema is PortRow in packages/tooling/nros-platform-config/src/manifest.rs.

KeyTypeUse
defineslist[str]define(name, None).
defines_conditionallist[{name, when}]A define gated by a when matcher. FreeRTOS uses it to pick ZENOH_FREERTOS_LWIP or ZENOH_ORIN_SPE from the board’s ip_stack capability.
defines_kvmapdefine(name, Some(value)).
defines_envmap of {env, default}Value from env var env, else the literal default.
includelist[str]Directories under zenoh-pico/src/ this port relies on. Not a source selection — that is zenoh-sources.txt plus extra_sources. The build checks each exists and holds a .c file, and watches it for rebuilds: the drift check against a submodule bump.
extra_sourceslist[ExtraSource].c files to compile on this port, e.g. {src}/system/unix/network.c. See below.
include_pathslist[str]Header search paths, interpolated. Order matters: first wins.
include_paths_conditionallist[{path, when}]Header paths gated by when.
mbedtlsstringpkg-config / vendored / none. Read only when the link-tls feature is on.
picboolcc::Build::pic override (NuttX flat builds set false).
rerun_if_env_changedlist[str]Extra cargo:rerun-if-env-changed triggers. VALUE knobs only, never a path variable (issue 0491).
compiled_bycargo | platformplatform means the platform’s own build compiles this C and cargo must not; the row is then descriptive, and still checked. Only zephyr sets it (issue 0534).

The system_libs, exclude and link keys that older docs list were parsed and read by nothing. They were removed (issue 2031), so a row naming one is refused. pthread on POSIX is emitted by the runner itself.

How a build resolves its platform

  1. Pick the name. From zpico-sys’s Cargo feature: threadx, nuttx, freertos, zephyr, bare-metal. With none of those, a hosted target (Linux, the BSDs, Android) is posix; macOS must pass the feature.
  2. Join. PlatformsTree::as_platform_manifest builds one PlatformEntry per platform file: its port row (or an empty one) plus the file’s inherits, arch_profiles, compile and required_env. A row that names no platform, or two rows that name the same one, is an error.
  3. Merge the inherits chain, parent first (PlatformManifest::for_platform):
    • lists append, parent’s entries first;
    • maps (defines_kv, defines_env) take the child’s value per key;
    • mbedtls, pic, compiled_by and each compile field take the child’s value if set, else the parent’s, so an unset child never downgrades a parent;
    • arch_profiles is replaced, not appended, when the child sets it.
  4. Check every platform, not just the selected one: each must resolve, and each port’s include directories must exist. A bad row anywhere fails every zenoh build, which is what keeps the ports nobody is building honest.

Shared shapes

when matcher

Every present field must hold; an empty matcher is always true.

FieldMeaning
target_matchSubstring (or <prefix>* glob) that must be in the target triple.
target_notSubstring that must NOT be in the triple. The special value "embedded" means “the target is one of the known embedded RTOSes”.
if_envEnv var that must be set (any value).
capabilityMap of RFC-0086 facts that must be declared with the given value, resolved through the RFC-0049 ladder (platform, then board, board winning). An undeclared fact matches neither true nor false. The one gate that can differ between two boards of one platform (issue 1143).

ExtraSource

extra_sources = [
  { path = "{src}/system/freertos/system.c" },
  { path = "{src}/system/freertos/lwip/network.c",
    when = { capability = { ip_stack = true } } },
  { path = "{nros}/c/platform/threadx/log_uart.c",
    if_env = "NROS_ZPICO_LOG_TO_UART",
    with_define = ["ZENOH_LOG_PRINT", "zpico_log_print"] },
]

when and if_env both gate the file. with_define is [name] or [name, value], defined whenever the file is compiled.

RequiredEnv

required_env = [
  { name = "THREADX_CONFIG_DIR",
    help = "Directory containing tx_user.h. Example: packages/boards/nros-board-threadx-linux/config" },
]

A missing variable panics with help. With validate_subdir, a present variable whose value lacks that subdirectory panics naming the path. An entry with a when is required only where the matcher holds, so a FreeRTOS board with no network stack does not need an lwIP SDK.

Interpolation tokens

Paths in extra_sources, include_paths and include_paths_conditional pass through manifest::interpolate:

TokenValue
{nros}zpico-sys’s CARGO_MANIFEST_DIR.
{out}OUT_DIR.
{src}zenoh-pico/src/ under {nros}.
{env:VAR}Value of VAR; an unset variable fails the build.
{envpath:VAR}A path-valued env var, resolved through the checkout-path rule (issue 1280).
{source:NAME}A vendored source tree by name, located through nros locate (RFC-0103 D4).
{nuttx_include}The NuttX include root for this arch’s export.
{freertos_port_dir}The FreeRTOS port directory FREERTOS_PORT names, mapped through upstream’s portable/CMakeLists.txt table (phase-349 W3).

The compile

build_zenoh_pico_unified in nros-zpico-build/src/runner.rs is the only place that invokes cc-rs for zenoh-pico, and it is skipped when the row says compiled_by = "platform". In order:

  1. Validate required_env (honouring when).
  2. Generate the version header into {out}/zenoh-pico-version/.
  3. Apply the first [arch.*] profile from arch_profiles that admits the target.
  4. Add the zenoh-sources.txt groups this lane selects, then the row’s extra_sources (when, if_env, with_define).
  5. Add include paths: the generated config, zenoh-pico’s own, the version header and the platform C ABI headers, then the row’s paths (unconditional, then conditional).
  6. Add defines: defines, defines_conditional, defines_kv, defines_env.
  7. Handle mbedTLS per mbedtls, when link-tls is on.
  8. Apply shim slot counts and compile settings, then the pic override.
  9. Compile libzenohpico.a and register rerun_if_env_changed.

The zpico shim (c/zpico/zpico.c) is a separate compile in build_c_shim, and shares the generated zenoh config header with the library (issue 0135).

Adding a platform

  1. The platform file. Create nros-platform.toml beside the platform crate (or in a directory on $NROS_PLATFORMS_DIR): names, and the C-build keys if the platform cross-compiles. Reuse an existing [arch.*] profile by naming it in arch_profiles, or define one.
  2. The port row. Add [port.<name>] to nros-rmw-zenoh/nros-rmw.toml: the ZENOH_* platform define, the system/<x>/ sources as extra_sources, and include paths. If the platform’s own build compiles zenoh-pico, set compiled_by = "platform".
  3. The feature. Add the zpico-sys Cargo feature and its arm in runner.rs’s platform_name derivation.
  4. Check. cargo build -p zpico-sys --features <name> --target <triple>, then just check zenoh-source-manifest and just check arch-profile-resolution.

A new CPU family is one [arch.*] block in each platform file that admits it, plus its name in their arch_profiles. A multi-arch platform lists every profile and lets the first match pick: bare-metal lists cortex-m3, cortex-m4f and riscv32imc, so one platform serves mps2-an385 and the ESP32-C3.

TX tuning benchmarks

The user page TX Throughput & Latency Tuning states the per-platform tx defaults. The measurements behind them: packages/testing/nros-bench/stress-zenoh-zephyr/ (streaming) and packages/testing/nros-tests/tests/w1_zephyr_tx_throughput_measure.rs (timer tiers). Background: docs/roadmap/archived/phase-279-*.md, docs/roadmap/archived/phase-282-*.md and issue #145. The Zephyr default relies on the zenoh-pico fork fix from issue 0213 (declarations bypass the batch). See also book-internals/src/platform/implementation-notes.md.

  • docs/roadmap/archived/phase-136-zpico-sys-unified-build.md — the unified cc-rs build.
  • docs/roadmap/archived/phase-349-rtos-integration-shells.md — W2b, the platform/backend split.
  • RFC-0049 (docs/design/0049-hierarchical-platform-board-config.md) — the platform and board files and their ladder.
  • book/src/concepts/platform-model.md — boards vs platforms.
  • book-internals/src/rmw/backend-host-language-policy.md — RMW host-language policy.

ROS 2 rmw_zenoh Interoperability

This page describes how nros’s zenoh backend speaks the rmw_zenoh_cpp wire protocol: key expressions, liveliness tokens, QoS strings, CDR framing and the per-message attachment. For the user steps (starting the router, running a talker against ros2 topic echo, matching domain ID, QoS and edition), see the book’s ROS 2 Interop page; this page does not repeat them.

Status: WORKING

nros <-> ROS 2 rmw_zenoh communication is fully operational as of January 2025.

Architecture

┌─────────────────────────┐          ┌─────────────────────────┐
│   ROS 2 Node            │          │   nros Node             │
│   (rmw_zenoh_cpp)       │<-------->│   (zenoh-pico)          │
│                         │  Zenoh   │                         │
│   ros2 topic echo       │  Router  │   native-rs-talker      │
│   /chatter              │ (zenohd) │                         │
└─────────────────────────┘          └─────────────────────────┘

Both nodes connect to the same Zenoh router (zenohd). zenoh’s router-free peer mode is not compiled into the nano-ros shim — NROS_SESSION_MODE=peer is refused at session open (issue 0682).

Protocol Requirements

1. Data Topic Key Expression Format

For ROS 2 Humble, the data key expression format is:

<domain_id>/<topic_name>/<type_name>/<type_hash>

Important: For Humble, use TypeHashNotSupported as the type hash (no RIHS01_ prefix).

Example:

0/chatter/std_msgs::msg::dds_::String_/TypeHashNotSupported

Note: Newer ROS 2 versions (Iron+) use RIHS01_<hash> format with actual type hashes.

Implementation: TopicInfo::to_key() in the transport traits module

2. Liveliness Token Format

rmw_zenoh uses Zenoh liveliness tokens for discovery. Without these, ros2 topic list won’t show nros topics.

Node Liveliness Token:

@ros2_lv/<domain>/<zid>/0/0/NN/%/%/<node_name>

Publisher Liveliness Token:

@ros2_lv/<domain>/<zid>/0/11/MP/%/%/<node_name>/<topic>/<type>/RIHS01_<hash>/<qos>

Subscriber Liveliness Token:

@ros2_lv/<domain>/<zid>/0/11/MS/%/%/<node_name>/<topic>/<type>/RIHS01_<hash>/<qos>

Key differences from data keyexprs:

  • Liveliness tokens do use RIHS01_ prefix
  • Topic names use % instead of / (e.g., %chatter not /chatter)
  • ZenohId must be in LSB-first hex format

Implementation: Ros2Liveliness in the transport/zenoh module

3. QoS String Format

The QoS portion of liveliness tokens uses this format:

reliability:durability:history,depth:deadline_sec,deadline_nsec:lifespan_sec,lifespan_nsec:liveliness,liveliness_sec,liveliness_nsec

Values:

  • Reliability: 1=RELIABLE, 2=BEST_EFFORT
  • Durability: 1=TRANSIENT_LOCAL, 2=VOLATILE
  • History: 1=KEEP_LAST

For BEST_EFFORT/VOLATILE with KEEP_LAST depth 1:

2:2:1,1:,:,:,,

Important: Empty values default to RELIABLE which causes QoS mismatch. Always specify explicit values.

3b. TRANSIENT_LOCAL — the @adv cache keyexpr

phase-455 W5 / issue 1341. This section is MEASURED, from a RUST_LOG=zenoh=debug router watching a stock rmw_zenoh_cpp 0.1.9 TRANSIENT_LOCAL pair on 2026-09-13, not read out of upstream source.

rmw_zenoh_cpp 0.1.9 builds its endpoints on zenoh’s ze_advanced_publisher / ze_advanced_subscriber (confirmed with nm -D --undefined-only librmw_zenoh_cpp.so), so transient-local durability is served by QUERY-ON-MATCH rather than by anything in the wire format above:

Declare queryable  <domain>/<topic>/<type>/<hash>/@adv/pub/<zid>/<eid>/_
Declare subscriber <domain>/<topic>/<type>/<hash>
Declare subscriber <domain>/<topic>/<type>/<hash>/@adv/pub/**
Route query    for <domain>/<topic>/<type>/<hash>/@adv/**
Route query    for <domain>/<topic>/<type>/<hash>/@adv/pub/<zid>/<eid>/_
  • The PUBLISHER’s cache is a queryable at <topic keyexpr>/@adv/pub/<zid>/<eid>/_. @adv is zenoh-ext’s own prefix — the key format ${remaining:**}/@adv/${entity:*}/${zid:*}/${eid:*}/${meta:**} is a literal string in libzenohc.so; pub is the entity and the trailing _ is the empty metadata chunk.
  • The SUBSCRIBER issues a GLOBAL history query at <topic keyexpr>/@adv/** when it is created. That intersects any publisher’s cache key, and it is what serves a late joiner.
  • The second query is late-joiner DETECTION: the subscriber also declares a liveliness subscriber on <topic keyexpr>/@adv/pub/** and queries each publisher whose @adv liveliness token appears after it.
  • The reply carries the TOPIC keyexpr, not the queryable’s, and the same attachment a live publication would have. The advanced subscriber accepts a reply keyexpr differing from the query’s, which is what makes that legal.

nano-ros serves the publisher half of this (shim/publisher.rs::transient_local) and declares no @adv liveliness token, so it answers the global query and not the per-publisher one. The subscriber half — querying a stock transient-local publisher on match — is not implemented, and shim/qos.rs::admit refuses TRANSIENT_LOCAL on a subscription rather than pretending.

4. CDR Message Format

Messages use CDR (Common Data Representation) little-endian encoding:

[0x00, 0x01, 0x00, 0x00]  // CDR encapsulation header (LE)
[... CDR payload ...]     // Message data

Implementation: the serdes crate

5. RMW Attachment Format

rmw_zenoh requires metadata attached to each published message. For Humble, the serialization format is:

FieldSizeFormat
sequence_number8 bytesint64, little-endian
timestamp8 bytesint64, little-endian (nanoseconds)
gid_length1 byteVLE encoded (value: 16)
gid16 bytesRandom bytes, constant per publisher

Total: 33 bytes

The serialization uses Zenoh’s ze_serializer to ensure compatibility with zenoh-cpp’s deserializer.

Implementation: serialize_rmw_attachment() in zenoh-pico/src/serializer.rs

6. ZenohId Format

The ZenohId in liveliness tokens must be formatted in LSB-first (little-endian) hex format.

#![allow(unused)]
fn main() {
// Correct: LSB-first
fn to_hex_string(&self) -> String {
    let mut hex = String::new();
    for byte in self.bytes.iter() {  // LSB first
        write!(&mut hex, "{:02x}", byte).ok();
    }
    hex
}
}

Common Issues and Solutions

These are failures of the protocol encoding itself, seen while developing the backend.

Issue: Discovery works but no messages received

Symptom: ros2 topic list shows the topic, ros2 topic info shows correct QoS, but ros2 topic echo receives nothing.

Cause: Data keyexpr format mismatch.

Solution: For Humble, don’t use RIHS01_ prefix in data keyexprs:

  • Wrong: 0/chatter/std_msgs::msg::dds_::String_/RIHS01_TypeHashNotSupported
  • Correct: 0/chatter/std_msgs::msg::dds_::String_/TypeHashNotSupported

Issue: Topic not visible in ros2 topic list

Symptom: nros publishes but ROS 2 doesn’t see the topic.

Cause: Liveliness token format incorrect.

Solutions:

  1. Check ZenohId is LSB-first hex format
  2. Ensure topic name uses % prefix (e.g., %chatter)
  3. Verify QoS string has explicit values, not defaults

User-level failures (QoS mismatch, rmw_zenoh_cpp left in peer mode, domain ID or edition mismatch) are covered in the book’s ROS 2 Interop → Troubleshooting.

Implementation Status

ComponentStatusLocation
Data keyexpr formatDoneTopicInfo::to_key()
Liveliness tokensDoneRos2Liveliness
QoS formatDone2:2:1,1:,:,:,,
CDR serializationDoneserdes crate
RMW attachmentDoneserialize_rmw_attachment()
ZenohId formatDoneZenohId::to_hex_string()
Node integrationDoneConnectedNode

Version Compatibility

ROS 2 VersionType Hash FormatStatus
HumbleTypeHashNotSupportedWorking (default edition)
Iron, JazzyRIHS01_<sha256>Working — the hash is computed at codegen time by the REP-2011 engine in packages/cli/rosidl-resolve/src/rihs.rs (phase-304, RFC-0056)

The edition is chosen per image (ros_edition in system.toml); a Humble-edition image and a Jazzy bus do not discover each other, because the key expressions differ.

References

RMW API — every upstream symbol, side by side

What ROS 2’s rmw asks an implementation for, what nano-ros provides, and the reason wherever the two differ. Derived from three sources — the ROS 2 signature extract, the nano-ros ABI headers, and the authored reason map docs/reference/rmw-api-map.toml that just check rmw-api-parity also reads — so a slot changing shape moves this page in the same commit or fails the gate.

For the prose rationale behind the big divergences, see RMW API: Differences from upstream rmw.h.

How to read a row

The signature says which surface it is on. nano-ros answers an upstream symbol in one of two ways, and C syntax already distinguishes them:

rmw_ret_t (*count_publishers)(...)   // vtable slot — a function pointer,
                                     // may differ per backend
rmw_ret_t rmw_compare_gids_equal(...) // global — a plain exported function,
                                     // defined once for the image

Slot names drop the rmw_ prefix because that is genuinely the name in nros_rmw_vtable_t; showing rmw_count_publishers on the right would flatter the comparison.

Each side is spelled the way its own headers spell it. Upstream writes const rmw_publisher_t * publisher, we write const rmw_publisher_t *publisher, and the two columns keep their own convention rather than being normalised to a third that neither project uses. Parameter NAMES are shown on both sides; the comparison behind the highlighting is on TYPES only, because a renamed argument is not an ABI difference.

The chip says what we DID with the symbol, which is a different question from what the signature shows:

chipmeans
● sameidentical signature and name
● re-shapedone slot, one symbol, different signature
◆ re-mappedanswered, but NOT 1:1 — decomposed, merged, or off this seam. The arrows under it name what provides the capability
✕ not-supporteda decision, permanent; the reason names the constraint
○ not-implementeda gap, with the issue tracking it

same and re-shaped are derived from the signatures, so the map cannot assert a match the types deny. The other three are authored, because no signature can say whether an absent symbol was decomposed or dropped — and not-implemented must name an issue, so silence cannot turn a gap into a decision.

Marks show the difference. Red — upstream takes it, we do not. Green — we take it, upstream does not. Yellow — the name differs from the mechanical one (upstream minus rmw_). A row with no marks is identical on both sides and carries no reason, because there is nothing to explain.

Argument names appear on the right only. The ROS 2 side is read from a signature extract that drops parameter names on purpose — a renamed argument is not an ABI difference, and reporting one trains people to skim. Ours are kept because they are the only place a reader learns what an argument means: size_t twice in a row is not a signature anyone can act on. The comparison itself runs on types, so a name never colours a row.

An empty right-hand cell reads rejected when the symbol is deliberately absent, or answered elsewhere when the capability ships outside the RMW seam — in the executor, in codegen, or inside a backend. Both carry the reason.

A dimmed right-hand cell is an inert slot: declared in the vtable and read by nothing. A reserved shape, not a working capability (issue 0800) — and some of them ARE filled by a backend, which is an implementation no caller reaches rather than a capability (phase-428 W8).

What is being compared

Not rmw.h against rmw_vtable.h — that comparison is wrong twice. Upstream declares 177 RMW_PUBLIC functions, but most are utilities rmw itself defines; an implementation links those, so comparing against 177 manufactures ~90 phantom gaps. And our vtable is only the backend seam: plenty of what upstream calls rmw lives one layer up in the executor, one layer down in a backend, or in codegen.

So the contract is empirical — the 88 rmw_* symbols that librmw_fastrtps_cpp.so and librmw_zenoh_cpp.so both define. Two independent implementations with identical symbol sets is a better definition of “what an rmw must provide” than any reading of the headers.

vtableglobalexecutorplatformbuild timeserdesnonetotal
same16117
re-shaped32133
re-mapped18413329
not supported — by decision99
total6624133988

Read a row for what we did, a column for where it lives. Only not implemented should shrink over time; not supported is the one line that is a decision rather than a state, so it is expected to stay.

Vocabulary — why we say session where upstream says context

An rmw_context_t is two things in one struct: the options the process was initialised with (rmw_init_options_t — domain id, enclave, security, discovery) and an opaque impl the backend allocates to hold its running state. Upstream can fuse them because both are per-process, heap-allocated at rmw_init and freed at rmw_shutdown.

Ours are not one thing. The options half is a build-time POD — that is the recorded reason rmw_init_options_{init,copy,fini} are declined, since “copy” is = and “fini” is nothing — and it lives on nros::Context. The running half is a Session, one per backend, created and destroyed through vtable slots, and an image may hold several at once: the bridge entries run a zenoh ingress and a Cyclone or XRCE egress in one process. There is no per-process singleton to name.

So the rename is unavailable in both directions. Context is already taken by the half that is not the backend state, and calling the backend state context would promise a one-per-process object that a bridge image visibly does not have. The word session is also the one the transports themselves use (a zenoh session, an XRCE-DDS session), which is what a reader of create_session is actually looking at.

Read rmw_context_t on this page as our Context plus one or more Sessions, and the rmw_init/rmw_shutdown rows follow from that.

Every contract symbol

ROS 2nano-rosreason
rmw_ret_t
rmw_borrow_loaned_message(
  const rmw_publisher_t * publisher,
  const rosidl_message_type_support_t * type_support,
  void * * ros_message
)
rmw_ret_t
(*borrow_loaned_message)(
  const rmw_publisher_t *publisher,
  size_t requested_len,
  rmw_mut_byte_span_t *out_slot,
  rmw_loan_token_t **out_token
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_client_request_publisher_get_actual_qos(
  const rmw_client_t * client,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*client_request_publisher_get_actual_qos)(
  const rmw_client_t *client,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_client_response_subscription_get_actual_qos(
  const rmw_client_t * client,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*client_response_subscription_get_actual_qos)(
  const rmw_client_t *client,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_client_set_on_new_response_callback(
  rmw_client_t * client,
  rmw_event_callback_t callback,
  const void * user_data
)
rmw_ret_t
(*client_set_on_new_response_callback)(
  rmw_client_t *client,
  rmw_event_callback_t callback,
  const void *user_data
)
● same
rmw_ret_t
rmw_compare_gids_equal(
  const rmw_gid_t * gid1,
  const rmw_gid_t * gid2,
  bool * result
)
rmw_ret_t
rmw_compare_gids_equal(
  const rmw_gid_t *gid1,
  const rmw_gid_t *gid2,
  bool *result
)
● same
rmw_ret_t
rmw_context_fini(rmw_context_t * context)
rmw_ret_t
(*destroy_session)(rmw_session_t *session)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is destroy_session.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_count_publishers(
  const rmw_node_t * node,
  const char * topic_name,
  size_t * count
)
rmw_ret_t
(*count_publishers)(
  const rmw_session_t *session,
  const char *topic_name,
  size_t *count
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_count_subscribers(
  const rmw_node_t * node,
  const char * topic_name,
  size_t * count
)
rmw_ret_t
(*count_subscribers)(
  const rmw_session_t *session,
  const char *topic_name,
  size_t *count
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_client_t *
rmw_create_client(
  const rmw_node_t * node,
  const rosidl_service_type_support_t * type_support,
  const char * service_name,
  const rmw_qos_profile_t * qos_policies
)
rmw_ret_t
(*create_client)(
  const rmw_node_t *node,
  const rmw_service_type_support_t *type_support,
  const char *service_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  rmw_client_t *out
)
● re-shaped
types are resolved at BUILD time — As the message typesupport.
rmw_guard_condition_t *
rmw_create_guard_condition(rmw_context_t * context)
—
◆ re-mapped · executor
executor: EntryKind::GuardCondition
EntryKind::GuardCondition; no transport variation, and once `wait` is declined there is no backend consumer
rmw_node_t *
rmw_create_node(
  rmw_context_t * context,
  const char * name,
  const char * namespace_
)
rmw_ret_t
(*create_node)(
  rmw_session_t *session,
  const char *name,
  const char *namespace_,
  rmw_node_t *out
)
● re-shaped
the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_publisher_t *
rmw_create_publisher(
  const rmw_node_t * node,
  const rosidl_message_type_support_t * type_support,
  const char * topic_name,
  const rmw_qos_profile_t * qos_profile,
  const rmw_publisher_options_t * publisher_options
)
rmw_ret_t
(*create_publisher)(
  const rmw_node_t *node,
  const rmw_message_type_support_t *type_support,
  const char *topic_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  const rmw_publisher_options_t *options,
  rmw_publisher_t *out
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.
rmw_service_t *
rmw_create_service(
  const rmw_node_t * node,
  const rosidl_service_type_support_t * type_support,
  const char * service_name,
  const rmw_qos_profile_t * qos_profile
)
rmw_ret_t
(*create_service)(
  const rmw_node_t *node,
  const rmw_service_type_support_t *type_support,
  const char *service_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  rmw_service_t *out
)
● re-shaped
types are resolved at BUILD time — As the message typesupport.
rmw_subscription_t *
rmw_create_subscription(
  const rmw_node_t * node,
  const rosidl_message_type_support_t * type_support,
  const char * topic_name,
  const rmw_qos_profile_t * qos_policies,
  const rmw_subscription_options_t * subscription_options
)
rmw_ret_t
(*create_subscription)(
  const rmw_node_t *node,
  const rmw_message_type_support_t *type_support,
  const char *topic_name,
  uint32_t domain_id,
  const rmw_qos_profile_t *qos,
  const rmw_subscription_options_t *options,
  rmw_subscription_t *out
)
● re-shaped
types are resolved at BUILD time — Upstream resolves a type through a runtime typesupport pointer. Ours are resolved by codegen before the image exists, so the seam carries bytes and a type hash rather than a pointer to a type description that would have to be walked at runtime.
rmw_wait_set_t *
rmw_create_wait_set(
  rmw_context_t * context,
  size_t max_conditions
)
—
◆ re-mapped · executor
executor: the arena entry table, allocated once
the executor's arena entry table IS the set, allocated once; a per-wait set would be heap on the spin path
rmw_ret_t
rmw_deserialize(
  const rmw_serialized_message_t * serialized_message,
  const rosidl_message_type_support_t * type_support,
  void * ros_message
)
—
◆ re-mapped · 1 → 2
nros-serdes: Deserialize / DeserializeView
codegen packs
as rmw_serialize
rmw_ret_t
rmw_destroy_client(
  rmw_node_t * node,
  rmw_client_t * client
)
rmw_ret_t
(*destroy_client)(rmw_client_t *client)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_guard_condition(rmw_guard_condition_t * guard_condition)
—
◆ re-mapped · executor
executor: EntryKind::GuardCondition
as rmw_create_guard_condition
rmw_ret_t
rmw_destroy_node(rmw_node_t * node)
rmw_ret_t
(*destroy_node)(rmw_node_t *node)
● same
rmw_ret_t
rmw_destroy_publisher(
  rmw_node_t * node,
  rmw_publisher_t * publisher
)
rmw_ret_t
(*destroy_publisher)(rmw_publisher_t *publisher)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_service(
  rmw_node_t * node,
  rmw_service_t * service
)
rmw_ret_t
(*destroy_service)(rmw_service_t *server)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_subscription(
  rmw_node_t * node,
  rmw_subscription_t * subscription
)
rmw_ret_t
(*destroy_subscription)(rmw_subscription_t *subscription)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_destroy_wait_set(rmw_wait_set_t * wait_set)
—
◆ re-mapped · executor
executor: the arena entry table, allocated once
as rmw_create_wait_set
rmw_ret_t
rmw_event_set_callback(
  rmw_event_t * event,
  rmw_event_callback_t callback,
  const void * user_data
)
rmw_ret_t
(*publisher_event_init)(
  const rmw_publisher_t *publisher,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is publisher_event_init.

events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.

one wake callback per session — Upstream installs a callback per entity. Ours is `set_wake_callback` on the session: one executor drives several backends, so the wake has to be attributable to a session rather than to whichever entity fired.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
bool
rmw_feature_supported(rmw_feature_t feature)
bool
(*feature_supported)(rmw_feature_t feature)
◆ re-mapped
per-capability slots, answered by nullity or a dedicated probe
inert — declared and READ BY NOTHING, and filled by nothing.
rmw_ret_t
rmw_fini_publisher_allocation(rmw_publisher_allocation_t * allocation)
—
✕ not-supported · by decision
as above
rmw_ret_t
rmw_fini_subscription_allocation(rmw_subscription_allocation_t * allocation)
—
✕ not-supported · by decision
as above
rmw_ret_t
rmw_get_client_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_client_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_gid_for_publisher(
  const rmw_publisher_t * publisher,
  rmw_gid_t * gid
)
rmw_ret_t
(*get_gid_for_publisher)(
  const rmw_publisher_t *publisher,
  rmw_gid_t *gid
)
● same
const char *
rmw_get_implementation_identifier(void)
const char *
(*get_implementation_identifier)(void)
● same
rmw_ret_t
rmw_get_node_names(
  const rmw_node_t * node,
  rcutils_string_array_t * node_names,
  rcutils_string_array_t * node_namespaces
)
rmw_ret_t
(*get_node_names)(
  const rmw_session_t *session,
  rmw_node_visitor_t visitor  /* visit(ctx, node_name, node_namespace, enclave) */
)
◆ re-mapped · 2 upstream → 1 slot
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

visitor, not an owning out-param — As `rmw_names_and_types_t` — an owning array upstream, a visitor here.
rmw_ret_t
rmw_get_node_names_with_enclaves(
  const rmw_node_t * node,
  rcutils_string_array_t * node_names,
  rcutils_string_array_t * node_namespaces,
  rcutils_string_array_t * enclaves
)
rmw_ret_t
(*get_node_names)(
  const rmw_session_t *session,
  rmw_node_visitor_t visitor  /* visit(ctx, node_name, node_namespace, enclave) */
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is get_node_names.

the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

visitor, not an owning out-param — As `rmw_names_and_types_t` — an owning array upstream, a visitor here.
rmw_ret_t
rmw_get_publisher_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_publisher_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_publishers_info_by_topic(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_array_t * publishers_info
)
rmw_ret_t
(*get_publishers_info_by_topic)(
  const rmw_session_t *session,
  const char *topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_visitor_t visitor  /* visit(ctx, info) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — `rmw_topic_endpoint_info_array_t` and `rmw_network_flow_endpoint_array_t` are heap-owning arrays with their own `fini`. Visited one entry at a time instead.
const char *
rmw_get_serialization_format(void)
const char *
(*get_serialization_format)(void)
◆ re-mapped
vtable: get_serialization_format, per session
rmw_ret_t
rmw_get_serialized_message_size(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  size_t * size
)
—
◆ re-mapped · serdes
nros-serdes: size.rs (size_bound / max_serialized_size)
nros-serdes `size.rs` — `size_bound` / `max_serialized_size` / `buffer_fits` / `serialized_size`, per TYPE and `const` where the type is bounded (phase-380 W1, issue 0776, resolved). Not a vtable slot and never will be: upstream takes a `rosidl_message_type_support_t *`, declined ABI-wide since W3.c, so the symbol cannot cross this seam — but the CAPABILITY it provides is answered, which is what `layer` records. This read `gap` until 2026-08-27, after 0776 had closed
rmw_ret_t
rmw_get_service_names_and_types(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_service_names_and_types)(
  const rmw_session_t *session,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_service_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  rmw_names_and_types_t * service_names_and_types
)
rmw_ret_t
(*get_service_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_subscriber_names_and_types_by_node(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * node_name,
  const char * node_namespace,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_subscriber_names_and_types_by_node)(
  const rmw_session_t *session,
  const char *node_name,
  const char *node_namespace,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_get_subscriptions_info_by_topic(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  const char * topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_array_t * subscriptions_info
)
rmw_ret_t
(*get_subscriptions_info_by_topic)(
  const rmw_session_t *session,
  const char *topic_name,
  bool no_mangle,
  rmw_topic_endpoint_info_visitor_t visitor  /* visit(ctx, info) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — `rmw_topic_endpoint_info_array_t` and `rmw_network_flow_endpoint_array_t` are heap-owning arrays with their own `fini`. Visited one entry at a time instead.
rmw_ret_t
rmw_get_topic_names_and_types(
  const rmw_node_t * node,
  rcutils_allocator_t * allocator,
  bool no_demangle,
  rmw_names_and_types_t * topic_names_and_types
)
rmw_ret_t
(*get_topic_names_and_types)(
  const rmw_session_t *session,
  bool no_demangle,
  rmw_names_and_types_visitor_t visitor  /* visit(ctx, name, types, types_count) */
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.

no allocator at this seam — This ABI does not allocate. Upstream hands an `rcutils_allocator_t` so the implementation can size and own the result; ours writes into a caller-owned byte range whose capacity the caller already knows. An allocator parameter cannot cross a seam that has nothing to give it.

visitor, not an owning out-param — Upstream fills a heap-owning out-parameter the caller must `fini`. We pass a VISITOR callback invoked once per entry, with borrowed strings valid for the call. No allocation, bounded work, and nothing for a caller to leak — which is what makes the graph family available on a target with no allocator at all.
rmw_ret_t
rmw_init(
  const rmw_init_options_t * options,
  rmw_context_t * context
)
rmw_ret_t
(*create_session)(
  const char *locator,
  uint8_t mode,
  uint32_t domain_id,
  const char *node_name,
  const rmw_session_options_t *options,
  rmw_session_t *out
)
● re-shaped
renamed — the slot is create_session.

build-time POD, no init/copy/fini — Upstream's options own heap and carry an allocator, which is why they need the init/copy/fini trio. Ours are a build-time POD passed by value.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_init_options_copy(
  const rmw_init_options_t * src,
  rmw_init_options_t * dst
)
—
◆ re-mapped · build time
build time: `copy` is `=`
as rmw_init_options_init — "copy" is `=`
rmw_ret_t
rmw_init_options_fini(rmw_init_options_t * init_options)
—
◆ re-mapped · build time
build time: `fini` is nothing
as rmw_init_options_init — "fini" is nothing
rmw_ret_t
rmw_init_options_init(
  rmw_init_options_t * init_options,
  rcutils_allocator_t allocator
)
—
◆ re-mapped · build time
build time: rmw_init_options_t is a POD
upstream needs the init/copy/fini trio because its options OWN heap and carry an rcutils_allocator_t, which cannot cross this seam; ours is a build-time POD. Does NOT decide what the options CARRY: `localhost_only` and `enclave` are CARRIED by `create_session`'s NULLable `rmw_session_options_t` (issue 0808 closed issue 0785's two gaps; Cyclone honours `localhost_only` by restricting discovery to loopback, a backend with no discovery to restrict ignores both), `security_options` is declined on the target (a DDS-SROS2 keystore path, no filesystem and no security plugin there), and `discovery_options` is an IRON field this Humble contract does not have
rmw_ret_t
rmw_init_publisher_allocation(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  rmw_publisher_allocation_t * allocation
)
—
✕ not-supported · by decision
upstream's first two parameters are a `rosidl_message_type_support_t *` and a `rosidl_runtime_c__Sequence__bound *`. The sequence bound is declined ABI-wide (it is a rosidl runtime type), and the third parameter is an `rmw_publisher_allocation_t`, whose body is `{const char *implementation_identifier; void *data;}` — an opaque handle whose contents only the implementation that made it can read. Nothing crosses this seam usefully. THREE earlier reasons here were wrong, and the corrections are the record worth keeping: 'pools are baked' (false for four backends of five — issue 0777, now RESOLVED with the finding that every deviation reason built on that clause was false); 'upstream pre-sizes an rcutils_allocator_t the caller owns' (false — verified against Humble's rmw/types.h); and, until phase-406, a tail deferring the capability question to 0777 as though it were still open. It is not: 0777 closed, and the pre-sizing CAPABILITY for cyclonedds is a separate question that needs its own issue if anyone wants it, not a citation here.
rmw_ret_t
rmw_init_subscription_allocation(
  const rosidl_message_type_support_t * type_support,
  const rosidl_runtime_c__Sequence__bound * message_bounds,
  rmw_subscription_allocation_t * allocation
)
—
✕ not-supported · by decision
as above
const rmw_guard_condition_t *
rmw_node_get_graph_guard_condition(const rmw_node_t * node)
rmw_ret_t
(*node_get_graph_guard_condition)(
  rmw_session_t *session,
  rmw_event_callback_t callback,
  const void *user_data
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_publish(
  const rmw_publisher_t * publisher,
  const void * ros_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish)(
  const rmw_publisher_t *publisher,
  rmw_byte_span_t payload
)
◆ re-mapped · 2 upstream → 1 slot
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publish_loaned_message(
  const rmw_publisher_t * publisher,
  void * ros_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish_loaned_message)(
  const rmw_publisher_t *publisher,
  rmw_loan_token_t *token,
  size_t actual_len
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publish_serialized_message(
  const rmw_publisher_t * publisher,
  const rmw_serialized_message_t * serialized_message,
  rmw_publisher_allocation_t * allocation
)
rmw_ret_t
(*publish)(
  const rmw_publisher_t *publisher,
  rmw_byte_span_t payload
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is publish.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_publisher_assert_liveliness(const rmw_publisher_t * publisher)
rmw_ret_t
(*publisher_assert_liveliness)(const rmw_publisher_t *publisher)
● same
rmw_ret_t
rmw_publisher_count_matched_subscriptions(
  const rmw_publisher_t * publisher,
  size_t * subscription_count
)
rmw_ret_t
(*publisher_count_matched_subscriptions)(
  const rmw_publisher_t *publisher,
  size_t *subscription_count
)
● same
rmw_ret_t
rmw_publisher_event_init(
  rmw_event_t * rmw_event,
  const rmw_publisher_t * publisher,
  rmw_event_type_t event_type
)
rmw_ret_t
(*publisher_event_init)(
  const rmw_publisher_t *publisher,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
◆ re-mapped · 2 upstream → 1 slot
events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.
rmw_ret_t
rmw_publisher_get_actual_qos(
  const rmw_publisher_t * publisher,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*publisher_get_actual_qos)(
  const rmw_publisher_t *publisher,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_publisher_get_network_flow_endpoints(
  const rmw_publisher_t * publisher,
  rcutils_allocator_t * allocator,
  rmw_network_flow_endpoint_array_t * network_flow_endpoint_array
)
—
✕ not-supported · by decision
DECLINED, phase-407: reporting the transport's 5-tuples is diagnostics, and the value is in a multi-homed deployment with dynamic discovery — the case least like this ABI's target, where one interface and a static config mean the answer is already in the file you wrote. Landed in phase-376 W5 for shape parity, never filled, never read (issue 0956). Cheap to revisit for Cyclone alone if `ros2` tooling ever needs to introspect a nano-ros node: it is read-only and has no wire effect.
rmw_ret_t
rmw_publisher_wait_for_all_acked(
  const rmw_publisher_t * publisher,
  rmw_time_t wait_timeout
)
rmw_ret_t
(*publisher_wait_for_all_acked)(
  const rmw_publisher_t *publisher,
  uint32_t timeout_ms
)
✕ not-supported · by decision
inert — declared and READ BY NOTHING, and filled by nothing.

milliseconds, not a struct — `rmw_time_t` is a `{sec, nsec}` pair. This ABI carries `uint32_t` milliseconds throughout — the resolution every supported platform clock actually offers, and the unit the deadline arithmetic already uses.
rmw_ret_t
rmw_qos_profile_check_compatible(
  const rmw_qos_profile_t publisher_profile,
  const rmw_qos_profile_t subscription_profile,
  rmw_qos_compatibility_type_t * compatibility,
  char * reason,
  size_t reason_size
)
rmw_ret_t
rmw_qos_profile_check_compatible(
  rmw_qos_profile_t publisher_profile,
  rmw_qos_profile_t subscription_profile,
  rmw_qos_compatibility_type_t *compatibility,
  char *reason,
  size_t reason_size
)
● re-shaped
by pointer, not by value — Upstream passes two `rmw_qos_profile_t` BY VALUE. The struct is ~80 bytes, and a by-value copy of it crosses the seam twice on every compatibility check — on a target where the call may run from a 512-byte task stack. Ours takes `const rmw_qos_profile_t *`, which is the same information without the copies.
rmw_ret_t
rmw_return_loaned_message_from_publisher(
  const rmw_publisher_t * publisher,
  void * loaned_message
)
rmw_ret_t
(*return_loaned_message_from_publisher)(
  const rmw_publisher_t *publisher,
  rmw_loan_token_t *token
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_return_loaned_message_from_subscription(
  const rmw_subscription_t * subscription,
  void * loaned_message
)
rmw_ret_t
(*return_loaned_message_from_subscription)(
  const rmw_subscription_t *subscription,
  rmw_loan_token_t *token
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_send_request(
  const rmw_client_t * client,
  const void * ros_request,
  int64_t * sequence_id
)
rmw_ret_t
(*send_request)(
  const rmw_client_t *client,
  rmw_byte_span_t request,
  int64_t *sequence_id
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_send_response(
  const rmw_service_t * service,
  rmw_request_id_t * request_header,
  void * ros_response
)
rmw_ret_t
(*send_response)(
  const rmw_service_t *server,
  int64_t seq,
  rmw_byte_span_t response
)
● re-shaped
reply against the backend's token — Upstream's `send_response` names the request by the client's `rmw_request_id_t`. Ours takes back `int64_t seq`, the reply-correlation token `take_request` returned — a reply-slot index on Cyclone, XRCE and zenoh — and the backend maps it to the requester itself. The client's own request id is reported separately, by `take_request_with_info`.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_serialize(
  const void * ros_message,
  const rosidl_message_type_support_t * type_support,
  rmw_serialized_message_t * serialized_message
)
—
◆ re-mapped · 1 → 2
nros-serdes: Serialize
codegen packs
nros-serdes (`Serialize`/`Deserialize`/`DeserializeView`) plus the per-language codegen packs; CDR for an IDL type is fixed by ROS interop, so a per-backend answer would be a DEFECT. Not a slot for the same reason it is not per-backend, and because upstream's parameters are two things this ABI declined anyway — a typesupport pointer and an `rmw_serialized_message_t`, which is an `rcutils_uint8_array_t` carrying an ALLOCATOR, at a seam with no allocator
rmw_ret_t
rmw_service_request_subscription_get_actual_qos(
  const rmw_service_t * service,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*service_request_subscription_get_actual_qos)(
  const rmw_service_t *service,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_service_response_publisher_get_actual_qos(
  const rmw_service_t * service,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*service_response_publisher_get_actual_qos)(
  const rmw_service_t *service,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_service_server_is_available(
  const rmw_node_t * node,
  const rmw_client_t * client,
  bool * is_available
)
rmw_ret_t
(*service_server_is_available)(
  const rmw_client_t *client,
  bool *out_available
)
● re-shaped
the SESSION is the seam — Upstream passes the node into almost every call. Our vtable is scoped to a session handle that already knows its node, so re-passing it would ask the caller to carry an identity the callee holds — the same argument that decided `handle-owns-node` in the C API, one layer down.
rmw_ret_t
rmw_service_set_on_new_request_callback(
  rmw_service_t * service,
  rmw_event_callback_t callback,
  const void * user_data
)
rmw_ret_t
(*service_set_on_new_request_callback)(
  rmw_service_t *service,
  rmw_event_callback_t callback,
  const void *user_data
)
● same
rmw_ret_t
rmw_set_log_severity(rmw_log_severity_t severity)
rmw_ret_t
(*set_log_severity)(rmw_log_severity_t severity)
● same
rmw_ret_t
rmw_shutdown(rmw_context_t * context)
rmw_ret_t
(*destroy_session)(rmw_session_t *session)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is destroy_session.

the SESSION is the seam — As `rmw_node_t`: upstream's context is the process-wide init state, and ours is the session the slot is being called on.
rmw_ret_t
rmw_subscription_count_matched_publishers(
  const rmw_subscription_t * subscription,
  size_t * publisher_count
)
rmw_ret_t
(*subscription_count_matched_publishers)(
  const rmw_subscription_t *subscription,
  size_t *publisher_count
)
● same
rmw_ret_t
rmw_subscription_event_init(
  rmw_event_t * rmw_event,
  const rmw_subscription_t * subscription,
  rmw_event_type_t event_type
)
rmw_ret_t
(*subscription_event_init)(
  const rmw_subscription_t *subscription,
  rmw_event_type_t kind,
  uint32_t deadline_ms,
  rmw_status_event_callback_t cb,
  void *user_context
)
● re-shaped
events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.
rmw_ret_t
rmw_subscription_get_actual_qos(
  const rmw_subscription_t * subscription,
  rmw_qos_profile_t * qos
)
rmw_ret_t
(*subscription_get_actual_qos)(
  const rmw_subscription_t *subscription,
  rmw_qos_profile_t *qos
)
● same
rmw_ret_t
rmw_subscription_get_content_filter(
  const rmw_subscription_t * subscription,
  rcutils_allocator_t * allocator,
  rmw_subscription_content_filter_options_t * options
)
—
✕ not-supported · by decision
as rmw_subscription_set_content_filter — declined with it, since a getter for a filter nothing can set is not a capability (issue 0956).
rmw_ret_t
rmw_subscription_get_network_flow_endpoints(
  const rmw_subscription_t * subscription,
  rcutils_allocator_t * allocator,
  rmw_network_flow_endpoint_array_t * network_flow_endpoint_array
)
—
✕ not-supported · by decision
as rmw_publisher_get_network_flow_endpoints (issue 0956).
rmw_ret_t
rmw_subscription_set_content_filter(
  rmw_subscription_t * subscription,
  const rmw_subscription_content_filter_options_t * options
)
—
✕ not-supported · by decision
DECLINED, phase-407: network-side content filtering needs a DDS feature our pinned Cyclone does not have. 0.10.5 exposes `dds_set_topic_filter{,_and_arg,_extended}` — a LOCAL sample callback applied after the message arrives — and not `dds_create_topic_filtered`, which is the one that propagates the predicate to the writer. Shipping the local filter under this name would promise saved BANDWIDTH and deliver saved CPU, which is the class of deviation reason W4 spent its time undoing. The slot existed from phase-376 W5 to 2026-08-31 and no backend ever filled it (issue 0956).
rmw_ret_t
rmw_subscription_set_on_new_message_callback(
  rmw_subscription_t * subscription,
  rmw_event_callback_t callback,
  const void * user_data
)
rmw_ret_t
(*subscription_set_on_new_message_callback)(
  rmw_subscription_t *subscription,
  rmw_event_callback_t callback,
  const void *user_data
)
● same
rmw_ret_t
rmw_take(
  const rmw_subscription_t * subscription,
  void * ros_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *out,
  bool *taken
)
◆ re-mapped · 2 upstream → 1 slot
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_event(
  const rmw_event_t * event_handle,
  void * event_info,
  bool * taken
)
rmw_ret_t
(*subscription_take_event)(
  const rmw_subscription_t *subscription,
  rmw_event_type_t kind,
  rmw_event_payload_t *out,
  bool *taken
)
● re-shaped
renamed — the slot is subscription_take_event.

events are a slot family, not an event object — Upstream models an event as an object you take from. Ours are per-kind slots on the session (`take_event` and the event mask in `rmw_event.h`), so the object that would carry the kind is the slot itself.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_loaned_message(
  const rmw_subscription_t * subscription,
  void * * loaned_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_loaned_message)(
  const rmw_subscription_t *subscription,
  rmw_byte_span_t *out_view,
  rmw_loan_token_t **out_token,
  bool *taken
)
● re-shaped
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_loaned_message_with_info(
  const rmw_subscription_t * subscription,
  void * * loaned_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_loaned_message_with_info)(
  const rmw_subscription_t *subscription,
  rmw_byte_span_t *out_view,
  rmw_loan_token_t **out_token,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 1 → 2
take_loaned_message
the attachment on the message already taken
inert — declared and READ BY NOTHING, and filled by nothing.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_request(
  const rmw_service_t * service,
  rmw_service_info_t * request_header,
  void * ros_request,
  bool * taken
)
rmw_ret_t
(*take_request)(
  const rmw_service_t *server,
  rmw_mut_byte_span_t *request,
  int64_t *seq_out,
  bool *taken
)
◆ re-mapped · 1 → 2
take_request
take_request_with_info
the reply token beside, the metadata in a sibling slot — `take_request` returns only `int64_t *seq_out`, the backend's reply-correlation token that `send_response` takes back — all a server needs to answer. The rest of upstream's `rmw_service_info_t` (the requesting client's `request_id` and the two timestamps) is carried by the sibling slot `take_request_with_info` (issue 2081), which takes a caller-owned `rmw_service_info_t *` field for field, exactly upstream's shape. The runtime dispatches through it whenever a backend fills it: Cyclone DDS reports `request_id` and `source_timestamp`, XRCE-DDS `request_id` and `received_timestamp`; the zenoh backend reports `request_id`, `source_timestamp` and `received_timestamp` through the Rust adapter's side table instead (issue 1942). `received_timestamp` is the server's wall clock when the request arrived, as `rmw_zenoh_cpp` stamps it (issue 2135); Cyclone 0.10 has no reception time and reports none. A field not reported is zero.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_response(
  const rmw_client_t * client,
  rmw_service_info_t * request_header,
  void * ros_response,
  bool * taken
)
rmw_ret_t
(*take_response)(
  const rmw_client_t *client,
  rmw_mut_byte_span_t *reply,
  int64_t *seq_out,
  bool *taken
)
◆ re-mapped · 1 → 2
take_response
take_response_with_info
the sequence number beside, the metadata in a sibling slot — `take_response` returns only `int64_t *seq_out`, the `sequence_id` `send_request` handed out, which is what the runtime matches a reply against (issue 0778). The rest of upstream's `rmw_service_info_t` — the `request_id` the reply answers (this client's request-writer GUID and its sequence number), the server's send time and the client's receive time — is carried by the sibling slot `take_response_with_info` (issue 2136), caller-owned and field for field. The runtime dispatches through it whenever a backend fills it: zenoh reports all three (the reply attachment and the reply callback's wall-clock stamp, as `rmw_zenoh_cpp`); Cyclone DDS `request_id` and `source_timestamp` (stock `rmw_cyclonedds_cpp`'s layout; 0.10 has no reception time); XRCE-DDS `received_timestamp` only, because the Agent owns the request writer and its GUID never reaches the client. Rust reads it through `Promise::take_with_info` and `Node::create_client_with_callback_with_info` (issue 2148), C through `rclc_executor_add_client_with_request_id`, C++ through `PollClient::take_response(response, request_header)` (issue 2148). A field not reported is zero.

bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.
rmw_ret_t
rmw_take_sequence(
  const rmw_subscription_t * subscription,
  size_t count,
  rmw_message_sequence_t * message_sequence,
  rmw_message_info_sequence_t * message_info_sequence,
  size_t * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_sequence)(
  const rmw_subscription_t *subscription,
  uint8_t *buf,
  size_t per_msg_cap,
  size_t max_msgs,
  size_t *out_lens,
  size_t *taken
)
● re-shaped
visitor, not an owning out-param — `rmw_take_sequence`'s message and info sequences are caller-owned arrays sized by an allocator. The bounded form is a repeated take, which is what the executor does.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_serialized_message(
  const rmw_subscription_t * subscription,
  rmw_serialized_message_t * serialized_message,
  bool * taken,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *out,
  bool *taken
)
◆ re-mapped · 2 upstream → 1 slot
renamed — the slot is take.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_serialized_message_with_info(
  const rmw_subscription_t * subscription,
  rmw_serialized_message_t * serialized_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_with_info)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *message,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 2 upstream → 1 slot
take
the attachment on the message already taken
renamed — the slot is take_with_info.

no allocator at this seam — `rmw_serialized_message_t` is an `rcutils_uint8_array_t`, which carries an allocator. The byte range and its length cross instead.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_take_with_info(
  const rmw_subscription_t * subscription,
  void * ros_message,
  bool * taken,
  rmw_message_info_t * message_info,
  rmw_subscription_allocation_t * allocation
)
rmw_ret_t
(*take_with_info)(
  const rmw_subscription_t *subscription,
  rmw_mut_byte_span_t *message,
  bool *taken,
  rmw_message_info_t *message_info
)
◆ re-mapped · 2 upstream → 1 slot
take
the attachment on the message already taken
bytes, not an untyped pointer — Upstream's `void *` is the message, interpreted through the typesupport pointer beside it. With types resolved at build time there is nothing to interpret it WITH, so the seam carries an explicit byte range and its length — which is also what makes the buffer's capacity checkable at the call.

pre-allocation declined ABI-wide — `rmw_publisher_allocation_t` / `rmw_subscription_allocation_t` are upstream's pre-sizing handles. Declined at this seam because their first parameters are a typesupport pointer and a sequence bound, both declined ABI-wide. The CAPABILITY question — can a backend pre-size — is live for cyclonedds alone, and issue 0777 is CLOSED, so it is no longer a place to route it: 0777 resolved with the finding that every deviation reason built on "pools are baked" was false, which settles the reason and not the capability. Anyone who wants pre-sizing for cyclonedds needs a NEW issue; a citation to a resolved one is not a reason.
rmw_ret_t
rmw_trigger_guard_condition(const rmw_guard_condition_t * guard_condition)
—
◆ re-mapped · platform
platform: the wake primitive, via GuardCondition::trigger
GuardCondition::trigger -> the platform wake primitive; ISR-safety is a platform-ABI guarantee no backend makes
rmw_ret_t
rmw_wait(
  rmw_subscriptions_t * subscriptions,
  rmw_guard_conditions_t * guard_conditions,
  rmw_services_t * services,
  rmw_clients_t * clients,
  rmw_events_t * events,
  rmw_wait_set_t * wait_set,
  const rmw_time_t * wait_timeout
)
—
◆ re-mapped · 1 → 5
has_data
has_request
drive_io
set_wake_callback
next_deadline_ms
has_data/has_request + drive_io + set_wake_callback + next_deadline_ms ARE this, decomposed. A vtable `wait` would add only the BLOCK, moved from the platform into a backend that can only block on its own handles — while one executor drives sessions from several backends, timers fire off the platform clock, and guard conditions fire from an ISR

Reproduce

commandasks
just check rmw-api-parityis every contract symbol classified
just check rmw-abi-shapedoes the vtable mirror it — name, args, return
just check rmw-slot-producerswhich slots anything actually writes or reads
python3 scripts/rmw-api-parity.py --contractre-derive the contract from an installed impl

RMW vs upstream: the decision ledger

The user page RMW API: Differences from upstream rmw.h states what is true of the vtable today. This page keeps the trail behind those statements: which phase, issue or RFC made each decision, and what the page used to say before the decision landed. Read it before changing a divergence, so the reason it exists is not lost.

Issue files live under docs/issues/archived/ unless noted; phase docs under docs/roadmap/archived/.

Vocabulary and slot shape

  • rmw names. The vtable once used its own vocabulary (subscriber, service_server, service_client, open/close). Phase-301 (phase-301-rmw-shape-alignment.md, issues 0240 and 0241) aligned it with upstream: rmw_subscription_t / rmw_service_t / rmw_client_t, create_session / destroy_session, transport hints moved out of rmw_qos_profile_t into per-create options structs, and the deprecated blocking call_raw slot was deleted. The Rust traits were renamed in the same phase (Subscriber → Subscription, ServiceServerTrait → ServiceTrait, ServiceClientTrait → ClientTrait).
  • can_loan_messages is derived (issue 0814). The flag and the borrow_loaned_message slot had drifted in both directions: nothing outside a test ever wrote true (so a zenoh build whose loans worked advertised that it could not loan), while a backend writing true with a NULL slot was believed. The page also once claimed the runtime “dispatches the publish path with no per-call branch” from the flag; it never did.
  • Return codes use upstream rmw’s values at the ABI (phase-376 W3.d, phase-376-rmw-api-parity.md).
  • Graph slots were added as visitor callbacks in phase-376 W4; node entities (rmw_node_t) arrived in phase-376 W5/B1.
  • Heap is a backend property (issue 0777, user page RMW API Design). That page said “no heap” flatly for years; scripts/rmw-alloc-sites.py now counts the allocation sites per backend.
  • zenoh version reading (issue 0609): the ROS package version of rmw_zenoh_cpp moved its vendored zenoh 1.2.0 → 1.8.0 in a patch bump, and reading the package version instead of zenoh_configure.h produced a wrong version claim in that issue’s first filing. The router pairing itself is phase-362 / RFC-0075.

QoS

  • _SYSTEM_DEFAULT names no concrete policy (issue 0829: the two system-default presets disagreed on depth).
  • The QoS mask default is EMPTY (phase-428 W9, phase-428-api-porting-principle-sweep.md). It was QoSPolicyMask::CORE — four policies granted to any implementation that had written no code — until measurement found the zenoh shim honouring none of the four. The check-qos-mask-derivation gate now requires each bit to be backed by a claim sited on the code that reads the profile field.
  • The C ABI answers per backend (issue 1329). The cffi route used to answer the UNION of what any supported RMW honours, which over-claimed for each of them.
  • Manual liveliness answers UNSUPPORTED where there is no per-topic lease (phase-467 RMW gap-closure study, Row 7, phase-467-rmw-gap-closure-design-study.md). The unsupported default used to be Ok(()) whatever the kind, which reported an assertion that never happened.

Status events

  • Matched and incompatible-type events (issue 1972). MATCHED was skipped until issue 1972 ruled it ADOPT; INCOMPATIBLE_TYPE came with it.

XRCE-DDS NULL slots (section 9b of the user page)

just check rmw-slot-producers prints how many slots each backend fills. The rows marked filled on the user page were filled by:

slot familyissue
graph: node names + change edge2106 (2106-xrce-node-enumeration-from-ros-discovery-info.md)
granted QoS (*_get_actual_qos)2107; requester/replier created from XML since issue 2120
status events: deadline2108
per-entity new-data callbacks2109
take with info (receive time)2135
request info2081, measured live in issue 2088
response info (receive time)2136
identity (get_gid_for_*)2110; GID derivation from issue 1292

None of the gaps was trivial enough to land with the rulings: the closest, *_get_actual_qos, needed a measurement first, which issue 2107 made (an Agent-side profile cannot override a BIN-created entity’s QoS) and which found that a requester/replier ignores its requested QoS altogether.

Publisher GID, message-info and service info

The section records issue 0242 (parity gaps: publisher GID, message info), updated 2026-07-27.

  • C rclc_executor_add_subscription_with_info registrations became removable in issue 1668.
  • take_with_info as the C/C++ backend channel, and one GID from one source for get_gid() and MessageInfo::publisher_gid: issue 1495.
  • received_timestamp stamping: issue 2135.
  • Service info on the server side: issues 2081 (C ABI request info) and 1942 (rclrs ServiceInfo counterpart, zenoh side table).
  • Client side: take_response_with_info from issue 2136; sequence-id correlation from issue 0778 (and 2009 for RequestCorrelator); the callback client and C++ PollClient::take_response header from issue 2148.

The cyclonedds fork: what it carries, and why

nano-ros builds Cyclone DDS from a fork (NEWSLabNTU/cyclonedds, branch nano-ros) rather than from the ROS package. This page records exactly what that fork adds, so the delta is a deliberate, reviewable thing rather than an accumulation nobody can enumerate.

The commit list itself, regenerated whenever the pin moves, is forks/cyclonedds.md; this page keeps the reasons.

The delta is carried on purpose. It is not tech debt awaiting an upstream PR — see “Why upstreaming does not retire it” below, which is the part that is easy to get wrong.

The pin tracks ROS, not upstream

version
fork pin (third-party/dds/cyclonedds)0.10.5
ros-humble-cyclonedds0.10.5
upstream master11.x

The pin matches the cyclonedds that ROS ships, and must. A ROS node on the host links the distro’s Cyclone through rmw_cyclonedds_cpp; an embedded image speaking a different Cyclone is the same drift class as issue 0609, where a vendored zenoh pin fell behind the one rmw_zenoh_cpp actually used. Moving the fork to upstream master would be a wire-and-ABI divergence from every ROS peer we exist to talk to.

So the fork is rebased when ROS’s cyclonedds moves (a distro migration), not when upstream releases.

What the fork adds

27 commits, 56 files, +2623/−142 against the 0.10.x boundary 5041f356. Eight groups.

Re-measured 2026-08-29 and it had drifted BOTH ways: the headline said 15 while the tables below listed 17, and ae14b312 appeared in neither. A count that disagrees with its own table is worse than no count — it reads as the authoritative number. The two verification recipes at the bottom are the check; run them rather than trusting this line.

1. ThreadX / NetX Duo port (6 commits) — a platform upstream does not have

commitsubject
902f7707ddsrt: add ThreadX NetX port
56e6170afix(threadx): stabilize Cyclone DDS runtime
e8ce7315fix(ddsrt/threadx): multicast byte-order + multi-iovec datagram sendto
5558c6aefix(ddsrt/threadx): network byte order for parsed/interface addresses
12b4af2cfix(ddsrt/threadx): join multicast with INADDR_ANY interface
6eb92277fix(ddsrt/threadx): drop duplicate ddsrt_setsockreuse (use generic)

Almost entirely additive — new ddsrt/{sync,threads,sockets,time,heap, ifaddrs,process}/threadx/ trees plus the DDSRT_WITH_THREADX selection arms. Retired only if upstream accepts a ThreadX port, which nobody has offered.

2. Zephyr platform gaps (6 commits)

commitsubject
290152c0fix(zephyr): tolerate NSOS socket gaps
1d794c0addsi_udp: Zephyr multicast join via struct ip_mreqn (issue 0231)
4aa337b0q_sockwaitset: AF_UNIX socketpair self-pipe on native-IP-stack Zephyr
ae14b312ddsrt: initialise the atomics mutexes at runtime on the Zephyr backend
79bd9cb9ddsrt: name threads on Zephyr, instead of leaving every one anonymous
f241020dddsrt: on Zephyr, destroy a thread’s attr after pthread_join, not after create (issue 1698)

Upstream does have a Zephyr port (ports/zephyr, WITH_ZEPHYR); these are places where it does not survive contact with Zephyr’s native IP stack / NSOS.

3. Lock-pool scaling (3 commits) — the pair this issue was opened about, plus a 4.x fix to it

commitsubject
a09babf3ddsrt: Zephyr-native sync backend — k_mutex/k_condvar instead of pooled pthreads
942dda3cddsi/addrset: stripe the lock instead of one mutex per addrset
404fc5d9ddsrt: re-take the mutex after a timed k_condvar_wait on Zephyr 4.x (issue 2188)

404fc5d9 repairs a09babf3 on Zephyr 4.x, where k_condvar_wait takes the mutex back only when woken, so a timed-out wait returned it unlocked and the GC thread’s next unlock aborted (-EINVAL). It takes the mutex back only when the waiter does not already own it, because 3.7 still takes it back itself.

The substantive ones. Zephyr’s POSIX pthread_mutex_t/pthread_cond_t are handles into fixed static pools (CONFIG_MAX_PTHREAD_MUTEX_COUNT and friends). Cyclone puts a mutex in every entity — one per addrset, three per writer — and the count scales with the remote graph (proxy entities, SEDP announcements), not just what this node creates. That makes “how large a ROS graph can this board join” a compile-time RAM constant.

Measured (issue 0371): a 33-node Autoware graph exhausted 16384 slots ~19 s in and died as an anonymous abort(); clearing it needed 131072 slots ≈ 4.1 MiB static. Raising the Kconfig knob is the stock workaround and is a bad trade — the RAM cost is proportional to a worst case you cannot know at build time, and getting it wrong fails deep inside discovery with nothing to point at.

The two changes attack the term from different sides and neither alone was enough: with striping only, 2048 slots still exhausted on the remaining per-proxy-entity locks. The native backend removes ddsrt sync from the pool entirely; striping stops the addrset term scaling at all.

Both carry a behavioural asymmetry worth remembering: k_mutex is recursive where a POSIX NORMAL mutex deadlocks, so a self-relock bug hangs on Linux and passes on Zephyr. See also book-internals/src/platform/implementation-notes.md.

4. Diagnostics for the failure above (3 commits)

commitsubject
4c8ff8c2ddsrt/posix: fail loudly when pthread_mutex_init fails
5b87ee52fix(ddsrt/posix): say which pool ran out, and fix cond/rwlock too
8601ca66ddsrt: the freertos/threadx sync ports say what failed before aborting

Stock Cyclone ignores the return of pthread_mutex_init. That is defensible on a desktop and useless here: it converts pool exhaustion into an anonymous abort() seconds later, which is exactly how 0371 cost as much time as it did.

5. FreeRTOS (3 commits)

commitsubject
22150fbffix(freertos): avoid TLS-only DDS state
99cfac88ddsrt: give every FreeRTOS thread its lwIP per-thread netconn semaphore
8665a51fddsrt: take the log sink lock exclusively where the scratch buffer is shared

With LWIP_NETCONN_SEM_PER_THREAD=1 every thread touching the socket API needs its own netconn semaphore. ddsrt creates its own threads and never called lwip_socket_thread_init(), so the first socket call from one asserted sem != NULL. thread_start_routine is the point they have in common. Found by phase-370 W4, the first work to boot an embedded Cyclone image at all.

8665a51f is the same TLS gap seen from the log path, and it covers ThreadX too. GCC compiles __thread as emulated TLS on a bare-metal newlib toolchain, whose gthreads are single-threaded, so vlog’s scratch buffer is one object shared by every task. vlog still took the sink lock for READING, so two threads tracing at once could advance the shared pos past the buffer and vsnprintf wrote without a bound. It now takes the lock exclusively on those two ports. Found tracing issue 0997, as interleaved records; that issue’s own cause was the FreeRTOS FPU context, not this.

6. The platform allocation funnel (3 commits)

commitsubject
6e2ad36fddsrt/posix: route the heap through nano-ros’s platform allocation funnel
8e6ff48addsi: allocate and free through ddsrt, not libc, in four mismatched places
d97a71e2ddsrt: one funnel heap for every port, instead of an arm inside one of them

Behind -DNROS_DDSRT_PLATFORM_FUNNEL, set by ProvideCycloneDDS.cmake on ddsc only (never on the tools-side ddsrt-internal: idlc and confgen link no platform layer). Undefined, the file is byte-identical to stock, so cyclone’s own ctest suite is unaffected. Issue 0832 measured the funnel DEFINED and UNREFERENCED in native cyclone images; it now has 4 inbound edges and the whole ddsrt_{malloc,malloc_s,calloc_s,realloc_s,free} family is off malloc@plt. Compile-time rather than weak-linked on purpose: weak linkage leaves the libc branch in the binary, and its absence is what a tier gate has to read.

Routing ddsrt was not enough, because four ddsi sites called libc DIRECTLY — and three of them were already a bug before the funnel existed. They allocate from one heap and release to the other: a listelem malloc‘d in network_interface_find_or_append but freed by free_all_elements’ ddsrt_free; split_at_comma’s ddsrt_malloc’d array released with libc free; a calloc’d ->verbatim released through dds_stream_free_sample, whose allocator is {ddsrt_malloc, ddsrt_realloc, ddsrt_free}; and a ddsrt_asprintf string freed with free. On POSIX both heaps are glibc so nothing faults; on ThreadX, FreeRTOS and Zephyr — and under the funnel — they are genuinely different heaps.

sysdeps.c’s free stays libc on purpose (backtrace_symbols allocated it), and q_freelist.c’s free is a function PARAMETER, not libc. After the sweep libddsc.a has exactly one object referencing a raw libc allocator, which is the sysdeps.c one.

The funnel is one file, not an arm per port. 6e2ad36f put the arms inside heap/posix/heap.c, which left FreeRTOS on pvPortMalloc and ThreadX on tx_byte_allocate — a second allocation route on exactly the ports whose heap genuinely is not libc’s. d97a71e2 replaces that with heap/nros/heap.c, which implements the whole ddsrt_* family on the platform ABI, and compiles each port’s own heap.c out under the same switch. heap/posix/heap.c is stock again apart from that guard.

The new file sits in the COMMON source list rather than being swapped in per port, and that is load-bearing: ddsrt is INTERFACE and ddsrt-internal compiles the same INTERFACE_SOURCES, so a swap would hand the funnel to the host tools (idlc, confgen) that link no platform layer. Because the switch is a PRIVATE compile definition on ddsc, both targets see one file set and only ddsc gets the funnel — measured: libddsrt-internal.a has zero nros_platform_* references and its stock posix heap still defines the family.

Dropping the per-port heaps also drops their size-header prefix and alignment arithmetic, which existed only because the RTOS allocators have no realloc; nros_platform_realloc is specified with libc realloc semantics. The ThreadX weak zpico_threadx_byte_pool goes with it.

heap/vxworks/heap.c is untouched: no CMakeLists references it, it does not include dds/ddsrt/heap.h, and nothing in this tree can compile it.

7. Nested-build path resolution (1 commit)

commitsubject
556f79d4build: resolve Cyclone’s own paths by project, not by CMAKE_SOURCE_DIR

CMAKE_SOURCE_DIR is the TOP-LEVEL project’s source dir, so a reference to a Cyclone file spelled that way is correct only when Cyclone is the top-level project. We reach it by add_subdirectory(), where it names nano-ros’s root.

Latent until section 6 made it reachable: _confgen’s hash check watches ddsi_config.c, so the first edit to that file arms the regeneration branch and its four AppendHashScript.cmake commands look for the script under nano-ros. The failure is not clean — _confgen-exe has already rewritten defconfig.c, options.md, cyclonedds.rnc and cyclonedds.xsd in the SOURCE tree, and the step that dies is the one appending the hashes, so a nested build leaves four generated files modified and hash-less and the next configure re-arms the same branch. This is what blocked routing ddsi_config_init through the funnel.

project(CycloneDDS ...) defines CycloneDDS_{SOURCE,BINARY}_DIR, which mean “Cyclone’s root” regardless of who included it. Identical to the old spelling in a standalone build, so upstream behaviour is unchanged and cyclone’s own ctest suite is unaffected. Fixed at all nine sites that mean Cyclone’s own tree (_confgen, idlc/xtests, src/idl’s MAIN_PROJECT_DIR, fuzz), not only the one that breaks a build today. core/xtests/cdrtest keeps its CMAKE_SOURCE_DIR: its scripts live in that test’s own directory, so it is wrong standalone too — a different bug in a target this change cannot exercise.

Pushed to origin/nano-ros as a fast-forward over 8601ca66, and the superproject pin bumped to it afterwards — that order, not the reverse: a pin naming an unpushed commit clones as an unfetchable ref.

Why upstreaming does not retire it

Upstream accepts patches against master (11.x). nano-ros consumes 0.10.5 and will keep doing so until a ROS distro migration. A merged upstream PR therefore changes nothing about this delta for years. Upstreaming is worth doing for future-proofing and for the community, but it is not the exit, and treating it as one produces work whose verification does not even transfer (a green ctest on 11.x says nothing about the 0.10.5 code that actually ships).

Three prep branches exist locally in the submodule, unpushed (fork remotes are maintainer-gated):

branchbaseverified
upstream-zephyr-syncfork’s master mirrorwiring verified at configure time; code identical to shipped
upstream-addrset-stripefork’s master mirrorbuilds clean, ctest 1498/1498
nano-ros-addrset-hashfork pin 8601ca66builds clean, ctest 1282/1282

The first two need an eclipse-cyclonedds remote added and a rebase off real upstream before they are PR-able; the fork’s master mirror lags.

nano-ros-addrset-hash is the one with near-term value: it makes the stripe hash independent of the allocator. The shipped hash divides the address by 2 * sizeof (void *), which distributes well only when the allocator’s chunk spacing for that exact struct size is coprime with the stripe count — measured with glibc, all 64 stripes at sizeof 40/64/96/128 but 16 of 64 at 48. sizeof (struct addrset) is 40 today, one added field from the bad case, and the allocator that matters is picolibc’s on Zephyr, which nobody measured.

8. Protocol and receive-path robustness (3 commits)

commitsubject
7f064c11ddsi: re-arm a suppressed-NACK acknack no earlier than now, instead of from a stale timestamp
67ff7518ddsi: assert that an acknack event still needing a NACK is still scheduled
2314625cddsi: rate-limit and back off a persistently failing select in the waitset (issue 1696)

The first two are backports of upstream 11.x behaviour onto 0.10.5. The third is ours: the select-mode receive loop retried a persistent select() failure with no back-off and logged every attempt. That is how one Zephyr image’s console reached 91 GB (issues 1674, 1696, 1697).

Re-deriving the delta

Three separate mechanisms have made this fork look like it carries commits nobody can fetch. All three were wrong, and each cost real time. Start here:

cd third-party/dds/cyclonedds
git remote prune origin        # stale remote-tracking refs (branches that moved)
git fetch --unshallow origin   # the default checkout is SHALLOW: 8 commits, not 2375

Then, two recipes that cross-check each other:

# by boundary: 5041f356 is the newest upstream 0.10.x commit the stack sits on
git log --oneline 5041f356..origin/nano-ros          # -> 26

# by authorship, as an independent check
git log --oneline --author=jerry73204 origin/master..origin/nano-ros   # -> 24
# (the other 2, 7f064c11 and 67ff7518, carry a second author address)

Do not count origin/master..origin/nano-ros alone: it sweeps in upstream 0.10.x release commits (version bumps, iceoryx fixes, deserializer patches) and reports 58.

origin is the NEWSLabNTU fork, carrying only master and nano-ros. It is not eclipse-cyclonedds, and any releases/* ref you remember seeing is a stale remote-tracking entry.

Testing the fork

ctest on a host build of the fork is 1282/1282, excluding the iceoryx/shm tests, which abort with POSH__RUNTIME_NO_WRITABLE_SHM_SEGMENT regardless of any nano-ros change — confirm against a baseline build before attributing one of those to your work. Note the 0.10.5 tree names them iceoryx_* and shm_*, not psmx_*_iox as 11.x does, so a -E iox filter silently misses them.

Opaque Storage Sizing

nano-ros exposes a C and C++ API on top of Rust types whose layout is chosen by the Rust compiler. The C/C++ side has to allocate storage for those types — by value, on the user’s stack or BSS — without knowing their exact byte size. The runtime makes Rust the single source of truth for those sizes: the values flow from core::mem::size_of of the real Rust types into auto-generated C/C++ headers, with no hand-tuned constants.

The pattern

Every size that crosses the Rust / C boundary follows the same path:

  1. Export from nros::sizes — the nros umbrella crate defines a pub const FOO_SIZE: usize = core::mem::size_of::<T>(); and emits a #[used] static __NROS_SIZE_FOO: [u8; FOO_SIZE] = [0u8; FOO_SIZE] with a #[no_mangle] symbol whose storage size in the rlib is FOO_SIZE. The two artefacts come from a single export_size! macro invocation. The static exists only under the ffi-size-markers feature, which only the size probe’s own nested build of nros turns on, so a linked image never carries the markers (issue 1765):

    #![allow(unused)]
    fn main() {
    // packages/api/nros/src/sizes.rs
    export_size!(pub PUBLISHER_SIZE = RmwPublisher);
    export_size!(pub SUBSCRIBER_SIZE = RmwSubscriber);
    export_size!(pub EXECUTOR_SIZE = nros_node::Executor);
    // ...etc
    }
  2. Probe from consumer build scripts — nros-c/build.rs and nros-cpp/build.rs use the helper crate nros-sizes-build to find build nros in an isolated probe target directory with ffi-size-markers added, and read the __NROS_SIZE_* symbol storage sizes with the object crate. No subprocess, no llvm-nm; pure Rust.

  3. Emit #define NROS_FOO_SIZE into a generated header — nros_config_generated.h (C) and nros_cpp_config_generated.h (C++) carry the probe values. types.h includes the generated config transitively, so every nros C header sees NROS_*_SIZE automatically.

  4. C/C++ structs use the macros —

    typedef struct rcl_publisher_t {
        /* ... */
        _Alignas(8) uint8_t _opaque[NROS_PUBLISHER_SIZE];
    } rcl_publisher_t;
    
    class Publisher {
        alignas(8) uint8_t storage_[NROS_PUBLISHER_SIZE];
        /* ... */
    };
    

    The Rust side reads/writes the same bytes via &mut *(opaque as *mut RmwPublisher). C and Rust agree on the size by construction.

What the SSoT covers today

The nros::sizes module exports:

SymbolTypeUsed by
SESSION_SIZERmwSessionrclc_support_t._opaque
PUBLISHER_SIZERmwPublisherrcl_publisher_t._opaque, nros::Publisher<M>::storage_
SUBSCRIBER_SIZERmwSubscriberrclcpp::PollSubscription<M>::storage_
SERVICE_CLIENT_SIZERmwServiceClientrclcpp::PollClient<S>::storage_
SERVICE_SERVER_SIZERmwServiceServerrclcpp::PollService<S>::storage_
EXECUTOR_SIZEnros_node::Executorrclc_executor_t._opaque, nros::Executor::storage_
GUARD_CONDITION_SIZEnros_node::GuardConditionrcl_guard_condition_t._guard_opaque, nros::GuardCondition::storage_
LIFECYCLE_CTX_SIZEnros_node::lifecycle::LifecyclePollingNodeCtxrcl_lifecycle_state_machine_t._opaque_storage
ACTION_SERVER_INTERNAL_SIZEActionServerInternalLayoutrcl_action_server_t._internal
CPP_ACTION_SERVER_SIZECppActionServerLayoutrclcpp::ActionServer<A>::storage_
CPP_ACTION_CLIENT_SIZECppActionClientLayoutrclcpp::ActionClient<A>::storage_

Plus three *Internal C-API shim structs (ServiceServerInternal, ServiceClientInternal, ActionClientInternal) that cbindgen now emits directly into nros_generated.h because they’re #[repr(C)] — the C side just embeds them as typed fields, no opaque storage at all.

Layout-mirror trick

Some downstream types — nros-c::ActionServerInternal, nros-cpp::CppActionServer, nros-cpp::CppActionClient — embed C-API pointer types (*mut rcl_action_server_t, *const rclc_goal_handle_t) that aren’t visible from the nros umbrella crate. They can’t be referenced from nros::sizes directly, but their byte size only depends on the field shape: pointers are pointers, Option<extern "C" fn> collapses to a fn-pointer-sized slot via niche optimization, etc.

The fix is a layout-mirror struct in nros::sizes:

#![allow(unused)]
fn main() {
// packages/api/nros/src/sizes.rs
#[repr(C)]
#[doc(hidden)]
pub struct ActionServerInternalLayout {
    pub handle: nros_node::ActionServerRawHandle,
    pub executor_ptr: *mut c_void,
    // ...same field shape as the real `ActionServerInternal`,
    // with C-API pointers replaced by `*mut c_void`...
}
export_size!(pub ACTION_SERVER_INTERNAL_SIZE = ActionServerInternalLayout);
}

Downstream then asserts byte-equivalence at compile time:

#![allow(unused)]
fn main() {
// packages/api/nros-c/src/opaque_sizes.rs
const _: () = assert!(
    size_of::<crate::action::ActionServerInternal>()
        == size_of::<nros::sizes::ActionServerInternalLayout>(),
    "ActionServerInternal size diverges from nros::sizes::ActionServerInternalLayout — \
     update the layout mirror in `nros/src/sizes.rs`"
);
}

The tripwire: any field-shape change in the real wrapper (adding a field, changing a pointer to a value, etc.) must be paired with an update to the mirror. The build fails immediately if they diverge.

How sizing works today

  • All four *Internal shim types are #[repr(C)] and embedded as typed fields in their outer nros_*_t structs.
  • All seven C++ wrappers (Publisher, Subscription, Service, Client, ActionServer, ActionClient, GuardCondition) use the NROS_*_SIZE / NROS_CPP_*_STORAGE_SIZE probe macros.
  • types.h ships zero *_OPAQUE_U64S macros; the four consumer module headers route through the probe.
  • The remaining hand-coded “upper bound” assertions (e.g., the EXECUTOR_OPAQUE_U64S envelope check in nros-c/build.rs) are defence-in-depth: they fire if a Rust type accidentally exceeds a configured envelope (which would normally also be flagged by the config knobs).

Adding a new size export

When a new Rust handle type needs to cross the FFI boundary:

  1. Add the export_size!(pub FOO_SIZE = nros_node::Foo) line to nros/src/sizes.rs. (If Foo lives in a downstream crate that nros can’t import, define a FooLayout mirror struct first and add a byte-equivalence assert in the owning crate.)
  2. Add a let probe_foo = probed.get("FOO_SIZE").copied().unwrap_or(0) line to nros-c/build.rs (and/or nros-cpp/build.rs), and emit #define NROS_FOO_SIZE {probe_foo} into the generated config header.
  3. Use NROS_FOO_SIZE in the C/C++ struct that needs the storage.

Cross-target verification: the same probe runs for every target triple. There’s no per-platform branching — size_of::<T>() resolves to the right value at the target’s compile time.

See also

  • packages/tooling/nros-sizes-build/src/lib.rs for the rlib probe implementation.
  • packages/api/nros/src/sizes.rs for the canonical exports.

Per-Platform Contributor Lanes

These are the in-tree just recipes contributors use to build fixture sets, boot QEMU lanes, and run per-platform test sweeps from a nano-ros checkout. Users never need them — the user path (setup, build, run with the vendor’s own tools) is on each platform’s own page, which links back here per platform. Every lane assumes source ./activate.sh (or direnv allow) in the checkout first.

Grid rows and recipe modules

platformfixture grid rowrecipes
Linux hostlinuxjust linux …
FreeRTOS (QEMU MPS2-AN385)freertosjust freertos …
NuttX (Arm)nuttxjust nuttx …
NuttX (RISC-V)nuttx-riscvjust nuttx …
ThreadX (Linux sim)threadx-linuxjust threadx_linux …
ThreadX (QEMU RISC-V 64)threadx-riscv64just threadx_riscv64 …
Zephyrzephyr, zephyr-cortex-mjust zephyr …
Bare-metal Cortex-M3baremetaljust qemu …

Multi-node workspace examples all live under examples/workspaces/, selected by fixture row rather than by directory, so a platform’s single-node and workspace examples share one grid row: a grid row names a platform FAMILY, never a board (RFC-0093 R6). Zephyr is the case that does split — zephyr and zephyr-cortex-m build the SAME examples for different boards. The user book’s How Integration Works lists the user-side board names and starter pages.

Builder per grid row

Each cell is the builder the fixture builds use for that pair, read from examples/fixtures.toml (the manifest the fixture builds and the staleness probe both consume). A dash means no in-tree coverage, not that the pair is forbidden.

platformrustccppmixed
linuxcargocmakecmakecmake
freertoscargocmakecmakecmake
nuttxcargocmakecmake—
nuttx-riscvcargocmakecmake—
threadx-linuxcargocmakecmakecmake
threadx-riscv64cargo, cmakecmakecmake—
zephyrcargo, westcmake, westcmake, westcargo
zephyr-cortex-mwestwestwest—
baremetalcargo———

Where a row lists two builders, both are real and build different things: on Zephyr, west builds the single-node examples under examples/zephyr/<lang>/ and cargo/cmake build the workspace examples under examples/workspaces/.

FreeRTOS

Lanes for FreeRTOS (QEMU MPS2-AN385).

The prebuilt test fixtures bake per-language allocator ports instead of 7447 (the copy-out examples’ port) so suites run in parallel; just freertos talker boots the fixture, so pair it with just freertos zenohd (listens on 7800, the Rust pub/sub fixture port).

just freertos build-fixtures builds every in-tree zenoh + DDS example across Rust / C / C++ in one pass.

just freertos zenohd & starts the router on the fixture port (7800) and just freertos talker boots the prebuilt talker fixture in QEMU instead of the copy-out example. For batch testing without manual QEMU launches, just freertos test runs every E2E (pub/sub, service, action) against a temporary in-test zenohd.

just freertos build / just freertos test build and exercise the in-tree fixtures.

ESP32 (dormant)

ESP32 support is dormant (issue 1525): the board, platform crate and examples are kept in the tree for future use, but nothing builds, tests or provisions them — the esp32 just module is not mounted, its fixture rows and CI job are gone, and nros new --platform no longer offers it.

NuttX

Lanes for NuttX (QEMU) and Integration: NuttX external app.

Build (arm QEMU):

just nuttx build

This cross-compiles all NuttX examples for armv7a-nuttx-eabi using cargo +nightly build --release.

Build (RISC-V rv-virt):

just nuttx build-riscv-c        # C example fixtures
just nuttx build-riscv-rust     # Rust example fixtures

Test:

just nuttx test        # arm QEMU integration tests
just nuttx test-all    # including the networked E2E lanes

Run: for nano-ros’s own in-tree QEMU examples, just nuttx zenohd & starts the router on the fixture port (8200) and just nuttx talker wraps qemu-system-arm with the right wiring. talker there is the Rust variant; the C / C++ variants boot through the make-driven path described under “Auto-configure glue” in Integration: NuttX external app.

Zephyr

Lanes for Zephyr (native_sim) and Zephyr Integration (west module).

E2E testing:

# Zenoh examples
just zephyr build           # Build Rust zenoh examples
just zephyr build-c         # Build C zenoh examples
just zephyr test            # Run zenoh E2E tests

# XRCE examples
just zephyr build-xrce      # Build all XRCE examples (Rust + C)
just zephyr test-xrce       # Run XRCE E2E tests

# All examples
just zephyr build-all       # Build everything
just zephyr ci              # Doctor + test (CI shortcut)

nano-ros’s own zephyr talker has a matching recipe for the canonical native_sim build path: just zephyr talker, paired with just zephyr zenohd & (listens on the fixture port 7400).

To completely recreate the in-tree Zephyr workspace:

just setup zephyr --force

Arm FVP

Lanes for ARM FVP (FVP_BaseR_AEMv8R).

Doctor: nros doctor --board fvp-aemv8r-smp runs the FVP resolution cross-check (it delegates part of its report to just doctor, so it needs just on PATH). The just zephyr run-fvp-ws-entry / run-fvp-board-import recipes do the equivalent inline via scripts/zephyr/resolve-fvp-bin.sh. When the binary can’t be found, a recipe you NAMED fails with the remedy (nros setup --tool arm-fvp); reached through a fixture fan-out it skips and is reported, like every platform lane.

Build: just zephyr build-fvp-all runs the FVP build lanes.

# The workspace C++ RT-tiers Entry — the canonical ASI-consumption
# reference (nano_ros_use_board + run_tiers).
just zephyr build-fvp-ws-entry

# The minimal board-crate IMPORT surface: nano_ros_use_board() and a
# trivial printk app, so the import path can be checked on its own.
just zephyr build-fvp-board-import

Each recipe shells west build -b fvp_baser_aemv8r/fvp_aemv8r_aarch64/smp from the Zephyr workspace and writes zephyr.elf into the checkout’s Zephyr build root (scripts/lib/zephyr-workspace.sh build-root, by default build/zephyr-workspace-builds/<version>/):

  • <build root>/build-fvp-ws-entry/zephyr/zephyr.elf
  • <build root>/build-fvp-board-import/zephyr/zephyr.elf

Run (once the build artifacts and ARM_FVP_DIR / ARMFVP_BIN_PATH are in place):

# Boot the workspace RT-tiers Entry (prints `[ctrl] tick=` / `[telem] tick=`).
just zephyr run-fvp-ws-entry

# Boot the board-import smoke (prints `nros: smoke ok`).
just zephyr run-fvp-board-import

Under the hood the recipe:

  1. Verifies west + the Zephyr workspace + zephyr.elf exist; skips with a hint otherwise.
  2. Resolves the FVP binary directory via scripts/zephyr/resolve-fvp-bin.sh (priority order: ARMFVP_BIN_PATH → ARM_FVP_DIR/models/Linux64_GCC-*/ → dirname $(command -v FVP_BaseR_AEMv8R)).
  3. Exports ARMFVP_BIN_PATH=<dir> and shells west build -d <build-dir> -t run, which drives Zephyr’s cmake/emu/armfvp.cmake target with the canonical boards/arm/fvp_baser_aemv8r/board.cmake -C flags — UART 0–3 piped to stdout, GICv3, cache state, NUM_CORES from CONFIG_MP_MAX_NUM_CPUS. No flags are duplicated in the just recipe.

Exit cleanly with Ctrl-C.

ThreadX

Lanes for ThreadX (Linux sim / RISC-V64 QEMU).

just <flavour> build-fixtures produces threadx_cpp_* and riscv64_threadx_cpp_* binaries alongside the Rust + C ones:

just threadx_linux build-fixtures     # build all rust + c examples
just threadx_riscv64 build-fixtures

just threadx_riscv64 talker boots the prebuilt riscv64 talker fixture in qemu-system-riscv64 with the virtio-net + Slirp wiring baked in. For batch testing, just threadx_linux test runs every pubsub / service / action against an in-test zenohd.

Cyclone DDS

Lanes for the Cyclone DDS backend (Choosing an RMW Backend). These are contributor-only recipes — a bare cmake / cargo consumer build needs no just cyclonedds pre-step; the consumer build self-provisions Cyclone from source.

just setup cyclonedds       # build Cyclone DDS from third-party/dds/cyclonedds (tag 0.10.5)
just cyclonedds build-rmw   # build packages/rmw/cyclonedds/nros-rmw-cyclonedds
just cyclonedds test        # run the CTest harness

Serial transport (QEMU)

Lane for Serial Transport: the integration test test_qemu_serial_pubsub_e2e in packages/testing/nros-tests/tests/emulator.rs automates the full QEMU serial pub/sub workflow. Run it with:

source ./activate.sh
just qemu build-fixtures                  # the test consumes a prebuilt
                                          # baremetal fixture
cargo nextest run -p nros-tests --test emulator test_qemu_serial_pubsub_e2e

(A bare cargo nextest counts a skipped-precondition test as a FAILURE; the contributor lane just test-all is what rewrites those into skips. Read the panic text before treating a red here as a regression.)

Zephyr (contributor / in-tree workflow)

Looking for the user-facing path? This page covers building nano-ros’s own Zephyr examples from this repository. If you’re consuming nano-ros as a Zephyr module in YOUR Zephyr workspace, see Integration: Zephyr (west module) instead.

Complete setup procedure for Zephyr native_sim testing. Networking uses NSOS (Native Sim Offloaded Sockets) — each socket call is forwarded to the host kernel, so tests run on 127.0.0.1 without TAP devices, bridges, or sudo.

Overview

nros uses an in-tree Zephyr workspace at zephyr-workspace/ (gitignored). Set $NROS_ZEPHYR_WORKSPACE to install elsewhere.

nros/
├── scripts/zephyr/
│   ├── setup.sh                      # Initialize workspace
│   ├── migrate-workspace.sh          # Move legacy sibling install in-tree
│   ├── downloads/                    # SDK tarball cache (gitignored)
│   └── sdk/                          # Installed Zephyr SDK (gitignored)
├── zephyr/                           # Zephyr module definition
│   ├── Kconfig                       # RMW backend, API selection, tuning
│   ├── CMakeLists.txt                # Transport C sources + nros-c build
│   └── cmake/                        # nros_cargo_build(), nros_generate_interfaces()
├── examples/zephyr/
│   ├── rust/                   # Rust + zenoh (talker, listener, ...)
│   ├── rust/xrce/                    # Rust + XRCE-DDS (talker, listener)
│   ├── c/                      # C + zenoh (talker, listener)
│   └── c/xrce/                       # C + XRCE-DDS (talker, listener)
├── west.yml                          # West manifest
└── zephyr-workspace/                 # Created by setup.sh (gitignored)
    ├── nros -> ../                   # Symlink back to repo root
    ├── zephyr/                       # Zephyr RTOS v3.7.0
    └── modules/                      # HALs, zephyr-lang-rust

Migrating from the legacy sibling layout

Legacy setups put the workspace at ../nano-ros-workspace/ with an in-tree symlink. Both layouts work (contributors: the in-tree just zephyr recipes auto-detect either), but to consolidate run:

./scripts/zephyr/migrate-workspace.sh --dry-run     # preview
./scripts/zephyr/migrate-workspace.sh               # execute

Prerequisites

Build the in-tree nros CLI (Phase 218), then let it provision the toolchain:

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

nros setup provides most components prebuilt per platform per RMW — the cross-compiler, emulator, RMW host daemon, and SDK sources (including the Zephyr west workspace + Zephyr SDK bits) are fetched from a pinned index into a shared store at ${NROS_HOME:-~/.nros}/sdk. You do not hand-install a cross-toolchain, and you do not need ROS 2 installed.

Packages are prebuilt where the index has a binary for your host and built from source otherwise — zenohd is the notable source build, and zenoh is the default --rmw. --dry-run prints the plan for your host. See Installation for the full explanation.

Step 1: Initialize Workspace (One-Time)

nros setup zephyr --rmw zenoh      # --rmw defaults to zenoh; xrce | cyclonedds also valid
source ./activate.sh

This provisions:

  • The Zephyr west workspace + Zephyr SDK bits
  • The emulator
  • The RMW host daemon (zenohd for zenoh, the Micro-XRCE-DDS agent for xrce)
  • The in-tree workspace at zephyr-workspace/ (gitignored; auto-detects legacy ../nano-ros-workspace/), with nros symlinked in
  • Rust embedded targets

Contributors: the in-tree just setup zephyr recipe still works and now delegates to nros setup zephyr under the hood.

The RMW host daemon must be running before any example: zenohd for zenoh, the Micro-XRCE-DDS agent for xrce. nros setup zephyr --rmw <rmw> installs it.

Step 2: Networking

No network setup is required. native_sim uses the NSOS offloaded-sockets driver, enabled by the module’s nros-nsos snippet (zephyr/snippets/nros-nsos/nsos.conf), which board zephyr names, so every example on it gets it with no file of its own; anything else passes -S nros-nsos:

CONFIG_ETH_DRIVER=n
CONFIG_NET_DRIVERS=y
CONFIG_NET_SOCKETS_OFFLOAD=y
CONFIG_NET_NATIVE_OFFLOADED_SOCKETS=y

ETH_DRIVER=n rather than naming the TAP driver, because that symbol is ETH_NATIVE_POSIX on 3.7 and ETH_NATIVE_TAP on 4.4 (issue 2157).

With NSOS, Zephyr’s socket API goes straight to host syscalls. Bind to 127.0.0.1 and reach zenohd / the XRCE Agent on the host loopback just like any other native test. Multiple native_sim processes can coexist without bridge configuration.

Step 3: Build and Run Zephyr Examples

# Source environment
source zephyr-workspace/env.sh

cd zephyr-workspace

# Rust — `nros configure` first, once per checkout location. It writes the
# generated message bindings and the [patch.crates-io] table the leaf's
# (GITIGNORED, never committed — phase-445 W6) .cargo/config.toml
# includes. Zephyr is the one lane that still needs that file rather than
# build/<image>/nros-cargo.toml: west drives cargo through
# zephyr-lang-rust's rust_cargo_application, which passes no --config
# (issue 1288). This applies to cargo under WEST too: west
# drives the same leaf, so without sync the build fails while PARSING the
# manifest, with an error that never names sync (issue 0694).
#
# The sync also runs the source-metadata probe over the leaf's `node/`
# package, a host build of the component. That writes `node/metadata/*.json`,
# which the configure composes into the image's pool sizes (issue 1662).
# Without it the pools fall back to crate defaults, and the XRCE talker's
# session no longer fits its heap (issue 2133). Do not pass `--no-metadata`.
nros configure ../examples/zephyr/rust/talker

# Build Zephyr talker (Rust + zenoh, default backend).
#
# `../examples/...` rather than `<name>/examples/...`: the workspace's link
# back to the checkout is named after YOUR checkout directory
# (`scripts/zephyr/setup.sh` uses `basename "$NROS_REPO_DIR"`), so it reads
# `nano-ros/` for a plain `git clone` and `nros/` only if you renamed the
# directory. For the in-tree workspace this page sets up, `..` IS the
# checkout and needs no name at all.
west build -b native_sim/native/64 ../examples/zephyr/rust/talker

# Run (no sudo needed)
./build/zephyr/zephyr.exe

Configuring an image: system.toml, not prj.conf

How an image’s system.toml becomes the Zephyr build’s Kconfig — and why a prj*.conf never carries a CONFIG_NROS_* line — is user material: Zephyr (west module) § Configuring an image. For the in-tree examples, each leaf’s system.toml declares one image per RMW; pick one with -DNROS_IMAGE=<id>:

west build -b native_sim/native/64 ../examples/zephyr/c/talker -- -DNROS_IMAGE=zephyr_xrce

API Selection

The language API comes from the package itself — the module renders CONFIG_NROS_RUST_API, CONFIG_NROS_C_API or CONFIG_NROS_CPP_API from the package’s entry language, so you never set it. What differs is the CMakeLists.txt.

Rust

prj.conf needs Zephyr’s own Rust support (CONFIG_RUST=y, CONFIG_RUST_ALLOC=y), and CMakeLists.txt uses rust_cargo_application():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
rust_cargo_application()

C

CMakeLists.txt uses nros_generate_interfaces():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
nros_generate_interfaces(std_msgs "msg/Int32.msg")
target_sources(app PRIVATE src/main.c)

Kconfig Reference

All options are under menuconfig NROS in zephyr/Kconfig. They are what the module RENDERS from system.toml, never from a conf file: a registry knob as [image.<id>.knobs.<group>] <name> ([image.zephyr.knobs.zenoh] max_publishers = 4), any other option as an image env row with the CONFIG_ dropped (env = { NROS_BOOT_REPORT = "1" }). An env row naming a registry knob is refused, naming its knobs spelling. The RMW, API, endpoint and domain rows have their own system.toml keys (table above).

Common Options

OptionTypeDefaultDescription
CONFIG_NROSboolnEnable nros module
CONFIG_NROS_RUST_APIboolyUse Rust API
CONFIG_NROS_C_APIboolnUse C API
CONFIG_NROS_DOMAIN_IDint0ROS 2 domain ID
CONFIG_NROS_INIT_DELAY_MSint2000Network init wait (ms)

Zenoh Options (visible when CONFIG_NROS_RMW_ZENOH=y)

OptionTypeDefaultDescription
CONFIG_NROS_ZENOH_LOCATORstring"tcp/127.0.0.1:7447"Router address
CONFIG_NROS_ZENOH_MULTI_THREADboolyZenoh-pico multithreading
CONFIG_NROS_ZENOH_PUBLICATIONboolyPublication support
CONFIG_NROS_ZENOH_SUBSCRIPTIONboolySubscription support
CONFIG_NROS_ZENOH_QUERYboolyService client support
CONFIG_NROS_ZENOH_QUERYABLEboolyService server support
CONFIG_NROS_ZENOH_LINK_TCPboolyTCP transport link
CONFIG_NROS_MAX_PUBLISHERSint8Max concurrent publishers
CONFIG_NROS_MAX_SUBSCRIBERSint8Max concurrent subscribers
CONFIG_NROS_MAX_QUERYABLESint-1 (derive)Max concurrent queryables (service servers). the param_services capability costs 6 per node, [lifecycle] 5 once — a value below that floor fails the build, naming this knob (phase-426 W3)
CONFIG_NROS_FRAG_MAX_SIZEint2048Max reassembled message size
CONFIG_NROS_BATCH_UNICAST_SIZEint1024Max unicast batch size
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZEint-1 (derive)Per-subscriber buffer, small payload class
CONFIG_NROS_MAX_LARGE_SUBSCRIBERSint-1 (derive)Blocks in the large payload class
CONFIG_NROS_SUBSCRIBER_LARGE_SIZEint-1 (derive)Per-sample capacity of the large class
CONFIG_NROS_SERVICE_BUFFER_SIZEint1024Per-service buffer

-1 means “derive it from the message bounds”

Three of the rows above, plus CONFIG_NROS_SUBSCRIPTION_BUFFER_SIZE, default to -1. That is not a size: it is how an image says nothing here chose a number, work it out (phase-403 W8, issue 0940).

Codegen derives an exact serialized-size bound for every message type it generates and writes it beside the code as nros_message_bounds.cmake. The build composes those, and the configure prints what it concluded:

-- nros: message-bound sizing DERIVED from 84 types in 11 interface packages (all bounded)
-- nros:   largest type std_msgs/msg/Float64MultiArray at 1496 B -> NROS_SUBSCRIPTION_BUFFER_SIZE
-- nros:   0 types over the 2048 B ceiling -> NROS_MAX_LARGE_SUBSCRIBERS

Three things to know before you rely on it:

  • Anything you state wins. A value in your image’s env, a board .conf, or an environment override outranks the derivation, silently and in both directions. The derived number is a default, never an override.
  • It is an UPPER BOUND. The inventory covers every type in the interface closure you LINK, not the ones you subscribe to, so the number is the largest message the image could receive. That is the safe direction, and it can still be far above what you need: the line above names std_msgs/Float64MultiArray, which that image never receives. When the type it names is not one of yours, either state your own value or stop linking the package.
  • One unbounded type refuses the lot. If any type in the closure has no derived bound, nothing is derived, every knob keeps its configured value, and the configure names the types and the members that cost them their bound. Deriving over the rest would publish a maximum a real sample can exceed, and that drop is silent on the C++ arena path. The remedy is a bound in the .msg or an inline cap in nros-codegen.toml; one cap on a declaring type ("std_msgs/Header.frame_id") reaches every message that nests it.

The answer, and why it came out that way, is written to <build>/nros/message_bound_knobs.cmake.

XRCE Options (visible when CONFIG_NROS_RMW_XRCE=y)

OptionTypeDefaultDescription
CONFIG_NROS_XRCE_AGENT_ADDRstring"127.0.0.1"Agent IP address
CONFIG_NROS_XRCE_AGENT_PORTint2018Agent UDP port
CONFIG_NROS_XRCE_TRANSPORT_MTUint512Transport MTU
CONFIG_NROS_XRCE_MAX_SUBSCRIBERSint-1 (derive)Max concurrent subscribers
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERSint-1 (derive)Max service servers
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTSint-1 (derive)Max service clients
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTHint32Queued samples per subscriber
CONFIG_NROS_XRCE_BUFFER_SIZEint1024Per-slot buffer size; the default of the three below
CONFIG_NROS_XRCE_SUBSCRIBER_BUFFER_SIZEint-1 (derive)Subscriber ring entry size
CONFIG_NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZEint-1 (= BUFFER_SIZE)Service-server request entry size
CONFIG_NROS_XRCE_SERVICE_REPLY_BUFFER_SIZEint-1 (= BUFFER_SIZE)Service-client reply slot size
CONFIG_NROS_XRCE_STREAM_HISTORYint16Reliable stream depth (2-16)

Every option in this table except the agent address and port is a knob-registry row: a project with a system.toml states it as [image.<id>.knobs.xrce] <name> — the option without CONFIG_NROS_XRCE_, lowercased (transport_mtu = 512, subscriber_ring_depth = 1) — not in prj.conf, where the symbol has no prompt.

C API Options (visible when CONFIG_NROS_C_API=y)

OptionTypeDefaultDescription
CONFIG_NROS_C_MAX_HANDLESint16Max executor handles
CONFIG_NROS_C_MAX_SUBSCRIPTIONSint8Max subscriptions
CONFIG_NROS_C_MAX_TIMERSint8Max timers
CONFIG_NROS_C_MAX_SERVICESint4Max services

CI lanes

The copy-out, snippet, patch-apply, and dual-line (Zephyr 3.7 LTS and 4.x) build flows the user book’s Zephyr chapter describes are exercised in CI by just zephyr ci-both and just zephyr check-copy-out.

ARM FVP resolution

scripts/zephyr/resolve-fvp-bin.sh resolves the FVP_BaseR_AEMv8R model, in order: ARMFVP_BIN_PATH, ARM_FVP_DIR, FVP_BaseR_AEMv8R on PATH, then the store copy (nros dev sdk path arm-fvp). The just zephyr run-fvp-* / verify-fvp-runtime recipes export the result as ARMFVP_BIN_PATH at BUILD time as well as run time, because Zephyr’s armfvp.cmake caches the location at configure. When nothing resolves, a recipe you NAMED fails with the remedy (nros setup --tool arm-fvp); reached through a fixture fan-out it skips and is reported.

For a model downloaded by hand, scripts/installers/arm-fvp-installer.sh (with ARM_FVP_DIR set to the extraction root) symlinks it to ~/.nros/sdks/arm-fvp/current/ and prints the export ARMFVP_BIN_PATH=… line. The user-facing variables are in the user book’s Environment Variables.

E2E Testing

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

Troubleshooting

IssueSolution
west: command not foundRun pip3 install --user west and add ~/.local/bin to PATH
Connection refusedStart zenohd / MicroXRCEAgent on the host loopback (e.g. tcp/127.0.0.1:7447)
Build failsSource environment: source zephyr-workspace/env.sh
XRCE Agent not foundProvision the xrce daemon: nros setup zephyr --rmw xrce
Zenoh mutex exhaustionIncrease CONFIG_MAX_PTHREAD_MUTEX_COUNT (default 5 is too low)

Network Architecture

With NSOS, Zephyr sockets are forwarded to host syscalls — there is no emulated L2/L3 stack to configure, no static IP, and no bridge.

┌─────────────────────────────────────────────────────────────┐
│                      Host (Linux)                            │
│                                                              │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zephyr.exe talker  │       │ zephyr.exe listener    │   │
│   │ (native_sim+NSOS)  │       │ (native_sim+NSOS)      │   │
│   └─────────┬──────────┘       └──────────┬─────────────┘   │
│             │ host socket() via NSOS       │                │
│             ▼                              ▼                │
│                 127.0.0.1 (loopback)                        │
│             │                              │                │
│             ▼                              ▼                │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zenohd             │       │ MicroXRCEAgent         │   │
│   │ tcp/127.0.0.1:7447 │       │ udp/127.0.0.1:2018     │   │
│   └────────────────────┘       └────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Updating the Workspace

To update Zephyr and modules to latest versions specified in west.yml:

cd zephyr-workspace
west update

To completely recreate the workspace:

Contributors: the in-tree workspace-recreate lane is in Per-Platform Contributor Lanes.

NuttX (contributor / in-tree workflow)

Looking for the user-facing path? This page covers building nano-ros’s own NuttX examples on QEMU from this repository. If you’re integrating nano-ros into YOUR NuttX app at apps/external/nano-ros/, see Integration: NuttX external app instead.

nano-ros runs on NuttX, targeting QEMU ARM virt (Cortex-A7 + virtio-net). NuttX provides POSIX-compatible BSD sockets, which makes it the most straightforward RTOS port – zenoh-pico uses the same socket API as on Linux.

Overview

The NuttX platform uses:

  • NuttX RTOS – POSIX-compliant real-time OS with BSD socket support
  • BSD sockets – standard socket API provided by NuttX’s network stack
  • zenoh-pico – Zenoh transport over NuttX sockets (same code path as POSIX)
  • virtio-net – NuttX built-in Ethernet driver (no custom driver needed)

Board crate: nros-board-nuttx-qemu (in packages/boards/) — ONE crate serves both QEMU NuttX boards, arm virt and rv-virt. Everything that differs between them is data (a defconfig, a toolchain file, a target triple); nothing in the Rust or C sources is arch-conditional.

Why NuttX Is Simpler Than FreeRTOS

NuttX is the simplest RTOS platform to port because of its strong POSIX compliance (POSIX.1-2008: pthreads, BSD sockets, select(), clock_gettime()):

AspectNuttXFreeRTOS
zenoh-pico layerReuses unix/ platformDedicated freertos/ platform
NetworkingBuilt-in BSD socketsExternal lwIP
Ethernet driverNuttX virtio-net (built-in)Custom LAN9118 lwIP netif
Rust targetarmv7a-nuttx-eabi with stdthumbv7m-none-eabi (no_std)
Build integrationNuttX build system + cargocc crate compiles FreeRTOS + lwIP
QEMU machinevirt (Cortex-A7)mps2-an385 (Cortex-M3)

Because NuttX supports Rust std, examples use standard fn main() entry points and println! – no semihosting or custom panic handlers needed.

Setup

nros setup qemu-armv7a-nuttx provisions everything this board needs — the NuttX cross-compiler, qemu-system-arm, the NuttX source tree, and the RMW host daemon — into the shared store at ~/.nros/sdk. No hand-installed cross-toolchain and no ROS 2 install required.

Build the in-tree nros CLI (Phase 218), then provision the board (--rmw defaults to zenoh):

./scripts/bootstrap.sh      # builds packages/cli/target/release/nros
source ./activate.sh        # OR: direnv allow / source ./activate.fish
nros setup qemu-armv7a-nuttx --rmw zenoh

As a contributor, just setup nuttx remains available and now delegates to nros setup qemu-armv7a-nuttx for the toolchain/SDK provisioning while also staging the external apps.

The NuttX sources land in third-party/nuttx/nuttx/ and third-party/nuttx/nuttx-apps/. Override the paths with environment variables if your sources are elsewhere:

VariableDefaultDescription
NUTTX_DIRthird-party/nuttx/nuttxNuttX RTOS source
NUTTX_APPS_DIRthird-party/nuttx/nuttx-appsNuttX apps source

Prerequisites

The nros setup qemu-armv7a-nuttx step above provisions the qemu-system-arm emulator and the arm-none-eabi-gcc cross-compiler used for NuttX kernel compilation. The one host-side tool you still supply yourself:

  • Rust nightly toolchain (NuttX targets are Rust tier-3 targets, so they need -Z build-std)

Building

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

The examples link against NuttX’s POSIX layer, which provides sockets, pthreads, and standard I/O.

Available Examples

All examples are in examples/qemu-armv7a-nuttx/rust/:

ExampleDescription
talkerPublishes std_msgs/String on /chatter
listenerSubscribes to std_msgs/String on /chatter
service-serverServes AddTwoInts on /add_two_ints
service-clientCalls AddTwoInts on /add_two_ints
action-serverServes Fibonacci action on /fibonacci
action-clientSends Fibonacci goal on /fibonacci

RISC-V (rv-virt, riscv32)

The same NuttX path also runs on QEMU’s RISC-V rv-virt board (rv32imac, riscv32imac-unknown-nuttx-elf), proving the provisioning path is arch-agnostic. Provision and build with:

nros setup rv-virt-nuttx --rmw zenoh

Contributors: the in-tree RISC-V fixture build lanes are in Per-Platform Contributor Lanes.

Board crate: nros-board-nuttx-qemu (in packages/boards/) — the same crate as the arm board above, selected by deploy = "nuttx-riscv", which picks the rv-virt [[board]] entry of its nros-board.toml (riscv toolchain, riscv defconfig, riscv32imac-unknown-nuttx-elf). The C pub/sub pair is exercised end-to-end under qemu-system-riscv32, and Rust / C / C++ realtime-tiers workspace runtime lanes cover the multi-tier scheduling path on this board.

Testing

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

Tests run under qemu-system-arm -M virt with TAP networking. Each QEMU instance connects to the host bridge (br-qemu) via TAP devices for zenohd communication. The test infrastructure builds a NuttX kernel image with the example app embedded, boots it in QEMU, and verifies message exchange.

Network Configuration

NuttX QEMU instances use the same IP scheme as other QEMU board crates:

RoleIP AddressTAP Device
Talker/Publisher192.0.3.10tap-qemu0
Listener/Sub192.0.3.11tap-qemu1
Service Server192.0.3.12tap-qemu0
Service Client192.0.3.13tap-qemu1
zenohd (host)192.0.3.1br-qemu

Architecture

Board Crate

The nros-board-nuttx-qemu board crate follows the standard Config / run() pattern documented in the Custom Board Package guide.

Unlike bare-metal and FreeRTOS board crates, there is no custom hardware initialization, no network stack setup, and no task creation. NuttX’s kernel boots the hardware, initializes virtio-net, and starts the application before main() runs. NuttX owns the network (RFC-0109 D2): eth0 takes the address the defconfig’s CONFIG_NETINIT_* states, and the board only reads it back — every image prints nros: eth0 <addr> (kernel-owned, RFC-0109 D2) at boot. An image that needs a different address changes the kernel configuration; nothing in nano-ros writes the interface. Because NuttX supports Rust std, examples use standard fn main() entry points.

NuttX Defconfig

The QEMU board configuration lives in packages/boards/nros-board-nuttx-qemu/nuttx-config/arm/defconfig (the rv-virt one sits beside it under riscv/) and enables:

  • CONFIG_NET – networking subsystem
  • CONFIG_NET_TCP / CONFIG_NET_UDP – TCP/UDP protocols
  • CONFIG_DRIVERS_VIRTIO + CONFIG_DRIVERS_VIRTIO_NET – virtio Ethernet
  • CONFIG_PTHREAD_MUTEX_TYPES – POSIX mutex types (for zenoh-pico)
  • CONFIG_DEV_URANDOM – /dev/urandom for session ID generation
  • CONFIG_DEFAULT_TASK_STACKSIZE=8192 – adequate stack for nros apps
  • CONFIG_BUILD_FLAT – flat memory model (no MMU protection)

Status

NuttX platform support is complete: feature flags, build integration, six Rust examples, E2E network tests, and documentation.

Platform & RMW implementation notes

Implementation-level detail relocated out of CLAUDE.md so the auto-loaded instruction file stays a thin router. This is reference material (how the current impl behaves), not design rationale — for the why, see the RFCs under docs/design/. Pitfall one-liners that agents need hot stay indexed in CLAUDE.md; the detail is here.

Build-profile constraints (phase-336)

Two platforms cannot use whatever profile the build resolved. Both are named constants in packages/tooling/nros-cargo-profile (NUTTX_RUST_PROFILE, FREERTOS_QEMU_PROFILE, reachable from shell as nros dev profile carve-out <name>) so the builder and every fixture resolver read one value — when they were separate literals, a test looked for a binary in a directory the builder never wrote to (#156).

  • NuttX Rust images require lto = "fat". At lto = "off" a non-deterministic armv7a-nuttx-eabihf cross-CGU miscompile corrupts the std lang_start main-closure fat pointer: the image reboots before main with no console output. Never root-caused (phase-177.8.c; phase-285 W5 rode the same dodge for nuttx-riscv).
  • FreeRTOS QEMU (Cortex-M3) images require real optimization. Not a miscompile — a timing floor. qemu-system-arm emulating an M3 is slow enough that a lightly-optimized zenoh-pico misses its session handshake window, and the image boots but never connects. Issue #126 is the C-side face of the same constraint (fixture rows pin CMAKE_BUILD_TYPE=Release).

Both resolve to nros-minsizerel, which carries the settings [profile.release] had before phase-336 split size from speed — so these images are unchanged by that phase.

Spin / yield

zpico_spin_once event-driven wake:

  • POSIX/Zephyr: _z_condvar_wait_until on g_spin_cv.
  • FreeRTOS: xSemaphoreTake(g_spin_sem, …).
  • NuttX: sem_timedwait(&g_spin_sem_posix, …) (pthread condvar hangs — archived Phase 55.12).
  • Bare-metal: single-thread zp_read loop.

Cooperative yield (PlatformYield): POSIX/NuttX sched_yield(), Zephyr k_yield(), FreeRTOS vPortYield(), ThreadX tx_thread_relinquish(), bare-metal core::hint::spin_loop() default, opt-in cortex_m::asm::wfi() via BoardIdle. RTOS yields are not ISR-safe; spin_loop() is.

Multi-threaded zpico_spin_once pitfall (critical): on multi-threaded platforms (Zephyr, POSIX) use z_sleep_ms(), not select(). select() returns immediately on any zenoh protocol traffic (keep-alives, interest msgs), burning a Promise::wait() budget of 500 spin_once(10) iterations in ~39 ms instead of 5000 ms. FreeRTOS uses vTaskDelay(), smoltcp a clock loop, single-threaded select()+zp_read() — all correct. Zephyr native_sim also needs CONFIG_NATIVE_SIM_SLOWDOWN_TO_REAL_TIME=y for service/action clients.

smoltcp multicast (bare-metal)

  • Interface::join_multicast_group(addr) needs a multicast addr; smoltcp 0.12 returns Unaddressable for 0.0.0.0. Pass the GROUP (239.255.0.1).
  • set_recv_timeout(_, 0) in define_smoltcp_platform! = non-blocking poll.
  • LAN9118 emulator filter rejects multicast unless MAC_CR.MCPAS; promiscuous (PRMS) recommended for QEMU -nic socket,….
  • MAX_UDP_SOCKETS default 4. RTPS needs 3/participant; zenoh/xrce 0..=1.

NetX Duo BSD (ThreadX)

  • SO_RCVTIMEO takes struct nx_bsd_timeval *, NOT INT ms. Wrong type → wait_option = NX_WAIT_FOREVER → deadlock. Use nros-platform-threadx::set_recv_timeout_ms.
  • fcntl(F_SETFL, O_NONBLOCK) works (toggles NX_BSD_SOCKET_ENABLE_OPTION_NON_BLOCKING).
  • NSOS-NetX shim translates SO_RCVTIMEO for threadx-linux. Accepts INT-ms and nx_bsd_timeval.

Board transport features

ethernet (default MPS2-AN385/STM32F4/ESP32-QEMU) or wifi (ESP32) → TCP/UDP via zpico-smoltcp; serial → UART via zpico-serial (bare-metal) or zenoh-pico built-in (ESP32, Zephyr). Config fields are #[cfg(feature)]-gated. ≥1 transport required (compile_error!). Transports coexist (locator selects). ESP32/ESP32-QEMU use zenoh-pico’s serial (no zpico-serial).

Parameter services

param-services feature in nros-node → ~/get_parameters, ~/set_parameters, etc. Uses nros-rcl-interfaces. Handlers return Box<Response>.

XRCE embedded build

nros-rmw-xrce-cffi (C FFI shim) gates UCLIENT_PROFILE_{UDP,TCP,SERIAL} + UCLIENT_PLATFORM_POSIX + transport_posix_{udp,serial}.c on target_os = linux|macos|*bsd. Bare-metal (target_os = "none") gets only UCLIENT_PROFILE_{DISCOVERY,CUSTOM_TRANSPORT, STREAM_FRAMING} and must inject its own custom transport. just check workspace-embedded excludes nros-rmw-xrce{,-cffi,-cffi-staticlib} (header-only backend’s internal.h references UDP types unconditionally; the staticlib sibling needs panic_handler resolution at compile time). The -staticlib sibling lets Corrosion import a real staticlib target without forcing the cffi rlib to emit one.

XRCE-DDS RMW bugs (critical): uxr_buffer_request_data must be flushed with uxr_run_session_time immediately after the call — unflushed request_data in the reliable output stream causes intermittent timeouts when a later service request is buffered (pre-301 call_raw; fixed in all three create_* methods). Reliable streams need STREAM_HISTORY >= 2 (we use 4); history=1 fails to recycle the single slot. The service client (requester) needs uxr_buffer_request_data to receive replies, same as subscribers and repliers.

Probe-only opaque sizes

EXECUTOR_OPAQUE_U64S etc. derive from nros::sizes::EXECUTOR_SIZE via the nros_sizes_build rlib probe — no hand-math upper bound. Per-consumer const _: () = assert!(size_of::<Ty>() <= STORAGE_SIZE …) enforces compile-time correctness. Probe=0 only on cargo check --no-default-features (warns + 1-word placeholder; the resulting rlib must not be linked). CppContext adds explicit CPP_CONTEXT_OVERHEAD = 8 (u32 domain_id + alignment padding) on top of Executor.

Wrapper timing

Future::wait(), Stream::wait_next(), Executor::spin(duration_ms) budget by wall-clock via nros_cpp_time_ns(). Iteration-count loops collapse on early-wake from signaled condvars (keep-alives, discovery gossip).

nros-cpp over the Rust core

nros-cpp does not sit on the C API. Its headers wrap nros-node through a typed extern "C" FFI (rclcpp/detail/nros_cpp_ffi.h), so type safety is kept per message type. The C++ user surface is the user book’s C++ API page.

  • Generated message bindings. nano_ros_generate_interfaces(<pkg> … LANGUAGE CPP) (cmake/NanoRosGenerateInterfaces.cmake) resolves the .msg/.srv/.action files (local directory, ament index, or bundled), generates the C++ headers and the Rust FFI glue (ffi_serialize / ffi_deserialize per type), compiles the glue into a static library through Corrosion, and exposes the result as the <pkg>__nano_ros_cpp CMake target. The Zephyr module spells the same function nros_generate_interfaces().
  • Action client storage moves with the object. rclcpp_action::Client<A> keeps its goal/feedback/result buffers and its four transport channels inside the instance. It is move-only, and a move calls nros_cpp_action_client_relocate so the trampoline context pointer the executor holds follows the new this.

cbindgen output as canonical FFI

nros-cpp *.hpp headers #include "nros_cpp_ffi.h" directly; per-file hand-written extern "C" redeclaration blocks were removed (drift broke things once). qos.hpp keeps a fallback redef under #ifndef NROS_CPP_FFI_H. Exceptions: parameter.hpp cross-references nros-c’s <nros/parameter.h>; action_{client,server}.hpp reinterpret_cast goal_id at FFI callsites (cbindgen renders *const [u8; 16] as ptr-to-array); set_callbacks excluded from cbindgen via [export.exclude] and declared locally with plain fn-ptr typedefs. cbindgen variants are prefixed with the enum name (prefix_with_name = true) to avoid C++ name collisions.

QEMU networked tests

  • Slirp networking (no TAP/sudo/bridges).
  • Per-platform zenohd ports in nros_tests::platform: baremetal=7450, freertos=7451, nuttx=7452, threadx-riscv=7453, esp32=7454, threadx-linux=7455, zephyr=7456. ZenohRouter::start(platform::FREERTOS.zenohd_port). Bridge-net (threadx-linux veth): ZenohRouter::start_on("0.0.0.0", port).
  • Subscriber first, then publisher. 5–10 s stabilization. Per-platform nextest groups (max-threads = 1); platforms run in parallel.
  • Domain ID: compile-time on embedded (Zephyr Kconfig CONFIG_NROS_DOMAIN_ID; others via each example’s config.toml domain_id → generated app_config.h), runtime env on native via nros_tests::unique_ros_domain_id(). For Cyclone (RTPS ports = 7400 + 250*domain), parallel fixtures bake a distinct domain per communicating role-set.
  • Patched qemu-system-arm (Phase 143): use nros_tests::qemu::qemu_system_arm_cmd(), never Command::new("qemu-system-arm"). New justfile recipes gate through the QEMU_BIN path_exists check. See book-internals/src/testing/qemu-patched-binary.md.
  • QEMU clock: -icount shift=auto (sleep=on) makes virtual time track wall-clock during WFI; full detail in qemu-icount.md.

FreeRTOS pitfalls

  • On an A/R-profile port (ARM_CRx_No_GIC, ARM_CR5, ARM_CA9, AArch64) every task must get an FPU context — issue 0997. Those ports default configUSE_TASK_FPU_SUPPORT to 1, which saves a task’s VFP/NEON registers only after it calls vPortTaskUsesFPU(); nothing calls it, and a hard-float build uses the d-registers in integer code (64-bit constants, struct copies, NEON memcpy). The result does not look like an FPU bug: Cyclone stamped its timed events tsched = 0 (a vstr d8 of DDS_NEVER), stopped rescheduling SPDP and PMD, and the participant went silent ~100 ms after discovery; the same stale stamp later surfaced as a heap_4 double free. Set configUSE_TASK_FPU_SUPPORT 2 in the board’s FreeRTOSConfig.h and configFPU_D32 with nros_freertos_config_define() — NOT in the header, because portASM.S never includes it and the two halves of the task frame would disagree. Gate: check-freertos-fpu-context. Cortex-M FPU ports stack the FPU lazily in hardware and need none of this.

  • Stack overflow → “Invalid mbox”. The three numbers this entry used to name are all gone — read it as a symptom, not as a recipe (corrected phase-392 W6, 2026-09-06):

    • Executor has NOT held an inline arena: [MaybeUninit<u8>; ARENA_SIZE] since phase-271 (issue 0110). It holds arena: &'s mut [MaybeUninit<u8>], a slice borrowed from caller-supplied backing, and on FreeRTOS that backing is a named .bss static (nros_node::executor::backing::EXECUTOR_BACKING, phase-392 W6) — not the task stack.
    • APP_TASK_STACK was deleted in phase-76. The live knob is app_stack_bytes (nros_board_common::freertos_config::DEFAULT_APP_STACK_BYTES, 131072 = 128 KiB since issue 1146; it was 393216), overridable with NROS_FREERTOS_APP_STACK_KB. No file in the tree sets that override. The C/C++ carrier’s own mirror in cmake/templates/freertos_app_config.c.in is still 524288, and that file says why.
    • No example pins NROS_EXECUTOR_ARENA_SIZE=8192; that was pre-phase-271 prose.

    And the SYMPTOM this entry is named for is not the one you will see (issue 1146, measured). “Invalid mbox” is a possible landing, not the landing: an action-server built at 32 KiB — 4 KiB under its measured 36 160-byte peak — died with *** MALLOC FAILED ***, because heap_4 hands out the task stack and the overflow lands in the next block’s header before configCHECK_FOR_STACK_OVERFLOW 2 gets a context switch to check the pattern. Read *** MALLOC FAILED ***, “Invalid mbox” and *** STACK OVERFLOW: *** as three faces of the same fault.

    Do not size it by argument. Read the boot line. Every FreeRTOS Rust image now prints nros: <task> stack peak <used> of <total> bytes (<free> free) at the end of its register pass (report_stack_peak, nros-board-freertos/src/entry.rs), which is the deepest point bring-up reaches. In-tree peaks are 22–36 KiB — the whole table is on freertos_config::app_stack_bytes.

  • Deterministic rand() starts from seed 1 → duplicate Zenoh session IDs across QEMU instances; srand() with an IP-based unique seed in nros_freertos_init_network().

  • Manual-polling action server: create_action_server() is not arena-registered, so spin_once() does not process get_result queries — call server.try_handle_get_result() after complete_goal().

  • Poll task priority must be ≥ 4 (same as zenoh-pico read/lease tasks) to drain the RX FIFO.

  • Debug guide: freertos-lan9118-debugging.md.

Zephyr POSIX resource limits

Defaults CONFIG_MAX_PTHREAD_MUTEX_COUNT=5 / CONFIG_MAX_PTHREAD_COND_COUNT=5 are too low; zenoh-pico needs ~8+ mutexes (transport TX/RX/peer + a write-filter mutex per publisher under Z_FEATURE_INTEREST=1). Exhaustion makes pthread_mutex_init fail → zenoh-pico returns -80. Set CONFIG_MAX_PTHREAD_MUTEX_COUNT=32 and CONFIG_MAX_PTHREAD_COND_COUNT=16 in prj.conf.

These pools are per-OBJECT, so anything that allocates a mutex per entity turns them into a cap on workload size, not just a startup constant. Zephyr’s pthread_mutex_t and pthread_cond_t are handles into static arrays sized by those knobs; a library that puts a mutex in every object scales its demand with the number of objects. CycloneDDS does exactly that (three per writer: e.lock, qos_lock, rdary_lock, plus one per addrset), so a native_sim image joining a ~40-participant Autoware graph exhausted 16384 slots ~19 s in and needed 131072 (~4.1 MiB static) — issue 0371 was that crash, 0496 the sizing rule.

Cyclone on Zephyr uses a NATIVE ddsrt sync backend, not the POSIX one (issue 0496)

Since cyclonedds@a09babf3, ddsrt_mutex_t is an embedded struct k_mutex and ddsrt_cond_t an embedded struct k_condvar, so cyclone takes NO pthread pool slots for either. The knob is back to the 256 example default and no longer scales with graph size. Wiring: DDSRT_WITH_ZEPHYR in zephyr/cyclonedds-config/dds/config.h picks the types (ddsrt/sync/zephyr.h), and zephyr/cmake/nros_rmw_cyclonedds.cmake swaps sync/posix/sync.c for sync/zephyr/sync.c. Both halves must move together — the types and the implementation — or the struct layouts disagree.

Two consequences worth knowing:

  • k_mutex is RECURSIVE for its owner; a pthread NORMAL mutex deadlocks. Correct code cannot tell the difference, but code that re-acquires its own lock hangs on POSIX and silently succeeds on Zephyr. So a locking bug can reproduce natively and NOT on Zephyr — which is what happened with the striped addrset locks, whose nesting hazard only ever hung the native build. When a lock-shaped bug is platform-asymmetric in that direction, suspect this.
  • ddsrt_rwlock_t and ddsrt_once_t are still pthreads (one rwlock exists in all of cyclone — the log.c sink — and pthread_once_t is caller-owned), so the pools are used but not scaled.

Zephyr zsock per-fd serialization vs zenoh-pico (issues 0129/0139)

Zephyr’s socket layer takes a per-fd fdtable mutex for the ENTIRE blocking call — NSOS offload included — so zenoh-pico’s blocking read task holds the session socket for a full SO_RCVTIMEO window between inbound packets, and every tx (entity or liveliness declare, lease keepalive, publish, query reply) queues behind it. Consequences and rules:

  • Total image tx ≈ sends-per-second on the shared socket, NOT the sum of publish rates (issue #145 / phase-279). Measured on native_sim (ws-realtime ctrl 100 Hz + telem 10 Hz, 20 s window): at the 100 ms default both tiers converge to ~4.3 msg/s each (~8.6 total — a 100 Hz and a 10 Hz publisher get the SAME throughput under contention); at 5 ms, ~39 total (ctrl 33 / telem 5.5). Z_CONFIG_SOCKET_TIMEOUT trades read-wake rate for tx budget but stays window-bound. Mitigation (phase-279, opt-in): NROS_ZENOH_TX_BATCH=1 env / Kconfig CONFIG_NROS_ZENOH_TX_BATCH=y enables tx batching PLUS a dedicated flush thread (multi-threaded platforms except ThreadX; flush cadence NROS_ZENOH_TX_BATCH_FLUSH_MS, default 50 ms) — measured 4× total throughput at the 100 ms default (34.1 vs 8.6 msg/s) and 1.35× at 5 ms (52.5 vs 39), with the 10 Hz tier reaching ≈ ideal. Flushing from the tier threads themselves measured WORSE-or-equal (4.7-9.2) — the flush must live on its own thread. Phase-282 closed the residual: NROS_ZENOH_TX_SPLIT_LOCK=1 / CONFIG_NROS_ZENOH_TX_SPLIT_LOCK=y (requires TX_BATCH) makes the flush STEAL the batch (buffer swap) and write the socket under a separate link mutex, and a batch overflow PARKS the buffer instead of blocking the publisher — tiers 43.2 total (+27%), streaming ~181 msg/s vs ~9 baseline (20×) with a tight-loop publisher completing instead of stalling. Wire order stays == SN order. Per-publisher escape: tx_express in the QoS profile (Rust .tx_express(true) / C rclc_qos_t.tx_express / C++ rclcpp::QoS().tx_express(true)) bypasses the batch for latency-critical low-rate topics (on Zephyr an express put pays the socket window itself — never use it for streams). Flush-thread attrs: zpico_set_flush_task_config(). Gets + query replies go express under the knob; keepalives bound batch sit-time. NOTE: TX_BATCH flips Z_FEATURE_BATCHING and TX_SPLIT_LOCK flips Z_FEATURE_TX_SPLIT_LOCK in the SHARED generated zenoh config (both gate transport-struct fields — the issue-0135 every-TU rule); rebuild fixtures after changing them. Harnesses: tests/w1_zephyr_tx_throughput_measure.rs (--ignored, tiers) + packages/testing/nros-bench/stress-zenoh-zephyr (streaming; bench listeners MUST build with NROS_ZENOH_SUBSCRIBER_RING_DEPTH=1024 — the default 4-slot ring evicts most of a batched callback burst, keeping its newest samples — issue 1979). User-facing decision tree: book “TX Throughput & Latency Tuning” page.
  • Z_CONFIG_SOCKET_TIMEOUT must stay short (100 ms on every port). At 5000 ms the client’s ~3.3 s lease keepalives miss the 10 s lease and zenohd silently drops the session — the image keeps spinning against a dead transport (boot-time declares crawl at one per recv window, then everything wedges). Fork patch in zenoh-pico/include/zenoh-pico/config.h (+ the zenoh_generic_config.h twin). NuttX kept 5000 until 2026-10-29 (issue 2094); on the fixed clock it takes 100 too, and its cells ran 2–16x faster.
  • A zenoh image’s socket receive MUST time out, and on Zephyr that takes CONFIG_NET_CONTEXT_RCVTIMEO (issue 2105). zenoh-pico 1.10’s executor thread runs the lease, keep-alive and query timeouts only between receives. Zephyr ignores SO_RCVTIMEO without that option, and tcp_zephyr.c only logs the refusal. On a native-IP image (any real board) the receive then blocked until the router spoke: the keep-alive went out once a minute and the session re-dialled. NROS_RMW_ZENOH selects the option, and zpico.c #errors without it. native_sim cannot show this, because its offloaded host sockets honour the timeout anyway.
  • NuttX’s zenoh-pico clock is nuttx_clock.c, never the alias TU’s (issue 2094). ZENOH_NUTTX makes zenoh-pico’s TUs see unix.h, where z_clock_t is a struct timespec returned through a hidden pointer, while platform_aliases.c is compiled against the generic uint64_t ms clock. So nros-zpico-build defines NROS_PLATFORM_ALIASES_SKIP_CLOCK for NuttX. Re-enabling the alias clock there is a duplicate-symbol link error, because nuttx_clock.c also owns _z_condvar_wait_until, which zenoh-pico always references. Before this, every executor wake-up was stack garbage, and a NuttX link with no traffic lost its session at the 60 s lease while every cell with traffic passed. Count nanoseconds in uint64_t: 32 bits wraps every 4.29 s. With the old 5 s socket timeout NuttX timers fired up to ~10 s late (keep-alives every 25–30 s); at 100 ms the bound is ~200 ms.
  • Intra-image pub→sub needs Z_FEATURE_LOCAL_SUBSCRIBER=1 (set by zephyr/cmake/nros_rmw_zenoh.cmake on Zephyr; the generated zenoh_generic_config.h from nros-zpico-build sets it for every cargo-built lane, embedded included): all nodes in an image share ONE session (RFC-0015 Model 1), and neither zenoh-pico nor zenohd loops a publication back to the session it came from. Without it a same-image pair (e.g. ws-qos-rust’s reliable_talker → qos_listener, or the multi-tier FreeRTOS safety island’s gate→actuator command) never delivers, silently — remote routes keep working, so the drop masquerades as a per-route scheduling bug.
  • The #129 per-node-liveliness “deadlock” was this same mechanism — the first declare to hit the recv window. The platform-zephyr gate it prompted was LIFTED after the timeout fix (issue 0143): per-node NN tokens declare fine on Zephyr now, and multi-node images list every component in ros2 node list.
  • tx throughput ceiling: because every send waits for the read task’s recv window, total tx is capped at ~one send per window (plus inbound-traffic wakes). At the 100 ms default that is ~10 msg/s for the WHOLE image — fine for 1 Hz demo lanes, fatal for high-rate publishers. Tune per-app via CONFIG_NROS_ZENOH_SOCKET_TIMEOUT_MS (→ Z_CONFIG_SOCKET_TIMEOUT); the ws-realtime zephyr entry (100 Hz ctrl tier) uses 5 ms.
  • Concurrent entity declares race the interest write filter: a declare triggers an interest handshake, and when two threads declare concurrently on one session (only on Z_FEATURE_MULTI_THREAD=0 — embedded; native’s MT=1 has internal session mutexes) the losing publisher’s zenoh-pico write filter can stay closed — z_publisher_put succeeds while nothing reaches the wire. All embedded run_tiers paths (ZephyrBoard::run_tiers, the FreeRTOS Rust run_tiers_entry, and the FreeRTOS C nros_board_freertos_run_tiers) therefore chain-spawn the tiers: the boot tier runs its setup, then spawns exactly one task for the next tier, which runs ITS setup and spawns the next — so no two setup() (declare) bursts ever overlap, for any tier count (issue 0144; the 0128 fix only closed boot↔tier for two tiers). Spins may overlap the next tier’s setup — safe, a spin exchanges keepalives/data, not declares.

NuttX ↔ zenoh-pico cooperation (Phase 225.O)

zenoh-pico is not platform-agnostic on the C side and is not meant to be; the Phase 227.3 “platform-agnostic” refactor (365d5cdce) only made the Rust shim (nros-rmw-zenoh) generic (no target_os/NuttX branches — just feature gates). zenoh-pico C keeps per-platform system layers and #ifdef ZENOH_NUTTX accommodations. How NuttX wires up:

  1. Feature → define. nros/platform-nuttx forwards nros-rmw-zenoh?/platform-nuttx → zpico-sys/nuttx → CARGO_FEATURE_NUTTX, and nros-zpico-build then #define ZENOH_NUTTX + selects the unix system layer (zenoh-pico/system/common/platform.h: NuttX is grouped with ZENOH_LINUX/MACOS/BSD) + LinkPolicy::nuttx(). The forwarding clause is load-bearing: without it ZENOH_NUTTX is undefined and the setsockopt guards below stay off.
  2. unix system layer = direct POSIX. NuttX is a hosted POSIX RTOS, so system/unix/system.c backs the primitives directly — z_malloc→libc malloc, _z_task_*→pthread_create/join, _z_mutex_*→pthread_mutex_*, sockets→NuttX kernel BSD sockets. No bare-metal platform shim is needed (contrast bare-metal/ESP32, which route through nros-platform-* + smoltcp). nros-platform-nuttx is a thin C-only glue crate (platform.c/net.c).
  3. ZENOH_NUTTX accommodations (6 branches in system/unix/network.c): skip <ifaddrs.h>/getifaddrs (NuttX lacks it → multicast binds INADDR_ANY); skip SO_LINGER (no CONFIG_NET_SOLINGER); skip TCP_NODELAY (host vs NuttX optname value mismatch under cross-compile); use MSG_NOSIGNAL on send and free getaddrinfo results (both shared with ZENOH_LINUX). The SO_LINGER/TCP_NODELAY skips are why step 1’s ZENOH_NUTTX define matters — otherwise those setsockopts fail on NuttX and _z_open_tcp returns Transport(ConnectionFailed).
  4. Backend registration is explicit on NuttX. The unified-RMW nros_rmw_register_backend! macro expands to a linkme distributed-slice entry on supported targets but to nothing on NuttX (linkme unsupported), and the standalone flat image does not run the auto-register .init_array. So nros-board-nuttx::run_entry calls nros_rmw_zenoh::register() explicitly before Executor::open (feature rmw-zenoh, wired entry → nros-board-nuttx-qemu → nros-board-nuttx) — same shape as the esp32 board.
  5. pthread pool. Like Zephyr, NuttX needs enough pthread mutex/sem resources (zenoh-pico uses ~8+ mutexes; transport TX/RX/peer + per-publisher write-filter under Z_FEATURE_INTEREST=1). The reference qemu-armv7a defconfig suffices for talker+listener; raise the NuttX pthread limits for heavier graphs.

Verified 2026-06-09: native_sim-less, on qemu-system-arm -M virt -cpu cortex-a7, the NuttX workspace Entry boots → registers → publishes /chatter → an external native listener receives it cross-process.

Manual liveliness assertion is per BACKEND, and only cyclonedds does it

The phase-467 RMW gap-closure design study’s Row 7. assert_liveliness() is one API call with four answers, and the divergence belongs to the backends rather than to the API — which is why it is recorded here and not as an API-parity divergence.

backendAUTOMATIC / NONEMANUAL_BY_TOPIC / MANUAL_BY_NODE
cycloneddsOKOK, and real — dds_assert_liveliness on the writer renews the lease and sends a Heartbeat
zenoh-picoOKUNSUPPORTED — session-scoped keepalive only; no per-topic lease exists to renew
XRCE-DDSOKUNSUPPORTED — NULL vtable slot, no lease in the protocol
uORBOKUNSUPPORTED — NULL vtable slot, no wire liveliness at all

OK under a manual kind therefore MEANS the assertion reached the wire. That is the whole change: zenoh returned Ok(()) unconditionally while sending nothing, and the NULL-slot arm in nros-rmw-cffi returned Unsupported unconditionally — including for an AUTOMATIC publisher that had asked for nothing. rmw_vtable.h has stated the split since phase 108 and neither half implemented it.

Two things NOT to change while reading this:

  • zenoh’s local watchdog stays. assert_liveliness() still stamps last_assert_at_ms, and check_liveliness_lost still fires this publisher’s own LivelinessLost when the gap exceeds the lease. It is a real capability; it is LOCAL, and the return value is about what a PEER can see. Recording the call and reporting UNSUPPORTED is not a contradiction.
  • Do not “fix” zenoh by re-declaring the @ros2_lv token on every assert. A peer reads the churn as leave/join, which is worse than silence.

The test is packages/rmw/cffi/tests/publisher_gid.rs; the one spelling of “the application owns the assertion” is QoSLivelinessPolicy::is_manual.

Publisher GID: one width, one value

Same study, Q1, and issue 1495. Publisher::get_gid() — upstream’s rmw_get_gid_for_publisher — is 24 bytes on every surface, matching upstream’s RMW_GID_STORAGE_SIZE, and on every backend that answers it is produced from the same source as the gid a take reports:

backendget_gid() and MessageInfo::publisher_gid on a take
cycloneddsthe DDS writer GUID, 16 bytes zero-extended to 24. The take reaches it through take_with_info (publication handle → dds_get_matched_publication_data, cached per subscription)
zenoh-picoXXH3-128 of the entity’s liveliness keyexpr (shim/entity_gid.rs), 16 bytes zero-extended — rmw_zenoh_cpp’s derivation. A peer RE-SERIALISES our token before hashing, so the token’s QoS field must be upstream’s canonical form (defaults elided) or the peer’s gid for us diverges
XRCE-DDS, uORBUNSUPPORTED — NULL slot; no metadata on a take

Verified against live peers in graph_interop (cells native-graph-rust-{zenoh,cyclone}-r2n). A Humble rmw_cyclonedds_cpp subscriber reports the 8-byte publication handle as its take-side gid, so it is not a valid oracle for ours.

Never report an all-zero gid as an answer. That is what an uninitialised rmw_gid_t holds, and a caller cannot tell it from an identity. A backend with none says UNSUPPORTED.

Platform-ABI carve-outs: Serial / IVC / PlatformLibc are Rust-only (issue 0244)

Not every platform primitive has a C-ABI mirror in the nros_platform_* headers. PlatformSerial and PlatformIvc (Tegra IVC mailbox) are Rust traits only — no nros_platform_serial / nros_platform_ivc header — because they are consumed exclusively by Rust boards. PlatformLibc is documentary-only (link-resolved from nros-baremetal-common, never dispatched).

This is a deliberate carve-out, not an oversight. Post-RFC-0054 the C headers are the platform-ABI SSoT and the Rust side consumes committed bindgen output; hand-written C mirrors of Rust traits are the RETIRED model. So the rule is: a primitive joins the portable C ABI only when a C consumer needs it, at which point it is AUTHORED as an SSoT header and the Rust trait wraps the generated bindings — never hand-mirrored ahead of a consumer. Until then, Rust-only is correct.

Separately, zpico-sys/src/platform_smoltcp.rs exposes smoltcp_set_clock_ms / smoltcp_clock_now_ms — an externally-fed monotonic tick (PUSH: the board timer ISR calls set_clock_ms) the bare-metal smoltcp stack needs, beside the canonical nros_platform_clock_ms (PULL). It is required today; unifying it onto the SSoT platform-clock header is a phase-230 normalization item.

board.cmake schema

Sidecar CMake manifest carried by every importable nano-ros board crate. The nano_ros_use_board(<name>) cmake function (Phase 215.B) include()s it to layer the board’s prj.conf / per-board Kconfig / DTS overlay / default RMW / runner onto a downstream Zephyr app build.

This document is the contract: each board’s board.cmake MUST set every variable in the “Variables” section below, and EVERY variable MUST be consumed by exactly one of the listed consumers. New variables land here first, then in the matching consumer. The “Provisioning contract” section (Phase 215.J) adds OPTIONAL fields — a Zephyr consumer board sets them to be provisionable via nros setup board, non-Zephyr boards omit them.

Cross-ref: docs/roadmap/phase-215-board-crate-as-importable-unit.md (work items 215.A.1–215.A.3 introduce this schema; 215.C.1 mirrors the same field set into Cargo.toml [package.metadata.nros.board]).

File location

packages/boards/nros-board-<name>/board.cmake

Path-resolution rule: every *_FILE / *_CONF / *_OVERLAY variable MUST be an absolute path. Use ${CMAKE_CURRENT_LIST_DIR} (NOT ${CMAKE_SOURCE_DIR} or ${PROJECT_SOURCE_DIR} — those resolve into the consumer’s tree, not the board crate’s).

Variables

NROS_BOARD_ZEPHYR_ID

Zephyr BOARD string passed to west build -b <id> (and equivalently the BOARD cmake cache variable).

  • Format: <board>/<soc>/<variant> for hwv2 boards, <board> for hwv1.
  • Example: fvp_baser_aemv8r/fvp_aemv8r_aarch64/smp.
  • Consumed by: nano_ros_use_board() step 4 — sets the BOARD cache variable when the downstream user has not passed -b (and warns on mismatch when they have).

NROS_BOARD_TOOLCHAIN

The SDK ABI target string that Zephyr-SDK selects (the <arch>-zephyr-<abi> triple used by *-zephyr-elf-gcc etc.).

  • Format: <arch>-zephyr-<abi> matching a Zephyr-SDK toolchain dir.
  • Examples: aarch64-zephyr-elf, arm-zephyr-eabi, riscv64-zephyr-elf.
  • Consumed by: nros board info <name> (Phase 215.C.3) — informational; nros doctor --board <name> cross-checks that the SDK install carries the matching toolchain.

NROS_BOARD_GATED_PKGS

Semicolon-separated list of license-gated package names from nros-sdk-index.toml [gated.*] that the board depends on.

  • Format: a CMake list (semicolon-separated). Empty list = no gated deps.
  • Valid values: any key under [gated.*] in nros-sdk-index.toml (e.g. arm-fvp, nv-spe-fsp).
  • Consumed by: nros doctor --board <name> (Phase 215.C.3 / 215.F.2) — for each name in the list, check the matching env var (e.g. arm-fvp → ARM_FVP_DIR) is set + the install layout looks right. Hard failure if missing, with installer hint from the index.

NROS_BOARD_DEFAULT_RMW

The RMW backend the board’s reference example was built against. Downstream consumer keeps the right to override via -DNROS_RMW=<rmw>, but if nothing is passed nano_ros_use_board() populates this default.

  • Valid values: cyclonedds, zenoh, xrce (the three RMWs nano-ros ships post-Phase 169 — dust-dds is retired).
  • Consumed by: nano_ros_use_board() step 7 — sets NROS_RMW in the cache if undefined.

NROS_BOARD_DEFAULT_TRANSPORT

The transport layer the board’s reference example wires up (matches the board crate’s default Cargo feature on the transport axis — ethernet / wifi / serial etc.).

  • Valid values: ethernet, wifi, serial, loopback.
  • Consumed by: nros board info <name> — informational only today. Phase 215 does NOT plumb a -DNANO_ROS_TRANSPORT= cache var; the transport selection is a board crate Cargo feature, not a consumer-visible knob.

NROS_BOARD_RUNNER

The Zephyr emulator name for the board’s [board.run] emulator (phase-528 W2, RFC-0118 D4): armfvp for fvp:, qemu for qemu:, renode for renode:. DERIVED, never authored: it was [board.zephyr] runner, which is now refused naming [board.run]. Absent when the board states no emulator.

  • Zephyr itself never reads it: the board’s own Zephyr definition selects the emulator west build -t run drives.
  • Consumed by: nano_ros_entry() (cmake/NanoRosEntry.cmake), which reads a non-empty value as “an embedded board was imported” and defaults the codegen board key to zephyr; and the board-import fixture test, which asserts the FVP caches armfvp. The west fvp extension that once read it is retired (RFC-0064 revision 5).

NROS_BOARD_PRJ_CONF

Absolute path to the board crate’s base prj.conf — the Kconfig fragment that wires kernel sizing, POSIX, networking, and the nros module bits common to every consumer of this board (irrespective of hwv2 SoC variant).

  • Format: absolute path. Use ${CMAKE_CURRENT_LIST_DIR}/prj.conf.
  • Consumed by: nano_ros_use_board() step 5 — list(APPEND EXTRA_CONF_FILE …) so Zephyr layers it on top of the consumer’s own prj.conf.

NROS_BOARD_BOARD_CONF

Absolute path to the per-board hwv2 Kconfig fragment under the board crate’s boards/ directory.

  • Format: absolute path. Use ${CMAKE_CURRENT_LIST_DIR}/boards/<hwv2-id>.conf.
  • The <hwv2-id> format is the slash-flattened Zephyr board id with / replaced by _ (e.g. fvp_baser_aemv8r_fvp_aemv8r_aarch64_smp).
  • Consumed by: nano_ros_use_board() step 5 — appended alongside NROS_BOARD_PRJ_CONF so the hwv2-specific deltas land on top.

NROS_BOARD_BOARD_OVERLAY

Absolute path to the per-board DTS overlay under the board crate’s boards/ directory.

  • Format: absolute path. Use ${CMAKE_CURRENT_LIST_DIR}/boards/<hwv2-id>.overlay.
  • Consumed by: nano_ros_use_board() step 6 — list(APPEND DTC_OVERLAY_FILE …) so Zephyr layers it on top of the consumer’s own DTS overlay.

Provisioning contract (Phase 215.J)

nano_ros_use_board() layers the board config (the variables above), but an import:false downstream Zephyr consumer does NOT inherit nano-ros’s toolchain provisioning (zephyr patches × zephyr-lang-rust × cyclonedds source). The following fields make the board crate the single source of truth for what a consumer’s tree needs, and drive nros setup board <name> --zephyr-workspace <dir> (Phase 215.J.2).

All four are OPTIONAL — a board that omits them is not provisionable via nros setup board (e.g. non-Zephyr boards consumed via cargo path-deps). Their Cargo.toml mirror lives in [package.metadata.nros.board] as zephyr_line / requires_rust / rust_targets / rmw_source.

NROS_BOARD_ZEPHYR_LINE

The Zephyr support line (the (zephyr × zephyr-lang-rust) pair’s zephyr floor) — selects the patch set scripts/zephyr/patches/<line>.sh, which takes the consumer’s workspace dir as $1.

  • Example: 3.7.
  • Consumed by: nros setup board step (b) — bash scripts/zephyr/patches/<line>.sh <zephyr-workspace>.

NROS_BOARD_REQUIRES_RUST

Whether the board’s build needs the Rust language module (CONFIG_RUST).

  • Format: a CMake boolean token (y / ON / true / 1 → true). Mirrored as a TOML bool on the Cargo side.
  • Consumed by: nros setup board steps (c) + (d) — gates rustup target add and the zephyr-lang-rust presence check.

NROS_BOARD_RUST_TARGETS

Semicolon list of rustup target triple(s) the board’s Zephyr build compiles the nros staticlib for (what zephyr-lang-rust’s _rust_map_target returns for the board’s arch).

  • Example: aarch64-unknown-none (AArch64 AEMv8-R).
  • Consumed by: nros setup board step (c) — rustup target add <t> for each.

NROS_BOARD_RMW_SOURCE

The nros-sdk-index.toml [source.*] name for the board’s RMW source tree. Index-driven — never a hardcoded path or a hand git submodule update.

  • Example: cyclonedds-src.
  • Consumed by: nros setup board step (a) — nros setup --source <name> (provisioned into nano-ros’s own tree, which the consumer links via nano_ros_use_board() / add_subdirectory(packages/rmw/cyclonedds/…)).

NROS_BOARD_RUST_SUPPORT_MODULE

Absolute path to a board-shipped Zephyr Kconfig overlay MODULE (<dir>/zephyr/module.yml + <dir>/Kconfig) that enables RUST_SUPPORTED for the board’s arch via a cross-file default y if <board> extension — WITHOUT mutating the consumer’s zephyr-lang-rust tree (Phase 215.J.4, option B).

  • Format: absolute path. Use ${CMAKE_CURRENT_LIST_DIR}/zephyr-rust-support.
  • Consumed by: nano_ros_use_board() step 9 — appended to ZEPHYR_EXTRA_MODULES so the overlay is picked up automatically.

Board west fragment (not a board.cmake variable)

Each Zephyr consumer board also ships packages/boards/nros-board-<name>/west-downstream.yml (Phase 215.J.3): an import:false-compatible west manifest fragment declaring ONLY zephyr-lang-rust at nano-ros’s supported rev. A consumer adds one manifest.self.import: - file: <…>/west-downstream.yml line + west update to fetch the module. This is the one genuinely west-native piece (module fetch); everything else flows through nros setup board.

Consumers

The schema is consumed at three sites:

  • zephyr/cmake/nano_ros_use_board.cmake (Phase 215.B, not yet landed). The cmake function nano_ros_use_board(<name>) resolves BOARD_DIR = ${NROS_REPO_DIR}/packages/boards/nros-board-<name>, include()s ${BOARD_DIR}/board.cmake, then routes each variable as documented above (EXTRA_CONF_FILE / DTC_OVERLAY_FILE / BOARD / NROS_RMW / cached NROS_BOARD_RUNNER). All overrides land BEFORE Zephyr’s board-resolution phase — the function either re-orders (sets variables in the parent scope) or FATAL_ERRORs on wrong-order invocation.

  • nros board info <name> (Phase 215.C.3, lives in nros-cli, not in this superproject). Read-only: parses both board.cmake and the [package.metadata.nros.board] table and prints them side by side, flagging any field that disagrees. The same parser backs --check-drift and the Phase 215.F drift audit.

  • Phase 215.F drift audit (packages/testing/nros-tests/tests/phase215_f_manifest_drift.rs). For every packages/boards/nros-board-*/ that carries BOTH board.cmake and [package.metadata.nros.board], the audit parses each and asserts byte-equal field-by-field for the overlap (zephyr_board, toolchain, default_rmw, runner, conf/overlay paths, and the Phase 215.J provisioning fields zephyr_line, requires_rust, rust_targets, rmw_source). Bare Rust-only boards without board.cmake are skipped.

Adding a new board

  1. Drop the board crate at packages/boards/nros-board-<name>/ with the standard layout (prj.conf + boards/<hwv2-id>.{conf,overlay}
    • Rust skeleton).
  2. Author board.cmake setting every variable in this doc. Use ${CMAKE_CURRENT_LIST_DIR} for path resolution.
  3. Mirror the same facts into [package.metadata.nros.board] in the crate’s Cargo.toml (see Phase 215.C.1 in the phase doc).
  4. Phase 215.F audit catches any drift between the two faces.

Zephyr patch upstreaming (Phase 205.B)

Purpose. Track the nano-ros Zephyr patches that are generic Zephyr fixes (not nano-ros-specific) and stage everything a maintainer needs to open the upstream PRs. As each lands upstream + ships in a tested Zephyr line, drop it from zephyr/patches.yml + the scripts/zephyr/*-patch.sh equivalent and narrow the tested-version matrix (less drift risk for the 205.A starter template).

This doc does not open PRs — that is human follow-up (Zephyr CLA, a fork on zephyrproject-rtos/zephyr). It packages the diffs, clean commit messages, repro, and the post-merge cleanup so the PR is a copy-paste-and-rebase.

Patches carry inline /* nano-ros: … Phase 180.A … */ markers so we can find them in a workspace. Strip those markers when preparing the upstream branch — upstream wants a generic comment (or none). The commit messages below are already nano-ros-free.

Status

Patch (in-tree)FilesUpstreamableUpstream PR
nsos-recvmsgdrivers/net/nsos_sockets.cyesnot opened
native-sim-ipproto-ip (guest)drivers/net/nsos_socket.h, drivers/net/nsos_sockets.cyesnot opened
nsos-adapt-ipproto-ip (host)drivers/net/nsos_adapt.cyesnot opened
nsos-getsocknamedrivers/net/nsos_sockets.c (+ adapt)yes (candidate)not opened
nsos-getifaddrsNSOS getifaddrs pathyes (candidate)not opened
nsos-mcjoin-mreqdrivers/net/nsos_sockets.cyes (candidate)not opened
pthread-mutex-unlockPOSIX k_mutexno (downstream-only)n/a
cargo-features / rust-cargo-extra-args / per-arch rust targetmodules/lang/rust/**yes (zephyr-lang-rust)not opened
cyclonedds-on-Zephyr (threads, log-flush, sockwaitset, udp-rcvbuf, mcjoin)cyclonedds forkyes (eclipse-cyclonedds)baked in fork pin

Each in-tree patch exists in two forms that must stay in sync: a .patch (zephyr/patches/<name>-4.4.patch, west patch, Zephyr 4.x) and an anchor-based script (scripts/zephyr/<name>-patch.sh / *-4.4.sh, also covers 3.7 LTS which has no west patch). Upstreaming removes the need for both.


PR 1 — NSOS: implement recvmsg()

Target: zephyrproject-rtos/zephyr → drivers/net/nsos_sockets.c. Source diff: zephyr/patches/nsos-recvmsg-4.4.patch (script: scripts/zephyr/nsos-recvmsg-patch.sh 3.7 / -4.4.sh 4.4).

Commit message:

drivers: net: nsos: implement recvmsg()

The Native Simulator Offloaded Sockets (NSOS) driver leaves nsos_recvmsg()
an ENOTSUP stub. Datagram users that read via recvmsg() (one iovec +
msg_name — the common UDP receive shape) therefore get a hard failure on
every receive and busy-spin the receive thread.

Implement the single-iovec form by delegating to nsos_recvfrom(), reusing
its poll/block handling and host<->guest sockaddr translation. Multi-iovec
recvmsg() remains unsupported (returns ENOTSUP) — no NSOS user needs it yet.

Signed-off-by: <your name> <your email>

Repro / test (before): any native_sim app doing UDP recvmsg() logs UDP recvmsg sock N: ret 0 retcode -1 and spins. After: receives succeed. nano-ros’s just zephyr cyclonedds native_sim e2e exercise this path (test_zephyr_*_cyclonedds_pubsub_e2e).


PR 2 — NSOS: IPv4 multicast setsockopt/getsockopt (guest + host)

Open as one PR — the guest marshalling and the host forwarder are useless apart.

Target: drivers/net/nsos_socket.h + drivers/net/nsos_sockets.c (guest) and drivers/net/nsos_adapt.c (host). Source diffs: zephyr/patches/native-sim-ipproto-ip-4.4.patch + zephyr/patches/nsos-adapt-ipproto-ip-4.4.patch (scripts: scripts/zephyr/native-sim-ipproto-ip-patch{,-4.4}.sh + scripts/zephyr/nsos-adapt-ipproto-ip-patch{,-4.4}.sh).

Commit message:

drivers: net: nsos: support IPv4 multicast socket options

NSOS forwards socket options to the host kernel through the midplane, but
has no IPPROTO_IP arm, so guest setsockopt(IP_ADD_MEMBERSHIP) /
IP_MULTICAST_{IF,TTL,LOOP} return EOPNOTSUPP. A native_sim app can never
join an IPv4 multicast group host-side, so multicast receive is
dead-on-arrival (e.g. RTPS/SPDP discovery).

Add the NSOS_MID_IP_* option constants + struct nsos_mid_ip_mreq
(nsos_socket.h), a NET_IPPROTO_IP case in the guest
nsos_setsockopt/getsockopt that marshals the options into the midplane
(nsos_sockets.c), and the matching NSOS_MID_IPPROTO_IP forwarder in the
host nsos_adapt_setsockopt/getsockopt that unmarshals and calls the real
host setsockopt(IPPROTO_IP, …) (nsos_adapt.c). The membership arm reads the
two leading struct in_addr so one path covers both ip_mreq (8 B) and
ip_mreqn (12 B).

Signed-off-by: <your name> <your email>

Repro / test: before, a native_sim listener never joins 239.255.0.1 and receives no multicast; after, multicast join reaches the host kernel and frames arrive. Covered by nano-ros native_sim cyclonedds discovery e2e.


Candidate NSOS PRs (3.7-line scripts, same rationale)

nsos-getsockname, nsos-getifaddrs, nsos-mcjoin-mreq (scripts/zephyr/nsos-*-patch.sh) are the same class of generic NSOS gap (unimplemented getsockname, getifaddrs, dual-mreq multicast join). They are not yet in patches.yml (4.x added some natively / they were only needed on the 3.7 line). Before upstreaming, re-check each against current Zephyr main — some may already be fixed upstream; drop those, file PRs for the rest with a commit message in the same shape as PR 1/2.

zephyr-lang-rust

scripts/zephyr/cargo-features-patch.sh, rust-cargo-extra-args-patch.sh, and the per-arch target registration (cortex-a9/cortex-r/aarch64-rust-patch.sh) patch modules/lang/rust. Pursue upstream in zephyrproject-rtos/zephyr-lang-rust so the rust examples need no in-tree patch — this also removes the lang-rust-shape-drift the Phase 202.5 version-tolerant patch papers over. Upstream-paced; track here as PRs open.

cyclonedds-on-Zephyr

The five cyclonedds patches (threads, log-flush, sockwaitset, udp-rcvbuf, mcjoin) are baked into the nano-ros cyclonedds fork pin (third-party/dds/cyclonedds), not west patch-delivered. Upstream them to eclipse-cyclonedds/cyclonedds; once released, bump the fork pin to a tag that includes them and drop the local commits.


Post-merge cleanup (per patch, once upstream + in a tested Zephyr release)

  1. Confirm the fix is in the Zephyr (or lang-rust / cyclonedds) release the tested pin uses (west.yml 3.7.0 LTS / west-4.4.yml 4.4.0 — see book-internals/src/platform/zephyr-version-support.md).
  2. Remove the patches.yml entry and the matching scripts/zephyr/*-patch.sh (both the 3.7 + 4.4 variants). Keep them as long as any supported line still needs them — drop per-line only.
  3. Drop the patch invocation from scripts/zephyr/patches/<line>.sh.
  4. Update this table + book-internals/src/platform/zephyr-version-support.md’s patch note, and narrow the 205.A template’s tested-version matrix.

Zephyr version support

How nano-ros decides which Zephyr versions it supports, why, and how to add a new one. Policy rationale + the full work plan live in docs/roadmap/phase-199-zephyr-version-support-policy.md; this is the developer-facing reference.

The contract: a (zephyr × zephyr-lang-rust) pair

nano-ros’s Zephyr support is not bounded by our patches — it is bounded by zephyr-lang-rust, the official in-tree Rust module our integration links through (rust_cargo_application() + the zephyr crate; a generated Zephyr package is a Rust staticlib). A supported configuration is therefore a pair:

a Zephyr revision plus a zephyr-lang-rust revision known to build against it.

Each west-<line>.yml pins both halves of the pair. Bumping one without a matching other is unsupported.

The floor: Zephyr 3.7

zephyr-lang-rust’s first commit (“Initial support for Rust on Zephyr”) landed 2024-09-11, right after the Zephyr 3.7.0 LTS (July 2024). It did not exist for 3.6 (Feb 2024) or 3.5 (Oct 2023). There is no nano-ros-on-Zephyr below 3.7 — no amount of native_sim / NSOS patching changes that; the Rust staticlib has nothing to link into. Downstream integrators (e.g. autoware-safety-island) must be on ≥ 3.7.

The window: current LTS + at most one rolling

  • Default / CI baseline: the current Zephyr LTS (today 3.7, supported ~5 yr). This is NROS_ZEPHYR_VERSION’s default + the west.yml manifest.
  • At most one rolling line that zephyr-lang-rust supports (today 4.4, west-4.4.yml, NROS_ZEPHYR_VERSION=4.4).
  • Never below the zephyr-lang-rust floor (3.7).

Zephyr releases on a 6-month cadence (Apr/Oct); an LTS every ~2.5–3 yr. When a new LTS is declared, it becomes the default and the old rolling line is dropped.

The rolling line is KEPT because consumers outside this repo use it

Decided 2026-08-20 (maintainer), and worth stating here because the record on its own says the opposite. Issue 0651 checked the “a feature requires 4.4” recollection against phase-199 and the commit that pinned west-4.4.yml, found no feature gated on it, and concluded the rolling line was a POLICY slot — which made “drop it rather than half-test it” a legitimate answer to the cost of maintaining a line no local tier could reach.

That conclusion was drawn from what is visible in this repository, and it is wrong in the way repository-only evidence usually is: nano-ros has users outside this tree, and they are on the rolling line. Nothing in the code or git history can show that. So the rolling line is not an optional policy artifact whose upkeep is weighed against convenience — dropping it breaks real consumers, and the cost of the 4.4 line is a cost of shipping, not of tidiness.

Practical consequence: 4.4 coverage is worth paying for. That is why tier 3 (just ci-full) now REQUIRES the 4.4 line rather than skipping it when unprovisioned, and why just zephyr kconfig-trees exists to make the cheap half of that check available without a west workspace.

If you are about to argue from “nothing in the tree needs 4.4”, read this paragraph first — the argument is sound and the premise is not.

Stable surfaces vs the churn

Build only on Zephyr’s stable surfaces (versioned ≥ 1.0.0, frozen ≥ 2 releases): kernel / k_*, Kconfig, devicetree, the public POSIX + BSD-socket API — plus the zephyr-lang-rust contract and our manifest selector. These do not move across versions.

The per-version churn is isolated to one place: the native_sim / NSOS / CycloneDDS-on-native_sim shims (drivers/net/nsos_sockets.c, native_simulator/.../nsos_adapt.c, pthread_mutex, SoC files). These edit Zephyr internals (explicitly not stable APIs), so they re-anchor each release. They are simulator-only — real targets (FVP fvp_baser_aemv8r_smp, NXP S32Z) use the hardware net stack and don’t apply them. The plan is to upstream them so the carried set shrinks per release (see phase 199.4).

How patches are applied (per-line dispatch)

just setup zephyr runs a version-dispatched patch set — no inline if version = … branching in the recipe:

just setup zephyr           # → bash scripts/zephyr/patches/${NROS_ZEPHYR_VERSION}.sh "$WORKSPACE"

Each line’s patch sequence lives in scripts/zephyr/patches/<line>.sh (3.7.sh, 4.4.sh). See scripts/zephyr/patches/README.md.

Adding a new Zephyr line — checklist

  1. Confirm the pair builds. Find a zephyr-lang-rust revision that builds against the target Zephyr. If the target is below 3.7, stop — it is not supportable (no Rust module).

  2. Pin the pair. Add west-<line>.yml pinning both the zephyr revision and a specific zephyr-lang-rust revision (not main — that drifts; see phase 199.2).

  3. Wire the selector. Add the NROS_ZEPHYR_VERSION == "<line>" arms in just/zephyr.just (the ZEPHYR_MANIFEST / ZEPHYR_WORKSPACE / ZEPHYR_VENV_BIN selectors).

  4. Drop a patch set. Add scripts/zephyr/patches/<line>.sh — re-anchor only the native_sim / NSOS / CycloneDDS patches still un-upstreamed for that line (each idempotent; cds to repo root; takes the workspace arg). No edit to the setup recipe — the dispatcher picks it up by name.

  5. Pin the SDK for the line. Read zephyr/SDK_VERSION in the new tree, add a [tool.zephyr-sdk-<ver>] table to nros-sdk-index.toml (host-keyed URLs

    • upstream’s own sha256s), and add the matching case "$MANIFEST" arm in scripts/zephyr/setup.sh naming that entry. Check the distribution shape — 0.16.x ships one fat tarball with every toolchain, 1.0.x ships _minimal plus per-toolchain downloads the SDK’s own setup.sh -t fetches on demand, and 1.0.x has no macos-x86_64 at all.

    This step exists because it was missed. Zephyr 4.4 requires SDK 1.0.1 while the version was a hardcoded 0.16.8, so NROS_ZEPHYR_VERSION=4.4 just zephyr setup exited 0 and produced a workspace that could not build any Cortex-M target — the failure surfacing only at the first west build as a bare FindZephyr-sdk.cmake:160 error. just zephyr doctor now compares the registered SDKs against zephyr/SDK_VERSION and fails loudly instead.

  6. Add a CI line. Extend the Zephyr jobs in .github/workflows/nightly.yml with the new line.

  7. Provision sources via nros. just setup zephyr provisions zenoh-pico / cyclonedds-src / px4-rs via nros setup --source (index-driven; the canonical path — no hand git submodule update).

See also

Running the real-time tests

The model these tests measure is The Real-Time Execution Model in the nano-ros Book. A level’s budget has exactly one enforcer (issue 2018); S3 was measured on the S32K344 safety island in phase-509.

Three tests need the kernel’s real-time scheduling, and live in packages/testing/nros-tests/tests/rt_sched_levels_e2e.rs:

  • the preemption order of levels-cpp’s three levels on one CPU;
  • a spawned level past its SCHED_DEADLINE budget, throttled by the kernel;
  • S1 (1 kHz critical loop beside 5 ms telemetry; see the scenario table on the user page).

Only host-tests’ rt-sched job claims the rt-sched capability, so everywhere else each is a [SKIPPED:lane]. None of them needs sudo. By default each image runs in a throwaway docker container granted SYS_NICE and an RT priority limit, so a member of the docker group can run them on any Linux host:

NROS_FIXTURE_ID=workspace-cpp-native-levels-s1 scripts/build/workspace-fixtures-build.sh linux cpp
# ... and -levels, -levels-s1-flat, -levels-budget
NROS_TEST_UNCLAIMED= cargo nextest run -p nros-tests --test rt_sched_levels_e2e

The container’s image is the host’s own distribution and release, so the binary finds the C++ runtime it was linked against; NROS_RT_SCHED_IMAGE names another. NROS_RT_SCHED_LAUNCHER=direct runs the binaries as they are, for a process that already holds the capability: a CI job container started with --cap-add SYS_NICE, or fixtures given setcap cap_sys_nice+ep (bound to the file’s contents, so again after every rebuild). NROS_RT_SCHED_CPU moves the pinned runs off CPU 0.

S1 asserts zero misses only on a PREEMPT_RT kernel. A generic kernel misses the odd release for any program: a bare C loop at the same priority missed 2 in 60 s. There the test asserts a miss rate at least 50 times below the one-level control’s, which is what the levels change.

A run that claims rt-sched on a host without the capability fails with [UNMET PRECONDITION] and names the binary to grant it to.

WCET benchmark harness

The DWT cycle-count harness described in Real-Time Analysis is the in-tree rs-wcet-bench QEMU fixture:

# Build all QEMU examples (includes rs-wcet-bench)
just qemu build

# Run the WCET benchmark
just qemu test-wcet

# Extract the harness's artifact from the run log
just qemu wcet-artifact

test-wcet is not part of the QEMU test group: under QEMU the DWT counter reads zero, so the bench refuses to emit numbers. Run it on a hardware lane for real cycle counts.

Dispatch ABI history

The user-facing description of DispatchStrategy, the tag API and the __nros_node_<pkg>_dispatch_strategy() symbol is Dispatch Strategy in the nano-ros Book. This page keeps the implementation history behind it: which phase built which piece, and which slots are still open.

The locked spec is docs/roadmap/archived/phase-216-baremetal-framework-integration.md.

Where each piece came from

PieceWork item
DispatchStrategy enum, Inline default for every NodePhase 216
Tag types (SubscriptionTag, ServiceTag, ActionTag) and the _static registration variantsPhase 216.A.4
__nros_node_<pkg>_dispatch_strategy() emitted by nros::node!()Phase 216.A.5
Macro lint rejecting closure registration on a Deferred NodePhase 216.A.6
nros::main!() emitting the __nros_dispatch task only when a Node is DeferredPhase 216.B.3 / C.3
Framework × strategy matrix in nros checkPhase 216.D.1
atomic_waker cross-task notification substratePhase 214.J
Board-side sink renamed NodeRuntime → NodeDispatchRuntimePhase 214.K.1
Executor-level deferral without the macros (set_deferred, set_defer_notifier, run_deferred, run_deferred_handle)issue 2146

Before Phase 216 every Node was Inline-by-default (Phase 212); the defaulted Node::DISPATCH const exists so those Node packages keep compiling unchanged.

The trait surface split

Phase 214.K.1 renamed the board-side dispatch sink from NodeRuntime to NodeDispatchRuntime; the user-facing sink kept the NodeRuntime name and now lives in packages/api/nros/src/node.rs. Phase 216 added signal_callback and dispatch_strategy to NodeDispatchRuntime, both defaulted so the existing Inline implementations (ExecutorNodeRuntime in nros, NullNodeRuntime in nros-platform) needed no change.

Open slots

  • FromIsr (Phase 216.E.1). The discriminant is reserved; the implementation needs the reentrancy audit, the lock-free SPSC variant and the per-Node #[isr_safe] contract described on the user page. The Phase 214.J atomic_waker substrate is the building block for the SPSC variant.
  • Per-callback strategies (Phase 216.E.3). The slot to reconsider per-Node granularity if a real Node needs mixed strategies.
  • Foreign host loops. A drain loop owned by a host nano-ros does not generate is out of scope under RFC-0113.

The memory report

What the report shows and how to read it is Measuring Static Memory in the nano-ros Book. This page is how to run it: the tool is scripts/nros-mem-report.py in this repository and has no nros verb yet, so a user of an installed release cannot run it.

Measure a linked image:

just mem-report path/to/your/binary
# for example
just mem-report examples/linux/rust/talker/target-zenoh/nros-fast-release/talker

just mem-report is a thin wrapper over python3 scripts/nros-mem-report.py <args>.

Showing a saving

Take a baseline, change something, and compare:

just mem-report my-binary --json > before.json
# ... tune a knob, rebuild ...
just mem-report my-binary --baseline before.json

In CI

The check suite runs the tool’s self-test (nros-mem-report.py --selftest), and the L3 CI lane runs nros-mem-report.py <elf> --check over the cross images it built.

RTIC Integration

For implementers. This page documents a feature with no support-level evidence row: RTIC is the rtic feature of the nros-board-mps2-an385 crate, it has no row of its own in packages/boards/board-support.toml, and no probe track or book run exercises it. So the nano-ros Book does not teach it (RFC-0114 D10: the book claims only what the registry backs). What follows is the integration as built, for whoever maintains it or gives it a registry row.

RTIC (Real-Time Interrupt-driven Concurrency) is a concurrency framework for ARM Cortex-M that compiles tasks directly to hardware interrupt handlers — no RTOS kernel, no task control blocks, no software scheduler. nano-ros runs on top of RTIC by letting the framework own fn main, the scheduler, and the dispatchers, while nano-ros contributes one spin task and (optionally) one callback-dispatch task.

For the underlying design — why per-Node dispatch strategies, why tags instead of closures, why no AsyncNode trait yet — see the user book’s Dispatch Strategy.

The board key chooses RTIC

An RTIC program is a workspace like any other: a node package and the bringup that runs it. The scaffold writes both:

nros setup mps2-an385-baremetal --rmw zenoh   # the same board; RTIC needs nothing extra
nros new rtic_ws --board rtic-mps2-an385 --template talker
cd rtic_ws
nros run rtic_node
rtic_ws/
└── src/
    ├── rtic_node/            # the node: package.xml, Cargo.toml, src/lib.rs
    └── rtic_node_bringup/    # package.xml and system.toml: the board, so RTIC

nros run builds the image, boots it in QEMU and starts the router, as in First Project. The bringup’s system.toml is the only place RTIC is named:

# src/rtic_node_bringup/system.toml
[system]
name = "rtic_node"
rmw = "zenoh"
domain_id = 0

[[component]]
pkg = "rtic_node"
class = "rtic_node::Talker"
name = "rtic_node"

[image.baremetal]
board = "rtic-mps2-an385"

The framework is a property of the board key, never something you spell in a manifest, and there is no image key that picks it. One board crate serves the MPS2-AN385 under two keys:

[image.<id>] board =Entry the build generates
"mps2-an385-baremetal"the plain entry: an owned spin loop in fn main
"rtic-mps2-an385"the RTIC entry: a #[rtic::app] module

zephyr resolves to Zephyr, esp32-qemu and esp32-c3-baremetal to the ESP32 shape, and every other board — Linux, FreeRTOS, NuttX, ThreadX, non-RTIC bare-metal — to the default owned-spin fn main. An out-of-tree board crate declares its own framework in [package.metadata.nros.board]. So moving a program between RTIC and a plain spin loop is a board change, not a code change: change board and nothing else, not the node source and not a build command.

Where the pieces live

rtic_ws/
└── src/
    ├── rtic_node/                     # Node pkg — board-agnostic
    │   ├── package.xml
    │   ├── Cargo.toml
    │   └── src/lib.rs                 # impl Component for Talker + nros::node!(Talker)
    └── rtic_node_bringup/             # Bringup pkg — no code
        ├── package.xml
        └── system.toml
  • Node pkg — declares what the node does (publishers, subscriptions, services, actions). No main, no #[rtic::app], no board choice. It is a library, linked into the generated entry.
  • Bringup pkg — holds system.toml (and launch files, once it has more than one node). This is where the board, and therefore RTIC, is chosen.
  • Entry — generated under build/, not authored. It is the #[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])] module, with #[init], the __nros_spin task, per-Node state slots and, when any node in the image declares DispatchStrategy::Deferred, the __nros_dispatch task. It depends on the board crate with its rtic feature, the RMW backend, rtic and the PAC, so the node package names none of them.

The split exists so the same rtic_node/ can be deployed under RTIC on bare-metal Cortex-M, under FreeRTOS on QEMU, and under POSIX on a Linux host without any per-target Node-pkg fork.

A minimal Node pkg — pub-only Talker

A pub-only Node declares DispatchStrategy::Inline (the default) — it publishes from its own RTIC task and never enters on_callback.

#![allow(unused)]
fn main() {
// File: src/rtic_node/src/lib.rs
#![no_std]

use core::time::Duration;
use nros::prelude::*;
use std_msgs::msg::Int32;

pub struct Talker;

pub struct TalkerState {
    publisher: Publisher<Int32>,
    counter: i32,
}

impl Component for Talker {
    const NAME: &'static str = "talker";
    // Inline is the default; spelled out here for clarity.
    const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        ctx.create_publisher::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Talker {
    type State = TalkerState;

    fn init() -> Self::State {
        TalkerState { publisher: Publisher::placeholder(), counter: 0 }
    }

    fn tick(state: &mut Self::State, _ctx: &mut TickCtx<'_>) {
        state.counter = state.counter.wrapping_add(1);
        let _ = state.publisher.publish(&Int32 { data: state.counter });
    }

    fn tick_period(_state: &Self::State) -> Option<Duration> {
        Some(Duration::from_millis(1000))
    }
}

nros::node!(Talker);
}
# File: src/rtic_node/Cargo.toml
[package]
name    = "rtic_node"
version = "0.1.0"
edition = "2024"

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

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

[dependencies]
nros     = { version = "*", default-features = false }
std_msgs = { path = "../../generated/std_msgs", default-features = false }  # nros-managed

Dependencies and the class registration, nothing else. The std_msgs row is not even yours: the build writes it from <depend>std_msgs</depend> in package.xml (The manifests). No board crate, no rtic, no PAC: those are the generated entry’s.

DispatchStrategy::Inline vs Deferred

A Node pkg declares DispatchStrategy via the Node::DISPATCH const. Two variants matter today; FromIsr is reserved as a design slot (see DispatchStrategy::FromIsr).

Inline — pub-only or tick-only Nodes

#![allow(unused)]
fn main() {
impl Component for Talker {
    const NAME: &'static str = "talker";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Inline;
    // ...
}
}

Inline means: callbacks (if any) fire from the executor’s spin loop — the same RTIC task that polls the network transport. On RTIC that’s __nros_spin running at priority = 1.

Pick Inline when:

  • The Node has no subscriptions, no service handlers, no action handlers. Its only output is Publisher::publish calls from tick or from a custom RTIC task you spawn yourself.
  • The Node has subscriptions, but the per-message work is so cheap (microseconds, no locks, no shared-state touches) that running it inline with the spin loop is acceptable.

The Talker above is the canonical Inline shape: it publishes from tick at 1 Hz, never receives anything, and never blocks the spin loop.

Deferred — callback-driven Nodes

#![allow(unused)]
fn main() {
impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;
    // ...
}
}

Deferred means: when a callback arrives, the spin loop enqueues a callback token plus context into a heapless::spsc::Queue and returns immediately. A separate __nros_dispatch RTIC task (typically priority = 2, one above spin) drains the queue and calls ExecutableNode::on_callback from its own task context.

Pick Deferred when:

  • The Node’s on_callback handler must hold an RTIC lock on a #[shared] resource — running it from the spin task would force every other dispatcher at the spin’s priority to wait on the lock.
  • The handler does non-trivial work (parses, integrates, posts to a hardware peripheral) and you want it scheduled independently from network polling.
  • The Node sits at a different priority tier than the spin task.

The tag-based registration API for Deferred Nodes

Deferred Nodes can’t use the closure form ctx.create_subscription("/chatter", |msg| { ... }) — the closure captures state by value, and a no-alloc framework runtime has no place to store an unknown closure type. Instead, you register with _static and dispatch via tag match in on_callback:

#![allow(unused)]
fn main() {
// File: src/listener_pkg/src/lib.rs
#![no_std]

use nros::prelude::*;
use std_msgs::msg::Int32;

pub struct Listener;

pub struct ListenerState {
    sub_chatter: SubscriptionTag,
}

impl Component for Listener {
    const NAME: &'static str = "listener";
    const DISPATCH: DispatchStrategy = DispatchStrategy::Deferred;

    fn register(ctx: &mut NodeContext<'_>) -> NodeResult<()> {
        let _tag = ctx.create_subscription_static::<Int32>("/chatter")?;
        Ok(())
    }
}

impl ExecutableNode for Listener {
    type State = ListenerState;

    fn init() -> Self::State {
        // The macro-emitted glue overwrites the placeholder with the
        // real tag at register time.
        ListenerState { sub_chatter: SubscriptionTag::placeholder() }
    }

    fn on_callback(
        state: &mut Self::State,
        cb: Callback<'_>,
        ctx: &mut CallbackCtx<'_>,
    ) {
        if state.sub_chatter == cb {
            let msg: Int32 = ctx.downcast().unwrap();
            defmt::info!("Received: {}", msg.data);
        }
    }
}

nros::node!(Listener);
}

The same shape applies to services (ServiceTag / create_service_static) and actions (ActionTag / create_action_static). The macro rejects mixing closure registration into a Deferred Node — the error spans the offending call and suggests switching to the _static form.

Your own RTIC tasks: the escapes, smallest first

Real RTIC applications don’t only have nano-ros tasks. You want a dedicated my_adc task to poll an ADC into a #[shared] buffer, or a my_ui task to drive an OLED, or another dispatcher priority. Take the smallest step that does what you need; the generated entry stays generated through the first three:

  1. An [image.*] key — panic, profile, features: anything the image already declares. See Customising an entry.
  2. before_boot — not on RTIC. RTIC’s #[init] owns the first statement, so the build refuses before_boot on an RTIC image; put pre-boot work in the board package (step 3) or in your own #[init] (step 4).
  3. A board package — hardware setup every image on the board needs (clocks, DMA, the monotonic) and the dispatcher list belong in the board’s init_hardware and its RTIC support, in a board package.
  4. nros materialize <bringup>:<image> — the last resort, for hand-written tasks.

nros materialize writes the image’s generated entry into src/<image>_entry/ as a package you own, and from then on nros build uses it and does not regenerate it:

nros materialize baremetal       # writes src/baremetal_entry/

Its crate root holds the same nros::main!(…) call the build generated. Add your tasks to it with the custom_tasks = [...] argument, which folds your task fns into the generated #[rtic::app] module:

#![allow(unused)]
fn main() {
// File: src/baremetal_entry/src/main.rs (after `nros materialize`)
#![no_std]
#![no_main]

use panic_semihosting as _;

nros::main!(launch = "rtic_node_bringup", custom_tasks = [my_adc, my_ui]);

#[rtic_task(priority = 3, shared = [adc_data])]
async fn my_adc(mut ctx: my_adc::Context) {
    loop {
        let sample = read_adc();
        ctx.shared.adc_data.lock(|d| *d = sample);
        Mono::delay(10.millis()).await;
    }
}

#[rtic_task(priority = 2)]
async fn my_ui(_ctx: my_ui::Context) {
    loop {
        draw_screen();
        Mono::delay(33.millis()).await;
    }
}
}

The proc-macro extracts the user task tokens verbatim, splices them into the generated mod __nros_app, and adds their dispatchers to the RTIC dispatchers = [...] list. Signatures + attributes + priorities are preserved. Because the macro still reads the launch file, a node you add to the bringup still reaches the materialized entry.

Custom tasks interact with nano-ros through the #[shared] resources the macro exposes — typically executor (the spin executor) for raw publisher access, or a Node’s state slot for direct handler calls. The exact resource names are not settled yet.

Replacing the macro. In the materialized entry you can also drop nros::main! and write the #[rtic::app] module yourself: manual Executor::open inside #[init] and a manual spin_once(0) polling task. You then own the boot, node registration, executor sizing and dispatcher list, and keep them in step with the bringup by hand. It looks like this:

#![allow(unused)]
fn main() {
// src/baremetal_entry/src/main.rs, written by hand
#[rtic::app(device = mps2_an385_pac, dispatchers = [UARTRX0, UARTTX0])]
mod app {
    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        let syst = nros_board_mps2_an385::init_hardware(&config, cx.device, cx.core);
        nros_rmw_zenoh::register().expect("Failed to register RMW backend");
        let mut executor = Executor::open(&exec_config).unwrap();
        let mut node = executor.create_node("talker").unwrap();
        let publisher = node.create_publisher::<Int32>("/chatter").unwrap();
        net_poll::spawn().unwrap();
        publish::spawn().unwrap();
        (Shared {}, Local { executor, publisher })
    }

    #[task(local = [executor], priority = 1)]
    async fn net_poll(cx: net_poll::Context) {
        loop {
            cx.local.executor.spin_once(core::time::Duration::from_millis(0));
            Mono::delay(10.millis()).await;
        }
    }
}
}

That is about 90 lines of glue for one node, and it is worth it only for fine-grained control the steps above cannot give: hand-tuned dispatcher priorities, monotonic setup, or #[shared] state of your own. Deleting src/baremetal_entry/ returns the image to generation.

DispatchStrategy::FromIsr — not yet

The third variant of DispatchStrategy is reserved for callbacks that fire directly from an ISR handler (e.g. a timer pulse triggering a publish without a scheduler hop). This is a design slot only; the implementation is deferred.

Landing it requires:

  • A reentrancy audit of the dispatch path.
  • A lock-free SPSC variant tolerant of ISR-priority producers.
  • A per-Node #[isr_safe] proof contract.

Until that work lands, nros check rejects DispatchStrategy::FromIsr deployments with a clear diagnostic.

Embassy

Embassy is not supported. nano-ros supports a way of running only if it can keep its real-time promise there: nano-ros decides which callback runs next and at what priority, so it can state when a callback runs, how late it can be, and that a low-priority callback never holds up a high-priority one.

Under Embassy the executor would run inside an Embassy task, and Embassy’s scheduler, configured by the application, would decide when that task and every callback in it run. nano-ros cannot analyse that schedule, so it cannot make the promise. The hand-driven spin task, Executor::spin_async / spin_async_with_sleep, the readiness notifier and the host-owned nros::setup! assembly that Embassy integration used are gone.

Use one of these instead:

  • An RTOS or bare-metal board through nros::main!. Tiers become OS threads (or the bare-metal loop) at priorities nano-ros derives from your launch contracts.
  • RTIC, as described on this page. RTIC’s scheduling is analysable and nros::main! generates the task mapping, so the promise holds.

The RTIC scheduling model

RTIC is not an RTOS — it is a concurrency framework that compiles directly to hardware interrupt handlers. There is no scheduler, no task control blocks, and no context switch overhead.

PropertyValue
ModelInterrupt-driven (NVIC hardware scheduling)
PreemptionYes — hardware interrupt nesting
Priority levels4–16 (depends on Cortex-M variant)
Priority directionLower number = higher priority (ARM convention)
Context switch12 cycles (Cortex-M3 tail-chain)
Mutual exclusionStack Resource Policy (SRP) — compile-time deadlock-free
Scheduling analysisFully static — all priorities and resources known at compile time

RTIC’s key innovation is the Stack Resource Policy (SRP): shared resources are protected by ceiling-based priority elevation, not locks. The compiler proves at build time that no deadlock or unbounded priority inversion can occur. This gives interrupt-driven scheduling the safety of a cooperative model.

#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac)]
mod app {
    #[task(priority = 2, shared = [sensor_data])]
    async fn read_sensor(mut ctx: read_sensor::Context) {
        ctx.shared.sensor_data.lock(|data| {
            *data = read_adc();  // runs at ceiling priority
        });
    }

    #[task(priority = 1, shared = [sensor_data])]
    async fn publish(mut ctx: publish::Context) {
        ctx.shared.sensor_data.lock(|data| {
            node.publish(*data);  // cannot deadlock — SRP guarantee
        });
    }
}
}

nano-ros integration: RTIC tasks call nano-ros directly — no executor needed. Each RTIC task can own a publisher, subscription, or service handle. The RTIC talker in the Rust language workspace, examples/workspaces/rust, shows it: a node package of its own, built by a bare-metal image row of the workspace’s pubsub_bringup.

Shared resources are guarded by the Stack Resource Policy (Baker, T.P. (1991), “Stack-Based Scheduling of Realtime Processes”, Real-Time Systems 3(1), 67–99): a compile-time ceiling with zero runtime overhead, so priority inversion is impossible by construction. Compared with the RTOS models in Scheduling Models, RTIC gives the best determinism (hardware scheduling, 12-cycle worst-case latency on a Cortex-M3 tail-chain) and the lowest RAM overhead (no task control blocks), at the cost of flexibility: ARM Cortex-M only, no dynamic task creation.

See also

Design background

Creating Examples

This guide describes the canonical example shape adopted (layout) + (consumption). When adding a new example, copy the nearest existing peer; the per-platform working examples are the authoritative templates.

Canonical layout

Every example is a self-contained, copy-out project under one of:

PathUsed for
examples/<plat>/<lang>/<example>/The standard cell. RMW is selected at build time (Cargo features / -DNROS_RMW= / Kconfig overlay), not encoded in the path. A single-package “app” example here is the canonical starter shape; the multi-package workspace shape (Node + Bringup + Entry pkgs) kicks in at ≥2 nodes — see Multi-Node Projects.
examples/templates/<name>/Multi-platform copy-out recipes (e.g. multi-package-workspace).

A cross-RMW gateway is not a separate category: it is a workspace whose system.toml declares a [[bridge]] row (examples/workspaces/bridge-cyclonedds/, examples/workspaces/bridge-xrce/). The examples/bridges/ category that held hand-written gateways was deleted in phase-477 — no lane built them.

The <plat> × <lang> coverage matrix (RMW chosen at build time) is authoritative in examples/README.md. Intentionally empty cells (bare-metal C/C++, PX4 Rust, …) are listed in the same file; do not fill them without lifting the underlying constraint.

Variant naming uses suffix form so peers sort together: talker-rtic, service-client-rtic, talker-rtic-mixed. Avoid parallel parent directories like async-*/ or rtic-*/.

Non-example binaries

Tests / benches / smokes are not under examples/. They live under packages/testing/:

UseLocation
Performance, fairness, stress, large-msgpackages/testing/nros-bench/<name>/
Driver / board bringup smoke (no nros API)packages/testing/nros-smoke/<name>/
Fixture binaries built by integration testspackages/testing/nros-tests/bins/<name>/

Consumption shape

Rust (native or Cargo cross-target)

Each example is a standalone Cargo package with empty [workspace] table — it does not participate in any walking-up workspace:

[package]
name = "native-rs-zenoh-talker"
edition = "2024"
publish = false

[[bin]]
name = "talker"
path = "src/main.rs"

[dependencies]
nros = { version = "*", default-features = false,
         features = ["alloc", "env", "log", "rmw-cffi", "ros-humble"] }
nros-rmw-zenoh = { version = "*",
                   features = ["std", "platform-posix", "ros-humble"] }

[workspace]

nano-ros crates are declared registry-style: they are not on crates.io, so something has to resolve them into the checkout. That something is no longer a file you write. Since RFC-0098 an example carries no .cargo/ directory at all: nros configure writes the [patch.crates-io] rows, the board’s cargo settings and the derived [env] into build/<image>/nros-cargo.toml, and cargo reads that file through --config. After adding or renaming nros deps or msg <depend> rows, re-run nros configure in the example dir — and commit nothing, because everything it wrote is build output. This is what makes the copy-out promise real: a copied example re-runs nros configure at its new location.

The canonical invocation from inside the example directory:

nros configure     # generated/ msg crates + build/<image>/nros-cargo.toml
nros build    # every declared image; or `nros build <image-id>`

Driving cargo yourself works too — hand it the generated settings file. Since phase-445 W6 deleted the leaf’s .cargo/, the working directory no longer matters: there is nothing beside the package for cargo to read a second time, and a tracked one is refused (check-example-cargo-dirs).

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

There is still no workspace-wide cargo build that picks up examples — they are explicitly out-of-workspace.

Multi-package workspace examples

A workspace example under examples/workspaces/ has no root Cargo.toml and no root CMakeLists.txt (RFC-0098 D9) — it is a directory of packages, like a colcon workspace, and everything generated lives under build/, dist/ and log/. There is no entry package to write either: the entry is generated per [image.*], at build/<coord>/<entry>/Cargo.toml (cargo) or build/<coord>/CMakeLists.txt (cmake). The bringup package’s system.toml carries the images. nros configure then nros build is the flow; a workspace with no bringup at all builds every package in dependency order, each into its own build/<pkg>/.

The board, stated once — and the one place it is still stated twice

An example names its board in system.toml and nowhere else in its build configuration:

[system]
name      = "my_talker"
rmw       = "zenoh"          # zenoh | cyclonedds | xrce
domain_id = 0

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

[image.mps2]                 # an embedded image also carries its identity
board   = "mps2-an385-baremetal"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

The same schema serves a workspace bringup (src/<name>_bringup/system.toml), so the resolver has one input shape either way. Retired by it, and refused wherever they still appear: [package.metadata.nros.entry] (including deploy = — an entry is the package an image claims, not one a manifest table marks), [package.metadata.nros.deploy.<board>], and the package.xml <nano_ros deploy= board= rmw=/> tuple. On a single-package leaf [package.metadata.nros.node] / component are refused too (its system.toml [[component]] says it); on a workspace NODE package they are kept — they are the Rust spelling of node registration, the counterpart of nros_components_register_node in a C/C++ package’s CMakeLists.txt, not a deployment (issue 1289).

A single-package Rust leaf names no board crate. It is its own entry, so something has to give nros::main! the board: a generated package. The leaf declares one fixed dependency, <pkg>_nros_selection = { path = "build/nros/selection" } (target-scoped when the node must also build for the host probe), and nros configure / nros build write that package for the image’s board — the board crate with its features, the RMW backend, nros-platform’s platform feature and the runtime and panic crates (RFC-0098 D6). So a new leaf names none of those, and switching [image.*] board is the only edit. If main.rs says nros::main!(panic = "platform"), state panic = "platform" in the [image.*] too, or the package also links the board’s panic handler. Naming a board crate beside the selection package is refused at nros configure, and a unit test walks every in-tree leaf for it. A C/C++ app on a cargo link root (examples/mps2-an385-baremetal/{c,cpp}/*) takes the same dependency and reaches the board’s re-exports through it from its own main.rs. The leaves outside this shape BY SHAPE (board code in the node, harness-templated test fixtures, a lib a CMake image links) are listed with their reasons in leaf_settings::NOT_ON_THE_SELECTION; see issue 1305.

C / C++ (CMake)

The canonical shape is an ament package — 152 of the 162 example CMakeLists.txt files in the tree, and what a new one should copy. Verbatim from examples/linux/c/talker/:

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

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)

find_package(nano_ros REQUIRED)
find_package(std_msgs REQUIRED)

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

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

No platform, no board, no RMW on the command line or in the file: find_package(nano_ros) reads the example’s system.toml through nros ws leaf-system and derives the platform from the board it names. -DNROS_PLATFORM, -DNROS_BOARD and -DNROS_RMW are retired as the way a user chooses. A bare cross configure still passes -DCMAKE_TOOLCHAIN_FILE by hand (or uses nros setup presets + cmake --preset <board>); nros build in the leaf maps the board to its toolchain file and runs the same configure into build/<image>/cmake (issue 1296, resolved).

A single-package C/C++ leaf needs no nros configure: its message bindings are a CMake-time output, so a clean copy outside the checkout configures and builds with no sync at all.

The other shape — a standalone CMake project pulling nano-ros in by path with add_subdirectory(<repo-root>), setting NROS_PLATFORM / NROS_RMW / NROS_BOARD itself, linking NanoRos::NanoRos and calling nros_platform_link_app() + nano_ros_link_rmw() — is what the ThreadX Rust leaves and the examples/templates/ recipes use, and is the right shape for an out-of-tree consumer that owns its whole CMakeLists.txt. Its cache-variable contract is in docs/reference/c-api-cmake.md.

nano_ros_link_rmw emits the strong-stub nros_app_register_backends() that calls every linked RMW’s nros_rmw_<x>_register() symbol — the auto-registration path for targets where linkme’s distributed-slice contribution isn’t picked up by the linker (FreeRTOS, NuttX, Zephyr, bare-metal).

Note find_package(nano_ros) above is the ament package. There is still no find_package(NanoRos) CMake-config export — that was deleted along with just install-local, every install(...) rule, and every Config.cmake.in template.

Per-example contents

examples/<plat>/<lang>/<example>/
├── system.toml                    # board, RMW, domain, components, net identity
├── package.xml                    # ROS-style manifest for the example
├── Cargo.toml | CMakeLists.txt    # language-toolchain facts only
├── src/                           # main.rs / main.c / main.cpp
├── generated/                     # codegen output — `nros configure`, gitignored
├── build/                         # `nros configure` / `nros build` output, gitignored
└── README.md                      # usage instructions

There is no .cargo/. Cargo.toml and CMakeLists.txt carry language-toolchain facts; every build setting the board implies is generated under build/. Each example’s Cargo.toml / CMakeLists.txt builds in isolation — no workspace reliance, no path heuristics walking up the source tree.

Message generation

Examples with custom .msg, .srv, or .action files generate bindings in-tree under generated/. The generated/ directory is gitignored per-example (only the core pre-generated set at packages/interfaces/generated/ lives in git, Arduino bundle exception aside).

source /opt/ros/humble/setup.sh        # for rosidl tooling
nros configure                              # what you run in an example

nros dev codegen rust (and generate-c / generate-cpp / generate-all) is the codegen-only primitive underneath it: it emits generated/ and nothing else. nros configure is the verb an example needs, because it also writes the image’s build/<image>/nros-cargo.toml.

Its --generate-config / --config / --nano-ros-path / --nano-ros-git flags still parse, and are retired as a way to configure an example: they wrote a leaf .cargo/config.toml, which no longer exists. (--nano-ros-path is a documented no-op on nros configure itself, kept for back-compat.)

For CMake consumers:

nros_find_interfaces(LANGUAGE C SKIP_INSTALL)
nano_ros_generate_interfaces(... LANGUAGE C)

See Message Generation for the full reference + package.xml schema.

Adding a new example — checklist

  1. Pick the canonical cell. Confirm <plat>/<lang>/<name> isn’t in the “intentionally empty” list in examples/README.md.
  2. Copy the nearest peer. Identical-RMW + adjacent-platform is the lowest-risk template (e.g. copy examples/mps2-an385-freertos/c/talker to make a new FreeRTOS C/zenoh example).
  3. Update names + package.xml. Rename Cargo.toml’s name and [[bin]] entries (Rust) or project(...) and nano_ros_add_executable(...) targets (CMake).
  4. Point system.toml at your board. [system] name, the [[component]] rows (pkg, class, name), and [image.<id>] board plus any network identity the board needs (locator, and the address in net). A Rust leaf also needs its [dependencies] board crate changed to match — see the exception above. If the board cannot be host-probed (a foreign [build] target, build-std, or a board crate with no host build), declare the component’s entities here too.
  5. Sync. nros configure in the example directory — generated message crates plus build/<image>/nros-cargo.toml. Custom messages need their own package.xml in the consuming example. A single-package C/C++ leaf can skip this; everything else cannot, and nros build refuses with one line naming it.
  6. Build standalone. nros build from the example directory (C/C++ single-package leaves can also run raw CMake: cmake -B build -Dnano_ros_ROOT="$(nros dev sdk root)" && cmake --build build). No walking-up workspace allowed.
  7. Wire the test fixture (optional). If the example needs an E2E gate, add a row to examples/fixtures.toml and the matching cell in matrix::CELLS; the builder lives in packages/testing/nros-tests/src/fixtures/binaries/<plat>.rs.
  8. Update examples/README.md coverage matrix if you filled a previously-empty cell.

Per-platform notes

The build command is nros build (Rust and workspaces) or cmake --build (single-package C/C++) almost everywhere; the table records what differs.

PlatformSource file shapeBuildNotes
nativesrc/main.rs, src/main.c, src/main.cppnros build / cmake --buildFull std.
mps2-an385-baremetalsrc/main.rs with #[entry]nros buildNo std. Pure Cortex-M3. The board’s QEMU runner is in the generated settings, so cargo run --config … boots it.
mps2-an385-freertossrc/main.rs / src/main.cpp / src/main.cnros build (Rust) or cmake --build (C/C++)FreeRTOS kernel + lwIP.
nuttxsrc/main.rs / src/main.ccmake --build (NuttX export tarball)NuttX kernel.
threadx-linux / threadx-riscv64src/main.rscmake --buildThreadX + NetX Duo.
esp32src/main.rsnros build + espflash save-imagebare-metal esp-hal; no cargo runner.
zephyrsrc/lib.rs (staticlib) or src/main.cppwest build (after nros configure)Kconfig + west module — the one carve-out where nros build is not the verb.

Artifacts land under <leaf>/build/<image-id>/target/[<triple>/]<profile>/<bin>: examples/linux/rust/talker gives build/linux/target/debug/talker, examples/mps2-an385-baremetal/rust/talker gives build/mps2-an385-baremetal/target/thumbv7m-none-eabi/debug/qemu-bsp-talker.

Per-platform deep-dives — toolchain setup, Kconfig variables, runner scripts — live in the Platform Guides.

See Also

Formal Verification

nano-ros uses three complementary verification tools to ensure correctness of its core libraries:

  • Kani – bounded model checking with 148 harnesses across 6 crates. Checks memory safety, integer overflow, and panic-freedom within bounded inputs.
  • Verus – deductive (unbounded) proofs with 83 verified properties. Proves functional correctness of scheduling, serialization, time arithmetic, and safety protocols.
  • Miri – runtime undefined behavior detection for unsafe code.

All three run via just recipes:

just verify          # Run both Kani + Verus
just verify-kani     # Kani only (~3 min)
just verify-verus    # Verus only (~1 sec)
just test-miri       # Miri UB detection

Kani

Kani is a bounded model checker that translates Rust code to a verification IR and exhaustively explores all possible executions up to a given bound.

just verify-kani

148 harnesses are spread across 6 crates (counts as of the last audit — regenerate with grep -rc '#[kani::proof' packages/):

CrateFocus
nros-serdesCDR serialization round-trip, buffer bounds
nros-coreTime arithmetic, type invariants
nros-paramsParameter value constraints, range checking
nros-cFFI boundary safety
nros-ghost-typesBuffer state machine transitions (overflow/lock)
nros-nodeExecutor scheduling, subscription handling

Kani checks three properties by default:

  1. Memory safety – no out-of-bounds access, no use-after-free
  2. Overflow freedom – no integer overflow on arithmetic operations
  3. Panic freedom – no reachable panic!, unwrap(), or assert! failure

A typical run takes approximately 3 minutes on a modern machine.

Miri

Miri is an interpreter for Rust’s Mid-level IR that detects undefined behavior at runtime:

just test-miri

Miri catches issues that neither the compiler nor Kani can detect:

  • Invalid pointer dereferences
  • Uninitialized memory reads
  • Violations of aliasing rules (Stacked Borrows / Tree Borrows)
  • Data races in concurrent code

If Miri fails with “contains outdated or invalid JSON”, clean the cache:

rm -rf target/miri

Verus

Verus is a deductive verification tool for Rust. Unlike Kani (which checks bounded executions), Verus proves properties hold for all possible inputs using SMT solving.

Quick Reference

just verification verus   # Provision the pinned Verus release into the SDK store (or `just verification setup` for kani + verus)
just verify-verus    # Run all Verus proofs
just verify          # Run both Kani + Verus

Verification crate: packages/verification/nros-verification/

Type Specifications

Every type used inside verus! { } that is defined outside the macro needs an external_type_specification. How you write it determines whether Verus treats the type as transparent or opaque.

Transparent enums (variant matching allowed)

Use external_type_specification without external_body:

#![allow(unused)]
fn main() {
use nros_node::TriggerCondition;

verus! {

#[verifier::external_type_specification]
pub struct ExTriggerCondition(TriggerCondition);

// Now we can match on variants in spec functions:
pub open spec fn trigger_eval_spec(cond: TriggerCondition, ready: Seq<bool>) -> bool {
    match cond {
        TriggerCondition::Any => ...,
        TriggerCondition::All => ...,
        TriggerCondition::Always => true,
        TriggerCondition::One(index) => ...,
    }
}

} // verus!
}

This works for enums whose variants are visible (public). Verus sees the full variant structure and allows pattern matching in specs and proofs.

Opaque types (no internal access)

Use external_type_specification with external_body:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
#[verifier::external_body]
pub struct ExSomeOpaqueType(SomeOpaqueType);
}

Use this for types with private fields, complex internals, or when you only need to pass them around without inspecting their structure. You cannot match on variants or access fields.

Structs with public fields

Use external_type_specification without external_body – fields become accessible in specs:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
pub struct ExDuration(nros_core::time::Duration);

// Fields are accessible:
pub assume_specification[ Duration::to_nanos ](self_: &Duration) -> (n: i64)
    ensures n == (self_.sec as i64) * 1_000_000_000i64 + (self_.nanosec as i64);
}

Linking Specs to Production Code

assume_specification

Axiomatically declares a contract on a production function. The contract is trusted (not verified by Verus) – a human auditor must confirm the spec matches the implementation.

#![allow(unused)]
fn main() {
pub assume_specification[ TriggerCondition::evaluate ](
    self_: &TriggerCondition,  // &self becomes a named parameter
    ready: &[bool],
) -> (ret: bool)
    ensures
        ret == trigger_eval_spec(*self_, ready@);
}

Rules:

  • &self must be written as self_: &Type (named parameter, not method syntax)
  • ready@ converts &[bool] to Seq<bool> (Verus view conversion)
  • *self_ dereferences &TriggerCondition to TriggerCondition for spec matching
  • The return value must be named: -> (ret: bool)

Ghost models

For types that can’t be imported (private fields, feature-gated behind C FFI, etc.), create a ghost model – a manually maintained mirror:

#![allow(unused)]
fn main() {
/// Ghost representation of TimerState (mirrors nros_node::timer::TimerState).
pub struct TimerGhost {
    pub period_ms: u64,
    pub elapsed_ms: u64,
    pub mode: TimerModeGhost,
    pub canceled: bool,
}
}

Ghost models have weaker guarantees than assume_specification. Correctness relies on manual comparison with the production source code.

Trust Levels

Every proof falls into one of three trust levels:

LevelMechanismWhat’s trustedStrength
Formally linkedassume_specification + external_type_specificationThe spec matches the impl (human audit of ~4 lines)Strongest
Ghost modelManual struct/enum mirrorLine-by-line correspondence with production sourceMedium
Pure mathArithmetic identitiesOnly the math itselfWeakest (no code link)

Document the trust level of each proof in the module-level doc comment.

Pitfalls

verify = true on production crates

Never add [package.metadata.verus] verify = true to a production crate that contains function pointers, dyn Trait, or closures. Verus will attempt THIR erasure on all items in the crate and panic at erase.rs:237.

Only the verification crate (nros-verification) should have verify = true. Production crates are used as regular dependencies – Verus compiles them with standard rustc without attempting verification.

vstd dependency

vstd is published on crates.io (vstd = "0.0.0-2026-02-08-0120"). Use the registry version, not a path dependency. The pre-built Verus release does not include the full source tree needed for path dependencies (missing dependencies/prettyplease etc.).

Items outside verus! are external

Any type, function, or trait defined outside the verus! { } macro is treated as external by Verus. To use it in specs, you must register it with external_type_specification or reference it via assume_specification.

Closures and iterators

Verus cannot verify code containing closures (including .iter().any(|&r| r)) or function pointers. Mark such items with #[verifier::external] if they’re in a crate being verified, or keep them in production code that Verus never touches.

Edition 2024

Verus’s bundled rustc (1.93.0) supports edition 2024. The verification crate uses edition = "2024" to match the nros workspace. No special configuration is needed.

Adding a New Proof

  1. Identify the production function and its source location
  2. Determine the trust level:
    • Can you import the type? Use external_type_specification
    • Is the type behind a feature gate with C FFI? Use a ghost model
    • Is it pure arithmetic? Pure math proof
  3. Write the spec function inside verus! { }
  4. If formally linking: add assume_specification and document which source lines the auditor should compare
  5. Write the proof function with ensures clauses
  6. Run just verify-verus to check
  7. Update the module doc comment with the new proof’s trust level

File Organization

packages/verification/nros-verification/src/
├── lib.rs            # Module declarations
├── scheduling.rs     # Timer + trigger + executor proofs
├── communication.rs  # CDR serialization safety proofs
├── cdr.rs            # CDR round-trip integrity proofs
├── time.rs           # Duration/Time arithmetic proofs
├── action.rs         # GoalStatus state machine proofs
├── params.rs         # ParameterValue + range proofs
├── progress.rs       # Executor progress / liveness proofs
├── safety.rs         # Safety-protocol proofs
└── e2e.rs            # End-to-end data path proofs

Proofs are organized by what they guarantee to the application developer, not by source crate. A single proof module may reference types from multiple production crates.

Patched qemu-system-arm Binary

nano-ros ships a project-local QEMU build that the test harness uses in preference to whatever qemu-system-arm is on $PATH. This chapter explains why it exists, where it comes from, how a test picks it up, and how to change it.

Why

Two production-blocking issues motivated the patched build:

  1. LAN9118 RX FIFO drain bug (mainline QEMU through at least 11.0). Under sustained burst RX, the LAN9118 emulator’s lan9118_can_receive() can stop returning true even after the guest drains the FIFO, so frames silently disappear. The fix is a commit on our fork branch (hw/net/lan9118: flush queued packets when RX is enabled). Bare-metal MPS2-AN385 RTPS / Zenoh tests on the system QEMU sporadically fail without it.

  2. -netdev dgram,local.type=unix,… requires QEMU 7.2+. Ubuntu jammy ships QEMU 6.2, and the dgram-tunnel pattern is how NuttX / ThreadX DDS multi-instance tests cross-deliver frames between two QEMU processes after retired the broken -netdev socket,mcast= cross-process path. With a too-old system QEMU, those tests fall back to [SKIPPED].

Both problems disappear once tests use the patched build.

Where it comes from

The patches live on a branch of our QEMU fork, NEWSLabNTU/qemu nano-ros-v11.0.0-patches, and nano-ros-sdk publishes it as a prebuilt dist. nros-sdk-index.toml pins it as one row, [tool.qemu] (version = "11.0.0-nros8" at the time of writing — read the index, not this line), with a [tool.qemu.source] recipe for a host that has no prebuilt. The fork is also the third-party/qemu/qemu submodule, pinned at the commit the current dist was built from.

Two places can hold it:

~/.nros/sdk/qemu/<version>/bin/qemu-system-arm   # the store: `nros setup --tool qemu`
build/qemu/bin/qemu-system-arm                   # project-local: `just qemu setup-qemu`

The store path is keyed by version, so it cannot drift from the pin. The project prefix is ONE directory across pin bumps, which is why it carries the .nros-provenance marker nros setup writes, naming the version it holds (issue 0930). just qemu setup-qemu runs nros setup --tool qemu --prefix build/qemu, which is a no-op when that marker names the pinned version and replaces the prefix when it does not.

How tests pick it up

ONE ladder (issue 1851), in packages/testing/nros-tests/src/qemu.rs (qemu_system_path) and its shell twin scripts/lib/qemu-bin.sh (nros_qemu_bin):

  1. QEMU_SYSTEM_<ARCH> (QEMU_SYSTEM_ARM) — an explicit override.
  2. The project prefix build/qemu/bin/ — only while its .nros-provenance names the version the index pins. A prefix holding another version, or none (a hand build), is REFUSED with the remedy (just qemu setup-qemu, or remove build/qemu), never skipped: a test must not run on an emulator nobody chose. just check tier-preconditions reports the same thing before a run.
  3. The store copy at the pinned version.
  4. $PATH.

Every QEMU launch in the test crates, just/ and scripts/ goes through it (check-qemu-one-ladder).

Smoke test

packages/testing/nros-tests/tests/qemu_patched_binary.rs asserts:

  • qemu_system_arm_path() resolves to either QEMU_SYSTEM_ARM or <workspace>/build/qemu/bin/qemu-system-arm.
  • The patched binary reports version ≥ 7.2.
  • -netdev help advertises dgram (the multi-instance backend).

Tests use nros_tests::unmet! when the patched build is absent — a fresh clone without just qemu setup-qemu FAILS with a clear [UNMET PRECONDITION] naming the remedy instead of silently passing.

Adding a patch, or moving the pin

  1. Land the change on the fork branch (nano-ros-v11.0.0-patches), one logical change per commit.
  2. Cut a new nano-ros-sdk QEMU release from it.
  3. Bump [tool.qemu] version and the dist rows in nros-sdk-index.toml.

Every checkout then sees its build/qemu as stale until it re-runs just qemu setup-qemu; the store copy is fetched at the new version on the next nros setup --tool qemu.

Scope

Only qemu-system-arm is unified. qemu-system-riscv32 is Espressif’s fork (different upstream, different patches); qemu-system-riscv64 and qemu-system-aarch64 ship no patches today. Other arches stay on the system binary until they accumulate their own patches; the helper pattern is easy to copy when that happens.

FreeRTOS LAN9118 Debugging

This page collects the low-level FreeRTOS + lwIP + QEMU LAN9118 notes that are useful when the platform guide is not enough.

Stack

  • Board: QEMU MPS2-AN385, Cortex-M3, 25 MHz.
  • Ethernet: LAN9118 MMIO at 0x4020_0000, IRQ 13.
  • Software: FreeRTOS, lwIP threaded mode (NO_SYS=0), zenoh-pico over BSD sockets.

Default task layout:

PriorityTaskRole
4tcpip_threadlwIP protocol processing
4poll taskdrains LAN9118 RX FIFO into lwIP
4zenoh read / leasezenoh-pico background I/O
3app tasknano-ros executor and user code
0idlemust execute WFI for QEMU networking

The poll task must run at least as high as the zenoh read task; if it cannot drain the RX FIFO, TCP keep-alives are missed and zenoh sessions expire.

LAN9118 Registers

Useful direct registers:

OffsetNamePurpose
0x00RX_DATA_PORTRX FIFO data
0x20TX_DATA_PORTTX FIFO data
0x40RX_STAT_PORTRX packet status
0x50ID_REVchip ID / revision
0x54IRQ_CFGinterrupt output configuration
0x58INT_STSinterrupt status
0x6CRX_CFGRX configuration
0x70TX_CFGTX configuration
0x7CRX_FIFO_INFRX FIFO status / bytes used
0x80TX_FIFO_INFTX FIFO free space
0xA4MAC_CSR_CMDindirect MAC CSR command
0xA8MAC_CSR_DATAindirect MAC CSR data

Indirect MAC CSR registers include MAC_CR (TXEN/RXEN), ADDRH, ADDRL, MII_ACC, and MII_DATA. Always wait for the CSR busy bit to clear before starting another access.

QEMU Delivery Model

QEMU routes LAN9118 traffic through a legacy hub:

guest LAN9118 NIC <-> QEMU hub <-> backend (Slirp / TAP / socket)

When the guest transmits, QEMU delivers the frame synchronously through the hub. Slirp or TAP replies, such as ARP responses, may arrive in the RX FIFO before the guest TX register write returns. This is useful for diagnostics: check RX_FIFO_INF immediately after TX, before yielding to FreeRTOS.

QEMU drops frames that arrive before MAC_CR_RXEN is enabled, and its LAN9118 model does not flush queued packets when RXEN changes. Enable RX before traffic starts.

Common Pitfalls

Diagnostic shows zero RX packets. The poll task may have consumed the packet before diagnostics read the register. Check RX_FIFO_INF immediately after TX or temporarily prevent the poll task from running.

Network init hangs. lwIP threaded mode requires the FreeRTOS scheduler to be running before tcpip_init(). Initialize networking from a task, not before vTaskStartScheduler().

HardFault on first context switch. The Cortex-M3 FreeRTOS port expects vPortSVCHandler, xPortPendSVHandler, and xPortSysTickHandler at the vector table positions. Avoid wrapper functions except for the guarded SysTick case.

lwIP core-lock assertion. Keep LWIP_TCPIP_CORE_LOCKING=0; the setup path calls netif functions from the app task after tcpip_init().

Intermittent stalls. Always write IRQ_CFG_DEFAULT (0x22000111) during LAN9118 init, even when polling.

Corrupted RX frames. RX_STAT_PORT packet length includes the 4-byte FCS. Read all aligned FIFO words, including CRC bytes, but pass only pkt_len - 4 bytes to lwIP.

Second QEMU node cannot connect. Seed rand() uniquely per node. zenoh-pico uses z_random_fill() for session IDs; identical QEMU boots otherwise produce duplicate IDs.

lwIP ASSERT: Invalid mbox. The application stack is too small. The executor arena lives on the FreeRTOS task stack; use 64 KB for service and action examples.

Action client times out on result. Manual-polling action servers created with create_action_server() must call try_handle_get_result() explicitly after completing a goal.

No inbound frames in QEMU. The idle hook must execute WFI so QEMU’s main event loop can service network file descriptors:

void vApplicationIdleHook(void) {
    __asm__ volatile("wfi");
}

Debugging Tips

  • Use ARM semihosting for early printf output.
  • Launch QEMU with -monitor telnet:127.0.0.1:4444,server,nowait and run info network or info qtree.
  • Send a diagnostic ARP request and check for immediate RX FIFO data.
  • Confirm ID_REV, MAC_CR TXEN/RXEN, TX_FIFO_INF, RX_FIFO_INF, and INT_STS during bring-up.

References

  • FreeRTOS-Plus-TCP MPS2_AN385 driver.
  • Zephyr drivers/ethernet/eth_smsc911x.c.
  • ARM CMSIS LAN9220 Ethernet driver templates.
  • QEMU hw/net/lan9118.c.
  • nano-ros packages/drivers/net/lan9118-smoltcp/.

Test failures

Failures in the repository’s own test suites and checkout builds. The user-facing troubleshooting page is the user book’s Troubleshooting.

Checkout: zenoh-pico submodule not found

Symptom:

zenoh-pico submodule not found at /path/to/zenoh-pico. Run: git submodule update --init

Solution: init that one submodule, scoped — not --recursive. The transitive closure is large and mostly irrelevant to any one task, and the launch toolchain’s third-level runtime submodules are never built by nano-ros.

git submodule update --init packages/rmw/zenoh/zpico-sys/zenoh-pico

Better still, nros setup <board> --rmw zenoh (or nros setup --source zenoh-pico) fetches it shallow.

Checkout: zenoh-pico CMake defines do not take effect

After cargo clean -p zpico-sys, also touch packages/rmw/zenoh/zpico-sys/build.rs so the build script reruns.

Zephyr workspace patch

just setup zephyr applies the Z_FEATURE_INTEREST / Z_FEATURE_MATCHING patch the user book’s troubleshooting page describes to the in-tree Zephyr workspace automatically.

Tests Hang Forever

Symptom: Integration tests hang indefinitely.

Possible causes:

  1. No zenohd router running: Many tests require a zenohd router
  2. Port already in use: Another process is using port 7447
  3. Network misconfigured: TAP or bridge interfaces not set up

Solutions:

# Check if zenohd is running
pgrep zenohd

# Check if port 7447 is in use
ss -tlnp | grep 7447

# Verify QEMU bare-metal TAP (only needed for QEMU bare-metal platforms)
ip addr show tap0

Zephyr Tests Skip

Symptom: All Zephyr tests are skipped.

Cause: The test framework couldn’t find the Zephyr workspace or required components.

Solution: Ensure the Zephyr workspace is properly configured:

# Default location: $repo/zephyr-workspace/ (gitignored, in-tree).
# Legacy setups: $repo/../nano-ros-workspace/ (auto-detected).
ls -la zephyr-workspace
nros doctor

# Or set explicitly
export NROS_ZEPHYR_WORKSPACE=/path/to/zephyr-workspace

# Verify west is available
west --version

Test Harness

This page covers nano-ros’ test runner conventions, with a focus on how the harness distinguishes environment-conditional skips from real failures in JUnit reports.

For a recipe-level cheat-sheet (which just <plat> test exists, which platform groups run under just test-all, where junit lives, etc.) see book-internals/src/dev-env/build-commands.md.

Build-fixtures ordering

Per-platform test always sequences build-fixtures first; the harness fails loud on missing fixtures rather than rebuilding. Test recipes assume fixtures are present + at the canonical paths from examples/fixtures.toml — when missing, the harness asserts with the source location and the expected fixture path, instead of silently re-running cargo-build.

Wired in Phase 214.Q.1 (d7e895228) by adding build-fixtures as a just-recipe prereq head form (test verbose="": build-fixtures) on every per-platform module — native, zephyr, esp32, freertos, nuttx, threadx-linux, threadx-riscv64, qemu-baremetal. Cyclonedds uses CTest with test: build-rmw as its equivalent. The fail-loud assertion lives in packages/testing/nros-tests/src/fixtures/binaries/mod.rs::require_prebuilt_binary — it returns TestError::BuildFailed("Test fixture binary not prebuilt: <path>. Run just build-test-fixtures first.") instead of invoking cargo. Builds inside test bodies historically stretched a 14 s test to 125 s on a saturated host racing against QEMU + zenohd; the prereq-then-assert shape keeps build and run phases cooperatively sequenced.

Staleness (phase-278, issue #147). Existence alone let a bare cargo nextest run (which skips the just test-all _check-fixtures-stale preflight) silently run a fixture whose source had changed since it was built — the recurring hazard behind #146 (a pre-W4 Int32_ listener vs the String_ test), #129, #140. The resolvers now also carry a DETECT-ONLY staleness probe that reads the toolchain’s own recorded dependency graph and mtime-compares against the built binary — never invoking the compiler, so it cannot rebuild: require_prebuilt_binary_fresh (cargo <binary>.d dep-info, native rust + bins/), require_prebuilt_binary_fresh_cmake (ninja -t deps on the C/C++ cell), and require_prebuilt_binary_fresh_zephyr (the west staticlib’s .d vs the linked zephyr.exe). A stale fixture hard-fails "… is STALE" naming the newer source; the fix is just build-test-fixtures, not NROS_SKIP_FIXTURE_CHECK=1 (that bypass exists for “built it another way”). A missing .d/.ninja_deps falls back to existence-only, so non-cargo/non-ninja fixtures (qemu/west-image/idf) are unaffected.

Skip vs. failure tally semantics

The skip contract (issue 1758)

CLAUDE.md → “Practices” mandates: a test never skips because something it needs is absent — it fails; the lane’s SCOPE decides what runs.

  • In scope, precondition unmet → nros_tests::unmet! panics with [UNMET PRECONDITION] …. The junit rewrite leaves it a failure. Missing zenohd, QEMU, cross toolchain, ROS 2, fixture — all red. The require_*() helpers (require_zenohd, require_ros2, …) return () and fail by themselves.

  • Out of scope → nros_tests::lane_skip! panics with [SKIPPED:lane] …, which the junit rewrite turns into <skipped>. It is reached ONLY from a scope predicate, checked before any host probe: the lane’s coordinates (NROS_TEST_COORDS → fixtures::lane::require_*_in_lane) and the host capabilities the lane does not claim (NROS_TEST_UNCLAIMED=ros2 → lane_scope). Both unset ⇒ the run claims everything.

  • Who sets the scope. Every runner of a scope reads both axes from scripts/build/fixture-lane.sh:

    • nros_lane_unclaimed <lane> is the declaration CiLane::unclaimed makes, bound by lane_build_covers_run.rs.
    • nros_scope_coords_file <module> is a platform’s own rows, from lane-coords --scope.

    So just ci tier1 and just test tier1 deselect the same tests, and just test linux does not claim a NuttX coordinate.

check-skip-budget fails any skip that is not lane, any lane skip for a coordinate the run selected, and a run in which nothing ran. There is no allowlist.

The literal [SKIPPED] prefix is load-bearing: every downstream consumer (the post-processor, the failure counters, the rerun-failed filterset, CI dashboards) keys off it. Do not change the prefix or re-format the message without sweeping every consumer.

Why the panic surfaces as <failure>, not <skipped>

Nextest exposes three test outcomes in JUnit XML:

nextest conceptJUnit elementTriggered by
pass<testcase> w/ no childrennormal return
skip<skipped>#[ignore] attribute
fail<failure>panic, non-zero exit, timeout

The <skipped> channel is reserved for the #[ignore] attribute, which is a compile-time decision (the test never runs). nextest has no built-in runtime-skip — and [SKIPPED] is a runtime decision, so the panic lands in <failure> like any other panic.

The JUnit post-processor (Phase 214.R.1)

scripts/test/rewrite-skipped-junit.py rewrites the junit.xml after each test recipe to convert [SKIPPED] failures into native <skipped> entries. For each <testcase> whose every <failure> contains the [SKIPPED] marker (message attribute or panic body), the script:

  1. Replaces each <failure message="[SKIPPED] …"> with <skipped message="[SKIPPED] …">, preserving the message attribute and panic body verbatim.
  2. Decrements failures="N" and increments skipped="N" on the enclosing <testsuite> and the top-level <testsuites> element.
  3. Atomically writes the file back (tmp + os.replace) so a crash mid-write leaves the original intact.

A testcase with mixed failures (one real + one [SKIPPED]) stays a <failure> — only purely-[SKIPPED] cases are rewritten.

The script is idempotent and exits 0 on missing or unparseable inputs, so it can be safely chained at the tail of any test recipe.

Recipe hook points

The post-processor runs at every test recipe’s tail, before the _count-real-failures / _test-summary chain reads the file:

  • justfile::test — workspace fast tier
  • justfile::test-all — full matrix
  • justfile::test-failed — rerun-failures workflow
  • justfile::_nextest-platform — shared helper for the per-platform recipes (just freertos test, just nuttx test, just orin-spe test, just threadx_linux test, just threadx_riscv64 test)
  • just/xrce.just::test / test-ros2 / test-c

Other plat-specific recipes (just zephyr test*, just linux test*, just qemu test*, just px4 test*, just cyclonedds test*) currently invoke nextest directly without a skip-aware tail. When you add or refactor one, mirror the _nextest-platform pattern:

set +e
cargo nextest run "${cargo_nextest_args[@]}" "${args[@]}"
rc=$?
set -e
just _rewrite-skipped-junit || true
[ $rc -eq 0 ] && exit 0
real="$(just _count-real-failures)"
just _test-summary || true
if [ "$real" -ne 0 ]; then
    echo "ERROR: $real real (non-[SKIPPED]) test failure(s)."
    exit 1
fi
echo "All failures were [SKIPPED] preconditions — treating as pass."

Tally consumers

ConsumerWhat it readsEffect of the rewrite
justfile::_count-real-failures<failure> countsees 0 after rewrite
justfile::_test-summary<failure> lines + [SKIPPED] markersgroups skips into a Environment-skipped tests: N line
scripts/test/failed-filterset.pytestcases with non-[SKIPPED] <failure>unchanged (already filters by marker)
CI dashboards (junit-cli-report-viewer, GitHub Actions test report, …)<failure> + <skipped> countssees skips as skips, not regressions

What a [SKIPPED] test is NOT

A [SKIPPED] testcase is not a regression and not a bug — it is a precondition that the local environment does not satisfy. Common reasons:

  • ROS’s rmw_zenohd not resolvable — source your ROS setup so AMENT_PREFIX_PATH is exported, or set NROS_RMW_ZENOHD (nano-ros ships no router; RFC-0075). just zenohd starts one. just zenohd setup).
  • qemu-system-arm / qemu-system-riscv64 not on PATH (run just qemu setup-qemu or install via the distro package).
  • Cross toolchain absent (e.g. arm-none-eabi-gcc, riscv64-unknown-elf-gcc) — the Phase 185.2 / 186.4 embedded Cyclone tests gate on these.
  • xrce-agent not built (run just xrce build-agent).
  • ros2 not sourced / no rmw_fastrtps runtime (run source /opt/ros/humble/setup.bash).

A Environment-skipped tests: line in the test summary is informational — it tells you which prerequisites are unmet on the current machine, not which code paths have regressed. The Real failures: X / Y line is the source of truth for “did anything break?”.

Forbidden patterns

Per CLAUDE.md, never:

  • Bare eprintln! + return in place of unmet! — the test reports PASS, hiding the precondition gap.
  • Silent early return in runtime code — panic, don’t swallow.
  • Change the [SKIPPED] prefix without sweeping every consumer listed above and CLAUDE.md.

The only blessed exception to the “panic, don’t return” rule is rstest #[values] matrix rows — the macro framework does not support per-row skip via panic, and skip_reason() is the documented escape hatch. Use it sparingly.

Test infrastructure for platform ports

Moved from the user book’s Platform Porting Pitfalls page: these are pitfalls of nano-ros’s own test harness, not of a port.

Orphan process prevention

When nextest is killed (Ctrl-C, OOM, timeout), child processes (zenohd, QEMU) can become orphans that hold ports and TAP devices.

Fix: Use PR_SET_PDEATHSIG(SIGKILL) in the pre-exec hook:

#![allow(unused)]
fn main() {
use std::os::unix::process::CommandExt;

let mut cmd = Command::new("zenohd");
unsafe {
    cmd.pre_exec(|| {
        // Kill this child when parent dies
        libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL);
        // New process group for tree cleanup
        libc::setpgid(0, 0);
        Ok(())
    });
}
}

Important: Do not use pkill to clean up zenohd — other agents or users may have their own zenohd instances. Use process groups and death signals instead.

Sequential test execution for shared resources

Tests that use QEMU networking (TAP devices, zenohd on fixed ports) must run sequentially. Use nextest test groups:

# .config/nextest.toml
[test-groups.qemu-network]
max-threads = 1

Stale TCP connection cleanup

Between sequential QEMU tests, host-side TCP sockets may linger in FIN-WAIT-1 (the QEMU peer was killed before completing the TCP close handshake). These cause 4-tuple collisions when the next QEMU instance reuses the same source port.

Fix: Call ss -K dst <ip> between tests to force-destroy stale sockets:

#![allow(unused)]
fn main() {
pub fn cleanup_tap_network() {
    for ip in &["192.0.3.10", "192.0.3.11"] {
        let _ = Command::new("ss")
            .args(["-K", "dst", ip])
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status();
    }
    std::thread::sleep(Duration::from_secs(1));
}
}

See also

  • CLAUDE.md → “Practices” — the parent contract.
  • packages/testing/nros-tests/src/lib.rs — unmet! / lane_skip!.
  • packages/testing/nros-tests/src/lane_scope.rs — NROS_TEST_UNCLAIMED.
  • packages/testing/nros-tests/src/fixtures/binaries/mod.rs — require_prebuilt_binary (Build-fixtures ordering contract).
  • scripts/test/rewrite-skipped-junit.py — the post-processor.
  • scripts/test/failed-filterset.py — the rerun-failures filterset.
  • book-internals/src/dev-env/build-commands.md — recipe cheat-sheet.
  • docs/roadmap/phase-214-antipattern-audit-findings.md Track R — the phase that introduced this doc.

QEMU icount: Virtual Clock Synchronization

Problem

QEMU’s virtual clock and wall-clock time diverge when emulating bare-metal firmware with TAP networking:

  1. Hardware timers (CMSDK Timer0 at 25 MHz) advance with QEMU’s virtual clock
  2. WFI (Wait For Interrupt) during Mono::delay(10ms) advances the virtual clock by 10ms almost instantly in wall-clock time
  3. TAP network I/O requires wall-clock time — packets traverse the host kernel TCP stack, through zenohd, and back at wall-clock speed

Without correction, a 10-second zenoh-pico query timeout (z_get) fires in ~1–2 seconds of wall-clock time. The server’s reply (traveling through zenohd on the host) hasn’t arrived yet, causing spurious timeout failures.

This affects all bare-metal QEMU platforms that use hardware timers and TAP networking. RTOS platforms (FreeRTOS, NuttX, Zephyr) handle this through their own OS tick management.

Solution: -icount shift=auto

QEMU’s -icount option virtualizes time based on instruction count. The key parameter is sleep=on (the default), which controls WFI behavior:

  • sleep=on (default): During WFI, virtual time advances at wall-clock speed via QEMU_CLOCK_VIRTUAL_RT. A 10ms delay takes ~10ms of wall time.
  • sleep=off: During WFI, virtual time jumps instantly to the next timer deadline. This provides determinism but breaks network timing.

With shift=auto, QEMU adaptively adjusts the instruction-to-nanosecond ratio to keep virtual time tracking wall-clock time. The adaptive algorithm (in icount-common.c:icount_adjust()) monitors the drift between QEMU_CLOCK_VIRTUAL and QEMU_CLOCK_VIRTUAL_RT and adjusts the shift value (between 0 and 10) to compensate.

From QEMU source (accel/tcg/icount-common.c, icount_start_warp_timer()):

/*
 * We do stop VCPUs and only advance QEMU_CLOCK_VIRTUAL after some
 * "real" time, (related to the time left until the next event) has
 * passed. The QEMU_CLOCK_VIRTUAL_RT clock will do this.
 * This avoids that the warps are visible externally; for example,
 * you will not be sending network packets continuously instead of
 * every 100ms.
 */

Usage

All networked QEMU MPS2-AN385 launches include -icount shift=auto:

qemu-system-arm \
    -cpu cortex-m3 \
    -machine mps2-an385 \
    -nographic \
    -icount shift=auto \
    -semihosting-config enable=on,target=native \
    -kernel firmware.elf \
    -nic tap,ifname=tap-qemu0,script=no,downscript=no,model=lan9118

This is configured in:

  • Test infrastructure: packages/testing/nros-tests/src/qemu.rs — start_mps2_an385_networked() adds -icount shift=auto
  • Launch script: scripts/qemu/launch-mps2-an385.sh — always includes -icount shift=auto

icount Parameter Reference

-icount [shift=N|auto][,align=on|off][,sleep=on|off]
ParameterDefaultDescription
shift=N—Fixed: each instruction = 2^N nanoseconds of virtual time
shift=auto—Adaptive: auto-adjusts shift (0–10) to track wall clock
sleep=ononWFI advances virtual time at wall-clock speed
sleep=off—WFI jumps instantly to next timer deadline (deterministic but breaks networking)
align=onoffThrottle guest to not run faster than real-time (requires fixed shift, incompatible with shift=auto)

Constraints

  • shift=auto is incompatible with align=on and sleep=off
  • align=on is incompatible with sleep=off
  • shift=auto starts at 125 MIPS (shift=3) and adjusts within 100ms

Tradeoffs

Without icountWith -icount shift=auto
Boot speedFast (emulates at host CPU speed)Slower (virtual time ≈ wall time)
Timer accuracyVirtual time races aheadVirtual time tracks wall clock
WFI behaviorVirtual clock advances instantlyVirtual clock advances at wall-clock speed
Network timeoutsFire too early (spurious failures)Fire at correct wall-clock times
DeterminismNon-deterministic (host-speed dependent)Semi-deterministic (adaptive)

Comparison with Other Platforms

The same virtual-vs-wall-clock problem appears in other contexts:

  • Zephyr native_sim: Uses CONFIG_NATIVE_SIM_SLOWDOWN_TO_REAL_TIME=y to throttle the simulator. Equivalent to our -icount shift=auto.
  • Zephyr QEMU: Uses -icount shift=6,align=off,sleep=off but disables it when networking is active (default y if !NETWORKING && !BT). Their sleep=off causes the instant-jump behavior that breaks networking. We use sleep=on (default) to avoid this.
  • ESP32-C3 QEMU: Uses -icount 3 (fixed shift, sleep=on default) for the boot test in justfile.

Hardware Timer Requirement

With -icount shift=auto, the platform clock must be backed by a hardware timer (CMSDK Timer0, DWT, etc.) rather than a software counter advanced by poll callbacks. The hardware timer reads from QEMU_CLOCK_VIRTUAL, which icount keeps synchronized with wall time. A poll-driven software clock is disconnected from QEMU’s virtual clock and would not benefit from icount.

See book/src/advanced/platform-porting-pitfalls.md § “Clock Sources” for the full platform clock status table.

References

Verus Verification Coding Practices

Practical guide for writing and maintaining Verus deductive proofs in nros. For architecture and proof targets, see phase-31-verus-verification.md.

Quick Reference

just verification verus   # Provision the pinned Verus release into the SDK store (or `just verification setup` for kani + verus)
just verify-verus    # Run all Verus proofs (67 verified)
just verify          # Run both Kani + Verus

Verification crate: packages/verification/nros-verification/

Type Specifications

Every type used inside verus! { } that is defined outside the macro needs an external_type_specification. How you write it determines whether Verus treats the type as transparent or opaque.

Transparent enums (variant matching allowed)

Use external_type_specification without external_body:

#![allow(unused)]
fn main() {
use nros_node::TriggerCondition;

verus! {

#[verifier::external_type_specification]
pub struct ExTriggerCondition(TriggerCondition);

// Now we can match on variants in spec functions:
pub open spec fn trigger_eval_spec(cond: TriggerCondition, ready: Seq<bool>) -> bool {
    match cond {
        TriggerCondition::Any => ...,
        TriggerCondition::All => ...,
        TriggerCondition::Always => true,
        TriggerCondition::One(index) => ...,
    }
}

} // verus!
}

This works for enums whose variants are visible (public). Verus sees the full variant structure and allows pattern matching in specs and proofs.

Opaque types (no internal access)

Use external_type_specification with external_body:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
#[verifier::external_body]
pub struct ExSomeOpaqueType(SomeOpaqueType);
}

Use this for types with private fields, complex internals, or when you only need to pass them around without inspecting their structure. You cannot match on variants or access fields.

Structs with public fields

Use external_type_specification without external_body — fields become accessible in specs:

#![allow(unused)]
fn main() {
#[verifier::external_type_specification]
pub struct ExDuration(nros_core::time::Duration);

// Fields are accessible:
pub assume_specification[ Duration::to_nanos ](self_: &Duration) -> (n: i64)
    ensures n == (self_.sec as i64) * 1_000_000_000i64 + (self_.nanosec as i64);
}

Linking Specs to Production Code

assume_specification

Axiomatically declares a contract on a production function. The contract is trusted (not verified by Verus) — a human auditor must confirm the spec matches the implementation.

#![allow(unused)]
fn main() {
pub assume_specification[ TriggerCondition::evaluate ](
    self_: &TriggerCondition,  // &self becomes a named parameter
    ready: &[bool],
) -> (ret: bool)
    ensures
        ret == trigger_eval_spec(*self_, ready@);
}

Rules:

  • &self must be written as self_: &Type (named parameter, not method syntax)
  • ready@ converts &[bool] to Seq<bool> (Verus view conversion)
  • *self_ dereferences &TriggerCondition to TriggerCondition for spec matching
  • The return value must be named: -> (ret: bool)

Ghost models

For types that can’t be imported (private fields, feature-gated behind C FFI, etc.), create a ghost model — a manually maintained mirror:

#![allow(unused)]
fn main() {
/// Ghost representation of TimerState (mirrors nros_node::timer::TimerState).
pub struct TimerGhost {
    pub period_ms: u64,
    pub elapsed_ms: u64,
    pub mode: TimerModeGhost,
    pub canceled: bool,
}
}

Ghost models have weaker guarantees than assume_specification. Correctness relies on manual comparison with the production source code. See Ghost Model Validation Strategy for the three-layer approach to detecting drift between ghost models and production code.

Trust Levels

Every proof falls into one of three trust levels:

LevelMechanismWhat’s trustedStrength
Formally linkedassume_specification + external_type_specificationThe spec matches the impl (human audit of ~4 lines)Strongest
Ghost modelManual struct/enum mirrorLine-by-line correspondence with production sourceMedium
Pure mathArithmetic identitiesOnly the math itselfWeakest (no code link)

Document the trust level of each proof in the module-level doc comment.

Pitfalls

verify = true on production crates

Never add [package.metadata.verus] verify = true to a production crate that contains function pointers, dyn Trait, or closures. Verus will attempt THIR erasure on all items in the crate and panic at erase.rs:237.

Only the verification crate (nros-verification) should have verify = true. Production crates are used as regular dependencies — Verus compiles them with standard rustc without attempting verification.

vstd dependency

vstd is published on crates.io (vstd = "0.0.0-2026-02-08-0120"). Use the registry version, not a path dependency. The pre-built Verus release does not include the full source tree needed for path dependencies (missing dependencies/prettyplease etc.).

Items outside verus! are external

Any type, function, or trait defined outside the verus! { } macro is treated as external by Verus. To use it in specs, you must register it with external_type_specification or reference it via assume_specification.

Closures and iterators

Verus cannot verify code containing closures (including .iter().any(|&r| r)) or function pointers. Mark such items with #[verifier::external] if they’re in a crate being verified, or keep them in production code that Verus never touches.

Edition 2024

Verus’s bundled rustc (1.93.0) supports edition 2024. The verification crate uses edition = "2024" to match the nros workspace. No special configuration is needed.

Adding a New Proof

  1. Identify the production function and its source location
  2. Determine the trust level:
    • Can you import the type? → use external_type_specification
    • Is the type behind a feature gate with C FFI? → use a ghost model
    • Is it pure arithmetic? → pure math proof
  3. Write the spec function inside verus! { }
  4. If formally linking: add assume_specification and document which source lines the auditor should compare
  5. Write the proof function with ensures clauses
  6. Run just verify-verus to check
  7. Update the module doc comment with the new proof’s trust level

File Organization

packages/verification/nros-verification/src/
├── lib.rs            # Module declarations
├── scheduling.rs     # Timer + trigger + executor proofs
├── communication.rs  # CDR serialization safety proofs
├── cdr.rs            # CDR round-trip integrity proofs
├── time.rs           # Duration/Time arithmetic proofs
├── action.rs         # GoalStatus state machine proofs
├── params.rs         # ParameterValue + range proofs
└── e2e.rs            # End-to-end data path proofs

Proofs are organized by what they guarantee to the application developer, not by source crate. A single proof module may reference types from multiple production crates.

FreeRTOS Development Setup

Guide for developing and testing nano-ros on FreeRTOS + lwIP using QEMU MPS2-AN385 (ARM Cortex-M3).

Prerequisites

  • qemu-system-arm (QEMU with ARM system emulation)
  • arm-none-eabi-gcc (ARM bare-metal cross-compiler)
  • Rust nightly toolchain with thumbv7m-none-eabi target
# Install cross-compiler (Ubuntu/Debian)
sudo apt install qemu-system-arm gcc-arm-none-eabi

# Install Rust target
rustup target add thumbv7m-none-eabi
rustup component add --toolchain nightly rust-src

Or use just setup which installs Rust targets automatically.

FreeRTOS + lwIP Sources

Download the FreeRTOS kernel and lwIP:

just setup freertos

This shallow-clones:

  • FreeRTOS kernel (V11.2.0) → third-party/freertos/kernel/
  • lwIP (STABLE-2_2_1_RELEASE) → third-party/freertos/lwip/

Override paths if sources are elsewhere:

VariableDefaultDescription
FREERTOS_DIRthird-party/freertos/kernelFreeRTOS kernel source
FREERTOS_PORTstated by the board (GCC_ARM_CM3 on mps2)upstream FreeRTOS-Kernel’s port enum (phase-349 W3)
LWIP_DIRthird-party/freertos/lwiplwIP source
FREERTOS_CONFIG_DIRBoard crate’s config/FreeRTOSConfig.h + lwipopts.h

Building Examples

just freertos build-examples

This cross-compiles all FreeRTOS examples for thumbv7m-none-eabi. The board crate’s build.rs compiles FreeRTOS kernel, lwIP, and the LAN9118 lwIP netif driver via the cc crate.

Available Examples

Rust examples are in examples/mps2-an385-freertos/rust/:

ExampleDescription
talkerPublishes std_msgs/Int32 on /chatter
listenerSubscribes to std_msgs/Int32 on /chatter
service-serverServes AddTwoInts on /add_two_ints
service-clientCalls AddTwoInts on /add_two_ints
action-serverServes Fibonacci action on /fibonacci
action-clientSends Fibonacci goal on /fibonacci

Testing

just freertos test

This runs QEMU-based integration tests with TAP networking. Each QEMU instance connects to the host via a TAP device, communicating through a bridge running zenohd:

┌─────────────────────────────────────────────────┐
│  Host (Linux)                                    │
│  zenohd (192.0.3.1)  QEMU talker  QEMU listener │
│       │                   │              │        │
│  ─────┴───────────────────┴──────────────┴────── │
│       br-qemu         tap-qemu0      tap-qemu1   │
└─────────────────────────────────────────────────┘

Network Setup

TAP bridge setup requires root. The test infrastructure handles this automatically via scripts/qemu/setup-network.sh.

RoleIP AddressTAP Device
zenohd (host)192.0.3.1br-qemu
Talker/Publisher192.0.3.10tap-qemu0
Listener/Sub192.0.3.11tap-qemu1
Service Server192.0.3.12tap-qemu0
Service Client192.0.3.13tap-qemu1

Architecture

The FreeRTOS platform stack:

User Application (Executor + Node + Pub/Sub)
        │
nros-node (Executor)
        │
nros-rmw-zenoh → zpico-sys (zenoh-pico + C shim)
        │                       │
        │          zenoh-pico FreeRTOS platform
        │          (system.c + lwip/network.c)
        │
Board Crate (nros-board-mps2-an385-freertos)
├── FreeRTOS kernel (tasks, mutexes, semaphores)
├── lwIP TCP/IP stack (BSD sockets, threaded mode)
└── LAN9118 lwIP netif driver (Ethernet on QEMU)

Key Design Points

  • Multi-threaded: FreeRTOS provides real tasks/mutexes. zenoh-pico uses background read/lease tasks.
  • lwIP socket API: POSIX-compatible socket()/connect()/select() — same code path as zenoh-pico’s POSIX platform.
  • Build via cc crate: FreeRTOS kernel + lwIP compiled in the board crate’s build.rs (no external CMake needed).
  • no_std target: thumbv7m-none-eabi with semihosting output.

Task Priorities

PriorityTaskRole
4tcpip_threadlwIP TCP/IP processing
4poll taskLAN9118 RX FIFO → lwIP
4zenoh read/leasezenoh-pico background I/O
3app tasknros application (Executor + Node)
2timer taskFreeRTOS software timers
0idleWFI (mandatory for QEMU networking)

Debugging

See FreeRTOS LAN9118 Debugging Guide for detailed register-level debugging, common pitfalls, and QEMU networking internals.

Porting to Real Hardware

The QEMU board crate (nros-board-mps2-an385-freertos) validates the FreeRTOS integration pattern. To port to a real board (e.g., STM32F767 Nucleo):

  1. Create a new board crate (e.g., nros-stm32f767-freertos)
  2. Replace LAN9118 netif with the vendor Ethernet driver (STM32 HAL ETH)
  3. Update FreeRTOSConfig.h for the target MCU (clock, heap, priorities)
  4. Update lwipopts.h for available RAM
  5. Update the linker script for the target’s memory map
  6. Keep the same run() pattern and Config builder

The nros Executor, Node, and zenoh-pico integration are unchanged — only the board-level HAL and Ethernet driver differ.

ThreadX Development Setup

Guide for developing and testing nano-ros on ThreadX (Eclipse ThreadX) + NetX Duo, with two targets: Linux simulation and QEMU RISC-V 64-bit virt machine.

Prerequisites

Linux Simulation

  • Linux host with /dev/net/tun and persistent user-owned TAP interfaces (created by just qemu setup-network). No CAP_NET_RAW or other capabilities needed on the test binaries.
  • Rust nightly toolchain

QEMU RISC-V 64-bit

  • qemu-system-riscv64 (QEMU with RISC-V system emulation)
  • riscv64-unknown-elf-gcc (RISC-V bare-metal cross-compiler)
  • Rust nightly toolchain with riscv64gc-unknown-none-elf target
# Install cross-compiler (Ubuntu/Debian)
sudo apt install qemu-system-misc gcc-riscv64-unknown-elf

# Install Rust target
rustup target add riscv64gc-unknown-none-elf
rustup component add --toolchain nightly rust-src

Or use just setup which installs Rust targets automatically.

ThreadX + NetX Duo Sources

Download ThreadX, NetX Duo, and the Linux simulation samples:

just setup threadx_linux     # Linux simulation SDK
just setup threadx_riscv64   # QEMU RISC-V SDK

This shallow-clones:

  • ThreadX kernel → third-party/threadx/kernel/
  • NetX Duo → third-party/threadx/netxduo/

Override paths if sources are elsewhere:

VariableDefaultDescription
THREADX_DIRthird-party/threadx/kernelThreadX kernel source
THREADX_CONFIG_DIRBoard crate’s config/ThreadX config (tx_user.h)
NETX_DIRthird-party/threadx/netxduoNetX Duo source
NETX_CONFIG_DIRBoard crate’s config/NetX Duo config (nx_user.h)

Building Examples

Linux Simulation

just threadx_linux build-examples

Builds all Linux simulation examples natively. The board crate’s build.rs compiles ThreadX kernel (Linux port), NetX Duo, and the Linux raw socket network driver via the cc crate.

QEMU RISC-V 64-bit

just threadx_riscv64 build-examples

Cross-compiles all QEMU RISC-V examples for riscv64gc-unknown-none-elf. The board crate’s build.rs compiles ThreadX kernel (RISC-V port), NetX Duo, and the virtio-net NetX Duo driver.

Available Examples

Examples are in examples/threadx-linux/rust/ and examples/rv-virt-threadx/rust/; select the RMW at build time.

ExampleDescription
talkerPublishes std_msgs/Int32 on /chatter
listenerSubscribes to std_msgs/Int32 on /chatter
service-serverServes AddTwoInts on /add_two_ints
service-clientCalls AddTwoInts on /add_two_ints
action-serverServes Fibonacci action on /fibonacci
action-clientSends Fibonacci goal on /fibonacci

Testing

just threadx_linux test    # Linux sim
just threadx_riscv64 test  # QEMU RISC-V
just threadx_linux test    # Linux simulation only
just threadx_riscv64 test  # QEMU RISC-V only

Linux Simulation Tests

Linux simulation tests use TAP networking via /dev/net/tun. The ThreadX Linux port runs the full kernel as pthreads on the host and attaches to persistent user-owned TAP interfaces, so no CAP_NET_RAW or root privilege is needed on the test binaries themselves. The TAP interfaces and bridge must exist (one-time setup, requires sudo):

just qemu setup-network         # Create qemu-br + tap-tx0 + tap-tx1 (one-time)
just threadx_linux test    # Run tests

QEMU RISC-V Tests

QEMU tests use TAP networking with virtio-net. Each QEMU instance connects to the host via a TAP device, communicating through a bridge running zenohd:

┌─────────────────────────────────────────────────┐
│  Host (Linux)                                    │
│  zenohd (192.0.3.1)  QEMU talker  QEMU listener │
│       │                   │              │        │
│  ─────┴───────────────────┴──────────────┴────── │
│       br-qemu         tap-qemu0      tap-qemu1   │
└─────────────────────────────────────────────────┘

Architecture

Linux Simulation

User Application (Executor + Node + Pub/Sub)
        │
nros-node (Executor)
        │
nros-rmw-zenoh → zpico-sys (zenoh-pico + C shim)
        │                       │
        │          zenoh-pico POSIX platform
        │          (pthreads, BSD sockets)
        │
Board Crate (nros-board-threadx-linux)
├── ThreadX kernel (Linux port — tx_thread via pthreads)
├── NetX Duo (BSD sockets)
└── TAP network driver (packages/drivers/tap-netx)

QEMU RISC-V 64-bit

User Application (Executor + Node + Pub/Sub)
        │
nros-node (Executor)
        │
nros-rmw-zenoh → zpico-sys (zenoh-pico + C shim)
        │                       │
        │          zenoh-pico ThreadX platform
        │          (tx_thread, tx_mutex, BSD sockets)
        │
Board Crate (nros-board-threadx-qemu-riscv64)
├── ThreadX kernel (RISC-V port — real preemptive scheduling)
├── NetX Duo (BSD sockets over virtio-net)
└── virtio-net NetX Duo driver (virtio MMIO)

Key Design Points

  • Multi-threaded: ThreadX provides real threads/mutexes. zenoh-pico uses background read/lease tasks.
  • NetX Duo BSD sockets: POSIX-compatible socket()/connect()/select() — same code path as zenoh-pico’s POSIX platform.
  • Build via cc crate: ThreadX kernel + NetX Duo compiled in the board crate’s build.rs (no external CMake needed).
  • Two targets: Linux simulation for fast iteration, QEMU RISC-V for real ThreadX scheduling validation.

Safety Certifications

ThreadX holds the highest level of safety certifications:

  • IEC 61508 SIL 4 — functional safety for industrial systems
  • IEC 62304 Class C — medical device software
  • ISO 26262 ASIL D — automotive functional safety

NetX Duo is certified to the same IEC 61508 SIL 4 standard. Combined with nano-ros’s Kani/Verus formal verification, this creates a layered safety argument for safety-critical deployments.

Zephyr Development Environment Setup

Complete setup procedure for Zephyr native_sim testing. Networking uses NSOS (Native Sim Offloaded Sockets) — each socket call is forwarded to the host kernel, so tests run on 127.0.0.1 without TAP devices, bridges, or sudo.

Overview

nros uses a sibling Zephyr workspace alongside the repository. A symlink inside nros provides a stable path for scripts to locate the workspace.

repos/
├── nros/                     # Your repository
│   ├── scripts/zephyr/
│   │   ├── setup.sh              # Initialize workspace
│   │   ├── downloads/            # SDK tarball cache (gitignored)
│   │   └── sdk/                  # Installed Zephyr SDK (gitignored)
│   ├── zephyr/                   # Zephyr module definition
│   │   ├── Kconfig               # RMW backend, API selection, tuning
│   │   ├── CMakeLists.txt        # Transport C sources + nros-c build
│   │   └── cmake/                # nros_cargo_build(), nros_generate_interfaces()
│   ├── examples/zephyr/
│   │   ├── rust/                 # Rust examples; RMW selected by prj-<rmw>.conf
│   │   ├── c/                    # C examples; RMW selected by prj-<rmw>.conf
│   │   ├── cpp/                  # C++ examples; RMW selected by prj-<rmw>.conf
│   │   └── cmake/                # Shared Zephyr example helpers
│   ├── zephyr-workspace -> ../nano-ros-workspace/  # Symlink (gitignored)
│   └── west.yml                  # West manifest
│
└── nano-ros-workspace/           # Created by setup script
    ├── nros -> ../nros   # Symlink to your repo
    ├── zephyr/                   # Zephyr RTOS v3.7.0
    └── modules/                  # HALs, zephyr-lang-rust

The zephyr-workspace symlink allows scripts to find the workspace without hardcoding paths. For custom workspace locations, update the symlink:

ln -sfn /path/to/custom-workspace zephyr-workspace

Prerequisites

Install system packages (Ubuntu/Debian):

sudo apt install python3 python3-pip python3-venv cmake ninja-build aria2 git

Install Rust (if not already):

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

Step 1: Initialize Workspace (One-Time)

just setup zephyr

This recipe automatically:

  • Installs west and Python tools
  • Downloads Zephyr SDK (~1.5 GB) to scripts/zephyr/downloads/ using aria2c (parallel, resumable)
  • Verifies download with sha256sum
  • Installs SDK to scripts/zephyr/sdk/
  • Creates sibling workspace ../nano-ros-workspace/
  • Symlinks nros into the workspace
  • Fetches Zephyr RTOS and all modules
  • Installs Rust embedded targets
  • Creates env.sh for environment setup

Options:

just setup zephyr --skip-sdk    # Skip SDK download/install
just setup zephyr --force       # Recreate existing workspace

Step 2: Networking

No network setup is required. native_sim uses the NSOS offloaded-sockets driver, enabled by the module’s nros-nsos snippet (zephyr/snippets/nros-nsos/nsos.conf), which board zephyr names, so every example on it gets it with no file of its own; anything else passes -S nros-nsos:

CONFIG_ETH_DRIVER=n
CONFIG_NET_DRIVERS=y
CONFIG_NET_SOCKETS_OFFLOAD=y
CONFIG_NET_NATIVE_OFFLOADED_SOCKETS=y

ETH_DRIVER=n rather than naming the TAP driver, because that symbol is ETH_NATIVE_POSIX on 3.7 and ETH_NATIVE_TAP on 4.4 (issue 2157).

With NSOS, Zephyr’s socket API goes straight to host syscalls. Bind to 127.0.0.1 and reach zenohd / the XRCE Agent on the host loopback just like any other native test. Multiple native_sim processes can coexist without bridge configuration.

Step 3: Build and Run Zephyr Examples

# Source environment
source ../nano-ros-workspace/env.sh

# Build Zephyr talker (Rust + Zenoh)
cd ../nano-ros-workspace
west build -b native_sim/native/64 nros/examples/zephyr/rust/talker \
  -- -DEXTRA_CONF_FILE=prj-zenoh.conf

# Run (no sudo needed)
./build/zephyr/zephyr.exe

Running Multiple Instances (Talker + Listener)

Zephyr native_sim uses a deterministic entropy source. Without unique seeds, multiple instances generate the same Zenoh session ID, causing the router to reject the second session (Close(MAX_LINKS)).

Using just recipes (recommended — each invocation gets a random seed via $RANDOM):

# Terminal 1
just zephyr zenohd

# Terminal 2
just zephyr listener

# Terminal 3
just zephyr talker

Manual launch — pass --seed=<unique> to each instance:

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

# Terminal 2
zephyr-workspace/build-listener/zephyr/zephyr.exe --seed=2000

# Terminal 3
zephyr-workspace/build-talker/zephyr/zephyr.exe --seed=1000

Without --seed, both instances produce the same ZID and the router closes the second connection. See docs/research/zephyr-native-sim-timing.md for the full investigation.

RMW Backend Selection

nros supports two RMW backends on Zephyr, selected via prj.conf:

Zenoh (default)

Connects to a zenoh router. Requires POSIX API for zenoh-pico threads.

CONFIG_NROS=y
# CONFIG_NROS_RMW_ZENOH=y  # default, can be omitted
CONFIG_NROS_ZENOH_LOCATOR="tcp/127.0.0.1:7447"
CONFIG_POSIX_API=y
CONFIG_MAX_PTHREAD_MUTEX_COUNT=32
CONFIG_MAX_PTHREAD_COND_COUNT=16

XRCE-DDS

Connects to a Micro-XRCE-DDS Agent over UDP. Requires BSD sockets.

CONFIG_NROS=y
CONFIG_NROS_RMW_XRCE=y
CONFIG_NROS_XRCE_AGENT_ADDR="127.0.0.1"
CONFIG_NROS_XRCE_AGENT_PORT=2018
CONFIG_NET_SOCKETS=y

API Selection

Choose between Rust and C APIs via prj.conf:

Rust API (default)

CONFIG_NROS_RUST_API=y
CONFIG_RUST=y
CONFIG_RUST_ALLOC=y

CMakeLists.txt uses rust_cargo_application():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
rust_cargo_application()

C API

CONFIG_NROS_C_API=y

CMakeLists.txt uses nros_generate_interfaces():

cmake_minimum_required(VERSION 3.20.0)
find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})
project(my_example)
nros_generate_interfaces(std_msgs "msg/Int32.msg")
target_sources(app PRIVATE src/main.c)

Kconfig Reference

All options are under menuconfig NROS in zephyr/Kconfig.

Common Options

OptionTypeDefaultDescription
CONFIG_NROSboolnEnable nros module
CONFIG_NROS_RUST_APIboolyUse Rust API
CONFIG_NROS_C_APIboolnUse C API
CONFIG_NROS_DOMAIN_IDint0ROS 2 domain ID
CONFIG_NROS_INIT_DELAY_MSint2000Network init wait (ms)

Zenoh Options (visible when CONFIG_NROS_RMW_ZENOH=y)

OptionTypeDefaultDescription
CONFIG_NROS_ZENOH_LOCATORstring"tcp/127.0.0.1:7447"Router address
CONFIG_NROS_ZENOH_MULTI_THREADboolyZenoh-pico multithreading
CONFIG_NROS_ZENOH_PUBLICATIONboolyPublication support
CONFIG_NROS_ZENOH_SUBSCRIPTIONboolySubscription support
CONFIG_NROS_ZENOH_QUERYboolyService client support
CONFIG_NROS_ZENOH_QUERYABLEboolyService server support
CONFIG_NROS_ZENOH_LINK_TCPboolyTCP transport link
CONFIG_NROS_MAX_PUBLISHERSint8Max concurrent publishers
CONFIG_NROS_MAX_SUBSCRIBERSint8Max concurrent subscribers
CONFIG_NROS_MAX_QUERYABLESint-1 (derive)Max concurrent queryables (service servers). the param_services capability costs 6 per node, [lifecycle] 5 once — a value below that floor fails the build, naming this knob (phase-426 W3)
CONFIG_NROS_FRAG_MAX_SIZEint2048Max reassembled message size
CONFIG_NROS_BATCH_UNICAST_SIZEint1024Max unicast batch size
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZEint-1 (derive)Per-subscriber buffer, small payload class
CONFIG_NROS_MAX_LARGE_SUBSCRIBERSint-1 (derive)Blocks in the large payload class
CONFIG_NROS_SUBSCRIBER_LARGE_SIZEint-1 (derive)Per-sample capacity of the large class
CONFIG_NROS_SERVICE_BUFFER_SIZEint1024Per-service buffer

-1 on the three rows above, and on CONFIG_NROS_SUBSCRIPTION_BUFFER_SIZE, means the build derives the number from this image’s message-bound inventory (phase-403 W8 / issue 0963) rather than a person choosing one. Anything you state wins; the derived number is an upper bound over the interface closure you LINK; and one unbounded type in that closure refuses the derivation outright and says which. Full explanation, and what to do when the type it names is not one you receive, in the book and Static Memory → Sizing the pools.

XRCE Options (visible when CONFIG_NROS_RMW_XRCE=y)

OptionTypeDefaultDescription
CONFIG_NROS_XRCE_AGENT_ADDRstring"127.0.0.1"Agent IP address
CONFIG_NROS_XRCE_AGENT_PORTint2018Agent UDP port
CONFIG_NROS_XRCE_TRANSPORT_MTUint512Transport MTU
CONFIG_NROS_XRCE_MAX_SUBSCRIBERSint-1 (derive)Max concurrent subscribers
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERSint-1 (derive)Max service servers
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTSint4Max service clients
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTHint32Queued samples per subscriber
CONFIG_NROS_XRCE_BUFFER_SIZEint1024Per-slot buffer size
CONFIG_NROS_XRCE_STREAM_HISTORYint16Reliable stream depth (2–16)

C API Options (visible when CONFIG_NROS_C_API=y)

OptionTypeDefaultDescription
CONFIG_NROS_C_MAX_HANDLESint16Max executor handles
CONFIG_NROS_C_MAX_SUBSCRIPTIONSint8Max subscriptions
CONFIG_NROS_C_MAX_TIMERSint8Max timers
CONFIG_NROS_C_MAX_SERVICESint4Max services

E2E Testing

# Zenoh examples
just zephyr build           # Build Rust zenoh examples
just zephyr build-c         # Build C zenoh examples
just zephyr test            # Run zenoh E2E tests

# XRCE examples
just zephyr build-xrce      # Build all XRCE examples (Rust + C)
just zephyr test-xrce       # Run XRCE E2E tests

# All examples
just zephyr build-all       # Build everything

Troubleshooting

IssueSolution
west: command not foundRun pip3 install --user west and add ~/.local/bin to PATH
Connection refusedStart zenohd / MicroXRCEAgent on the host loopback (e.g. tcp/127.0.0.1:7447)
Build failsSource environment: source ../nano-ros-workspace/env.sh
XRCE Agent not foundInstall: just setup (installs MicroXRCEAgent)
Zenoh mutex exhaustionIncrease CONFIG_MAX_PTHREAD_MUTEX_COUNT (default 5 is too low)
z_declare_publisher failed: -128 with two instancesDuplicate ZID — pass unique --seed to each native_sim instance

Network Architecture

With NSOS, Zephyr sockets are forwarded to host syscalls — there is no emulated L2/L3 stack to configure, no static IP, and no bridge.

┌─────────────────────────────────────────────────────────────┐
│                      Host (Linux)                            │
│                                                              │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zephyr.exe talker  │       │ zephyr.exe listener    │   │
│   │ (native_sim+NSOS)  │       │ (native_sim+NSOS)      │   │
│   └─────────┬──────────┘       └──────────┬─────────────┘   │
│             │ host socket() via NSOS       │                │
│             ▼                              ▼                │
│                 127.0.0.1 (loopback)                        │
│             │                              │                │
│             ▼                              ▼                │
│   ┌────────────────────┐       ┌────────────────────────┐   │
│   │ zenohd             │       │ MicroXRCEAgent         │   │
│   │ tcp/127.0.0.1:7447 │       │ udp/127.0.0.1:2018     │   │
│   └────────────────────┘       └────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Updating the Workspace

To update Zephyr and modules to latest versions specified in west.yml:

cd ../nano-ros-workspace
west update

To completely recreate the workspace:

just setup zephyr --force

QEMU Bare-Metal Testing

This document describes how to run bare-metal nros applications in QEMU with network connectivity.

Overview

QEMU emulates the MPS2-AN385 board (ARM Cortex-M3) with LAN9118 Ethernet, allowing full network testing without physical hardware.

Network Topology

┌─────────────────────────────────────────────────────────────────┐
│                         Host System                              │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐  │
│  │  zenohd     │    │ ROS 2 Node  │    │   Test Runner       │  │
│  │  (router)   │    │ (rmw_zenoh) │    │   (cargo test)      │  │
│  └──────┬──────┘    └──────┬──────┘    └──────────┬──────────┘  │
│         │                  │                       │            │
│         └─────────┬────────┴───────────────────────┘            │
│                   │                                             │
│         ┌─────────▼─────────┐                                   │
│         │   Bridge          │  192.0.2.1                        │
│         │   (qemu-br)       │                                   │
│         └─────────┬─────────┘                                   │
│                   │                                             │
│    ┌──────────────┼──────────────┐                              │
│    │              │              │                              │
│    ▼              ▼              ▼                              │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐                          │
│ │tap-qemu0 │ │tap-qemu1 │ │tap-qemu2 │                          │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘                          │
└──────┼────────────┼────────────┼────────────────────────────────┘
       │            │            │
  ┌────▼────┐  ┌────▼────┐  ┌────▼────┐
  │  QEMU   │  │  QEMU   │  │  QEMU   │
  │ MPS2    │  │ MPS2    │  │ MPS2    │
  │ talker  │  │listener │  │  node   │
  │192.0.2.10│ │192.0.2.11│ │192.0.2.12│
  └─────────┘  └─────────┘  └─────────┘

IP Address Allocation

AddressDevice
192.0.2.1Host bridge (zenohd)
192.0.2.10QEMU node 0 (tap-qemu0)
192.0.2.11QEMU node 1 (tap-qemu1)
192.0.2.12+Additional QEMU nodes

Prerequisites

Install QEMU

sudo apt install qemu-system-arm

Verify Installation

just qemu check

Quick Start

1. Build QEMU Examples

just qemu build-examples

2. Run Tests (No Networking)

# Run all QEMU tests
just qemu test

# Run only LAN9118 driver test
just qemu test-lan9118

3. Setup Networking (For Full Stack Tests)

# Create TAP bridge (requires sudo)
just qemu setup-network

# Verify setup
just qemu status-network

4. Run with Networking

# Terminal 1: Start zenoh router
ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/0.0.0.0:7447"];scouting/multicast/enabled=false' ros2 run rmw_zenoh_cpp rmw_zenohd

# Terminal 2: Run QEMU with network
./scripts/qemu/launch-mps2-an385.sh \
    --tap tap-qemu0 \
    --ip 192.0.2.10 \
    --binary examples/mps2-an385-baremetal/rust/standalone/lan9118/target/thumbv7m-none-eabi/release/qemu-rs-lan9118

5. Teardown

just qemu teardown-network

Scripts Reference

scripts/qemu/setup-network.sh

Creates Linux bridge and TAP interfaces for QEMU networking.

# Create default setup (2 TAP interfaces)
sudo ./scripts/qemu/setup-network.sh

# Create with more TAP interfaces
sudo ./scripts/qemu/setup-network.sh -n 4

# Show current status
./scripts/qemu/setup-network.sh --status

# Tear down
sudo ./scripts/qemu/setup-network.sh --down

scripts/qemu/launch-mps2-an385.sh

Launches QEMU mps2-an385 with optional networking.

# Basic usage (no network)
./scripts/qemu/launch-mps2-an385.sh --binary app.elf

# With networking
./scripts/qemu/launch-mps2-an385.sh \
    --tap tap-qemu0 \
    --ip 192.0.2.10 \
    --binary app.elf

# With GDB debugging
./scripts/qemu/launch-mps2-an385.sh --gdb --binary app.elf
# Then: arm-none-eabi-gdb -ex "target remote :1234" app.elf

# Show full command without executing
./scripts/qemu/launch-mps2-an385.sh --debug --tap tap-qemu0 --binary app.elf

Justfile Recipes

RecipeDescription
build-examples-qemuBuild all QEMU examples
test-qemuRun all QEMU tests
test-qemu-basicRun basic serialization test
test-qemu-lan9118Run LAN9118 driver test
setup-qemu-networkCreate TAP bridge (sudo)
teardown-qemu-networkRemove TAP bridge (sudo)
status-qemu-networkShow network status
qemu-helpShow QEMU help

Available Examples

qemu-rs-test

Basic nros test on Cortex-M3 (lm3s6965evb machine, no networking).

  • Tests CDR serialization
  • Tests Node API
  • Uses semihosting for output

qemu-rs-lan9118

LAN9118 Ethernet driver test on MPS2-AN385.

  • Tests driver initialization
  • Tests smoltcp Device trait
  • Uses semihosting for output

qemu-rs-talker

TCP client example with smoltcp networking on MPS2-AN385.

  • Connects to a TCP server on the host (192.0.2.1:7777)
  • Sends 5 test messages
  • Demonstrates LAN9118 + smoltcp integration
  • Static IP: 192.0.2.10

qemu-rs-listener

TCP server example with smoltcp networking on MPS2-AN385.

  • Listens on port 7778
  • Echoes received data
  • Demonstrates TCP server pattern
  • Static IP: 192.0.2.11

Debugging

Using GDB

# Terminal 1: Start QEMU with GDB server
./scripts/qemu/launch-mps2-an385.sh --gdb --binary app.elf

# Terminal 2: Connect GDB
arm-none-eabi-gdb \
    -ex "target remote :1234" \
    -ex "load" \
    app.elf

Semihosting Output

All examples use semihosting for debug output. Output appears directly in the terminal where QEMU runs.

Common Issues

TAP interface not found

Error: TAP interface tap-qemu0 does not exist

Solution: Run just qemu setup-network

Permission denied on TAP

qemu-system-arm: -netdev tap,id=net0,ifname=tap-qemu0: could not open /dev/net/tun: Permission denied

Solution: Ensure TAP is owned by your user. Re-run sudo ./scripts/qemu/setup-network.sh $USER

QEMU not found

Error: qemu-system-arm not found

Solution: sudo apt install qemu-system-arm

QEMU version compatibility

The LAN9118 networking requires QEMU 7.0+ for full TAP networking support. On older versions (e.g., QEMU 6.2), the LAN9118 driver tests work but TAP networking may have issues.

Check your QEMU version:

qemu-system-arm --version

To install a newer QEMU version on Ubuntu:

sudo add-apt-repository ppa:canonical-server/server-backports
sudo apt update
sudo apt install qemu-system-arm

Hardware Details

MPS2-AN385 Machine

FeatureValue
CPUARM Cortex-M3
Flash4MB at 0x00000000
SRAM4MB at 0x20000000
EthernetLAN9118 at 0x40200000

LAN9118 Ethernet Controller

The lan9118-smoltcp crate provides a Rust driver for the LAN9118.

#![allow(unused)]
fn main() {
use lan9118_smoltcp::{Lan9118, Config};

let config = Config {
    base_addr: 0x4020_0000,
    mac_addr: [0x02, 0x00, 0x00, 0x00, 0x00, 0x01],
};

let mut eth = unsafe { Lan9118::new(config) }?;
eth.init()?;

// Use with smoltcp
let mut iface = Interface::new(iface_config, &mut eth, instant);
}

See Also

FreeRTOS LAN9118 Networking Debugging Guide

Reference for debugging LAN9118 Ethernet on QEMU MPS2-AN385 with FreeRTOS + lwIP.

1. Architecture Overview

Board: QEMU MPS2-AN385 (ARM Cortex-M3, 25 MHz)

  • Flash: 4 MB at 0x0000_0000
  • SRAM: 4 MB at 0x2000_0000
  • LAN9118 Ethernet: MMIO base 0x4020_0000, IRQ 13

Software stack: FreeRTOS kernel + lwIP (threaded mode, NO_SYS=0) + zenoh-pico (BSD sockets)

Task priorities (highest to lowest):

PriorityTaskRole
4tcpip_threadlwIP TCP/IP processing (set via TCPIP_THREAD_PRIO)
4poll taskCalls lan9118_lwip_poll() to drain RX FIFO
4zenoh read/leasezenoh-pico background tasks (default: configMAX_PRIORITIES/2)
3app taskzenoh-pico / nros application logic
2timer taskFreeRTOS software timer service
0idleWFI hook (critical for QEMU – see Section 4)

Poll task at priority 4: The poll task must run at the same priority as the zenoh-pico read task (which uses a 100ms recv() timeout loop). At lower priority, the read task monopolizes CPU time and the poll task can’t drain the LAN9118 RX FIFO, causing TCP keep-alives to be missed and zenoh sessions to expire.

Data flow: LAN9118 RX FIFO –> poll task (lan9118_lwip_poll) –> tcpip_input –> tcpip_thread –> socket recv buffers –> zenoh-pico

2. LAN9118 Register Map (Key Registers)

Direct registers (base + offset):

OffsetNameDescription
0x00RX_DATA_PORTRX FIFO data read (32-bit words)
0x20TX_DATA_PORTTX FIFO data write (32-bit words, also TX commands)
0x40RX_STAT_PORTRX status FIFO (packet length, error flags)
0x50ID_REVChip ID + revision (upper 16 = 0x9220 or 0x0118)
0x54IRQ_CFGIRQ output configuration (default 0x22000111)
0x58INT_STSInterrupt status (TX/RX activity flags)
0x5CINT_ENInterrupt enable mask
0x68FIFO_INTFIFO interrupt threshold levels
0x6CRX_CFGRX configuration
0x70TX_CFGTX configuration (bit 1 = TX_ON)
0x74HW_CFGHardware config (bit 0 = soft reset, bits 19:16 = TX FIFO size)
0x78RX_DP_CTRLRX data path control (bit 31 = fast-forward/discard)
0x7CRX_FIFO_INFRX FIFO info (bits 23:16 = status entries used, bits 15:0 = data bytes used)
0x80TX_FIFO_INFTX FIFO info (bits 15:0 = data free space)
0x88GPIO_CFGGPIO/LED configuration
0xA4MAC_CSR_CMDMAC CSR indirect access command (bit 31 = busy, bit 30 = read)
0xA8MAC_CSR_DATAMAC CSR indirect access data
0xACAFC_CFGAuto flow control configuration

Indirect MAC CSR registers (accessed via MAC_CSR_CMD / MAC_CSR_DATA):

IndexNameKey Bits
1MAC_CRbit 3 = TXEN, bit 2 = RXEN, bit 18 = PRMS (promiscuous)
2ADDRHMAC address bytes 5:4
3ADDRLMAC address bytes 3:0
6MII_ACCPHY register access (bit 0 = busy, bit 1 = write)
7MII_DATAPHY register data

MAC CSR access protocol: Write MAC_CSR_CMD with busy + read/write + register index, poll until busy clears, then read/write MAC_CSR_DATA. Always check busy before starting.

3. QEMU LAN9118 Networking Model

Understanding QEMU’s internal networking is essential for debugging. Key behaviors:

Hub-based architecture

QEMU uses a legacy VLAN 0 hub that connects the NIC model to the network backend (Slirp user-mode, TAP, socket, etc.). All packets flow through this hub.

Guest LAN9118 NIC  <-->  QEMU Hub (VLAN 0)  <-->  Backend (Slirp / TAP / socket)

Synchronous frame delivery

When the guest transmits a packet via do_tx_packet(), QEMU calls qemu_send_packet() which delivers the frame through the hub synchronously. If using Slirp (user-mode networking), ARP replies and other responses are generated immediately within the same call chain:

Guest TX write to TX_DATA_PORT
  --> do_tx_packet()
    --> qemu_send_packet()
      --> Hub delivery
        --> Slirp processes frame
          --> Slirp generates ARP reply (if applicable)
            --> Hub delivers reply back
              --> lan9118_receive()
                --> Frame lands in RX FIFO

This means the RX FIFO may already contain a response before the TX register write returns to the guest. This is a useful property for diagnostics.

No can_receive callback

QEMU’s LAN9118 model does not implement a can_receive callback. The NIC always accepts frames from the hub (subject to MAC_CR_RXEN, FIFO space, and MAC filter checks in lan9118_receive()).

No qemu_flush_queued_packets() on RXEN transition

When the guest enables MAC_CR_RXEN (bit 2 of MAC_CR), QEMU does not call qemu_flush_queued_packets(). Packets that arrived while RXEN was off are silently dropped. The init sequence must enable RXEN before any traffic is expected.

The delivering flag

QEMU’s net queue has a per-queue delivering flag to prevent re-entrant delivery. This is per-direction, so TX-triggered synchronous RX delivery works fine – the TX and RX paths use separate queues.

MAC filter

lan9118_receive() checks:

  1. MAC_CR_RXEN must be set
  2. Frame size must be within limits (64-2048 bytes for tagged, 14+ bytes minimum)
  3. RX FIFO must have space
  4. MAC address filter (or MAC_CR_PRMS for promiscuous mode)

4. Common Pitfalls

Diagnostic timing: false “0 RX” readings

Problem: Checking RX FIFO status after vTaskDelay() shows zero entries even though frames arrived.

Cause: The poll task runs during the delay and consumes frames from the RX FIFO before the diagnostic code reads the register.

Fix: Check RX_FIFO_INF immediately after TX, before yielding to the OS. For diagnostics, use busy-wait with WFI instead of vTaskDelay().

Initialization order: scheduler must be running first

Problem: nros_freertos_init_network() hangs or asserts.

Cause: lwIP threaded mode (NO_SYS=0) requires FreeRTOS to be running before tcpip_init() is called. tcpip_init() creates the tcpip_thread, which requires a running scheduler.

Fix: Call vTaskStartScheduler() first (via nros_freertos_start_scheduler()), then call nros_freertos_init_network() from within a FreeRTOS task.

Vector table: FreeRTOS handlers must go directly

Problem: HardFault on first context switch or mysterious crashes.

Cause: FreeRTOS Cortex-M3 port validates that vPortSVCHandler, xPortPendSVHandler, and xPortSysTickHandler are installed at the correct vector table positions. Wrapper functions break this validation.

Fix: Place the FreeRTOS port functions directly in the vector table:

const vector_fn isr_vector[] = {
    (vector_fn)(uintptr_t)&_estack,  /* MSP */
    Reset_Handler,                    /* 1 */
    ...
    vPortSVCHandler,                  /* 11: SVCall */
    ...
    xPortPendSVHandler,              /* 14: PendSV */
    SysTick_Handler,                 /* 15: SysTick -- thin wrapper OK here */
};

The SysTick_Handler wrapper is acceptable because it only guards against ticks before the scheduler starts (checking xTaskGetSchedulerState()).

LWIP_TCPIP_CORE_LOCKING must be 0

Problem: Assert failure in netif_add() or netif_set_up() about an uninitialized mutex.

Cause: When LWIP_TCPIP_CORE_LOCKING=1, lwIP expects all netif operations to be called from tcpip_thread or with the core lock held. Our setup calls netif_add() from the app task after tcpip_init() completes.

Fix: Set LWIP_TCPIP_CORE_LOCKING 0 in lwipopts.h. The socket API is already thread-safe without core locking.

Missing IRQ_CFG write

Problem: Intermittent stalls or missed interrupts (even in polling mode).

Cause: The IRQ_CFG register at offset 0x54 controls the interrupt output configuration. If left at its reset default, the interrupt line may be in an unexpected state.

Fix: Always write IRQ_CFG_DEFAULT (0x22000111) during init, even in polling mode:

reg_write(base, 0x54 /* IRQ_CFG */, 0x22000111u);

CRC handling when reading RX FIFO

Problem: Corrupted frames or misaligned reads after receiving a packet.

Cause: The packet length in RX_STAT_PORT includes the 4-byte FCS/CRC. All 32-bit words must be read from RX_DATA_PORT including the CRC bytes, even though the CRC is not passed to lwIP.

Fix: Read (pkt_len + 3) / 4 words from the FIFO (where pkt_len includes CRC), but only copy pkt_len - 4 bytes to the pbuf. The driver already handles this correctly.

Deterministic rand() causes duplicate Zenoh session IDs

Problem: When running two QEMU instances (talker + listener), the second instance’s z_open() hangs and zenohd closes the TCP connection (visible as FIN-WAIT-2 in ss -tnp).

Cause: zenoh-pico generates a 16-byte session ID via z_random_fill() → LWIP_RAND() → rand(). On FreeRTOS, rand() starts from default seed 1 on every boot. All QEMU instances generate identical random sequences → identical Zenoh session IDs → zenohd rejects the duplicate by closing the connection.

Diagnosis: Monitor zenohd connections during startup:

watch -n0.5 'ss -tnp | grep 7447'

If both QEMU instances use the same source port or the second connection immediately transitions to FIN-WAIT-2, duplicate session IDs are the cause.

Fix: Seed srand() with a value unique to each node during nros_freertos_init_network(). Use the node’s IP address (guaranteed unique per node) with a multiplicative hash to spread bits:

uint32_t seed = ((uint32_t)ip[0] << 24) | ((uint32_t)ip[1] << 16)
              | ((uint32_t)ip[2] << 8)  | (uint32_t)ip[3];
seed = seed * 2654435761u;  /* Knuth multiplicative hash */
seed ^= ((uint32_t)mac[4] << 8) | (uint32_t)mac[5];
if (seed == 0) seed = 1;
srand(seed);

Caveat: Do NOT use a simple XOR of MAC and IP — common address patterns can cancel out (e.g., MAC ...00 XOR IP ...0A equals MAC ...01 XOR IP ...0B).

Task stack overflow corrupts lwIP tcpip_mbox

Problem: Service server or action server crashes with lwIP ASSERT: Invalid mbox during network init or shortly after z_open(). Simpler examples (pub/sub) work fine.

Cause: The application task’s frame is dominated by zenoh-pico’s internal stack buffers (transport TX/RX, peer structures) and by the Rust frames of the executor-open chain — Executor::open_in builds an Executor in its own frame and returns it by value. The total exceeds a small task stack.

Corrected 2026-09-06 (phase-392 W6). This paragraph used to say the cause was Executor’s inline arena: [MaybeUninit<u8>; ARENA_SIZE], and that service/action examples pin NROS_EXECUTOR_ARENA_SIZE to 4096/8192. Neither is true and neither has been since phase-271 (issue 0110). The arena is a slice borrowed from caller-supplied backing, which on FreeRTOS is a named .bss static; no example in the tree sets NROS_EXECUTOR_ARENA_SIZE. Raising the arena knob will not change this symptom.

When the stack overflows, it corrupts adjacent memory including lwIP’s global tcpip_mbox variable (declared in tcpip.c). Any subsequent call to tcpip_input(), tcpip_callback(), or sys_mbox_trypost() triggers the “Invalid mbox” assertion, which enters an infinite for(;;){} loop.

Diagnosis: If pub/sub examples work but service/action examples crash with “Invalid mbox”, the extra depth is in the registration pass, not in the arena — a service or action entry registers more entities and each registration adds frames.

Fix: Raise the application task’s stack — but read the number off the image first, do not guess it. Every FreeRTOS Rust image prints, at the end of its register pass (the deepest point bring-up reaches):

nros: app task stack peak 36160 of 131072 bytes (94912 free) — raise with NROS_FREERTOS_APP_STACK_KB / `[node.rt] app_stack_bytes`

The knob is app_stack_bytes (nros_board_common::freertos_config), default 131072 (128 KiB); override it at build time with NROS_FREERTOS_APP_STACK_KB=<kib>, or per tier with [node.rt] app_stack_bytes.

Corrected 2026-09-07 (issue 1146). This step used to read “set APP_TASK_STACK to 16384 words (64 KB)”, then (phase-392 W6) “the default is 393216”. APP_TASK_STACK was deleted in phase-76; the 384 KiB that replaced it was never derived from anything. It is 128 KiB now, from uxTaskGetStackHighWaterMark on 8 in-tree images whose worst peak is 36 152 bytes — the table is on freertos_config::app_stack_bytes. The C/C++ carrier’s mirror in cmake/templates/freertos_app_config.c.in is still 524288, and that file says why (its C++ half cannot be built — issue 1187).

And the fault does not have to announce itself as “Invalid mbox” (measured, issue 1146). An action-server built 4 KiB under its measured peak died with *** MALLOC FAILED *** instead: heap_4 hands out the task stack, so the overflow smashes the next heap block’s header before configCHECK_FOR_STACK_OVERFLOW 2 gets a context switch to check the pattern. *** MALLOC FAILED ***, “Invalid mbox” and *** STACK OVERFLOW: *** are three faces of one fault here.

Note: The FreeRTOS heap (configTOTAL_HEAP_SIZE, 2 MiB on the zenoh images) has room. The constraint is the per-task stack — and it is charged PER TASK: a tier whose stack_bytes is 0 gets the app default too, so a 2-tier image reserved 768 KiB of that heap before issue 1146 and reserves 256 KiB after.

Manual-polling action server must call try_handle_get_result() explicitly

Problem: Action client receives ServiceRequestFailed on get_result(), even though the server completes the goal successfully.

Cause: When using create_action_server() (manual polling), the action server is NOT registered in the executor arena. spin_once() only processes get_result queries for arena-registered servers (i.e., those added via add_action_server()). Without explicit try_handle_get_result() calls, the server never responds to the client’s get_result query, which times out after SERVICE_DEFAULT_TIMEOUT_MS (5 seconds).

Diagnosis: Server output shows “Goal completed” and “Server shutting down.” but client output shows “ServiceRequestFailed” after “Requesting result…”.

Fix: After complete_goal(), call try_handle_get_result() in the spin loop:

#![allow(unused)]
fn main() {
server.complete_goal(&goal_id, GoalStatus::Succeeded, result);
// Must explicitly handle get_result queries since we're not arena-registered
for _ in 0..2000 {
    executor.spin_once(10);
    let _ = server.try_handle_get_result();
}
}

WFI in idle hook is mandatory for QEMU networking

Problem: QEMU never delivers incoming frames (ARP replies, TCP SYN-ACKs) to the guest.

Cause: Without WFI, the FreeRTOS idle task busy-loops. QEMU’s main event loop never gets CPU time to service the TAP/Slirp file descriptor and deliver inbound packets.

Fix: Enable the idle hook and execute WFI:

#define configUSE_IDLE_HOOK 1

void vApplicationIdleHook(void) {
    __asm__ volatile("wfi");
}

5. Key Insights from QEMU Source Analysis

These findings come from reading QEMU’s hw/net/lan9118.c:

lan9118_receive()

Called by the hub when a frame arrives for the NIC. Checks:

  1. MAC_CR_RXEN – if clear, frame is silently dropped
  2. Size limits: rejects frames < 14 bytes or > 2048 bytes (with VLAN headroom)
  3. FIFO space: needs room for status word + aligned data words
  4. MAC filter: calls lan9118_filter() which checks unicast/multicast/broadcast against configured MAC address, or accepts everything if MAC_CR_PRMS (promiscuous, bit 18) is set

do_tx_packet()

Assembles frame from TX FIFO entries and calls qemu_send_packet(). The packet is delivered synchronously through the hub. If the backend generates a response (e.g., Slirp ARP reply), it arrives in the RX FIFO before do_tx_packet() returns.

lan9118_filter()

Evaluates MAC filter rules. For our use case, the simplest approach is to rely on the default filter (which accepts frames addressed to the configured MAC + broadcast). Promiscuous mode (MAC_CR_PRMS) can be enabled for debugging but is not needed for normal operation.

6. Reference Implementations

These are useful for cross-referencing register sequences and understanding expected behavior:

  • FreeRTOS-Plus-TCP MPS2_AN385 driver (official FreeRTOS demo): FreeRTOS-Plus/Source/FreeRTOS-Plus-TCP/portable/NetworkInterface/MPS2_AN385/ in the FreeRTOS repository

  • Zephyr eth_smsc911x.c (cleanest single-file reference): drivers/ethernet/eth_smsc911x.c in the Zephyr repository

  • ARM CMSIS-Driver ETH_LAN9220: CMSIS/Driver/DriverTemplates/Driver_ETH_MAC.c and related files in the ARM-software/CMSIS_5 repository

  • QEMU LAN9118 model source: hw/net/lan9118.c in the QEMU repository

  • nano-ros Rust LAN9118 driver (smoltcp variant, same register init sequence): packages/drivers/net/lan9118-smoltcp/

7. Debugging Tips

Use semihosting for printf-style output

ARM semihosting bypasses UART and writes directly to the QEMU console via bkpt #0xAB. The startup code provides semihosting_write0():

void semihosting_write0(const char *s) {
    __asm__ volatile("mov r0, #0x04\n"
                     "mov r1, %0\n"
                     "bkpt #0xAB\n"
                     : : "r"(s) : "r0", "r1", "memory");
}

Requires QEMU flag: -semihosting-config enable=on,target=native

Check RX FIFO immediately after TX

Due to synchronous delivery (Section 3), the most reliable way to verify that the full TX-hub-backend-hub-RX path works is to check RX_FIFO_INF immediately after writing a TX frame, before any OS delay or yield.

phase-337 W5.c deleted the nros_freertos_diag_network() helper that used to demonstrate this — ~180 lines of raw CSR pokes and a hand-assembled ARP frame, duplicated into two board C files and called from no path in either lane. The technique, which is the part worth keeping, is here:

  1. Read MAC_CR through the indirect MAC CSR at base + 0xA4 / 0xA8 and check RXEN (bit 2) / TXEN (bit 3).
  2. Write TX Command A (FIRST_SEG | LAST_SEG | len) then Command B (len << 16 | len) to base + 0x20, then the frame as DWORDs.
  3. Read RX_FIFO_INF at base + 0x7C and take (v >> 16) & 0xFF as the RX status-entry count — immediately, then again after a few wfi iterations. A count that only appears after wfi means QEMU’s main loop, not the driver, was the gate.
  4. INT_STS at base + 0x58 before and after.

Paste that into the board’s C file while you are debugging and delete it again; it is a probe, not a component.

Use QEMU monitor for hub state inspection

Launch QEMU with a monitor:

qemu-system-arm ... -monitor telnet:127.0.0.1:4444,server,nowait

Useful monitor commands:

  • info network – shows NICs, hub connections, and backend info
  • info qtree – full device tree (find the LAN9118 instance)

WFI triggers QEMU main loop iteration

A WFI (Wait For Interrupt) instruction causes the vCPU to halt, returning control to QEMU’s main event loop. This is when QEMU services network FDs. Use this to force packet delivery in diagnostic loops:

for (int i = 0; i < 200; i++) {
    __asm__ volatile("wfi");
    if (rx_packets_pending(base) > 0) break;
}

Diagnostic ARP probing

Send a raw ARP request to a known-responding address (e.g., the QEMU gateway at 10.0.2.2 for Slirp, or 192.0.3.1 for TAP bridge) and check for an immediate RX FIFO entry. If the reply arrives synchronously (before any delay), the NIC TX/RX path, hub routing, and backend are all functioning. If it does not, check:

  1. MAC_CR has both TXEN and RXEN set
  2. MAC address in the ARP frame matches the NIC’s configured MAC
  3. The QEMU network backend is connected (check -netdev and -nic flags)

Register dump for quick health check

Read these registers to assess NIC state:

  • ID_REV (0x50): should be 0x92200001 or similar – confirms NIC is present
  • MAC_CR (indirect index 1): confirm TXEN (bit 3) and RXEN (bit 2) are set
  • TX_FIFO_INF (0x80): free space should be ~4-5 KB after init
  • RX_FIFO_INF (0x7C): status entries (bits 23:16) show pending unread packets
  • INT_STS (0x58): TX/RX interrupt flags show recent activity