Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Guide

nano-ros config is language-agnostic and has one home per concern — no setting lives in two places, and nothing is merged across files. The authored surfaces are a universal system.toml, package.xml, and launch XML — plus Kconfig for the embedded build (Zephyr). Cargo.toml and CMakeLists.txt carry language-toolchain facts and nothing else. This guide covers what each file owns, the network identity an embedded image bakes at build time, and the standalone config.toml for hand-written no_std apps.

Retired surfaces you may find in old material: the nros.toml per-package/workspace file (rejected by the CLI), the old config.toml [network]/[zenoh]/[scheduling] schema, every [package.metadata.nros.{node,entry,component,deploy.*}] table, and the package.xml <nano_ros deploy=… board=… rmw=…/> tuple. The last two moved into system.toml; readers refuse them rather than reading them. The design records are listed under Design background.

One home per concern

FileOwnsPer
system.tomlEverything about the deployment — [system] (RMW, domain, capability axes like features = ["param_services"] and the [safety] block, tiers), [[component]] (what runs, and its entities), and one [image.<id>] per target (board, locator, the board network file holding its address, os_config = ["board-net.toml"], and how it runs, is flashed and is watched, [image.<id>.run], [image.<id>.flash], [image.<id>.console]). One schema for Rust, C and C++, and the same file whether the project is one package or a workspace.package, or bringup pkg
Cargo.tomlRust toolchain facts only: crate, language deps, the RMW feature menu (rmw-zenoh/rmw-cyclonedds/rmw-xrce) the generated entry selects from. No board, no target triple, no deploy block.Rust project
CMakeLists.txtC/C++ toolchain facts only: targets and language deps, via find_package(nano_ros) + nano_ros_add_executable / nano_ros_auto_add_library + nros_components_register_node (nano_ros_node_register remains as the compat spelling) / nano_ros_entry. No platform, board or RMW set().C/C++ project
build/<image>/nros-cargo.tomlGENERATED, never edited — every cargo setting the board choice implies: [build] target, [target.*] runner and rustflags, the cross compiler, the derived pool sizes as [env], the nano-ros profiles, and the [patch.crates-io] rows that resolve nano-ros’s registry-style names into your checkout. Written by nros build (or nros configure alone), passed to cargo with --config.image
package.xmlROS package identity + msg <depend>s (codegen input for nros interface generate)all
config.toml (standalone)Hand-written no_std direct-mode apps only — [node] / [eth0] / [network.<name>], compile-baked via Config::from_toml(include_str!(…)). Apps that use nros::main!() / codegen do not have one — they use [image.<id>] above.embedded single-node app (no codegen)

Boundary rule. If a knob changes what is compiled/linked as a language choice — a C++ standard, an RMW feature the entry may select — it lives in the build file. Everything else about the deployment (components, board, domain, RMW, and the physical link + router address a target boots with) lives in system.toml. Nothing a board implies is authored anywhere: it is derived from the board descriptor into the generated settings file.

Config home by language × scale

This table has one answer in all four cells, which is the point of it:

One nodeSeveral nodes
Rustsystem.toml in the bringup pkgsystem.toml in the bringup pkg
C / C++system.toml in the bringup pkgsystem.toml in the bringup pkg

A system.toml beside a Cargo.toml or CMakeLists.txt is refused, so the resolver has one input shape and you have one thing to learn. Resolution is a fixed precedence ladder, not a merge: explicit CLI/build flag (--rmw / -DNROS_*) > system.toml ([image.<id>] > [system]) > the board descriptor’s [knobs] > built-in default. nros config show prints the resolved effective config with per-value provenance; nros check flags values still sourced from legacy files.

For the multi-RMW runtime topic-forwarding bridge (a separate file/feature), see nros-bridge.toml.

Embedded image config (codegen apps — the common case)

An embedded app built through nros::main!() (Rust) or the CMake entry codegen (C/C++) declares its board and its network + router config per image, in system.toml. The toolchain bakes it at compile time — there is no config file on the device and nothing is parsed at runtime. The file is the same in both languages:

