Configuration Guide
nano-ros config is language-agnostic and has one home per concern — no
setting lives in two places, and nothing is merged across files. The authored
surfaces are Cargo.toml metadata (Rust) or the CMake nano_ros_* functions
(C/C++), a universal system.toml, package.xml, and launch XML — plus
Kconfig for the embedded build (Zephyr). This guide covers what each file
owns, the embedded deploy config both languages bake at build time, and the
standalone config.toml for hand-written no_std apps.
Two files you may find in old material are retired: the
nros.tomlper-package/workspace file (rejected by the CLI; see below) and the oldconfig.toml[network]/[zenoh]/[scheduling]schema. Design of record: RFC-0004.
One home per concern
| File | Owns | Per |
|---|---|---|
Cargo.toml | Rust build: crate, language deps, the RMW feature menu (rmw-zenoh/rmw-cyclonedds/rmw-xrce); node identity via [package.metadata.nros.node]; entry/boot via [package.metadata.nros.entry]; embedded net config via [package.metadata.nros.deploy.<target>]; workspace membership via [workspace.metadata.nros] | Rust project |
CMakeLists.txt | C/C++ build: targets, language deps, the NROS_RMW option; node/entry registration via nano_ros_auto_add_library + nros_components_register_node (RFC-0057; nano_ros_node_register remains as the compat spelling) / nano_ros_entry | C/C++ project |
.cargo/config.toml | [patch.crates-io] dependency injection only (written by nros sync; local crate + generated-msg paths), plus the cargo [build]/[target]/[env] knobs (target triple, runner, rustflags). No nano-ros runtime config. | Rust project |
package.xml | ROS package identity + msg <depend>s (codegen input for nros generate) | all |
system.toml | System topology — components, deploy targets, domain, RMW, capability axes ([safety], [param_services]), tiers. The language-agnostic universal descriptor (same schema for Rust/C/C++). Optional for single-node (the toolchain synthesises an implicit 1-component system when absent). | bringup pkg |
config.toml (standalone) | Hand-written no_std direct-mode apps only — [node] / [[transport]] / [node.rt], compile-baked via Config::from_toml(include_str!(…)). Apps that use nros::main!() / codegen do not have one — they use the deploy metadata above. | embedded single-node app (no codegen) |
Boundary rule. If a knob changes what is compiled/linked, it lives in the build file (
Cargo.tomlfeature /CMakeLists.txtoption). If it changes the system topology (components, deploy, domain, RMW), it lives insystem.toml. If it is the physical link + router address a target boots with, it lives in thedeployconfig ([package.metadata.nros.deploy.<t>]for Rust / the package.xml<export><nano_ros deploy=… rmw=…/>tuple for C/C++).
Config home by language × scale
Mirrors RFC-0004 §3:
| Single-node | Workspace | |
|---|---|---|
| Rust | Cargo.toml [package.metadata.nros.{node,entry,deploy.<t>}] (+ nros::main!); optional system.toml to pin rmw/domain | root [workspace.metadata.nros] + node [package.metadata.nros.node] + entry [package.metadata.nros.entry] + bringup system.toml |
| C / C++ | CMakeLists.txt + package.xml (the <nano_ros deploy=… rmw=…/> tuple); optional system.toml | nros_components_register_node / nano_ros_entry per pkg + same system.toml + package.xml |
Where a concern has both a native-idiom projection and a system.toml, the
resolution is a fixed precedence ladder, not a merge: explicit CLI/build
flag (--rmw / -DNANO_ROS_*) > system.toml ([image.<id>] > [system])
the per-package projection (
[package.metadata.nros.*]/ CMake) > built-in default.nros config showprints the resolved effective config with per-value provenance;nros checkflags values still sourced from legacy files.
Full design rationale: RFC-0004 (configuration & transports);
RMW backend selection & lowering is RFC-0031.
For the multi-RMW runtime topic-forwarding bridge (a separate file/feature), see
nros-bridge.toml.
Embedded deploy config (codegen apps — the common case)
An embedded app built through nros::main!() (Rust) or the CMake entry
codegen (C/C++) declares its network + router config per deploy target in
its build manifest. The toolchain bakes it at compile time — there is no
config file on the device and nothing is parsed at runtime.
Rust — [package.metadata.nros.deploy.<target>] in the app’s
Cargo.toml. nros::main!() bakes the block into a DeployOverlay that
BoardEntry::run_with_deploy applies onto the board’s boot Config:
# Cargo.toml (e.g. examples/qemu-arm-baremetal/rust/talker)
[package.metadata.nros.node]
class = "talker_pkg::Talker"
name = "talker"
[package.metadata.nros.deploy.mps2-an385]
board = "mps2-an385" # board crate (optional where unambiguous)
rmw = "zenoh"
locator = "tcp/192.168.1.1:7447"
ip = "192.168.1.10"
gateway = "192.168.1.1"
netmask = "255.255.255.0"
# domain_id = 0
C/C++ — the <export><nano_ros …/> tuple in the package’s
package.xml (RFC-0048 §4; the retired nano_ros_deploy() cmake call’s
successor). Domain/locator ride the build config (config.toml /
-DNROS_ENTRY_LOCATOR), not the tuple:
<!-- package.xml (e.g. examples/qemu-arm-freertos/c/talker) -->
<export>
<build_type>ament_cmake</build_type>
<nano_ros deploy="freertos" board="mps2-an385-freertos" rmw="zenoh"/>
</export>
RT/stack/priority for a single-node embedded app comes from board-crate
Cargo features + Kconfig (prj*.conf on Zephyr), not a config file.
Multi-node RT is declared in system.toml ([tiers.<name>.<rtos>] +
[[node_overrides]]) — see RFC-0015.
Periodic timers after a stall
If a tier is preempted past several periods of a periodic timer, the backlog is
coalesced, not replayed: the timer fires ONCE and the missed periods are
counted. Phase is preserved, so activations stay on the declared cadence grid
rather than drifting by each stall. This is the default (TimerOverrunPolicy::Skip)
and matches rclcpp and Zephyr’s k_timer.
Replay is available — Executor::set_timer_overrun_policy(.., CatchUp) — and is
the right choice for a counter or accumulator, where every tick carries state
that must not be lost. It is the wrong choice for a control loop: the replayed
activations compute with stale inputs and emit a burst of commands inside a
fraction of a period.
Overruns are counted per timer (Executor::timer_overruns) and reported as the
timer-overrun-runtime violation. Watch that rather than a publish-rate check:
under CatchUp a replayed burst makes the achieved rate look nominal — measured
100.03 Hz on a declared 100 Hz loop while the tier was stalling for up to 611 ms
at a time — so a rate monitor reports health during exactly the fault it exists
to catch. Rationale and the measurements: RFC-0002 § 4.4a.
Timer periods are microseconds throughout, including across the C ABI.
Standalone config.toml — hand-written no_std apps (no codegen)
A hand-written single-node app that writes its own main() and bypasses
nros::main!() keeps its net config in a file, not hardcoded in Rust: a
sibling config.toml carrying [node] / [[transport]] / [node.rt],
compile-baked with include_str! and parsed by the board crate’s
Config::from_toml.
# config.toml
[node]
domain_id = 0 # ROS 2 domain ID (0–232)
# namespace = "/"
# rmw = "zenoh" # ACTIVE backend; must match a LINKED backend
# (the build file picks what is linked)
# One transport per session. A single ethernet/wifi/serial entry is the
# common case. In-process topic forwarding is the separate [[bridge]] path.
[[transport]]
kind = "ethernet" # ethernet | wifi | serial | can
ip = "10.0.2.10/24" # CIDR — the prefix rides the address
mac = "02:00:00:00:00:00"
gateway = "10.0.2.2"
locator = "tcp/10.0.2.2:7447"
[node.rt] # scheduling / real-time (RTOS); omit for defaults
app_priority = 12
app_stack_bytes = 65536
zenoh_read_priority = 16
zenoh_lease_priority = 16
poll_priority = 16
poll_interval_ms = 5
Per-kind transport fields:
| kind | fields |
|---|---|
ethernet | ip (CIDR), mac, gateway, interface |
wifi | ssid, password, optional static ip/gateway |
serial / can | device, baudrate |
| all | id, rmw, locator |
ip is CIDR (10.0.2.10/24) — the board derives the prefix or netmask from it.
A serial locator carrying # (serial/UART_0#baudrate=115200) is fine — quote it.
Consumption (compile-baked; no filesystem on the target):
use nros_board_mps2_an385::{Config, run};
fn main() -> ! {
run(Config::from_toml(include_str!("../config.toml")), |config| {
let exec = ExecutorConfig::new(config.zenoh_locator)
.domain_id(config.domain_id);
// ...
})
}
Pick by whether the app uses codegen: nros::main!() app → deploy metadata
(above); hand-written main() → config.toml. Only the old
config.toml schema ([network]/[zenoh]/[scheduling]) is retired.
Retired files
nros.toml— retired in full. A workspace-rootnros.tomlis rejected by the CLI (NrosTomlNotSupported; migrate with thenros-v0.5.0tag’snros migrate workspace— the one-shot verb is retired on newer trees, #186); the legacy per-package overlay is a deprecated fallback thatnros checkflags; the embedded-runtime role never shipped (no example ever declared one). If a doc tells you to writenros.toml, it predates the migration — the content belongs indeploymetadata orsystem.toml.- Old
config.tomlschema ([network]/[zenoh]/[scheduling]) — retired; superseded by thedeployclass (net) + board features / Kconfig (RT). The direct-modeconfig.tomlabove is the kept, supported shape.
Build-time environment variables
Read during cargo build (by build.rs) or by justfile recipes. Set in .env
or export in your shell.
SDK paths
Auto-resolved by nros setup <board>; override if SDKs live elsewhere.
| Variable | Default | Description |
|---|---|---|
FREERTOS_DIR | third-party/freertos/kernel | FreeRTOS kernel source |
FREERTOS_PORT | GCC/ARM_CM3 | FreeRTOS portable layer |
LWIP_DIR | third-party/freertos/lwip | lwIP source |
FREERTOS_CONFIG_DIR | Board crate’s config/ | FreeRTOSConfig.h |
NUTTX_DIR / NUTTX_APPS_DIR | third-party/nuttx/… | NuttX RTOS + apps |
THREADX_DIR / NETX_DIR | third-party/threadx/… | ThreadX + NetX Duo |
THREADX_CONFIG_DIR / NETX_CONFIG_DIR | Board crate’s config/ | tx_user.h / nx_user.h |
Buffer-tuning vars (ZPICO_*, XRCE_*, NROS_*) are optional — see the
Environment Variables Reference.
Binary-size knobs (embedded)
On a constrained MCU, two build-time env vars (set in the example’s
.cargo/config.toml [env], like the other NROS_* tuning) shed the parts a
brokered client doesn’t need:
| Variable | Default | Effect |
|---|---|---|
NROS_LINK_IP | on | NROS_LINK_IP=0 on a serial-only node drops the IP link layer — zenoh-pico’s TCP/UDP link C (Z_FEATURE_LINK_TCP/UDP=0) and (with --gc-sections) the smoltcp platform impl. Serial link stays. |
NROS_SMOLTCP_MAX_SOCKETS / NROS_SMOLTCP_MAX_UDP_SOCKETS | 4 / 2 | Sized for DDS RTPS (3 UDP/participant). A zenoh/XRCE client multiplexes everything over one session → set both to 1 to drop the spare socket buffers (≈8 KB each). |
NROS_HEAP_SIZE | per-platform-crate (64 KB nros-platform-mps2-an385, 32 KB nros-platform-stm32f4) | Decimal bytes for the bare-metal static heap. The defaults are generous; size to the RMW’s working set (table below). E.g. NROS_HEAP_SIZE = "24576" on a zenoh-pico node cut the mps2-an385 .data 66 → 25 KB (−41 KB). |
Static-heap sizing by backend (bare-metal FreeListHeap, set via
NROS_HEAP_SIZE):
| Backend | Peak working set | Recommended heap | Notes |
|---|---|---|---|
| zenoh-pico (TCP) | ~16 KB | 24–32 KB (≈2× for fragmentation) | peer middleware; alloc-based session/buffers |
| zenoh-pico (serial) | lighter than TCP | 16–24 KB | no TCP link buffers; verified running at 16 KB |
| XRCE (Micro-XRCE-DDS) | ~3 KB (micro-ROS figure) | ~8 KB | static pools, discovery offloaded to the agent — the RAM-minimal backend; a measured bare-metal XRCE figure is pending an example (no bare-metal XRCE example ships yet — XRCE bare-metal needs a custom-transport injection) |
Measured footprint
Honest, reproducible numbers per (platform, transport, backend, profile) —
built with the in-tree examples, the release profile is cargo’s default
(opt-3), the size profile is the scaffolded [profile.size] (opt-s + lto
strip, see the size-tuned release profile notes). RAM =data + bss. All cells are after--gc-sections+ the size knobs above are applied where noted; the serial cell ships with the recipe below.
| platform | transport | backend | profile | text (flash code) | data | bss | RAM total |
|---|---|---|---|---|---|---|---|
| qemu-arm-baremetal (mps2-an385, cortex-m3) | ethernet (smoltcp) | zenoh-pico | release | 177.4 KB | 67.0 KB | 91.7 KB | 158.7 KB |
| qemu-arm-baremetal | ethernet | zenoh-pico | size | 158.3 KB | 67.0 KB | 91.7 KB | 158.7 KB |
| qemu-arm-baremetal | serial (no IP stack) | zenoh-pico | release | 128.6 KB | 25.2 KB | 75.8 KB | 101.0 KB |
| qemu-arm-baremetal | serial | zenoh-pico | size + recipe | 116.1 KB | 25.2 KB | 75.8 KB | 101.0 KB |
| stm32f4 (thumbv7em-eabihf, cortex-m4) ¹ | ethernet | zenoh-pico | release | 186.9 KB | 13.7 KB | 123.0 KB | 136.7 KB |
| stm32f4 ¹ | ethernet | zenoh-pico | size | 138.1 KB | 13.7 KB | 123.0 KB | 136.7 KB |
| qemu-arm-freertos (cortex-m3 + lwIP, RTOS-reused stack) | ethernet (lwIP) | zenoh-pico | release | 240.6 KB | 10.7 KB | 3.3 MB | 3.3 MB |
| qemu-arm-baremetal (serial) | serial (custom XRCE transport) | XRCE | size, heap 24 KB, tight XRCE pools | 60.3 KB | 25.2 KB (heap 24 KB) | 8.8 KB | ~34 KB |
| micro-ROS reference (XRCE) | serial | XRCE-DDS Client | -Os | < 75 KB | — | ~3 KB | ~3 KB peak |
¹ The two stm32f4 rows are historical: the board crates left the tree in
a later cleanup, so nothing in-tree reproduces them today. They stay because a
Cortex-M4F-with-its-own-stack data point is still the closest published figure
for that class, and deleting a measurement is worse than dating it. The chip
crate the numbers were built against (nros-platform-stm32f4) is still here.
The XRCE row uses tight per-session XRCE pools — set in the
example’s .cargo/config.toml [env] and read by nros-rmw-xrce-cffi’s
build.rs: NROS_XRCE_STREAM_HISTORY=4,
NROS_XRCE_CUSTOM_TRANSPORT_MTU=512, NROS_XRCE_MAX_SUBSCRIBERS=1,
NROS_XRCE_MAX_SERVICE_SERVERS=1, NROS_XRCE_MAX_SERVICE_CLIENTS=1,
NROS_XRCE_SUBSCRIBER_RING_DEPTH=1, NROS_XRCE_BUFFER_SIZE=256. Vendor
defaults grow xrce_session_state_t to ~390 KB (which wouldn’t fit a
24 KB heap); these knobs drop it to ~12 KB. Defaults are unchanged for
hosted / non-tight-RAM consumers — the env vars are pure opt-in.
How to read this:
- The size profile (opt-
s) shrinks.textby ~10–26 % with.bss/.dataunchanged (opt-level doesn’t touch static buffers — those are the env knobs above).-Ozis not used — on smoltcp examples it grows.bss+24 KB by defeating opt-3’s per-socket dead-buffer DCE (see the size-tuning notes above). - Switching ethernet → serial sheds ~50 KB text + ~42 KB
.data(no smoltcp stack, no IP link C, tuned heap) — the structural lever. - FreeRTOS + lwIP cells
.bssis dominated by lwIP’s heap + FreeRTOS task stacks (3 MB is the configured headroom, not nano-ros overhead). - The micro-ROS / XRCE row is a reference, not a nano-ros measurement — no bare-metal XRCE example ships yet (needs a custom-transport injection); the path to parity is XRCE + serial + static pools.
Size-minimal recipe
Smallest measured nano-ros configuration today (qemu-arm-baremetal serial talker, 116 KB text / 101 KB RAM):
# Cargo.toml
[profile.size]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true
# .cargo/config.toml — gc + serial knobs
[target.thumbv7m-none-eabi]
rustflags = [
"-C", "link-arg=--gc-sections", # 204.8 — strip unreferenced fns/data
"-C", "link-arg=-Tlink.x",
]
[env]
NROS_LINK_IP = "0" # 204.7 — drop zenoh-pico TCP/UDP link C
ZPICO_NO_SMOLTCP = "1" # skip smoltcp glue on bare-metal
# Heap floor: the per-entry executor backing is a single ~75 KB
# allocation, so a `nros::main!` image needs ≥128 KB (the #176 board
# default) — the pre-271 24 KB "zenoh-pico working set" figure OOMs at
# boot. HEAP is `.bss` (no flash cost); shrink below the default only on
# a non-`nros::main!` direct-mode image with a measured smaller peak.
NROS_HEAP_SIZE = "131072"
NROS_SMOLTCP_MAX_SOCKETS = "1" # 204.2 — brokered client multiplexes
NROS_SMOLTCP_MAX_UDP_SOCKETS = "1"
Build with cargo build --profile size, or fleet-wide via
NROS_CARGO_PROFILE=size just <plat> build. nros new --platform baremetal
already scaffolds the [profile.size] + the .cargo/config.toml shape (Phase
204.7/204.8); uncomment the serial block when you swap to a serial transport.
The deeper RAM win waits on XRCE on bare-metal (the ~3 KB-class client +
static pools, with discovery offloaded to the agent) — tracked separately;
zenoh-pico’s SUBSCRIBER_BUFFERS + alloc-based session are what keep this row’s
.bss ~76 KB.
Cargo features (which RMW/platform is linked)
Features select the linked RMW backend, platform, and ROS edition. The
deploy config’s rmw (or system.toml [system].rmw) picks which linked
backend is active — the two are different layers (link vs run). Matrix +
mutual-exclusion rules: Platform Model.
[dependencies]
nros = { path = "…/nros", default-features = false, features = [
"rmw-cffi", # generic C-vtable runtime registry
"platform-bare-metal", # or platform-{freertos,nuttx,threadx,zephyr,posix}
"ros-humble", # or ros-iron
"std", "alloc", # optional, target-dependent
] }
# Exactly one RMW backend crate; its registration runs before main:
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal", "link-tcp", "ros-humble"] }
# …or nros-rmw-xrce-cffi / the cyclonedds CMake backend
Runtime environment (native host only)
On the native host build, ExecutorConfig::from_env() reads at process start
(embedded targets bake their config at build time instead):
| Variable | Description | Default |
|---|---|---|
ROS_DOMAIN_ID | ROS 2 domain ID | 0 |
NROS_LOCATOR | Router address (legacy alias ZENOH_LOCATOR) | tcp/127.0.0.1:7447 |
NROS_SESSION_MODE | client / peer (legacy alias ZENOH_MODE) | client |
ZENOH_TLS_ROOT_CA_CERTIFICATE* | TLS CA cert (path / base64) | (none) |
By deployment scenario
| Scenario | Config source | Cargo features | Notes |
|---|---|---|---|
| Desktop (POSIX) | env (ExecutorConfig::from_env()) | rmw-cffi, platform-posix, std + zenoh dep | run the router locally (ros2 run rmw_zenoh_cpp rmw_zenohd, ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false') |
| QEMU bare-metal | [package.metadata.nros.deploy.<t>] ip/mac/gateway/locator | rmw-cffi, platform-bare-metal, ros-humble + zenoh | TAP/slirp bridge |
| FreeRTOS hardware | deploy metadata / the package.xml tuple + board features (RT) | …, platform-freertos, … | FREERTOS_DIR/LWIP_DIR |
| ESP32 WiFi | deploy metadata (ssid/password via build env) | …, platform-bare-metal, … | SSID/PASSWORD build env |
| Zephyr module | Kconfig overlay (prj-<rmw>.conf) | (Kconfig → features) | |
Hand-written no_std (no codegen) | standalone config.toml → Config::from_toml | per-board | net config in a file, not in code |
| Minimal RAM (XRCE serial) | config.toml [[transport]] kind="serial" or deploy | … + xrce dep | XRCE_* buffer tuning |
.env
cp .env.example .env # uncomment + adjust; gitignored; auto-loaded by just + direnv