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 RMW | the nano-ros Book |
| How the tree is built, tested, generated and released | this book |
| Why a decision was made | an RFC in docs/design/ |
| What is being worked on, and its acceptance | a phase doc in docs/roadmap/ |
| A known bug, limitation or piece of tech debt | an 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
- Set up a checkout: Contributing.
- Learn the shape of the system: Architecture, then the repo map.
- Learn the verbs.
justwith no arguments lists about eight, grouped by when you need them, andjust check <name>runs one gate. Build Commands has the full list. - 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
- 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.
- The feature axes. RMW × platform × ROS edition (ARCHITECTURE §2, RFC-0005, RFC-0006).
- 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 configureis the single resolver every road calls (RFC-0120). - How an image runs. A target is a launcher or flasher, a console and a link, executed from one RunPlan (RFC-0118).
- What a board is. A board is a descriptor plus a registry row. Every support claim is generated from the registry (RFC-0114).
- The CLI’s shape. A user tier and an
nros devtier (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_portabilitytest 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;allocandstdare 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.pyreports 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.
| Directory | Holds |
|---|---|
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/, justfile | Build 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. Butjust testlinks every RMW’s-syscrate andjust build-test-fixturesresolves 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>, ornros setup --build-sourcesto 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:
| Recipe | What it tests | External deps |
|---|---|---|
just test-unit | Unit tests (no external deps) | None |
just test-integration | Rust integration tests, no heavy QEMU/Zephyr (builds zenohd automatically) | None |
just test | test-unit + test-integration (default dev tier) | None |
just test-doc | rustdoc doctests for the nros umbrella crate | None |
just test-miri | Undefined behavior detection (standalone) | None |
just qemu test | QEMU bare-metal examples | qemu-system-arm |
just freertos test | FreeRTOS QEMU E2E | qemu-system-arm + arm-none-eabi-gcc |
just nuttx test | NuttX QEMU E2E | nightly + qemu-system-arm |
just threadx_linux test | ThreadX Linux user-space E2E | gcc |
just threadx_riscv64 test | ThreadX RISC-V QEMU E2E | qemu-system-riscv64 |
just zephyr test | Zephyr E2E (native_sim + NSOS) | west |
just test-all | Everything: test + heavy QEMU/Zephyr/ROS-interop + test-doc + test-miri + C codegen | All 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 theunsafekeyword#[unsafe(no_mangle)]–no_manglerequires theunsafeattribute- Unsafe operations inside
unsafe fnneed explicitunsafe { ... }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")]gatesVec/Boxusage#[cfg(feature = "std")]gates std-only featuresheapless::Vecis used inno_stdcontexts
Unused variables
- Rename to
_namewith 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
- Create a branch from
main - Make changes
- Run
just ci– all checks must pass - 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
- 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). - 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
- 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.
- 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).
- 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 andcheck-user-surface-idsrefuse).
How it is organized
- 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.
- 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.
- Starters before depth. Every admitted board has a starter that needs only Install, twin-and-hardware boards first.
- 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
-
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.
-
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.
-
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, withnros flash --monitoron hardware. -
Say plainly where nano-ros differs from ROS 2. Never imply a symmetry that is not there.
-
Every runnable step is probed on its row’s evidence. A step that cannot run yet carries an
awaitsline on its track and fails loudly. Nothing is skipped. Gated: a shell fence with noprobe=is reported. -
Budgets. Gated:
Type Words of prose how-to 300–1,500 tutorial chapter 1,500–3,000 explanation 800–2,500 reference no 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.
| Level | Topics | Where |
|---|---|---|
| 1. First contact | install; a board starter; run a talker; what you built; add a node; pick and configure the RMW | Start, starters, Tutorial 1–4 |
| 2. Building real applications | your 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 languages | Tutorial 5–10, User guide |
| 3. Production and tuning | configuring an image; build profiles; offline and vendored builds; deploying; static memory; scheduling and timing; throughput; bridges; porting a ROS 2 node | User guide, Background |
| 4. Extending nano-ros | a board, platform or transport; a custom RMW backend; runner and command packages; API design | Extending, 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
awaitsline); - the output of
python3 scripts/check-book-audience.py --charterfor 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 —
.envis auto-loaded. Missing file silently ignored. - direnv —
.envrcsources.envif present. - Manual —
set -a; source .env; set +abeforecargo 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) viacargo 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:
| Variable | Effect |
|---|---|
NROS_NEXTEST_RECORD=1 | Enable recording + artifact export. |
NROS_NEXTEST_RECORD_DIR=<path> | Override the output dir (and its -latest link). |
NROS_NEXTEST_TRACE_GROUP_BY=slot|binary | Perfetto grouping; default slot (wall-clock/concurrency view). |
NROS_NEXTEST_REPLAY_LOG=1 | Also 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=1 | Keep the temp NEXTEST_STATE_DIR (<dir>/state); removed after export otherwise. |
NROS_NEXTEST_RUN_PROFILE=fail-fast | Stop 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-zenohfeature here is the lowering of the declared RMW — you declare the backend once insystem.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:
$NROS_CLI(explicit override)nroson$PATH(activate file puts the repo-local one here)packages/cli/target/release/nros(per-checkout)${NROS_HOME:-~/.nros}/bin/nros(transitional, removed once every active branch lands on 218)- 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
| Subcommand | Description |
|---|---|
env | Print shell export adding <dir> (default ./src) to NROS_INTERFACE_SEARCH_PATH |
status | Non-fatal freshness check — sibling of nros sync --check |
list | List discovered msg + Rust-consumer pkgs (kind, name, dir per row) |
clean | Remove 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 |
doctor | Lint: 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
| Flag | Description |
|---|---|
--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-vendor | pio: 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 verb | What it does | What 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 it | nothing: a release-pipeline verb |
nros dev codegen rust | Rust 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 CMake | the CMake road only |
nros dev lock managed-deps [--check] | write the # nros-managed message rows into each Rust package, alone | nros sync (which runs it) |
nros dev image entity-inventory --metadata <json> --model <yaml> | print the composed entity inventory an image is sized from | nothing |
nros dev image entity-census take --image <bringup>:<image> | take a cross image’s census before a hand-run west build / cmake | nros build takes it; a hand-run configure refuses and prints this command |
nros dev image facts | per-image facts, including cmake_build_dir | the command nros build --dry-run prints |
nros dev image model-path | where a resolved SystemModel landed | build/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/ subdirectory | the configure’s own -- nano-ros: cargo profile … line |
nros dev board cmake-vars | project 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 image | nros 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 spelling | Now |
|---|---|
nros init | nros setup presets |
nros store list / nros store gc | nros toolchain list / nros toolchain gc |
nros toolchain uninstall | nros toolchain remove |
nros toolchain manifest | nros dev sdk manifest |
nros generate | nros interface generate |
nros generate-rust | nros dev codegen rust |
nros ws list / nros ws clean | nros list / nros clean |
nros sdk-root, nros sdk-path, nros sdk-front | nros dev sdk root, nros dev sdk path, nros dev sdk front |
nros codegen, nros codegen-system | nros dev codegen, nros dev codegen system |
nros image-facts, nros model-path | nros 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-knobs | nros dev image <same> |
nros ws rmw-dispatch, rmw-config, session-properties | nros dev rmw dispatch, config, session-properties |
nros ws seed-lock, check-lock, managed-deps, central-patch | nros dev lock seed, check, managed-deps, central-patch |
nros ws env, order, providers | nros dev ws env, order, providers |
nros profile | nros dev profile |
nros board cmake-vars, nros board net | nros dev board cmake-vars, nros dev board net |
nros metadata | nros 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-rosis the user spelling: it answers through thenano_ros_rootladder 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 scaffoldsnros newwrites and the probe fences still spellnros dev sdk root, which gives the same answer. - A store tool’s directory.
nros locate toolcoversqemu-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
-nicvalue 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 project | nros new … |
| Generate bindings + the image’s build settings | nros sync (primitive: nros dev codegen rust) |
| Build | nros 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 file | nros dev metadata → nros plan → nros check |
| Flash / run | Platform 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
| Tier | Modules | Use case |
|---|---|---|
base | cli (in-tree CLI build → packages/cli/target/release/nros, Phase 218), workspace (incl. Corrosion → ~/.nros/sdk/corrosion/, ninja, make, rust pinned toolchains), zenohd | First-time users who want native nano-ros examples and standard ROS/zenoh workflows without every RTOS SDK |
| platform-specific | one module, e.g. zephyr, nuttx, esp_idf, px4 | Developers focused on one target platform |
all | workspace, verification, zenohd, qemu, freertos, nuttx, threadx_{linux,riscv64}, esp32, zephyr, xrce, rmw_zenoh, orin_spe, cyclonedds, platformio, esp_idf, px4 | Contributors 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:
- Expected by first-time users. It supports native examples, standard ROS/zenoh interop, or core workspace development.
- Moderate surprise. It does not fetch large platform SDKs such as PX4-Autopilot, ESP-IDF, Zephyr, or full RTOS trees.
- 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 —
- an already-defined
CycloneDDS::ddsctarget, find_package(CycloneDDS CONFIG)— a prebuilt install onCMAKE_PREFIX_PATH/CycloneDDS_DIR(a user install),- self-provision from source —
add_subdirectory(${CYCLONEDDS_SOURCE_DIR})(defaults to the pinnedthird-party/dds/cycloneddssubmodule; a user points it at their own checkout), with the per-platform flags staged incmake/platform/nano-ros-<plat>.cmakeand 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:
alltier (shipsarm-none-eabi-gccviafreertos, RV64 GCC viathreadx_riscv64): the embedded-Cyclonetest-allcases build + pass.basetier (no cross toolchains): the embedded-Cyclone tests are filtered out oftest-all(gated oncommand -v <cross-cc>, Phase 185.2/ 186.4) so they reportskipped, notfailed. ThreadX-RV64 Cyclone fixtures stay behind the experimentalNROS_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:
- Land
just/<module>.justwithsetup(idempotent) anddoctor(read-only) recipes. - Add
mod <module> 'just/<module>.just'tojustfile. - Decide whether it belongs in
base,all, or platform-only setup. - Add
run <module>to the matching tier branch of_orchestrateinjustfile. - Update the table in §Tiers in this doc.
- 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.gzassets).
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:
| File | Role |
|---|---|
Cargo.toml | runtime workspace (nros-core, nros-c, nros-cpp, boards, RMW shims, …) |
packages/cli/Cargo.toml | CLI 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:
- 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). - Minor bump (
0.4.x→0.5.0) — any ABI-affecting change tonros-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. - 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 archivedgithub.com/NEWSLabNTU/nros-clihistory). - It doesn’t claim post-1.0 stability the project hasn’t yet established.
0.x.ySemVer convention (everything pre-1.0 is “API may change”) matches the current development pace.
Related
- 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-bumpin the rootjustfile - (The pre-288
release.ymlprebuilt 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-refsenforces 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.ymlproves 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, jobcore-libs) — the portableno_stdcore crates cross-checked on bare embedded targets. No SDKs, no submodules; the only setup is arustup target add. Split by target (one job per target, parallel), each a singlecargo checkover the crates compatible with it (e.g.nros-rmw-cffineeds atomic CAS, so it’s checked onthumbv7mbut notriscv32imc). - Cross-platform resolution lane (
dep-chain.yml) — proves every(board, rmw)dep chain resolves via one cheap job (nros setupper 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.ymlis the template). The heavy lanes; add a new one per platform rather than fattening a shared job.
The worked examples
| Workflow | What it shows |
|---|---|
gate.yml | the fast gate: path-filtered jobs, container image + credentials, build-CLI-from-source, nros setup --source provisioning |
host-tests.yml | ROS on the runner, and installing a package rather than assuming it |
nightly.yml | the broad matrix, and lanes too expensive to gate a merge |
queue.yml | merge_group, cancel-in-progress: false, and a self-hosted job interlocked on a repo variable |
post-submit.yml | push-to-main scope, and why cancel-in-progress is right here and wrong in the queue |
docs.yml | a 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.
| Historical | Replaced by |
|---|---|
ci.yml | gate.yml (fast gate) + nightly.yml (matrix) — phase-253 |
dep-chain.yml | nightly.yml; the check itself is scripts/ci/dep-chain-check.sh |
zephyr-dual-line.yml | nightly.yml |
codegen-convention.yml | gate.yml; the lint is scripts/ci/codegen-invocation-check.sh |
sdk-index-gate.yml | gate.yml, job sdk-index |
host-integration-tests.yml | host-tests.yml |
build-wide.yml | queue.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:
-
Put rootless podman’s storage on the big disk. A user-level
~/.config/containers/storage.confwithgraphrootpointing 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 theDBX_CONTAINER_MANAGER=dockerprefix). -
Use docker exactly as documented and accept the space on
/. Simplest and matches the Setup section verbatim — fine when/has ≥ 15 GB free. -
Install
ros-humble-ros-baseinstead ofdesktop, keepingrmw-{zenoh,cyclonedds,fastrtps}-cpp. Roughly half the size, and a divergence fromscripts/dev/ros2-distrobox-setup.sh— so when a lane later wants a packagedesktopwould 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
zstdis not in a stock Ubuntu 22.04, and the prebuilt dists are.tar.zst, so the first prebuilt install fails withtar (child): zstd: Cannot exec.sudo apt-get install zstdinside the box. → issue 0385rmw_zenoh_cppis 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-clior 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, becausegit clonedoes 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_cpptoday; 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 withjust doctor, fix withsudo apt-get install ros-<distro>-rmw-zenoh-cppinside the box. - The vendored
makeandnros-launch-resolveare host binaries in a shared tree, exactly like the CLI. The pinned make used to link the build host’s guile and died here aslibguile-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 withnros setup --tool make).nros-launch-resolveis per-environment by design (issue 0409): a stale one failsnros syncwith a TOMLunknown fielderror naming a field the current schema does have, which reads as a brokensystem.tomlrather than a stale binary.just setup-launch-resolvein the box. - ROS’s
setup.bashdies underset -u(AMENT_TRACE_SETUP_FILES: unbound variable). Any script sourcing it needsset +uaround 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-runnros_box_publish. sourcein a pipeline runs in a subshell —source env.sh | tailleaves 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, nofind_package. This handbook documents the other build world: the in-repojustorchestration 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_neededconfigures a build dir only when itsCMakeCache.txt/ generated build system is missing;cmake --buildthen handles reconfigure onCMakeListschanges. - 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) andnros setup --tool ninja(upstream prebuilt zip) — andscripts/sdk-path-tools.txtputs them onPATH(incl. agmake→ make-4.4 alias). Resolve an exact path withnros 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_LEVELso the tools inherit the pool.
- Same artifacts either way; without the pinned tools it falls back to a static split.
NROS_NO_JOBSERVER=1forces 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:
| Build | contains |
|---|---|
build | workspace + transports |
build-examples | ⊃ build + every example |
build-all | ⊃ build-examples + test fixtures |
| Test | wall-clock | contains |
|---|---|---|
test-unit | ~5 s | unit |
test-integration | ~30 s | ⊃ + integration |
test | — | ⊃ |
test-all | minutes | ⊃ + 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 throughcargo build, C/C++ cells throughcmake --build(configure-once + per-RMW dir). Platform-wide-Ddefs (toolchain, codegen tool, SDK dirs) are injected by the recipe viaNROS_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-fixturesstampstarget/nextest/.fixtures-builtwith the lane and the fixture coordinates it built, not just a timestamp;test-all’s_require-fixturesfast-fails (~1 s) if the stamp is absent or does not cover the run implied byNROS_FIXTURE_LANE(bypass:NROS_SKIP_FIXTURE_CHECK=1). So alane=linuxbuild satisfiesjust ci(the tier-1 lane) and is correctly rejected by an unscopedtest-all. A pre-issue-0393 timestamp-only stamp is read aslane=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_laneis the mapping between them (CiLane::run_scopeis the declaration;lane-coords <lane> --build-laneprints 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.pyproves it on thejust ci gatelane.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 itsexamples/fixtures.tomlrow throughrow_artifact_root(), and a row outside the lane reports SKIPPED instead of missing. Build and run then apply the samerow_coord()predicate to the same coordinate file, sojust build-test-fixtures lane=tier2 && just ci-matrixis a coherent pair. Before W3 the tier-2 lane ran the whole suite, and alane=tier2build 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
allrequiresNROS_TEST_COORDSto be set and to hold exactly that lane’s coordinates, so a hand-runNROS_FIXTURE_LANE=tier2 just test-allis refused instead of reproducing issue 0482. A full build needs no narrowing, soNROS_FIXTURE_LANE=tier2on top oflane=allstill works — scope the freshness gate, keep the run wide. -
Rust cells.
scripts/test/rust-fixture-stale.shreuses cargo’s own fingerprint —cargo build … --message-format=jsonreports"fresh":falsefor a stale unit, so the probe both detects and self-heals. -
C/C++ cells.
scripts/test/cmake-fixture-stale.shruns the incrementalcmake --build(near-no-op when fresh) and flags cells that actually rebuilt. (ninja -nis 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-probeomits them so they don’t toolchain-thrash. -
Source-list / drift gates.
zpico-sys(vendored zenoh-pico, Phase 136.6) andnros-rmw-xrce-cffi(vendored uxr/micro-cdr, Phase 145.4) verify each vendored source root resolves to a real dir with.cfiles and panic with agit submodule update --inithint on drift. cbindgen build scripts (nros-c,nros-cpp,zpico-sys) emitcargo:rerun-if-changed=cbindgen.tomlsrc/.
-
Probe runs in preflight.
_check-fixtures-staleruns both probes (rust + cmake) over the manifest before thetest-allnextest 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/:
| pack | output |
|---|---|
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_typerust_type_rmw/rust_type_idiomatic,rclc_typesnake_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_DIRduring 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
- add its
.jinjatemplates (a newpacks/<lang>/) and apack.tomldeclaring them. No Rust edit: the registry is discovered from the manifest, and a pack directory with no manifest — or a.jinjano manifest claims — fails the build naming the file. If the pack names C-family artifacts, its three naming fields make it agenerator::namingsurface and theSurfacevariant is generated for it; - 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*_spellingfunction intypes.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_typereturns Rust; - add a generator per kind (
generator/{msg,srv,action}.rs, or one file likegenerator/cpp.rs) that builds the context, names the output files and callsrender::render("<template-name>", &ctx), and its arm innros 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.
packs/entry/<surface>/entry.<ext>.jinjaoverLoweredEntry. 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;packs/entry/<surface>/pack.toml— the extension, whether the TU is C-family, the entry template, itstemplates = [{ key, file }]rows, and what the renderer must know:context = "lowered-entry", thefiltersits templates call, the component kinds it constructs (components), and whether it calls the board’s C-ABI runners (c_abi_runners). CMake reads it throughnros dev codegen entry-pack. The build discovers the directory — there is no registry row to add;- one variant on
Languageinnros-lang. Every consumer sees it — one enumeration; - 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 incodegen/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.
| road | exec | emits a root | carries a knob by | the defect it produced |
|---|---|---|---|---|
| cargo | cargo build | yes | [env] rows in the generated nros-cargo.toml, read via --config | 0491 — a PATH-valued row has three spellings, so a rerun-if-env-changed on it rebuilds forever; watch the CONTENT |
| cmake | cmake --build | yes | corrosion_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 |
| west | west build -b <board> | no | $DOTCONFIG, read per build script through nros_zephyr_build | 0460 — 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.
| rung | stated in | owner |
|---|---|---|
| builtin | the reading crate’s own default | the crate |
| platform | nros-platform.toml (config/*/, packages/platform/*/) | RFC-0049 |
| board | nros-board.toml [knobs.<group>] | RFC-0064 R5 D4, RFC-0103 D6 |
| app | [image.<id>] env in the leaf’s system.toml | RFC-0049 / RFC-0098 |
| env | the process environment | RFC-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 throughnros-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:
| rung | stated by |
|---|---|
arg | an explicit argument |
env | the resource’s one env name in its index row |
project | [sources] / [tools] in the project’s system.toml |
local-edit | a checkout submodule moved off its pin, or dirty |
store | $NROS_HOME/sources/<name>/<version>+<sha8>/, materialised by nros setup --source |
checkout | the submodule in this checkout |
Every road asks the same ladder, never a copy of it:
| road | front end |
|---|---|
| a build script | nros_build_paths::locate::source(name); it watches the answer’s CONTENT, never the variable (issue 0491) |
| cmake | nros_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 / just | nros_locate_source / nros_locate_artifact in scripts/build/cargo.sh |
| Python gates | scripts/lib/source_locate.py |
| a person | nros 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
nrosandnros-launch-resolvebinaries:nros_cli_bin [--checkout-only],nros locate tool, and in Rustnros_cli_in_checkout/launch_resolver_from(issue 1850,check-host-tool-path-literals); - QEMU:
nros_qemu_binand thenros-qemu-laddercrate (CLI + tests; issue 1851,check-qemu-one-ladder); - the generated-header directory names:
nros locate artifactandcmake/NanoRosArtifactLayout.cmake, which readnros_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 alinks=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 thelinkshand-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-pathsholds the shell half and names nobuild.rs). It read 5 before issue 1527 and reads 0 now. A build script that resolves one of these variables goes throughnros_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
| slice | owner |
|---|---|
| the stages, the driver, what stage 4 emits | RFC-0065 |
| the knob ladder and the descriptors | RFC-0049 |
board organization, nros-board.toml as the single source | RFC-0064 |
| a leaf’s build configuration is generated | RFC-0098 |
| resolve before configure | RFC-0094 |
| the sizing descriptor | RFC-0100 |
| build caches: one root, one vocabulary | RFC-0070 |
| platform and build determinism | RFC-0042 |
| the backend descriptor | RFC-0071 |
| where a build script gets a vendored tree’s ROOT | RFC-0101 |
| one home per fact: the location ladder, knob names, selections | RFC-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 type | Rust type |
|---|---|
bool | bool |
byte / uint8 | u8 |
char / int8 | i8 |
int16 | i16 |
uint16 | u16 |
int32 | i32 |
uint32 | u32 |
int64 | i64 |
uint64 | u64 |
float32 | f32 |
float64 | f64 |
String fields
| ROS type | Rust type | Notes |
|---|---|---|
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 type | Rust type | Notes |
|---|---|---|
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 type | Rust type | Notes |
|---|---|---|
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 type | Rust 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:
| Trait | Notes |
|---|---|
Serialize | CDR serialization via CdrWriter |
Deserialize | CDR deserialization via CdrReader |
RosMessage | Provides TYPE_NAME and TYPE_HASH constants |
Default | All fields zero/empty |
Clone | Deep copy |
PartialEq | Field-wise equality |
Debug | Formatted 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 type | C 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 type | C++ 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).
| Component | Type |
|---|---|
| Request struct | FooRequest |
| Response struct | FooResponse |
| Service marker | Foo (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] namein 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_tbridge. The underlying library’s host language is an implementation detail of the per-backend-cffishim; 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)
| Backend | Underlying lib | Underlying lang | Shim crate | Verdict |
|---|---|---|---|---|
| Cyclone DDS | Cyclone DDS | C / C++ | nros-rmw-cyclonedds (C++ direct vtable; canonical DDS backend) | keep |
| XRCE | micro-XRCE-DDS-Client | C | nros-rmw-xrce-cffi (Rust shim over the C nros-rmw-xrce static lib; 115.K.2 ported) | keep |
| zenoh-pico | zenoh-pico | C | nros-rmw-zenoh (Rust → vtable via RustBackendAdapter<ZenohRmw>) | keep |
| uORB | PX4 module SDK | C++ | 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:
| Backend | Name | Registered 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/...vsserial:/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-argnros_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
nrosrmw-<x>feature plus the matchingnros-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). Thenano_ros_link_rmw(... RMW zenoh)helper auto-generates the per-targetnros_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:
linkmedistributed-slice (/ 128.H.2) — each backend contributes anRMW_INIT_ENTRIESentry through thenros_rmw_register_backend!macro.rclc_support_init/Executor::openwalks the slice and calls each entry. Canonical on the hosted targets whose linker sectionslinkmerecognises — of which nano-ros builds only thelinuxhost board. Macro expands to a no-op on RTOS targets wherelinkmecan’t recognise the section (NuttX, Zephyr, FreeRTOS, bare-metal).- Rust ctor (legacy fallback):
#[unsafe(link_section = ".init_array")] #[used] static AUTO_REGISTER_CTOR.#[used]keeps rustc from dead-stripping. - C ctor (legacy fallback):
__attribute__((constructor)) static void nros_rmw_<name>_register_ctor. Same survival via.init_arraywalk by libc startup. - CMake strong stub (landed): the
nano_ros_link_rmw(<target> RMW <name>)helper atcmake/NanoRosLink.cmake:62-117emits an auto-generated TU per target that defines a strongnros_app_register_backends()calling every linked RMW’snros_rmw_<name>_register(). The weak default inlibnros_c_weak_stubs.ais overridden. This is the canonical path on every RTOS wherelinkmecan’t survive. - Explicit user call (Rust no_std bridges):
nros_rmw_<name>::register()frommain()— drags the rlib’s CGU into the binary so the linkme entry is reachable. See the gateway fixturepackages/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_exwithrcl_node_options_t.rmw_nameset. - C++:
rclcpp::Executor::open_with_rmw(...)andrclcpp::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.
| Backend | poll_wcet_us | Buffer-pool size | Notes |
|---|---|---|---|
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 ring | Wake-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 session | Poll-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.
| What | File | Owner |
|---|---|---|
| The core zenoh-pico sources, every platform | packages/rmw/zenoh/zpico-sys/zenoh-sources.txt | the 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.toml | the backend |
| CPU flag profiles, compile settings, required SDKs | each platform’s nros-platform.toml | the 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:
| Key | Type | Use |
|---|---|---|
names | list[str] | Names this platform answers to; names[0] is canonical (config/bare-metal answers bare-metal and esp32). |
inherits | string | Parent platform. Its C-build keys and its port row merge underneath this one (rules below). |
arch_profiles | str | list[str] | Which [arch.*] profiles apply, in order; the first whose target_match admits the target wins. |
compile | table | opt_level / warnings / cflags. |
required_env | list[RequiredEnv] | SDK paths the build needs. See below. |
[arch.<name>] | table | A 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>]
| Key | Type | Use |
|---|---|---|
target_match | string | Substring or <prefix>* glob the target triple must match. |
target_exclude | string | Veto when the triple contains this substring (cortex-m3 excludes thumbv7em, so a Cortex-M4 does not pick it). |
cflags | list[str] | Compiler flags. |
needs_picolibc | bool | Add the picolibc sysroot’s include/. |
needs_errno_override | bool | Generate and prepend the errno-override shadow header (RISC-V picolibc TLS-errno workaround). |
needs_riscv_compiler | bool | Probe 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.
| Key | Type | Use |
|---|---|---|
defines | list[str] | define(name, None). |
defines_conditional | list[{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_kv | map | define(name, Some(value)). |
defines_env | map of {env, default} | Value from env var env, else the literal default. |
include | list[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_sources | list[ExtraSource] | .c files to compile on this port, e.g. {src}/system/unix/network.c. See below. |
include_paths | list[str] | Header search paths, interpolated. Order matters: first wins. |
include_paths_conditional | list[{path, when}] | Header paths gated by when. |
mbedtls | string | pkg-config / vendored / none. Read only when the link-tls feature is on. |
pic | bool | cc::Build::pic override (NuttX flat builds set false). |
rerun_if_env_changed | list[str] | Extra cargo:rerun-if-env-changed triggers. VALUE knobs only, never a path variable (issue 0491). |
compiled_by | cargo | platform | platform 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
- 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) isposix; macOS must pass the feature. - Join.
PlatformsTree::as_platform_manifestbuilds onePlatformEntryper platform file: its port row (or an empty one) plus the file’sinherits,arch_profiles,compileandrequired_env. A row that names no platform, or two rows that name the same one, is an error. - Merge the
inheritschain, 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_byand eachcompilefield take the child’s value if set, else the parent’s, so an unset child never downgrades a parent;arch_profilesis replaced, not appended, when the child sets it.
- Check every platform, not just the selected one: each must resolve, and
each port’s
includedirectories 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.
| Field | Meaning |
|---|---|
target_match | Substring (or <prefix>* glob) that must be in the target triple. |
target_not | Substring that must NOT be in the triple. The special value "embedded" means “the target is one of the known embedded RTOSes”. |
if_env | Env var that must be set (any value). |
capability | Map 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:
| Token | Value |
|---|---|
{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:
- Validate
required_env(honouringwhen). - Generate the version header into
{out}/zenoh-pico-version/. - Apply the first
[arch.*]profile fromarch_profilesthat admits the target. - Add the
zenoh-sources.txtgroups this lane selects, then the row’sextra_sources(when,if_env,with_define). - 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).
- Add defines:
defines,defines_conditional,defines_kv,defines_env. - Handle mbedTLS per
mbedtls, whenlink-tlsis on. - Apply shim slot counts and
compilesettings, then thepicoverride. - Compile
libzenohpico.aand registerrerun_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
- The platform file. Create
nros-platform.tomlbeside 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 inarch_profiles, or define one. - The port row. Add
[port.<name>]tonros-rmw-zenoh/nros-rmw.toml: theZENOH_*platform define, thesystem/<x>/sources asextra_sources, and include paths. If the platform’s own build compiles zenoh-pico, setcompiled_by = "platform". - The feature. Add the
zpico-sysCargo feature and its arm inrunner.rs’splatform_namederivation. - Check.
cargo build -p zpico-sys --features <name> --target <triple>, thenjust check zenoh-source-manifestandjust 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.
Related
docs/roadmap/archived/phase-136-zpico-sys-unified-build.md— the unifiedcc-rsbuild.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.,%chatternot/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>/_.@advis zenoh-ext’s own prefix — the key format${remaining:**}/@adv/${entity:*}/${zid:*}/${eid:*}/${meta:**}is a literal string inlibzenohc.so;pubis 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@advliveliness 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:
| Field | Size | Format |
|---|---|---|
| sequence_number | 8 bytes | int64, little-endian |
| timestamp | 8 bytes | int64, little-endian (nanoseconds) |
| gid_length | 1 byte | VLE encoded (value: 16) |
| gid | 16 bytes | Random 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:
- Check ZenohId is LSB-first hex format
- Ensure topic name uses
%prefix (e.g.,%chatter) - 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
| Component | Status | Location |
|---|---|---|
| Data keyexpr format | Done | TopicInfo::to_key() |
| Liveliness tokens | Done | Ros2Liveliness |
| QoS format | Done | 2:2:1,1:,:,:,, |
| CDR serialization | Done | serdes crate |
| RMW attachment | Done | serialize_rmw_attachment() |
| ZenohId format | Done | ZenohId::to_hex_string() |
| Node integration | Done | ConnectedNode |
Version Compatibility
| ROS 2 Version | Type Hash Format | Status |
|---|---|---|
| Humble | TypeHashNotSupported | Working (default edition) |
| Iron, Jazzy | RIHS01_<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:
| chip | means |
|---|---|
● same | identical signature and name |
● re-shaped | one slot, one symbol, different signature |
◆ re-mapped | answered, but NOT 1:1 — decomposed, merged, or off this seam. The arrows under it name what provides the capability |
✕ not-supported | a decision, permanent; the reason names the constraint |
○ not-implemented | a 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.
| vtable | global | executor | platform | build time | serdes | none | total | |
|---|---|---|---|---|---|---|---|---|
| same | 16 | 1 | 17 | |||||
| re-shaped | 32 | 1 | 33 | |||||
| re-mapped | 18 | 4 | 1 | 3 | 3 | 29 | ||
| not supported — by decision | 9 | 9 | ||||||
| total | 66 | 2 | 4 | 1 | 3 | 3 | 9 | 88 |
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 2 | nano-ros | reason |
|---|---|---|
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
|
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
|
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
|
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
|
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
|
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
|
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)
|
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 `=`
|
rmw_ret_t rmw_init_options_fini(rmw_init_options_t * init_options) |
— |
◆ re-mapped · build time
build time: `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
|
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
|
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
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
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
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
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
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
|
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
|
Reproduce
| command | asks |
|---|---|
just check rmw-api-parity | is every contract symbol classified |
just check rmw-abi-shape | does the vtable mirror it — name, args, return |
just check rmw-slot-producers | which slots anything actually writes or reads |
python3 scripts/rmw-api-parity.py --contract | re-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 ofrmw_qos_profile_tinto per-create options structs, and the deprecated blockingcall_rawslot was deleted. The Rust traits were renamed in the same phase (Subscriber→Subscription,ServiceServerTrait→ServiceTrait,ServiceClientTrait→ClientTrait). can_loan_messagesis derived (issue 0814). The flag and theborrow_loaned_messageslot had drifted in both directions: nothing outside a test ever wrotetrue(so a zenoh build whose loans worked advertised that it could not loan), while a backend writingtruewith 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.pynow counts the allocation sites per backend. - zenoh version reading (issue 0609): the ROS package version of
rmw_zenoh_cppmoved its vendored zenoh 1.2.0 → 1.8.0 in a patch bump, and reading the package version instead ofzenoh_configure.hproduced a wrong version claim in that issue’s first filing. The router pairing itself is phase-362 / RFC-0075.
QoS
_SYSTEM_DEFAULTnames 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 wasQoSPolicyMask::CORE— four policies granted to any implementation that had written no code — until measurement found the zenoh shim honouring none of the four. Thecheck-qos-mask-derivationgate 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
UNSUPPORTEDwhere 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 beOk(())whatever the kind, which reported an assertion that never happened.
Status events
- Matched and incompatible-type events (issue 1972).
MATCHEDwas skipped until issue 1972 ruled it ADOPT;INCOMPATIBLE_TYPEcame 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 family | issue |
|---|---|
| graph: node names + change edge | 2106 (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: deadline | 2108 |
| per-entity new-data callbacks | 2109 |
| take with info (receive time) | 2135 |
| request info | 2081, 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_inforegistrations became removable in issue 1668. take_with_infoas the C/C++ backend channel, and one GID from one source forget_gid()andMessageInfo::publisher_gid: issue 1495.received_timestampstamping: issue 2135.- Service info on the server side: issues 2081 (C ABI request info) and
1942 (rclrs
ServiceInfocounterpart, zenoh side table). - Client side:
take_response_with_infofrom issue 2136; sequence-id correlation from issue 0778 (and 2009 forRequestCorrelator); the callback client and C++PollClient::take_responseheader 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-cyclonedds | 0.10.5 |
upstream master | 11.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
| commit | subject |
|---|---|
902f7707 | ddsrt: add ThreadX NetX port |
56e6170a | fix(threadx): stabilize Cyclone DDS runtime |
e8ce7315 | fix(ddsrt/threadx): multicast byte-order + multi-iovec datagram sendto |
5558c6ae | fix(ddsrt/threadx): network byte order for parsed/interface addresses |
12b4af2c | fix(ddsrt/threadx): join multicast with INADDR_ANY interface |
6eb92277 | fix(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)
| commit | subject |
|---|---|
290152c0 | fix(zephyr): tolerate NSOS socket gaps |
1d794c0a | ddsi_udp: Zephyr multicast join via struct ip_mreqn (issue 0231) |
4aa337b0 | q_sockwaitset: AF_UNIX socketpair self-pipe on native-IP-stack Zephyr |
ae14b312 | ddsrt: initialise the atomics mutexes at runtime on the Zephyr backend |
79bd9cb9 | ddsrt: name threads on Zephyr, instead of leaving every one anonymous |
f241020d | ddsrt: 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
| commit | subject |
|---|---|
a09babf3 | ddsrt: Zephyr-native sync backend — k_mutex/k_condvar instead of pooled pthreads |
942dda3c | ddsi/addrset: stripe the lock instead of one mutex per addrset |
404fc5d9 | ddsrt: 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)
| commit | subject |
|---|---|
4c8ff8c2 | ddsrt/posix: fail loudly when pthread_mutex_init fails |
5b87ee52 | fix(ddsrt/posix): say which pool ran out, and fix cond/rwlock too |
8601ca66 | ddsrt: 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)
| commit | subject |
|---|---|
22150fbf | fix(freertos): avoid TLS-only DDS state |
99cfac88 | ddsrt: give every FreeRTOS thread its lwIP per-thread netconn semaphore |
8665a51f | ddsrt: 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)
| commit | subject |
|---|---|
6e2ad36f | ddsrt/posix: route the heap through nano-ros’s platform allocation funnel |
8e6ff48a | ddsi: allocate and free through ddsrt, not libc, in four mismatched places |
d97a71e2 | ddsrt: 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)
| commit | subject |
|---|---|
556f79d4 | build: 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):
| branch | base | verified |
|---|---|---|
upstream-zephyr-sync | fork’s master mirror | wiring verified at configure time; code identical to shipped |
upstream-addrset-stripe | fork’s master mirror | builds clean, ctest 1498/1498 |
nano-ros-addrset-hash | fork pin 8601ca66 | builds 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)
| commit | subject |
|---|---|
7f064c11 | ddsi: re-arm a suppressed-NACK acknack no earlier than now, instead of from a stale timestamp |
67ff7518 | ddsi: assert that an acknack event still needing a NACK is still scheduled |
2314625c | ddsi: 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:
-
Export from
nros::sizes— thenrosumbrella crate defines apub 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 isFOO_SIZE. The two artefacts come from a singleexport_size!macro invocation. The static exists only under theffi-size-markersfeature, which only the size probe’s own nested build ofnrosturns 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 } -
Probe from consumer build scripts —
nros-c/build.rsandnros-cpp/build.rsuse the helper cratenros-sizes-buildto find buildnrosin an isolated probe target directory withffi-size-markersadded, and read the__NROS_SIZE_*symbol storage sizes with theobjectcrate. No subprocess, no llvm-nm; pure Rust. -
Emit
#define NROS_FOO_SIZEinto a generated header —nros_config_generated.h(C) andnros_cpp_config_generated.h(C++) carry the probe values.types.hincludes the generated config transitively, so every nros C header seesNROS_*_SIZEautomatically. -
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:
| Symbol | Type | Used by |
|---|---|---|
SESSION_SIZE | RmwSession | rclc_support_t._opaque |
PUBLISHER_SIZE | RmwPublisher | rcl_publisher_t._opaque, nros::Publisher<M>::storage_ |
SUBSCRIBER_SIZE | RmwSubscriber | rclcpp::PollSubscription<M>::storage_ |
SERVICE_CLIENT_SIZE | RmwServiceClient | rclcpp::PollClient<S>::storage_ |
SERVICE_SERVER_SIZE | RmwServiceServer | rclcpp::PollService<S>::storage_ |
EXECUTOR_SIZE | nros_node::Executor | rclc_executor_t._opaque, nros::Executor::storage_ |
GUARD_CONDITION_SIZE | nros_node::GuardCondition | rcl_guard_condition_t._guard_opaque, nros::GuardCondition::storage_ |
LIFECYCLE_CTX_SIZE | nros_node::lifecycle::LifecyclePollingNodeCtx | rcl_lifecycle_state_machine_t._opaque_storage |
ACTION_SERVER_INTERNAL_SIZE | ActionServerInternalLayout | rcl_action_server_t._internal |
CPP_ACTION_SERVER_SIZE | CppActionServerLayout | rclcpp::ActionServer<A>::storage_ |
CPP_ACTION_CLIENT_SIZE | CppActionClientLayout | rclcpp::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
*Internalshim types are#[repr(C)]and embedded as typed fields in their outernros_*_tstructs. - All seven C++ wrappers (Publisher, Subscription, Service, Client,
ActionServer, ActionClient, GuardCondition) use the
NROS_*_SIZE/NROS_CPP_*_STORAGE_SIZEprobe macros. types.hships zero*_OPAQUE_U64Smacros; the four consumer module headers route through the probe.- The remaining hand-coded “upper bound” assertions (e.g., the
EXECUTOR_OPAQUE_U64Senvelope check innros-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:
- Add the
export_size!(pub FOO_SIZE = nros_node::Foo)line tonros/src/sizes.rs. (IfFoolives in a downstream crate thatnroscan’t import, define aFooLayoutmirror struct first and add a byte-equivalence assert in the owning crate.) - Add a
let probe_foo = probed.get("FOO_SIZE").copied().unwrap_or(0)line tonros-c/build.rs(and/ornros-cpp/build.rs), and emit#define NROS_FOO_SIZE {probe_foo}into the generated config header. - Use
NROS_FOO_SIZEin 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.rsfor the rlib probe implementation.packages/api/nros/src/sizes.rsfor 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
| platform | fixture grid row | recipes |
|---|---|---|
| Linux host | linux | just linux … |
| FreeRTOS (QEMU MPS2-AN385) | freertos | just freertos … |
| NuttX (Arm) | nuttx | just nuttx … |
| NuttX (RISC-V) | nuttx-riscv | just nuttx … |
| ThreadX (Linux sim) | threadx-linux | just threadx_linux … |
| ThreadX (QEMU RISC-V 64) | threadx-riscv64 | just threadx_riscv64 … |
| Zephyr | zephyr, zephyr-cortex-m | just zephyr … |
| Bare-metal Cortex-M3 | baremetal | just 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.
| platform | rust | c | cpp | mixed |
|---|---|---|---|---|
linux | cargo | cmake | cmake | cmake |
freertos | cargo | cmake | cmake | cmake |
nuttx | cargo | cmake | cmake | — |
nuttx-riscv | cargo | cmake | cmake | — |
threadx-linux | cargo | cmake | cmake | cmake |
threadx-riscv64 | cargo, cmake | cmake | cmake | — |
zephyr | cargo, west | cmake, west | cmake, west | cargo |
zephyr-cortex-m | west | west | west | — |
baremetal | cargo | — | — | — |
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:
- Verifies
west+ the Zephyr workspace +zephyr.elfexist; skips with a hint otherwise. - 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)). - Exports
ARMFVP_BIN_PATH=<dir>and shellswest build -d <build-dir> -t run, which drives Zephyr’scmake/emu/armfvp.cmaketarget with the canonicalboards/arm/fvp_baser_aemv8r/board.cmake-Cflags — UART 0–3 piped to stdout, GICv3, cache state, NUM_CORES fromCONFIG_MP_MAX_NUM_CPUS. No flags are duplicated in thejustrecipe.
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 (
westmodule) 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 (
zenohdfor 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 zephyrrecipe still works and now delegates tonros setup zephyrunder 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
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS | bool | n | Enable nros module |
CONFIG_NROS_RUST_API | bool | y | Use Rust API |
CONFIG_NROS_C_API | bool | n | Use C API |
CONFIG_NROS_DOMAIN_ID | int | 0 | ROS 2 domain ID |
CONFIG_NROS_INIT_DELAY_MS | int | 2000 | Network init wait (ms) |
Zenoh Options (visible when CONFIG_NROS_RMW_ZENOH=y)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_ZENOH_LOCATOR | string | "tcp/127.0.0.1:7447" | Router address |
CONFIG_NROS_ZENOH_MULTI_THREAD | bool | y | Zenoh-pico multithreading |
CONFIG_NROS_ZENOH_PUBLICATION | bool | y | Publication support |
CONFIG_NROS_ZENOH_SUBSCRIPTION | bool | y | Subscription support |
CONFIG_NROS_ZENOH_QUERY | bool | y | Service client support |
CONFIG_NROS_ZENOH_QUERYABLE | bool | y | Service server support |
CONFIG_NROS_ZENOH_LINK_TCP | bool | y | TCP transport link |
CONFIG_NROS_MAX_PUBLISHERS | int | 8 | Max concurrent publishers |
CONFIG_NROS_MAX_SUBSCRIBERS | int | 8 | Max concurrent subscribers |
CONFIG_NROS_MAX_QUERYABLES | int | -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_SIZE | int | 2048 | Max reassembled message size |
CONFIG_NROS_BATCH_UNICAST_SIZE | int | 1024 | Max unicast batch size |
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZE | int | -1 (derive) | Per-subscriber buffer, small payload class |
CONFIG_NROS_MAX_LARGE_SUBSCRIBERS | int | -1 (derive) | Blocks in the large payload class |
CONFIG_NROS_SUBSCRIBER_LARGE_SIZE | int | -1 (derive) | Per-sample capacity of the large class |
CONFIG_NROS_SERVICE_BUFFER_SIZE | int | 1024 | Per-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
.msgor aninlinecap innros-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)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_XRCE_AGENT_ADDR | string | "127.0.0.1" | Agent IP address |
CONFIG_NROS_XRCE_AGENT_PORT | int | 2018 | Agent UDP port |
CONFIG_NROS_XRCE_TRANSPORT_MTU | int | 512 | Transport MTU |
CONFIG_NROS_XRCE_MAX_SUBSCRIBERS | int | -1 (derive) | Max concurrent subscribers |
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERS | int | -1 (derive) | Max service servers |
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTS | int | -1 (derive) | Max service clients |
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTH | int | 32 | Queued samples per subscriber |
CONFIG_NROS_XRCE_BUFFER_SIZE | int | 1024 | Per-slot buffer size; the default of the three below |
CONFIG_NROS_XRCE_SUBSCRIBER_BUFFER_SIZE | int | -1 (derive) | Subscriber ring entry size |
CONFIG_NROS_XRCE_SERVICE_REQUEST_BUFFER_SIZE | int | -1 (= BUFFER_SIZE) | Service-server request entry size |
CONFIG_NROS_XRCE_SERVICE_REPLY_BUFFER_SIZE | int | -1 (= BUFFER_SIZE) | Service-client reply slot size |
CONFIG_NROS_XRCE_STREAM_HISTORY | int | 16 | Reliable 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)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_C_MAX_HANDLES | int | 16 | Max executor handles |
CONFIG_NROS_C_MAX_SUBSCRIPTIONS | int | 8 | Max subscriptions |
CONFIG_NROS_C_MAX_TIMERS | int | 8 | Max timers |
CONFIG_NROS_C_MAX_SERVICES | int | 4 | Max 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
| Issue | Solution |
|---|---|
west: command not found | Run pip3 install --user west and add ~/.local/bin to PATH |
Connection refused | Start zenohd / MicroXRCEAgent on the host loopback (e.g. tcp/127.0.0.1:7447) |
Build fails | Source environment: source zephyr-workspace/env.sh |
XRCE Agent not found | Provision the xrce daemon: nros setup zephyr --rmw xrce |
| Zenoh mutex exhaustion | Increase 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()):
| Aspect | NuttX | FreeRTOS |
|---|---|---|
| zenoh-pico layer | Reuses unix/ platform | Dedicated freertos/ platform |
| Networking | Built-in BSD sockets | External lwIP |
| Ethernet driver | NuttX virtio-net (built-in) | Custom LAN9118 lwIP netif |
| Rust target | armv7a-nuttx-eabi with std | thumbv7m-none-eabi (no_std) |
| Build integration | NuttX build system + cargo | cc crate compiles FreeRTOS + lwIP |
| QEMU machine | virt (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:
| Variable | Default | Description |
|---|---|---|
NUTTX_DIR | third-party/nuttx/nuttx | NuttX RTOS source |
NUTTX_APPS_DIR | third-party/nuttx/nuttx-apps | NuttX 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/:
| Example | Description |
|---|---|
talker | Publishes std_msgs/String on /chatter |
listener | Subscribes to std_msgs/String on /chatter |
service-server | Serves AddTwoInts on /add_two_ints |
service-client | Calls AddTwoInts on /add_two_ints |
action-server | Serves Fibonacci action on /fibonacci |
action-client | Sends 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:
| Role | IP Address | TAP Device |
|---|---|---|
| Talker/Publisher | 192.0.3.10 | tap-qemu0 |
| Listener/Sub | 192.0.3.11 | tap-qemu1 |
| Service Server | 192.0.3.12 | tap-qemu0 |
| Service Client | 192.0.3.13 | tap-qemu1 |
| zenohd (host) | 192.0.3.1 | br-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 subsystemCONFIG_NET_TCP/CONFIG_NET_UDP– TCP/UDP protocolsCONFIG_DRIVERS_VIRTIO+CONFIG_DRIVERS_VIRTIO_NET– virtio EthernetCONFIG_PTHREAD_MUTEX_TYPES– POSIX mutex types (for zenoh-pico)CONFIG_DEV_URANDOM–/dev/urandomfor session ID generationCONFIG_DEFAULT_TASK_STACKSIZE=8192– adequate stack for nros appsCONFIG_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". Atlto = "off"a non-deterministicarmv7a-nuttx-eabihfcross-CGU miscompile corrupts the stdlang_startmain-closure fat pointer: the image reboots beforemainwith 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-armemulating 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 pinCMAKE_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_untilong_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_readloop.
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 returnsUnaddressablefor0.0.0.0. Pass the GROUP (239.255.0.1).set_recv_timeout(_, 0)indefine_smoltcp_platform!= non-blocking poll.- LAN9118 emulator filter rejects multicast unless
MAC_CR.MCPAS; promiscuous (PRMS) recommended for QEMU-nic socket,…. MAX_UDP_SOCKETSdefault 4. RTPS needs 3/participant; zenoh/xrce 0..=1.
NetX Duo BSD (ThreadX)
SO_RCVTIMEOtakesstruct nx_bsd_timeval *, NOTINTms. Wrong type →wait_option = NX_WAIT_FOREVER→ deadlock. Usenros-platform-threadx::set_recv_timeout_ms.fcntl(F_SETFL, O_NONBLOCK)works (togglesNX_BSD_SOCKET_ENABLE_OPTION_NON_BLOCKING).- NSOS-NetX shim translates
SO_RCVTIMEOfor threadx-linux. Accepts INT-ms andnx_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/.actionfiles (local directory, ament index, or bundled), generates the C++ headers and the Rust FFI glue (ffi_serialize/ffi_deserializeper type), compiles the glue into a static library through Corrosion, and exposes the result as the<pkg>__nano_ros_cppCMake target. The Zephyr module spells the same functionnros_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 callsnros_cpp_action_client_relocateso the trampolinecontextpointer the executor holds follows the newthis.
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’sconfig.tomldomain_id→ generatedapp_config.h), runtime env on native vianros_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): usenros_tests::qemu::qemu_system_arm_cmd(), neverCommand::new("qemu-system-arm"). New justfile recipes gate through theQEMU_BINpath_exists check. Seebook-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 defaultconfigUSE_TASK_FPU_SUPPORTto 1, which saves a task’s VFP/NEON registers only after it callsvPortTaskUsesFPU(); nothing calls it, and a hard-float build uses the d-registers in integer code (64-bit constants, struct copies, NEONmemcpy). The result does not look like an FPU bug: Cyclone stamped its timed eventstsched = 0(avstr d8ofDDS_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. SetconfigUSE_TASK_FPU_SUPPORT 2in the board’sFreeRTOSConfig.handconfigFPU_D32withnros_freertos_config_define()— NOT in the header, becauseportASM.Snever 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):
Executorhas NOT held an inlinearena: [MaybeUninit<u8>; ARENA_SIZE]since phase-271 (issue 0110). It holdsarena: &'s mut [MaybeUninit<u8>], a slice borrowed from caller-supplied backing, and on FreeRTOS that backing is a named.bssstatic (nros_node::executor::backing::EXECUTOR_BACKING, phase-392 W6) — not the task stack.APP_TASK_STACKwas deleted in phase-76. The live knob isapp_stack_bytes(nros_board_common::freertos_config::DEFAULT_APP_STACK_BYTES, 131072 = 128 KiB since issue 1146; it was 393216), overridable withNROS_FREERTOS_APP_STACK_KB. No file in the tree sets that override. The C/C++ carrier’s own mirror incmake/templates/freertos_app_config.c.inis 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-serverbuilt 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 beforeconfigCHECK_FOR_STACK_OVERFLOW 2gets 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 onfreertos_config::app_stack_bytes. -
Deterministic
rand()starts from seed 1 → duplicate Zenoh session IDs across QEMU instances;srand()with an IP-based unique seed innros_freertos_init_network(). -
Manual-polling action server:
create_action_server()is not arena-registered, sospin_once()does not process get_result queries — callserver.try_handle_get_result()aftercomplete_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_mutexis 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_tandddsrt_once_tare still pthreads (one rwlock exists in all of cyclone — thelog.csink — andpthread_once_tis 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_TIMEOUTtrades read-wake rate for tx budget but stays window-bound. Mitigation (phase-279, opt-in):NROS_ZENOH_TX_BATCH=1env / KconfigCONFIG_NROS_ZENOH_TX_BATCH=yenables tx batching PLUS a dedicated flush thread (multi-threaded platforms except ThreadX; flush cadenceNROS_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_expressin the QoS profile (Rust.tx_express(true)/ Crclc_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 flipsZ_FEATURE_BATCHINGand TX_SPLIT_LOCK flipsZ_FEATURE_TX_SPLIT_LOCKin 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 withNROS_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_TIMEOUTmust 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 inzenoh-pico/include/zenoh-pico/config.h(+ thezenoh_generic_config.htwin). 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 ignoresSO_RCVTIMEOwithout that option, andtcp_zephyr.conly 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_ZENOHselects the option, andzpico.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_NUTTXmakes zenoh-pico’s TUs seeunix.h, wherez_clock_tis astruct timespecreturned through a hidden pointer, whileplatform_aliases.cis compiled against the genericuint64_tms clock. Sonros-zpico-builddefinesNROS_PLATFORM_ALIASES_SKIP_CLOCKfor NuttX. Re-enabling the alias clock there is a duplicate-symbol link error, becausenuttx_clock.calso 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 inuint64_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 byzephyr/cmake/nros_rmw_zenoh.cmakeon Zephyr; the generatedzenoh_generic_config.hfromnros-zpico-buildsets 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’sreliable_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-zephyrgate 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 inros2 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_putsucceeds while nothing reaches the wire. All embeddedrun_tierspaths (ZephyrBoard::run_tiers, the FreeRTOS Rustrun_tiers_entry, and the FreeRTOS Cnros_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 twosetup()(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:
- Feature → define.
nros/platform-nuttxforwardsnros-rmw-zenoh?/platform-nuttx→zpico-sys/nuttx→CARGO_FEATURE_NUTTX, andnros-zpico-buildthen#define ZENOH_NUTTX+ selects theunixsystem layer (zenoh-pico/system/common/platform.h: NuttX is grouped withZENOH_LINUX/MACOS/BSD) +LinkPolicy::nuttx(). The forwarding clause is load-bearing: without itZENOH_NUTTXis undefined and the setsockopt guards below stay off. unixsystem layer = direct POSIX. NuttX is a hosted POSIX RTOS, sosystem/unix/system.cbacks the primitives directly —z_malloc→libcmalloc,_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 throughnros-platform-*+ smoltcp).nros-platform-nuttxis a thin C-only glue crate (platform.c/net.c).ZENOH_NUTTXaccommodations (6 branches insystem/unix/network.c): skip<ifaddrs.h>/getifaddrs(NuttX lacks it → multicast bindsINADDR_ANY); skipSO_LINGER(noCONFIG_NET_SOLINGER); skipTCP_NODELAY(host vs NuttX optname value mismatch under cross-compile); useMSG_NOSIGNALonsendand freegetaddrinforesults (both shared withZENOH_LINUX). The SO_LINGER/TCP_NODELAY skips are why step 1’sZENOH_NUTTXdefine matters — otherwise those setsockopts fail on NuttX and_z_open_tcpreturnsTransport(ConnectionFailed).- Backend registration is explicit on NuttX. The unified-RMW
nros_rmw_register_backend!macro expands to alinkmedistributed-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. Sonros-board-nuttx::run_entrycallsnros_rmw_zenoh::register()explicitly beforeExecutor::open(featurermw-zenoh, wired entry →nros-board-nuttx-qemu→nros-board-nuttx) — same shape as the esp32 board. - 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 referenceqemu-armv7adefconfig 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.
| backend | AUTOMATIC / NONE | MANUAL_BY_TOPIC / MANUAL_BY_NODE |
|---|---|---|
| cyclonedds | OK | OK, and real — dds_assert_liveliness on the writer renews the lease and sends a Heartbeat |
| zenoh-pico | OK | UNSUPPORTED — session-scoped keepalive only; no per-topic lease exists to renew |
| XRCE-DDS | OK | UNSUPPORTED — NULL vtable slot, no lease in the protocol |
| uORB | OK | UNSUPPORTED — 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 stampslast_assert_at_ms, andcheck_liveliness_loststill fires this publisher’s ownLivelinessLostwhen 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 reportingUNSUPPORTEDis not a contradiction. - Do not “fix” zenoh by re-declaring the
@ros2_lvtoken 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:
| backend | get_gid() and MessageInfo::publisher_gid on a take |
|---|---|
| cyclonedds | the 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-pico | XXH3-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, uORB | UNSUPPORTED — 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 theBOARDcache 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.*]innros-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 matchingenvvar (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-ddsis retired). - Consumed by:
nano_ros_use_board()step 7 — setsNROS_RMWin 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 rundrives. - 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 tozephyr; and the board-import fixture test, which asserts the FVP cachesarmfvp. Thewest fvpextension 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 ownprj.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 alongsideNROS_BOARD_PRJ_CONFso 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 boardstep (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 TOMLboolon the Cargo side. - Consumed by:
nros setup boardsteps (c) + (d) — gatesrustup target addand thezephyr-lang-rustpresence 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 boardstep (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 boardstep (a) —nros setup --source <name>(provisioned into nano-ros’s own tree, which the consumer links vianano_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 toZEPHYR_EXTRA_MODULESso 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 functionnano_ros_use_board(<name>)resolvesBOARD_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/ cachedNROS_BOARD_RUNNER). All overrides land BEFORE Zephyr’s board-resolution phase — the function either re-orders (sets variables in the parent scope) orFATAL_ERRORs on wrong-order invocation. -
nros board info <name>(Phase 215.C.3, lives innros-cli, not in this superproject). Read-only: parses bothboard.cmakeand the[package.metadata.nros.board]table and prints them side by side, flagging any field that disagrees. The same parser backs--check-driftand the Phase 215.F drift audit. -
Phase 215.F drift audit (
packages/testing/nros-tests/tests/phase215_f_manifest_drift.rs). For everypackages/boards/nros-board-*/that carries BOTHboard.cmakeand[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 fieldszephyr_line,requires_rust,rust_targets,rmw_source). Bare Rust-only boards withoutboard.cmakeare skipped.
Adding a new board
- Drop the board crate at
packages/boards/nros-board-<name>/with the standard layout (prj.conf+boards/<hwv2-id>.{conf,overlay}- Rust skeleton).
- Author
board.cmakesetting every variable in this doc. Use${CMAKE_CURRENT_LIST_DIR}for path resolution. - Mirror the same facts into
[package.metadata.nros.board]in the crate’sCargo.toml(see Phase 215.C.1 in the phase doc). - 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) | Files | Upstreamable | Upstream PR |
|---|---|---|---|
nsos-recvmsg | drivers/net/nsos_sockets.c | yes | not opened |
native-sim-ipproto-ip (guest) | drivers/net/nsos_socket.h, drivers/net/nsos_sockets.c | yes | not opened |
nsos-adapt-ipproto-ip (host) | drivers/net/nsos_adapt.c | yes | not opened |
nsos-getsockname | drivers/net/nsos_sockets.c (+ adapt) | yes (candidate) | not opened |
nsos-getifaddrs | NSOS getifaddrs path | yes (candidate) | not opened |
nsos-mcjoin-mreq | drivers/net/nsos_sockets.c | yes (candidate) | not opened |
pthread-mutex-unlock | POSIX k_mutex | no (downstream-only) | n/a |
cargo-features / rust-cargo-extra-args / per-arch rust target | modules/lang/rust/** | yes (zephyr-lang-rust) | not opened |
| cyclonedds-on-Zephyr (threads, log-flush, sockwaitset, udp-rcvbuf, mcjoin) | cyclonedds fork | yes (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)
- Confirm the fix is in the Zephyr (or lang-rust / cyclonedds) release the
tested pin uses (
west.yml3.7.0 LTS /west-4.4.yml4.4.0 — seebook-internals/src/platform/zephyr-version-support.md). - Remove the
patches.ymlentry and the matchingscripts/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. - Drop the patch invocation from
scripts/zephyr/patches/<line>.sh. - 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-rustrevision 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 + thewest.ymlmanifest. - At most one rolling line that
zephyr-lang-rustsupports (today 4.4,west-4.4.yml,NROS_ZEPHYR_VERSION=4.4). - Never below the
zephyr-lang-rustfloor (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
-
Confirm the pair builds. Find a
zephyr-lang-rustrevision that builds against the target Zephyr. If the target is below 3.7, stop — it is not supportable (no Rust module). -
Pin the pair. Add
west-<line>.ymlpinning both the zephyr revision and a specificzephyr-lang-rustrevision (notmain— that drifts; see phase 199.2). -
Wire the selector. Add the
NROS_ZEPHYR_VERSION == "<line>"arms injust/zephyr.just(theZEPHYR_MANIFEST/ZEPHYR_WORKSPACE/ZEPHYR_VENV_BINselectors). -
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 thesetuprecipe — the dispatcher picks it up by name. -
Pin the SDK for the line. Read
zephyr/SDK_VERSIONin the new tree, add a[tool.zephyr-sdk-<ver>]table tonros-sdk-index.toml(host-keyed URLs- upstream’s own sha256s), and add the matching
case "$MANIFEST"arm inscripts/zephyr/setup.shnaming that entry. Check the distribution shape — 0.16.x ships one fat tarball with every toolchain, 1.0.x ships_minimalplus per-toolchain downloads the SDK’s ownsetup.sh -tfetches 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, soNROS_ZEPHYR_VERSION=4.4 just zephyr setupexited 0 and produced a workspace that could not build any Cortex-M target — the failure surfacing only at the firstwest buildas a bareFindZephyr-sdk.cmake:160error.just zephyr doctornow compares the registered SDKs againstzephyr/SDK_VERSIONand fails loudly instead. - upstream’s own sha256s), and add the matching
-
Add a CI line. Extend the Zephyr jobs in
.github/workflows/nightly.ymlwith the new line. -
Provision sources via
nros.just setup zephyrprovisionszenoh-pico/cyclonedds-src/px4-rsvianros setup --source(index-driven; the canonical path — no handgit submodule update).
See also
docs/roadmap/phase-199-zephyr-version-support-policy.md— rationale + the full work plan.scripts/zephyr/patches/README.md— the per-line patch-set contract.book-internals/src/dev-env/sdk-tiers.md— which modules install in whichjust setuptier.
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_DEADLINEbudget, 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
| Piece | Work item |
|---|---|
DispatchStrategy enum, Inline default for every Node | Phase 216 |
Tag types (SubscriptionTag, ServiceTag, ActionTag) and the _static registration variants | Phase 216.A.4 |
__nros_node_<pkg>_dispatch_strategy() emitted by nros::node!() | Phase 216.A.5 |
| Macro lint rejecting closure registration on a Deferred Node | Phase 216.A.6 |
nros::main!() emitting the __nros_dispatch task only when a Node is Deferred | Phase 216.B.3 / C.3 |
Framework × strategy matrix in nros check | Phase 216.D.1 |
atomic_waker cross-task notification substrate | Phase 214.J |
Board-side sink renamed NodeRuntime → NodeDispatchRuntime | Phase 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.Jatomic_wakersubstrate 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.pyin this repository and has nonrosverb 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
rticfeature of thenros-board-mps2-an385crate, it has no row of its own inpackages/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_spintask, per-Node state slots and, when any node in the image declaresDispatchStrategy::Deferred, the__nros_dispatchtask. It depends on the board crate with itsrticfeature, the RMW backend,rticand 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::publishcalls fromtickor 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_callbackhandler 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:
- An
[image.*]key —panic,profile,features: anything the image already declares. See Customising an entry. before_boot— not on RTIC. RTIC’s#[init]owns the first statement, so the build refusesbefore_booton an RTIC image; put pre-boot work in the board package (step 3) or in your own#[init](step 4).- 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_hardwareand its RTIC support, in a board package. 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.
| Property | Value |
|---|---|
| Model | Interrupt-driven (NVIC hardware scheduling) |
| Preemption | Yes — hardware interrupt nesting |
| Priority levels | 4–16 (depends on Cortex-M variant) |
| Priority direction | Lower number = higher priority (ARM convention) |
| Context switch | 12 cycles (Cortex-M3 tail-chain) |
| Mutual exclusion | Stack Resource Policy (SRP) — compile-time deadlock-free |
| Scheduling analysis | Fully 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
- Dispatch Strategy — the trichotomy and the per-Node-vs-per-callback rationale.
- Role reference — the package roles in full.
- Bare-metal Cortex-M3 — the MPS2-AN385 board and the direct-exec entry shape the book teaches.
- Scheduling Models — the scheduling models of the supported targets; the RTIC one is below.
Design background
- RFC-0113: RT unified wait
- RFC-0065: colcon-like workspace builder
D5: declarative escapes first,
nros materializelast. - RFC-0121: workspace-first project layout: every project is a workspace, and the entry is generated.
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:
| Path | Used 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/:
| Use | Location |
|---|---|
| Performance, fairness, stress, large-msg | packages/testing/nros-bench/<name>/ |
| Driver / board bringup smoke (no nros API) | packages/testing/nros-smoke/<name>/ |
| Fixture binaries built by integration tests | packages/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
- Pick the canonical cell. Confirm
<plat>/<lang>/<name>isn’t in the “intentionally empty” list inexamples/README.md. - Copy the nearest peer. Identical-RMW + adjacent-platform is
the lowest-risk template (e.g. copy
examples/mps2-an385-freertos/c/talkerto make a new FreeRTOS C/zenoh example). - Update names +
package.xml. RenameCargo.toml’snameand[[bin]]entries (Rust) orproject(...)andnano_ros_add_executable(...)targets (CMake). - Point
system.tomlat your board.[system] name, the[[component]]rows (pkg,class,name), and[image.<id>] boardplus any network identity the board needs (locator, and the address innet). 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’sentitieshere too. - Sync.
nros configurein the example directory — generated message crates plusbuild/<image>/nros-cargo.toml. Custom messages need their ownpackage.xmlin the consuming example. A single-package C/C++ leaf can skip this; everything else cannot, andnros buildrefuses with one line naming it. - Build standalone.
nros buildfrom 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. - Wire the test fixture (optional). If the example needs an E2E
gate, add a row to
examples/fixtures.tomland the matching cell inmatrix::CELLS; the builder lives inpackages/testing/nros-tests/src/fixtures/binaries/<plat>.rs. - Update
examples/README.mdcoverage 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.
| Platform | Source file shape | Build | Notes |
|---|---|---|---|
native | src/main.rs, src/main.c, src/main.cpp | nros build / cmake --build | Full std. |
mps2-an385-baremetal | src/main.rs with #[entry] | nros build | No std. Pure Cortex-M3. The board’s QEMU runner is in the generated settings, so cargo run --config … boots it. |
mps2-an385-freertos | src/main.rs / src/main.cpp / src/main.c | nros build (Rust) or cmake --build (C/C++) | FreeRTOS kernel + lwIP. |
nuttx | src/main.rs / src/main.c | cmake --build (NuttX export tarball) | NuttX kernel. |
threadx-linux / threadx-riscv64 | src/main.rs | cmake --build | ThreadX + NetX Duo. |
esp32 | src/main.rs | nros build + espflash save-image | bare-metal esp-hal; no cargo runner. |
zephyr | src/lib.rs (staticlib) or src/main.cpp | west 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
- Build as a CMake subdirectory
- Message Generation
examples/README.md— coverage matrix + intentionally-empty cellsexamples/templates/multi-package-workspace/— Pattern A copy-out template
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/):
| Crate | Focus |
|---|---|
nros-serdes | CDR serialization round-trip, buffer bounds |
nros-core | Time arithmetic, type invariants |
nros-params | Parameter value constraints, range checking |
nros-c | FFI boundary safety |
nros-ghost-types | Buffer state machine transitions (overflow/lock) |
nros-node | Executor scheduling, subscription handling |
Kani checks three properties by default:
- Memory safety – no out-of-bounds access, no use-after-free
- Overflow freedom – no integer overflow on arithmetic operations
- Panic freedom – no reachable
panic!,unwrap(), orassert!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:
&selfmust be written asself_: &Type(named parameter, not method syntax)ready@converts&[bool]toSeq<bool>(Verus view conversion)*self_dereferences&TriggerConditiontoTriggerConditionfor 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:
| Level | Mechanism | What’s trusted | Strength |
|---|---|---|---|
| Formally linked | assume_specification + external_type_specification | The spec matches the impl (human audit of ~4 lines) | Strongest |
| Ghost model | Manual struct/enum mirror | Line-by-line correspondence with production source | Medium |
| Pure math | Arithmetic identities | Only the math itself | Weakest (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
- Identify the production function and its source location
- 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
- Can you import the type? Use
- Write the spec function inside
verus! { } - If formally linking: add
assume_specificationand document which source lines the auditor should compare - Write the proof function with
ensuresclauses - Run
just verify-verusto check - 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:
-
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. -
-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):
QEMU_SYSTEM_<ARCH>(QEMU_SYSTEM_ARM) — an explicit override.- The project prefix
build/qemu/bin/— only while its.nros-provenancenames 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 removebuild/qemu), never skipped: a test must not run on an emulator nobody chose.just check tier-preconditionsreports the same thing before a run. - The store copy at the pinned version.
$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 eitherQEMU_SYSTEM_ARMor<workspace>/build/qemu/bin/qemu-system-arm.- The patched binary reports version ≥ 7.2.
-netdev helpadvertisesdgram(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
- Land the change on the fork branch (
nano-ros-v11.0.0-patches), one logical change per commit. - Cut a new nano-ros-sdk QEMU release from it.
- Bump
[tool.qemu] versionand the dist rows innros-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:
| Priority | Task | Role |
|---|---|---|
| 4 | tcpip_thread | lwIP protocol processing |
| 4 | poll task | drains LAN9118 RX FIFO into lwIP |
| 4 | zenoh read / lease | zenoh-pico background I/O |
| 3 | app task | nano-ros executor and user code |
| 0 | idle | must 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:
| Offset | Name | Purpose |
|---|---|---|
0x00 | RX_DATA_PORT | RX FIFO data |
0x20 | TX_DATA_PORT | TX FIFO data |
0x40 | RX_STAT_PORT | RX packet status |
0x50 | ID_REV | chip ID / revision |
0x54 | IRQ_CFG | interrupt output configuration |
0x58 | INT_STS | interrupt status |
0x6C | RX_CFG | RX configuration |
0x70 | TX_CFG | TX configuration |
0x7C | RX_FIFO_INF | RX FIFO status / bytes used |
0x80 | TX_FIFO_INF | TX FIFO free space |
0xA4 | MAC_CSR_CMD | indirect MAC CSR command |
0xA8 | MAC_CSR_DATA | indirect 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
printfoutput. - Launch QEMU with
-monitor telnet:127.0.0.1:4444,server,nowaitand runinfo networkorinfo qtree. - Send a diagnostic ARP request and check for immediate RX FIFO data.
- Confirm
ID_REV,MAC_CRTXEN/RXEN,TX_FIFO_INF,RX_FIFO_INF, andINT_STSduring 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:
- No zenohd router running: Many tests require a zenohd router
- Port already in use: Another process is using port 7447
- 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. Therequire_*()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 declarationCiLane::unclaimedmakes, bound bylane_build_covers_run.rs.nros_scope_coords_file <module>is a platform’s own rows, fromlane-coords --scope.
So
just ci tier1andjust test tier1deselect the same tests, andjust test linuxdoes 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 concept | JUnit element | Triggered by |
|---|---|---|
| pass | <testcase> w/ no children | normal 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:
- Replaces each
<failure message="[SKIPPED] …">with<skipped message="[SKIPPED] …">, preserving the message attribute and panic body verbatim. - Decrements
failures="N"and incrementsskipped="N"on the enclosing<testsuite>and the top-level<testsuites>element. - 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 tierjustfile::test-all— full matrixjustfile::test-failed— rerun-failures workflowjustfile::_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
| Consumer | What it reads | Effect of the rewrite |
|---|---|---|
justfile::_count-real-failures | <failure> count | sees 0 after rewrite |
justfile::_test-summary | <failure> lines + [SKIPPED] markers | groups skips into a Environment-skipped tests: N line |
scripts/test/failed-filterset.py | testcases with non-[SKIPPED] <failure> | unchanged (already filters by marker) |
| CI dashboards (junit-cli-report-viewer, GitHub Actions test report, …) | <failure> + <skipped> counts | sees 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_zenohdnot resolvable — source your ROS setup soAMENT_PREFIX_PATHis exported, or setNROS_RMW_ZENOHD(nano-ros ships no router; RFC-0075).just zenohdstarts one.just zenohd setup). qemu-system-arm/qemu-system-riscv64not on PATH (runjust qemu setup-qemuor 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-agentnot built (runjust xrce build-agent).ros2not sourced / normw_fastrtpsruntime (runsource /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!+returnin place ofunmet!— 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.mdTrack 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:
- Hardware timers (CMSDK Timer0 at 25 MHz) advance with QEMU’s virtual clock
- WFI (Wait For Interrupt) during
Mono::delay(10ms)advances the virtual clock by 10ms almost instantly in wall-clock time - 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 viaQEMU_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]
| Parameter | Default | Description |
|---|---|---|
shift=N | — | Fixed: each instruction = 2^N nanoseconds of virtual time |
shift=auto | — | Adaptive: auto-adjusts shift (0–10) to track wall clock |
sleep=on | on | WFI advances virtual time at wall-clock speed |
sleep=off | — | WFI jumps instantly to next timer deadline (deterministic but breaks networking) |
align=on | off | Throttle guest to not run faster than real-time (requires fixed shift, incompatible with shift=auto) |
Constraints
shift=autois incompatible withalign=onandsleep=offalign=onis incompatible withsleep=offshift=autostarts at 125 MIPS (shift=3) and adjusts within 100ms
Tradeoffs
| Without icount | With -icount shift=auto | |
|---|---|---|
| Boot speed | Fast (emulates at host CPU speed) | Slower (virtual time ≈ wall time) |
| Timer accuracy | Virtual time races ahead | Virtual time tracks wall clock |
| WFI behavior | Virtual clock advances instantly | Virtual clock advances at wall-clock speed |
| Network timeouts | Fire too early (spurious failures) | Fire at correct wall-clock times |
| Determinism | Non-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=yto throttle the simulator. Equivalent to our-icount shift=auto. - Zephyr QEMU: Uses
-icount shift=6,align=off,sleep=offbut disables it when networking is active (default y if !NETWORKING && !BT). Theirsleep=offcauses the instant-jump behavior that breaks networking. We usesleep=on(default) to avoid this. - ESP32-C3 QEMU: Uses
-icount 3(fixed shift,sleep=ondefault) for the boot test injustfile.
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
- QEMU TCG Instruction Counting
- Zephyr: Configure QEMU to run independent of the host clock (#14173)
- Zephyr: qemu_x86 and qemu_cortex_m3 time handling broken with CONFIG_QEMU_ICOUNT (#26242)
- QEMU source:
accel/tcg/icount-common.c
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:
&selfmust be written asself_: &Type(named parameter, not method syntax)ready@converts&[bool]toSeq<bool>(Verus view conversion)*self_dereferences&TriggerConditiontoTriggerConditionfor 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:
| Level | Mechanism | What’s trusted | Strength |
|---|---|---|---|
| Formally linked | assume_specification + external_type_specification | The spec matches the impl (human audit of ~4 lines) | Strongest |
| Ghost model | Manual struct/enum mirror | Line-by-line correspondence with production source | Medium |
| Pure math | Arithmetic identities | Only the math itself | Weakest (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
- Identify the production function and its source location
- 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
- Can you import the type? → use
- Write the spec function inside
verus! { } - If formally linking: add
assume_specificationand document which source lines the auditor should compare - Write the proof function with
ensuresclauses - Run
just verify-verusto check - 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-eabitarget
# 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:
| Variable | Default | Description |
|---|---|---|
FREERTOS_DIR | third-party/freertos/kernel | FreeRTOS kernel source |
FREERTOS_PORT | stated by the board (GCC_ARM_CM3 on mps2) | upstream FreeRTOS-Kernel’s port enum (phase-349 W3) |
LWIP_DIR | third-party/freertos/lwip | lwIP source |
FREERTOS_CONFIG_DIR | Board 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/:
| Example | Description |
|---|---|
talker | Publishes std_msgs/Int32 on /chatter |
listener | Subscribes to std_msgs/Int32 on /chatter |
service-server | Serves AddTwoInts on /add_two_ints |
service-client | Calls AddTwoInts on /add_two_ints |
action-server | Serves Fibonacci action on /fibonacci |
action-client | Sends 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.
| Role | IP Address | TAP Device |
|---|---|---|
| zenohd (host) | 192.0.3.1 | br-qemu |
| Talker/Publisher | 192.0.3.10 | tap-qemu0 |
| Listener/Sub | 192.0.3.11 | tap-qemu1 |
| Service Server | 192.0.3.12 | tap-qemu0 |
| Service Client | 192.0.3.13 | tap-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
cccrate: FreeRTOS kernel + lwIP compiled in the board crate’sbuild.rs(no external CMake needed). no_stdtarget:thumbv7m-none-eabiwith semihosting output.
Task Priorities
| Priority | Task | Role |
|---|---|---|
| 4 | tcpip_thread | lwIP TCP/IP processing |
| 4 | poll task | LAN9118 RX FIFO → lwIP |
| 4 | zenoh read/lease | zenoh-pico background I/O |
| 3 | app task | nros application (Executor + Node) |
| 2 | timer task | FreeRTOS software timers |
| 0 | idle | WFI (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):
- Create a new board crate (e.g.,
nros-stm32f767-freertos) - Replace LAN9118 netif with the vendor Ethernet driver (STM32 HAL ETH)
- Update
FreeRTOSConfig.hfor the target MCU (clock, heap, priorities) - Update
lwipopts.hfor available RAM - Update the linker script for the target’s memory map
- Keep the same
run()pattern andConfigbuilder
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/tunand persistent user-owned TAP interfaces (created byjust 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-elftarget
# 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:
| Variable | Default | Description |
|---|---|---|
THREADX_DIR | third-party/threadx/kernel | ThreadX kernel source |
THREADX_CONFIG_DIR | Board crate’s config/ | ThreadX config (tx_user.h) |
NETX_DIR | third-party/threadx/netxduo | NetX Duo source |
NETX_CONFIG_DIR | Board 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.
| Example | Description |
|---|---|
talker | Publishes std_msgs/Int32 on /chatter |
listener | Subscribes to std_msgs/Int32 on /chatter |
service-server | Serves AddTwoInts on /add_two_ints |
service-client | Calls AddTwoInts on /add_two_ints |
action-server | Serves Fibonacci action on /fibonacci |
action-client | Sends 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
cccrate: ThreadX kernel + NetX Duo compiled in the board crate’sbuild.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
westand 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.shfor 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
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS | bool | n | Enable nros module |
CONFIG_NROS_RUST_API | bool | y | Use Rust API |
CONFIG_NROS_C_API | bool | n | Use C API |
CONFIG_NROS_DOMAIN_ID | int | 0 | ROS 2 domain ID |
CONFIG_NROS_INIT_DELAY_MS | int | 2000 | Network init wait (ms) |
Zenoh Options (visible when CONFIG_NROS_RMW_ZENOH=y)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_ZENOH_LOCATOR | string | "tcp/127.0.0.1:7447" | Router address |
CONFIG_NROS_ZENOH_MULTI_THREAD | bool | y | Zenoh-pico multithreading |
CONFIG_NROS_ZENOH_PUBLICATION | bool | y | Publication support |
CONFIG_NROS_ZENOH_SUBSCRIPTION | bool | y | Subscription support |
CONFIG_NROS_ZENOH_QUERY | bool | y | Service client support |
CONFIG_NROS_ZENOH_QUERYABLE | bool | y | Service server support |
CONFIG_NROS_ZENOH_LINK_TCP | bool | y | TCP transport link |
CONFIG_NROS_MAX_PUBLISHERS | int | 8 | Max concurrent publishers |
CONFIG_NROS_MAX_SUBSCRIBERS | int | 8 | Max concurrent subscribers |
CONFIG_NROS_MAX_QUERYABLES | int | -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_SIZE | int | 2048 | Max reassembled message size |
CONFIG_NROS_BATCH_UNICAST_SIZE | int | 1024 | Max unicast batch size |
CONFIG_NROS_SUBSCRIBER_BUFFER_SIZE | int | -1 (derive) | Per-subscriber buffer, small payload class |
CONFIG_NROS_MAX_LARGE_SUBSCRIBERS | int | -1 (derive) | Blocks in the large payload class |
CONFIG_NROS_SUBSCRIBER_LARGE_SIZE | int | -1 (derive) | Per-sample capacity of the large class |
CONFIG_NROS_SERVICE_BUFFER_SIZE | int | 1024 | Per-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)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_XRCE_AGENT_ADDR | string | "127.0.0.1" | Agent IP address |
CONFIG_NROS_XRCE_AGENT_PORT | int | 2018 | Agent UDP port |
CONFIG_NROS_XRCE_TRANSPORT_MTU | int | 512 | Transport MTU |
CONFIG_NROS_XRCE_MAX_SUBSCRIBERS | int | -1 (derive) | Max concurrent subscribers |
CONFIG_NROS_XRCE_MAX_SERVICE_SERVERS | int | -1 (derive) | Max service servers |
CONFIG_NROS_XRCE_MAX_SERVICE_CLIENTS | int | 4 | Max service clients |
CONFIG_NROS_XRCE_SUBSCRIBER_RING_DEPTH | int | 32 | Queued samples per subscriber |
CONFIG_NROS_XRCE_BUFFER_SIZE | int | 1024 | Per-slot buffer size |
CONFIG_NROS_XRCE_STREAM_HISTORY | int | 16 | Reliable stream depth (2–16) |
C API Options (visible when CONFIG_NROS_C_API=y)
| Option | Type | Default | Description |
|---|---|---|---|
CONFIG_NROS_C_MAX_HANDLES | int | 16 | Max executor handles |
CONFIG_NROS_C_MAX_SUBSCRIPTIONS | int | 8 | Max subscriptions |
CONFIG_NROS_C_MAX_TIMERS | int | 8 | Max timers |
CONFIG_NROS_C_MAX_SERVICES | int | 4 | Max 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
| Issue | Solution |
|---|---|
west: command not found | Run pip3 install --user west and add ~/.local/bin to PATH |
Connection refused | Start zenohd / MicroXRCEAgent on the host loopback (e.g. tcp/127.0.0.1:7447) |
Build fails | Source environment: source ../nano-ros-workspace/env.sh |
XRCE Agent not found | Install: just setup (installs MicroXRCEAgent) |
| Zenoh mutex exhaustion | Increase CONFIG_MAX_PTHREAD_MUTEX_COUNT (default 5 is too low) |
z_declare_publisher failed: -128 with two instances | Duplicate 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
| Address | Device |
|---|---|
| 192.0.2.1 | Host bridge (zenohd) |
| 192.0.2.10 | QEMU node 0 (tap-qemu0) |
| 192.0.2.11 | QEMU 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
| Recipe | Description |
|---|---|
build-examples-qemu | Build all QEMU examples |
test-qemu | Run all QEMU tests |
test-qemu-basic | Run basic serialization test |
test-qemu-lan9118 | Run LAN9118 driver test |
setup-qemu-network | Create TAP bridge (sudo) |
teardown-qemu-network | Remove TAP bridge (sudo) |
status-qemu-network | Show network status |
qemu-help | Show 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
| Feature | Value |
|---|---|
| CPU | ARM Cortex-M3 |
| Flash | 4MB at 0x00000000 |
| SRAM | 4MB at 0x20000000 |
| Ethernet | LAN9118 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):
| Priority | Task | Role |
|---|---|---|
| 4 | tcpip_thread | lwIP TCP/IP processing (set via TCPIP_THREAD_PRIO) |
| 4 | poll task | Calls lan9118_lwip_poll() to drain RX FIFO |
| 4 | zenoh read/lease | zenoh-pico background tasks (default: configMAX_PRIORITIES/2) |
| 3 | app task | zenoh-pico / nros application logic |
| 2 | timer task | FreeRTOS software timer service |
| 0 | idle | WFI 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):
| Offset | Name | Description |
|---|---|---|
0x00 | RX_DATA_PORT | RX FIFO data read (32-bit words) |
0x20 | TX_DATA_PORT | TX FIFO data write (32-bit words, also TX commands) |
0x40 | RX_STAT_PORT | RX status FIFO (packet length, error flags) |
0x50 | ID_REV | Chip ID + revision (upper 16 = 0x9220 or 0x0118) |
0x54 | IRQ_CFG | IRQ output configuration (default 0x22000111) |
0x58 | INT_STS | Interrupt status (TX/RX activity flags) |
0x5C | INT_EN | Interrupt enable mask |
0x68 | FIFO_INT | FIFO interrupt threshold levels |
0x6C | RX_CFG | RX configuration |
0x70 | TX_CFG | TX configuration (bit 1 = TX_ON) |
0x74 | HW_CFG | Hardware config (bit 0 = soft reset, bits 19:16 = TX FIFO size) |
0x78 | RX_DP_CTRL | RX data path control (bit 31 = fast-forward/discard) |
0x7C | RX_FIFO_INF | RX FIFO info (bits 23:16 = status entries used, bits 15:0 = data bytes used) |
0x80 | TX_FIFO_INF | TX FIFO info (bits 15:0 = data free space) |
0x88 | GPIO_CFG | GPIO/LED configuration |
0xA4 | MAC_CSR_CMD | MAC CSR indirect access command (bit 31 = busy, bit 30 = read) |
0xA8 | MAC_CSR_DATA | MAC CSR indirect access data |
0xAC | AFC_CFG | Auto flow control configuration |
Indirect MAC CSR registers (accessed via MAC_CSR_CMD / MAC_CSR_DATA):
| Index | Name | Key Bits |
|---|---|---|
| 1 | MAC_CR | bit 3 = TXEN, bit 2 = RXEN, bit 18 = PRMS (promiscuous) |
| 2 | ADDRH | MAC address bytes 5:4 |
| 3 | ADDRL | MAC address bytes 3:0 |
| 6 | MII_ACC | PHY register access (bit 0 = busy, bit 1 = write) |
| 7 | MII_DATA | PHY 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:
MAC_CR_RXENmust be set- Frame size must be within limits (64-2048 bytes for tagged, 14+ bytes minimum)
- RX FIFO must have space
- MAC address filter (or
MAC_CR_PRMSfor 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 inlinearena: [MaybeUninit<u8>; ARENA_SIZE], and that service/action examples pinNROS_EXECUTOR_ARENA_SIZEto 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.bssstatic; no example in the tree setsNROS_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_STACKto 16384 words (64 KB)”, then (phase-392 W6) “the default is 393216”.APP_TASK_STACKwas deleted in phase-76; the 384 KiB that replaced it was never derived from anything. It is 128 KiB now, fromuxTaskGetStackHighWaterMarkon 8 in-tree images whose worst peak is 36 152 bytes — the table is onfreertos_config::app_stack_bytes. The C/C++ carrier’s mirror incmake/templates/freertos_app_config.c.inis 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:
MAC_CR_RXEN– if clear, frame is silently dropped- Size limits: rejects frames < 14 bytes or > 2048 bytes (with VLAN headroom)
- FIFO space: needs room for status word + aligned data words
- MAC filter: calls
lan9118_filter()which checks unicast/multicast/broadcast against configured MAC address, or accepts everything ifMAC_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.cin the Zephyr repository -
ARM CMSIS-Driver ETH_LAN9220:
CMSIS/Driver/DriverTemplates/Driver_ETH_MAC.cand related files in the ARM-software/CMSIS_5 repository -
QEMU LAN9118 model source:
hw/net/lan9118.cin 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:
- Read
MAC_CRthrough the indirect MAC CSR atbase + 0xA4/0xA8and checkRXEN(bit 2) /TXEN(bit 3). - Write TX Command A (
FIRST_SEG | LAST_SEG | len) then Command B (len << 16 | len) tobase + 0x20, then the frame as DWORDs. - Read
RX_FIFO_INFatbase + 0x7Cand take(v >> 16) & 0xFFas the RX status-entry count — immediately, then again after a fewwfiiterations. A count that only appears afterwfimeans QEMU’s main loop, not the driver, was the gate. INT_STSatbase + 0x58before 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 infoinfo 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:
MAC_CRhas both TXEN and RXEN set- MAC address in the ARP frame matches the NIC’s configured MAC
- The QEMU network backend is connected (check
-netdevand-nicflags)
Register dump for quick health check
Read these registers to assess NIC state:
ID_REV(0x50): should be0x92200001or similar – confirms NIC is presentMAC_CR(indirect index 1): confirm TXEN (bit 3) and RXEN (bit 2) are setTX_FIFO_INF(0x80): free space should be ~4-5 KB after initRX_FIFO_INF(0x7C): status entries (bits 23:16) show pending unread packetsINT_STS(0x58): TX/RX interrupt flags show recent activity