# system.toml (e.g. examples/mps2-an385-baremetal/rust/talker)
[system]
name      = "qemu_bsp_talker"
rmw       = "zenoh"
domain_id = 0

[[component]]
pkg      = "qemu_bsp_talker"
class    = "qemu_bsp_talker::Talker"
name     = "talker"
dispatch = "deferred"          # optional: callbacks hand off to a ring

[image.mps2-an385-baremetal]
board   = "mps2-an385-baremetal"
locator = "tcp/10.0.2.2:10500"
net     = { eth0 = { network = "qemu", host = 10 } }

The [image.<id>] key is the image’s name — what you pass to nros build <image> — and board is the only thing that has to change to retarget it. nros::main!() bakes the image’s network fields into a DeployOverlay that BoardEntry::run_with_deploy applies onto the board’s boot Config; the C/C++ entry codegen bakes the same values into its generated glue. Nothing is written in Cargo.toml, in package.xml or on a cmake command line.

Networks and addresses

An image’s address is a layer-1 fact: the operating system owns the network device. On Linux that is the kernel; on Zephyr and NuttX it is their own configuration (Kconfig, devicetree, defconfig); on a board where nano-ros is the OS (the reference FreeRTOS, ThreadX and bare-metal boards) it is the board package, through a small board network file the image names with os_config — one table per interface:

[image.talker]
board = "mps2-an385-baremetal"
os_config = ["board-net.toml"]
# board-net.toml, beside system.toml
[eth0]
network = "qemu"
host = 10                      # 10.0.2.10 on QEMU's network

qemu is built in: QEMU’s own user-mode (SLIRP) network, 10.0.2.0/24 with gateway and router host 10.0.2.2. Every -nic user makes a private copy of it, so two images on qemu never meet. Any other network is defined in the board network file beside the interface on it, and the image states its router as its own locator:

# board-net-sensor.toml
[eth0]
network = "lab"
address = "192.168.50.20"

[network.lab]                  # e.g. a switch or a Linux bridge with boards on it
subnet  = "192.168.50.0/24"
gateway = "192.168.50.1"
[image.sensor]
board = "mps2-an385-baremetal"
os_config = ["board-net-sensor.toml"]
locator = "tcp/192.168.50.1:7447"

Two images on one network each define it in their own file, and the files must agree: one network has one subnet, gateway and lease policy.

Per interface:

  • network defaults to the board’s (qemu on every QEMU board), so [eth0] host = 10 is enough there.
  • host is the last octet within the network’s subnet; address is the full IPv4, or "dhcp" on a network with dhcp = true (and a board whose interface runs a DHCP client). An interface on qemu that states no address takes 10.0.2.15, the address a plain -nic user guest picks.
  • mac is optional. Left out, it is derived from the board interface’s mac_prefix and the system, image, network and interface names, so two images never share one by accident.
  • The gateway and netmask come from the network, so an interface never states them.
  • An image dials its own locator, else [system] locator. On a network other than qemu one of the two must be stated.

A board may have several interfaces. An image addresses each on its own network, one table each: [eth0], [eth1].

These fail the build, naming the image (and, for a duplicate, both images):

  • an address outside its subnet, equal to the gateway, or on a network no file defines;
  • two images with the same static address, or the same MAC, on one network other than qemu, or two files defining one network differently;
  • a board network file on a board whose network belongs to the host operating system (linux, freertos-posix, threadx-linux), or on a board that declares no interfaces (Zephyr boards take their address from Kconfig);
  • the retired spellings, each refused naming its new home: [network.<name>] and [image.<id>.net.<if>] in system.toml, and the flat ip / gateway / netmask keys on [image.<id>].

The board’s half is [board.net] in its descriptor: stack (who owns the network: host, os or board), links, network (its default network) and interfaces, each a kind (eth0 = "ethernet") or a table (eth0 = { kind = "ethernet", mac_prefix = "52:54:00", dhcp = true }). A board states no address: [board.net] ip / mac are refused.

The resolved answer for an image is written to build/nros/net/<image>.toml (under the workspace) and reaches the firmware as NROS_IMAGE_NET.

How an emulated image joins its network is a fact about how it is RUN, not built, so it has its own table, read only by the QEMU launchers and the test harness:

[image.sensor.run]
nic = { bridge = "qemu-br" }   # QEMU: -nic bridge,br=qemu-br,…

nic is "slirp" (QEMU’s own user-mode network, the default on qemu), { bridge = "<dev>" } or { tap = "<dev>" } on the host, and applies to the image’s first interface. An image on any other network with no nic is physical only, and launching it under QEMU is refused. nros run passes the -nic value to QEMU when it boots the image.

The same goes for a board that is flashed and watched on a serial port: the board’s descriptor states its flasher and console ([board.flash], [board.console], see the descriptor section), and an image overrides any key of them in its own tables:

[image.lab.flash]
command = "pyocd flash -t stm32f767zi {hex}"     # replaces the board's method
prepare = [{ say = "Set BOOT0 to 1, then press RESET", wait_for = "usb:0483:df11" }]

[image.lab.console]
baud = 921600                                     # kind stays the board's

Both are checked when system.toml is read (an unknown key, wait_for event or {placeholder} is refused) and are read by nros flash and nros monitor.

The middleware’s own file

How a session finds its peers — which router to dial, which endpoints to listen on — is the middleware’s own configuration, written in its own language. A bringup ships it beside its system.toml, and it applies to every image of that backend:

BackendFileThe same document ROS 2 reads as
zenohrmw/zenoh.json5ZENOH_SESSION_CONFIG_URI
CycloneDDSrmw/cyclonedds.xmlCYCLONEDDS_URI
// rmw/zenoh.json5
{
  mode: "client",
  connect: { endpoints: ["tcp/192.168.50.1:7447"] },
}

zenoh-pico has no json5 parser, so the build reads the file on the host, lowers each key to a zenoh-pico session property and bakes the result into the image. A key zenoh-pico cannot take is refused, naming the key — never dropped. With a file, the file IS the wiring: the image’s connect list replaces the default router, and an image that also states a locator is refused (a [system] locator is simply not used). On a hosted build ZENOH_SESSION_CONFIG_URI replaces the baked file at run time, whole, as it does for rmw_zenoh.

The flat rmw/zenoh.conf list (key = value) is retired and refused, naming the json5 that replaced it.

The names above are the default. To ship a different file — one per site, one for a QEMU run and one for the lab — name it:

[system]
rmw_config = "rmw/zenoh-lab.json5"      # every image's default session

[image.qemu]
board = "mps2-an385-freertos"
rmw_config = "rmw/zenoh-qemu.json5"     # this image's own

A named file that does not exist is refused. XRCE has no file format; its endpoint is the image’s locator.

A bridge opens one session per [[domain]], and each domain names its own file, so one image can speak to a CAN zenoh network and an Ethernet DDS one with two documents:

[[domain]]
name = "zen"
rmw = "zenoh"
config = "rmw/zenoh-zen.json5"

[[domain]]
name = "dds"
rmw = "cyclonedds"
id = 5
config = "rmw/cyclonedds-dds.xml"

[[bridge]]
name = "gw"
from = "zenoh:zen"
to = "cyclonedds:dds"

A domain with a config states no locator (the file is its wiring), and a domain config that no [[bridge]] opens is refused, since nothing would read it. An image overrides one domain’s file with a table, rmw_config = { dds = "rmw/cyclonedds-lab.xml" }: the build then writes that image its own bridge configuration (nros-bridge.<image>.toml) beside the bringup’s, and the other images keep the shared one. At run time ZENOH_SESSION_CONFIG_URI and CYCLONEDDS_URI still replace a hosted bridge’s files, and NROS_BRIDGE_<SESSION>_LOCATOR replaces that session’s file whole.

Per-image build knobs

A build knob that one image needs, and that its board does not state, goes in [image.<id>.knobs.<group>]. The table has the same shape as a board descriptor’s [knobs.<group>]:

[image.zephyr_xrce.knobs.zephyr]
heap_size = 98304            # NROS_ZEPHYR_HEAP_SIZE

[image.zephyr_xrce.knobs.xrce]
max_subscribers = 1          # NROS_XRCE_MAX_SUBSCRIBERS (-1, the default, derives it)

[image.linux.knobs.log]
max_level = "warn"           # NROS_LOG_MAX_LEVEL (a level name or 0..6)

[image.linux.knobs.executor]
max_cbs = 16                 # NROS_EXECUTOR_MAX_CBS

Each key is a row of the knob registry (packages/tooling/nros-zephyr-build/knobs.toml). The key is the row’s board key where it has one (executor.max_cbs, zenoh.tx.batch); otherwise it is the env name without NROS_, split at the first _ (NROS_ZEPHYR_HEAP_SIZE becomes zephyr.heap_size). An unknown key, a value of the wrong type or a value outside the knob’s range stops nros build with an error that names the key. The value reaches the build exactly as the knob’s NROS_* variable would: through the generated cargo config on the cargo road, the image env file on the cmake road, and the Kconfig defaults on west. It sits on the image rung (above the board, below an exported variable).

[image.<id>] env = { NAME = "value" } still carries an application’s own build variable, a name the registry does not hold. An env row that names a registry knob is an error that names its knobs spelling (release 0.6.0 accepted it with a warning).

RT/stack/priority for a single-node embedded app comes from board-crate Cargo features + Kconfig (prj*.conf on Zephyr), not a config file. Multi-node RT is declared in system.toml ([tasks.<name>.<rtos>] + [[node_overrides]]).

Periodic timers after a stall

If a tier is preempted past several periods of a periodic timer, the backlog is coalesced, not replayed: the timer fires ONCE and the missed periods are counted. Phase is preserved, so activations stay on the declared cadence grid rather than drifting by each stall. This is the default (TimerOverrunPolicy::Skip) and matches rclcpp and Zephyr’s k_timer.

Replay is available — Executor::set_timer_overrun_policy(.., CatchUp) — and is the right choice for a counter or accumulator, where every tick carries state that must not be lost. It is the wrong choice for a control loop: the replayed activations compute with stale inputs and emit a burst of commands inside a fraction of a period.

Overruns are counted per timer (Executor::timer_overruns) and reported as the timer-overrun-runtime violation. Watch that rather than a publish-rate check: under CatchUp a replayed burst makes the achieved rate look nominal — measured 100.03 Hz on a declared 100 Hz loop while the tier was stalling for up to 611 ms at a time — so a rate monitor reports health during exactly the fault it exists to catch. The rationale and the measurements are in the execution-model design record (see Design background).

Timer periods are microseconds throughout, including across the C ABI.

Standalone config.toml — hand-written no_std apps (no codegen)

A hand-written single-node app that writes its own main() and bypasses nros::main!() keeps its net config in a file, not hardcoded in Rust: a sibling config.toml, compile-baked with include_str! and parsed by the board crate’s Config::from_toml. It is the board package’s network file — the same [<interface>] / [network.<name>] shape an image’s os_config file has — plus a [node] table that stands in for the session a system.toml would declare.

# config.toml

[node]
domain_id = 0                    # ROS 2 domain ID (0–232)
locator = "tcp/10.0.2.2:7447"    # the router the firmware dials

[eth0]
network = "qemu"                 # built in: QEMU user-mode, 10.0.2.0/24, gateway 10.0.2.2
host = 10                        # the address within that network → 10.0.2.10
# address = "10.0.2.10"          # or the whole address
mac = "02:00:00:00:00:00"

# A network of your own: name it on the interface instead of `qemu`.
# [network.lab]
# subnet = "192.168.50.0/24"
# gateway = "192.168.50.1"

[node.rt.freertos]               # FreeRTOS boards: raw task priorities; omit for defaults
app_priority = 3
zenoh_read_priority = 4

A board reads its own facts from the interface table: a serial link’s [uart0] baud, ThreadX Linux’s host veth as [eth0] device. One interface is brought up; a direct-mode board takes no DHCP lease, so state the address.

A file in the retired shape ([[transport]] with ip, gateway and locator) panics at boot naming the new spelling, rather than booting on the board’s defaults: ip = "10.0.2.10/24" + gateway = "10.0.2.2" is [eth0] network = "qemu" + host = 10, and the locator moves to [node].

Consumption (compile-baked; no filesystem on the target):

use nros_board_mps2_an385::{Config, run};

fn main() -> ! {
    run(Config::from_toml(include_str!("../config.toml")), |config| {
        let exec = ExecutorConfig::new(config.zenoh_locator)
            .domain_id(config.domain_id);
        // ...
    })
}

Pick by whether the app uses codegen: nros::main!() app → system.toml [image.<id>] (above); hand-written main() → config.toml.

Files that used to carry configuration and no longer do are listed in Retired configuration files.

Build-time environment variables

Read during cargo build (by build.rs). Export them in your shell.

SDK paths

You normally set none of these. nros setup puts each vendored source tree in the shared store, and every build finds it through one ladder: an environment override, then your project’s [sources] in system.toml, then a checkout submodule you are editing, then the store, then the checkout’s own copy. Ask where a tree came from with nros locate <name> --why.

Set one of these variables only to build against a tree of your own:

VariableTree
FREERTOS_DIR / LWIP_DIRFreeRTOS kernel / lwIP
THREADX_DIR / NETX_DIRThreadX / NetX Duo
NUTTX_DIR / NUTTX_APPS_DIRNuttX RTOS / apps
CYCLONEDDS_SOURCE_DIRCyclone DDS source

For a tree that belongs to one project, prefer [sources] <name> = "<path>" in its system.toml (project-relative) over an exported variable, which every build in that shell inherits.

FREERTOS_PORT, FREERTOS_CONFIG_DIR, THREADX_CONFIG_DIR and NETX_CONFIG_DIR are not yours to set: they are facts about the board, stated in its nros-board.toml and carried to the build from there.

Buffer-tuning vars (ZPICO_*, XRCE_*, NROS_*) are optional — see the Environment Variables Reference.

Binary-size knobs (embedded)

On a constrained MCU, two build-time env vars — carried in the image’s generated build/<image>/nros-cargo.toml [env], like the other NROS_* tuning, and set there from the board descriptor’s [knobs] or from your own environment — shed the parts a brokered client doesn’t need:

VariableDefaultEffect
NROS_LINK_IPonNROS_LINK_IP=0 on a serial-only node drops the IP link layer — zenoh-pico’s TCP/UDP link C (Z_FEATURE_LINK_TCP/UDP=0) and (with --gc-sections) the smoltcp platform impl. Serial link stays.
NROS_SMOLTCP_MAX_SOCKETS / NROS_SMOLTCP_MAX_UDP_SOCKETS4 / 2Sized for DDS RTPS (3 UDP/participant). A zenoh/XRCE client multiplexes everything over one session → set both to 1 to drop the spare socket buffers (≈8 KB each).
NROS_HEAP_SIZEper-platform-crate (64 KB nros-platform-mps2-an385, 32 KB nros-platform-stm32f4)Decimal bytes for the bare-metal static heap. The defaults are generous; size to the RMW’s working set (table below). E.g. NROS_HEAP_SIZE = "24576" on a zenoh-pico node cut the mps2-an385 .data 66 → 25 KB (−41 KB).

Static-heap sizing by backend (bare-metal FreeListHeap, set via NROS_HEAP_SIZE):

BackendPeak working setRecommended heapNotes
zenoh-pico (TCP)~16 KB24–32 KB (≈2× for fragmentation)peer middleware; alloc-based session/buffers
zenoh-pico (serial)lighter than TCP16–24 KBno TCP link buffers; verified running at 16 KB
XRCE (Micro-XRCE-DDS)~3 KB (micro-ROS figure)~8 KBstatic pools, discovery offloaded to the agent — the RAM-minimal backend; a measured bare-metal XRCE figure is pending an example (no bare-metal XRCE example ships yet — XRCE bare-metal needs a custom-transport injection)

Measured footprint

Honest, reproducible numbers per (platform, transport, backend, profile) — built with the in-tree examples, the release profile is cargo’s default (opt-3), the size profile is the scaffolded [profile.size] (opt-s + lto

  • strip, see the size-tuned release profile notes). RAM = data + bss. All cells are after --gc-sections + the size knobs above are applied where noted; the serial cell ships with the recipe below.
platformtransportbackendprofiletext (flash code)databssRAM total
mps2-an385-baremetal (cortex-m3)ethernet (smoltcp)zenoh-picorelease177.4 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalethernetzenoh-picosize158.3 KB67.0 KB91.7 KB158.7 KB
mps2-an385-baremetalserial (no IP stack)zenoh-picorelease128.6 KB25.2 KB75.8 KB101.0 KB
mps2-an385-baremetalserialzenoh-picosize + recipe116.1 KB25.2 KB75.8 KB101.0 KB
stm32f4 (thumbv7em-eabihf, cortex-m4) ¹ethernetzenoh-picorelease186.9 KB13.7 KB123.0 KB136.7 KB
stm32f4 ¹ethernetzenoh-picosize138.1 KB13.7 KB123.0 KB136.7 KB
mps2-an385-freertos (cortex-m3 + lwIP, RTOS-reused stack)ethernet (lwIP)zenoh-picorelease240.6 KB10.7 KB3.3 MB3.3 MB
mps2-an385-baremetal (serial)serial (custom XRCE transport)XRCEsize, heap 24 KB, tight XRCE pools60.3 KB25.2 KB (heap 24 KB)8.8 KB~34 KB
micro-ROS reference (XRCE)serialXRCE-DDS Client-Os< 75 KB—~3 KB~3 KB peak

¹ The two stm32f4 rows are historical: the board crates left the tree in a later cleanup, so nothing in-tree reproduces them today. They stay because a Cortex-M4F-with-its-own-stack data point is still the closest published figure for that class, and deleting a measurement is worse than dating it. The chip crate the numbers were built against (nros-platform-stm32f4) is still here.

The XRCE row uses tight per-session XRCE pools — delivered through the image’s generated [env] and read by nros-rmw-xrce-cffi’s build.rs: NROS_XRCE_STREAM_HISTORY=4, NROS_XRCE_CUSTOM_TRANSPORT_MTU=512, NROS_XRCE_MAX_SUBSCRIBERS=1, NROS_XRCE_MAX_SERVICE_SERVERS=1, NROS_XRCE_MAX_SERVICE_CLIENTS=1, NROS_XRCE_SUBSCRIBER_RING_DEPTH=1, NROS_XRCE_BUFFER_SIZE=256. Vendor defaults grow xrce_session_state_t to ~390 KB (which wouldn’t fit a 24 KB heap); these knobs drop it to ~12 KB. Defaults are unchanged for hosted / non-tight-RAM consumers — the env vars are pure opt-in.

How to read this:

  • The size profile (opt-s) shrinks .text by ~10–26 % with .bss/.data unchanged (opt-level doesn’t touch static buffers — those are the env knobs above). -Oz is not used — on smoltcp examples it grows .bss +24 KB by defeating opt-3’s per-socket dead-buffer DCE (see the size-tuning notes above).
  • Switching ethernet → serial sheds ~50 KB text + ~42 KB .data (no smoltcp stack, no IP link C, tuned heap) — the structural lever.
  • FreeRTOS + lwIP cells .bss is dominated by lwIP’s heap + FreeRTOS task stacks (3 MB is the configured headroom, not nano-ros overhead).
  • The micro-ROS / XRCE row is a reference, not a nano-ros measurement — no bare-metal XRCE example ships yet (needs a custom-transport injection); the path to parity is XRCE + serial + static pools.

Size-minimal recipe

Smallest measured nano-ros configuration today (mps2-an385-baremetal serial talker, 116 KB text / 101 KB RAM):

# Cargo.toml
[profile.size]
inherits = "release"
opt-level = "s"
lto = "fat"
codegen-units = 1
debug = false
strip = true

The gc-sections and link-script rustflags are not yours to write — they are the board’s, and they arrive in the generated build/<image>/nros-cargo.toml [target.<triple>] block:

# build/<image>/nros-cargo.toml — GENERATED, shown for orientation
[target.thumbv7m-none-eabi]
rustflags = [
    "-C", "link-arg=-Tlink.x",
    "-C", "link-arg=--gc-sections",   # 204.8 — strip unreferenced fns/data
]

The knobs that are yours are ordinary environment variables. The generated [env] never uses force, so a value you set in the calling environment wins:

NROS_LINK_IP=0 \
ZPICO_NO_SMOLTCP=1 \
NROS_HEAP_SIZE=131072 \
NROS_SMOLTCP_MAX_SOCKETS=1 \
NROS_SMOLTCP_MAX_UDP_SOCKETS=1 \
    nros build <image>

The profile is not one of them: name it on the image, as [image.<id>] profile = "size", and nros build passes it to whichever tool it drives.

NROS_LINK_IP=0 drops zenoh-pico’s TCP/UDP link C and ZPICO_NO_SMOLTCP=1 skips the smoltcp glue on bare-metal. On the heap floor: the per-entry executor backing is a single ~75 KB allocation, so a nros::main! image needs ≥128 KB (the board default) — the older 24 KB “zenoh-pico working set” figure OOMs at boot. HEAP is .bss (no flash cost); shrink below the default only on a non-nros::main! direct-mode image with a measured smaller peak. A knob you always want for a board belongs in that board’s descriptor [knobs], not on every command line.

nros new --platform baremetal already scaffolds the [profile.size] shape.

The deeper RAM win waits on XRCE on bare-metal (the ~3 KB-class client + static pools, with discovery offloaded to the agent) — tracked separately; zenoh-pico’s SUBSCRIBER_BUFFERS + alloc-based session are what keep this row’s .bss ~76 KB.

Cargo features (which RMW/platform is linked)

Features select the linked RMW backend, platform, and ROS edition. The system.toml rmw ([image.<id>], else [system]) picks which linked backend is active — the two are different layers (link vs run). Matrix + mutual-exclusion rules: Platform Model.

[dependencies]
nros = { path = "…/nros", default-features = false, features = [
    "rmw-cffi",            # generic C-vtable runtime registry
    "platform-bare-metal", # or platform-{freertos,nuttx,threadx,zephyr,posix}
    "ros-humble",          # or ros-iron
    "std", "alloc",        # optional, target-dependent
] }
# Exactly one RMW backend crate; its registration runs before main:
nros-rmw-zenoh = { path = "…/nros-rmw-zenoh", features = ["platform-bare-metal", "link-tcp", "ros-humble"] }
# …or nros-rmw-xrce-cffi / the cyclonedds CMake backend

Runtime environment (native host only)

On the native host build, ExecutorConfig::from_env() reads at process start (embedded targets bake their config at build time instead):

VariableDescriptionDefault
ROS_DOMAIN_IDROS 2 domain ID0
NROS_LOCATORRouter addresstcp/127.0.0.1:7447
NROS_SESSION_MODEclient / peerclient
ZENOH_TLS_ROOT_CA_CERTIFICATE*TLS CA cert (path / base64)(none)

By deployment scenario

ScenarioConfig sourceCargo featuresNotes
Desktop (POSIX)env (ExecutorConfig::from_env())rmw-cffi, platform-posix, std + zenoh deprun the router locally (ros2 run rmw_zenoh_cpp rmw_zenohd, ZENOH_CONFIG_OVERRIDE='listen/endpoints=["tcp/127.0.0.1:7447"];scouting/multicast/enabled=false')
QEMU bare-metalsystem.toml [image.<id>] ip/mac/gateway/locatorrmw-cffi, platform-bare-metal, ros-humble + zenohTAP/slirp bridge
FreeRTOS hardware[image.<id>] + board features (RT)…, platform-freertos, …FREERTOS_DIR/LWIP_DIR
ESP32 WiFi[image.<id>] (ssid/password via build env)…, platform-bare-metal, …SSID/PASSWORD build env
Zephyr moduleKconfig overlay (prj-<rmw>.conf)(Kconfig → features)
Hand-written no_std (no codegen)standalone config.toml → Config::from_tomlper-boardnet config in a file, not in code
Minimal RAM (XRCE serial)config.toml [uart0] baud + [node] locator or [image.<id>]… + xrce depXRCE_* buffer tuning

Design background